// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch // gerechnet statt nachgestellt: // - Pixel-Snap im Vertex-Shader → Vertex-Jitter // - @interpolate(linear) → affine (nicht perspektivkorrigierte) UVs // - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader // - 1-Bit-Alpha-Test (Cutouts), kein Blending struct Uniforms { mvp: mat4x4f, // Halbe interne Auflösung (NDC läuft -1..1, Spanne 2 → Skalierung mit // der halben Auflösung trifft das Pixelraster). Kommt aus dem Renderer. half_res: vec2f, }; @group(0) @binding(0) var u: Uniforms; @group(1) @binding(0) var tex: texture_2d; @group(1) @binding(1) var smp: sampler; struct VsOut { @builtin(position) pos: vec4f, // linear = affin interpoliert → das Textur-Wobbeln der PS1. @location(0) @interpolate(linear) uv: vec2f, }; @vertex fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut { var clip = u.mvp * vec4f(pos, 1.0); // Pixel-Snap: xy nach der Projektion aufs interne Raster runden und // zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde // die Division Unsinn liefern, das Hardware-Clipping übernimmt. if clip.w > 0.0 { let ndc = clip.xy / clip.w; clip = vec4f(round(ndc * u.half_res) / u.half_res * clip.w, clip.zw); } var out: VsOut; out.pos = clip; out.uv = uv; return out; } // 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering. fn bayer4(px: vec2u) -> f32 { var m = array( 0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u, ); return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]); } @fragment fn fs_main(in: VsOut) -> @location(0) vec4f { let texel = textureSample(tex, smp, in.uv); if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt // RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden → // Verläufe zerfallen ins typische Dither-Muster. let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0; let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0)); return vec4f(floor(c * 31.0 + t) / 31.0, 1.0); }