diff --git a/src/AcDream.App/Rendering/FrustumCuller.cs b/src/AcDream.App/Rendering/FrustumCuller.cs new file mode 100644 index 0000000..3c792bb --- /dev/null +++ b/src/AcDream.App/Rendering/FrustumCuller.cs @@ -0,0 +1,100 @@ +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Six normalized view-frustum planes extracted from a View×Projection matrix. +/// Each plane is represented as (normal.X, normal.Y, normal.Z, distance) where +/// dot(normal, point) + distance >= 0 means the point is on the visible side. +/// +public readonly struct FrustumPlanes +{ + public readonly Vector4 Left; + public readonly Vector4 Right; + public readonly Vector4 Bottom; + public readonly Vector4 Top; + public readonly Vector4 Near; + public readonly Vector4 Far; + + private FrustumPlanes(Vector4 left, Vector4 right, Vector4 bottom, Vector4 top, Vector4 near, Vector4 far) + { + Left = left; + Right = right; + Bottom = bottom; + Top = top; + Near = near; + Far = far; + } + + /// + /// Extracts the six frustum planes from a combined View×Projection matrix + /// using the Gribb-Hartmann method. System.Numerics.Matrix4x4 is row-major, + /// so rows are accessed directly via M{row}{col} fields. + /// + public static FrustumPlanes FromViewProjection(Matrix4x4 vp) + { + // System.Numerics.Matrix4x4 uses ROW-VECTOR convention: clip = worldPos * VP + // So clip.x = dot(worldPos, col1), clip.w = dot(worldPos, col4), etc. + // Gribb-Hartmann left plane: clip.x + clip.w >= 0 => dot(worldPos, col1 + col4) >= 0 + // Therefore we must operate on COLUMNS of the matrix (not rows). + // Column vectors (M{row}{col}, so col1 has elements from col-index=1): + var col1 = new Vector4(vp.M11, vp.M21, vp.M31, vp.M41); + var col2 = new Vector4(vp.M12, vp.M22, vp.M32, vp.M42); + var col3 = new Vector4(vp.M13, vp.M23, vp.M33, vp.M43); + var col4 = new Vector4(vp.M14, vp.M24, vp.M34, vp.M44); + + // Gribb-Hartmann extraction for row-vector (System.Numerics) convention: + var left = Normalize(col4 + col1); + var right = Normalize(col4 - col1); + var bottom = Normalize(col4 + col2); + var top = Normalize(col4 - col2); + var near = Normalize(col4 + col3); + var far = Normalize(col4 - col3); + + return new FrustumPlanes(left, right, bottom, top, near, far); + } + + private static Vector4 Normalize(Vector4 plane) + { + float length = MathF.Sqrt(plane.X * plane.X + plane.Y * plane.Y + plane.Z * plane.Z); + return plane / length; + } +} + +/// +/// Conservative AABB-vs-frustum culling. Zero allocations; suitable for per-frame use. +/// +public static class FrustumCuller +{ + /// + /// Returns true if the axis-aligned bounding box defined by + /// [min, max] is potentially visible against the given frustum + /// planes. Conservative: returns true for partial intersections. + /// Zero allocations; suitable for per-frame use. + /// + public static bool IsAabbVisible(FrustumPlanes planes, Vector3 min, Vector3 max) + { + // For each plane, test the AABB's "most-positive vertex" — + // the corner most in the direction of the plane normal. If + // that corner is behind the plane, the entire box is outside. + return TestPlane(planes.Left, min, max) + && TestPlane(planes.Right, min, max) + && TestPlane(planes.Bottom, min, max) + && TestPlane(planes.Top, min, max) + && TestPlane(planes.Near, min, max) + && TestPlane(planes.Far, min, max); + } + + private static bool TestPlane(Vector4 plane, Vector3 min, Vector3 max) + { + // Pick the corner of the AABB most in the direction of the + // plane normal (the "positive vertex"). + float px = plane.X >= 0 ? max.X : min.X; + float py = plane.Y >= 0 ? max.Y : min.Y; + float pz = plane.Z >= 0 ? max.Z : min.Z; + + // If the positive vertex is behind the plane, the box is + // fully outside this half-space. + return plane.X * px + plane.Y * py + plane.Z * pz + plane.W >= 0; + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs b/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs new file mode 100644 index 0000000..8c5f480 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/FrustumCullerTests.cs @@ -0,0 +1,133 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.Core.Tests.Rendering; + +public class FrustumCullerTests +{ + [Fact] + public void IdentityVP_EverythingVisible() + { + // Identity VP: all planes at infinity, every box is visible. + // Actually identity VP has degenerate planes — instead use a + // simple ortho that covers [-1,1]³. + var vp = Matrix4x4.CreateOrthographic(2f, 2f, 0.1f, 100f); + var planes = FrustumPlanes.FromViewProjection(vp); + + // Box at origin, well within the frustum. + Assert.True(FrustumCuller.IsAabbVisible(planes, + new Vector3(-0.5f, -0.5f, -50f), + new Vector3(0.5f, 0.5f, -1f))); + } + + [Fact] + public void PerspectiveCamera_BoxInFront_Visible() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(0, 0, 0), + new Vector3(0, 0, -1), + Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 1f, 1f, 1000f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Box directly in front of the camera. + Assert.True(FrustumCuller.IsAabbVisible(planes, + new Vector3(-10f, -10f, -100f), + new Vector3(10f, 10f, -10f))); + } + + [Fact] + public void PerspectiveCamera_BoxBehind_NotVisible() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(0, 0, 0), + new Vector3(0, 0, -1), + Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 1f, 1f, 1000f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Box entirely behind the camera (positive Z). + Assert.False(FrustumCuller.IsAabbVisible(planes, + new Vector3(-10f, -10f, 10f), + new Vector3(10f, 10f, 100f))); + } + + [Fact] + public void PerspectiveCamera_BoxFarLeft_NotVisible() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(0, 0, 0), + new Vector3(0, 0, -1), + Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 1f, 1f, 1000f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Box way off to the left. + Assert.False(FrustumCuller.IsAabbVisible(planes, + new Vector3(-1000f, -10f, -50f), + new Vector3(-500f, 10f, -10f))); + } + + [Fact] + public void PerspectiveCamera_BoxStraddlingNearPlane_Visible() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(0, 0, 0), + new Vector3(0, 0, -1), + Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 1f, 1f, 1000f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Box that straddles the near plane (conservative: visible). + Assert.True(FrustumCuller.IsAabbVisible(planes, + new Vector3(-1f, -1f, -2f), + new Vector3(1f, 1f, 0.5f))); + } + + [Fact] + public void PerspectiveCamera_BoxBeyondFarPlane_NotVisible() + { + var view = Matrix4x4.CreateLookAt( + new Vector3(0, 0, 0), + new Vector3(0, 0, -1), + Vector3.UnitY); + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 1f, 1f, 100f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Box far beyond the far plane. + Assert.False(FrustumCuller.IsAabbVisible(planes, + new Vector3(-10f, -10f, -500f), + new Vector3(10f, 10f, -200f))); + } + + [Fact] + public void AcdreamCamera_LandblockInFront_Visible() + { + // Simulate acdream's actual camera setup (Z-up, looking +Y, + // positioned at Holtburg). A landblock-sized AABB at the + // camera's forward should be visible. + var view = Matrix4x4.CreateLookAt( + new Vector3(96f, 96f, 150f), // camera pos (center of a landblock, elevated) + new Vector3(96f, 288f, 100f), // looking roughly +Y (forward toward next landblock) + Vector3.UnitZ); // Z-up + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + MathF.PI / 3f, 16f / 9f, 1f, 5000f); + var planes = FrustumPlanes.FromViewProjection(view * proj); + + // Landblock-sized AABB 192 units ahead in Y. + Assert.True(FrustumCuller.IsAabbVisible(planes, + new Vector3(0f, 192f, 50f), + new Vector3(192f, 384f, 200f))); + + // Landblock behind the camera (negative Y relative to camera). + Assert.False(FrustumCuller.IsAabbVisible(planes, + new Vector3(0f, -384f, 50f), + new Vector3(192f, -192f, 200f))); + } +}