Campaign OVERHAUL S3 chunk 4 (docs/research/2026-09-01-overhaul/s3-walk-ownership-map.md §10). Retail
polygon-clips exactly three things on the CPU — portal polygons (PView::GetClip), punch fans
(DrawPortalPolyInternal against building_view), and exit seals (setup_view + polyClipFinish);
ordinary meshes, cell shells, terrain, particles and the weather are never view-clipped. Every
acdream-only clip rule beyond those three is deleted; the two that remain get a new CPU/GPU
equivalence pin.
DELETED (no retail counterpart):
- RetailPViewRenderer.DrawLandscapeDynamicsPhase's per-outside-view-slice weather loop
(SetTerrainClip + ClearClipRouting + the old DrawLandscapeSliceLate leaf, one call per active
landscape view) — retail's GameSky::Draw(sky,1) @0x00506ff0 runs ONCE, unclipped, after
LScape::draw's whole block loop. RetailPViewPassExecutor.DrawWeatherOnce stays the one call site;
it now also submits the rain particles (ParticleRenderPass.SkyPostScene) as ONE unclipped
submission, folded in from the deleted loop's per-slice clipSlot draw.
- RetailPViewPassExecutor.DrawLandscapeSliceLate, RetailPViewLandscapeLateSliceContext,
SetTerrainClip (the walk-level wrapper — its only caller was the deleted loop),
ClearClipRouting (same), SetCellShellClipRouting (already had no caller), and
UseIndoorMembershipOnlyRouting (4 call sites in RetailPViewRenderer.cs) — all "first view clips
the shell" residue: SetCellShellClipRouting was the ONLY place EnvCellRenderer.SetClipRouting
ever received a non-null value anywhere in the app, so once it's gone,
UseIndoorMembershipOnlyRouting's own clear calls are provably permanent no-ops. AbortFrame's
matching TryAbort(SetClipRouting(null))/TryAbort(ClearClipRouting) lines are dropped for the
same reason. KEPT: BeginDoorwayScissor, _surface.BindTerrainClip(), EnableClipDistances/
DisableClipDistances, and IWorldPassSurface.SetTerrainClip/ClipFrame.SetTerrainClip/
PrepareClipFrame's terrain-clip publish — all still have real consumers (DrawWalkSky's per-slice
outdoor sky mesh draw; the always-bound TerrainClip UBO section) or are shared classic (non-walk)
infrastructure out of this chunk's ownership (WorldScenePassExecutor, PortalTunnelPresentation).
- WbDrawDispatcher.WalkClassify.ResolvePartClipSlots (renamed ResolvePartVisible, returns bool) and
RetailWholeMeshSlot: the per-view clip-slot LIST always collapsed to either nothing or one
element (RenderDeviceD3D::DrawMesh @0x005A0860 submits the whole mesh once any view admits it) —
never anything but a Boolean wearing a list-shaped costume. IWalkLookInViewSource.
VisibleClipSlotsInLookInTurn is deleted outright (no consumer besides that collapse);
SphereVisibleInLookInTurn gained VisibleClipSlotsInLookInTurn's own testSphere parameter so the
no-authored-sphere case ("any admitted view, geometry ignored") still has a home.
EmitClassifiedBatches now emits exactly one batch per surviving TryClassifyBatch using the
entity's own classic slot (always 0 while _clipRoutingActive is never armed by the walk path).
- The six ProbeCathedralSkip*/ProbeCathedralShellOrderEnabled discriminators (RenderingDiagnostics,
their six docs/launch-options.md rows, every read site in WalkFrameDriver/
RetailPViewPassExecutor.WalkLeaf/RetailPViewRenderer, and the WalkFrameDriver.
TraceCathedralShellOrder method + RetailPViewRenderer._probeCathedralShellOrderFrame it served) —
the investigation is closed: the cathedral leak is retail behavior, not an acdream defect to
discriminate.
O1-O4 (the ledger's carried chunk-1 items, closed here because this chunk owns the weather path):
IWalkEventSink gains OnWeatherTurn(viewerCellId), fired by RetailFrameWalk.DrawLandscape
UNCONDITIONALLY at retail's own call-site shape (GameSky::Draw(sky,1) is itself unconditional from
LScape::draw; the is_player_outside gate lives inside it) but gated by the walk context's own new
WeatherGateOpen/ViewerCellId members (default false/0, so every other IRetailFrameWalkContext
implementer — test fixtures, the FW1 conformance replay context — is unaffected).
WalkProductionFrameContext wires RetailPViewFrameInput.ViewerCellId and
RetailPViewPassExecutor.ShouldDrawWeatherOnce(RenderSky, RenderWeather, PlayerCellId) into those two
members. WalkFrameDriver.OnWeatherTurn prints the "OC" transcript line at COLLECT time; the print is
removed from DrawWeatherOnce (the DRAW stays there, at Replay). Because DrawLandscape's new call sits
at the very end of the method, an outdoor root's transcript naturally ends with the line and an
interior root's lands strictly between "LS" and the flood's first "EC" — exactly retail's position —
with no extra plumbing.
Kept items each get a synthetic-view CPU/GPU equivalence pin (ClipFrameLayoutTests): exit seals via
the real ClipFrame.AppendSlot/GetSlotPlanes round trip, punch fans via the direct
ClipViewSlice.Planes = cps.PlaneArray assignment ClipFrameAssembler.Assemble uses — both assert every
polygon edge midpoint has ~0 signed distance under the GPU-read-back planes and non-negative distance
under every plane.
Every new pin's mutation was hand-verified this session (temporarily reintroduced, confirmed the
exact failing assertion, then reverted): a duplicated OnWeatherTurn call fails Assert.Single with
"2 matching items" on both the outdoor and interior position tests; suppressing the Collect-time
call fails the same assertions with "did not contain any matching items"; perturbing one GPU plane's
offset by 0.05 fails the exit-seal equivalence pin with "the closest plane was only 0.050000012
away". WalkProductionFrameContextTests pins that the ViewerCellId/WeatherGateOpen storage seam
itself never drops or swaps its own two constructor/Reset arguments.
No register row added or removed — every change here deletes an acdream-only rule; none introduces
a new deviation. Full solution build: 0 warnings/0 errors. App hermetic 6,829/6,829 (was 6,828 base
+ 1 new pin). InstalledDat 244 pass/1 skip/4 known (2x #383, TowerAscent, and the pre-existing #458
WalkLandscape.CheckBlocks block-visibility divergence — unrelated to this chunk, untouched by it).
Core.Tests Rendering 216/216.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
326 lines
14 KiB
C#
326 lines
14 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
/// <summary>
|
|
/// Phase U.3: CPU-side proof that <see cref="ClipFrame"/> packs the shared clip
|
|
/// data in the EXACT std430 (mesh SSBO) / std140 (terrain UBO) byte layout the
|
|
/// shaders read. A silent layout drift here would mis-clip at U.4 with no build
|
|
/// error — these tests are the gate that catches it.
|
|
///
|
|
/// Layout under test (mesh CellClip, std430):
|
|
/// offset 0 : uint count
|
|
/// offset 4 : uint _p0 (pad)
|
|
/// offset 8 : uint _p1 (pad)
|
|
/// offset 12 : uint _p2 (pad)
|
|
/// offset 16 : vec4 planes[0] (16-byte vec4 stride)
|
|
/// ...
|
|
/// offset 16 + i*16 : vec4 planes[i]
|
|
/// stride 144 bytes per slot.
|
|
/// Terrain UBO (std140): int count at 0 (padded to 16), vec4 planes[8] at 16.
|
|
/// </summary>
|
|
public class ClipFrameLayoutTests
|
|
{
|
|
private static float ReadFloat(System.ReadOnlySpan<byte> b, int offset)
|
|
=> System.BitConverter.ToSingle(b.Slice(offset, 4));
|
|
|
|
private static uint ReadUInt(System.ReadOnlySpan<byte> b, int offset)
|
|
=> System.BitConverter.ToUInt32(b.Slice(offset, 4));
|
|
|
|
private static int ReadInt(System.ReadOnlySpan<byte> b, int offset)
|
|
=> System.BitConverter.ToInt32(b.Slice(offset, 4));
|
|
|
|
[Fact]
|
|
public void LayoutConstants_MatchShaderStruct()
|
|
{
|
|
// CellClip: 16 (count + 3 pad uints) + 8*16 (vec4 planes) = 144.
|
|
Assert.Equal(144, ClipFrame.CellClipStrideBytes);
|
|
Assert.Equal(16, ClipFrame.CellClipPlanesOffset);
|
|
Assert.Equal(8, ClipFrame.MaxPlanes);
|
|
Assert.Equal(144, ClipFrame.TerrainUboBytes);
|
|
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
|
|
// The mesh side's binding index moved off ClipFrame at Campaign V slice
|
|
// V11 — the RHI arm addresses it through GpuBindingModel.StorageClipRegions
|
|
// instead of a raw GL binding constant (see ClipFrame's BeginFrame doc
|
|
// comment); the terrain UBO binding is still genuinely shared, so it stays.
|
|
Assert.Equal(2u, GpuBindingModel.StorageClipRegions);
|
|
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoClip_HasExactlyOneSlot_AllZeros_Count0()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
Assert.Equal(1, frame.SlotCount);
|
|
|
|
var bytes = frame.RegionBytesForTest;
|
|
Assert.Equal(ClipFrame.CellClipStrideBytes, bytes.Length); // 144 — exactly one slot
|
|
|
|
// count == 0 ⇒ shader passes every plane (no-clip).
|
|
Assert.Equal(0u, ReadUInt(bytes, 0));
|
|
// Every byte of the reserved no-clip slot is zero.
|
|
foreach (var b in bytes)
|
|
Assert.Equal(0, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void NoClip_TerrainBytes_Count0_AllZeros()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
var t = frame.TerrainBytesForTest;
|
|
Assert.Equal(ClipFrame.TerrainUboBytes, t.Length);
|
|
Assert.Equal(0, ReadInt(t, 0)); // count 0 ⇒ terrain ungated
|
|
foreach (var b in t)
|
|
Assert.Equal(0, b);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendSlot_WritesCountAndPlanes_AtStd430Offsets()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
|
|
// Three distinct planes so each lands at a verifiable offset.
|
|
var p0 = new Vector4(1f, 0f, 0f, 0.5f);
|
|
var p1 = new Vector4(0f, 1f, 0f, 0.25f);
|
|
var p2 = new Vector4(-1f, 0f, 0f, -0.75f);
|
|
|
|
int slot = frame.AppendSlot(new[] { p0, p1, p2 });
|
|
Assert.Equal(1, slot); // slot 0 is the reserved no-clip; this is slot 1
|
|
Assert.Equal(2, frame.SlotCount);
|
|
|
|
var bytes = frame.RegionBytesForTest;
|
|
Assert.Equal(2 * ClipFrame.CellClipStrideBytes, bytes.Length); // two slots now
|
|
|
|
int baseOff = slot * ClipFrame.CellClipStrideBytes; // 144
|
|
|
|
// count == 3 at offset 0 of the slot; the 3 pad uints stay zero.
|
|
Assert.Equal(3u, ReadUInt(bytes, baseOff + 0));
|
|
Assert.Equal(0u, ReadUInt(bytes, baseOff + 4));
|
|
Assert.Equal(0u, ReadUInt(bytes, baseOff + 8));
|
|
Assert.Equal(0u, ReadUInt(bytes, baseOff + 12));
|
|
|
|
// planes[0..2] at offset 16, 32, 48 (vec4 stride 16).
|
|
AssertPlaneAt(bytes, baseOff + 16, p0);
|
|
AssertPlaneAt(bytes, baseOff + 32, p1);
|
|
AssertPlaneAt(bytes, baseOff + 48, p2);
|
|
|
|
// Slot 0 (the reserved no-clip) is untouched: still count 0.
|
|
Assert.Equal(0u, ReadUInt(bytes, 0));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetSlotPlanes_BorrowsTheExactPackedClipRegion()
|
|
{
|
|
using ClipFrame frame = ClipFrame.NoClip();
|
|
Vector4[] expected =
|
|
[
|
|
new(1f, 2f, 3f, 4f),
|
|
new(-5f, 6f, -7f, 8f),
|
|
];
|
|
|
|
int slot = frame.AppendSlot(expected);
|
|
|
|
ReadOnlySpan<Vector4> actual = frame.GetSlotPlanes(checked((uint)slot));
|
|
Assert.Equal(expected.Length, actual.Length);
|
|
Assert.Equal(expected[0], actual[0]);
|
|
Assert.Equal(expected[1], actual[1]);
|
|
Assert.Equal(0, frame.GetSlotPlanes(0).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
int slot = frame.AppendSlot(System.ReadOnlySpan<Vector4>.Empty);
|
|
Assert.Equal(1, slot);
|
|
|
|
var bytes = frame.RegionBytesForTest;
|
|
Assert.Equal(0u, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes)); // count 0
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendSlot_ClampsToEightPlanes()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
var planes = new Vector4[12];
|
|
for (int i = 0; i < planes.Length; i++)
|
|
planes[i] = new Vector4(i, 0f, 0f, 0f);
|
|
|
|
int slot = frame.AppendSlot(planes);
|
|
var bytes = frame.RegionBytesForTest;
|
|
// Only MaxPlanes (8) are recorded in the count.
|
|
Assert.Equal((uint)ClipFrame.MaxPlanes, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes));
|
|
}
|
|
|
|
[Fact]
|
|
public void AppendSlot_FromClipPlaneSet_AxisAlignedSquare_PacksFourPlanes()
|
|
{
|
|
// A unit square in NDC → ClipPlaneSet with 4 convex planes.
|
|
var cv = new CellView();
|
|
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 frame = ClipFrame.NoClip();
|
|
int slot = frame.AppendSlot(cps);
|
|
|
|
var bytes = frame.RegionBytesForTest;
|
|
int baseOff = slot * ClipFrame.CellClipStrideBytes;
|
|
Assert.Equal(4u, ReadUInt(bytes, baseOff + 0));
|
|
|
|
// Each packed plane must match the ClipPlaneSet's plane bit-for-bit.
|
|
for (int i = 0; i < 4; i++)
|
|
AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]);
|
|
}
|
|
|
|
[Fact]
|
|
public void SetTerrainClip_WritesCountAndPlanes_AtStd140Offsets()
|
|
{
|
|
var frame = ClipFrame.NoClip();
|
|
var p0 = new Vector4(0.3f, -0.4f, 0f, 0.1f);
|
|
var p1 = new Vector4(-0.6f, 0.8f, 0f, -0.2f);
|
|
|
|
frame.SetTerrainClip(new[] { p0, p1 });
|
|
|
|
var t = frame.TerrainBytesForTest;
|
|
Assert.Equal(2, ReadInt(t, 0)); // int count at offset 0
|
|
AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 0, p0); // planes start at 16 under std140 too
|
|
AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 16, p1);
|
|
}
|
|
|
|
private static void AssertPlaneAt(System.ReadOnlySpan<byte> bytes, int offset, Vector4 expected)
|
|
{
|
|
Assert.Equal(expected.X, ReadFloat(bytes, offset + 0), 6);
|
|
Assert.Equal(expected.Y, ReadFloat(bytes, offset + 4), 6);
|
|
Assert.Equal(expected.Z, ReadFloat(bytes, offset + 8), 6);
|
|
Assert.Equal(expected.W, ReadFloat(bytes, offset + 12), 6);
|
|
}
|
|
|
|
// ── S3 chunk 4 (§10.2 KEEP): CPU/GPU clip-plane equivalence pins ────────
|
|
//
|
|
// Grepped first (per the chunk 4 contract): ClipPlaneSetTests.cs pins
|
|
// sign correctness (inside >= 0, just-outside < 0 on SOME plane) and
|
|
// AppendSlot_FromClipPlaneSet_AxisAlignedSquare_PacksFourPlanes pins
|
|
// bit-exact GPU packing of ClipPlaneSet's own output — neither asks
|
|
// whether a point ON an edge has ~0 distance under the planes actually
|
|
// read back through each KEEP item's real production accessor. These
|
|
// two tests close that gap, one per KEEP item (exit seals, punch fans).
|
|
|
|
/// <summary>
|
|
/// KEEP item 1 — exit seals: <c>RetailPViewPassExecutor.DrawExitPortalMask</c>
|
|
/// reads its clip planes through <c>WalkFrameDriver.
|
|
/// InteriorFloodViewClipPlanesAt</c> → <see cref="ClipFrame.GetSlotPlanes"/>,
|
|
/// the packed-byte round trip <c>ClipFrameAssembler.Assemble</c> builds
|
|
/// via <see cref="ClipFrame.AppendSlot(ClipPlaneSet)"/>. For a synthetic
|
|
/// quad "view" (a stand-in for one live portal view), every edge
|
|
/// midpoint has near-zero signed distance under the GPU-read-back
|
|
/// planes AND non-negative distance under every plane (retail's
|
|
/// <c>polyClipFinish</c> keeps the whole convex region, never clips a
|
|
/// point that sits ON the boundary) — proving the bytes
|
|
/// <c>InteriorFloodViewClipPlanesAt</c> hands the seal leaf really are
|
|
/// the CPU view polygon's own edge planes, not merely SOME bytes that
|
|
/// happen to round-trip.
|
|
/// </summary>
|
|
[Fact]
|
|
public void GetSlotPlanes_ExitSealPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView()
|
|
{
|
|
Vector2[] verts =
|
|
[
|
|
new(-1f, -0.5f), new(1f, -0.5f), new(1f, 0.5f), new(-1f, 0.5f),
|
|
];
|
|
var cv = new CellView();
|
|
cv.Add(new ViewPolygon(verts));
|
|
ClipPlaneSet cps = ClipPlaneSet.From(cv);
|
|
Assert.Equal(4, cps.Count);
|
|
|
|
// The EXACT production path: ClipFrameAssembler.Assemble packs the
|
|
// ClipPlaneSet into a slot; the seal leaf reads it back through
|
|
// ClipFrame.GetSlotPlanes (WalkFrameDriver.InteriorFloodViewClipPlanesAt).
|
|
var frame = ClipFrame.NoClip();
|
|
int slot = frame.AppendSlot(cps);
|
|
ReadOnlySpan<Vector4> gpuPlanes = frame.GetSlotPlanes(checked((uint)slot));
|
|
Assert.Equal(4, gpuPlanes.Length);
|
|
|
|
AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, gpuPlanes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// KEEP item 2 — punch fans: <c>RetailPViewPassExecutor.DrawWalkPunchFan</c>
|
|
/// reads its clip planes through <c>clipAssembly.OutsideViewSlices
|
|
/// [activeViewIndex].Planes</c> — <see cref="ClipViewSlice"/>'s
|
|
/// <c>Planes</c> field, which <c>ClipFrameAssembler.Assemble</c> sets
|
|
/// DIRECTLY to <c>cps.PlaneArray</c> (no packed-byte round trip at
|
|
/// all). This pin is therefore the more fundamental of the two: it
|
|
/// proves <see cref="ClipPlaneSet.From(CellView)"/>'s own output is
|
|
/// geometrically correct for the polygon it was built from — the
|
|
/// property BOTH keep items ultimately depend on. Same synthetic-view
|
|
/// method as the exit-seal pin, a different (non-axis-aligned)
|
|
/// synthetic polygon so the two pins are not testing the identical
|
|
/// input.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView()
|
|
{
|
|
Vector2[] verts =
|
|
[
|
|
new(0f, 0.6f), new(-0.6f, -0.4f), new(0.5f, -0.5f), new(0.7f, 0.2f),
|
|
];
|
|
var cv = new CellView();
|
|
cv.Add(new ViewPolygon(verts));
|
|
ClipPlaneSet cps = ClipPlaneSet.From(cv);
|
|
Assert.True(cps.Count >= 3);
|
|
|
|
// ClipFrameAssembler.Assemble: `planes = cps.PlaneArray; slices.Add(
|
|
// new ClipViewSlice(slot, AabbOf(poly), planes));` — the SAME array
|
|
// reference DrawWalkPunchFan reads through
|
|
// clipAssembly.OutsideViewSlices[activeViewIndex].Planes.
|
|
var slice = new ClipViewSlice(0, default, cps.PlaneArray);
|
|
|
|
AssertEveryEdgeMidpointLiesOnSomeGpuPlane(verts, slice.Planes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CPU/GPU equivalence: a point on a convex polygon's edge must sit
|
|
/// (a) non-negative under EVERY plane (still inside-or-on the region —
|
|
/// no plane clips it away) and (b) within <paramref name="verts"/>'s
|
|
/// own floating-point tolerance of ZERO under at least one plane (that
|
|
/// edge's own plane). MUTATION: perturbing any one GPU plane's offset
|
|
/// or normal after it left <see cref="ClipPlaneSet.From(CellView)"/>
|
|
/// (a packing/binding bug) breaks either (a) for a nearby edge or (b)
|
|
/// for its own edge, failing the corresponding assertion below.
|
|
/// </summary>
|
|
private static void AssertEveryEdgeMidpointLiesOnSomeGpuPlane(
|
|
Vector2[] verts, ReadOnlySpan<Vector4> gpuPlanes)
|
|
{
|
|
const float eps = 1e-4f;
|
|
for (int i = 0; i < verts.Length; i++)
|
|
{
|
|
Vector2 a = verts[i];
|
|
Vector2 b = verts[(i + 1) % verts.Length];
|
|
Vector2 mid = (a + b) / 2f;
|
|
var clip = new Vector4(mid.X, mid.Y, 0f, 1f);
|
|
|
|
float minAbsDistance = float.PositiveInfinity;
|
|
foreach (Vector4 plane in gpuPlanes)
|
|
{
|
|
float distance = Vector4.Dot(plane, clip);
|
|
Assert.True(
|
|
distance >= -eps,
|
|
$"edge {i} midpoint ({mid.X},{mid.Y}) must be inside-or-on every "
|
|
+ $"GPU plane; plane {plane} gave distance {distance}");
|
|
minAbsDistance = MathF.Min(minAbsDistance, MathF.Abs(distance));
|
|
}
|
|
Assert.True(
|
|
minAbsDistance < eps,
|
|
$"edge {i} midpoint ({mid.X},{mid.Y}) should lie ~on its OWN GPU plane; "
|
|
+ $"the closest plane was only {minAbsDistance} away");
|
|
}
|
|
}
|
|
}
|