From 478c549b9e793bbc7f5d63fd9454c6a62941a529 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 11 Jun 2026 16:05:48 +0200 Subject: [PATCH] #117: depth-gate the aperture punch - stencil mark+punch (z-buffered equivalent of retail's painter's order) T5 reported doors/interiors visible through terrain hills and through nearer buildings, always in aperture-shaped regions. Root cause, decomp- settled: retail's DrawPortalPolyInternal (Ghidra 0x0059bc90) draws the punch with DEPTHTEST_ALWAYS + per-vertex far-Z (0.99999899, maxZ1 bit0) - it UNCONDITIONALLY stomps any occluder depth at aperture pixels. Retail is safe only because its outdoor pass is painter's-ordered far->near: anything nearer (hills, closer houses) draws AFTER the punch and re-covers it. Our z-buffered MDI frame has no such global order (one terrain pass + one shells pass), so the faithful GL-state port of the punch was unsafe by construction - the far house's aperture punch erased the near house's wall depth / the hill's depth, and the interior + door entities (dynamics drawn last) painted through. Fix - the z-buffer-correct equivalent of the painter's-order guarantee: punch only where the aperture polygon itself is VISIBLE. PortalDepthMaskRenderer's punch path is now two passes: A) stencil-mark: aperture fan at its (slightly biased) true depth, depth LEQUAL, no depth write -> stencil=1 where the aperture wins against everything drawn so far (terrain + all shells precede DrawExitPortalMasks in the frame, so the buffer holds the real occluders); B) far-Z punch with depth ALWAYS, stencil-gated EQUAL 1, zeroing the stencil as it goes (self-cleaning; no frame-level stencil state). The mark bias (0.0005 NDC ~ 6 cm at 5 m) keeps #108's case covered: terrain hugging the door plane still punches; a hill or another house meters nearer no longer does. The SEAL path (interior roots) stays retail-verbatim single-pass - it runs right after the gated full depth clear, so there is nothing nearer to stomp. Also: WindowOptions now requests 8 stencil bits explicitly (was the GLFW platform default), and PortalDepthMaskRenderer's stale "RESERVED - not wired" banner is corrected (T1 wired it via DrawRetailPViewPortalDepthWrite). Acceptance rides the focused post-T5 re-gate (downhill door check + behind-house openings check + #108 cellar stays clean). Co-Authored-By: Claude Fable 5 --- src/AcDream.App/Rendering/GameWindow.cs | 5 + .../Rendering/PortalDepthMaskRenderer.cs | 101 ++++++++++++++---- 2 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 15a037ca..596c40b7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -926,6 +926,11 @@ public sealed class GameWindow : IDisposable // Cannot be changed at runtime; Quality changes mid-session that would // alter MsaaSamples are logged as a restart-required warning. Samples = startupQuality.MsaaSamples, + // #117 (2026-06-11): the aperture punch's depth gate needs a + // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). + // GLFW defaults to 8 stencil bits, but make the requirement + // explicit rather than platform-implicit. + PreferredStencilBufferBits = 8, }; _window = Window.Create(options); diff --git a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs index 809d6211..969396b8 100644 --- a/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs +++ b/src/AcDream.App/Rendering/PortalDepthMaskRenderer.cs @@ -9,17 +9,13 @@ namespace AcDream.App.Rendering; /// writes — the port of D3DPolyRender::DrawPortalPolyInternal /// (Ghidra 0x0059bc90, pc:424490). /// -/// ⚠ RESERVED — NOT wired into the frame as of 2026-06-11. The -/// first BR-2 attempt wired this as a seal (interior root) + punch (outdoor / -/// look-in) and was reverted at the visual gate: the outdoor far-Z punch -/// erased the depth of DYNAMIC objects (player / NPCs) standing in a door -/// aperture, so the interior painted over them. The gate also proved #108 -/// (cellar grass-sweep) is a MEMBERSHIP bug, not a depth bug — the punch was -/// only masking it on outdoor-classified cellar frames. The correct depth -/// discipline (punch → interior → dynamics-last ordering) will be rebuilt -/// during BR-3 when it can be verified against the shell-chop deletion. This -/// class is the verified-correct depth-write primitive kept for that work; it -/// has no callers today. +/// Wired by T1 (BR-3, `579c8b0`): seal on interior roots, punch +/// on outdoor / look-in roots, via GameWindow.DrawRetailPViewPortalDepthWrite +/// (the DrawExitPortalMasks slice callback) — safe alongside the +/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after +/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11) +/// added the two-pass stencil depth gate on the punch side — see +/// . /// /// Retail projects a portal polygon, software-clips it against the /// installed portal view (polyClipFinish), and draws the survivor as a @@ -52,10 +48,11 @@ public sealed class PortalDepthMaskRenderer : IDisposable { private const string VertSrc = @"#version 430 core layout(location = 0) in vec3 aPos; -uniform mat4 uViewProjection; -uniform int uPlaneCount; -uniform vec4 uPlanes[8]; -uniform int uForceFarZ; +uniform mat4 uViewProjection; +uniform int uPlaneCount; +uniform vec4 uPlanes[8]; +uniform int uForceFarZ; +uniform float uDepthBias; // NDC bias toward the viewer (mark pass only) out float gl_ClipDistance[8]; void main() { @@ -64,6 +61,8 @@ void main() gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0; if (uForceFarZ == 1) clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) + else if (uDepthBias > 0.0) + clipPos.z -= uDepthBias * clipPos.w; // #117 mark-pass bias (see DrawDepthFan) gl_Position = clipPos; }"; @@ -79,6 +78,7 @@ void main() { } // depth-only: color writes are masked off by the caller state private readonly int _locPlaneCount; private readonly int _locPlanes; private readonly int _locForceFarZ; + private readonly int _locDepthBias; private const int MaxFanVerts = 32; private readonly float[] _scratch = new float[MaxFanVerts * 3]; @@ -103,6 +103,7 @@ void main() { } // depth-only: color writes are masked off by the caller state _locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount"); _locPlanes = _gl.GetUniformLocation(_program, "uPlanes"); _locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ"); + _locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias"); _vao = _gl.GenVertexArray(); _vbo = _gl.GenBuffer(); @@ -130,10 +131,39 @@ void main() { } // depth-only: color writes are masked off by the caller state return s; } + /// + /// #117 (2026-06-11): the mark-pass depth bias, in NDC, toward the + /// viewer. Retail's punch is DEPTHTEST_ALWAYS and is safe only because + /// retail's outdoor pass is painter's-ordered far→near (anything nearer + /// redraws AFTER the punch and re-covers it). Our z-buffered MDI frame + /// has no such order, so an unconditional far-Z punch erased the depth + /// of NEARER occluders (terrain hills, closer buildings) at aperture + /// pixels — doors/interiors painted through them (the T5 #117 report). + /// The z-buffer-correct equivalent: punch ONLY where the aperture + /// polygon itself wins a depth test at its true depth (two-pass + /// stencil below). The bias keeps the #108 case covered — terrain + /// hugging the door plane (centimeters in front of the aperture) must + /// still be punched; a hill or another house meters nearer must not. + /// 0.0005 NDC ≈ 6 cm at 5 m / ≈ 1 m at 20 m with znear=0.1. + /// + private const float PunchMarkDepthBias = 0.0005f; + /// /// Draw one portal polygon as an invisible depth write, clipped to the /// slice's clip-space half-planes. selects /// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth). + /// + /// Seal (interior root): one pass, retail-verbatim — + /// depth ALWAYS + true projected depth. It runs immediately after the + /// gated full depth clear, so there is no nearer content to stomp. + /// + /// Punch (outdoor root / look-in): two passes (#117). + /// Pass A marks stencil where the aperture fan passes a LEQUAL depth + /// test at its (biased) true depth — i.e. where the aperture is + /// actually visible against everything drawn so far. Pass B writes the + /// far-Z punch with depth ALWAYS but stencil-gated to the marked + /// pixels, and zeroes the stencil as it goes (self-cleaning). This is + /// the z-buffered equivalent of retail's painter's-order safety. /// public void DrawDepthFan( ReadOnlySpan worldVerts, @@ -159,8 +189,6 @@ void main() { } // depth-only: color writes are masked off by the caller state _gl.Disable(EnableCap.CullFace); // portal fans face either way _gl.Disable(EnableCap.ScissorTest); _gl.Enable(EnableCap.DepthTest); - _gl.DepthFunc(DepthFunction.Always); // retail DEPTHTEST ALWAYS - _gl.DepthMask(true); // z-write on _gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color for (int i = 0; i < planeCount; i++) _gl.Enable(EnableCap.ClipDistance0 + i); @@ -183,13 +211,47 @@ void main() { } // depth-only: color writes are masked off by the caller state fixed (float* pp = p) _gl.Uniform4(_locPlanes, (uint)planeCount, pp); } - _gl.Uniform1(_locForceFarZ, forceFarZ ? 1 : 0); _gl.BindVertexArray(_vao); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); fixed (float* v = _scratch) _gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v); - _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + + if (!forceFarZ) + { + // ── SEAL: retail-verbatim single pass ── + _gl.DepthFunc(DepthFunction.Always); + _gl.DepthMask(true); + _gl.Uniform1(_locForceFarZ, 0); + _gl.Uniform1(_locDepthBias, 0f); + _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + } + else + { + // ── PUNCH pass A: stencil-mark visible aperture pixels ── + _gl.Enable(EnableCap.StencilTest); + _gl.StencilFunc(StencilFunction.Always, 1, 0xFF); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace); + _gl.StencilMask(0xFF); + _gl.DepthFunc(DepthFunction.Lequal); + _gl.DepthMask(false); + _gl.Uniform1(_locForceFarZ, 0); + _gl.Uniform1(_locDepthBias, PunchMarkDepthBias); + _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + + // ── PUNCH pass B: far-Z write on marked pixels only; + // zero the stencil as we go (self-cleaning) ── + _gl.StencilFunc(StencilFunction.Equal, 1, 0xFF); + _gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Zero); + _gl.DepthFunc(DepthFunction.Always); + _gl.DepthMask(true); + _gl.Uniform1(_locForceFarZ, 1); + _gl.Uniform1(_locDepthBias, 0f); + _gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n); + + _gl.Disable(EnableCap.StencilTest); + } + _gl.BindVertexArray(0); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); } @@ -198,6 +260,7 @@ void main() { } // depth-only: color writes are masked off by the caller state for (int i = 0; i < planeCount; i++) _gl.Disable(EnableCap.ClipDistance0 + i); _gl.ColorMask(true, true, true, true); + _gl.DepthMask(true); _gl.DepthFunc(DepthFunction.Less); _gl.Enable(EnableCap.CullFace); _gl.CullFace(TriangleFace.Back);