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)));
+ }
+}