// 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; } }