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

@ -0,0 +1,68 @@
using System;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// #129 — doors/doorways leak through terrain and houses from over a landblock
/// away. The punch's mark pass (#117, AD-18) 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²·(fn)/(f·n) meters of eye depth at eye distance d
/// — 0.125 m at 5 m but ~190 m at a landblock (znear 0.1), so distant
/// occluders in front of an aperture passed the mark and were far-Z punched:
/// the door-shaped leak. The fix caps the bias's eye-space span
/// (PortalDepthMaskRenderer.MarkBiasNdc): identical to the validated constant
/// below the ~10 m crossover, never more than the cap beyond it.
/// </summary>
public class Issue129PunchBiasTests
{
private const float Near = PortalDepthMaskRenderer.CameraNearPlaneMeters; // 0.1 (retail znear)
private const float Far = 5000f;
/// <summary>Eye-depth span (meters) covered by an NDC depth bias b at eye
/// distance d: ndc(d) = f(dn)/((fn)d) ⇒ d(ndc) inverse ⇒
/// span = b·d²·(fn)/(f·n) (exact for small b via the derivative).</summary>
private static float EyeSpanMeters(float biasNdc, float d) =>
biasNdc * d * d * (Far - Near) / (Far * Near);
[Fact]
public void OldConstantBias_SpansMetersAtALandblock_TheLeak()
{
// The refuted form (documentation of WHY the constant was wrong):
// 0.0005 NDC at ~one landblock spans far more eye depth than any
// occluder separation — everything in front got punched.
Assert.True(EyeSpanMeters(0.0005f, 192f) > 100f);
// ...while at close range it was a sane sliver:
Assert.InRange(EyeSpanMeters(0.0005f, 5f), 0.05f, 0.30f);
}
[Fact]
public void CappedBias_MatchesValidatedConstant_AtCloseRange()
{
// Below the crossover the T5-validated constant must win unchanged —
// this preserves the #108 grass coverage bit-for-bit.
foreach (float d in new[] { 0.5f, 1f, 3f, 5f, 8f, 9.9f })
Assert.Equal(0.0005f, PortalDepthMaskRenderer.MarkBiasNdc(d), 6);
}
[Fact]
public void CappedBias_EyeSpanNeverExceedsCap_AtAnyDistance()
{
for (float d = 1f; d <= 400f; d += 1f)
{
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(d), d);
Assert.True(span <= PortalDepthMaskRenderer.PunchMarkBiasEyeCapMeters * 1.02f,
FormattableString.Invariant($"bias spans {span:F2} m of eye depth at d={d} m"));
}
}
[Fact]
public void CappedBias_At200m_CannotReachOccluders()
{
// The reported #129 distance: occluder separations are tens of
// meters; the punch reach must stay under the 0.5 m cap.
float span = EyeSpanMeters(PortalDepthMaskRenderer.MarkBiasNdc(200f), 200f);
Assert.True(span <= 0.51f, FormattableString.Invariant($"span {span:F3} m at 200 m"));
}
}