3D viewport, milestone 1

This commit is contained in:
John McCardle 2026-02-04 13:33:14 -05:00
commit e277663ba0
27 changed files with 7389 additions and 8 deletions

2
src/3d/shaders/.gitkeep Normal file
View file

@ -0,0 +1,2 @@
# Placeholder for shader files
# PS1-style shaders will be added in Milestone 1

View file

@ -0,0 +1,90 @@
// PS1-style fragment shader for OpenGL 3.2+
// Implements affine texture mapping, fog, color quantization, and dithering
#version 150 core
// Uniforms - texturing
uniform sampler2D u_texture;
uniform bool u_has_texture; // Whether to use texture or just vertex color
// Uniforms - PS1 effects
uniform bool u_enable_dither; // Enable ordered dithering
uniform vec3 u_fog_color; // Fog color (usually matches background)
// Varyings from vertex shader
in vec4 v_color; // Gouraud-shaded vertex color
noperspective in vec2 v_texcoord; // Texture coordinates (affine interpolation!)
in float v_fog; // Fog factor
// Output
out vec4 fragColor;
// =========================================================================
// 4x4 Bayer Dithering Matrix
// Used to add ordered noise for color quantization, reducing banding
// =========================================================================
const int bayerMatrix[16] = int[16](
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5
);
float getBayerValue(vec2 fragCoord) {
int x = int(mod(fragCoord.x, 4.0));
int y = int(mod(fragCoord.y, 4.0));
return float(bayerMatrix[y * 4 + x]) / 16.0;
}
// =========================================================================
// 15-bit Color Quantization
// PS1 had 15-bit color (5 bits per channel), creating visible color banding
// =========================================================================
vec3 quantize15bit(vec3 color) {
// Quantize to 5 bits per channel (32 levels)
return floor(color * 31.0 + 0.5) / 31.0;
}
void main() {
// Sample texture or use vertex color
vec4 color;
if (u_has_texture) {
vec4 texColor = texture(u_texture, v_texcoord);
// Binary alpha test (PS1 style - no alpha blending)
if (texColor.a < 0.5) {
discard;
}
color = texColor * v_color;
} else {
color = v_color;
}
// =========================================================================
// PS1 Effect: Color Quantization with Dithering
// Reduce color depth to 15-bit, using dithering to reduce banding
// =========================================================================
if (u_enable_dither) {
// Get Bayer dither threshold for this pixel
float threshold = getBayerValue(gl_FragCoord.xy);
// Apply dither before quantization
// Threshold is in range [0, 1), we scale it to affect quantization
vec3 dithered = color.rgb + (threshold - 0.5) / 31.0;
// Quantize to 15-bit
color.rgb = quantize15bit(dithered);
} else {
// Just quantize without dithering
color.rgb = quantize15bit(color.rgb);
}
// =========================================================================
// Fog Application
// Linear fog blending based on depth
// =========================================================================
color.rgb = mix(color.rgb, u_fog_color, v_fog);
fragColor = color;
}

View file

