#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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 16:05:48 +02:00
parent 2d15084243
commit 478c549b9e
2 changed files with 87 additions and 19 deletions

View file

@ -926,6 +926,11 @@ public sealed class GameWindow : IDisposable
// Cannot be changed at runtime; Quality changes mid-session that would // Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning. // alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples, 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); _window = Window.Create(options);

View file

@ -9,17 +9,13 @@ namespace AcDream.App.Rendering;
/// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c> /// writes — the port of <c>D3DPolyRender::DrawPortalPolyInternal</c>
/// (Ghidra 0x0059bc90, pc:424490). /// (Ghidra 0x0059bc90, pc:424490).
/// ///
/// <para><b>⚠ RESERVED — NOT wired into the frame as of 2026-06-11.</b> The /// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
/// first BR-2 attempt wired this as a seal (interior root) + punch (outdoor / /// on outdoor / look-in roots, via <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>
/// look-in) and was reverted at the visual gate: the outdoor far-Z punch /// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
/// erased the depth of DYNAMIC objects (player / NPCs) standing in a door /// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
/// aperture, so the interior painted over them. The gate also proved #108 /// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)
/// (cellar grass-sweep) is a MEMBERSHIP bug, not a depth bug — the punch was /// added the two-pass stencil depth gate on the punch side — see
/// only masking it on outdoor-classified cellar frames. The correct depth /// <see cref="DrawDepthFan"/>.</para>
/// 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.</para>
/// ///
/// <para>Retail projects a portal polygon, software-clips it against the /// <para>Retail projects a portal polygon, software-clips it against the
/// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a /// installed portal view (<c>polyClipFinish</c>), and draws the survivor as a
@ -52,10 +48,11 @@ public sealed class PortalDepthMaskRenderer : IDisposable
{ {
private const string VertSrc = @"#version 430 core private const string VertSrc = @"#version 430 core
layout(location = 0) in vec3 aPos; layout(location = 0) in vec3 aPos;
uniform mat4 uViewProjection; uniform mat4 uViewProjection;
uniform int uPlaneCount; uniform int uPlaneCount;
uniform vec4 uPlanes[8]; uniform vec4 uPlanes[8];
uniform int uForceFarZ; uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
out float gl_ClipDistance[8]; out float gl_ClipDistance[8];
void main() void main()
{ {
@ -64,6 +61,8 @@ void main()
gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0; gl_ClipDistance[i] = (i < uPlaneCount) ? dot(uPlanes[i], clipPos) : 1.0;
if (uForceFarZ == 1) if (uForceFarZ == 1)
clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail) 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; 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 _locPlaneCount;
private readonly int _locPlanes; private readonly int _locPlanes;
private readonly int _locForceFarZ; private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private const int MaxFanVerts = 32; private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3]; 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"); _locPlaneCount = _gl.GetUniformLocation(_program, "uPlaneCount");
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes"); _locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ"); _locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_vao = _gl.GenVertexArray(); _vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer(); _vbo = _gl.GenBuffer();
@ -130,10 +131,39 @@ void main() { } // depth-only: color writes are masked off by the caller state
return s; return s;
} }
/// <summary>
/// #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.
/// </summary>
private const float PunchMarkDepthBias = 0.0005f;
/// <summary> /// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the /// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects /// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
/// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth). /// punch (true, retail maxZ1) vs seal (false, retail maxZ2 true depth).
///
/// <para><b>Seal</b> (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.</para>
///
/// <para><b>Punch</b> (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.</para>
/// </summary> /// </summary>
public void DrawDepthFan( public void DrawDepthFan(
ReadOnlySpan<Vector3> worldVerts, ReadOnlySpan<Vector3> 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.CullFace); // portal fans face either way
_gl.Disable(EnableCap.ScissorTest); _gl.Disable(EnableCap.ScissorTest);
_gl.Enable(EnableCap.DepthTest); _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 _gl.ColorMask(false, false, false, false); // alpha-0 fan ≙ no color
for (int i = 0; i < planeCount; i++) for (int i = 0; i < planeCount; i++)
_gl.Enable(EnableCap.ClipDistance0 + 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) fixed (float* pp = p)
_gl.Uniform4(_locPlanes, (uint)planeCount, pp); _gl.Uniform4(_locPlanes, (uint)planeCount, pp);
} }
_gl.Uniform1(_locForceFarZ, forceFarZ ? 1 : 0);
_gl.BindVertexArray(_vao); _gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (float* v = _scratch) fixed (float* v = _scratch)
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, 0, (nuint)(n * 3 * sizeof(float)), v); _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.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 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++) for (int i = 0; i < planeCount; i++)
_gl.Disable(EnableCap.ClipDistance0 + i); _gl.Disable(EnableCap.ClipDistance0 + i);
_gl.ColorMask(true, true, true, true); _gl.ColorMask(true, true, true, true);
_gl.DepthMask(true);
_gl.DepthFunc(DepthFunction.Less); _gl.DepthFunc(DepthFunction.Less);
_gl.Enable(EnableCap.CullFace); _gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back); _gl.CullFace(TriangleFace.Back);