acdream/src/AcDream.App/Rendering/ClipPlaneSet.cs
Erik 99d9c6d564 chore(render): S3 landing hygiene — scissor-era dead state, stale comments, the #130 harness, a conjunction-proof weather-gate pin
Chunk H of Campaign OVERHAUL v2's S3 walk-ownership program
(docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md §10.6), landing
the three post-hoc three-lens findings on S3 chunk 4 round 2. Pure dead-state
deletion, comment truth, one test deletion, one stronger pin — no admission,
order, or behavior change; no new flag or probe; no register row (AD-17's
correction is the lead's own, already on the campaign branch).

H1 — ClipPlaneSet.cs: deleted ScissorNdcAabb (no production reader — only
AppendOutsideSlice consumes ClipPlaneSet, and it reads only
IsNothingVisible/Count/PlaneArray) and renamed UseScissorFallback ->
IsPlaneOverflow with a doc stating the true consumer contract: Count==0
overflow means the region draws UNCLIPPED via the no-clip slot, never a
scissor box. Rewrote the file header's convexity-rule and Count==0-states
sections to the two states that exist (Empty / overflow) instead of three,
and simplified the private Scissor(...) AABB-computing helpers into a
parameterless Overflow() factory since nothing reads the AABB any more.
ClipPlaneSetTests.cs: renamed every UseScissorFallback reference, deleted
the AABB assertions (From_MultiplePolygons's four ScissorNdcAabb.X/Y/Z/W
checks, Empty_StaticProperty_DrawsNothing's degenerate-box check,
ScissorFallback_IsNotNothingVisible's bounds check) that pinned the deleted
consumer contract.

H2 — TerrainModernRenderer.cs: deleted Draw's clipPlanes/ndcClipAabb
parameters (grep: TerrainModernRenderer.Draw has exactly one production
caller, WorldScenePassExecutor.DrawFlatTerrain, and it never passed
either), which made CollectVisibleCells's own clipPlanes/ndcClipAabb/
viewProjection parameters and the IsAabbVisibleThroughClipRegion +
IsAabbOutsideHomogeneousPlane helper pair they fed entirely dead — deleted
along with the stale "Retail publishes landcell in_view from the clipped
landscape view" comment; CollectVisibleCells is frustum-only now, matching
DrawLandCells's own "retail never view-clips terrain" doc. Deleted the
terrainUploadCount computation at RetailPViewRenderer.cs (the
DrawInside/DrawLandscapeDynamicsPhase caller) and PrepareClipFrame's
GL-era reservation-count parameter end to end:
IWorldPassSurface.PrepareClipFrame(int), RhiWorldPassSurface's matching
body and its now-pointless "_ = terrainUploadCount" discard,
RetailPViewPassExecutor.PrepareClipFrame's pass-through, and
WorldScenePassExecutor.PrepareFlatWorldClip's hardcoded `1` argument —
PrepareClipFrame() takes nothing on every arm.
TerrainParticleCellVisibilityTests.cs: deleted the three tests that only
existed to pin the removed clip-plane/scissor mechanism
(RejectsCellsOutsideDoorwayClipPlanes, RejectsCellsOutsideDoorwayScissorAabb,
UnionsCellsFromEveryLandscapeSlice) and updated the two surviving frustum
tests to the new signature.

H3 — deleted tests/AcDream.App.Tests/Rendering/Issue130DoorwayStripTests.cs
outright. #130 was a background-color strip along a doorway's top edge from
a since-fixed under-inclusion in the CPU clip pipeline; its scissor half was
already retired at S3 chunk 4 fix round 2 (L7), and its surviving plane-gap
half exercised only PortalVisibilityBuilder.Build and
ClipFrameAssembler.Assemble, both confirmed zero-production-caller legacy
paths (grep) — the one production stage it actually touched,
ClipPlaneSet.From, is already pinned through the real producers
(BeginWalkFrame/ReassembleOutsideViewFromWalk) by the K6/L4 punch-fan
equivalence pin in ClipFrameLayoutTests, so nothing production-relevant lost
coverage.

H4 — comment truth pass:
- ClipFrameAssembler.cs: rewrote the file-header "renderer uses scissor for
  passes that need that fallback" line and the TerrainClipMode summary's
  "that path never produces Scissor" clause. Deleted the TerrainClipMode
  enum's Scissor member — grepped every reader first
  (WorldRenderDiagnostics.cs, WorldSceneDiagnosticsController.cs,
  WorldSceneRendererTests.cs all only pass the value through to a string
  Append or a mock parameter; WorldSceneRenderer.cs, the only writer, sets
  only Planes (default) or Skip, never Scissor) — kept Planes/Skip for the
  flat path and corrected Skip's stale doc ("No outside_view slice is
  visible; skip landscape indoors", a leftover from the pre-round-2 walk
  meaning) to what the flat path's own writer actually means now ("the
  PView walk ran instead of the flat-world path this frame"). Corrected
  ClipViewSlice's doc: the AABB is retained for WorldRenderDiagnostics only,
  not for a scissor consumer.
- WorldPassSurface.cs: "Four concerns... the clip-frame publication, the
  doorway scissor, gl_ClipDistance enablement, and retail's interior depth
  clear" -> three concerns (the doorway scissor was retired at L2), with a
  pointer to ClearInteriorDepth's own truthful doc. EnableClipDistances'
  "all three world vertex shaders already write 1.0" -> only
  portal_depth.vert writes gl_ClipDistance now (grep: sky.vert and
  terrain_modern.vert/terrain_atmospheric.vert no longer declare it at all
  — L3 deleted the block outright; portal_depth.vert still writes 1.0 past
  its active clip count). RhiWorldPassSurface's class doc dropped "the
  scissor is dynamic state on the borrowed encoder" (no producer left).
- RetailPViewPassExecutorTests.cs: DrawLandscapeDynamicsPhase_CallsDraw
  WeatherOnceExactlyOnce's doc corrected "unconditionally" (false since K2)
  to "conditional on WalkFrameDriver.WeatherTurnFired, see the L1 pin".
  Added the switch-table scope note (matching L8's own note on
  DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch) to the K1 pin's
  doc (DrawLandscapeDynamicsPhase_DrawWeatherOnceCallSiteHasNoEnclosing
  BackwardBranch).
- RetailPViewRenderer.cs: the DrawWeatherOnce citation cited the callee
  address (GameSky::Draw(sky,1) @0x00506ff0) where §10.1 cites the call
  site (@0x00506396) — corrected to cite the call site with the callee
  address alongside it.

H5 — MUTATION CHECK (mandatory, run against the production gate at
RetailPViewRenderer.DrawLandscapeDynamicsPhase):
  Added DrawLandscapeDynamicsPhase_ExactlyOneBranchGuardsDrawWeatherOnce,
  which widens the L1 pin's window to start at the
  DrawUnattachedSceneParticles call (the last call before the whole gate)
  and asserts EXACTLY ONE branch total in that window — the L1 pin's own
  window (getter-call to draw-call) misses a conjoined gate's extra,
  earlier condition because that condition's own brfalse lands BEFORE the
  getter call's offset.
  - Mutation 1 (the conjoined gate this pin exists to reject): changed the
    gate to `if (clipAssembly.OutsideViewSlices.Length != 0 &&
    walkDriver.WeatherTurnFired)`, built, ran the new pin — FAILED with:
      Assert.Single() Failure: The collection contained 2 matching items
      Expected:      (predicate expression)
      Collection:    [CompiledBranch { Offset = 7, OpCode = brfalse.s, TargetOffset = 17 }, CompiledBranch { Offset = 24, OpCode = brfalse.s, TargetOffset = 42 }, CompiledBranch { Offset = 33, OpCode = brfalse.s, TargetOffset = 42 }]
      Match indices: 1, 2
    (offset 7 = the DrawUnattachedSceneParticles block's own guard, correctly
    excluded since it precedes that call's offset; offsets 24 and 33 are the
    conjoined gate's two brfalse's, both inside the widened window — exactly
    the failure this pin exists to catch). Restored the single-flag gate;
    rebuilt; the new pin and all 13 sibling tests in
    RetailPViewPassExecutorTests pass again (14/14).
  - Mutation M1 (the existing round-2 regression: restore the pre-fix gate
    `if (clipAssembly.OutsideViewSlices.Length != 0)`), built, ran the full
    suite — the L1 pin
    (DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired)
    still FAILS, as required, with:
      Assert.Equal() Failure: Values differ
      Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
      Actual:   typeof(AcDream.App.Rendering.ClipFrameAssembly)
    (the new H5 pin does NOT fail under M1 — a single-flag gate still
    produces exactly one branch in the widened window, which is correct:
    catching "wrong condition source" is L1's job, catching "an extra
    conjoined condition" is H5's). Restored the correct gate; rebuilt;
    14/14 green again.

H6 grep sweep (extends §10.5's L6 sweep per the plan):
  grep -rnE "ScissorNdcAabb|UseScissorFallback|TerrainClipMode\.Scissor|ndcClipAabb|terrainUploadCount" --include=*.cs --include=*.vert --include=*.frag src tests
  -> empty (every mention, including in this commit's own explanatory
  comments, was rephrased to avoid the literal deleted identifiers).
  The base L6 sweep (SetTerrainClip|BeginDoorwayScissor|BindTerrainClip|
  TerrainBytes|active scissor|TerrainClipMode\.Scissor|NdcScissorRect) is
  also still empty.

Gates: dotnet build tests/AcDream.App.Tests -c Release -> 0 warnings / 0
errors. Hermetic lane -> 6825/6825 passed, 0 failed. InstalledDat lane
(ACDREAM_DAT_DIR set) -> 243 passed / 4 known failures (2x #383 layout
tests, TowerAscentReplayTests's TowerAscent KnownFailure,
WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff #458
KnownFailure) / 1 skipped / 248 total — one fewer test than before H3, as
expected from Issue130DoorwayStripTests's deletion.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 18:24:37 +02:00

280 lines
14 KiB
C#

// 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 signal that the region OVER-includes (draws
// unclipped) because it cannot 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 instead report a plane overflow:
// the consumer (ClipFrameAssembler.AppendOutsideSlice) reads Count == 0 and
// IsNothingVisible == false, appends an EMPTY plane array (slot 0, no clip), and
// the polygon draws whole (the punch fan covers the whole fan). That OVER-includes
// (draws a few extra pixels/triangles) but never hides anything. Over-inclusion is
// safe; under-inclusion is the bug class. S3 chunk 4 (fix round 2, L2) deleted the
// only mechanism that ever read a scissor AABB from this type — there is no GPU
// scissor consumer left anywhere in the walk — so an overflow region simply draws
// unclipped; it does not carry a bounding box for anyone to scissor against.
//
// === The two Count==0 states (how a consumer tells them apart) ===============
// Count == 0 can mean two different things; the consumer MUST distinguish:
// (a) Empty — IsNothingVisible == true, IsPlaneOverflow == false.
// The cell/region isn't visible at all → DRAW NOTHING.
// (b) Overflow — IsNothingVisible == false, IsPlaneOverflow == true.
// The convex-plane budget was exceeded (multi-polygon or >8
// edges) → DRAW UNCLIPPED (over-include, never a scissor).
// When Count > 0, Planes carries the convex gate and both flags above are false.
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// An NDC convex view region reduced to ≤8 clip-space gl_ClipDistance planes, or a
/// plane-overflow signal (draw unclipped). See the file header for the convexity
/// rule and the two Count==0 states.
/// </summary>
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 isPlaneOverflow, bool isNothingVisible)
{
_planes = planes ?? Array.Empty<Vector4>();
IsPlaneOverflow = isPlaneOverflow;
IsNothingVisible = isNothingVisible;
}
/// <summary>Number of active clip planes, 0..8. 0 ⇒ inspect <see cref="IsPlaneOverflow"/>
/// and <see cref="IsNothingVisible"/> to decide between "draw unclipped" and "draw nothing".</summary>
public int Count => _planes?.Length ?? 0;
/// <summary>The active clip-space planes (nx, ny, 0, d). Empty when <see cref="Count"/> is 0.
/// A clip-space point <c>clip</c> is inside iff <c>Vector4.Dot(plane, clip) &gt;= 0</c> for every plane.</summary>
public IReadOnlyList<Vector4> Planes => _planes ?? (IReadOnlyList<Vector4>)Array.Empty<Vector4>();
// The set is immutable after construction. ClipFrameAssembler transfers this exact array into
// its frame-scoped slice instead of cloning every plane payload a second time.
internal Vector4[] PlaneArray => _planes ?? Array.Empty<Vector4>();
/// <summary>True ⇒ Count == 0 because the region needs more than <see cref="MaxPlanes"/>
/// half-planes (or is multi-polygon); the consumer draws UNCLIPPED
/// (<c>ClipFrameAssembler.AppendOutsideSlice</c> emits an empty plane array, so the region
/// lands on the no-clip slot and — for the punch fans — the fan draws whole) — over-include,
/// never a scissor. Always false when <see cref="Count"/> &gt; 0 or when the region is empty.</summary>
public bool IsPlaneOverflow { get; }
/// <summary>True ⇒ the region is not visible at all; the consumer draws NOTHING.
/// Mutually exclusive with <see cref="IsPlaneOverflow"/>, and only meaningful when Count == 0.</summary>
public bool IsNothingVisible { get; }
/// <summary>The "nothing is visible" sentinel: Count == 0, not a plane overflow, draw nothing.</summary>
public static ClipPlaneSet Empty { get; } =
new(Array.Empty<Vector4>(), isPlaneOverflow: false, isNothingVisible: true);
/// <summary>
/// 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 → plane overflow
/// (draw unclipped); empty/degenerate → <see cref="Empty"/>. See the file header for the
/// full rule.
/// </summary>
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.
if (region.Polygons.Count > 1)
return Overflow();
return From(region.Polygons[0]);
}
/// <summary>
/// Reduce one convex view polygon without wrapping it in a temporary <see cref="CellView"/>.
/// The clip-frame assembler already iterates individual retail <c>view_poly</c> slices, so
/// constructing a CellView there needlessly ran the flood's canonical-key/dedup machinery for
/// every slice on every frame. This overload is behavior-identical to the one-polygon branch
/// of <see cref="From(CellView)"/> and avoids that unrelated allocation path.
/// </summary>
public static ClipPlaneSet From(in ViewPolygon polygon)
{
if (polygon.IsEmpty)
return Empty;
// Exactly one polygon: normalize winding to CCW and merge collinear edges in reusable
// scratch. The surviving vertices are consumed immediately to build the actual plane
// payload; there is no reason to allocate a second exact-sized polygon array per slice.
Vector2[] input = polygon.Vertices;
Vector2[]? rented = null;
Span<Vector2> verts = input.Length <= 32
? stackalloc Vector2[input.Length]
: (rented = ArrayPool<Vector2>.Shared.Rent(input.Length)).AsSpan(0, input.Length);
try
{
int count = NormalizeAndMerge(input, verts);
// Fewer than 3 distinct edges survive ⇒ a sliver/line with no area ⇒ nothing visible.
if (count < 3)
return Empty;
ReadOnlySpan<Vector2> normalized = verts[..count];
// A single convex polygon with too many edges to fit the hardware budget ⇒ plane
// overflow (draw unclipped — still a superset of the polygon → over-include, safe).
if (count > MaxPlanes)
return Overflow();
// 3..8 edges: emit one inward half-space plane per edge (CCW formula). This array is
// the retained GPU-routing payload and therefore the one necessary allocation.
var planes = new Vector4[count];
for (int i = 0; i < count; i++)
{
Vector2 p = normalized[i];
Vector2 q = normalized[(i + 1) % count];
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, isPlaneOverflow: false, isNothingVisible: false);
}
finally
{
if (rented is not null)
ArrayPool<Vector2>.Shared.Return(rented);
}
}
private static ClipPlaneSet Overflow() =>
new(Array.Empty<Vector4>(), isPlaneOverflow: true, isNothingVisible: false);
/// <summary>
/// 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.
/// </summary>
private static int NormalizeAndMerge(ReadOnlySpan<Vector2> input, Span<Vector2> points)
{
if (input.Length < 3)
return 0;
// 1) Drop exact/near-duplicate consecutive points first so edge directions are well-defined.
int count = 0;
foreach (Vector2 vertex in input)
{
if (count == 0 || (vertex - points[count - 1]).LengthSquared() > DegenerateEdgeLen * DegenerateEdgeLen)
points[count++] = vertex;
}
// Wrap-around duplicate (last == first).
if (count >= 2 && (points[count - 1] - points[0]).LengthSquared() <= DegenerateEdgeLen * DegenerateEdgeLen)
count--;
if (count < 3)
return 0;
// 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(points[..count]) < 0f)
points[..count].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 && count >= 3)
{
changed = false;
for (int i = 0; i < count; i++)
{
Vector2 prev = points[(i - 1 + count) % count];
Vector2 cur = points[i];
Vector2 next = points[(i + 1) % count];
Vector2 d0 = cur - prev;
Vector2 d1 = next - cur;
float l0 = d0.Length();
float l1 = d1.Length();
if (l0 < DegenerateEdgeLen || l1 < DegenerateEdgeLen)
{
points[(i + 1)..count].CopyTo(points[i..]);
count--;
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)
{
points[(i + 1)..count].CopyTo(points[i..]);
count--; // cur lies on the straight line prev→next
changed = true;
break;
}
}
}
if (count < 3)
return 0;
// 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(points[..count])) * 0.5f < MinPolygonArea)
return 0;
return count;
}
// Twice the signed area (the "shoelace" sum). > 0 ⇒ CCW, < 0 ⇒ CW.
private static float SignedArea2(ReadOnlySpan<Vector2> poly)
{
float a = 0f;
for (int i = 0; i < poly.Length; i++)
{
Vector2 p = poly[i];
Vector2 q = poly[(i + 1) % poly.Length];
a += p.X * q.Y - q.X * p.Y;
}
return a;
}
}