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>
278 lines
12 KiB
C#
278 lines
12 KiB
C#
using System.Diagnostics;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Walk;
|
|
using AcDream.Core.Vfx;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>Campaign FW3.2b-2 (step 1): the walk driver's leaf draws over
|
|
/// this executor's renderers. Same leaf mechanics as the packed slice
|
|
/// methods — the cutover changes ORDER only.</summary>
|
|
internal sealed partial class RetailPViewPassExecutor
|
|
{
|
|
/// <summary>The walk's single sky turn: retail draws GameSky once
|
|
/// inside <c>LScape::draw</c>, clipped by the active views; looping
|
|
/// today's per-slice scissor + terrain-clip block reproduces today's
|
|
/// pixels while the driver sees ONE turn.</summary>
|
|
internal void DrawWalkSky(
|
|
RetailPViewFrameInput frame, ClipFrameAssembly clipAssembly)
|
|
{
|
|
if (!frame.RenderSky)
|
|
return;
|
|
|
|
// FW4 slice 6: an INTERIOR root draws the sky ONCE, unclipped —
|
|
// the same LScape::draw rule as the terrain turn below (view-culled
|
|
// landscape content is never view-clipped; the clear + seals +
|
|
// interior repaint own aperture exactness). The former per-slice
|
|
// scissor left stale sky color wherever the authored exit-portal
|
|
// bands were narrower than the real opening.
|
|
if (!frame.RootCell.IsOutdoorNode)
|
|
{
|
|
_sky?.RenderSky(
|
|
frame.Camera,
|
|
frame.CameraWorldPosition,
|
|
frame.DayFraction,
|
|
frame.ActiveDayGroup,
|
|
frame.SkyKeyframe,
|
|
frame.EnvironOverrideActive);
|
|
if (_particles is not null && _particleRenderer is not null)
|
|
{
|
|
_particleRenderer.Draw(
|
|
frame.Camera,
|
|
frame.CameraWorldPosition,
|
|
ParticleRenderPass.SkyPreScene);
|
|
}
|
|
return;
|
|
}
|
|
|
|
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
|
|
for (int sliceIndex = 0; sliceIndex < slices.Length; sliceIndex++)
|
|
{
|
|
ClipViewSlice slice = slices[sliceIndex];
|
|
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
|
|
_surface.BindTerrainClip();
|
|
EnableClipDistances();
|
|
_sky?.RenderSky(
|
|
frame.Camera,
|
|
frame.CameraWorldPosition,
|
|
frame.DayFraction,
|
|
frame.ActiveDayGroup,
|
|
frame.SkyKeyframe,
|
|
frame.EnvironOverrideActive);
|
|
DisableClipDistances();
|
|
if (_particles is not null && _particleRenderer is not null)
|
|
{
|
|
_particleRenderer.Draw(
|
|
frame.Camera,
|
|
frame.CameraWorldPosition,
|
|
ParticleRenderPass.SkyPreScene);
|
|
}
|
|
if (scissor)
|
|
_surface.EndScissor();
|
|
}
|
|
}
|
|
|
|
/// <summary>S3 chunk 3 (§9.2 B1): the walk's per-land-cell terrain
|
|
/// turn — retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120,
|
|
/// batched (S3 §9.2 B2, fix round 1 F2 — a batch may span several
|
|
/// landblocks). UNCLIPPED for BOTH root kinds — retail never
|
|
/// view-clips terrain (<c>LScape::draw</c> draws whole blocks; the
|
|
/// walk's own <c>CellInView</c> admission already decided which cells
|
|
/// reach here, and the exit-seal/interior-repaint turn owns aperture
|
|
/// exactness afterward, S3 §9.1 R4/R5), so unlike the whole-stage
|
|
/// turn this superseded, there is no scissor, no terrain clip, and no
|
|
/// per-slice loop — one call per (already order-merged) batch.
|
|
///
|
|
/// <para>S3 chunk 3 fix round 1 (F3): times THIS ONE batch with a raw
|
|
/// <c>Stopwatch.GetTimestamp()</c> delta (no allocation, no per-batch
|
|
/// stopwatch Restart/Stop) and accumulates it into
|
|
/// <see cref="TerrainDrawDiagnosticsController.AccumulateWalkBatch"/> —
|
|
/// <see cref="RetailPViewRenderer.DrawWalkDrivenStatics"/> pushes the
|
|
/// frame's single resulting sample once, after the whole walk replay
|
|
/// finishes.</para></summary>
|
|
internal void DrawWalkLandCellBatch(
|
|
RetailPViewFrameInput frame,
|
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells)
|
|
{
|
|
long start = Stopwatch.GetTimestamp();
|
|
_terrain?.DrawLandCells(frame.ViewProjection, cells);
|
|
_terrainDiagnostics.AccumulateWalkBatch(Stopwatch.GetTimestamp() - start);
|
|
}
|
|
|
|
/// <summary>S3 chunk 3 fix round 1 (F3): pushes THIS FRAME'S single
|
|
/// accumulated terrain-timing sample (from every
|
|
/// <see cref="DrawWalkLandCellBatch"/> call this frame) and publishes
|
|
/// the periodic diagnostic if the cadence is due — the walk path's
|
|
/// analogue of the deleted whole-stage <c>DrawWalkTerrainSlice</c>
|
|
/// leaf's own Begin()/Complete() bracket. Called exactly once, at the
|
|
/// end of the walk replay (<see cref="RetailPViewRenderer.DrawWalkDrivenStatics"/>).</summary>
|
|
internal void CompleteWalkTerrainFrame() => _terrainDiagnostics.CompleteWalkFrame();
|
|
|
|
/// <summary>S3 chunk 3 fix round 1 (F2): the production
|
|
/// <c>IWalkFrameLeafRenderer.HasRenderableEmittersInCell</c> wiring —
|
|
/// both <see cref="DrawLandscapeStaticParticles"/> and
|
|
/// <see cref="DrawCellParticles"/> draw through
|
|
/// <c>ParticleRenderPass.Scene</c>, so this asks that SAME pass.</summary>
|
|
internal bool HasWalkRenderableEmittersInCell(uint cellId) =>
|
|
_particles?.HasRenderableEmittersInCell(ParticleRenderPass.Scene, cellId) ?? false;
|
|
|
|
/// <summary>The walk's punch-fan turn — <c>DrawPortalPolyInternal</c>
|
|
/// @0x0059bc90's far-Z punch through <c>PortalDepthMaskRenderer</c>,
|
|
/// clipped by the pinned view's slice planes (retail
|
|
/// <c>building_view</c> @0x0059f3bf). FW3.3: fans draw at the dat
|
|
/// aperture verbatim (the ShellDrawLiftZ retirement).</summary>
|
|
internal void DrawWalkPunchFan(
|
|
RetailPViewFrameInput frame,
|
|
ClipFrameAssembly clipAssembly,
|
|
WalkPolygon worldPolygon,
|
|
int activeViewIndex)
|
|
{
|
|
if (_portalDepthMask is null)
|
|
return;
|
|
Vector3[] vertices = worldPolygon.Vertices;
|
|
if (vertices.Length < 3)
|
|
return;
|
|
ReadOnlySpan<ClipViewSlice> slices = clipAssembly.OutsideViewSlices;
|
|
ReadOnlySpan<Vector4> planes = (uint)activeViewIndex < (uint)slices.Length
|
|
? slices[activeViewIndex].Planes
|
|
: default;
|
|
|
|
Span<Vector3> world = stackalloc Vector3[32];
|
|
int count = Math.Min(vertices.Length, world.Length);
|
|
for (int vertex = 0; vertex < count; vertex++)
|
|
world[vertex] = vertices[vertex];
|
|
_portalDepthMask.DrawDepthFan(
|
|
world[..count],
|
|
frame.ViewProjection,
|
|
planes,
|
|
forceFarZ: true);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign FW stage FW3.2b-2 (step 1, additive): the production
|
|
/// <see cref="IWalkFrameLeafRenderer"/> over this executor's leaf renderers.
|
|
/// Constructed per frame by the FW3.2b-2 rooting (plan §FW3 "FW3.2b-2 —
|
|
/// the production rooting"); nothing invokes it until the static cutover
|
|
/// flips. Each member maps a <see cref="WalkFrameDriver"/> turn onto the
|
|
/// SAME leaf calls the packed path uses today, so the cutover changes
|
|
/// ORDER, never the leaf mechanics:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><see cref="DrawSky"/> → the sky block of <c>DrawLandscapeSlice</c>,
|
|
/// looped over the active slices internally (retail draws GameSky once
|
|
/// inside <c>LScape::draw</c> clipped by the active views; the per-slice
|
|
/// scissor+clip here reproduces today's pixels while the driver still sees
|
|
/// ONE sky turn).</item>
|
|
/// <item><see cref="DrawLandCellBatch"/> → S3 chunk 3's per-land-cell
|
|
/// terrain turn, UNCLIPPED for both root kinds (retail never view-clips
|
|
/// terrain) — no scissor, no terrain clip, one call per order-merged
|
|
/// batch.</item>
|
|
/// <item><see cref="DrawCellShell"/> → <c>EnvCellRenderer</c> opaque +
|
|
/// transparent-ordered for ONE cell (retail <c>DrawEnvCell</c>
|
|
/// @0x0059f170 draws per cell at its flood turn).</item>
|
|
/// <item><see cref="DrawPunchFan"/> → <c>PortalDepthMaskRenderer.DrawDepthFan</c>
|
|
/// with <c>forceFarZ</c>, clipped by the pinned view's slice planes
|
|
/// (retail <c>building_view</c> @0x0059f3bf); FW3.3 draws fans at the dat
|
|
/// aperture verbatim (the ShellDrawLiftZ retirement).</item>
|
|
/// <item><see cref="FlushLandscape"/>/<see cref="ClearInteriorDepth"/>/
|
|
/// <see cref="DrawExitSeals"/> → caller-supplied delegates (the renderer
|
|
/// owns the pass scope, the pre-clear dynamics phase, and the root-flood
|
|
/// seal iteration; the adapter only provides the turns).
|
|
/// <see cref="DrawExitSeals"/> returns the submitted seal-polygon count so
|
|
/// the driver can re-arm its persistent <c>PortalsDrawnCount</c> (S3 §8.2
|
|
/// B2).</item>
|
|
/// <item><see cref="AlphaBarrier"/> → <c>FlushLandscapeAlpha</c>, retail's
|
|
/// flush-all <c>FlushAlphaList(0f)</c> @0x0059f30b.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
internal sealed class WalkProductionLeafRenderer : IWalkFrameLeafRenderer
|
|
{
|
|
private RetailPViewPassExecutor _passes = null!;
|
|
private RetailPViewFrameInput _frame = null!;
|
|
private ClipFrameAssembly _clipAssembly = null!;
|
|
private Action _flushLandscape = null!;
|
|
private Action _clearInteriorDepth = null!;
|
|
private Func<int> _drawExitSeals = null!;
|
|
private readonly HashSet<uint> _singleCellScratch = new();
|
|
private readonly List<uint> _singleCellListScratch = new();
|
|
|
|
internal WalkProductionLeafRenderer(
|
|
RetailPViewPassExecutor passes,
|
|
RetailPViewFrameInput frame,
|
|
ClipFrameAssembly clipAssembly,
|
|
Action flushLandscape,
|
|
Action clearInteriorDepth,
|
|
Func<int> drawExitSeals)
|
|
=> Reset(passes, frame, clipAssembly, flushLandscape, clearInteriorDepth, drawExitSeals);
|
|
|
|
/// <summary>
|
|
/// FW6 allocation closeout: bind the retained leaf and its retained
|
|
/// one-cell collections to the current frame. Replay is synchronous, so
|
|
/// no frame may outlive these references.
|
|
/// </summary>
|
|
internal void Reset(
|
|
RetailPViewPassExecutor passes,
|
|
RetailPViewFrameInput frame,
|
|
ClipFrameAssembly clipAssembly,
|
|
Action flushLandscape,
|
|
Action clearInteriorDepth,
|
|
Func<int> drawExitSeals)
|
|
{
|
|
_passes = passes ?? throw new ArgumentNullException(nameof(passes));
|
|
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
|
|
_clipAssembly = clipAssembly
|
|
?? throw new ArgumentNullException(nameof(clipAssembly));
|
|
_flushLandscape = flushLandscape
|
|
?? throw new ArgumentNullException(nameof(flushLandscape));
|
|
_clearInteriorDepth = clearInteriorDepth
|
|
?? throw new ArgumentNullException(nameof(clearInteriorDepth));
|
|
_drawExitSeals = drawExitSeals
|
|
?? throw new ArgumentNullException(nameof(drawExitSeals));
|
|
}
|
|
|
|
public void DrawSky() => _passes.DrawWalkSky(_frame, _clipAssembly);
|
|
|
|
public void DrawLandCellBatch(
|
|
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells) =>
|
|
_passes.DrawWalkLandCellBatch(_frame, cells);
|
|
|
|
public bool HasRenderableEmittersInCell(uint cellId) =>
|
|
_passes.HasWalkRenderableEmittersInCell(cellId);
|
|
|
|
public void DrawCellShell(uint cellId)
|
|
{
|
|
// Retail DrawEnvCell @0x0059F170 submits the prepared EnvCell mesh
|
|
// whole after stamping it drawn-this-frame. obj_view_set prepares
|
|
// legacy polygon/object tests, but the use_built_mesh branch calls
|
|
// D3DPolyRender::DrawMesh directly; it does not slice the shell mesh
|
|
// once per portal polygon.
|
|
_singleCellScratch.Clear();
|
|
_singleCellScratch.Add(cellId);
|
|
_passes.DrawOpaqueCellShells(_singleCellScratch);
|
|
if (_passes.CellHasTransparentShell(cellId))
|
|
{
|
|
_singleCellListScratch.Clear();
|
|
_singleCellListScratch.Add(cellId);
|
|
_passes.DrawTransparentCellShellsOrdered(_singleCellListScratch);
|
|
}
|
|
}
|
|
|
|
public void FlushLandscape() => _flushLandscape();
|
|
|
|
public void ClearInteriorDepth() => _clearInteriorDepth();
|
|
|
|
public void DrawStaticParticles(uint cellId) =>
|
|
_passes.DrawLandscapeStaticParticles(_frame, cellId);
|
|
|
|
public void DrawCellParticles(uint cellId) =>
|
|
_passes.DrawCellParticles(_frame, cellId);
|
|
|
|
public int DrawExitSeals() => _drawExitSeals();
|
|
|
|
public void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
|
|
_passes.DrawWalkPunchFan(_frame, _clipAssembly, worldPolygon, activeViewIndex);
|
|
|
|
public void AlphaBarrier() => _passes.FlushLandscapeAlpha();
|
|
}
|