@ -0,0 +1,120 @@
// PS1-style fragment shader for OpenGL ES 2.0 / WebGL 1.0
// Implements affine texture mapping, fog, color quantization, and dithering
precision mediump float;
// Uniforms - texturing
uniform sampler2D u_texture;
uniform bool u_has_texture; // Whether to use texture or just vertex color
// Uniforms - PS1 effects
uniform bool u_enable_dither; // Enable ordered dithering
uniform vec3 u_fog_color; // Fog color (usually matches background)
// Varyings from vertex shader
varying vec4 v_color; // Gouraud-shaded vertex color
varying vec2 v_texcoord; // Texture coordinates (pre-multiplied by w)
varying float v_w; // Clip space w for affine restoration
varying float v_fog; // Fog factor
// =========================================================================
// 4x4 Bayer Dithering Matrix
// Used to add ordered noise for color quantization, reducing banding
// =========================================================================
const mat4 bayerMatrix = mat4(
0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0,
12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0,
3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0,
15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0
);
float getBayerValue(vec2 fragCoord) {
int x = int(mod(fragCoord.x, 4.0));
int y = int(mod(fragCoord.y, 4.0));
// Manual matrix lookup (GLES2 doesn't support integer indexing of mat4)
if (y == 0) {
if (x == 0) return 0.0/16.0;
if (x == 1) return 8.0/16.0;
if (x == 2) return 2.0/16.0;
return 10.0/16.0;
}
if (y == 1) {
if (x == 0) return 12.0/16.0;
if (x == 1) return 4.0/16.0;
if (x == 2) return 14.0/16.0;
return 6.0/16.0;
}
if (y == 2) {
if (x == 0) return 3.0/16.0;
if (x == 1) return 11.0/16.0;
if (x == 2) return 1.0/16.0;
return 9.0/16.0;
}
// y == 3
if (x == 0) return 15.0/16.0;
if (x == 1) return 7.0/16.0;
if (x == 2) return 13.0/16.0;
return 5.0/16.0;
}
// =========================================================================
// 15-bit Color Quantization
// PS1 had 15-bit color (5 bits per channel), creating visible color banding
// =========================================================================
vec3 quantize15bit(vec3 color) {
// Quantize to 5 bits per channel (32 levels)
return floor(color * 31.0 + 0.5) / 31.0;
}
void main() {
// =========================================================================
// PS1 Effect: Affine Texture Mapping
// Divide by interpolated w to restore texture coordinates.
// Because w was interpolated linearly (not perspectively), this creates
// the characteristic texture warping on PS1.
// =========================================================================
vec2 uv = v_texcoord / v_w;
// Sample texture or use vertex color
vec4 color;
if (u_has_texture) {
vec4 texColor = texture2D(u_texture, uv);
// Binary alpha test (PS1 style - no alpha blending)
if (texColor.a < 0.5) {
discard;
}
color = texColor * v_color;
} else {
color = v_color;
}
// =========================================================================
// PS1 Effect: Color Quantization with Dithering
// Reduce color depth to 15-bit, using dithering to reduce banding
// =========================================================================
if (u_enable_dither) {
// Get Bayer dither threshold for this pixel
float threshold = getBayerValue(gl_FragCoord.xy);
// Apply dither before quantization
// Threshold is in range [0, 1), we scale it to affect quantization
vec3 dithered = color.rgb + (threshold - 0.5) / 31.0;
// Quantize to 15-bit
color.rgb = quantize15bit(dithered);
} else {
// Just quantize without dithering
color.rgb = quantize15bit(color.rgb);
}
// =========================================================================
// Fog Application
// Linear fog blending based on depth
// =========================================================================
color.rgb = mix(color.rgb, u_fog_color, v_fog);
gl_FragColor = color;
}

View file

