acdream/src/AcDream.App/Rendering/FrustumCuller.cs
Erik 07fde88534 feat(app): Phase A.2 — FrustumCuller + FrustumPlanes (Gribb-Hartmann)
Per-landblock frustum culling for the streaming renderer. Extracts
6 normalized view-frustum planes from a View×Projection matrix using
the standard Gribb-Hartmann method. IsAabbVisible tests the AABB's
most-positive vertex against each plane — conservative (no false
negatives) and zero-allocation.

Key implementation note: System.Numerics.Matrix4x4 uses ROW-VECTOR
convention (clip = worldPos * VP), so Gribb-Hartmann must operate on
the COLUMNS of the matrix (not rows). The spec's row-based pseudocode
assumed column-major (OpenGL) convention; the fix is col4 ± col{1..3}.

7 new tests covering ortho, perspective (front/behind/left/far/
near-straddling), and acdream's actual Z-up camera convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:49:17 +02:00

100 lines
4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
/// <summary>
/// Conservative AABB-vs-frustum culling. Zero allocations; suitable for per-frame use.
/// </summary>
public static class FrustumCuller
{
/// <summary>
/// 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.
/// </summary>
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;
}
}