From a83b4306f8dadca46c02950096004a9f28f47ed8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 30 May 2026 17:03:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20U.2c=20=E2=80=94=20Clip?= =?UTF-8?q?PlaneSet=20(NDC=20convex=20region=20=E2=86=92=20gl=5FClipDistan?= =?UTF-8?q?ce=20planes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CellView convex polygon edges → clip-space planes (nx,ny,0,d) for gl_ClipDistance, ≤8 with collinear-edge merge. Multi-polygon or >8-edge regions degrade to the union AABB scissor (over-include, never hide); empty regions are distinct (draw nothing). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/ClipPlaneSet.cs | 260 ++++++++++++++++++ .../Rendering/ClipPlaneSetTests.cs | 255 +++++++++++++++++ 2 files changed, 515 insertions(+) create mode 100644 src/AcDream.App/Rendering/ClipPlaneSet.cs create mode 100644 tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs diff --git a/src/AcDream.App/Rendering/ClipPlaneSet.cs b/src/AcDream.App/Rendering/ClipPlaneSet.cs new file mode 100644 index 0000000..a4824eb --- /dev/null +++ b/src/AcDream.App/Rendering/ClipPlaneSet.cs @@ -0,0 +1,260 @@ +// ClipPlaneSet.cs +// +// Phase U.2c: turn a CellView (a cell's accumulated screen-space clip region, +// in NDC) into a small set of clip-space half-space planes for the GPU's +// gl_ClipDistance, OR a scissor AABB when the region can't be expressed as one +// convex plane set. +// +// This is the bridge between PortalVisibilityBuilder's 2D NDC view polygons and +// the per-vertex clip the mesh/terrain shaders will perform (Phase U.2c → U.2e). +// Pure System.Numerics math; NO GL. The shader consumes each plane as +// d = nx*clip.x + ny*clip.y + 0*clip.z + dw*clip.w (>= 0 ⇒ keep) +// where (nx, ny, dw) = the plane's (normal.xy, offset). z is always 0 because a +// screen-space (NDC) edge is a vertical slab in clip space — independent of depth. +// +// === The convexity rule (read before touching this file) ===================== +// gl_ClipDistance planes are a CONJUNCTION of half-spaces, i.e. exactly ONE +// convex region (their intersection). A CellView with MORE THAN ONE polygon is a +// UNION of convex regions, which is in general NOT convex and CANNOT be +// represented by one plane set. Emitting just the first/largest polygon's planes +// would clip away the others → a real visibility bug (under-inclusion). +// +// Therefore From() NEVER emits a single polygon's planes when the CellView holds +// several. Multi-polygon (and >8-edge) regions degrade to the UNION AABB scissor: +// the scissor is a superset of the true region, so it OVER-includes (draws a few +// extra pixels) but never hides anything. Over-inclusion is safe; under-inclusion +// is the bug class. +// +// === The three Count==0 states (how a consumer tells them apart) ============= +// Count == 0 can mean three different things; the consumer MUST distinguish: +// (a) Empty — IsNothingVisible == true, UseScissorFallback == false. +// The cell/region isn't visible at all → DRAW NOTHING. The +// ScissorNdcAabb is a degenerate inverted box (min > max) so that a +// consumer which naively scissors on it still draws nothing. +// (b) Scissor — UseScissorFallback == true, IsNothingVisible == false. +// The convex-plane budget was exceeded (multi-polygon or >8 edges) +// → DRAW the ScissorNdcAabb box (a valid min<=max NDC rectangle). +// There is no third Count==0 state produced by From(). (A separate "no-clip, +// pass-all" slot 0 is constructed by the consumer directly, not via From().) +// When Count > 0, Planes carries the convex gate and the scissor fields are unused. +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a +/// scissor AABB fallback. See the file header for the convexity rule and the three +/// Count==0 states. +/// +public readonly struct ClipPlaneSet +{ + // Max simultaneous hardware clip planes we target (GL guarantees >= 8). + private const int MaxPlanes = 8; + + // Collinear-edge merge threshold. Two consecutive edge directions are treated as + // the same edge when the turn between them is below ~0.5° (retail copy_view does a + // ~1px screen-space dedup). |sin θ| for unit dirs = |cross|; sin(0.5°) ≈ 0.0087265. + private const float CollinearSinEps = 0.0087265f; + + // Drop a vertex whose two incident edges are shorter than this (NDC) — a duplicate + // or near-duplicate point that would otherwise yield a garbage normalized normal. + private const float DegenerateEdgeLen = 1e-6f; + + // A polygon whose absolute signed area (full area, not 2x) falls below this is a line + // or point — zero screen coverage ⇒ nothing visible. A real portal opening has area far + // above this (e.g. the sliver-clip test region is 0.4); only an edge-on projection gets here. + private const float MinPolygonArea = 1e-7f; + + private readonly Vector4[] _planes; + + private ClipPlaneSet(Vector4[] planes, bool useScissorFallback, bool isNothingVisible, Vector4 scissorNdcAabb) + { + _planes = planes ?? Array.Empty(); + UseScissorFallback = useScissorFallback; + IsNothingVisible = isNothingVisible; + ScissorNdcAabb = scissorNdcAabb; + } + + /// Number of active clip planes, 0..8. 0 ⇒ inspect + /// and to decide between "draw the AABB" and "draw nothing". + public int Count => _planes?.Length ?? 0; + + /// The active clip-space planes (nx, ny, 0, d). Empty when is 0. + /// A clip-space point clip is inside iff Vector4.Dot(plane, clip) >= 0 for every plane. + public IReadOnlyList Planes => _planes ?? (IReadOnlyList)Array.Empty(); + + /// True ⇒ the convex-plane budget was exceeded; gate on + /// instead (draw the box). Always false when > 0 or when the region is empty. + public bool UseScissorFallback { get; } + + /// True ⇒ the region is not visible at all; the consumer draws NOTHING. + /// Mutually exclusive with , and only meaningful when Count == 0. + public bool IsNothingVisible { get; } + + /// NDC axis-aligned scissor box (minX, minY, maxX, maxY). Valid (min <= max) only when + /// is true. For the empty/nothing-visible case it is a degenerate + /// inverted box so naive scissoring still draws nothing. + public Vector4 ScissorNdcAabb { get; } + + /// The "nothing is visible" sentinel: Count == 0, not a scissor fallback, draw nothing. + public static ClipPlaneSet Empty { get; } = + new(Array.Empty(), useScissorFallback: false, isNothingVisible: true, scissorNdcAabb: DegenerateAabb); + + // Inverted box (min > max) — any sane AABB intersection against it is empty. + private static Vector4 DegenerateAabb => new(1f, 1f, -1f, -1f); + + /// + /// Reduce a CellView's NDC clip region to a ClipPlaneSet. One convex polygon (≤8 edges + /// after collinear-merge) → per-edge planes; multi-polygon or >8 edges → union-AABB scissor; + /// empty/degenerate → . See the file header for the full rule. + /// + public static ClipPlaneSet From(CellView region) + { + if (region is null || region.IsEmpty || region.Polygons.Count == 0) + return Empty; + + // MORE THAN ONE polygon ⇒ union, not convex ⇒ never emit one polygon's planes. + // Over-include via the union AABB (safe). region.Min/Max already track the union. + if (region.Polygons.Count > 1) + return Scissor(region.MinX, region.MinY, region.MaxX, region.MaxY); + + // Exactly one polygon: normalize winding to CCW, merge collinear edges, then decide. + Vector2[] verts = NormalizeAndMerge(region.Polygons[0].Vertices); + + // Fewer than 3 distinct edges survive ⇒ a sliver/line with no area. There is no + // meaningful AABB to over-include (a zero-area region), so treat it as nothing visible. + if (verts.Length < 3) + return Empty; + + // A single convex polygon with too many edges to fit the hardware budget ⇒ scissor + // on ITS own AABB (still a superset of the polygon → over-include, safe). + if (verts.Length > MaxPlanes) + return Scissor(verts); + + // 3..8 edges: emit one inward half-space plane per edge (CCW formula). + var planes = new Vector4[verts.Length]; + for (int i = 0; i < verts.Length; i++) + { + Vector2 p = verts[i]; + Vector2 q = verts[(i + 1) % verts.Length]; + Vector2 dir = q - p; + // Inward normal for CCW winding: perp(dir) = (-dir.y, dir.x) points to the polygon's + // interior (the "left" side of the directed edge p→q). + Vector2 n = Vector2.Normalize(new Vector2(-dir.Y, dir.X)); + // Plane: n·x + d >= 0 inside, with d = -(n·p). In clip space with NDC x = clip.x/clip.w: + // dist = n.x*clip.x + n.y*clip.y + 0*clip.z + (-(n·p))*clip.w (>= 0 ⇒ keep) + planes[i] = new Vector4(n.X, n.Y, 0f, -Vector2.Dot(n, p)); + } + return new ClipPlaneSet(planes, useScissorFallback: false, isNothingVisible: false, scissorNdcAabb: DegenerateAabb); + } + + private static ClipPlaneSet Scissor(float minX, float minY, float maxX, float maxY) => + new(Array.Empty(), useScissorFallback: true, isNothingVisible: false, + scissorNdcAabb: new Vector4(minX, minY, maxX, maxY)); + + private static ClipPlaneSet Scissor(ReadOnlySpan verts) + { + float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue; + foreach (var v in verts) + { + if (v.X < minX) minX = v.X; + if (v.X > maxX) maxX = v.X; + if (v.Y < minY) minY = v.Y; + if (v.Y > maxY) maxY = v.Y; + } + return Scissor(minX, minY, maxX, maxY); + } + + /// + /// Return the polygon wound CCW with collinear vertices removed. The PortalVisibilityBuilder + /// already EnsureCcw's its output, but From() is a public entry point that must be robust to + /// either winding (e.g. a hand-built CellView), so we normalize here too. + /// + private static Vector2[] NormalizeAndMerge(Vector2[] input) + { + if (input is null || input.Length < 3) + return Array.Empty(); + + // 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined. + var pts = new List(input.Length); + foreach (var v in input) + { + if (pts.Count == 0 || (v - pts[^1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen) + pts.Add(v); + } + // Wrap-around duplicate (last == first). + if (pts.Count >= 2 && (pts[^1] - pts[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen) + pts.RemoveAt(pts.Count - 1); + if (pts.Count < 3) + return Array.Empty(); + + // 2) Force CCW winding (positive signed area). perp(dir)=(-y,x) is the inward normal only + // for CCW; if the caller handed us CW, reverse so the plane signs come out inside-positive. + if (SignedArea2(pts) < 0f) + pts.Reverse(); + + // 3) Merge collinear edges: drop vertex i when edge (i-1→i) and edge (i→i+1) point the same + // way (turn angle < ~0.5°). Iterate until stable — removing one vertex can expose a new + // collinear triple. |cross(a,b)| of unit dirs = |sin θ|; dot>0 rules out a 180° reversal. + bool changed = true; + while (changed && pts.Count >= 3) + { + changed = false; + for (int i = 0; i < pts.Count; i++) + { + Vector2 prev = pts[(i - 1 + pts.Count) % pts.Count]; + Vector2 cur = pts[i]; + Vector2 next = pts[(i + 1) % pts.Count]; + + Vector2 d0 = cur - prev; + Vector2 d1 = next - cur; + float l0 = d0.Length(); + float l1 = d1.Length(); + if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen) + { + pts.RemoveAt(i); + changed = true; + break; + } + + d0 /= l0; + d1 /= l1; + float cross = d0.X * d1.Y - d0.Y * d1.X; // sin θ + float dot = d0.X * d1.X + d0.Y * d1.Y; // cos θ + if (dot > 0f && MathF.Abs(cross) < CollinearSinEps) + { + pts.RemoveAt(i); // cur lies on the straight line prev→next + changed = true; + break; + } + } + } + + if (pts.Count < 3) + return Array.Empty(); + + // Final degeneracy gate: a polygon with negligible area is a line/point even if it still + // has >= 3 distinct vertices (e.g. an edge-on portal, or a near-collinear triple the 0.5° + // merge didn't quite collapse). Emitting its planes would yield an empty half-space + // intersection that silently gates out everything; report it honestly as nothing-visible. + if (MathF.Abs(SignedArea2(pts)) * 0.5f < MinPolygonArea) + return Array.Empty(); + + return pts.ToArray(); + } + + // Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW. + private static float SignedArea2(List poly) + { + float a = 0f; + for (int i = 0; i < poly.Count; i++) + { + Vector2 p = poly[i]; + Vector2 q = poly[(i + 1) % poly.Count]; + a += p.X * q.Y - q.X * p.Y; + } + return a; + } +} diff --git a/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs new file mode 100644 index 0000000..1c2a82a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +public class ClipPlaneSetTests +{ + // A CellView holding one regular n-gon centred at the origin, wound CCW. + private static CellView RegularNgonCellView(int n, float radius) + { + var verts = new Vector2[n]; + for (int i = 0; i < n; i++) + { + // CCW: increasing angle. + float a = MathF.Tau * i / n; + verts[i] = new Vector2(radius * MathF.Cos(a), radius * MathF.Sin(a)); + } + var cv = new CellView(); + cv.Add(new ViewPolygon(verts)); + return cv; + } + + private static CellView SquareCellView(float min, float max) + { + var cv = new CellView(); + cv.Add(new ViewPolygon(new[] + { + new Vector2(min, min), new Vector2(max, min), new Vector2(max, max), new Vector2(min, max), + })); + return cv; + } + + // --- The three required tests (verbatim intent from the plan) ------------- + + [Fact] + public void From_AxisAlignedSquare_FourPlanes_PointInsideHasPositiveDistances() + { + var sq = new CellView(); + sq.Add(new ViewPolygon(new[] + { + new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f), + })); + var cps = ClipPlaneSet.From(sq); + Assert.Equal(4, cps.Count); + + var clip = new Vector4(0, 0, 0, 1); // NDC (0,0), inside + foreach (var p in cps.Planes) + Assert.True(Vector4.Dot(p, clip) >= 0); + + var outClip = new Vector4(0.9f, 0, 0, 1); // NDC (0.9,0), outside the square + Assert.Contains(cps.Planes, p => Vector4.Dot(p, outClip) < 0); + } + + [Fact] + public void From_NineEdgePolygon_FallsBackToScissor() + { + var poly = RegularNgonCellView(n: 9, radius: 0.6f); + var cps = ClipPlaneSet.From(poly); + Assert.True(cps.UseScissorFallback || cps.Count <= 8); + if (cps.UseScissorFallback) + { + Assert.Equal(0, cps.Count); // AABB carries the gate + } + } + + [Fact] + public void From_EmptyRegion_IsEmpty() + { + Assert.Equal(0, ClipPlaneSet.From(new CellView()).Count); + } + + // --- Multi-polygon safety (the under-inclusion guard) --------------------- + + [Fact] + public void From_MultiplePolygons_FallsBackToUnionScissor_NeverEmitsOnePolygonsPlanes() + { + // Two disjoint squares: a CONVEX plane set can never represent their union. + var cv = new CellView(); + cv.Add(new ViewPolygon(new[] + { + new Vector2(-0.8f, -0.8f), new Vector2(-0.4f, -0.8f), new Vector2(-0.4f, -0.4f), new Vector2(-0.8f, -0.4f), + })); + cv.Add(new ViewPolygon(new[] + { + new Vector2(0.4f, 0.4f), new Vector2(0.8f, 0.4f), new Vector2(0.8f, 0.8f), new Vector2(0.4f, 0.8f), + })); + + var cps = ClipPlaneSet.From(cv); + + // MUST NOT emit a single polygon's convex planes (that would hide the other). + Assert.True(cps.UseScissorFallback); + Assert.Equal(0, cps.Count); + Assert.Empty(cps.Planes); + + // Scissor AABB is the UNION of both polygons (over-include, safe). + Assert.Equal(-0.8f, cps.ScissorNdcAabb.X, 5); // minX + Assert.Equal(-0.8f, cps.ScissorNdcAabb.Y, 5); // minY + Assert.Equal(0.8f, cps.ScissorNdcAabb.Z, 5); // maxX + Assert.Equal(0.8f, cps.ScissorNdcAabb.W, 5); // maxY + + // The far corner of the second square is inside the union AABB → not hidden. + Assert.True(0.7f >= cps.ScissorNdcAabb.X && 0.7f <= cps.ScissorNdcAabb.Z); + Assert.True(0.7f >= cps.ScissorNdcAabb.Y && 0.7f <= cps.ScissorNdcAabb.W); + } + + // --- Distinguishing the three Count==0 states ---------------------------- + + [Fact] + public void Empty_IsNothingVisible_NotScissorFallback() + { + var e = ClipPlaneSet.From(new CellView()); + Assert.Equal(0, e.Count); + Assert.False(e.UseScissorFallback); // NOT "draw the AABB box" + Assert.True(e.IsNothingVisible); // "draw nothing" + Assert.Empty(e.Planes); + } + + [Fact] + public void Empty_StaticProperty_DrawsNothing() + { + var e = ClipPlaneSet.Empty; + Assert.Equal(0, e.Count); + Assert.False(e.UseScissorFallback); + Assert.True(e.IsNothingVisible); + // Degenerate AABB (min > max) so a consumer that naively scissors on it draws nothing. + Assert.True(e.ScissorNdcAabb.X > e.ScissorNdcAabb.Z); + Assert.True(e.ScissorNdcAabb.Y > e.ScissorNdcAabb.W); + } + + [Fact] + public void ScissorFallback_IsNotNothingVisible() + { + var poly = RegularNgonCellView(n: 9, radius: 0.6f); + var cps = ClipPlaneSet.From(poly); + Assert.True(cps.UseScissorFallback); + Assert.False(cps.IsNothingVisible); // "draw the AABB box", not "draw nothing" + // The fallback AABB bounds the 9-gon (radius 0.6). + Assert.True(cps.ScissorNdcAabb.Z - cps.ScissorNdcAabb.X > 0.5f); + Assert.True(cps.ScissorNdcAabb.W - cps.ScissorNdcAabb.Y > 0.5f); + } + + // --- Plane-sign correctness for every edge ------------------------------- + + [Fact] + public void From_Square_EveryEdgePlane_IsPositiveAtCenter_NegativeJustOutsideThatEdge() + { + var cps = ClipPlaneSet.From(SquareCellView(-0.5f, 0.5f)); + Assert.Equal(4, cps.Count); + + var center = new Vector4(0, 0, 0, 1); + // Inside ⇒ EVERY plane non-negative. + foreach (var p in cps.Planes) + Assert.True(Vector4.Dot(p, center) >= 0, $"plane {p} should be >=0 at center"); + + // For each of the four cardinal directions just outside the square, at least one + // plane goes negative (the edge facing that direction). + foreach (var pt in new[] + { + new Vector4(0.6f, 0, 0, 1), new Vector4(-0.6f, 0, 0, 1), + new Vector4(0, 0.6f, 0, 1), new Vector4(0, -0.6f, 0, 1), + }) + { + Assert.Contains(cps.Planes, p => Vector4.Dot(p, pt) < 0); + } + } + + [Fact] + public void From_Triangle_ThreePlanes_AllZeroZComponent() + { + var cv = new CellView(); + cv.Add(new ViewPolygon(new[] + { + new Vector2(0f, 0.5f), new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f), // CCW + })); + var cps = ClipPlaneSet.From(cv); + Assert.Equal(3, cps.Count); + foreach (var p in cps.Planes) + { + Assert.Equal(0f, p.Z, 6); // clip-space planes are (nx, ny, 0, d) + // Normal is unit length in xy. + Assert.Equal(1f, MathF.Sqrt(p.X * p.X + p.Y * p.Y), 4); + } + } + + // --- Winding normalization: a CW square must still yield inside-positive planes. + + [Fact] + public void From_ClockwiseSquare_NormalizesWinding_InsideStillPositive() + { + var cv = new CellView(); + // Same square but wound CW (reverse order). Builder normally EnsureCcw's, + // but From must be robust to either winding. + cv.Add(new ViewPolygon(new[] + { + new Vector2(-0.5f, -0.5f), new Vector2(-0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, -0.5f), + })); + var cps = ClipPlaneSet.From(cv); + Assert.Equal(4, cps.Count); + var center = new Vector4(0, 0, 0, 1); + foreach (var p in cps.Planes) + Assert.True(Vector4.Dot(p, center) >= 0, $"plane {p} must be >=0 at center even for CW input"); + } + + // --- Collinear-edge merge: a square with a midpoint added on one edge → 4 planes. + + [Fact] + public void From_SquareWithCollinearMidpoint_MergesToFourPlanes() + { + var cv = new CellView(); + cv.Add(new ViewPolygon(new[] + { + new Vector2(-0.5f, -0.5f), + new Vector2(0f, -0.5f), // collinear midpoint on the bottom edge + new Vector2(0.5f, -0.5f), + new Vector2(0.5f, 0.5f), + new Vector2(-0.5f, 0.5f), + })); + var cps = ClipPlaneSet.From(cv); + Assert.Equal(4, cps.Count); // the redundant collinear edge is merged away + } + + // --- Exactly 8 edges fit (octagon); 9 spills to scissor (after merge fails to help). + + [Fact] + public void From_RegularOctagon_FitsInEightPlanes() + { + var cps = ClipPlaneSet.From(RegularNgonCellView(n: 8, radius: 0.6f)); + Assert.False(cps.UseScissorFallback); + Assert.Equal(8, cps.Count); + var center = new Vector4(0, 0, 0, 1); + foreach (var p in cps.Planes) + Assert.True(Vector4.Dot(p, center) >= 0); + } + + // --- Degenerate single polygon (all collinear after merge) → nothing convex → empty. + + [Fact] + public void From_DegenerateSinglePolygon_IsNothingVisible() + { + // A ViewPolygon needs >=3 verts to be added, but they can be (nearly) collinear, + // leaving <3 distinct edges after merge → no convex region → treat as nothing visible. + var cv = new CellView(); + cv.Add(new ViewPolygon(new[] + { + new Vector2(-0.5f, -0.5f), new Vector2(0f, 0f), new Vector2(0.5f, 0.5f), + })); + var cps = ClipPlaneSet.From(cv); + Assert.Equal(0, cps.Count); + // Degenerate sliver carries no area → safest is "nothing visible" (over-include would + // need a real AABB; a zero-area line has none). + Assert.True(cps.IsNothingVisible); + } +}