acdream/src/AcDream.App/Rendering/RetailPViewRenderer.cs

882 lines
40 KiB
C#

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Scene;
using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// App-layer port of the retail indoor render orchestration:
/// SmartBox::RenderNormalMode -> RenderDeviceD3D::DrawInside ->
/// PView::DrawInside -> ConstructView -> DrawCells.
/// </summary>
internal sealed class RetailPViewRenderer
{
private readonly RenderSceneShadowRuntime _renderSceneShadow;
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly RetailPViewFrameResult _frameResultScratch = new();
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>();
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every walk consumer reads it synchronously in this frame.
private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly HashSet<uint> _visibleCellsScratch = new();
// FW3 visual-gate fix: the interior root's dynamics phase, invoked by
// the driver's clearInteriorDepth closure at the walk's pre-clear
// boundary (retail draws outside objects inside LScape::draw, before
// the clear+seals). Assigned per frame around the Replay, always
// cleared in finally.
private Action? _walkPreClearDynamics;
// Output-only Facility Hub staircase probe. This counter is consulted
// only while ACDREAM_PROBE_FACILITY_STAIRS=1 and never affects the walk.
private ulong _probeFacilityStairFrame;
private ulong _probeCathedralShellOrderFrame;
private string? _probeFacilityStairRootSignature;
private string? _probeCathedralStairRootSignature;
// FW6 allocation closeout: the walk's large event/view/route scratch,
// frame context, and one-cell leaf collections are renderer-lifetime
// owners. Only their frame-local bindings change. Before this cutover all
// three were constructed inside DrawInside and accounted for the measured
// ~1.5 MiB/frame dense-town tail.
private Walk.WalkFrameDriver? _walkFrameDriverScratch;
private Walk.WalkProductionFrameContext? _walkFrameContextScratch;
private WalkProductionLeafRenderer? _walkLeafRendererScratch;
private readonly Action _walkClearInteriorDepthAction;
private readonly Action _walkDrawExitSealsAction;
private RetailPViewPassExecutor? _activeWalkPasses;
private RetailPViewFrameInput? _activeWalkFrame;
private ClipFrameAssembly? _activeWalkClipAssembly;
// Campaign FW3.2b-2: the walk's production world-data registries
// (published/retired by LandblockRenderPublisher) plus the per-frame
// driver state. Null until the composition passes them; the static
// cutover requires all three.
private readonly Walk.WalkBuildingRegistry _walkBuildings;
private readonly Walk.WalkLandscapeAssembler _walkLandscape;
private readonly CellVisibility _walkCellRegistry;
// Campaign FW3.2b-2: the production IWalkFrameWorldData over the retained
// scene — owned here (not just inside the driver) because DrawInside also
// reads it directly to re-source particle owners for the routes the walk
// now draws (plan §FW3 item 4). Non-null exactly when _walkBuildings is.
private readonly Walk.WalkProductionWorldData _walkWorldData;
internal RetailPViewRenderer(
RenderSceneShadowRuntime renderSceneShadow,
Walk.WalkBuildingRegistry walkBuildings,
Walk.WalkLandscapeAssembler walkLandscape,
CellVisibility walkCellRegistry,
ShadowObjectRegistry shadows,
Func<uint, uint?>? findParentLocalId = null)
{
_renderSceneShadow = renderSceneShadow
?? throw new ArgumentNullException(nameof(renderSceneShadow));
_walkBuildings = walkBuildings
?? throw new ArgumentNullException(nameof(walkBuildings));
_walkLandscape = walkLandscape
?? throw new ArgumentNullException(nameof(walkLandscape));
_walkCellRegistry = walkCellRegistry
?? throw new ArgumentNullException(nameof(walkCellRegistry));
_walkWorldData = new Walk.WalkProductionWorldData(
_walkBuildings,
shadows ?? throw new ArgumentNullException(nameof(shadows)),
findParentLocalId);
_walkClearInteriorDepthAction = ClearWalkInteriorDepth;
_walkDrawExitSealsAction = DrawWalkExitSeals;
}
// T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the
// caller's per-building frustum pre-gate on aperture bounds (GameWindow's
// gather); seeds themselves are unbounded.
private const float OutdoorBuildingSeedDistance = float.PositiveInfinity;
internal RetailPViewFrameResult DrawInside(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(passes);
passes.BeginFrame();
// Campaign FW4: there is one production renderer and one visibility
// owner. The fake/legacy executor path was useful during the cutover,
// but retaining it here kept the retired PortalVisibilityBuilder and
// look-in seed machinery alive. Fail loudly if composition ever tries
// to reintroduce that split.
RetailPViewPassExecutor walkExecutor = passes as RetailPViewPassExecutor
?? throw new InvalidOperationException(
"The retail frame walk requires RetailPViewPassExecutor.");
ClipFrameAssembly clipAssembly = passes.BeginWalkClipFrame(
ctx.RootCell.IsOutdoorNode,
_clipAssemblyScratch);
// FW4 slice 1: PrepareClipFrame (the one clip-region publication)
// moved BELOW the walk block — an interior-rooted walk frame
// re-derives the outside-view slices from the walk's own views
// first, so the appended slots join the same single publication.
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
// so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
_drawableCellsScratch.Clear();
HashSet<uint> drawableCells = _drawableCellsScratch;
passes.UseIndoorMembershipOnlyRouting();
// Campaign FW3.4a: THE ONE WALK. Builds walkContext/walkLandscape/
// walkCameraCell exactly as the pre-FW3.4a pre-walk collection pass
// did, then drives WalkFrameDriver.Collect — a SINGLE RetailFrameWalk
// pass that both learns the flood/visited-cell set (needed below,
// BEFORE prepareCells is finalized, so EnvCellRenderer prepares
// batches for every shell the driver will draw later in this same
// DrawInside call) and records the walk's draw events for Replay
// further down, in DrawWalkDrivenStatics. The former SECOND walk pass
// (a dedicated set-collecting sink, run again through this same
// driver machinery just to submit) is gone — see WalkFrameDriver's
// own doc comment for the FW3.4 perf numbers that motivated this.
// _walkWorldData.BeginFrame precedes Collect deliberately: Collect's
// stream appends classify records immediately (WalkStaticStreamPopulator
// runs at append time, not at Replay time), so the world data must
// already be rebuilt for this frame before the walk starts.
Walk.WalkFrameDriver walkDriver;
{
Matrix4x4 view = ctx.CameraView;
var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33));
(int Width, int Height)? attachment = walkExecutor!.WalkAttachmentExtent;
// Fallback matches the FW3.2b-2 shadow probe's own comment: every
// screen projection shares the same constants, so this is only
// reached before the world pass has published its scope.
float viewportWidth = attachment?.Width ?? 1024f;
float viewportHeight = attachment?.Height ?? 720f;
if (_walkFrameContextScratch is null)
{
_walkFrameContextScratch = new Walk.WalkProductionFrameContext(
_walkCellRegistry,
_walkBuildings,
ctx.ViewerEyePos,
forward,
ctx.ViewProjection,
viewportWidth,
viewportHeight);
}
else
{
_walkFrameContextScratch.Reset(
ctx.ViewerEyePos,
forward,
ctx.ViewProjection,
viewportWidth,
viewportHeight);
}
Walk.WalkProductionFrameContext walkContext = _walkFrameContextScratch;
_walkLandscape!.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos);
Walk.WalkLandscape walkLandscape = _walkLandscape.Landscape;
Walk.WalkCell? walkCameraCell = null;
if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100)
{
walkCameraCell = _walkCellRegistry!.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded)
? loaded?.Walk
: null;
if (walkCameraCell is null)
{
throw new InvalidOperationException(
$"walk root=0x{ctx.ViewerCellId:X8}: the interior camera cell has "
+ "no committed walk data — the Campaign FW3.2b-2 static cutover "
+ "requires the walk registry to already hold the viewer's own cell "
+ "(fail-loud rule; a silently skipped root would leave the frame "
+ "with no static draws at all).");
}
}
if (ctx.RootCell.IsOutdoorNode && clipAssembly.OutsideViewSlices.Length != 1)
{
throw new InvalidOperationException(
"walk static cutover: an outdoor root's clip assembly produced "
+ $"{clipAssembly.OutsideViewSlices.Length} outside-view slices, not the "
+ "expected 1 — the outdoor root draws through the full-screen default "
+ "view (retail set_default_view; the walk fans exactly its own 1), and "
+ "the outdoor slice data still comes from the assembler (plan §FW3 item "
+ "2c's pinned assumption; assert rather than silently coercing to 1).");
}
_walkWorldData!.BeginFrame(
_renderSceneShadow!.Query,
ctx.PlayerLandblockId ?? 0u,
ctx.RenderCenterLbX,
ctx.RenderCenterLbY);
_activeWalkPasses = walkExecutor;
_activeWalkFrame = ctx;
_activeWalkClipAssembly = clipAssembly;
if (_walkLeafRendererScratch is null)
{
_walkLeafRendererScratch = new WalkProductionLeafRenderer(
walkExecutor,
ctx,
clipAssembly,
_walkClearInteriorDepthAction,
_walkDrawExitSealsAction);
}
else
{
_walkLeafRendererScratch.Reset(
walkExecutor,
ctx,
clipAssembly,
_walkClearInteriorDepthAction,
_walkDrawExitSealsAction);
}
if (_walkFrameDriverScratch is null)
{
_walkFrameDriverScratch = new Walk.WalkFrameDriver(
walkExecutor.Dispatcher,
_walkLeafRendererScratch,
_walkWorldData,
clipFrame: clipAssembly.Frame);
}
else
{
_walkFrameDriverScratch.RebindFrame(
_walkLeafRendererScratch,
clipAssembly.Frame);
}
walkDriver = _walkFrameDriverScratch;
try
{
walkDriver.Collect(
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
ctx.ViewProjection, ctx.CameraWorldPosition);
}
catch
{
ClearWalkFrameBindings();
throw;
}
// FW4 slice 1: an interior root's terrain/sky/punch clip slices
// come from THE WALK'S OWN outside_view — retail's one
// visibility structure. See ClipFrameAssembler.
// ReassembleOutsideViewFromWalk's doc comment for the boundary-
// frame desync (the stairwell/grass flash) this retires. The
// outdoor root keeps the assembler's single full-screen slice
// (asserted ==1 above; identical content by construction).
if (walkCameraCell is not null)
{
ClipFrameAssembler.ReassembleOutsideViewFromWalk(
clipAssembly,
_frameWalk.InteriorOutsideView,
viewportWidth,
viewportHeight);
}
// The walk's visited set replaces PortalVisibilityFrame's old
// OrderedVisibleCells side-channel for every production consumer.
_drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(walkDriver.VisitedCells);
walkDriver.CopyVisibleCellsTo(_visibleCellsScratch);
if (AcDream.Core.Rendering.RenderingDiagnostics
.ProbeCathedralShellOrderEnabled
&& ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u
|| (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u))
{
_probeCathedralShellOrderFrame++;
walkDriver.TraceCathedralShellOrder(
_probeCathedralShellOrderFrame,
ctx.ViewerCellId,
ctx.PlayerCellId,
ctx.RootCell.CellId,
ctx.ViewerEyePos);
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFacilityStairsEnabled)
{
static int TargetMembership(IReadOnlyCollection<uint> cells)
{
const uint a = 0x8A02015Eu;
const uint b = 0x8A02015Fu;
const uint c = 0x8A0201C1u;
int mask = 0;
foreach (uint cell in cells)
{
if (cell == a) mask |= 1;
if (cell == b) mask |= 2;
if (cell == c) mask |= 4;
}
return mask;
}
_probeFacilityStairFrame++;
int floodMask = TargetMembership(walkDriver.InteriorFloodCells);
int visitedMask = TargetMembership(walkDriver.VisitedCells);
int turnMask = TargetMembership(walkDriver.LookInCells);
string signature = $"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:"
+ $"{ctx.RootCell.CellId:X8}:{floodMask}:{visitedMask}:{turnMask}:"
+ $"{walkDriver.InteriorFloodCells.Count}:{walkDriver.VisitedCells.Count}:"
+ $"{walkDriver.LookInCellTurns.Count}";
bool changed = !string.Equals(
_probeFacilityStairRootSignature,
signature,
StringComparison.Ordinal);
_probeFacilityStairRootSignature = signature;
if (changed)
{
static string DescribeMask(int mask) =>
$"15e={((mask & 1) != 0 ? 1 : 0)},"
+ $"15f={((mask & 2) != 0 ? 1 : 0)},"
+ $"1c1={((mask & 4) != 0 ? 1 : 0)}";
Console.WriteLine(
$"[facility-root] f={_probeFacilityStairFrame} changed={(changed ? 1 : 0)} "
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
+ $"rootFlood={walkDriver.InteriorFloodCells.Count}"
+ $"[{DescribeMask(floodMask)}] "
+ $"visited={walkDriver.VisitedCells.Count}"
+ $"[{DescribeMask(visitedMask)}] "
+ $"turns={walkDriver.LookInCellTurns.Count}"
+ $"[{DescribeMask(turnMask)}]");
}
static int CathedralMembership(IReadOnlyCollection<uint> cells)
{
const uint oldBuilding = 0xF4180107u;
const uint stairParent = 0xF4180112u;
int mask = 0;
foreach (uint cell in cells)
{
if (cell == oldBuilding) mask |= 1;
if (cell == stairParent) mask |= 2;
}
return mask;
}
static uint BuildingAnchor(Walk.WalkBuilding building)
{
foreach (Walk.WalkBldPortal portal in building.Portals)
{
if (portal.OtherCellId != 0xFFFFFFFFu)
return portal.OtherCellId;
}
return 0u;
}
int cathedralFlood = CathedralMembership(walkDriver.InteriorFloodCells);
int cathedralVisited = CathedralMembership(walkDriver.VisitedCells);
int cathedralLookIn = CathedralMembership(walkDriver.LookInCells);
bool bucket107 = _walkWorldData.StaticBucketContains(
0xF4180107u,
0x020009A2u);
bool bucket112 = _walkWorldData.StaticBucketContains(
0xF4180112u,
0x020009A2u);
string buildingAnchors = string.Join(",", walkDriver.VisitedBuildings
.ConvertAll(building => $"0x{BuildingAnchor(building):X8}"));
string cathedralSignature =
$"{ctx.ViewerCellId:X8}:{ctx.PlayerCellId:X8}:{ctx.RootCell.CellId:X8}:"
+ $"{cathedralFlood}:{cathedralVisited}:{cathedralLookIn}:"
+ $"{(bucket107 ? 1 : 0)}:{(bucket112 ? 1 : 0)}:{buildingAnchors}";
bool cathedralChanged = !string.Equals(
_probeCathedralStairRootSignature,
cathedralSignature,
StringComparison.Ordinal);
_probeCathedralStairRootSignature = cathedralSignature;
if (cathedralChanged
&& ((ctx.ViewerCellId & 0xFFFF0000u) == 0xF4180000u
|| (ctx.PlayerCellId & 0xFFFF0000u) == 0xF4180000u))
{
static string DescribeCathedralMask(int mask) =>
$"107={((mask & 1) != 0 ? 1 : 0)},"
+ $"112={((mask & 2) != 0 ? 1 : 0)}";
Console.WriteLine(
$"[cathedral-stair] f={_probeFacilityStairFrame} "
+ $"viewer=0x{ctx.ViewerCellId:X8} player=0x{ctx.PlayerCellId:X8} "
+ $"root=0x{ctx.RootCell.CellId:X8} res={ctx.CameraCellResolution} "
+ $"eye=({ctx.ViewerEyePos.X:F4},{ctx.ViewerEyePos.Y:F4},{ctx.ViewerEyePos.Z:F4}) "
+ $"playerPos=({ctx.PlayerViewPosition.X:F4},{ctx.PlayerViewPosition.Y:F4},{ctx.PlayerViewPosition.Z:F4}) "
+ $"rootFlood=[{DescribeCathedralMask(cathedralFlood)}] "
+ $"visited=[{DescribeCathedralMask(cathedralVisited)}] "
+ $"lookIn=[{DescribeCathedralMask(cathedralLookIn)}] "
+ $"buckets=[107={(bucket107 ? 1 : 0)},112={(bucket112 ? 1 : 0)}] "
+ $"buildings=[{buildingAnchors}]");
}
}
}
// FW4 slice 1: the ONE clip-region publication, after any walk
// reassembly so the walk-derived outside-view slots are included
// (moved from directly after AssembleClipFrame; the count is
// reservation metadata the RHI arm ignores).
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
passes.PrepareClipFrame(terrainUploadCount);
// Production prepares exactly the one walk's visited-cell set.
HashSet<uint> prepareCells = drawableCells;
// (#176 correction, 2026-07-06: the flood-scoped light-pool rebuild that ran
// here was the seam-floor flicker mechanism — retail's visible_cell_table is
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
// is built once per frame in GameWindow, player-anchored.)
passes.PrepareCellBatches(ctx, prepareCells);
{
RenderProjectionCounts retainedCounts = _renderSceneShadow.Counts;
RenderFrameDiagnosticCounts counts = WalkDiagnosticCounts(retainedCounts);
RenderProjectionCounts sourceCounts = retainedCounts;
RetailPViewFrameResult result = _frameResultScratch.Reset(
clipAssembly,
drawableCells,
_visibleCellsScratch,
counts,
sourceCounts,
diagnosticPartition: null);
passes.EmitDiagnostics(ctx, result);
// The one collected walk owns terrain, cell shells, buildings,
// statics, dynamics, particles, punches, and alpha barriers at
// their retail turns. Interior-root landscape services run at the
// pre-clear callback; outdoor roots have no clear and run after
// replay.
_walkPreClearDynamics = () =>
{
passes.UseIndoorMembershipOnlyRouting();
DrawLandscapeDynamicsPhase(
ctx,
passes,
clipAssembly);
};
try
{
DrawWalkDrivenStatics(ctx, walkExecutor, walkDriver!);
passes.UseIndoorMembershipOnlyRouting();
if (ctx.RootCell.IsOutdoorNode)
{
DrawLandscapeDynamicsPhase(
ctx,
passes,
clipAssembly);
}
}
finally
{
_walkPreClearDynamics = null;
ClearWalkFrameBindings();
}
// OUTDOOR root: the LScape-boundary alpha drain deferred from the
// landscape stage runs HERE, after punches, interior shells, cell
// objects, and the dynamics pass — the frame's complete opaque
// world. Retail's walk draws all of those before its boundary
// flush (LScape::draw includes every cell's objects,
// DrawSortCell 0x005A17C0), so this is the same one-list far→near
// composite over finished depth; draining at the stage end instead
// let every later opaque mesh overwrite the flames (#132).
if (ctx.RootCell.IsOutdoorNode)
passes.FlushLandscapeAlpha();
// Interior-cell UNATTACHED emitters (spell ground effects and
// swirls anchored in EnvCells) draw in this final world scope —
// the cells' walls and the seals already own the depth buffer, so
// one unclipped submission matches retail's cell-walk insertion.
// Outdoor-cell unattached emitters drew in the landscape stage.
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: false);
return result;
}
}
private readonly Walk.RetailFrameWalk _frameWalk = new();
private void ClearWalkInteriorDepth()
{
RetailPViewPassExecutor passes = _activeWalkPasses
?? throw new InvalidOperationException(
"The retained walk leaf has no active pass binding.");
// FW3 visual-gate fix (owner report: doors/candles invisible looking
// out; the crossing vanish): retail draws outside objects inside
// LScape::draw, before the clear+seals.
_walkPreClearDynamics?.Invoke();
passes.FlushLandscapeAlpha();
passes.ClearInteriorDepth();
}
private void DrawWalkExitSeals()
{
RetailPViewFrameInput frame = _activeWalkFrame
?? throw new InvalidOperationException(
"The retained walk leaf has no active frame binding.");
RetailPViewPassExecutor passes = _activeWalkPasses
?? throw new InvalidOperationException(
"The retained walk leaf has no active pass binding.");
Walk.WalkFrameDriver driver = _walkFrameDriverScratch
?? throw new InvalidOperationException(
"The retained walk leaf has no active driver binding.");
DrawWalkExitPortalMasks(frame, passes, driver);
}
private void ClearWalkFrameBindings()
{
_walkFrameDriverScratch?.AbortFrame();
_activeWalkPasses = null;
_activeWalkFrame = null;
_activeWalkClipAssembly = null;
}
/// <summary>Campaign FW3.2b-2 — THE PRODUCTION ROOTING; Campaign FW3.4a —
/// REPLAY ONLY. <paramref name="driver"/> already ran its Collect pass
/// earlier in <see cref="DrawInside"/> (before <c>PrepareCellBatches</c>);
/// this method's job is now just <see cref="Walk.WalkFrameDriver.Replay"/>
/// (terrain/sky, every building's shell + punch + look-in cell statics,
/// and the interior root's own flood shells + statics, with retail's
/// depth-clear/exit-seal turn — all in walk order, replacing
/// <see cref="DrawLandscapeThroughOutsideView"/>'s static half,
/// <see cref="DrawExitPortalMasks"/>'s old top-level call,
/// <see cref="DrawEnvCellShells"/>, and <see cref="DrawCellObjectLists"/>'s
/// static half for this frame) plus re-sourcing the particle owners the
/// routes it replaced used to ride, from the driver's own visited
/// sets (plan §FW3 "FW3.2b-2 — the production rooting", items 2 and
/// 4).</summary>
private void DrawWalkDrivenStatics(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
Walk.WalkFrameDriver driver)
{
var (frame, encoder) = passes.RequireWalkSubmission();
passes.SetWalkTerrainInViewLandcells(driver.VisitedLandscapeCellIds);
try
{
driver.Replay(frame, encoder);
}
finally
{
passes.SetWalkTerrainInViewLandcells(null);
}
// Landscape-stage static-owner particles (candles, the cathedral
// falls) submit AT THEIR OWN WALK TURNS inside Replay
// (WalkFrameEventKind.StaticParticles) for BOTH root kinds — the
// #132 positional invariant (e102fb36's user-verified rule ported
// walk-natively): each owner cell's emitters enter the one alpha
// queue at that cell's far-to-near position, so every nearer
// building's pre-punch AlphaBarrier drains the farther content
// against still-true depth before its punch stamps far-Z.
// Cell-stage particle owners for the interior root's OWN flood cells
// (non-look-in — the walk draws their statics too, via
// OnInteriorFloodDrawTurn/EmitCellTurn, so the retired CellStatic
// route's cell-particle submission needs the same re-sourcing).
// These stay POST-replay: interior emitters draw in the final world
// scope where the cell walls already own depth (retail's cell-walk
// insertion). Look-in cells get their OWN per-cell union in
// DrawBuildingLookInDynamics so a static owner is never submitted
// twice.
_cellParticleOwnerScratch.Clear();
foreach (uint cellId in driver.VisitedCells)
{
if (driver.LookInCells.Contains(cellId))
continue;
UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch);
}
if (_cellParticleOwnerScratch.Count > 0)
{
passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(0u, NoClipSlice, _cellParticleOwnerScratch));
}
}
private static void UnionRecordOwners(
Walk.WalkFrameStaticRecords records, HashSet<uint> destination)
{
foreach (RenderProjectionRecord record in records.Records)
{
if (record.Source.LocalEntityId != 0)
destination.Add(record.Source.LocalEntityId);
}
}
/// <summary>Campaign FW3.2b-2: the DYNAMICS-only remainder of the old
/// <see cref="DrawLandscapeThroughOutsideView"/> + <see cref="DrawBuildingLookIns"/>
/// split. The walk now draws every mesh and every attached emitter at its
/// own turn. This tail therefore carries only the two landscape services
/// that are not scene projections: ownerless outdoor-cell emitters and
/// weather. It deliberately submits no packed entity route.</summary>
private void DrawLandscapeDynamicsPhase(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// Ownerless OUTDOOR-cell emitters — now unconditional: the old
// hasBuildingLookIns gate only existed to sequence this submission
// around the OLD static barrier drains, which no longer run here
// (the walk owns its own alpha barriers — WalkFrameDriver.OnBuildingTurn).
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
// GameSky's weather pass still runs through each active landscape
// view so doorway scissor/clip state matches the old executor. The
// dynamics collection is intentionally empty: the walk already drew
// those records at OnLandscapeCellTurn.
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
passes.ClearClipRouting();
passes.DrawLandscapeSliceLate(
ctx,
new RetailPViewLandscapeLateSliceContext(
slice,
Array.Empty<WorldEntity>()));
}
passes.UseIndoorMembershipOnlyRouting();
}
/// <summary>Campaign FW4 slice 2 — the walk-flood seal draw. Retail's
/// <c>PView::DrawCells</c> stamps every exit portal of THE FLOOD'S OWN
/// cells (pc:432785-432786, reverse cell_draw_list far→near) — one
/// visibility structure decides the flood, the seals, and the terrain
/// views alike. The old apparatus's flood misses exit portals at the
/// #456 cathedral seam band (its never-drawn panel family), leaving
/// aperture depth unsealed after the interior clear; the end-of-frame
/// alpha drain (cell-owned emitters — retail's own timing) then
/// z-passes across the whole opening (the falls shine-through). Each
/// portal is stamped once per exact walk-owned view captured for that
/// flood cell, matching retail's <c>CEnvCell::setup_view</c> loop. The
/// legacy visibility assembly has no production role here.</summary>
private void DrawWalkExitPortalMasks(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
Walk.WalkFrameDriver driver)
{
List<uint> floodCells = driver.InteriorFloodCells;
for (int i = floodCells.Count - 1; i >= 0; i--)
{
uint cellId = floodCells[i];
int sliceCount = driver.InteriorFloodViewSliceCountAt(i);
for (int sliceIndex = 0; sliceIndex < sliceCount; sliceIndex++)
{
passes.DrawExitPortalMask(
ctx,
cellId,
driver.InteriorFloodViewClipPlanesAt(i, sliceIndex));
}
}
}
private static RenderFrameDiagnosticCounts WalkDiagnosticCounts(
RenderProjectionCounts source)
{
int dynamics = checked(
source.LiveDynamicRoot
+ source.ActiveAnimatedStatic
+ source.EquippedChild);
return new RenderFrameDiagnosticCounts(
source.OutdoorStatic,
source.IndoorCellStatic,
dynamics,
TransformCount: source.Total,
OpaqueClassificationCount: 0,
AlphaClassificationCount: 0,
LightSetCount: 0,
SelectionPartCount: 0,
RouteCandidateCount: source.Total,
EntityCandidateCount: source.Total,
MeshPartCount: 0);
}
}
public interface IRetailPViewCellSource
{
LoadedCell? Find(uint cellId);
}
/// <summary>
/// Typed execution seam for the GL passes ordered by
/// <see cref="RetailPViewRenderer"/>. Implementations execute the requested
/// pass only; visibility construction and draw ordering remain renderer-owned.
/// All frame inputs and results are borrowed for the duration of the call.
/// </summary>
public sealed class RetailPViewFrameInput
{
public LoadedCell RootCell { get; private set; } = null!;
/// <summary>R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the
/// outdoor node. Null for interior roots. Grouped by BuildingId inside <see cref="DrawInside"/>.</summary>
public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; private set; }
public Vector3 ViewerEyePos { get; private set; }
public Matrix4x4 ViewProjection { get; private set; }
public IRetailPViewCellSource Cells { get; private set; } = null!;
public ICamera Camera { get; private set; } = null!;
public Vector3 CameraWorldPosition { get; private set; }
public FrustumPlanes? Frustum { get; private set; }
public uint? PlayerLandblockId { get; private set; }
public HashSet<uint>? AnimatedEntityIds { get; private set; }
public int RenderCenterLbX { get; private set; }
public int RenderCenterLbY { get; private set; }
public int RenderRadius { get; private set; }
public IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{ get; private set; } = Array.Empty<(uint, Vector3, Vector3,
IReadOnlyList<WorldEntity>,
IReadOnlyDictionary<uint, WorldEntity>?)>();
// Pass-presentation and diagnostic values consumed synchronously by the
// typed executor. This input is data-only and is never retained.
public bool RenderSky { get; private set; }
public bool RenderWeather { get; private set; }
public float DayFraction { get; private set; }
public DayGroupData? ActiveDayGroup { get; private set; }
public SkyKeyframe SkyKeyframe { get; private set; }
public bool EnvironOverrideActive { get; private set; }
public uint ViewerCellId { get; private set; }
public uint PlayerCellId { get; private set; }
public Vector3 PlayerViewPosition { get; private set; }
public Matrix4x4 CameraView { get; private set; }
public CameraCellResolution CameraCellResolution { get; private set; }
internal RetailPViewFrameInput Reset(
LoadedCell rootCell,
IReadOnlyList<LoadedCell>? nearbyBuildingCells,
Vector3 viewerEyePos,
Matrix4x4 viewProjection,
IRetailPViewCellSource cells,
ICamera camera,
Vector3 cameraWorldPosition,
FrustumPlanes? frustum,
uint? playerLandblockId,
HashSet<uint>? animatedEntityIds,
int renderCenterLbX,
int renderCenterLbY,
int renderRadius,
IReadOnlyList<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
bool renderSky,
bool renderWeather,
float dayFraction,
DayGroupData? activeDayGroup,
SkyKeyframe skyKeyframe,
bool environOverrideActive,
uint viewerCellId,
uint playerCellId,
Vector3 playerViewPosition,
Matrix4x4 cameraView,
CameraCellResolution cameraCellResolution)
{
RootCell = rootCell;
NearbyBuildingCells = nearbyBuildingCells;
ViewerEyePos = viewerEyePos;
ViewProjection = viewProjection;
Cells = cells;
Camera = camera;
CameraWorldPosition = cameraWorldPosition;
Frustum = frustum;
PlayerLandblockId = playerLandblockId;
AnimatedEntityIds = animatedEntityIds;
RenderCenterLbX = renderCenterLbX;
RenderCenterLbY = renderCenterLbY;
RenderRadius = renderRadius;
LandblockEntries = landblockEntries;
RenderSky = renderSky;
RenderWeather = renderWeather;
DayFraction = dayFraction;
ActiveDayGroup = activeDayGroup;
SkyKeyframe = skyKeyframe;
EnvironOverrideActive = environOverrideActive;
ViewerCellId = viewerCellId;
PlayerCellId = playerCellId;
PlayerViewPosition = playerViewPosition;
CameraView = cameraView;
CameraCellResolution = cameraCellResolution;
return this;
}
}
/// <summary>
/// Borrowed renderer scratch valid only until the next
/// <see cref="RetailPViewRenderer.DrawInside"/> call. Collections and nested
/// frame objects are deliberately reused to keep the render loop allocation
/// free; consumers must copy any state they need to retain asynchronously.
/// </summary>
public sealed class RetailPViewFrameResult
{
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public HashSet<uint> DrawableCells { get; private set; } = null!;
/// <summary>
/// The production retail walk's exact visited-cell set. This is the one
/// visibility answer consumed by EnvCell preparation, particles, lights,
/// and directional-shadow filtering.
/// </summary>
public HashSet<uint> VisibleCells { get; private set; } = null!;
internal RenderFrameDiagnosticCounts DiagnosticCounts { get; private set; }
internal RenderProjectionCounts SourceCounts { get; private set; }
internal InteriorEntityPartition.Result? DiagnosticPartition
{ get; private set; }
internal RetailPViewFrameResult Reset(
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
HashSet<uint> visibleCells,
RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition)
{
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
VisibleCells = visibleCells;
DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition;
return this;
}
}
/// <summary>
/// Scene-particle owners for ONE unclipped landscape-stage submission (the
/// union of every outside slice's cone survivors). Mesh alpha for the same
/// owners is already queued by the entity routes; retail inserts each
/// emitter's polys into the single alpha list once, during its owner cell's
/// walk turn, with no portal-view clip.
/// </summary>
public readonly record struct RetailPViewLandscapeStaticParticleContext(
IReadOnlySet<uint> ParticleOwnerIds);
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the particle owners not already
/// submitted at a pre-building barrier.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);