@ -0,0 +1,87 @@
// PS1-style vertex shader for OpenGL 3.2+
// Implements vertex snapping, Gouraud shading, and fog distance calculation
#version 150 core
// Uniforms - transform matrices
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// Uniforms - PS1 effects
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
uniform float u_fog_start; // Fog start distance
uniform float u_fog_end; // Fog end distance
// Uniforms - lighting
uniform vec3 u_light_dir; // Directional light direction (normalized)
uniform vec3 u_ambient; // Ambient light color
// Attributes
in vec3 a_position;
in vec2 a_texcoord;
in vec3 a_normal;
in vec4 a_color;
// Varyings - passed to fragment shader
out vec4 v_color; // Gouraud-shaded vertex color
noperspective out vec2 v_texcoord; // Texture coordinates (affine interpolation!)
out float v_fog; // Fog factor (0 = no fog, 1 = full fog)
void main() {
// Transform vertex to clip space
vec4 worldPos = u_model * vec4(a_position, 1.0);
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
// =========================================================================
// PS1 Effect: Vertex Snapping
// The PS1 had limited precision for vertex positions, causing vertices
// to "snap" to a grid, creating the characteristic jittery look.
// =========================================================================
if (u_enable_snap) {
// Convert to NDC
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
// Snap to pixel grid based on render resolution
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
// Convert back to clip space
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
// =========================================================================
// PS1 Effect: Gouraud Shading
// Per-vertex lighting was used on PS1 due to hardware limitations.
// This creates characteristic flat-shaded polygons.
// =========================================================================
vec3 worldNormal = mat3(u_model) * a_normal;
worldNormal = normalize(worldNormal);
// Simple directional light + ambient
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
// Apply lighting to vertex color
v_color = vec4(a_color.rgb * lighting, a_color.a);
// =========================================================================
// PS1 Effect: Affine Texture Mapping
// Using 'noperspective' qualifier disables perspective-correct interpolation
// This creates the characteristic texture warping on large polygons
// =========================================================================
v_texcoord = a_texcoord;
// =========================================================================
// Fog Distance Calculation
// Calculate linear fog factor based on view-space depth
// =========================================================================
float depth = -viewPos.z; // View space depth (positive)
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}

View file

@ -0,0 +1,90 @@
// PS1-style vertex shader for OpenGL ES 2.0 / WebGL 1.0
// Implements vertex snapping, Gouraud shading, and fog distance calculation
precision mediump float;
// Uniforms - transform matrices
uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;
// Uniforms - PS1 effects
uniform vec2 u_resolution; // Internal render resolution for vertex snapping
uniform bool u_enable_snap; // Enable vertex snapping to pixel grid
uniform float u_fog_start; // Fog start distance
uniform float u_fog_end; // Fog end distance
// Uniforms - lighting
uniform vec3 u_light_dir; // Directional light direction (normalized)
uniform vec3 u_ambient; // Ambient light color
// Attributes
attribute vec3 a_position;
attribute vec2 a_texcoord;
attribute vec3 a_normal;
attribute vec4 a_color;
// Varyings - passed to fragment shader
varying vec4 v_color; // Gouraud-shaded vertex color
varying vec2 v_texcoord; // Texture coordinates (multiplied by w for affine trick)
varying float v_w; // Clip space w for affine mapping restoration
varying float v_fog; // Fog factor (0 = no fog, 1 = full fog)
void main() {
// Transform vertex to clip space
vec4 worldPos = u_model * vec4(a_position, 1.0);
vec4 viewPos = u_view * worldPos;
vec4 clipPos = u_projection * viewPos;
// =========================================================================
// PS1 Effect: Vertex Snapping
// The PS1 had limited precision for vertex positions, causing vertices
// to "snap" to a grid, creating the characteristic jittery look.
// =========================================================================
if (u_enable_snap) {
// Convert to NDC
vec4 ndc = clipPos;
ndc.xyz /= ndc.w;
// Snap to pixel grid based on render resolution
vec2 grid = u_resolution * 0.5;
ndc.xy = floor(ndc.xy * grid + 0.5) / grid;
// Convert back to clip space
ndc.xyz *= clipPos.w;
clipPos = ndc;
}
gl_Position = clipPos;
// =========================================================================
// PS1 Effect: Gouraud Shading
// Per-vertex lighting was used on PS1 due to hardware limitations.
// This creates characteristic flat-shaded polygons.
// =========================================================================
vec3 worldNormal = mat3(u_model) * a_normal;
worldNormal = normalize(worldNormal);
// Simple directional light + ambient
float diffuse = max(dot(worldNormal, -u_light_dir), 0.0);
vec3 lighting = u_ambient + vec3(diffuse);
// Apply lighting to vertex color
v_color = vec4(a_color.rgb * lighting, a_color.a);
// =========================================================================
// PS1 Effect: Affine Texture Mapping Trick
// GLES2 doesn't have 'noperspective' interpolation, so we manually
// multiply texcoords by w here and divide by w in fragment shader.
// This creates the characteristic texture warping on large polygons.
// =========================================================================
v_texcoord = a_texcoord * clipPos.w;
v_w = clipPos.w;
// =========================================================================
// Fog Distance Calculation
// Calculate linear fog factor based on view-space depth
// =========================================================================
float depth = -viewPos.z; // View space depth (positive)
v_fog = clamp((depth - u_fog_start) / (u_fog_end - u_fog_start), 0.0, 1.0);
}