Remove the deleted outdoor-fallback claim, describe the visible-cell union as diagnostics rather than a shared admission answer, and distinguish the landscape particle, resident-light, and future shadow consumers. Correct ParticleSystem complexity prose: only the SortedSet indexes have logarithmic lifecycle edges; the view list uses binary-search insertion with linear shift and linear removal. Focused verification: Core ParticleSystemTests 47/47; App visibility/terrain/frame/renderer 38/38; AP-116 settings/live-object 3/3; git diff --check clean. No behavior, tests, register, or architecture change. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2178 lines
108 KiB
C#
2178 lines
108 KiB
C#
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using AcDream.App.Rendering;
|
||
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Rendering.Scene;
|
||
using AcDream.App.Rendering.Wb;
|
||
|
||
namespace AcDream.App.Rendering.Walk;
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: one cell's or one building's already-queried
|
||
/// static content, ready for <see cref="WalkStaticStreamPopulator"/> —
|
||
/// caller-built, never read from a retained scene (see
|
||
/// <see cref="IWalkFrameWorldData"/>'s doc comment).
|
||
/// </summary>
|
||
/// <param name="Records">Already-classified <see cref="RenderProjectionRecord"/>s
|
||
/// for this turn's cell/building, retaining authored traversal/registration
|
||
/// order. For ordinary cells, <see cref="WalkStaticStreamPopulator"/> builds
|
||
/// the combined static/dynamic part list and performs retail's per-cell CYpt
|
||
/// sort; these records are not final stream order. Campaign FW3.4a: a segment
|
||
/// INTO <see cref="WalkProductionWorldData"/>'s
|
||
/// per-frame arena, not a freshly allocated array — see that type's own doc
|
||
/// comment.</param>
|
||
/// <param name="TupleLandblockId">The clip-slot-resolving landblock id
|
||
/// <c>WbDrawDispatcher.ClassifyEntityForWalk</c> needs per record (FW3.2a's
|
||
/// <c>tupleLandblockId</c> parameter) — carried per-turn rather than once per
|
||
/// frame because a single frame's cells/buildings can span more than one
|
||
/// committed landblock.</param>
|
||
internal readonly record struct WalkFrameStaticRecords(
|
||
ArraySegment<RenderProjectionRecord> Records, uint TupleLandblockId)
|
||
{
|
||
public static readonly WalkFrameStaticRecords Empty =
|
||
new(ArraySegment<RenderProjectionRecord>.Empty, 0);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: the world-data lookups <see cref="WalkFrameDriver"/>
|
||
/// needs at each walk turn, entirely caller-built — the driver reads no
|
||
/// retained scene state of its own (mirrors <see cref="WalkStaticStreamPopulator"/>'s
|
||
/// own "reads no retained scene state itself" contract one layer up). FW3.2b-2
|
||
/// wires the real production implementation (<c>RenderSceneQuery.CopyCellStaticsTo</c>
|
||
/// / <c>CopyIndexTo</c> + <see cref="WalkBuildingRegistry"/>); this stage's
|
||
/// headless referee tests wire a synthetic fake instead.
|
||
/// </summary>
|
||
internal interface IWalkFrameWorldData
|
||
{
|
||
/// <summary>The exact combined ordinary-object membership for one indoor
|
||
/// cell, preserving the registry's single static/dynamic insertion
|
||
/// sequence for S4-c3a's per-part stable CYpt sort.</summary>
|
||
WalkFrameStaticRecords GetCellObjects(uint cellId);
|
||
|
||
/// <summary>The exact combined ordinary-object membership for one
|
||
/// landscape cell; see <see cref="GetCellObjects"/>.</summary>
|
||
WalkFrameStaticRecords GetOutdoorObjects(uint cellId);
|
||
|
||
/// <summary>An indoor <c>PView::DrawCells</c> flood cell's (or a building
|
||
/// look-in's) static content — <c>RenderProjectionClass.IndoorCellStatic</c>.</summary>
|
||
WalkFrameStaticRecords GetCellStatics(uint cellId);
|
||
|
||
/// <summary>The live dynamic occupants of one indoor cell. These records
|
||
/// enter the ordered stream at that cell's own object-list turn; a
|
||
/// building look-in supplies its exact installed portal view for the
|
||
/// per-part <c>viewconeCheck</c>.</summary>
|
||
WalkFrameStaticRecords GetCellDynamics(uint cellId);
|
||
|
||
/// <summary>One visited landscape (outdoor) cell's static content —
|
||
/// <c>RenderProjectionClass.OutdoorStatic</c>, keyed by the SAME
|
||
/// <c>(landblockId & 0xFFFF0000) | (cellIndex+1)</c> id
|
||
/// <see cref="IWalkEventSink.OnLandscapeCellTurn"/> computes.</summary>
|
||
WalkFrameStaticRecords GetOutdoorStatics(uint cellId);
|
||
|
||
/// <summary>One visited landscape cell's live dynamic occupants. The
|
||
/// walk draws these at the same <c>DrawObjCell</c> turn as its statics,
|
||
/// through the landscape's currently installed view set.</summary>
|
||
WalkFrameStaticRecords GetOutdoorDynamics(uint cellId);
|
||
|
||
/// <summary>One building's own exterior shell content (<c>IsBuildingShell</c>
|
||
/// records anchored at the building's position cell).</summary>
|
||
WalkFrameStaticRecords GetBuildingShellStatics(WalkBuilding building);
|
||
|
||
/// <summary>Building-local → world, for transforming a punch polygon
|
||
/// before <see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> — the
|
||
/// production implementation is <see cref="WalkBuildingRegistry.TryGetEntry"/>'s
|
||
/// <c>WorldTransform</c> (FW3.2b-2 wiring).</summary>
|
||
Matrix4x4 GetBuildingWorldTransform(WalkBuilding building);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1: the leaf GPU-adjacent actions
|
||
/// <see cref="WalkFrameDriver"/> calls at walk turns that have no
|
||
/// <see cref="OrderedDrawStream"/> submission path YET (sky, terrain, an
|
||
/// EnvCell shell, a portal punch fan, the interior depth clear, the exit-
|
||
/// portal seals) or that aren't a draw at all (the
|
||
/// <see cref="RetailAlphaQueue"/> barrier). Kept as its own seam — rather
|
||
/// than folding these into <see cref="WalkFrameDriver"/> directly — so the
|
||
/// FW3.2b-1 headless referee suite can wire a fake and prove turn ORDER
|
||
/// without standing up the real renderers <c>EnvCellRenderer</c>,
|
||
/// <c>TerrainModernRenderer</c>, <c>GameSky</c>, and
|
||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> — FW3.2b-2's job.
|
||
///
|
||
/// <para>Stream submission itself (<c>WbDrawDispatcher.PrepareOrderedStream</c>/
|
||
/// <c>DrawOrderedRange</c>) is deliberately NOT part of this interface: it is
|
||
/// already real, production-tested machinery, so <see cref="WalkFrameDriver"/>
|
||
/// calls it directly rather than abstracting a method that would just
|
||
/// forward to it one layer deeper.</para>
|
||
/// </summary>
|
||
internal interface IWalkFrameLeafRenderer
|
||
{
|
||
/// <summary><c>LScape::draw</c> draws <c>GameSky</c> once per outdoor
|
||
/// walk (retail draws it once inside <c>LScape::draw</c>; the CURRENT
|
||
/// executor's per-slice call is per-slice-equals-once only for the
|
||
/// single-view outdoor case it handles today — the walk driver always
|
||
/// calls this exactly once per frame's Landscape turn).</summary>
|
||
void DrawSky();
|
||
|
||
/// <summary>S3 chunk 3 (§9.2 B1/B2), fix round 1 (F2), round 2 (F10):
|
||
/// retail <c>RenderDeviceD3D::DrawLandCell</c> @0x0059f120 — one or more
|
||
/// admitted LOD cells, possibly spanning several landblocks, submitted
|
||
/// together as ONE indirect draw. <see cref="WalkFrameDriver.Replay"/>
|
||
/// keeps a PENDING batch that <see cref="WalkFrameEventKind.LandCell"/>
|
||
/// events append to and flushes (calls this once) immediately before any
|
||
/// event whose GPU order against terrain is OBSERVABLE — see that
|
||
/// method's own doc comment for the exact flush-point list and its
|
||
/// pipeline-state citation. A <see cref="WalkFrameEventKind.StreamMark"/>
|
||
/// is NOT one of them (F10: the ordered stream it draws is opaque-only,
|
||
/// depth test AND write on, no blending); the F2 particle-turn exception
|
||
/// still holds (a genuinely empty <see cref="PrepareStaticParticles"/>/<see
|
||
/// cref="PrepareCellParticles"/> turn neither submits nor flushes). This is
|
||
/// order-preserving by construction: the batch's GPU submission point is
|
||
/// always the SAME point the unbatched terrain draws would have occupied
|
||
/// (immediately before the next real submission), so pixel output is
|
||
/// identical to submitting each entry as its own draw. <paramref
|
||
/// name="cells"/> carries (landblockId, side, cellIndex) triples in
|
||
/// event (draw) order — the R3 index-run arithmetic (contiguous runs
|
||
/// inside each entry's own landblock's 384-index slot,
|
||
/// <c>TerrainModernRenderer.AppendCellIndexRuns</c>) runs per entry.
|
||
/// Draws UNCLIPPED for both root kinds: retail never view-clips terrain
|
||
/// (<c>LScape::draw</c> draws whole blocks; the exit-seal/interior-
|
||
/// repaint turn owns aperture exactness afterward, S3 §9.1 R4/R5).</summary>
|
||
void DrawLandCellBatch(
|
||
IReadOnlyList<(uint LandblockId, int SideCellCount, int CellIndex)> cells);
|
||
|
||
/// <summary>S3 chunk 3 fix round 1 (F2): whether <paramref
|
||
/// name="cellId"/> has any renderable emitter right now — production
|
||
/// wires this to <c>ParticleSystem.HasRenderableEmittersInCell</c> with
|
||
/// the SAME <c>ParticleRenderPass.Scene</c> pass both <see
|
||
/// cref="PrepareStaticParticles"/> and <see cref="PrepareCellParticles"/>
|
||
/// prepare through. <see cref="WalkFrameDriver.Replay"/> asks this BEFORE
|
||
/// flushing the pending terrain batch (or preparing either particle leaf)
|
||
/// so a genuinely empty particle turn submits nothing and does not
|
||
/// force an otherwise-unneeded terrain flush.</summary>
|
||
bool HasRenderableEmittersInCell(uint cellId);
|
||
|
||
/// <summary>One committed cell's EnvCell shell —
|
||
/// <c>PView::DrawCells</c>'s <c>DrawEnvCell</c> @0x005a4abe. Retail first
|
||
/// draws ALL shells in reverse <c>cell_draw_list</c> order, then starts a
|
||
/// second reverse loop for <c>DrawObjCellForDummies</c> @0x005a4b0d.
|
||
/// The ordinary interior-root flood and every building look-in flood use
|
||
/// this same two-pass discipline. Retail's runtime <c>use_built_mesh</c>
|
||
/// branch submits the complete constructed shell; the cell's drawn stamp,
|
||
/// portal depth writes, and later depth-tested repaint own visibility.</summary>
|
||
void DrawCellShell(uint cellId);
|
||
|
||
/// <summary>One landscape (land) cell's own-cell particle emitters, at
|
||
/// its own walk turn — see <see cref="WalkFrameEventKind.StaticParticles"/>
|
||
/// for the retail positional invariant this carries (the #132 falls
|
||
/// containment). Retail <c>CPhysicsObj::add_particle_shadow_to_cell</c>
|
||
/// (0x00514a70) draws an emitter at its own current cell's object turn
|
||
/// regardless of its attached owner's registry membership, so this is a
|
||
/// cell lookup, not an owner union.</summary>
|
||
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareStaticParticles(uint cellId);
|
||
|
||
/// <summary>Submits one indoor cell's own-cell particle emitters at that
|
||
/// cell's own object-list turn. Meshes have already entered the ordered
|
||
/// stream before this leaf event.</summary>
|
||
ReadOnlySpan<PreparedParticleAlphaSubmission> PrepareCellParticles(uint cellId);
|
||
|
||
/// <summary><c>PView::DrawCells</c> @0x005a4840's gated full depth clear
|
||
/// (pc:432731-432732) between the outside stage and the interior root's
|
||
/// own flood — production maps this to <c>IWorldPassScope.ClearInteriorDepth</c>
|
||
/// (see that interface's own member of the same name in
|
||
/// <c>RetailPViewRenderer.cs</c>). The driver calls this leaf ONLY when
|
||
/// <see cref="WalkFrameDriver.PortalsDrawnCount"/> was nonzero at the
|
||
/// read-then-zero decision point (S3 §8.1 R4: retail's
|
||
/// <c>portalsDrawnCount</c>, read-then-zeroed @0x005a489c-0x005a489e —
|
||
/// <c>forceClear</c> never writes in the pseudo-C, so the clear fires
|
||
/// iff the counter was nonzero). Only called for an INTERIOR root, never
|
||
/// outdoors (retail has no depth clear there —
|
||
/// <c>portalsDrawnCount</c> never applies to <c>LScape::draw</c>'s own
|
||
/// top-level walk) and never for a building look-in (R1: those call
|
||
/// <c>DrawCells</c> re-entrantly with <c>ov==0</c>, which never reaches
|
||
/// this leaf at all).</summary>
|
||
void ClearInteriorDepth();
|
||
|
||
/// <summary>The exit-portal seals (pc:432785-432786) — re-stamping every
|
||
/// outside-leading portal's TRUE depth right after
|
||
/// <see cref="ClearInteriorDepth"/> (when it ran) so the aperture the
|
||
/// clear just wiped stays occluded by the world beyond it rather than by
|
||
/// whatever draws next. Production maps this to the existing seal-fan
|
||
/// machinery (<c>RetailPViewRenderer.DrawExitPortalMask</c>/
|
||
/// <c>PortalDepthMaskRenderer</c>) — this driver only provides the TURN;
|
||
/// the real per-portal fan geometry is FW3.2b-2's job. Returns the
|
||
/// number of seal polygons actually submitted this turn (S3 §8.1 R4/§8.2
|
||
/// B2: retail's <c>D3DPolyRender::portalsDrawnCount</c> @0x008719b4
|
||
/// increments once per <c>DrawPortalPolyInternal</c> call with its
|
||
/// second argument FALSE — exactly the exit-seal calls, never punch
|
||
/// fans) — the driver adds the returned count to
|
||
/// <see cref="WalkFrameDriver.PortalsDrawnCount"/>, which the NEXT
|
||
/// <c>ov>0</c> interior-root flood's <see cref="ClearInteriorDepth"/>
|
||
/// decision reads. Only called for an INTERIOR root's own flood, never
|
||
/// for a building look-in (those call <c>DrawCells</c> re-entrantly with
|
||
/// no clear/seal step) and never outdoors.</summary>
|
||
int DrawExitSeals();
|
||
|
||
/// <summary>Retail <c>D3DPolyRender::FlushAlphaList(0f)</c> @0x005a4872
|
||
/// plus the pre-clear dynamics hook (<c>RetailPViewRenderer</c>'s
|
||
/// <c>FlushWalkLandscape</c>) — the first action inside
|
||
/// <c>if (outside_view.view_count > 0)</c> at the interior root's own
|
||
/// flood turn (S3 §8.1 R3), strictly before the device-stamp advance and
|
||
/// the gated depth clear. Distinct from <see cref="AlphaBarrier"/> (a
|
||
/// BUILDING turn's own alpha barrier, @0x0059f30b) even though both map
|
||
/// to the same underlying flush call — kept as a separate leaf member so
|
||
/// a driver trace can tell the two turns apart. Only called for an
|
||
/// INTERIOR root's own flood with a surviving exit view (<c>ov>0</c>),
|
||
/// never outdoors and never for a building look-in.</summary>
|
||
void FlushLandscape();
|
||
|
||
/// <summary><c>DrawPortalPolyInternal</c> @0x0059bc90's depth-only far-Z
|
||
/// punch fan — pass 1 of the building portal walk.
|
||
/// <paramref name="worldPolygon"/> is already transformed building-local
|
||
/// → world (<see cref="WalkFrameDriver"/> does the transform via
|
||
/// <see cref="IWalkFrameWorldData.GetBuildingWorldTransform"/> at Collect
|
||
/// time — see that type's own doc comment). The real implementation is
|
||
/// <c>PortalDepthMaskRenderer.DrawDepthFan</c> with <c>forceFarZ</c>
|
||
/// (FW3.2b-2 wiring) — Replay calls this at exactly the point Collect
|
||
/// recorded it: any content queued ahead of the punch (a preceding
|
||
/// cell's/building's contents — never this building's OWN shell, which
|
||
/// retail draws only after the whole portal walk completes; see
|
||
/// <see cref="WalkFrameDriver"/>'s type doc comment) reaches the GPU
|
||
/// first. <paramref name="activeViewIndex"/> is the view the emitting
|
||
/// two-pass walk was pinned to (retail <c>building_view =
|
||
/// Render::portal_view_num</c> @0x0059f3bf) — production clips the fan by
|
||
/// that view's slice planes.</summary>
|
||
void DrawPunchFan(WalkPolygon worldPolygon, int activeViewIndex);
|
||
|
||
/// <summary>Retail <c>D3DPolyRender::FlushAlphaList(0f)</c>
|
||
/// @0x0059f30b. The walk is positional: only farther cells have submitted
|
||
/// alpha when a building turn fires, so this is a full drain with no
|
||
/// synthetic viewer-distance threshold.</summary>
|
||
void AlphaBarrier();
|
||
|
||
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
|
||
/// (per-land-cell loop head @0x005a18d0) own <c>FlushAlphaList(::flush)</c>
|
||
/// @0x005a1a07 — the
|
||
/// immutable global 0.75f pressure valve, inert below 2250 entries in
|
||
/// either list (a no-op the overwhelming majority of the time at
|
||
/// today's scene complexity) but pinned exact by the capacity/threshold
|
||
/// tests. Fires once per admitted land-block cell, at the SAME turn
|
||
/// <see cref="IWalkEventSink.OnSortCellExit"/> records it (after that
|
||
/// cell's object-list turn, before the next cell's land turn).</summary>
|
||
void FlushSortCellExit();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1 test seam: an optional, diagnostic-only
|
||
/// observer of every ordered-stream range <see cref="WalkFrameDriver.Replay"/>
|
||
/// draws. Production callers pass <see langword="null"/> (the default) — this
|
||
/// exists purely so the headless referee suite can assert range COUNT,
|
||
/// per-range command count, and per-range stage without re-deriving them
|
||
/// from <c>RecordingGpuDevice.Calls</c>' lower-level RHI call log.
|
||
/// </summary>
|
||
internal interface IWalkFrameDriverTrace
|
||
{
|
||
/// <summary><paramref name="stages"/> is a snapshot (never a live,
|
||
/// about-to-mutate list) of every command's <see cref="WalkDrawStage"/> in
|
||
/// the drawn segment, in stream order — by this stage's own segmenting
|
||
/// discipline (a segment boundary before every non-stream leaf action) a
|
||
/// segment is always single-stage in practice, but the full list is
|
||
/// passed so a test can assert that invariant itself instead of trusting
|
||
/// it.</summary>
|
||
void OnFlush(int commandCount, IReadOnlyList<WalkDrawStage> stages);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a: one turn Collect recorded, replayed by
|
||
/// <see cref="WalkFrameDriver.Replay"/> in the exact order Collect saw it.
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> is the collect-time analogue of
|
||
/// the old immediate driver's flush point — see <see cref="WalkFrameEventKind"/>'s
|
||
/// own doc comment for the full list and what each carries.
|
||
/// </summary>
|
||
internal enum WalkFrameEventKind : byte
|
||
{
|
||
/// <summary>The accumulated <see cref="OrderedDrawStream"/> grew since the
|
||
/// last mark and must be drawn, via <c>WbDrawDispatcher.DrawOrderedRange</c>,
|
||
/// before whatever leaf event follows. <see cref="WalkFrameEvent.IntArg"/>
|
||
/// is the stream's exclusive-end command index at the moment this event
|
||
/// was recorded.</summary>
|
||
StreamMark,
|
||
|
||
/// <summary>Submits every retained translucent walk batch through
|
||
/// <see cref="WbDrawDispatcher.SubmitWalkAlphaInstance"/> up to the
|
||
/// exclusive-end index in <see cref="WalkFrameEvent.IntArg"/>. This is a
|
||
/// replay-time event: collecting the complete frame must not make alpha
|
||
/// from a later/near cell visible to an earlier building barrier.</summary>
|
||
AlphaSubmitMark,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawSky"/>.</summary>
|
||
Sky,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/> —
|
||
/// one admitted land cell's terrain (retail <c>DrawLandCell</c>
|
||
/// @0x0059f120): <see cref="WalkFrameEvent.CellId"/> is the landblock,
|
||
/// <see cref="WalkFrameEvent.IntArg"/> packs <c>side << 8 | cellIndex</c>.
|
||
/// <see cref="Replay"/> does NOT draw it immediately: it appends the
|
||
/// cell to ONE pending terrain batch that may span landblocks and
|
||
/// flushes only at a flush point whose GPU order against terrain is
|
||
/// OBSERVABLE — <see cref="PunchFan"/>, <see cref="AlphaBarrier"/>,
|
||
/// <see cref="LandscapeFlush"/>, <see cref="ClearInteriorDepth"/>,
|
||
/// <see cref="ExitSeals"/>, <see cref="Sky"/>, <see cref="CellShell"/>,
|
||
/// a particle turn whose cell has a renderable emitter, or the end of
|
||
/// Replay (S3 chunk 3 fix round 2, §9.7 F10; see <see cref="Replay"/>'s
|
||
/// own doc comment for the full citation). A <see cref="StreamMark"/> is
|
||
/// deliberately NOT a flush point: the ordered stream it draws holds
|
||
/// ONLY opaque batches, depth test AND write on, no blending, so opaque
|
||
/// terrain and opaque statics resolve identically in either submission
|
||
/// order. A particle turn whose cell has no renderable emitter submits
|
||
/// nothing and keeps the batch open (fix round 1, F2). The resolved image is therefore identical to replaying each cell
|
||
/// alone: every event whose GPU order against opaque terrain is
|
||
/// observable still flushes first (§9.7 F10), and opaque-vs-opaque
|
||
/// order is depth-resolved.</summary>
|
||
LandCell,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawCellShell"/> —
|
||
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Retail's
|
||
/// <c>DrawEnvCell</c> marks the EnvCell drawn before submitting its built
|
||
/// mesh, so the shell is whole and frame-deduplicated; portal-view slices
|
||
/// are not shell geometry clips.</summary>
|
||
CellShell,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawPunchFan"/> —
|
||
/// <see cref="WalkFrameEvent.Polygon"/> is the already-world-transformed
|
||
/// polygon (transformed at Collect time, exactly as the pre-FW3.4a driver
|
||
/// transformed it before its own immediate call), <see cref="WalkFrameEvent.IntArg"/>
|
||
/// is the active view index.</summary>
|
||
PunchFan,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.AlphaBarrier"/> —
|
||
/// <see cref="WalkFrameEvent.FloatArg"/> is the viewer distance, computed
|
||
/// at Collect time (the context that supplies it does not outlive Collect).</summary>
|
||
AlphaBarrier,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.FlushLandscape"/>.</summary>
|
||
LandscapeFlush,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.ClearInteriorDepth"/>.</summary>
|
||
ClearInteriorDepth,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.DrawExitSeals"/>.</summary>
|
||
ExitSeals,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareStaticParticles"/> —
|
||
/// ONE landscape (land) cell's (<see cref="WalkFrameEvent.CellId"/>)
|
||
/// own-cell particle emitters, emitted AT ITS OWN WALK TURN. Retail's
|
||
/// falls containment is positional: an outdoor emitter's polys join the
|
||
/// one alpha list during its owner cell's <c>DrawObjCell</c> in the
|
||
/// far-to-near landscape walk, so every nearer building's pre-punch
|
||
/// alpha barrier (<c>DrawBuilding</c> @0x0059f2a0's
|
||
/// <c>FlushAlphaList</c> @0x0059f30b) drains the already-queued FARTHER
|
||
/// content against still-true depth BEFORE the punch stamps far-Z into
|
||
/// the aperture. The former single batched submission at the pre-clear
|
||
/// closure ran AFTER every punch — the barriers fired over an empty
|
||
/// queue and the falls drained against punched-far aperture pixels (the
|
||
/// cathedral bleed; the old pipeline's user-verified #132 fix
|
||
/// `e102fb36` encoded the same invariant). A building's own shell fires
|
||
/// no event of this kind any more — its interior emitters live in their
|
||
/// own EnvCells and draw at those cells' own <see cref="CellParticles"/>
|
||
/// turns (Campaign OVERHAUL S2 chunk 6, retail
|
||
/// <c>CPhysicsObj::add_particle_shadow_to_cell</c> 0x00514a70: an emitter
|
||
/// owns one shadow in its OWN current cell, never its parent's).</summary>
|
||
StaticParticles,
|
||
|
||
/// <summary><see cref="IWalkFrameLeafRenderer.PrepareCellParticles"/> —
|
||
/// <see cref="WalkFrameEvent.CellId"/> is the cell. Fires at EVERY
|
||
/// visited interior cell's own object-list turn regardless of whether
|
||
/// that cell has any visible static/dynamic owner record: an emitter
|
||
/// draws at its own cell's turn independent of its attached owner's
|
||
/// registry membership (a hidden/suspended owner's emitter still shows —
|
||
/// Campaign OVERHAUL S2 chunk 6, the portal-haze fix).</summary>
|
||
CellParticles,
|
||
|
||
/// <summary>S4-c2: <see cref="IWalkFrameLeafRenderer.FlushSortCellExit"/> —
|
||
/// retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's (per-land-cell
|
||
/// loop head @0x005a18d0) own <c>FlushAlphaList(0.75f)</c>.</summary>
|
||
SortCellExit,
|
||
}
|
||
|
||
/// <summary>
|
||
/// The exact portal-view cones installed at the walk's building look-in
|
||
/// <c>DrawCells</c> turns. Retail <c>RenderDeviceD3D::DrawMesh</c>
|
||
/// @0x005A0860 tests each object's drawing sphere against these views before
|
||
/// drawing the mesh whole; cell membership alone is not an admission rule.
|
||
/// </summary>
|
||
internal interface IWalkLookInViewSource
|
||
{
|
||
IReadOnlyList<uint> LookInCellTurns { get; }
|
||
|
||
/// <summary>
|
||
/// Campaign OVERHAUL S3 chunk 4 (§10.2): retail's real question — does
|
||
/// ANY active view slice for this DrawObjCell turn admit the part's
|
||
/// drawing sphere (<c>RenderDeviceD3D::DrawMesh</c> @0x005A0860's
|
||
/// per-view <c>viewconeCheck</c> loop)? Once any view admits, retail
|
||
/// submits the WHOLE constructed mesh once
|
||
/// (<c>DrawMeshInternal</c> @0x0059F360) — the portal polygon is never
|
||
/// forwarded as a GPU mesh clip, so this is a pure Boolean, never a
|
||
/// per-view clip-slot list (the deleted <c>VisibleClipSlotsInLookInTurn</c>
|
||
/// always collapsed to that same Boolean one call site up anyway).
|
||
/// <paramref name="testSphere"/> false asks "does this route have any
|
||
/// admitted view at all", ignoring the sphere geometry — retail's own
|
||
/// no-authored-sphere case, where there is nothing to sphere-test.
|
||
/// </summary>
|
||
bool SphereVisibleInLookInTurn(
|
||
int routeIndex,
|
||
in Vector3 center,
|
||
float radius,
|
||
bool testSphere = true);
|
||
|
||
/// <summary>Output-only description of the exact CY/edge-plane distances
|
||
/// used by one DrawObjCell turn. Kept behind the existing Facility probe;
|
||
/// production admission never consumes this text.</summary>
|
||
string DescribeLookInTurn(
|
||
int routeIndex,
|
||
in Vector3 center,
|
||
float radius) => "unavailable";
|
||
}
|
||
|
||
internal readonly record struct WalkLookInSlice(
|
||
int PlaneStart,
|
||
int PlaneCount,
|
||
uint ClipSlot);
|
||
|
||
internal readonly record struct WalkLookInTurn(
|
||
uint CellId,
|
||
int SliceStart,
|
||
int SliceCount);
|
||
|
||
/// <summary>See <see cref="WalkFrameEventKind"/> for what each field means per
|
||
/// kind. A single struct (rather than a kind hierarchy) keeps Collect's
|
||
/// per-turn list a flat, allocation-cheap <c>List<WalkFrameEvent></c> —
|
||
/// only <see cref="Polygon"/> (a punch fan's already-transformed geometry)
|
||
/// allocates, and only once per punch, which is rare enough per frame to be
|
||
/// unconditionally acceptable (plan §FW3.4a's own call).</summary>
|
||
internal readonly struct WalkFrameEvent
|
||
{
|
||
private WalkFrameEvent(
|
||
WalkFrameEventKind kind, int intArg, uint cellId, float floatArg, WalkPolygon? polygon)
|
||
{
|
||
Kind = kind;
|
||
IntArg = intArg;
|
||
CellId = cellId;
|
||
FloatArg = floatArg;
|
||
Polygon = polygon;
|
||
}
|
||
|
||
internal WalkFrameEventKind Kind { get; }
|
||
|
||
internal int IntArg { get; }
|
||
|
||
internal uint CellId { get; }
|
||
|
||
internal float FloatArg { get; }
|
||
|
||
internal WalkPolygon? Polygon { get; }
|
||
|
||
internal static WalkFrameEvent Mark(int exclusiveEnd) =>
|
||
new(WalkFrameEventKind.StreamMark, exclusiveEnd, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent AlphaSubmitMark(int exclusiveEnd) =>
|
||
new(WalkFrameEventKind.AlphaSubmitMark, exclusiveEnd, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent Sky() =>
|
||
new(WalkFrameEventKind.Sky, 0, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent LandCell(uint landblockId, int sideCellCount, int cellIndex) =>
|
||
new(WalkFrameEventKind.LandCell, (sideCellCount << 8) | cellIndex, landblockId, 0f, null);
|
||
|
||
internal static WalkFrameEvent CellShell(uint cellId) =>
|
||
new(WalkFrameEventKind.CellShell, 0, cellId, 0f, null);
|
||
|
||
internal static WalkFrameEvent PunchFan(WalkPolygon worldPolygon, int activeViewIndex) =>
|
||
new(WalkFrameEventKind.PunchFan, activeViewIndex, 0, 0f, worldPolygon);
|
||
|
||
internal static WalkFrameEvent AlphaBarrier() =>
|
||
new(WalkFrameEventKind.AlphaBarrier, 0, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent LandscapeCellParticles(
|
||
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
|
||
new(
|
||
WalkFrameEventKind.StaticParticles,
|
||
alphaExclusiveEnd,
|
||
cellId,
|
||
includeParticles ? 1f : 0f,
|
||
null);
|
||
|
||
internal static WalkFrameEvent CellParticles(
|
||
uint cellId, int alphaExclusiveEnd, bool includeParticles) =>
|
||
new(
|
||
WalkFrameEventKind.CellParticles,
|
||
alphaExclusiveEnd,
|
||
cellId,
|
||
includeParticles ? 1f : 0f,
|
||
null);
|
||
|
||
internal static WalkFrameEvent LandscapeFlush() =>
|
||
new(WalkFrameEventKind.LandscapeFlush, 0, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent ClearInteriorDepth() =>
|
||
new(WalkFrameEventKind.ClearInteriorDepth, 0, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent ExitSeals() =>
|
||
new(WalkFrameEventKind.ExitSeals, 0, 0, 0f, null);
|
||
|
||
internal static WalkFrameEvent SortCellExit() =>
|
||
new(WalkFrameEventKind.SortCellExit, 0, 0, 0f, null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW stage FW3.2b-1 — THE WALK FRAME DRIVER. Executes one full
|
||
/// static-content frame by driving <see cref="RetailFrameWalk"/> with itself
|
||
/// as the <see cref="IWalkEventSink"/>, so that GPU command-buffer order
|
||
/// equals retail's walk order (plan §FW3.2b-1's "INTERLEAVING RULE").
|
||
///
|
||
/// <para><b>Campaign FW3.4a — the ONE-walk split.</b> Before this stage, a
|
||
/// single frame ran <see cref="RetailFrameWalk"/> TWICE — once with a
|
||
/// set-collecting sink to learn the flood/visited-cell set before
|
||
/// <c>PrepareCellBatches</c>, once more through this driver to actually
|
||
/// submit — and each walk turn's stream content flushed IMMEDIATELY through
|
||
/// its own full <c>WbDrawDispatcher.SubmitOrderedStream</c> call (~40 of
|
||
/// those per frame at a town, each rewriting and rebinding all nine
|
||
/// per-instance sections for that turn's handful of instances). The FW3.4
|
||
/// perf checkpoint measured +33.5% CPU p50 and 14× frame allocation from
|
||
/// exactly those two costs (plus a third, unrelated one — see
|
||
/// <see cref="WalkProductionWorldData"/>'s own doc comment) and tripped the
|
||
/// campaign's ±20% stop rule. This stage collapses both: <see cref="Collect"/>
|
||
/// runs <see cref="RetailFrameWalk"/> ONCE, doing everything the immediate
|
||
/// driver used to do EXCEPT the actual GPU submission — stream appends
|
||
/// accumulate without flushing, every former immediate leaf call records a
|
||
/// <see cref="WalkFrameEvent"/> instead, and the driver keeps its
|
||
/// visited-set bookkeeping (absorbing the renderer's old dedicated
|
||
/// set-collecting sink) so the SAME walk answers both questions.
|
||
/// <see cref="Replay"/> then performs the actual GPU work afterward:
|
||
/// <c>WbDrawDispatcher.PrepareOrderedStream</c> uploads the WHOLE frame's
|
||
/// stream once, and each recorded <see cref="WalkFrameEventKind.StreamMark"/>
|
||
/// becomes one cheap <c>DrawOrderedRange</c> call over the already-uploaded
|
||
/// payload — interleaved, in the exact recorded order, with the leaf
|
||
/// renderer calls the OLD immediate driver made inline. Because Replay walks
|
||
/// the SAME event sequence Collect recorded at the SAME points the old code
|
||
/// flushed, GPU command order is unchanged bit-for-bit; only the number of
|
||
/// walks (two → one) and the shape of the GPU submission (many small
|
||
/// rebind-and-draw calls → one bind, many cheap draws) changes.
|
||
/// <see cref="RunFrame"/> remains Collect immediately followed by Replay, for
|
||
/// callers (today: the headless referee suite) that do not need the split;
|
||
/// <c>RetailPViewRenderer</c> uses the split directly, since it must run
|
||
/// <c>PrepareCellBatches</c>/<c>BuildAndBorrow</c> BETWEEN them.</para>
|
||
///
|
||
/// <para><b>The one mark rule that reproduces the whole frame script:</b>
|
||
/// before EVERY leaf-renderer event (<see cref="WalkFrameEventKind.Sky"/>,
|
||
/// <c>LandCell</c>, <c>CellShell</c>, <c>LandscapeFlush</c>,
|
||
/// <c>ClearInteriorDepth</c>, <c>ExitSeals</c>, <c>PunchFan</c>) and before
|
||
/// every <see cref="WalkFrameEventKind.AlphaBarrier"/> event, Collect
|
||
/// records a <see cref="WalkFrameEventKind.StreamMark"/> if the stream grew
|
||
/// since the last one (a no-op otherwise — "empty segments submit
|
||
/// nothing"); a building's own shell content is APPENDED (not marked) the
|
||
/// moment <see cref="IWalkEventSink.OnBuildingShellTurn"/> fires, so it only
|
||
/// gets a mark ahead of whatever non-stream event comes next (the next
|
||
/// building's alpha barrier, or the final mark at <see cref="Replay"/>'s
|
||
/// prepare step). This single rule, combined with retail's two reverse
|
||
/// flood passes (ALL shells, then ALL contents),
|
||
/// retail's own building order (alpha barrier → portal pass → shell — see
|
||
/// <see cref="RetailFrameWalk.DrawBuilding"/>'s doc comment), and retail's
|
||
/// own interior-root DRAW order (landscape → flush/stamp/[gated clear]/seals
|
||
/// → the flood's own cells, the middle four steps ALL gated on
|
||
/// <c>outside_view.view_count > 0</c> — see
|
||
/// <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>'s doc comment; this
|
||
/// is NOT the order the walk's EVENTS fire in, which is breakpoint-entry
|
||
/// order matching the FW0 oracle traces), is what produces every ordering
|
||
/// constraint the plan's frame script names: [far shell] … [near shell]
|
||
/// [far contents] … [near contents], [alpha barrier] [punch fan(s) +
|
||
/// look-in flood(s), each following the SAME reverse two-pass discipline]
|
||
/// [building shell content mark], [landscape (if exit views survived)]
|
||
/// [landscape flush] [gated interior depth clear] [exit-portal seals] — the
|
||
/// landscape and those three only when <c>outside_view.view_count > 0</c>
|
||
/// (S3 §8.1 R3) — then [the interior root's own flood cells] unconditionally,
|
||
/// and a final mark at Replay's prepare step. No special-casing per turn kind is needed beyond
|
||
/// that.</para>
|
||
///
|
||
/// <para>Retail anchors: <c>SmartBox::RenderNormalMode</c> @0x00453aa0 (the
|
||
/// root <see cref="RetailFrameWalk.WalkFrame"/> already ports),
|
||
/// <c>RenderDeviceD3D::DrawSortCell</c> @0x0059f140 (building-before-
|
||
/// DrawObjCell per landscape cell), <c>PView::DrawCells</c> @0x005a4840
|
||
/// (the complete reverse <c>DrawEnvCell</c> loop @0x005a4a00–0x005a4ade
|
||
/// before the complete reverse <c>DrawObjCellForDummies</c> loop
|
||
/// @0x005a4ade–0x005a4b2d; <c>LScape::draw</c> pc:432719, the depth
|
||
/// clear pc:432731-432732, and the exit-portal seals pc:432785-432786 —
|
||
/// ALL strictly before the flood's own cells, though the event marking
|
||
/// <c>DrawCells</c> entry fires before all three), <c>RenderDeviceD3D::DrawBuilding</c>
|
||
/// @0x0059f2a0 (the <c>part->gfxobj[deg_level]!=0</c> gate @0x0059f2d3
|
||
/// and the alpha-barrier → portal-pass → shell order @0x0059f30b–0x0059f345).</para>
|
||
/// </summary>
|
||
internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
|
||
{
|
||
private readonly WbDrawDispatcher _dispatcher;
|
||
private readonly WalkStaticStreamPopulator _populator;
|
||
private IWalkFrameLeafRenderer _leafRenderer;
|
||
private readonly IWalkFrameWorldData _worldData;
|
||
private readonly IWalkFrameDriverTrace? _trace;
|
||
private ClipFrame? _clipFrame;
|
||
private readonly OrderedDrawStream _stream = new();
|
||
private readonly List<WalkFrameEvent> _events = new();
|
||
private readonly List<int> _markPositions = new();
|
||
private readonly List<WbDrawDispatcher.WalkClassifiedBatch> _alphaSubmissions = new();
|
||
private int _alphaSubmitMark;
|
||
|
||
// Campaign FW3.4a: visited-set collection, absorbed from the renderer's
|
||
// former dedicated set-collecting sink (RetailPViewRenderer's old
|
||
// WalkVisitedSetCollector) — the SAME shapes that sink produced, now
|
||
// populated by the ONE walk Collect already runs instead of a second
|
||
// walk pass dedicated to nothing but set-gathering.
|
||
internal HashSet<uint> VisitedCells { get; } = new();
|
||
|
||
/// <summary>Building look-in cells in the exact order the walk encountered
|
||
/// their <c>DrawCells</c> turns. Duplicates are intentional: two authored
|
||
/// portal views can independently visit the same cell and therefore own
|
||
/// distinct packed route indices.</summary>
|
||
internal List<uint> LookInCellTurns { get; } = new();
|
||
|
||
private readonly List<WalkLookInTurn> _lookInTurns = new();
|
||
private readonly List<WalkLookInSlice> _lookInSlices = new();
|
||
private readonly List<WalkPlane> _lookInPlanes = new();
|
||
private readonly List<int> _floodViewRouteScratch = new();
|
||
private WalkPlane _lookInCyPlane;
|
||
|
||
/// <summary>S3 chunk 4 fix round 1 (K2): set exactly when <see
|
||
/// cref="IWalkEventSink.OnWeatherTurn"/> fires this frame — i.e. a
|
||
/// Landscape turn actually ran (retail's <c>RetailFrameWalk.DrawLandscape</c>
|
||
/// only reaches that call for an outdoor root, or an interior root whose
|
||
/// flood still has a surviving exit view) AND <c>ctx.WeatherGateOpen</c>
|
||
/// held. This is "the walk's own predicate" the S3 chunk-4 fix round 1
|
||
/// unifies the transcript print and the Replay-time weather draw
|
||
/// on — before this, the print's own gate (<c>ctx.WeatherGateOpen</c>
|
||
/// alone) and the draw's gate (<c>clipAssembly.OutsideViewSlices.Length
|
||
/// > 0</c>, re-derived independently at Replay) could diverge on an
|
||
/// interior root whose landscape turn ran but whose reassembled outside-
|
||
/// view slices ended up empty, letting the transcript report a weather
|
||
/// turn the frame never actually drew. Reset every Collect
|
||
/// (<see cref="BeginFrame"/>/<see cref="AbortFrame"/>); read at Replay
|
||
/// by <see cref="AcDream.App.Rendering.RetailPViewRenderer.DrawLandscapeDynamicsPhase"/>.</summary>
|
||
internal bool WeatherTurnFired { get; private set; }
|
||
|
||
/// <summary>S3 chunk 3 (§9.2 B2), fix round 1 (F2), round 2 (F10):
|
||
/// <see cref="Replay"/>'s PENDING terrain batch — every
|
||
/// <see cref="WalkFrameEventKind.LandCell"/> event since the last flush,
|
||
/// across however many landblocks and however many <see cref="WalkFrameEventKind.StreamMark"/>
|
||
/// events (F10: not a flush point), in event order. Cleared at the start
|
||
/// of each <see cref="Replay"/> call ("frame start") and by every flush;
|
||
/// NOT cleared between events, since it is the whole point of the
|
||
/// deferred-batching rule.</summary>
|
||
private readonly List<(uint LandblockId, int SideCellCount, int CellIndex)> _pendingTerrainBatch = new();
|
||
|
||
private readonly HashSet<uint> _cellShellsDrawnThisFrame = new();
|
||
// Chunk 6 review F1: one particle turn per cell per render stamp (see EmitCellContentsTurn).
|
||
private readonly HashSet<uint> _cellParticleTurnsDrawnThisFrame = new();
|
||
|
||
/// <summary>S3 chunk 2 (§8.2 B2): retail's <c>D3DPolyRender::portalsDrawnCount</c>
|
||
/// (uint16 @0x008719b4) — retained ACROSS frames (this driver is itself
|
||
/// retained by <c>RetailPViewRenderer</c>), never cleared by
|
||
/// <see cref="BeginFrame"/>/<see cref="AbortFrame"/>/<see cref="EndFrame"/>/
|
||
/// <see cref="Replay"/>. Read-then-zeroed at every <c>ov>0</c>
|
||
/// interior-root flood's clear decision (<see cref="OnInteriorFloodDrawTurn"/>
|
||
/// implementation, S3 §8.1 R4); incremented at <see cref="Replay"/> by
|
||
/// the count <see cref="IWalkFrameLeafRenderer.DrawExitSeals"/> returns
|
||
/// for THIS frame's own exit-seal turn, which the NEXT <c>ov>0</c>
|
||
/// frame's read will see. A driver whose interior floods never reach an
|
||
/// exit portal keeps this at zero forever — the gated clear never fires
|
||
/// for it, matching retail exactly (no seals submitted, no depth ever
|
||
/// needed clearing). Retail consumes and re-arms inside ONE synchronous
|
||
/// <c>DrawCells</c>; here the consume is at Collect and the re-arm at
|
||
/// Replay, so a frame that Collects but never Replays (an exception path
|
||
/// that already failed the frame) leaves the next ov>0 frame without a
|
||
/// clear — no retail analogue, no production path.</summary>
|
||
internal int PortalsDrawnCount;
|
||
|
||
IReadOnlyList<uint> IWalkLookInViewSource.LookInCellTurns => LookInCellTurns;
|
||
|
||
/// <summary>The set form of <see cref="LookInCellTurns"/>, for drawn-once
|
||
/// exclusion and root-flood particle bookkeeping.</summary>
|
||
internal HashSet<uint> LookInCells { get; } = new();
|
||
|
||
/// <summary>FW4 slice 2: the interior root's ORDERED flood cell list,
|
||
/// exactly as retail's <c>PView::DrawCells</c> iterates it for the
|
||
/// exit-portal seals (pc:432785-432786) — captured at
|
||
/// <see cref="IWalkEventSink.OnInteriorFloodDrawTurn"/>. Empty for an
|
||
/// outdoor-rooted frame. The production seal draw iterates THIS list
|
||
/// (the walk's own flood), not the old visibility apparatus's — at the
|
||
/// #456 seam band the old builder's flood misses exit portals the walk
|
||
/// provably floods, leaving unsealed aperture depth the end-of-frame
|
||
/// alpha drain splats through (the cathedral falls shine-through).</summary>
|
||
internal List<uint> InteriorFloodCells { get; } = new();
|
||
|
||
/// <summary>Number of exact walk-owned portal-view slices that retail's
|
||
/// <c>CEnvCell::setup_view</c> installs while sealing one entry in
|
||
/// <see cref="InteriorFloodCells"/>. The matching route was captured at
|
||
/// that cell's shell turn and remains valid through synchronous replay.</summary>
|
||
internal int InteriorFloodViewSliceCountAt(int floodCellIndex)
|
||
{
|
||
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
|
||
return _lookInTurns[routeIndex].SliceCount;
|
||
}
|
||
|
||
/// <summary>Borrows the clip-space half-planes for one exact walk-owned
|
||
/// portal-view slice. An empty span is the legitimate no-clip slot, not a
|
||
/// fallback to another visibility structure.</summary>
|
||
internal ReadOnlySpan<Vector4> InteriorFloodViewClipPlanesAt(
|
||
int floodCellIndex,
|
||
int sliceOffset)
|
||
{
|
||
int routeIndex = InteriorFloodViewRouteAt(floodCellIndex);
|
||
WalkLookInTurn turn = _lookInTurns[routeIndex];
|
||
if ((uint)sliceOffset >= (uint)turn.SliceCount)
|
||
throw new ArgumentOutOfRangeException(nameof(sliceOffset));
|
||
|
||
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
|
||
if (_clipFrame is null)
|
||
return ReadOnlySpan<Vector4>.Empty;
|
||
return _clipFrame.GetSlotPlanes(slice.ClipSlot);
|
||
}
|
||
|
||
private int InteriorFloodViewRouteAt(int floodCellIndex)
|
||
{
|
||
if ((uint)floodCellIndex >= (uint)InteriorFloodCells.Count
|
||
|| (uint)floodCellIndex >= (uint)_floodViewRouteScratch.Count)
|
||
{
|
||
throw new ArgumentOutOfRangeException(nameof(floodCellIndex));
|
||
}
|
||
|
||
int routeIndex = _floodViewRouteScratch[floodCellIndex];
|
||
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Interior flood cell 0x{InteriorFloodCells[floodCellIndex]:X8} "
|
||
+ "has no captured portal-view route.");
|
||
}
|
||
return routeIndex;
|
||
}
|
||
|
||
/// <summary>Test-only accessor (S3 chunk 4 fix round 1, K7): the packed
|
||
/// GPU clip slot one look-in route's slice was assigned by <see
|
||
/// cref="CaptureViews"/>'s call to <see cref="AppendClipSlot"/> — the
|
||
/// SAME value <see cref="InteriorFloodViewClipPlanesAt"/> resolves
|
||
/// through <c>_clipFrame.GetSlotPlanes(slice.ClipSlot)</c> for the root-
|
||
/// flood case. Restores the "a real look-in slice never reuses the
|
||
/// reserved no-clip slot 0" pin the deleted <c>VisibleClipSlotsInLookInTurn</c>
|
||
/// used to prove. <paramref name="routeIndex"/> indexes <c>_lookInTurns</c>
|
||
/// directly, matching <see cref="SphereVisibleInLookInTurn"/>'s own
|
||
/// parameter (not the flood-cell indirection <see
|
||
/// cref="InteriorFloodViewClipPlanesAt"/> goes through).</summary>
|
||
internal uint LookInSliceClipSlotAt(int routeIndex, int sliceOffset = 0)
|
||
{
|
||
WalkLookInTurn turn = _lookInTurns[routeIndex];
|
||
if ((uint)sliceOffset >= (uint)turn.SliceCount)
|
||
throw new ArgumentOutOfRangeException(nameof(sliceOffset));
|
||
return _lookInSlices[turn.SliceStart + sliceOffset].ClipSlot;
|
||
}
|
||
|
||
internal List<WalkBuilding> VisitedBuildings { get; } = new();
|
||
|
||
internal HashSet<uint> VisitedLandscapeCellIds { get; } = new();
|
||
|
||
// ---- transient per-Collect state (set in BeginFrame, read by Replay,
|
||
// cleared by Replay's own completion) ----
|
||
private IWalkBuildingFrameContext? _ctx;
|
||
private Matrix4x4 _viewProjection;
|
||
private Vector3 _cameraWorldPosition;
|
||
|
||
/// <summary>Frame-scoped fail-loud guard (replaces the deleted
|
||
/// <c>_skyDrawnThisFrame</c> proxy — S3 chunk 2): counts this frame's
|
||
/// <see cref="WalkEventKind.Landscape"/> turns so a second one throws
|
||
/// (see <see cref="HandleLandscapeTurn"/>) rather than double-drawing
|
||
/// sky/terrain. Retail's own gate — the interior-root flood's
|
||
/// landscape/flush/stamp/clear/seal turn — now reads the real
|
||
/// <c>outside_view.view_count</c> the sink receives, not this counter.</summary>
|
||
private int _landscapeTurnsThisFrame;
|
||
private WalkDrawStage? _currentDcStage;
|
||
private bool _readyToReplay;
|
||
private int _cellViewRouteIndex;
|
||
private int _landscapeViewRouteIndex;
|
||
|
||
/// <summary>S3 chunk 1 (§11.2 B1): the "F n" transcript frame counter —
|
||
/// monotonic across this driver's whole lifetime (never reset by
|
||
/// BeginFrame/AbortFrame/EndFrame), matching a live cdb capture's own
|
||
/// per-process frame counter. Only ever incremented when the transcript
|
||
/// flag is on, so it costs nothing while off.</summary>
|
||
private int _transcriptFrameNumber;
|
||
|
||
internal WalkFrameDriver(
|
||
WbDrawDispatcher dispatcher,
|
||
IWalkFrameLeafRenderer leafRenderer,
|
||
IWalkFrameWorldData worldData,
|
||
IWalkFrameDriverTrace? trace = null,
|
||
ClipFrame? clipFrame = null)
|
||
{
|
||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||
_leafRenderer = leafRenderer ?? throw new ArgumentNullException(nameof(leafRenderer));
|
||
_worldData = worldData ?? throw new ArgumentNullException(nameof(worldData));
|
||
_trace = trace;
|
||
_clipFrame = clipFrame;
|
||
_populator = new WalkStaticStreamPopulator(dispatcher);
|
||
}
|
||
|
||
/// <summary>
|
||
/// FW6 allocation closeout: reuse this driver's large event/view/stream
|
||
/// scratch across production frames while rebinding the frame-local leaf
|
||
/// and clip destination. A prior failed frame is discarded here so the
|
||
/// renderer's report-and-continue policy cannot poison the next frame.
|
||
/// </summary>
|
||
internal void RebindFrame(
|
||
IWalkFrameLeafRenderer leafRenderer,
|
||
ClipFrame? clipFrame)
|
||
{
|
||
AbortFrame();
|
||
_leafRenderer = leafRenderer
|
||
?? throw new ArgumentNullException(nameof(leafRenderer));
|
||
_clipFrame = clipFrame;
|
||
}
|
||
|
||
/// <summary>Discard only transient frame state; retained capacities stay
|
||
/// available for the next frame.</summary>
|
||
internal void AbortFrame()
|
||
{
|
||
_ctx = null;
|
||
_viewProjection = default;
|
||
_cameraWorldPosition = default;
|
||
_landscapeTurnsThisFrame = 0;
|
||
_currentDcStage = null;
|
||
_readyToReplay = false;
|
||
WeatherTurnFired = false;
|
||
_stream.Reset();
|
||
_events.Clear();
|
||
_markPositions.Clear();
|
||
_alphaSubmissions.Clear();
|
||
_pendingTerrainBatch.Clear();
|
||
_alphaSubmitMark = 0;
|
||
VisitedCells.Clear();
|
||
LookInCellTurns.Clear();
|
||
_lookInTurns.Clear();
|
||
_lookInSlices.Clear();
|
||
_lookInPlanes.Clear();
|
||
_floodViewRouteScratch.Clear();
|
||
_dispatcher.EndWalkPartFrame();
|
||
_cellShellsDrawnThisFrame.Clear();
|
||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||
_lookInCyPlane = default;
|
||
LookInCells.Clear();
|
||
VisitedBuildings.Clear();
|
||
VisitedLandscapeCellIds.Clear();
|
||
InteriorFloodCells.Clear();
|
||
_cellViewRouteIndex = 0;
|
||
_landscapeViewRouteIndex = -1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Copies the walk's diagnostic union: EnvCells reached by interior
|
||
/// floods/look-ins plus outdoor land cells visited by the landscape walk.
|
||
/// The two families remain separately retained because EnvCells are shell
|
||
/// preparation input while only the landscape half feeds land-cell particle
|
||
/// eligibility; EnvCell particle eligibility is the constant-true virtual.
|
||
/// Point lights use the resident registry, and directional-shadow use of the
|
||
/// landscape product remains owned by S5-c2. No admission path consumes this
|
||
/// union as a complete <c>CObjCell::IsInView</c> answer.
|
||
/// </summary>
|
||
internal void CopyVisibleCellsTo(HashSet<uint> destination)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(destination);
|
||
if (ReferenceEquals(destination, VisitedCells)
|
||
|| ReferenceEquals(destination, VisitedLandscapeCellIds))
|
||
{
|
||
throw new ArgumentException(
|
||
"The visible-cell destination cannot alias a walk source set.",
|
||
nameof(destination));
|
||
}
|
||
|
||
destination.Clear();
|
||
destination.UnionWith(VisitedCells);
|
||
destination.UnionWith(VisitedLandscapeCellIds);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drives one complete frame at retail's root (<c>SmartBox::RenderNormalMode</c>):
|
||
/// <see cref="Collect"/> immediately followed by <see cref="Replay"/>. Kept
|
||
/// for callers that don't need the split (today: the headless referee
|
||
/// suite) — <c>RetailPViewRenderer</c> calls the pair directly, since it
|
||
/// must run other frame work BETWEEN them (plan §FW3.4a).
|
||
/// </summary>
|
||
internal void RunFrame(
|
||
RetailFrameWalk walk,
|
||
uint cameraCellId,
|
||
WalkCell? cameraCell,
|
||
WalkLandscape landscape,
|
||
IRetailFrameWalkContext ctx,
|
||
IGpuFrame frame,
|
||
IGpuPassEncoder encoder,
|
||
Matrix4x4 viewProjection,
|
||
Vector3 cameraWorldPosition)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(frame);
|
||
ArgumentNullException.ThrowIfNull(encoder);
|
||
|
||
Collect(
|
||
walk, cameraCellId, cameraCell, landscape, ctx,
|
||
viewProjection, cameraWorldPosition);
|
||
Replay(frame, encoder);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a Phase 1 — THE ONE WALK. Drives
|
||
/// <see cref="RetailFrameWalk.WalkFrame"/> with this driver as its sink,
|
||
/// sandwiched between <see cref="BeginFrame"/>/<see cref="EndFrame"/>,
|
||
/// performing NO GPU work: see this type's own doc comment.
|
||
/// </summary>
|
||
internal void Collect(
|
||
RetailFrameWalk walk,
|
||
uint cameraCellId,
|
||
WalkCell? cameraCell,
|
||
WalkLandscape landscape,
|
||
IRetailFrameWalkContext ctx,
|
||
Matrix4x4 viewProjection,
|
||
Vector3 cameraWorldPosition)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(walk);
|
||
ArgumentNullException.ThrowIfNull(landscape);
|
||
ArgumentNullException.ThrowIfNull(ctx);
|
||
|
||
BeginFrame(ctx, viewProjection, cameraWorldPosition);
|
||
// S3 chunk 1 (§11.2 B1): the frame-root "F n" + "P …" print — AFTER
|
||
// BeginFrame (a scratch reset, not an admission decision), BEFORE
|
||
// the walk itself runs, matching retail's SmartBox::RenderNormalMode
|
||
// @0x00453aa0 breakpoint (entered once per frame, before either the
|
||
// outdoor or the indoor branch). Every cost here (the counter
|
||
// increment, the landblock-local subtraction) sits inside the flag
|
||
// check, so an ordinary launch pays nothing.
|
||
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
|
||
{
|
||
_transcriptFrameNumber++;
|
||
WalkTranscriptDump.PrintFrameRoot(
|
||
_transcriptFrameNumber,
|
||
cameraCellId,
|
||
cameraWorldPosition - new Vector3(
|
||
landscape.ViewerWorldOriginX, landscape.ViewerWorldOriginY, 0f),
|
||
ctx.CyPlane.Normal);
|
||
}
|
||
try
|
||
{
|
||
walk.WalkFrame(cameraCellId, cameraCell, landscape, ctx, this);
|
||
EndFrame();
|
||
}
|
||
catch
|
||
{
|
||
AbortFrame();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens a collect scope without driving the walk itself — for a caller
|
||
/// (or a test) that already holds an isolated walk entry point (e.g. one
|
||
/// <see cref="RetailFrameWalk.DrawBuilding"/> or
|
||
/// <see cref="RetailFrameWalk.DrawLandscape"/> call) and wants this
|
||
/// driver's turn handling without going through the top-level root.
|
||
/// <see cref="Collect"/> is implemented in terms of this pair. Performs no
|
||
/// GPU work — see this type's own doc comment.
|
||
/// </summary>
|
||
internal void BeginFrame(
|
||
IRetailFrameWalkContext ctx,
|
||
Matrix4x4 viewProjection,
|
||
Vector3 cameraWorldPosition)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(ctx);
|
||
if (_ctx is not null)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"WalkFrameDriver.BeginFrame was called while a previous frame was still open — "
|
||
+ "the driver is not re-entrant (Campaign FW3.2b-1 fail-loud rule); call "
|
||
+ "EndFrame (or let a thrown exception's cleanup run) before starting the next.");
|
||
}
|
||
|
||
_dispatcher.BeginWalkPartFrame();
|
||
_ctx = ctx;
|
||
_viewProjection = viewProjection;
|
||
_cameraWorldPosition = cameraWorldPosition;
|
||
_landscapeTurnsThisFrame = 0;
|
||
_currentDcStage = null;
|
||
_readyToReplay = false;
|
||
WeatherTurnFired = false;
|
||
_stream.Reset();
|
||
_events.Clear();
|
||
_markPositions.Clear();
|
||
_alphaSubmissions.Clear();
|
||
_pendingTerrainBatch.Clear();
|
||
_alphaSubmitMark = 0;
|
||
VisitedCells.Clear();
|
||
LookInCellTurns.Clear();
|
||
_lookInTurns.Clear();
|
||
_lookInSlices.Clear();
|
||
_lookInPlanes.Clear();
|
||
_lookInCyPlane = ctx.CyPlane;
|
||
_cellShellsDrawnThisFrame.Clear();
|
||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||
LookInCells.Clear();
|
||
VisitedBuildings.Clear();
|
||
VisitedLandscapeCellIds.Clear();
|
||
InteriorFloodCells.Clear();
|
||
_cellViewRouteIndex = 0;
|
||
_landscapeViewRouteIndex = -1;
|
||
}
|
||
|
||
/// <summary>Records the final segment mark (plan §FW3.2b-1's "at frame
|
||
/// end: final segment flush", now a mark rather than a draw — see this
|
||
/// type's own doc comment), then closes the collect scope. The recorded
|
||
/// stream/events survive this call — <see cref="Replay"/> consumes them —
|
||
/// which is the one behavioral difference from the pre-FW3.4a EndFrame,
|
||
/// which reset the stream here because it had just drawn it.</summary>
|
||
internal void EndFrame()
|
||
{
|
||
try
|
||
{
|
||
MarkIfGrown();
|
||
MarkAlphaIfGrown();
|
||
}
|
||
finally
|
||
{
|
||
_dispatcher.EndWalkPartFrame();
|
||
_ctx = null;
|
||
_readyToReplay = true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Campaign FW3.4a Phase 2. Requires a completed Collect (an
|
||
/// <see cref="EndFrame"/> having run since the last Replay) — throws
|
||
/// otherwise, rather than silently replaying a stale or empty event list.
|
||
/// Uploads the WHOLE collected stream exactly once (skipped when it is
|
||
/// empty), then walks the recorded events in order: a
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> issues one
|
||
/// <c>WbDrawDispatcher.DrawOrderedRange</c> call over the segment it
|
||
/// closes off; every other event kind issues its corresponding
|
||
/// <see cref="IWalkFrameLeafRenderer"/> call. Because Collect recorded
|
||
/// these events at EXACTLY the points the pre-FW3.4a immediate driver
|
||
/// used to flush/draw, this reproduces the SAME interleaved GPU command
|
||
/// order — the campaign invariant — from one walk instead of two.
|
||
///
|
||
/// <para><b>S3 chunk 3 fix round 2 (§9.7 F10) — the pending terrain
|
||
/// batch's flush points, authoritative list.</b> A
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> is NOT one of them: the
|
||
/// ordered stream it draws holds ONLY opaque batches — <c>WalkStaticStreamPopulator</c>'s
|
||
/// per-entity classify step appends to the stream iff <c>batch.IsOpaque</c>
|
||
/// and otherwise routes translucent batches to the alpha list
|
||
/// (WalkStaticStreamPopulator.cs:179) — drawn through the <c>Opaque</c>/
|
||
/// <c>OpaqueAlphaToCoverage</c> pipelines, both built with depth test AND
|
||
/// write ON and NO blending (<c>WbDrawDispatcher.CreateMeshPipeline</c>:
|
||
/// <c>Depth = new GpuDepthState(Test: true, Write: depthWrite,
|
||
/// depthCompare)</c> called with <c>depthWrite: true</c>,
|
||
/// <c>GpuBlendMode.None</c> for both, WbDrawDispatcher.Rhi.cs:240-248,343);
|
||
/// <c>DrawOrderedRange</c> resolves every walk-stream command's bucket to
|
||
/// <c>PipelineBucket.Opaque</c> because every command it holds is opaque
|
||
/// (WbDrawDispatcher.OrderedStream.cs:569-571). Opaque terrain and opaque
|
||
/// statics are therefore depth-resolved correctly in EITHER submission
|
||
/// order — pixel-identical except exact z-ties — so deferring the pending
|
||
/// terrain batch across a <c>StreamMark</c> changes nothing observable.
|
||
/// The flush points that DO remain, because their GPU order against
|
||
/// terrain IS observable: <see cref="WalkFrameEventKind.PunchFan"/>
|
||
/// (<c>DEPTHTEST_ALWAYS</c> + write — the far-Z stamp this chunk's
|
||
/// interleave exists to order correctly against nearer terrain, S3 §9.1
|
||
/// R4), <see cref="WalkFrameEventKind.AlphaBarrier"/> and
|
||
/// <see cref="WalkFrameEventKind.LandscapeFlush"/> (both drain the
|
||
/// translucent alpha list, which must see every opaque surface — terrain
|
||
/// included — already resolved in the depth buffer),
|
||
/// <see cref="WalkFrameEventKind.ClearInteriorDepth"/>,
|
||
/// <see cref="WalkFrameEventKind.ExitSeals"/>,
|
||
/// <see cref="WalkFrameEventKind.Sky"/>, <see cref="WalkFrameEventKind.CellShell"/>,
|
||
/// a <see cref="WalkFrameEventKind.StaticParticles"/>/
|
||
/// <see cref="WalkFrameEventKind.CellParticles"/> turn whose cell has a
|
||
/// renderable emitter (a genuinely empty turn still submits nothing and
|
||
/// still does not flush, unchanged from fix round 1's F2), and the end of
|
||
/// this method. This list's premise is the opaque-only, depth-test-and-
|
||
/// write, no-blend pipeline pair the walk's ordered stream actually uses
|
||
/// TODAY — not <c>StreamMark</c> itself: if any future ordered-stream
|
||
/// stage draws with blending or a disabled depth write (for example the
|
||
/// deferred building-detail overlay replay,
|
||
/// <c>WbDrawDispatcher.DrawBuildingDetailRangeRhi</c>'s
|
||
/// <c>RetailDetailTransparent</c> pass — not wired into this walk path
|
||
/// today, OrderedStream.cs:46-52), that stage's <c>StreamMark</c> MUST
|
||
/// become a flush point again; the contract gets amended, not bent.</para>
|
||
/// </summary>
|
||
internal void Replay(IGpuFrame frame, IGpuPassEncoder encoder)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(frame);
|
||
ArgumentNullException.ThrowIfNull(encoder);
|
||
if (!_readyToReplay)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"WalkFrameDriver.Replay was called without a completed Collect (BeginFrame/"
|
||
+ "EndFrame, or Collect/RunFrame) preceding it — there is nothing recorded to "
|
||
+ "replay.");
|
||
}
|
||
|
||
try
|
||
{
|
||
if (_stream.Count > 0)
|
||
_dispatcher.PrepareOrderedStream(frame, _stream, _viewProjection, _markPositions);
|
||
|
||
// S3 chunk 3 fix round 1 (F2): cleared at the start of THIS
|
||
// replay ("frame start") — a batch never survives across frames.
|
||
_pendingTerrainBatch.Clear();
|
||
|
||
int cursor = 0;
|
||
int alphaCursor = 0;
|
||
for (int i = 0; i < _events.Count; i++)
|
||
{
|
||
WalkFrameEvent e = _events[i];
|
||
switch (e.Kind)
|
||
{
|
||
case WalkFrameEventKind.StreamMark:
|
||
// S3 chunk 3 fix round 2 (F10): NOT a terrain flush
|
||
// point. The ordered stream this draws holds ONLY
|
||
// opaque batches (WalkStaticStreamPopulator.cs:179)
|
||
// drawn with the Opaque/OpaqueAlphaToCoverage
|
||
// pipelines — depth test AND write on, no blending
|
||
// (WbDrawDispatcher.Rhi.cs:240-248,343; bucket
|
||
// resolution WbDrawDispatcher.OrderedStream.cs:569-571)
|
||
// — so opaque terrain and opaque statics are
|
||
// depth-resolved correctly in either submission
|
||
// order. See this method's own doc comment for the
|
||
// full flush-point list and the citation this rests
|
||
// on.
|
||
int end = e.IntArg;
|
||
int count = end - cursor;
|
||
if (_trace is not null)
|
||
_trace.OnFlush(count, _stream.Stages.GetRange(cursor, count));
|
||
_dispatcher.DrawOrderedRange(encoder, cursor, count);
|
||
cursor = end;
|
||
break;
|
||
case WalkFrameEventKind.AlphaSubmitMark:
|
||
// Not a flush point (unchanged by fix round 2, F10):
|
||
// this arm only ENQUEUES the translucent instances
|
||
// into the CPU alpha list (SubmitWalkAlphaInstance);
|
||
// nothing reaches the GPU until the list drains at an
|
||
// AlphaBarrier / LandscapeFlush leaf or the pass end
|
||
// — both remain terrain flush points (F10) — so the
|
||
// drained alpha still lands after every preceding
|
||
// land cell's terrain, exactly as when unbatched.
|
||
int alphaEnd = e.IntArg;
|
||
for (; alphaCursor < alphaEnd; alphaCursor++)
|
||
{
|
||
WbDrawDispatcher.WalkClassifiedBatch batch =
|
||
_alphaSubmissions[alphaCursor];
|
||
_dispatcher.SubmitWalkAlphaInstance(
|
||
in batch,
|
||
_viewProjection);
|
||
}
|
||
break;
|
||
case WalkFrameEventKind.Sky:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.DrawSky();
|
||
break;
|
||
case WalkFrameEventKind.LandCell:
|
||
// S3 chunk 3 fix round 1 (F2), round 2 (F10):
|
||
// append-only — no lookahead, no same-landblock
|
||
// restriction, no flush. The batch may span several
|
||
// landblocks; it flushes only at one of the F10
|
||
// flush points (PunchFan, AlphaBarrier,
|
||
// LandscapeFlush, ClearInteriorDepth, ExitSeals,
|
||
// Sky, CellShell, a has-emitter particle turn) or at
|
||
// Replay's own end — NOT at a StreamMark, since the
|
||
// opaque ordered stream's GPU order against terrain
|
||
// is unobservable (see Replay's own doc comment).
|
||
_pendingTerrainBatch.Add((e.CellId, e.IntArg >> 8, e.IntArg & 0xFF));
|
||
break;
|
||
case WalkFrameEventKind.CellShell:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.DrawCellShell(e.CellId);
|
||
break;
|
||
case WalkFrameEventKind.PunchFan:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.DrawPunchFan(e.Polygon!, e.IntArg);
|
||
break;
|
||
case WalkFrameEventKind.AlphaBarrier:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.AlphaBarrier();
|
||
break;
|
||
case WalkFrameEventKind.SortCellExit:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.FlushSortCellExit();
|
||
break;
|
||
case WalkFrameEventKind.LandscapeFlush:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.FlushLandscape();
|
||
break;
|
||
case WalkFrameEventKind.ClearInteriorDepth:
|
||
FlushPendingTerrainBatch();
|
||
_leafRenderer.ClearInteriorDepth();
|
||
break;
|
||
case WalkFrameEventKind.ExitSeals:
|
||
FlushPendingTerrainBatch();
|
||
// S3 §8.2 B2: the driver — not Collect — owns adding
|
||
// the leaf's returned submitted-fan count to the
|
||
// persistent PortalsDrawnCount, since the real
|
||
// enumeration (and therefore the real count) only
|
||
// exists once the production leaf actually runs, at
|
||
// Replay.
|
||
PortalsDrawnCount += _leafRenderer.DrawExitSeals();
|
||
break;
|
||
case WalkFrameEventKind.StaticParticles:
|
||
// S3 chunk 3 fix round 1 (F2): a genuinely empty
|
||
// particle turn submits nothing — and, since it
|
||
// submits nothing, must NOT flush the pending
|
||
// terrain batch either (the batch is order-preserving
|
||
// only because a skipped turn changes nothing about
|
||
// what reaches the GPU, at all). Retail
|
||
// CPhysicsObj::add_particle_shadow_to_cell
|
||
// (0x00514a70): an emitter owns one shadow in its OWN
|
||
// current cell, drawn at that cell's object turn
|
||
// regardless of any owner's registry membership —
|
||
// this is a cell lookup, not an owner union.
|
||
SubmitCellAlpha(
|
||
e,
|
||
ref alphaCursor,
|
||
staticParticleTurn: true);
|
||
break;
|
||
case WalkFrameEventKind.CellParticles:
|
||
SubmitCellAlpha(
|
||
e,
|
||
ref alphaCursor,
|
||
staticParticleTurn: false);
|
||
break;
|
||
}
|
||
}
|
||
// S3 chunk 3 fix round 1 (F2), round 2 (F10): the end of Replay
|
||
// is itself a flush point — the remainder never crosses into
|
||
// the next frame.
|
||
FlushPendingTerrainBatch();
|
||
}
|
||
finally
|
||
{
|
||
_stream.Reset();
|
||
_events.Clear();
|
||
_markPositions.Clear();
|
||
_alphaSubmissions.Clear();
|
||
_pendingTerrainBatch.Clear();
|
||
_alphaSubmitMark = 0;
|
||
_readyToReplay = false;
|
||
_ctx = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// S4-c3a: completes one retail object-cell turn. Object delayed subsets
|
||
/// are already in stable far→near part order. Real particle records are
|
||
/// prepared only now, then merged by their retained CYpt before either
|
||
/// source appends to the two untouched FIFO queue lists. Object wins an
|
||
/// exactly equal cross-source key; AP-242 records that deterministic
|
||
/// source tie because acdream has no shared physical registration
|
||
/// ordinal. The event always drains its object range, including a cell
|
||
/// with no particle emitters.
|
||
/// </summary>
|
||
private void SubmitCellAlpha(
|
||
in WalkFrameEvent e,
|
||
ref int alphaCursor,
|
||
bool staticParticleTurn)
|
||
{
|
||
ReadOnlySpan<PreparedParticleAlphaSubmission> particles =
|
||
ReadOnlySpan<PreparedParticleAlphaSubmission>.Empty;
|
||
bool includeParticles = e.FloatArg != 0f;
|
||
if (includeParticles && _leafRenderer.HasRenderableEmittersInCell(e.CellId))
|
||
{
|
||
// Preserve the existing particle-turn terrain boundary: a row-5
|
||
// immediate mesh may draw during preparation.
|
||
FlushPendingTerrainBatch();
|
||
particles = staticParticleTurn
|
||
? _leafRenderer.PrepareStaticParticles(e.CellId)
|
||
: _leafRenderer.PrepareCellParticles(e.CellId);
|
||
}
|
||
|
||
int objectEnd = e.IntArg;
|
||
int particleIndex = 0;
|
||
while (alphaCursor < objectEnd || particleIndex < particles.Length)
|
||
{
|
||
bool takeParticle = particleIndex < particles.Length
|
||
&& (alphaCursor >= objectEnd
|
||
|| particles[particleIndex].DistanceSq
|
||
> _alphaSubmissions[alphaCursor].SortDistanceSq);
|
||
if (takeParticle)
|
||
{
|
||
particles[particleIndex++].Append();
|
||
}
|
||
else
|
||
{
|
||
WbDrawDispatcher.WalkClassifiedBatch batch =
|
||
_alphaSubmissions[alphaCursor++];
|
||
_dispatcher.SubmitWalkAlphaInstance(in batch, _viewProjection);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>S3 chunk 3 fix round 1 (F2), round 2 (F10): submits the
|
||
/// pending terrain batch as ONE <see cref="IWalkFrameLeafRenderer.DrawLandCellBatch"/>
|
||
/// call, if it holds anything, and clears it. Called immediately before
|
||
/// every event kind whose GPU order against terrain is OBSERVABLE — see
|
||
/// <see cref="Replay"/>'s own doc comment for the full flush-point list
|
||
/// and its citation — so the batch's actual submission point is always
|
||
/// the exact point the unbatched per-cell draws would have occupied
|
||
/// relative to those events. A <see cref="WalkFrameEventKind.StreamMark"/>
|
||
/// is deliberately NOT one of the callers (F10): the opaque ordered
|
||
/// stream it draws is depth-resolved identically regardless of
|
||
/// submission order relative to terrain.</summary>
|
||
private void FlushPendingTerrainBatch()
|
||
{
|
||
if (_pendingTerrainBatch.Count == 0)
|
||
return;
|
||
_leafRenderer.DrawLandCellBatch(_pendingTerrainBatch);
|
||
_pendingTerrainBatch.Clear();
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// IWalkEventSink
|
||
// ------------------------------------------------------------------
|
||
|
||
void IWalkEventSink.Emit(in WalkEvent walkEvent)
|
||
{
|
||
switch (walkEvent.Kind)
|
||
{
|
||
case WalkEventKind.DrawInside:
|
||
// S3 chunk 1 (§11.2 B1): PView::DrawInside @0x005a5860 entry.
|
||
WalkTranscriptDump.PrintDrawInside(walkEvent.CellId);
|
||
_currentDcStage = WalkDrawStage.CellStatic;
|
||
VisitedCells.Add(walkEvent.CellId);
|
||
break;
|
||
case WalkEventKind.Landscape:
|
||
// S3 chunk 1 (§11.2 B1): LScape::draw @0x00506330 entry —
|
||
// printed before the driver's own duplicate-turn guard so a
|
||
// desynced second LS still shows up in the transcript
|
||
// (the guard is a walk/driver fail-loud check, not a retail
|
||
// admission decision retail's own breakpoint would skip).
|
||
WalkTranscriptDump.PrintLandscape();
|
||
HandleLandscapeTurn(walkEvent.OutsideViewCount);
|
||
break;
|
||
case WalkEventKind.DrawCells:
|
||
// S3 chunk 1 (§11.2 B1): PView::DrawCells @0x005a4840 entry
|
||
// (breakpoint-entry order, matching the FW0/OH captures).
|
||
// _currentDcStage at THIS point (set by the immediately
|
||
// preceding DI or OnBuildingTurn event, never mutated by
|
||
// this case itself) tells us which PView owns this flood:
|
||
// CellStatic == the interior root's own flood (interior
|
||
// pview); anything else (LookInStatic) == a building
|
||
// look-in through the outdoor pview.
|
||
WalkTranscriptDump.PrintDrawCells(
|
||
outdoorPview: _currentDcStage == WalkDrawStage.LookInStatic,
|
||
walkEvent.OutsideViewCount,
|
||
walkEvent.Cells);
|
||
foreach (uint id in walkEvent.Cells)
|
||
VisitedCells.Add(id);
|
||
HandleDrawCellsTurn(walkEvent.Cells);
|
||
break;
|
||
case WalkEventKind.Building:
|
||
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawBuilding
|
||
// @0x0059f2a0 entry — unconditional, matching the BLD event's
|
||
// own "fires at ENTRY, before the degrade check" contract
|
||
// (this type's class doc).
|
||
WalkTranscriptDump.PrintBuilding(walkEvent.CellId);
|
||
// OnBuildingTurn (below) carries the actual side effects —
|
||
// this vocabulary-only event needs no driver action.
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>S3 chunk 3 (§9.2 B1): records this admitted land cell's
|
||
/// terrain turn. <c>sideCellCount</c>/<c>cellIndex</c> travel packed
|
||
/// (not expanded) — R3's index-run arithmetic runs at LOD resolution in
|
||
/// the production leaf, not at 8×8-bucket resolution like the object-
|
||
/// list turn's expansion.</summary>
|
||
void IWalkEventSink.OnLandCellTurn(uint landblockId, int sideCellCount, int cellIndex)
|
||
{
|
||
RequireOpenFrame();
|
||
if (sideCellCount is not (1 or 2 or 4 or 8))
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(sideCellCount),
|
||
sideCellCount,
|
||
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
|
||
}
|
||
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
|
||
throw new ArgumentOutOfRangeException(nameof(cellIndex));
|
||
|
||
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawLandCell @0x0059f120
|
||
// entry — one LC line per admitted LOD land cell, at LOD resolution
|
||
// (never expanded to the 8x8 owner-bucket ids OnLandscapeCellTurn
|
||
// uses for object-list content). S3 chunk 1 fix round 1 (G2): the
|
||
// flag gate wraps the LodCellId computation itself, not just
|
||
// WalkTranscriptDump.PrintLandCell's own internal check — an
|
||
// ordinary launch (flag off) must not pay for the division/modulo/
|
||
// checked-conversion work on ~1,300 land-cell turns/frame just to
|
||
// discard the result.
|
||
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
|
||
{
|
||
WalkTranscriptDump.PrintLandCell(
|
||
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
|
||
}
|
||
|
||
// The one mark rule (this type's own doc comment): flush any
|
||
// already-queued stream content ahead of this leaf-renderer event.
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.LandCell(landblockId, sideCellCount, cellIndex));
|
||
}
|
||
|
||
/// <summary>S3 chunk 1 fix round 1 (G7): <c>RenderDeviceD3D::
|
||
/// DrawSortCell</c> @0x0059f140 ENTRY — fired by <see cref="RetailFrameWalk.DrawLandscape"/>
|
||
/// BEFORE that turn's own <c>DrawBuilding</c> call, so the SC line
|
||
/// prints between LC and BLD (retail's own order — see the interface
|
||
/// member's own doc comment; the three-lens review's transcript pass
|
||
/// caught the earlier placement, which printed SC from
|
||
/// <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/> AFTER
|
||
/// DrawBuilding had already run). Print-only — no stream-side effect of
|
||
/// its own; the object-list turn stays entirely in
|
||
/// <c>OnLandscapeCellTurn</c> below, unchanged.
|
||
/// <para>
|
||
/// S3 chunk 1 fix round 2 (§11.6 H4): UNLIKE <see cref="OnLandCellTurn"/>
|
||
/// (whose <c>RequireOpenFrame</c>/range validation stay unconditional —
|
||
/// that hook always records a real <c>WalkFrameEvent</c>), this hook has
|
||
/// NO side effect beyond the print, so the flag gate wraps the ENTIRE
|
||
/// body, not just the <c>LodCellId</c> computation: an ordinary launch
|
||
/// pays exactly one interface dispatch per visited land cell and nothing
|
||
/// else (docs/launch-options.md).
|
||
/// </para>
|
||
/// </summary>
|
||
void IWalkEventSink.OnSortCellTurn(uint landblockId, int sideCellCount, int cellIndex)
|
||
{
|
||
if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
|
||
return;
|
||
|
||
RequireOpenFrame();
|
||
if (sideCellCount is not (1 or 2 or 4 or 8))
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(sideCellCount),
|
||
sideCellCount,
|
||
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
|
||
}
|
||
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
|
||
throw new ArgumentOutOfRangeException(nameof(cellIndex));
|
||
|
||
WalkTranscriptDump.PrintSortCell(
|
||
WalkTranscriptDump.LodCellId(landblockId, sideCellCount, cellIndex));
|
||
}
|
||
|
||
void IWalkEventSink.OnLandscapeCellTurn(uint cellId)
|
||
=> HandleLandscapeCellTurn(cellId);
|
||
|
||
/// <summary>The object-list half of retail's DrawSortCell turn — see
|
||
/// <see cref="IWalkEventSink.OnSortCellTurn"/> for the SC transcript
|
||
/// line, which now fires separately, BEFORE the building's own turn
|
||
/// (S3 chunk 1 fix round 1, G7); this method never prints one.</summary>
|
||
void IWalkEventSink.OnLandscapeCellTurn(
|
||
uint landblockId,
|
||
int sideCellCount,
|
||
int cellIndex)
|
||
{
|
||
if (sideCellCount is not (1 or 2 or 4 or 8))
|
||
{
|
||
throw new ArgumentOutOfRangeException(
|
||
nameof(sideCellCount),
|
||
sideCellCount,
|
||
"A landscape LOD grid must be 1, 2, 4, or 8 cells per side.");
|
||
}
|
||
if ((uint)cellIndex >= (uint)(sideCellCount * sideCellCount))
|
||
throw new ArgumentOutOfRangeException(nameof(cellIndex));
|
||
|
||
uint blockPrefix = landblockId & 0xFFFF0000u;
|
||
if (sideCellCount == 8)
|
||
{
|
||
HandleLandscapeCellTurn(blockPrefix | checked((uint)(cellIndex + 1)));
|
||
return;
|
||
}
|
||
|
||
// CLandBlock's coarse DrawSortCell owns the shadows from every
|
||
// original 24 m land cell it covers. Our render journal deliberately
|
||
// retains those authoritative 8x8 owner buckets, so turn all covered
|
||
// buckets here while preserving the coarse cell's single walk turn.
|
||
int span = 8 / sideCellCount;
|
||
int coarseX = cellIndex / sideCellCount;
|
||
int coarseY = cellIndex % sideCellCount;
|
||
int firstX = coarseX * span;
|
||
int firstY = coarseY * span;
|
||
for (int x = firstX; x < firstX + span; x++)
|
||
{
|
||
for (int y = firstY; y < firstY + span; y++)
|
||
{
|
||
HandleLandscapeCellTurn(
|
||
blockPrefix | checked((uint)(x * 8 + y + 1)));
|
||
}
|
||
}
|
||
}
|
||
|
||
private void HandleLandscapeCellTurn(uint cellId)
|
||
{
|
||
RequireOpenFrame();
|
||
if (_landscapeViewRouteIndex < 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"A landscape cell turn fired before RetailFrameWalk installed its active "
|
||
+ "view set — the walk and draw driver are desynchronized.");
|
||
}
|
||
VisitedLandscapeCellIds.Add(cellId);
|
||
WalkFrameStaticRecords records = _worldData.GetOutdoorObjects(cellId);
|
||
_populator.PopulateCellObjects(
|
||
_stream,
|
||
WalkDrawStage.OutdoorStatic,
|
||
cellId,
|
||
records.Records,
|
||
records.TupleLandblockId,
|
||
_cameraWorldPosition,
|
||
_viewProjection,
|
||
this,
|
||
_landscapeViewRouteIndex,
|
||
_alphaSubmissions);
|
||
// FW4 (the #132 positional invariant): this cell's emitters submit
|
||
// AT THIS TURN, so nearer buildings' pre-punch barriers drain them
|
||
// against still-true depth — see WalkFrameEventKind.StaticParticles.
|
||
// Mark first so the cell's own meshes flush ahead of its particle
|
||
// submission (retail's per-object DrawObjCell order). Fires
|
||
// unconditionally: retail's add_particle_shadow_to_cell draws an
|
||
// emitter at its own cell's turn independent of whether that cell
|
||
// has any visible static/dynamic owner record (Campaign OVERHAUL S2
|
||
// chunk 6 — a suspended/hidden owner's emitter must still show).
|
||
MarkIfGrown();
|
||
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
|
||
_alphaSubmitMark = _alphaSubmissions.Count;
|
||
_events.Add(WalkFrameEvent.LandscapeCellParticles(
|
||
cellId,
|
||
_alphaSubmitMark,
|
||
includeParticles));
|
||
}
|
||
|
||
/// <summary>S4-c2: retail <c>RenderDeviceD3D::DrawBlock</c> @0x005a17c0's
|
||
/// (per-land-cell loop head @0x005a18d0) own <c>FlushAlphaList(::flush)</c>
|
||
/// @0x005a1a07 (the 0.75f
|
||
/// pressure valve) — fires once per admitted land-block cell, at the SAME
|
||
/// gate <see cref="IWalkEventSink.OnLandscapeCellTurn(uint,int,int)"/>
|
||
/// already fires under (<c>RetailFrameWalk.DrawLandscape</c> calls this
|
||
/// immediately after that turn, still inside the loop body). Marks first,
|
||
/// same as <see cref="IWalkEventSink.OnBuildingTurn"/>'s AlphaBarrier
|
||
/// emission, so this cell's own content flushes ahead of the valve.</summary>
|
||
void IWalkEventSink.OnSortCellExit(uint landblockId, int sideCellCount, int cellIndex)
|
||
{
|
||
RequireOpenFrame();
|
||
MarkIfGrown();
|
||
MarkAlphaIfGrown();
|
||
_events.Add(WalkFrameEvent.SortCellExit());
|
||
}
|
||
|
||
void IWalkEventSink.OnLandscapeViews(WalkPortalView activeViews)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(activeViews);
|
||
RequireOpenFrame();
|
||
_landscapeViewRouteIndex = _cellViewRouteIndex++;
|
||
CaptureViews(0, activeViews);
|
||
}
|
||
|
||
void IWalkEventSink.OnBuildingTurn(WalkBuilding building)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(building);
|
||
RequireOpenFrame();
|
||
VisitedBuildings.Add(building);
|
||
|
||
// D3DPolyRender::FlushAlphaList(0f) @0x0059f30b — retail's alpha
|
||
// barrier, first inside the gate. The portal pass (punches +
|
||
// look-ins) follows this call; the building's own shell content is
|
||
// appended only once that pass completes (OnBuildingShellTurn).
|
||
MarkIfGrown();
|
||
MarkAlphaIfGrown();
|
||
_events.Add(WalkFrameEvent.AlphaBarrier());
|
||
|
||
_currentDcStage = WalkDrawStage.LookInStatic;
|
||
}
|
||
|
||
void IWalkEventSink.OnBuildingShellTurn(WalkBuilding building)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(building);
|
||
RequireOpenFrame();
|
||
|
||
// CPhysicsPart::Draw(parts, 0) @0x0059f331 — retail's plain-mesh
|
||
// shell draw, strictly after the portal pass (CPhysicsPart::Draw
|
||
// (parts, 1)). Mark first so this building's shell content never
|
||
// shares a replayed range with whatever the portal pass's last
|
||
// look-in flood appended (keeps every range single-stage).
|
||
MarkIfGrown();
|
||
WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building);
|
||
_populator.PopulateCell(
|
||
_stream, WalkDrawStage.BuildingShell, building.PositionCellId,
|
||
shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection,
|
||
alphaSubmissions: _alphaSubmissions);
|
||
if (_alphaSubmissions.Count != _alphaSubmitMark)
|
||
{
|
||
MarkIfGrown();
|
||
MarkAlphaIfGrown();
|
||
}
|
||
// Campaign OVERHAUL S2 chunk 6: a building's shell fires no particle
|
||
// turn of its own any more. Retail's add_particle_shadow_to_cell
|
||
// (0x00514a70) gives an emitter exactly one shadow in ITS OWN
|
||
// current cell — a building's interior emitters live in their own
|
||
// EnvCells and draw at those cells' own CellParticles turns, never
|
||
// at their parent building's shell turn.
|
||
}
|
||
|
||
void IWalkEventSink.OnPunchGeometry(
|
||
WalkBuilding building, WalkPolygon polygon, int activeViewIndex)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(building);
|
||
ArgumentNullException.ThrowIfNull(polygon);
|
||
RequireOpenFrame();
|
||
|
||
// S4-c1 C1: DrawPortalPolyInternal's degenerate-input guard
|
||
// (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard's own
|
||
// doc comment) — tested on the polygon's LOCAL vertices, BEFORE the
|
||
// building -> world transform below. A hit drops the whole polygon:
|
||
// no punch event, no transform, no counter effect (retail's own
|
||
// reject -> transform -> clip -> count order).
|
||
if (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon.Vertices))
|
||
return;
|
||
|
||
MarkIfGrown();
|
||
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
|
||
_events.Add(
|
||
WalkFrameEvent.PunchFan(TransformToWorld(polygon, worldTransform), activeViewIndex));
|
||
}
|
||
|
||
void IWalkEventSink.OnInteriorFloodDrawTurn(IReadOnlyList<uint> cells, int outsideViewCount)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(cells);
|
||
RequireOpenFrame();
|
||
|
||
// PView::DrawCells @0x005a4840 (S3 §8.1 R3): the landscape flush, the
|
||
// device-stamp advance, the gated depth clear, and the exit-portal
|
||
// seals ALL sit strictly inside `if (outside_view.view_count > 0)`
|
||
// (0x005a4852-0x005a49eb) — outsideViewCount==0 skips straight to
|
||
// the flood's own cells below with none of the four having run.
|
||
if (outsideViewCount > 0)
|
||
{
|
||
// D3DPolyRender::FlushAlphaList(0f) @0x005a4872, plus the
|
||
// pre-clear dynamics hook RetailPViewRenderer.FlushWalkLandscape
|
||
// bundles — the first action inside the gate.
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.LandscapeFlush());
|
||
|
||
// m_nFrameStamp += 1 @0x005a4886. Its drawn-part AND DrawEnvCell
|
||
// dedupe is per render stamp, not per presented frame: content
|
||
// admitted during the landscape must remain eligible for the
|
||
// interior-cell repaint after the clear. In Collect, every
|
||
// landscape candidate has been classified by this point and no
|
||
// interior-root candidate has, so re-arming both CPU-side stamp
|
||
// mirrors here is the exact boundary. Without the shell re-arm,
|
||
// color from a pre-clear building look-in survives while the
|
||
// root repaint is incorrectly suppressed, producing
|
||
// wall-textured bleed slabs.
|
||
_dispatcher.AdvanceWalkPartPassStamp();
|
||
_cellShellsDrawnThisFrame.Clear();
|
||
_cellParticleTurnsDrawnThisFrame.Clear();
|
||
|
||
// The gated full depth clear (pc:432731-432732): retail's
|
||
// portalsDrawnCount (D3DPolyRender::portalsDrawnCount,
|
||
// uint16 @0x008719b4) is read-then-zeroed HERE
|
||
// (@0x005a489c-0x005a489e; R4) — forceClear never writes in the
|
||
// pseudo-C, so the clear fires iff the counter was nonzero. A
|
||
// fresh driver's very first ov>0 frame therefore draws NO clear
|
||
// (portalsDrawnCount starts at 0); every later ov>0 frame clears
|
||
// because the PREVIOUS frame's own exit seals armed the counter
|
||
// at Replay (see PortalsDrawnCount's own doc comment).
|
||
int armed = PortalsDrawnCount;
|
||
PortalsDrawnCount = 0;
|
||
if (armed != 0)
|
||
{
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.ClearInteriorDepth());
|
||
}
|
||
|
||
// The exit-portal seals (pc:432785-432786) — this turn's
|
||
// submitted fan count re-arms PortalsDrawnCount at Replay (B2),
|
||
// read by the NEXT ov>0 frame's decision above.
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.ExitSeals());
|
||
}
|
||
|
||
// FW4 slice 2: retain the ordered flood for the seal draw (the
|
||
// DrawExitSeals leaf runs at Replay, when Collect has long filled
|
||
// this) — see the property's own doc comment.
|
||
InteriorFloodCells.Clear();
|
||
for (int i = 0; i < cells.Count; i++)
|
||
InteriorFloodCells.Add(cells[i]);
|
||
|
||
EmitFloodTurns(WalkDrawStage.CellStatic, cells);
|
||
}
|
||
|
||
/// <summary>S3 chunk 4 (O3): retail's <c>GameSky::Draw(sky,1)</c>
|
||
/// @0x00506ff0 weather pass — <see cref="RetailFrameWalk.DrawLandscape"/>
|
||
/// only calls this when its own gate already held, so this handler
|
||
/// records <see cref="WeatherTurnFired"/> unconditionally (S3 chunk 4
|
||
/// fix round 1, K2 — the one predicate the Replay-time draw now reads,
|
||
/// so a landscape turn that ran with the weather gate open can never be
|
||
/// under-reported to the draw side) and is otherwise print-only,
|
||
/// matching <see cref="IWalkEventSink.OnSortCellTurn"/>'s own shape (no
|
||
/// stream side effect). The GPU draw stays where chunk 1 round 2 put
|
||
/// it — <see cref="RetailPViewPassExecutor.DrawWeatherOnce"/>, called
|
||
/// directly from <see cref="RetailPViewRenderer.DrawLandscapeDynamicsPhase"/>
|
||
/// at Replay time, now gated on THIS flag instead of re-deriving its
|
||
/// own independent condition.</summary>
|
||
void IWalkEventSink.OnWeatherTurn(uint viewerCellId)
|
||
{
|
||
RequireOpenFrame();
|
||
WeatherTurnFired = true;
|
||
|
||
if (!AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
|
||
return;
|
||
|
||
WalkTranscriptDump.PrintObjectCellTurn(viewerCellId);
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// Turn handlers
|
||
// ------------------------------------------------------------------
|
||
|
||
private void HandleLandscapeTurn(int activeViewCount)
|
||
{
|
||
RequireOpenFrame();
|
||
if (activeViewCount < 1)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"A Landscape turn fired with {activeViewCount} active views — "
|
||
+ "RetailFrameWalk only draws the landscape through an installed view set "
|
||
+ "(the outdoor root's full-screen default view, or an interior root's "
|
||
+ "surviving exit views, both at least 1). A zero/negative count is a "
|
||
+ "walk/driver desync (Campaign FW fail-loud rule).");
|
||
}
|
||
if (_landscapeTurnsThisFrame != 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"A second Landscape turn fired in one frame — RetailFrameWalk.WalkFrame/"
|
||
+ "DrawInside's own call graph guarantees at most one Landscape turn per "
|
||
+ "frame (outdoor root draws it once; an interior root draws it at most "
|
||
+ "once more, through surviving exit views). A second occurrence is a walk/"
|
||
+ "driver desync, not something to silently double-draw sky for (Campaign "
|
||
+ "FW3.2b-1 fail-loud rule).");
|
||
}
|
||
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.Sky());
|
||
_landscapeTurnsThisFrame++;
|
||
// S3 chunk 3 (§9.1 R4/§9.2 B1): the FW4-slice-6 whole-stage terrain
|
||
// turn (retail draws terrain blocks ONCE per landscape turn) is
|
||
// SUPERSEDED — retail's true per-cell DrawLandCell/DrawSortCell
|
||
// interleave now emits one WalkFrameEventKind.LandCell turn per
|
||
// admitted land cell (IWalkEventSink.OnLandCellTurn, fired from
|
||
// RetailFrameWalk.DrawLandscape's own per-cell loop), not a single
|
||
// TerrainSlice(0) here. S3 chunk 3 fix round 1 (F6) corrects the
|
||
// direction of this note: drawing ALL terrain BEFORE every building
|
||
// let a FARTHER building's far-Z punch survive, because the NEARER
|
||
// terrain that retail draws AFTER that punch (and which overwrites
|
||
// it) was instead drawn BEFORE it here — nothing later covered the
|
||
// punched depth, so a doorway behind a hill punched straight through
|
||
// it. The interleave fixes this by ORDER alone: nearer terrain now
|
||
// draws AFTER the farther building's punch, exactly like retail. The
|
||
// walk's active views still own only the punch fans and dynamics
|
||
// apertures; terrain itself is UNCLIPPED for both root kinds.
|
||
}
|
||
|
||
private void HandleDrawCellsTurn(IReadOnlyList<uint> cells)
|
||
{
|
||
RequireOpenFrame();
|
||
if (_currentDcStage is not { } stage)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"A DrawCells turn fired before any DrawInside or Building turn established "
|
||
+ "which stage its cells belong to — a walk/driver desync (Campaign FW3.2b-1 "
|
||
+ "fail-loud rule): RetailFrameWalk only ever emits DrawCells after DrawInside "
|
||
+ "(the interior root's own flood) or after a building's look-in portal pass.");
|
||
}
|
||
|
||
if (stage == WalkDrawStage.CellStatic)
|
||
{
|
||
// The interior root's OWN flood: this DC event fired at
|
||
// breakpoint-ENTRY order (matching the FW0 oracle traces), not
|
||
// retail's actual draw point — RECORD only. RetailFrameWalk.
|
||
// DrawInside hands the SAME cell list to
|
||
// OnInteriorFloodDrawTurn later, at the point retail really
|
||
// draws it (after the landscape turn, or immediately when no
|
||
// exit views survived) — see that method's doc comment.
|
||
return;
|
||
}
|
||
|
||
// Any other stage (LookInStatic) is a building's look-in flood:
|
||
// retail calls DrawCells re-entrantly there with no landscape/clear/
|
||
// seal step, so its DC event already fires at the real draw point —
|
||
// record immediately. PView::DrawCells uses two complete reverse
|
||
// loops: every EnvCell shell first, then every cell object list.
|
||
EmitFloodTurns(stage, cells);
|
||
}
|
||
|
||
private void EmitFloodTurns(WalkDrawStage stage, IReadOnlyList<uint> cells)
|
||
{
|
||
_floodViewRouteScratch.Clear();
|
||
for (int i = 0; i < cells.Count; i++)
|
||
_floodViewRouteScratch.Add(-1);
|
||
|
||
// PView::DrawCells @0x005A4840, loop 2 (005A4A00–005A4ADE):
|
||
// cell_draw_list[count - 1] down to zero. The loop calls setup_view +
|
||
// DrawEnvCell for each live view, but DrawEnvCell @0x0059F170 begins
|
||
// with GetDrawnThisFrame/SetDrawnThisFrame. Its built-mesh path then
|
||
// calls D3DPolyRender::DrawMesh directly: the EnvCell shell is drawn
|
||
// WHOLE on the first call and every later view/cross-flood call is a
|
||
// no-op for this render stamp. Portal-view clipping remains active for
|
||
// the following object-list loop; it is not a shell geometry clip.
|
||
for (int i = cells.Count - 1; i >= 0; i--)
|
||
{
|
||
int viewRouteIndex = CaptureCellViewRoute(cells[i]);
|
||
_floodViewRouteScratch[i] = viewRouteIndex;
|
||
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawEnvCell
|
||
// @0x0059f170 entry — UNCONDITIONAL, before the per-render-stamp
|
||
// dedupe below. Retail's own stamp check (GetDrawnThisFrame/
|
||
// SetDrawnThisFrame) sits INSIDE DrawEnvCell, past this
|
||
// breakpoint's address, so every flood visit prints an EC line
|
||
// even when the visit's actual shell submission is suppressed —
|
||
// proof: holtburg-doorway-still.walk.log:1126,1131,1134,1137
|
||
// print "EC a9b40100" four times in ONE frame across four
|
||
// look-in DC turns that all redraw the same cell. S3 chunk 1 fix
|
||
// round 2 (§11.6 H2): "EC and OC counts are always exactly
|
||
// equal" (an earlier version of this comment) is FALSE —
|
||
// terrace-edge has 12 EC vs 16 OC, the extra 4 being the
|
||
// trailing per-frame weather OC (§11.6 H1), which has no EC
|
||
// counterpart at all; see WalkTranscriptDump.PrintEnvCellShell's
|
||
// own doc comment for the same citation.
|
||
//
|
||
// S3 review fix round 1 (F4a): retail's breakpoint sits inside
|
||
// the setup_view/DrawEnvCell LOOP (PView::DrawCells
|
||
// @0x005a4ab1-0x005a4acc), which iterates the cell's live
|
||
// portal_view slices — @0x0059f170's own entry, above, fires
|
||
// once PER live view, not once per cell. Read the SliceCount
|
||
// directly off the route CaptureCellViewRoute just captured
|
||
// (_lookInTurns[viewRouteIndex]) rather than through
|
||
// InteriorFloodViewSliceCountAt's flood-index indirection: this
|
||
// loop also runs for a building's LOOK-IN flood
|
||
// (WalkDrawStage.LookInStatic), whose cells never populate
|
||
// InteriorFloodCells (that list is the interior ROOT flood
|
||
// only — see its own doc comment) — indexing it here would
|
||
// throw ArgumentOutOfRangeException on a real look-in turn. A
|
||
// cell with zero live views (an untracked/degenerate route)
|
||
// prints zero EC lines, matching setup_view never iterating an
|
||
// empty view list. The SHELL DRAW event below is unchanged —
|
||
// the frame stamp still dedupes it to one submission regardless
|
||
// of how many times this prints.
|
||
// Guarded at the call site (the §11.5 G2 convention): flag off
|
||
// costs one bool read here, not a list index plus N no-op calls.
|
||
if (AcDream.Core.Rendering.RenderingDiagnostics.DumpWalkTranscriptEnabled)
|
||
{
|
||
int liveViewCount = _lookInTurns[viewRouteIndex].SliceCount;
|
||
for (int view = 0; view < liveViewCount; view++)
|
||
WalkTranscriptDump.PrintEnvCellShell(cells[i]);
|
||
}
|
||
if (_cellShellsDrawnThisFrame.Add(cells[i]))
|
||
{
|
||
MarkIfGrown();
|
||
_events.Add(WalkFrameEvent.CellShell(cells[i]));
|
||
}
|
||
}
|
||
|
||
// Loop 3 (005A4ADE–005A4B2D): restart at count - 1 and draw each
|
||
// complete object cell. Keep a stream mark per cell so the packed
|
||
// static records and its dynamic/particle tail stay at that exact
|
||
// retail turn.
|
||
for (int i = cells.Count - 1; i >= 0; i--)
|
||
EmitCellContentsTurn(stage, cells[i], _floodViewRouteScratch[i]);
|
||
}
|
||
|
||
private int CaptureCellViewRoute(uint cellId)
|
||
{
|
||
int viewRouteIndex = _cellViewRouteIndex++;
|
||
CaptureCellViews(cellId);
|
||
return viewRouteIndex;
|
||
}
|
||
|
||
private void EmitCellContentsTurn(
|
||
WalkDrawStage stage,
|
||
uint cellId,
|
||
int viewRouteIndex)
|
||
{
|
||
// S3 chunk 1 (§11.2 B1): RenderDeviceD3D::DrawObjCellForDummies
|
||
// @0x005a0760 entry — the interior/look-in object-list turn (retail's
|
||
// outdoor DrawObjCell is a different function, folded into the SC
|
||
// line's own DrawSortCell call). Unconditional, matching this loop's
|
||
// existing no-dedupe shape (every flood visit already calls this
|
||
// method once, with no per-stamp gate).
|
||
WalkTranscriptDump.PrintObjectCellTurn(cellId);
|
||
|
||
// The shell loop captured the exact live portal_view for this cell.
|
||
// Static and dynamic object lists reuse that same route: retail's
|
||
// DrawEnvCell and DrawObjCell consume the same PortalList.
|
||
WalkFrameStaticRecords records = _worldData.GetCellObjects(cellId);
|
||
_populator.PopulateCellObjects(
|
||
_stream,
|
||
stage,
|
||
cellId,
|
||
records.Records,
|
||
records.TupleLandblockId,
|
||
_cameraWorldPosition,
|
||
_viewProjection,
|
||
this,
|
||
viewRouteIndex,
|
||
_alphaSubmissions);
|
||
MarkIfGrown();
|
||
|
||
if (stage == WalkDrawStage.LookInStatic)
|
||
{
|
||
LookInCellTurns.Add(cellId);
|
||
LookInCells.Add(cellId);
|
||
}
|
||
|
||
// Fires for every cell turn regardless of whether that cell has any
|
||
// visible static/dynamic owner record (Campaign OVERHAUL S2 chunk 6 —
|
||
// a suspended/hidden owner's emitter must still show), but ONCE per
|
||
// render stamp: retail's particle parts sit in the same shadow_part_list
|
||
// as every other part, so CPhysicsPart::Draw's frame stamp suppresses a
|
||
// second submission when one cell gets two object-list turns in one
|
||
// frame (a chamber reached through two portals). The same frame-scoped
|
||
// set that dedupes the cell SHELL dedupes the particle turn (chunk 6
|
||
// review F1).
|
||
bool includeParticles = _cellParticleTurnsDrawnThisFrame.Add(cellId);
|
||
_alphaSubmitMark = _alphaSubmissions.Count;
|
||
_events.Add(WalkFrameEvent.CellParticles(
|
||
cellId,
|
||
_alphaSubmitMark,
|
||
includeParticles));
|
||
}
|
||
|
||
private void CaptureCellViews(uint cellId)
|
||
{
|
||
IWalkBuildingFrameContext ctx = RequireOpenFrame();
|
||
WalkCell? cell = ctx.GetVisible(cellId);
|
||
if (cell is null || cell.NumView <= 0)
|
||
{
|
||
_lookInTurns.Add(new WalkLookInTurn(
|
||
cellId, _lookInSlices.Count, 0));
|
||
return;
|
||
}
|
||
|
||
CaptureViews(cellId, cell.TopView);
|
||
}
|
||
|
||
private void CaptureViews(uint cellId, WalkPortalView portalView)
|
||
{
|
||
int sliceStart = _lookInSlices.Count;
|
||
for (int sliceIndex = 0; sliceIndex < portalView.ViewCount; sliceIndex++)
|
||
{
|
||
WalkViewPoly poly = portalView.View.Polys[sliceIndex];
|
||
int planeStart = _lookInPlanes.Count;
|
||
for (int edge = 0; edge < poly.VertexCount; edge++)
|
||
{
|
||
_lookInPlanes.Add(
|
||
portalView.View.Vertices[poly.VertexIndex + edge].Plane);
|
||
}
|
||
uint clipSlot = AppendClipSlot(portalView, poly);
|
||
_lookInSlices.Add(new WalkLookInSlice(
|
||
planeStart, poly.VertexCount, clipSlot));
|
||
}
|
||
_lookInTurns.Add(new WalkLookInTurn(
|
||
cellId, sliceStart, _lookInSlices.Count - sliceStart));
|
||
}
|
||
|
||
/// <summary>See the interface member's own doc comment (S3 chunk 4,
|
||
/// §10.2): replaces the deleted <c>VisibleClipSlotsInLookInTurn</c>'s
|
||
/// per-slice loop with the SAME admission condition per slice, short-
|
||
/// circuiting to a Boolean the moment any slice admits instead of
|
||
/// accumulating a slot list nothing downstream read for its content
|
||
/// (every caller only ever asked <c>Count == 0</c>).</summary>
|
||
public bool SphereVisibleInLookInTurn(
|
||
int routeIndex,
|
||
in Vector3 center,
|
||
float radius,
|
||
bool testSphere = true)
|
||
{
|
||
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
|
||
return false;
|
||
|
||
WalkLookInTurn turn = _lookInTurns[routeIndex];
|
||
for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++)
|
||
{
|
||
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
|
||
if (!testSphere
|
||
|| WalkVisibilityMath.ViewconeCheck(
|
||
center,
|
||
radius,
|
||
_lookInCyPlane,
|
||
CollectionsMarshal.AsSpan(_lookInPlanes).Slice(
|
||
slice.PlaneStart,
|
||
slice.PlaneCount)) != WalkBoundingType.Outside)
|
||
{
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
public string DescribeLookInTurn(
|
||
int routeIndex,
|
||
in Vector3 center,
|
||
float radius)
|
||
{
|
||
if ((uint)routeIndex >= (uint)_lookInTurns.Count)
|
||
return "route-missing";
|
||
|
||
WalkLookInTurn turn = _lookInTurns[routeIndex];
|
||
var description = new System.Text.StringBuilder(192);
|
||
float cyDistance = Vector3.Dot(_lookInCyPlane.Normal, center)
|
||
+ _lookInCyPlane.D;
|
||
description.Append("turnCell=0x")
|
||
.Append(turn.CellId.ToString("X8"))
|
||
.Append(" slices=").Append(turn.SliceCount)
|
||
.Append(" cy=").Append(cyDistance.ToString("F5"))
|
||
.Append(" cyMargin=").Append((cyDistance + radius).ToString("F5"));
|
||
|
||
for (int sliceOffset = 0; sliceOffset < turn.SliceCount; sliceOffset++)
|
||
{
|
||
WalkLookInSlice slice = _lookInSlices[turn.SliceStart + sliceOffset];
|
||
description.Append(" slice[").Append(sliceOffset).Append("]=");
|
||
for (int planeOffset = 0; planeOffset < slice.PlaneCount; planeOffset++)
|
||
{
|
||
if (planeOffset != 0)
|
||
description.Append(',');
|
||
WalkPlane plane = _lookInPlanes[slice.PlaneStart + planeOffset];
|
||
float distance = Vector3.Dot(plane.Normal, center) + plane.D;
|
||
description.Append(distance.ToString("F5"));
|
||
}
|
||
}
|
||
return description.ToString();
|
||
}
|
||
|
||
/// <summary>Converts one retail pixel-space portal_view polygon into the
|
||
/// clip-space half-planes consumed by mesh_modern.vert. copy_view already
|
||
/// deduplicates and removes collinear vertices, so 3..8 points map
|
||
/// directly. The rare >8-edge case uses its convex AABB as a conservative
|
||
/// four-plane gate rather than falling back to slot 0 (which would erase
|
||
/// the aperture entirely).</summary>
|
||
private uint AppendClipSlot(WalkPortalView portalView, WalkViewPoly poly)
|
||
{
|
||
if (_clipFrame is null)
|
||
return 0;
|
||
|
||
IRetailFrameWalkContext ctx = (IRetailFrameWalkContext)RequireOpenFrame();
|
||
int count = poly.VertexCount;
|
||
if (count < 3)
|
||
return 0;
|
||
|
||
Span<Vector2> ndc = stackalloc Vector2[Math.Min(count, WalkCopyView.MaxVertices)];
|
||
float minX = float.MaxValue, minY = float.MaxValue;
|
||
float maxX = float.MinValue, maxY = float.MinValue;
|
||
for (int i = 0; i < ndc.Length; i++)
|
||
{
|
||
Vector2 px = portalView.View.Vertices[poly.VertexIndex + i].Point;
|
||
Vector2 point = new(
|
||
px.X / ctx.ViewportWidth * 2f - 1f,
|
||
1f - px.Y / ctx.ViewportHeight * 2f);
|
||
ndc[i] = point;
|
||
minX = MathF.Min(minX, point.X);
|
||
minY = MathF.Min(minY, point.Y);
|
||
maxX = MathF.Max(maxX, point.X);
|
||
maxY = MathF.Max(maxY, point.Y);
|
||
}
|
||
|
||
if (ndc.Length > ClipFrame.MaxPlanes)
|
||
{
|
||
Span<Vector4> aabbPlanes = stackalloc Vector4[4]
|
||
{
|
||
new(1f, 0f, 0f, -minX),
|
||
new(-1f, 0f, 0f, maxX),
|
||
new(0f, 1f, 0f, -minY),
|
||
new(0f, -1f, 0f, maxY),
|
||
};
|
||
return checked((uint)_clipFrame.AppendSlot(aabbPlanes));
|
||
}
|
||
|
||
float area2 = 0f;
|
||
for (int i = 0; i < ndc.Length; i++)
|
||
area2 += ndc[i].X * ndc[(i + 1) % ndc.Length].Y
|
||
- ndc[(i + 1) % ndc.Length].X * ndc[i].Y;
|
||
bool ccw = area2 >= 0f;
|
||
Span<Vector4> planes = stackalloc Vector4[ClipFrame.MaxPlanes];
|
||
for (int i = 0; i < ndc.Length; i++)
|
||
{
|
||
int current = ccw ? i : ndc.Length - 1 - i;
|
||
int next = ccw
|
||
? (i + 1) % ndc.Length
|
||
: (ndc.Length - 2 - i + ndc.Length) % ndc.Length;
|
||
Vector2 p = ndc[current];
|
||
Vector2 q = ndc[next];
|
||
Vector2 dir = q - p;
|
||
Vector2 normal = Vector2.Normalize(new Vector2(-dir.Y, dir.X));
|
||
planes[i] = new Vector4(normal.X, normal.Y, 0f, -Vector2.Dot(normal, p));
|
||
}
|
||
return checked((uint)_clipFrame.AppendSlot(planes[..ndc.Length]));
|
||
}
|
||
|
||
/// <summary>Campaign FW3.4a: the collect-time analogue of the old
|
||
/// immediate driver's <c>FlushIfNonEmpty</c> — records a
|
||
/// <see cref="WalkFrameEventKind.StreamMark"/> at the stream's current
|
||
/// length if it grew since the last mark (a no-op otherwise, exactly
|
||
/// like that method's own "empty segments submit nothing" rule). Also
|
||
/// appends the boundary to <see cref="_markPositions"/>, which
|
||
/// <see cref="Replay"/> hands to <c>PrepareOrderedStream</c> so a merge
|
||
/// run can never span it — see <c>WbDrawDispatcher.BuildOrderedMergeRuns</c>'s
|
||
/// <c>forcedBreaksAscending</c> parameter for why that matters.</summary>
|
||
private void MarkIfGrown()
|
||
{
|
||
int count = _stream.Count;
|
||
int last = _markPositions.Count > 0 ? _markPositions[^1] : 0;
|
||
if (count == last)
|
||
return;
|
||
|
||
_markPositions.Add(count);
|
||
_events.Add(WalkFrameEvent.Mark(count));
|
||
}
|
||
|
||
/// <summary>Records translucent walk batches at the same temporal point
|
||
/// as their cell turn. Replay submits them only when it reaches this
|
||
/// marker, so an earlier building's full alpha barrier cannot see content
|
||
/// that the far-to-near walk has not reached yet.</summary>
|
||
private void MarkAlphaIfGrown()
|
||
{
|
||
int count = _alphaSubmissions.Count;
|
||
if (count == _alphaSubmitMark)
|
||
return;
|
||
|
||
_alphaSubmitMark = count;
|
||
_events.Add(WalkFrameEvent.AlphaSubmitMark(count));
|
||
}
|
||
|
||
private IWalkBuildingFrameContext RequireOpenFrame() =>
|
||
_ctx ?? throw new InvalidOperationException(
|
||
"WalkFrameDriver received a walk turn outside BeginFrame/EndFrame — call "
|
||
+ "BeginFrame (or Collect/RunFrame) before driving the walk with this driver as "
|
||
+ "its IWalkEventSink.");
|
||
|
||
/// <summary><c>ConstructBuildingView</c>'s polygon is building-local; the
|
||
/// punch fan needs world space. Vertices transform directly; the plane
|
||
/// normal uses <see cref="Vector3.TransformNormal"/> (correct for the
|
||
/// rigid, shear-free placements <see cref="WalkBuildingFactory"/> and
|
||
/// <see cref="WalkCellFactory"/> build) and <c>D</c> is rederived from
|
||
/// the transformed normal and the first transformed vertex — the SAME
|
||
/// construction <see cref="WalkCellFactory"/>/<see cref="WalkBuildingFactory"/>
|
||
/// already use for their own polygons (<c>Plane = new WalkPlane(normal,
|
||
/// -Vector3.Dot(normal, vertices[0]))</c>).</summary>
|
||
private static WalkPolygon TransformToWorld(WalkPolygon local, Matrix4x4 worldTransform)
|
||
{
|
||
var vertices = new Vector3[local.Vertices.Length];
|
||
for (int i = 0; i < vertices.Length; i++)
|
||
vertices[i] = Vector3.Transform(local.Vertices[i], worldTransform);
|
||
|
||
Vector3 normal = local.Vertices.Length > 0
|
||
? Vector3.Normalize(Vector3.TransformNormal(local.Plane.Normal, worldTransform))
|
||
: Vector3.Zero;
|
||
float d = vertices.Length > 0 ? -Vector3.Dot(normal, vertices[0]) : 0f;
|
||
|
||
return new WalkPolygon { Vertices = vertices, Plane = new WalkPlane(normal, d) };
|
||
}
|
||
}
|