acdream/src/AcDream.App/Rendering/ClipFrameAssembler.cs
Erik 7df0b94c9f fix(render): S3 chunk 4 round 2 — draw-gate condition pinned, scissor and terrain-clip stacks deleted, one outside-view slice constructor
Round-1 three-lens FAIL at d60ca4ea0 found: the K2 fix was right in
production but nothing pinned the draw side (restoring the pre-fix gate
left every lane green); a stale "still ends an active scissor" comment
asserted a mechanism the same round had already deleted; the sky.vert
comment claimed retail clips the sky when it draws unclipped; the K6
pin ran through ClipFrameAssembler.Assemble, which has zero production
callers, so it proved nothing about the producer that actually runs.
This round closes L1-L9.

L1 (BLOCKING) — draw-side weather-gate pin. Added
RetailPViewPassExecutorTests.DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired:
reads DrawLandscapeDynamicsPhase's compiled call graph and asserts (a)
the call immediately before DrawWeatherOnce is
WalkFrameDriver.get_WeatherTurnFired and (b) exactly one
brfalse/brfalse.s branch sits between that call and the draw, jumping
forward past it. Three mutations, each shown failing:

  M1 (restore `if (clipAssembly.OutsideViewSlices.Length != 0)`):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.ClipFrameAssembly)

  M2 (drop the gate, unconditional call):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.RetailPViewPassExecutor)

  M3 (invert to `if (!walkDriver.WeatherTurnFired)`):
    Assert.Single() Failure: The collection did not contain any matching items
    Expected:   (predicate expression)
    Collection: [CompiledBranch { Offset = 7, OpCode = brfalse.s, TargetOffset = 17 }, CompiledBranch { Offset = 24, OpCode = brtrue.s, TargetOffset = 33 }]

Corrected WalkFrameDriverTranscriptTests.cs's doc comment (the
Collect_WeatherTurnFiredMatchesThePrintedOcLineExactly block): it pins
the print half and the flag only, and now names the real draw-side pin
instead of falsely claiming to double as one.

