diff --git a/docs/ISSUES.md b/docs/ISSUES.md index f0b70cc4..50f70a2e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/src/AcDream.App/Rendering/FrustumCuller.cs b/src/AcDream.App/Rendering/FrustumCuller.cs index 3c792bbb..ef97ed72 100644 --- a/src/AcDream.App/Rendering/FrustumCuller.cs +++ b/src/AcDream.App/Rendering/FrustumCuller.cs @@ -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); diff --git a/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs b/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs index 8c5f4807..d9e7ea2f 100644 --- a/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs @@ -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() {