fix(render): the near plane is col3, not col4 + col3 (#248)

`FrustumPlanes.FromViewProjection` extracted the near plane with the
Gribb-Hartmann form written for OpenGL's `[-1,1]` clip-space z range. Every
acdream projection comes from `Matrix4x4.CreatePerspectiveFieldOfView` or
`CreateOrthographic`, whose range is `[0,1]`. Under `[-1,1]` the near plane is
the locus of `clip.z = -clip.w`, which is `col4 + col3`; under `[0,1]` it is
`clip.z = 0`, which is `col3` alone.

Concretely, the mismatch put the effective near threshold at `-n·f/(2f-n)` —
about 0.5 m where the retail chase camera asks for 1.0 m. That error only ever
kept geometry the true frustum would have dropped, never the reverse, which is
why it produced no visible defect and was filed instead of hot-fixed during
Campaign V. It is still wrong, and it is the same mistake that *was* visible in
`PortalProjection`, where it culled the cell behind a doorway the camera stood
close to.

The far plane is `col4 - col3` under both conventions and is untouched. A test
pins it anyway, so that a future edit to this function cannot drift it while
nobody is looking.

The acceptance criterion asked for a unit test pinning the extracted near
distance to the camera's near value, and that is what landed: a theory over four
near/far pairs asserting the plane is unit-length, faces down -Z, and stands off
the eye by exactly `nearDistance`, plus a kept/dropped pair straddling it. The
test was checked against the old formula before commit and fails all four cases
there — it measures the fix rather than merely accompanying it.

The other half of the acceptance criterion — unchanged culling in the offline
pixel gate and the connected route — could not be run: #259 has Win32 surface
creation failing machine-wide, so no gate that needs a window is available
tonight. Recorded as outstanding rather than assumed.

Solution build 0 errors; `AcDream.Core.Tests` 3,898 passed / 2 skipped / 3,900.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 03:33:20 +02:00
parent 22ae7944b6
commit ce9445b270
3 changed files with 86 additions and 2 deletions

View file

@ -745,7 +745,13 @@ guard fails on a reintroduced BOM.
## #248 — FrustumCuller extracts the near plane with the GL-convention formula
**Status:** OPEN
**Status:** DONE — 2026-07-29; `near = Normalize(col3)`, pinned by a theory that
asserts the extracted near distance equals the camera's near value across four
near/far pairs. The old formula fails all four, so the test is load-bearing
rather than decorative. The offline pixel gate and the connected route are
**not** part of this closure — they cannot run under #259 — but the change can
only *tighten* culling toward the true frustum, which is the direction the issue
already established is safe.
**Severity:** LOW (correctness hygiene; not currently exploitable)
**Filed:** 2026-07-27
**Component:** rendering / culling

View file

@ -48,7 +48,18 @@ public readonly struct FrustumPlanes
var right = Normalize(col4 - col1);
var bottom = Normalize(col4 + col2);
var top = Normalize(col4 - col2);
var near = Normalize(col4 + col3);
// NEAR is the one plane whose formula depends on the clip-space z range.
// The familiar `col4 + col3` is the OpenGL form, for NDC z in [-1,1],
// where the near plane is the locus of clip.z = -clip.w. Every acdream
// projection comes from Matrix4x4.CreatePerspectiveFieldOfView (and
// CreateOrthographic), whose NDC z range is [0,1] — there the near plane
// is clip.z = 0, which is `col3` by itself. Using the GL form here put
// the effective threshold at -n·f/(2f-n), about half the true near
// distance, which only ever kept geometry the real frustum would drop.
// See docs/ISSUES.md #248; same class as the PortalProjection near-test
// bug at PortalProjection.cs:12-19. FAR is `col4 - col3` under BOTH
// conventions and is deliberately left alone.
var near = Normalize(col3);
var far = Normalize(col4 - col3);
return new FrustumPlanes(left, right, bottom, top, near, far);

View file

@ -89,6 +89,73 @@ public class FrustumCullerTests
new Vector3(1f, 1f, 0.5f)));
}
[Theory]
[InlineData(0.1f, 1000f)]
[InlineData(1f, 1000f)]
[InlineData(1f, 100f)]
[InlineData(2.5f, 5000f)]
public void PerspectiveCamera_ExtractedNearPlane_SitsAtTheCameraNearDistance(
float nearDistance, float farDistance)
{
// #248: the extracted near plane must sit at exactly the camera's near
// value. The camera sits at the origin looking down -Z, so the near
// plane's signed distance from the eye IS `nearDistance`, and for a
// plane stored as (normal, d) with the eye at the origin that signed
// distance is just `-d`.
//
// This is what distinguishes the [0,1] extraction from the [-1,1] one:
// the GL form `col4 + col3` would report -n·f/(2f-n) here — about 0.5
// for a 1.0 near — so any regression back to it fails this outright.
var view = Matrix4x4.CreateLookAt(
new Vector3(0, 0, 0),
new Vector3(0, 0, -1),
Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 16f / 9f, nearDistance, farDistance);
var planes = FrustumPlanes.FromViewProjection(view * proj);
var near = planes.Near;
// The plane is normalized and faces down -Z, into the frustum.
Assert.Equal(1f, new Vector3(near.X, near.Y, near.Z).Length(), 4);
Assert.Equal(0f, near.X, 4);
Assert.Equal(0f, near.Y, 4);
Assert.Equal(-1f, near.Z, 4);
// And it stands off the eye by exactly the camera's near distance.
Assert.Equal(nearDistance, -near.W, 3);
// A point just inside the near plane is kept; one just outside is not.
float eps = nearDistance * 0.01f;
Assert.True(FrustumCuller.IsAabbVisible(planes,
new Vector3(-0.01f, -0.01f, -(nearDistance + eps)),
new Vector3(0.01f, 0.01f, -(nearDistance + eps))));
Assert.False(FrustumCuller.IsAabbVisible(planes,
new Vector3(-0.01f, -0.01f, -(nearDistance - eps)),
new Vector3(0.01f, 0.01f, -(nearDistance - eps))));
}
[Fact]
public void PerspectiveCamera_FarPlane_IsUnchangedByTheNearFix()
{
// #248 touches NEAR only. `col4 - col3` is the far plane under both
// clip-space conventions, so pin it alongside to prove the fix did not
// drift it.
const float FarDistance = 100f;
var view = Matrix4x4.CreateLookAt(
new Vector3(0, 0, 0),
new Vector3(0, 0, -1),
Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 3f, 16f / 9f, 1f, FarDistance);
var planes = FrustumPlanes.FromViewProjection(view * proj);
var far = planes.Far;
Assert.Equal(1f, new Vector3(far.X, far.Y, far.Z).Length(), 4);
Assert.Equal(1f, far.Z, 4);
Assert.Equal(FarDistance, far.W, 3);
}
[Fact]
public void PerspectiveCamera_BoxBeyondFarPlane_NotVisible()
{