L2 (MAJOR) — scissor stack deleted, K4 comment corrected. Deleted
IWorldPassSurface.BeginScissor/EndScissor and their RhiWorldPassSurface
bodies, the scissor call inside ClearInteriorDepth, NdcScissorRect.cs +
NdcScissorRectTests.cs (BeginScissor had zero remaining callers, and
RhiWorldPassSurface.BeginScissor was NdcScissorRect.ToPixels' only
production caller). This left RhiWorldPassSurface's 4th constructor
parameter (IRetailPViewFramebufferSource) and RetailPViewPassExecutor.cs's
RetailPViewFramebufferSize/IRetailPViewFramebufferSource/
SilkRetailPViewFramebufferSource types entirely dead (framebuffer size
was needed only for the scissor's NDC-to-pixel conversion) — removed
them and their one call site in FrameRootComposition.cs. Rewrote
RetailPViewPassExecutor.cs's K4 comment: it no longer asserts a live
scissor mechanism; it states that VulkanGpuPassEncoder's constructor
sets the full-attachment scissor once, at pass begin
(VulkanGpuPassEncoder.cs:87), and nothing narrows it after that. KEPT
per the round-1 verdict: ScissorFallbacks, OutsidePlaneCount,
OutsideViewNdcAabb, HasOutsideView, OutdoorVisible, OutdoorSlot,
ClipViewSlice.NdcAabb (the >8-edge zero-plane slice case the punch fans
still consume) and the VulkanViewportMapping.ScissorToVulkan pass-begin
path (IGpuPassEncoder.SetScissor stays). Also deleted
ClipFrameAssembly.TerrainMode/TerrainScissorNdcAabb and their writers
in ClipFrameAssembler.cs (both Assemble and ReassembleOutsideViewFromWalk);
OutsidePlaneCount's formula reduces to `outsideHasScissorFallback ? 0 :
outsideMaxPlaneCount` without needing the deleted TerrainClipMode
comparison (a scissor-fallback slice can only exist when outdoorVisible
is already true, so the three original cases collapse identically).
Deleted the two "terrain=" / "outMode=" diagnostic reads in
WorldRenderDiagnostics.cs (:345, :409) and the now-vestigial
EmitClipRouteProbe "ubo: n=..." segment that read ClipFrame's deleted
terrain bytes.

DEVIATION from L2's literal text: kept the TerrainClipMode enum type
itself — WorldSceneRenderer.cs's flat-world safety path (explicitly
"out of this chunk's scope" per K4's own round-1 comment) still uses it
for an unrelated "did the flat terrain draw" diagnostic flag, with its
own WorldSceneDiagnosticsController/WorldRenderDiagnostics/test
consumers. Deleting the type would require rewriting files outside
every round's declared file list. Reworded its doc comment to state
this plainly.

L3 (MAJOR, completes K3) — dead TerrainClip UBO deleted from the sky
and terrain shaders. Deleted the block, both gl_ClipDistance loops, and
the gl_PerVertex redeclaration (nothing else needs it) from sky.vert,
terrain_modern.vert, terrain_atmospheric.vert. Recompiled via
tools/compile-shaders.ps1 (glslc via the Vulkan SDK, managed shaderc
fallback also runs) — 24/24 pairs compiled; only sky.vert.spv,
terrain_atmospheric.vert.spv and terrain_modern.vert.spv changed.
Re-pinned VulkanShaderManifestTests.cs's frozen retail-oracle hashes
for sky.vert.spv (7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5)
and terrain_modern.vert.spv (8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c)
with dated comments, matching the existing re-pin convention.

Deleted WorldFrameSectionBinding.BindTerrainClip and
WorldFrameSections.TerrainClip (+ its Reset) with its two callers
(SkyRenderer.Rhi.cs:258, TerrainModernRenderer.Rhi.cs:259). DEVIATION
from L3's literal text: kept the shared Zeroed(...) helper in
WorldPassScope.cs — it is also called by BindSceneLighting and
BindClipRegions, both of which stay; deleting it would have broken
those two live bindings. Deleted IWorldPassSurface.BindTerrainClip
(no-op interface method + RhiWorldPassSurface body) and
WorldScenePassExecutor.cs's two calls to it (:121, :235).

Deleted ClipFrame's _terrainBytes field, TerrainBytes/TerrainBytesForTest
properties, Reset's Array.Clear(_terrainBytes), and the stale
header/K3 comment paragraphs. DEVIATION from L3's literal text: kept
ClipFrame.TerrainUboBytes and ClipFrame.TerrainClipUboBinding —
PortalDepthMaskRenderer.Rhi.cs (:140, :187) is a live production
consumer of both constants for the KEPT exit-seal/punch-fan clip block,
which was never in scope for deletion (portal_depth.vert's own
TerrainClip UBO declaration is the KEEP block, untouched). Reworded
both constants' doc comments (ClipFrame.cs, VulkanPipelineLayouts.cs's
UniformTerrainClip) to say only the portal-depth clip block uses
binding 2 now, and corrected the two GpuBindingModel.cs comments that
explained why terrain-tiling/sky-params bindings are 3/4 by naming "the
terrain clip block". Updated VulkanShaderDescriptorContractTests.cs's
TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet (renamed
TerrainVertexShaderDeclaresOnlySceneLightingInTheUniformSet):
terrain_modern.vert's uniform set is now {SceneLighting} only. Deleted
ClipFrameLayoutTests.NoClip_TerrainBytes_Count0_AllZeros and its K3
comment; corrected the class doc comment and LayoutConstants_MatchShaderStruct's
"terrain UBO"/binding-contract comments to describe the portal-depth
consumer instead.

REQUIRED L3 fact (declared-but-never-bound dynamic uniform binding):
VulkanFrameBindings's constructor seeds EVERY declared uniform binding
(0..UniformBindingCount-1, including binding 2) with the shared dummy
buffer's range before any renderer runs (VulkanFrameBindings.cs:128-130,
`_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange)`
in a loop over every binding) — "Every binding is always bound, whether
a renderer uses it or not... unused ones point at a shared dummy range"
(VulkanFrameBindings.cs:27-31). So after this round, when the sky or
terrain pipeline draws, set 1's shared descriptor layout still declares
binding 2 (portal_depth.vert's own declaration keeps
IsDeclaredUniformBinding(2) true), and its descriptor still points at a
valid (dummy) range from that seed — vkCmdBindDescriptorSets stays
legal even though neither shader statically uses binding 2 any more.
No SPIR-V-side change was needed to keep this legal.

L4 (MAJOR) — one outside-view slice constructor. Extracted
ClipFrameAssembler.AppendOutsideSlice (frame, ViewPolygon,
outsideSlicesList, ref maxPlaneCount, ref hasScissorFallback, ref
scissorFallbacks) — the exact ClipPlaneSet.From/AppendSlot/
ClipViewSlice-construction body — and call it from both Assemble's
outside_view loop and ReassembleOutsideViewFromWalk's outside_view
loop. Rewrote ClipFrameLayoutTests's K6 pin
(ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView):
builds a synthetic WalkPortalView from pixel-space points (the
WalkCopyViewTests pattern) via a trivial IWalkRayCaster, runs
BeginWalkFrame(frame, outdoorRoot: false) then
ReassembleOutsideViewFromWalk(assembly, walkView, 640, 480), and reads
assembly.OutsideViewSlices[0].Planes — the exact production pair
RetailPViewPassExecutor.cs's BeginWalkFrame call and
RetailPViewRenderer.cs's ReassembleOutsideViewFromWalk call make.
Mutation (perturbed planes[0].W inside the shared helper, right after
`planes = cps.PlaneArray;`):

  edge 0 midpoint (-0.3,0.10000001) should lie ~on its OWN GPU plane; the closest plane was only 0.5251073 away

The pin's own source contains no `Assemble(` call — grep-checked by
extracting the method body and searching it for the literal text; no
match.

L5 — shader comments corrected. sky.vert/terrain_modern.vert/
terrain_atmospheric.vert now state retail draws the sky once
(LScape::draw @0x00506330 -> GameSky::Draw(sky,0) @0x0050633c, before
draw_check_blocks) and the landscape is view-culled per cell, never
GPU-clipped (RenderDeviceD3D::DrawBlock @0x005a17c0). The "Phase W
Stage 4"/"U.3 default" clip narratives are deleted along with the
blocks they described.

L6 — grep sweep. `grep -rn "<token>" src tests docs/architecture`
returns nothing for SetTerrainClip, BeginDoorwayScissor,
BindTerrainClip, TerrainBytes, "active scissor", and NdcScissorRect —
confirmed after this commit (docs/plans and docs/research keep the
historical record, untouched). TerrainClipMode is the one deliberate
exception (see the L2 deviation note above); every one of its 9
remaining hits is a live, non-stale reference (the enum declaration,
WorldSceneRenderer's flat-path local, or their diagnostic/test
plumbing), not a stale mention of a deleted mechanism.

L7 — Issue130DoorwayStripTests. Deleted AnySliceAdmitsScissor,
worstScissorGapPx and its PIN 1 assertion, the header's scissor
sentences, and the scissorGap half of MeasureTopEdgeGap (dropped the
fbW parameter it alone needed). Rewrote the header: the scissor
mechanism is retired (the sky and landscape draw unclipped; aperture
exactness comes from the depth clear, the exit seals and the interior
repaint). The remaining plane-gap half (the canary PIN,
`worstPlaneGapPx <= 1.2f`) still pins something production reads:
AnySliceAdmitsPlanes walks slice.Planes from the SAME ProjectToClip ->
ClipToRegion -> ClipPlaneSet.From pipeline
RetailPViewPassExecutor.DrawWalkPunchFan reads through
clipAssembly.OutsideViewSlices[activeViewIndex].Planes — so the test
was kept, not deleted.

L8 — DrawWalkSky loop-shape pin. Added
DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch (same
backward-branch-span shape as K1's DrawWeatherOnce pin), plus a note in
both pins that CompiledCallGraph.ReadBranches does not decode a
compiled switch jump table, but no C# loop construct compiles to one.
Mutation (wrapped the RenderSky call in `for (int i = 0; i < 2; i++)`):

  Assert.DoesNotContain() Failure: Filter matched in collection
  Collection: [..., CompiledBranch { Offset = 20, OpCode = brtrue.s, TargetOffset = 25 }, CompiledBranch { Offset = 23, OpCode = br.s, TargetOffset = 66 }, CompiledBranch { Offset = 72, OpCode = blt.s, TargetOffset = 13 }, CompiledBranch { Offset = 80, OpCode = brfalse.s, TargetOffset = 116 }, CompiledBranch { Offset = 88, OpCode = brfalse.s, TargetOffset = 116 }]

(the Offset=72 -> TargetOffset=13 entry is the injected loop's backward
branch spanning the call).

L9 — this message.

Gates: dotnet build 0 warnings / 0 errors. Hermetic filter
(Lane!=InstalledDat&...&Status!=KnownFailure): 6827 passed, 0 failed.
InstalledDat lane against a real DAT directory: 244 passed, 4 failed —
exactly the four known failures (LayoutImporterMediaBearingChildSweepTests
+ LayoutImporterInvisibleSweepTests, both #383; TowerAscentReplayTests,
KnownFailure; WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff,
#458 KnownFailure). No register row added (every deletion here removes
an acdream-only rule; the KEEP items already had their equivalence
pins from round 1). No new flag/probe.

OWED: the lead's ACDREAM_DEVTOOLS=1 four-pose visual self-gate against
the running client is not run by this agent — CLAUDE.md and this
task's own instructions forbid launching the graphical client from
here; that visual/validation-layer pass remains the user's to run
before this round is accepted.

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

576 lines
23 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.

// ClipFrameAssembler.cs
//
// Retail view assembly policy. The production frame walk supplies its
// outside_view directly; the legacy PortalVisibilityBuilder overload remains
// only for isolated research/replay tests. Each visible polygon is packed as
// an individual GPU clip slot.
//
// outside_view landscape slices
// reverse cell_draw_list exit masks
// reverse cell_draw_list EnvCell shells
// reverse cell_draw_list object lists
//
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
// passes that need that fallback. Empty regions are omitted entirely.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// S3 chunk 4 fix round 2 (L2): retired from the walk's own clip assembly —
/// <see cref="ClipFrameAssembly"/> no longer tracks a terrain clip mode at
/// all, because the walk draws the sky, terrain and weather unclipped, one
/// call each, matching retail (<c>LScape::draw</c> never installs a view
/// before any of the three). The type stays only because
/// <see cref="WorldSceneRenderer"/>'s separate flat-world safety path still
/// uses it for its own unrelated "did the flat terrain draw this frame"
/// diagnostic flag (<c>Planes</c> = drew, <c>Skip</c> = the PView walk ran
/// instead; that path never produces <c>Scissor</c>).
/// </summary>
public enum TerrainClipMode
{
/// <summary>All outside_view slices have convex plane clips.</summary>
Planes,
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
Scissor,
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
Skip,
}
/// <summary>
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
/// for passes that cannot write gl_ClipDistance and must use scissor.
/// </summary>
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
/// <summary>
/// Populated clip buffers plus routing data consumed by the render walk.
/// </summary>
public sealed class ClipFrameAssembly
{
public ClipFrame Frame { get; private set; } = null!;
/// <summary>First drawable slice slot per visible cell. Compatibility map
/// for renderer APIs that can accept only one slot at a time.</summary>
public Dictionary<uint, int> CellIdToSlot { get; } = new();
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
public Dictionary<uint, int[]> CellIdToViewSlots { get; } = new();
/// <summary>Full retail portal_view slices per visible cell.</summary>
public Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; } = new();
/// <summary>Full retail outside_view slices.</summary>
public ClipViewSlice[] OutsideViewSlices { get; private set; } = System.Array.Empty<ClipViewSlice>();
public int OutdoorSlot { get; internal set; }
public bool OutdoorVisible { get; internal set; }
public bool HasOutsideView { get; internal set; }
public Vector4 OutsideViewNdcAabb { get; internal set; }
// Probe data.
public int OutsidePlaneCount { get; internal set; }
public Dictionary<uint, int> PerCellPlaneCounts { get; } = new();
public int ScissorFallbacks { get; internal set; }
// The assembly is frame-scoped. An owner that passes it back to Assemble may reuse the
// dictionaries, per-cell arrays, and construction list after the prior frame is consumed.
// Exact-length pools keep the public array API honest: Length is always the live slice count.
private readonly Dictionary<int, Stack<ClipViewSlice[]>> _sliceArraysByLength = new();
private readonly Dictionary<int, Stack<int[]>> _slotArraysByLength = new();
internal List<ClipViewSlice> SliceScratch { get; } = new();
internal int SliceArrayAllocationCount { get; private set; }
internal int SlotArrayAllocationCount { get; private set; }
internal const int MaxRetainedSliceItems = 4096;
internal const int MaxRetainedSlotItems = 8192;
internal const int MaxRetainedArraysPerPool = 128;
internal int RetainedSliceItems { get; private set; }
internal int RetainedSlotItems { get; private set; }
internal int RetainedSliceArrays { get; private set; }
internal int RetainedSlotArrays { get; private set; }
internal void Reset(ClipFrame frame)
{
Frame = frame;
foreach (ClipViewSlice[] slices in CellIdToViewSlices.Values)
ReturnSlices(slices);
foreach (int[] slots in CellIdToViewSlots.Values)
ReturnSlots(slots);
if (OutsideViewSlices.Length != 0)
ReturnSlices(OutsideViewSlices);
CellIdToSlot.Clear();
CellIdToViewSlots.Clear();
CellIdToViewSlices.Clear();
PerCellPlaneCounts.Clear();
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
SliceScratch.Clear();
}
internal ClipViewSlice[] CopySlices(List<ClipViewSlice> source)
{
if (source.Count == 0)
return System.Array.Empty<ClipViewSlice>();
ClipViewSlice[] result = RentSlices(source.Count);
source.CopyTo(result, 0);
return result;
}
internal int[] CopySlots(ClipViewSlice[] slices)
{
if (slices.Length == 0)
return System.Array.Empty<int>();
int[] result = RentSlots(slices.Length);
for (int i = 0; i < slices.Length; i++)
result[i] = slices[i].Slot;
return result;
}
internal void SetOutsideViewSlices(ClipViewSlice[] slices) => OutsideViewSlices = slices;
/// <summary>FW4 slice 1: returns the outside-view slice array to the
/// pool ahead of a same-frame reassembly from the walk's own views
/// (<see cref="ClipFrameAssembler.ReassembleOutsideViewFromWalk"/>) —
/// without this the replaced array would leak from the slice pool for
/// the frame.</summary>
internal void ReturnOutsideViewSlicesForReassembly()
{
if (OutsideViewSlices.Length != 0)
ReturnSlices(OutsideViewSlices);
OutsideViewSlices = System.Array.Empty<ClipViewSlice>();
}
private ClipViewSlice[] RentSlices(int length)
{
if (_sliceArraysByLength.TryGetValue(length, out Stack<ClipViewSlice[]>? pool)
&& pool.Count != 0)
{
ClipViewSlice[] result = pool.Pop();
RetainedSliceItems -= result.Length;
RetainedSliceArrays--;
if (pool.Count == 0)
_sliceArraysByLength.Remove(length);
return result;
}
SliceArrayAllocationCount++;
return new ClipViewSlice[length];
}
private int[] RentSlots(int length)
{
if (_slotArraysByLength.TryGetValue(length, out Stack<int[]>? pool)
&& pool.Count != 0)
{
int[] result = pool.Pop();
RetainedSlotItems -= result.Length;
RetainedSlotArrays--;
if (pool.Count == 0)
_slotArraysByLength.Remove(length);
return result;
}
SlotArrayAllocationCount++;
return new int[length];
}
private void ReturnSlices(ClipViewSlice[] array)
{
// ClipViewSlice contains a Vector4[] reference. Clear before either retaining or dropping
// the array so a bounded cache cannot accidentally extend plane-payload lifetimes.
System.Array.Clear(array);
if (array.Length > MaxRetainedSliceItems)
return;
while (RetainedSliceItems + array.Length > MaxRetainedSliceItems
|| RetainedSliceArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSliceArray())
break;
}
if (!_sliceArraysByLength.TryGetValue(array.Length, out Stack<ClipViewSlice[]>? pool))
{
pool = new Stack<ClipViewSlice[]>();
_sliceArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSliceItems += array.Length;
RetainedSliceArrays++;
}
private void ReturnSlots(int[] array)
{
if (array.Length > MaxRetainedSlotItems)
return;
while (RetainedSlotItems + array.Length > MaxRetainedSlotItems
|| RetainedSlotArrays >= MaxRetainedArraysPerPool)
{
if (!EvictOneSlotArray())
break;
}
if (!_slotArraysByLength.TryGetValue(array.Length, out Stack<int[]>? pool))
{
pool = new Stack<int[]>();
_slotArraysByLength.Add(array.Length, pool);
}
pool.Push(array);
RetainedSlotItems += array.Length;
RetainedSlotArrays++;
}
private bool EvictOneSliceArray()
{
int selectedLength = -1;
foreach ((int length, Stack<ClipViewSlice[]> pool) in _sliceArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<ClipViewSlice[]> selected = _sliceArraysByLength[selectedLength];
ClipViewSlice[] evicted = selected.Pop();
RetainedSliceItems -= evicted.Length;
RetainedSliceArrays--;
if (selected.Count == 0)
_sliceArraysByLength.Remove(selectedLength);
return true;
}
private bool EvictOneSlotArray()
{
int selectedLength = -1;
foreach ((int length, Stack<int[]> pool) in _slotArraysByLength)
{
if (pool.Count != 0 && length > selectedLength)
selectedLength = length;
}
if (selectedLength < 0)
return false;
Stack<int[]> selected = _slotArraysByLength[selectedLength];
int[] evicted = selected.Pop();
RetainedSlotItems -= evicted.Length;
RetainedSlotArrays--;
if (selected.Count == 0)
_slotArraysByLength.Remove(selectedLength);
return true;
}
}
public static class ClipFrameAssembler
{
/// <summary>
/// Starts one production walk assembly without constructing a parallel
/// PortalVisibilityFrame. Outdoor roots begin with retail's full-screen
/// default view; interior roots begin empty and are populated from
/// <see cref="Walk.RetailFrameWalk.InteriorOutsideView"/> after Collect.
/// </summary>
public static ClipFrameAssembly BeginWalkFrame(
ClipFrame frame,
bool outdoorRoot,
ClipFrameAssembly? reuseAssembly = null)
{
System.ArgumentNullException.ThrowIfNull(frame);
frame.Reset();
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
assembly.Reset(frame);
if (outdoorRoot)
{
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
var fullScreen = new Vector4(-1f, -1f, 1f, 1f);
slices.Add(new ClipViewSlice(0, fullScreen, System.Array.Empty<Vector4>()));
assembly.SetOutsideViewSlices(assembly.CopySlices(slices));
assembly.OutdoorSlot = 0;
assembly.OutdoorVisible = true;
assembly.HasOutsideView = true;
assembly.OutsideViewNdcAabb = fullScreen;
assembly.OutsidePlaneCount = 0;
assembly.ScissorFallbacks = 1;
}
else
{
assembly.OutdoorSlot = 0;
assembly.OutdoorVisible = false;
assembly.HasOutsideView = false;
assembly.OutsideViewNdcAabb = Vector4.Zero;
assembly.OutsidePlaneCount = 0;
assembly.ScissorFallbacks = 0;
}
return assembly;
}
public static ClipFrameAssembly Assemble(
ClipFrame frame,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly? reuseAssembly = null)
{
System.ArgumentNullException.ThrowIfNull(frame);
System.ArgumentNullException.ThrowIfNull(pvFrame);
frame.Reset();
ClipFrameAssembly assembly = reuseAssembly ?? new ClipFrameAssembly();
assembly.Reset(frame);
Dictionary<uint, int> cellIdToSlot = assembly.CellIdToSlot;
Dictionary<uint, int[]> cellIdToViewSlots = assembly.CellIdToViewSlots;
Dictionary<uint, ClipViewSlice[]> cellIdToViewSlices = assembly.CellIdToViewSlices;
Dictionary<uint, int> perCellPlaneCounts = assembly.PerCellPlaneCounts;
int scissorFallbacks = 0;
foreach (uint cellId in pvFrame.OrderedVisibleCells)
{
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
continue;
List<ClipViewSlice> slices = assembly.SliceScratch;
slices.Clear();
int maxPlaneCount = 0;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
scissorFallbacks++;
}
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
}
if (slices.Count == 0)
continue;
ClipViewSlice[] sliceArray = assembly.CopySlices(slices);
cellIdToViewSlices[cellId] = sliceArray;
cellIdToViewSlots[cellId] = assembly.CopySlots(sliceArray);
cellIdToSlot[cellId] = sliceArray[0].Slot;
perCellPlaneCounts[cellId] = maxPlaneCount;
}
List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
outsideSlicesList.Clear();
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
foreach (var poly in pvFrame.OutsideView.Polygons)
{
AppendOutsideSlice(
frame,
poly,
outsideSlicesList,
ref outsideMaxPlaneCount,
ref outsideHasScissorFallback,
ref scissorFallbacks);
}
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero;
assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible;
assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
// S3 chunk 4 fix round 2 (L2): equivalent to the deleted
// terrainMode == TerrainClipMode.Planes gate without needing
// TerrainClipMode at all — a scissor-fallback slice can only exist
// when outdoorVisible is already true (both branches above append a
// slice), so this reduces to the same three cases (no slices -> 0;
// any scissor fallback -> 0; all-planes -> outsideMaxPlaneCount).
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
assembly.ScissorFallbacks = scissorFallbacks;
return assembly;
}
/// <summary>
/// Campaign FW4 slice 1 — the interior root's outside-view cutover.
/// Replaces the assembly's outside-view block (slices, terrain mode,
/// scissor/NDC bounds, plane count) with slices derived from THE WALK'S
/// OWN <c>outside_view</c> (<see cref="Walk.RetailFrameWalk.InteriorOutsideView"/>),
/// filled by the walk's <c>ConstructView</c> during Collect. Retail has
/// exactly ONE visibility structure per frame: <c>LScape::draw</c>'s
/// terrain clip, the punch fans' <c>building_view</c> planes, and the
/// landscape turn's view count all read the views the walk itself
/// installed. Feeding these from the old <c>PortalVisibilityBuilder</c>
/// assembly let the two systems desynchronize on camera-transition
/// boundary frames — terrain splashed through stale/fat old-apparatus
/// exit views over interior pixels the walk's flood never repainted
/// (the FW3 visual-gate stairwell/grass flash, probe-pinned
/// 2026-08-30), and punch fans indexed the old slice array with walk
/// view indices.
///
/// The walk stores view vertices as PIXEL screen points
/// (<c>copy_view</c>'s post-divide viewport coordinates, origin
/// top-left, +Y down — <see cref="Walk.WalkScreenClip.TransformToScreen"/>:
/// x=(W/2)(x_c+w), y=(H/2)(wy_c)); inverting that mapping yields the
/// standard NDC this assembler's <see cref="ViewPolygon"/>s already use
/// (ndcX = 2x/W 1, ndcY = 1 2y/H). Winding is normalized inside
/// <see cref="ClipPlaneSet.From(in ViewPolygon)"/>, and the closing
/// duplicate vertex <c>copy_view</c> stores is merged there too.
///
/// Must run AFTER the walk's Collect and BEFORE
/// <c>PrepareClipFrame</c> publishes the clip regions — appended slots
/// join the same single publication.
/// </summary>
public static void ReassembleOutsideViewFromWalk(
ClipFrameAssembly assembly,
Walk.WalkPortalView outsideView,
float viewportWidth,
float viewportHeight)
{
System.ArgumentNullException.ThrowIfNull(assembly);
System.ArgumentNullException.ThrowIfNull(outsideView);
if (viewportWidth <= 0f || viewportHeight <= 0f)
{
throw new System.ArgumentOutOfRangeException(
nameof(viewportWidth),
$"viewport {viewportWidth}x{viewportHeight} — the walk projected its "
+ "views through a real viewport; a non-positive extent here means the "
+ "caller handed a different frame's context (fail-loud rule).");
}
ClipFrame frame = assembly.Frame;
int viewCount = outsideView.ViewCount;
var polys = outsideView.View.Polys;
var pool = outsideView.View.Vertices;
if (polys.Count < viewCount)
{
throw new System.InvalidOperationException(
$"walk outside_view holds {polys.Count} polys for ViewCount={viewCount} — "
+ "the view set's append bookkeeping desynchronized (fail-loud rule).");
}
assembly.ReturnOutsideViewSlicesForReassembly();
List<ClipViewSlice> outsideSlicesList = assembly.SliceScratch;
outsideSlicesList.Clear();
int outsideMaxPlaneCount = 0;
bool outsideHasScissorFallback = false;
int scissorFallbacks = assembly.ScissorFallbacks;
float unionMinX = float.MaxValue, unionMinY = float.MaxValue;
float unionMaxX = float.MinValue, unionMaxY = float.MinValue;
for (int v = 0; v < viewCount; v++)
{
Walk.WalkViewPoly walkPoly = polys[v];
var vertices = new Vector2[walkPoly.VertexCount];
for (int k = 0; k < walkPoly.VertexCount; k++)
{
Vector2 px = pool[walkPoly.VertexIndex + k].Point;
vertices[k] = new Vector2(
px.X / viewportWidth * 2f - 1f,
1f - px.Y / viewportHeight * 2f);
}
var poly = new ViewPolygon(vertices);
if (!poly.IsEmpty)
{
if (poly.MinX < unionMinX) unionMinX = poly.MinX;
if (poly.MinY < unionMinY) unionMinY = poly.MinY;
if (poly.MaxX > unionMaxX) unionMaxX = poly.MaxX;
if (poly.MaxY > unionMaxY) unionMaxY = poly.MaxY;
}
AppendOutsideSlice(
frame,
poly,
outsideSlicesList,
ref outsideMaxPlaneCount,
ref outsideHasScissorFallback,
ref scissorFallbacks);
}
ClipViewSlice[] outsideViewSlices = assembly.CopySlices(outsideSlicesList);
bool outdoorVisible = outsideViewSlices.Length > 0;
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
Vector4 outsideViewNdcAabb = outdoorVisible
? new Vector4(unionMinX, unionMinY, unionMaxX, unionMaxY)
: Vector4.Zero;
assembly.SetOutsideViewSlices(outsideViewSlices);
assembly.OutdoorSlot = outdoorSlot;
assembly.OutdoorVisible = outdoorVisible;
assembly.HasOutsideView = outdoorVisible;
assembly.OutsideViewNdcAabb = outsideViewNdcAabb;
// S3 chunk 4 fix round 2 (L2): see Assemble's matching comment — the
// same reduction applies here.
assembly.OutsidePlaneCount = outsideHasScissorFallback ? 0 : outsideMaxPlaneCount;
assembly.ScissorFallbacks = scissorFallbacks;
}
/// <summary>
/// S3 chunk 4 fix round 2 (L4): the ONE place a single outside_view
/// <see cref="ViewPolygon"/> becomes an appended clip slot plus its
/// <see cref="ClipViewSlice"/> — both <see cref="Assemble"/>'s
/// outside_view loop and <see cref="ReassembleOutsideViewFromWalk"/>'s
/// outside_view loop call this instead of each carrying its own copy of
/// the plane/scissor-fallback bookkeeping (a prior round's three-lens
/// review found the duplication let a CPU/GPU equivalence pin exercise
/// one copy while production ran the other — see <c>ClipFrameLayoutTests</c>'
/// punch-fan pin). A polygon entirely outside every plane
/// (<see cref="ClipPlaneSet.IsNothingVisible"/>) appends nothing and
/// returns false; the caller's own loop simply moves to the next
/// polygon either way, so the return value only matters to a caller that
/// needs to know.
/// </summary>
private static bool AppendOutsideSlice(
ClipFrame frame,
in ViewPolygon poly,
List<ClipViewSlice> outsideSlicesList,
ref int maxPlaneCount,
ref bool hasScissorFallback,
ref int scissorFallbacks)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
return false;
int slot;
Vector4[] planes;
if (cps.Count > 0)
{
planes = cps.PlaneArray;
slot = frame.AppendSlot(planes);
if (cps.Count > maxPlaneCount)
maxPlaneCount = cps.Count;
}
else
{
planes = System.Array.Empty<Vector4>();
slot = 0;
hasScissorFallback = true;
scissorFallbacks++;
}
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
return true;
}
private static Vector4 AabbOf(ViewPolygon poly) =>
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
}