fix #129: cap the punch mark bias's eye-space reach (was unbounded at distance)

The user's "doors/doorways leak through terrain and houses over a
landblock" is the #117 mark-pass bias evaluated in the wrong space.

Mechanism (confirmed analytically, Issue129PunchBiasTests): the punch's
pass-A stencil mark biased the aperture fan toward the viewer by a
CONSTANT 0.0005 NDC. NDC depth is non-linear - a constant NDC bias b
spans ~= b*d^2*(f-n)/(f*n) meters of eye depth at eye distance d. With
retail's znear 0.1 (d4b5c71) that is 0.125 m at 5 m but ~190 m at one
landblock: every hill/house in front of a distant aperture passed the
LEQUAL mark and was far-Z punched -> door-shaped leak through the
occluder. This is exactly the risk AD-18's register row recorded
("an occluder within ~bias in front of a distant aperture gets punched
through") - the symptom-scan rule found it before instrumentation.

Fix: cap the bias's EYE-SPACE span at 0.5 m -
  biasNdc(d) = min(0.0005, capMeters * near / d^2)
in the mark-pass vertex shader (clipPos.w = eye depth), CPU-mirrored as
PortalDepthMaskRenderer.MarkBiasNdc for tests. Below the ~10 m
crossover the constant-NDC term is smaller and wins - bit-identical to
the T5-validated close-range behavior, so the #108 grass coverage that
justified the bias is untouched. Beyond it the punch can never reach an
occluder more than 0.5 m in front of the aperture plane.

Pins (Issue129PunchBiasTests): the old form spans >100 m of eye depth
at a landblock (the leak, kept as documentation of the refuted shape);
the capped form stays <= 0.5 m at every distance 1-400 m and matches
the validated constant bit-for-bit below 10 m.

AD-18 register row updated in the same commit (bias description + the
#129 closure + the residual risk note: door-hugging geometry beyond the
0.5 m cap at >10 m viewing range re-occludes - the cap constant is the
tuning knob if the gate shows residue).

Suites: App 256+1skip / Core 1439+2skip / UI 420 / Net 294 green.
Awaiting the user visual gate at the original spot (+ #108 cellar
re-check up close).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-12 13:38:59 +02:00
parent 6c4b6d64d9
commit 4ba714835d
4 changed files with 134 additions and 25 deletions

View file

@ -52,7 +52,8 @@ uniform mat4 uViewProjection;
uniform int uPlaneCount;
uniform vec4 uPlanes[8];
uniform int uForceFarZ;
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBias; // NDC bias toward the viewer (mark pass only)
uniform float uDepthBiasEyeCapN; // eye-span cap x near plane (#129; see MarkBiasNdc)
out float gl_ClipDistance[8];
void main()
{
@ -62,7 +63,14 @@ void main()
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)
{
// #117 mark-pass bias, #129 eye-space cap. clipPos.w = eye depth d;
// an NDC bias b spans ~b*d*d/near meters of eye depth, so the
// constant-NDC form alone reached METERS at distance (door-shaped
// leaks through hills/houses). Keep in sync with MarkBiasNdc.
float biasNdc = min(uDepthBias, uDepthBiasEyeCapN / max(clipPos.w * clipPos.w, 1e-6));
clipPos.z -= biasNdc * clipPos.w;
}
gl_Position = clipPos;
}";
@ -79,6 +87,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
private readonly int _locPlanes;
private readonly int _locForceFarZ;
private readonly int _locDepthBias;
private readonly int _locDepthBiasEyeCapN;
private const int MaxFanVerts = 32;
private readonly float[] _scratch = new float[MaxFanVerts * 3];
@ -104,6 +113,7 @@ void main() { } // depth-only: color writes are masked off by the caller state
_locPlanes = _gl.GetUniformLocation(_program, "uPlanes");
_locForceFarZ = _gl.GetUniformLocation(_program, "uForceFarZ");
_locDepthBias = _gl.GetUniformLocation(_program, "uDepthBias");
_locDepthBiasEyeCapN = _gl.GetUniformLocation(_program, "uDepthBiasEyeCapN");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
@ -144,10 +154,37 @@ void main() { } // depth-only: color writes are masked off by the caller state
/// 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>
/// #129 (2026-06-12): NDC depth is non-linear — a constant NDC bias b
/// spans ≈ b·d²/near meters of eye depth at eye distance d. With
/// znear = 0.1, the 0.0005 constant alone spanned 0.125 m at 5 m but
/// ~190 m at a landblock away: every hill/house in front of a distant
/// aperture passed the mark and got far-Z punched — door-shaped leaks
/// through occluders. Fix: cap the bias's EYE-SPACE span at
/// <see cref="PunchMarkBiasEyeCapMeters"/>. Below the ~10 m crossover
/// (sqrt(cap·near/0.0005)) the constant-NDC term is smaller and wins —
/// bit-identical to the T5-validated close-range behavior (#108 grass
/// coverage untouched); beyond it the punch can never reach an occluder
/// more than the cap in front of the aperture plane.
/// </summary>
public const float PunchMarkBiasEyeCapMeters = 0.5f;
/// <summary>Retail <c>Render::znear</c> = 0.1 (decomp :342173, re-landed
/// d4b5c71). The cap conversion below assumes the production camera near
/// plane; the small f/(fn) factor (~1.00002 at far 5000) is ignored.</summary>
public const float CameraNearPlaneMeters = 0.1f;
/// <summary>CPU mirror of the vertex-shader mark-bias expression (keep in
/// sync with <c>VertSrc</c>): the NDC bias applied at eye depth
/// <paramref name="eyeDepthMeters"/>.</summary>
public static float MarkBiasNdc(float eyeDepthMeters) =>
MathF.Min(PunchMarkDepthBias,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters
/ MathF.Max(eyeDepthMeters * eyeDepthMeters, 1e-6f));
/// <summary>
/// Draw one portal polygon as an invisible depth write, clipped to the
/// slice's clip-space half-planes. <paramref name="forceFarZ"/> selects
@ -237,6 +274,8 @@ void main() { } // depth-only: color writes are masked off by the caller state
_gl.DepthMask(false);
_gl.Uniform1(_locForceFarZ, 0);
_gl.Uniform1(_locDepthBias, PunchMarkDepthBias);
_gl.Uniform1(_locDepthBiasEyeCapN,
PunchMarkBiasEyeCapMeters * CameraNearPlaneMeters);
_gl.DrawArrays(PrimitiveType.TriangleFan, 0, (uint)n);
// ── PUNCH pass B: far-Z write on marked pixels only;