acdream/src/AcDream.App/Rendering/RetailPViewRenderer.cs
Erik 0d6cd5c06e fix(render): FW4 slice 5 - straddling cell statics'' particles emit pre-clear
The cathedral falls'' true identity, pinned by the emitter/owner dumps
and the owner''s retail-vs-ACE oracle (retail renders them perfectly):
they are EnvCell STAB-LIST statics (interior-cell dat objects, ids
minted by InteriorEntityIdAllocator) whose geometry hangs out over the
lake. Our cell-statics route submitted every interior flood cell''s
static particle owners POST-clear ("walls own depth") - true for
content that stays inside, but a straddler''s outside half meets
seal/cleared depth and splats across the vista.

Retail''s mechanism is the outdoor shadow-cell draw: an object
overlapping outdoor landcells draws during LScape::draw (DrawBlock
pc:430056-430064, once per overlapped shadow cell), so its particles
submit in the landscape scope and drain at the pre-clear boundary
flush over true terrain depth; the cell''s own turn skips them
(drawn-once). This slice ports that rule for particles: after Collect,
the interior flood cells'' static records are classified by the shared
exit-plane straddle test (extracted as SphereStraddlesExitPlane from
the #118 dynamic split); straddlers'' owners submit in the pre-clear
closure and the post-replay cell-owners union subtracts them, so every
owner emits exactly once. Also adds the [walk-emit] probe dump that
falsified the unattached-route theory.

Hermetic 6,762/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:58:04 +02:00

3184 lines
145 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Scene;
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>
public sealed class RetailPViewRenderer
{
private readonly InteriorEntityPartition.IObserver? _partitionObserver;
private readonly ICurrentRenderPViewObserver? _candidateObserver;
private readonly RenderScenePViewFrameProductController?
_sceneFrameProduct;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = 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 ClipViewSlice[] NoClipSlices = { NoClipSlice };
private static readonly IReadOnlySet<uint> NoParticleOwners =
new HashSet<uint>();
// Frame unions for the once-per-frame particle submissions (retail: one
// unclipped alpha-list insertion per emitter; occlusion by depth at the
// flush). Per-slice owner culls still run — these accumulate their union.
private readonly HashSet<uint> _staticParticleUnionScratch = new();
private readonly HashSet<uint> _cellParticleUnionScratch = new();
// Every cell drawn as a building look-in this frame. Retail marks each
// drawn non-player part for the frame (DrawMeshInternal @0x0059F360,
// GetDrawnThisFrame), so an object whose cell drew with a look-in cannot
// draw again in a later pass; dynamics-last consults this set to honor
// the same drawn-once contract.
private readonly HashSet<uint> _lookInCellIds = new();
private readonly HashSet<uint> _oneCell = new(1);
// Shell-batch scratch: all of a pass's cells collected for ONE batched
// opaque Render call (instead of one heavy Render per cell). Reused across
// frames + across look-in buildings. Spec:
// docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md
private readonly HashSet<uint> _shellBatch = new();
// Transparent shell cells retain IndoorDrawPlan's far-to-near order. The
// EnvCell renderer uploads shared instance/command data once, then issues
// range-addressed MDI calls in this exact order.
private readonly List<uint> _orderedTransparentShellCells = new();
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly BuildingGroupScratch _buildingGroups = new();
private readonly PortalVisibilityFrame _outdoorBuildingFrameScratch = new();
// #124: per-building look-in frames under an INTERIOR root, drawn as a
// landscape-stage sub-pass (DrawBuildingLookIns) — never merged into the
// main frame (see DrawInside). Rebuilt each interior-root frame.
private readonly List<PortalVisibilityFrame> _lookInFrames = new();
private readonly Stack<PortalVisibilityFrame> _lookInFramePool = new();
private readonly HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: landscape scene-particle owner survivors. With building
// look-ins, static owners use the pre-building alpha barrier and the late
// phase contains only outside-stage dynamics; otherwise the late phase
// carries both sets.
private readonly HashSet<uint> _lateParticleOwnerScratch = new();
private readonly HashSet<uint> _cellParticleOwnerScratch = new();
private readonly HashSet<uint> _dynamicParticleOwnerScratch = new();
// FW4 slice 4: owners whose particles ALREADY submitted in the
// pre-clear outside stage this frame (interior roots only). The last
// pass subtracts them — retail submits an emitter's polys at its
// object's FIRST draw (the landscape stage for anything overlapping
// outdoor shadow cells) and drains them at the pre-clear boundary
// flush over true landscape depth; a straddler re-emitted post-clear
// has no outdoor depth left to occlude it (the cathedral falls
// shine-through, fourth surface).
private readonly HashSet<uint> _preClearParticleOwnerScratch = new();
// FW4 slice 5: interior flood-cell STATICS whose bounds straddle their
// cell's exit-portal plane (the cathedral falls — EnvCell stab-list
// objects hanging out over the lake). Retail draws such an object
// during LScape::draw via the outdoor shadow-cell lists (DrawBlock
// pc:430056-430064, once per overlapped shadow cell), so its particles
// submit in the landscape scope and drain at the pre-clear boundary
// flush over true terrain depth; the cell's own post-clear turn skips
// them (drawn-once). Computed after Collect from the walk's flood;
// submitted by the pre-clear closure; subtracted from the post-replay
// cell-owners union.
private readonly HashSet<uint> _preClearCellStaticOwnerScratch = new();
// MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/
// Dynamics), reused across frames instead of `new`ing a Result (a Dictionary
// + 2 Lists, plus one List<WorldEntity> per visible cell) every DrawInside
// call. See InteriorEntityPartition.Partition(Result, ...) — clears in
// place and reuses each cell's list across frames when the cell stays
// visible.
// Slice G4: this is now a diagnostic/fallback oracle only. Normal
// production consumes the retained RenderFrameView routes directly.
private readonly InteriorEntityPartition.Result _partitionResult = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every consumer (DrawEntityBucket, DrawExitPortalMasks,
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new();
private readonly RetailPViewScratchRetention _scratchRetention = new();
public RetailPViewRenderer()
{
}
// 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;
// ACDREAM_PROBE_WALK_ROOT (FW3 visual-gate apparatus, throwaway): the
// previous frame's root kind + a post-flip frame countdown so each
// interior/outdoor transition dumps 8 frames of rooting facts.
private bool? _probeWalkRootPrevOutdoor;
private int _probeWalkRootFramesLeft;
private ulong _probeWalkRootFrame;
// 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(
InteriorEntityPartition.IObserver? partitionObserver,
RenderScenePViewFrameProductController? sceneFrameProduct = null,
Walk.WalkBuildingRegistry? walkBuildings = null,
Walk.WalkLandscapeAssembler? walkLandscape = null,
CellVisibility? walkCellRegistry = null)
{
_walkBuildings = walkBuildings;
_walkLandscape = walkLandscape;
_walkCellRegistry = walkCellRegistry;
_walkWorldData = walkBuildings is not null
? new Walk.WalkProductionWorldData(walkBuildings)
: null;
_partitionObserver = partitionObserver;
_candidateObserver = partitionObserver as ICurrentRenderPViewObserver;
_sceneFrameProduct = sceneFrameProduct;
}
// 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;
public RetailPViewFrameResult DrawInside(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(passes);
passes.BeginFrame();
RecycleLookInFrames();
ResetBuildingGroups();
_preClearParticleOwnerScratch.Clear();
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.Cells.Find,
ctx.ViewProjection,
buildingMembership: null,
reuseFrame: _mainPortalFrameScratch);
// R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge
// the small (~2-cell) per-building views into the frame. Retail reaches building interiors via
// the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the
// land root itself has no portals (it floods nothing into buildings). Per-building seeding is
// robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood
// oscillated as the chase eye grazed a doorway (the indoor flap).
if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null)
MergeNearbyBuildingFloods(ctx, pvFrame);
// #124: interior-root building look-ins. Retail runs the look-in INSIDE
// the landscape stage for ANY root — LScape::draw is the FIRST call of
// DrawCells' outside-view branch (pc:432719), strictly BEFORE the depth
// clear (pc:432732) and the exit-portal seals (pc:432785); a far
// building seen through our doorway floods clipped to the INSTALLED
// outside view (GetClip vs current view, ConstructView(CBldPortal)
// 0x005a59a0). These frames therefore draw in DrawBuildingLookIns
// (inside the landscape stage), NEVER merged into the main frame — a
// merged cell would draw post-clear and z-fail against the root's seal
// (its geometry is beyond the door plane). The eye-side seed test
// self-excludes the root's own building (the eye is on its interior
// side). Outdoor roots keep the MergeNearbyBuildingFloods path above
// (no depth clear under outdoor roots — the merged form is equivalent
// there).
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = passes.AssembleClipFrame(
pvFrame,
_clipAssemblyScratch);
passes.AppendLookInClipFrames(_lookInFrames, clipAssembly);
// 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();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch;
passes.UseIndoorMembershipOnlyRouting();
// Campaign FW3.2b-2: the production rooting. A concrete executor is
// required — the walk submits through WbDrawDispatcher.SubmitOrderedStream
// and needs a real GPU frame/encoder (RequireWalkSubmission), which no
// test IRetailPViewPassExecutor fake can supply. Whenever a concrete
// executor IS present, the packed entity-route product must ALSO be
// wired with all three walk registries — a scene product without the
// walk data (or vice versa) is a production miswiring, not a
// legacy/diagnostic shape, so it fails loud rather than silently
// falling back to the retired static routes (plan §FW3 item 6).
RetailPViewPassExecutor? walkExecutor = passes as RetailPViewPassExecutor;
bool walkRegistriesReady =
_walkBuildings is not null
&& _walkLandscape is not null
&& _walkCellRegistry is not null
&& _walkWorldData is not null;
if (walkExecutor is not null
&& _sceneFrameProduct is not null
&& !walkRegistriesReady)
{
throw new InvalidOperationException(
"RetailPViewRenderer has a concrete pass executor and a "
+ "RenderScenePViewFrameProductController but the walk registries "
+ "(WalkBuildingRegistry/WalkLandscapeAssembler/CellVisibility) are "
+ "not all wired — the Campaign FW3.2b-2 static cutover requires "
+ "every piece together; see FrameRootComposition's "
+ "RetailPViewRenderer construction.");
}
bool walkActive =
walkExecutor is not null
&& _sceneFrameProduct is not null
&& walkRegistriesReady;
// 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 = null;
if (walkActive)
{
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;
var walkContext = new Walk.WalkProductionFrameContext(
_walkCellRegistry!,
_walkBuildings!,
ctx.ViewerEyePos,
forward,
ctx.ViewProjection,
viewportWidth,
viewportHeight);
_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(
_sceneFrameProduct!.SceneQuery,
ctx.PlayerLandblockId ?? 0u,
ctx.RenderCenterLbX,
ctx.RenderCenterLbY);
Action clearInteriorDepth = () =>
{
// FW3 visual-gate fix (owner report: doors/candles invisible
// looking out; the crossing vanish): retail draws the
// OUTSIDE world's objects INSIDE LScape::draw — strictly
// BEFORE the depth clear + seals (the #118 house-exit
// clip+vanish lesson: anything drawn after the seals z-fails
// against their true-depth stamp the moment it stands beyond
// the door plane). The surviving dynamic routes + outdoor
// particles + weather therefore run HERE, at the walk's
// pre-clear boundary, for an interior root.
_walkPreClearDynamics?.Invoke();
// Retail PView::DrawCells 0x005A4872 drains the landscape
// alpha list immediately before the gated full depth clear —
// mirrors DrawLandscapeThroughOutsideView's own pre-clear
// drain.
passes.FlushLandscapeAlpha();
passes.ClearInteriorDepth();
};
// FW4 slice 2: the seals stamp the WALK'S OWN flood cells (see
// DrawWalkExitPortalMasks). walkDriver is assigned below, before
// any Replay can fire this closure.
Action drawExitSeals = () =>
DrawWalkExitPortalMasks(ctx, passes, clipAssembly, walkDriver!);
var leafRenderer = new WalkProductionLeafRenderer(
walkExecutor!, ctx, clipAssembly, clearInteriorDepth, drawExitSeals);
walkDriver = new Walk.WalkFrameDriver(walkExecutor!.Dispatcher, leafRenderer, _walkWorldData);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
{
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame =
_probeWalkRootFrame % 90 == 0;
}
walkDriver.Collect(
_frameWalk, ctx.ViewerCellId, walkCameraCell, walkLandscape, walkContext,
ctx.ViewProjection, ctx.CameraWorldPosition);
AcDream.Core.Rendering.RenderingDiagnostics.WalkPortalProbeThisFrame = false;
// 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);
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkRootEnabled)
{
_probeWalkRootFrame++;
bool outdoorNow = ctx.RootCell.IsOutdoorNode;
if (_probeWalkRootPrevOutdoor is bool prev && prev != outdoorNow)
{
_probeWalkRootFramesLeft = 8;
Console.WriteLine(
$"[walk-root] ---- FLIP {(prev ? "OUT->IN" : "IN->OUT")} at frame {_probeWalkRootFrame} ----");
}
_probeWalkRootPrevOutdoor = outdoorNow;
if (_probeWalkRootFramesLeft > 0)
{
_probeWalkRootFramesLeft--;
var cellsList = new List<uint>(walkDriver.VisitedCells);
cellsList.Sort();
string cells = string.Join(",", cellsList.ConvertAll(c => c.ToString("x8"))
.GetRange(0, Math.Min(cellsList.Count, 10)));
Console.WriteLine(
$"[walk-root] f={_probeWalkRootFrame} out={(outdoorNow ? 1 : 0)} "
+ $"viewer=0x{ctx.ViewerCellId:X8} root=0x{ctx.RootCell.CellId:X8} "
+ $"res={ctx.CameraCellResolution} slices={clipAssembly.OutsideViewSlices.Length} "
+ $"eye=({ctx.ViewerEyePos.X:F2},{ctx.ViewerEyePos.Y:F2},{ctx.ViewerEyePos.Z:F2}) "
+ $"walkCells={walkDriver.VisitedCells.Count} bld={walkDriver.VisitedBuildings.Count} "
+ $"cells=[{cells}]");
}
}
}
// 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);
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
// cell-object pass iterates pvFrame.OrderedVisibleCells, which never
// contains them). drawableCells itself stays the MAIN flood: it feeds the
// seals, the outside-stage predicate, and the frame result.
var prepareCells = drawableCells;
_lookInCellIds.Clear();
if (_lookInFrames.Count > 0 || walkActive)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
{
foreach (uint c in f.OrderedVisibleCells)
{
_lookInPrepareScratch.Add(c);
_lookInCellIds.Add(c);
}
}
if (walkActive)
{
// The walk's own flood/look-in cell set — unioned in (never
// aliased with drawableCells, which the outside-stage and seal
// predicates below still need scoped to the OLD flood only).
_lookInPrepareScratch.UnionWith(walkDriver!.VisitedCells);
}
prepareCells = _lookInPrepareScratch;
}
// (#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);
// T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then
// per-cell statics, then ALL dynamics last (retail draws objects after
// cells: PView::DrawCells Ghidra 0x005a4840; DrawBuilding 0x0059f2a0).
// The geometric shell chop (gl_ClipDistance crop, 927fd8f/9ce335e) is
// DELETED — retail never clips cell geometry; aperture exactness comes
// from the punch/seal depth writes + the z-buffer, and the dynamics-
// last order is what makes the punch safe (the first BR-2 attempt
// punched after dynamics and erased the player, reverted 88be519).
// T3 (BR-5): retail viewconeCheck — meshes are sphere-CULLED per view,
// never clipped (Ghidra 0x0054c250). Built once per frame from the
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(
clipAssembly,
ctx.ViewProjection,
_viewconeScratch);
IRenderFrameEntityPassExecutor? frameEntityPasses = null;
if (_sceneFrameProduct is not null)
{
frameEntityPasses = passes as IRenderFrameEntityPassExecutor
?? throw new InvalidOperationException(
"The production frame product requires a packed entity-pass executor.");
}
RenderFrameView frameView = default;
bool frameViewBorrowed = false;
bool entityFrameOpen = false;
_candidateObserver?.BeginPViewFrame();
try
{
// FW4 slice 3: the outside-stage predicate's flood-membership set
// is THE WALK'S visited cells on walk frames (root flood +
// look-in floods — retail draws all of their objects inside
// LScape::draw, pre-clear). The old apparatus's drawableCells
// misses cells at the #456 seam band, dropping interior-parented
// outdoor emitters (the cathedral falls weenies) to the
// post-clear last pass, where the cleared depth lets them splat
// across terrain and water. drawableCells keeps its other roles
// (prepare filter, frame result) unchanged.
HashSet<uint> outsideStageFlood =
walkActive ? walkDriver!.VisitedCells : drawableCells;
if (_sceneFrameProduct is not null)
{
frameView = _sceneFrameProduct.BuildAndBorrow(
pvFrame,
clipAssembly,
viewcone,
_lookInFrames,
outsideStageFlood,
ctx.Cells,
ctx.AnimatedEntityIds,
ctx.RootCell.IsOutdoorNode);
frameViewBorrowed = true;
frameEntityPasses!.BeginEntityFrame(in frameView);
entityFrameOpen = true;
}
// The retained scene product is the production object source.
// Rebuild the former WorldEntity partition only for the standalone
// fallback and explicitly enabled comparison/probe paths.
InteriorEntityPartition.Result? partition = null;
if (_sceneFrameProduct is null || LegacyPartitionDiagnosticsEnabled)
{
InteriorEntityPartition.Partition(
_partitionResult,
prepareCells,
ctx.LandblockEntries,
_partitionObserver,
ctx.Frustum,
ctx.PlayerLandblockId ?? 0u);
partition = _partitionResult;
}
RenderFrameDiagnosticCounts counts = frameViewBorrowed
? frameView.DiagnosticCounts
: LegacyDiagnosticCounts(partition!);
RenderProjectionCounts sourceCounts = frameViewBorrowed
? frameView.SourceDigest.Counts
: LegacySourceCounts(partition!);
// prepareCells is exactly "main flood look-in cells" — the cells
// this traversal actually reached, i.e. retail's in-view set.
RetailPViewFrameResult result = _frameResultScratch.Reset(
pvFrame,
clipAssembly,
drawableCells,
prepareCells,
counts,
sourceCounts,
partition);
passes.EmitDiagnostics(ctx, result);
// Campaign FW3.2b-2 flip apparatus (ACDREAM_PROBE_WALK_SHADOW=1,
// throwaway — dies with the flip commit): run the PRODUCTION
// retail frame walk in shadow and report set divergence vs this
// frame's old-path visibility. No draws change.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeWalkShadowEnabled)
RunWalkShadowProbe(ctx, prepareCells);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage —
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
// full depth clear (pc:432731-432732) and the exit-portal SEALS
// (pc:432785-432786); DrawBlock draws every landcell's objects via
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
// single last pass instead z-fails against the seal's true-depth stamp
// the moment it stands beyond the door plane — the house-exit
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
// interior root: outdoor-classified dynamics draw in the outside
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
// plane. Outdoor roots keep ALL dynamics in the last pass — our
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
// BR-2 punch-after-dynamics lesson, reverted 88be519).
_outsideStageDynamics.Clear();
if (partition is not null && !ctx.RootCell.IsOutdoorNode)
{
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
// FW4 slice 3: same walk-flood membership set as the
// frame product's BuildOutsideDynamicRoutes — the two
// predicate call sites must agree or routed and drawn
// stages diverge.
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, outsideStageFlood, ctx.Cells))
_outsideStageDynamics.Add(e);
}
}
// FW4 slice 5: classify the interior flood cells' STATICS by the
// exit-plane straddle test — a straddler's particles (the
// cathedral falls: EnvCell stab objects hanging over the lake)
// submit pre-clear like retail's outdoor shadow-cell draw; see
// _preClearCellStaticOwnerScratch's field comment. GetCellStatics
// is per-frame cached, so this also pre-warms the post-replay
// cell-owners pass.
_preClearCellStaticOwnerScratch.Clear();
if (walkActive && !ctx.RootCell.IsOutdoorNode)
{
foreach (uint cellId in walkDriver!.VisitedCells)
{
if ((cellId & 0xFFFFu) < 0x100u || _lookInCellIds.Contains(cellId))
continue;
LoadedCell? staticCell = ctx.Cells.Find(cellId);
if (staticCell is null)
continue;
Walk.WalkFrameStaticRecords cellRecords =
_walkWorldData!.GetCellStatics(cellId);
foreach (RenderProjectionRecord record in cellRecords.Records)
{
if (record.Source.LocalEntityId == 0)
continue;
Vector3 c = (record.Bounds.Minimum + record.Bounds.Maximum) * 0.5f;
float r = (record.Bounds.Maximum - record.Bounds.Minimum).Length() * 0.5f;
if (SphereStraddlesExitPlane(staticCell, c, r))
_preClearCellStaticOwnerScratch.Add(record.Source.LocalEntityId);
}
}
}
if (walkActive)
{
// Campaign FW3.2b-2: the walk owns every static draw —
// terrain/sky, building shells + punches + look-in cell
// statics, and the interior root's own flood shells +
// statics (with the depth clear + exit seals at their real
// retail turn). The OLD visibility (pvFrame/clipAssembly/
// viewcone, already built above) keeps running unchanged to
// feed the surviving dynamic routes only (plan §FW3 item 1's
// dual-compute split). Campaign FW3.4a: the walk itself
// already ran (Collect, above) — this is Replay only.
//
// FW3 visual-gate fix: for an INTERIOR root the dynamics
// phase (outside dynamics + look-in dynamics + outdoor
// particles + weather) must draw at the walk's PRE-CLEAR
// boundary — retail draws them inside LScape::draw, before
// the clear+seals — so the driver's clearInteriorDepth
// closure invokes it via _walkPreClearDynamics. For an
// OUTDOOR root there is no clear (retail has none) and the
// phase runs after the driver, matching the old order.
RenderFrameView capturedView = frameView;
IRenderFrameEntityPassExecutor? capturedPasses = frameEntityPasses;
InteriorEntityPartition.Result? capturedPartition = partition;
ViewconeCuller capturedViewcone = viewcone;
Walk.WalkFrameDriver capturedDriver = walkDriver!;
_walkPreClearDynamics = () =>
{
// Retail inserts static-owner emitters (candle flames)
// during their cells' landscape walk turns — submit
// BEFORE the pre-clear drain so they composite against
// still-true landscape depth, never the seals.
SubmitWalkLandscapeStaticParticles(ctx, walkExecutor!, capturedDriver);
// FW4 slice 5: straddling flood-cell statics' emitters
// (the falls) — retail's shadow-cell landscape draw.
if (_preClearCellStaticOwnerScratch.Count > 0)
{
_preClearParticleOwnerScratch.UnionWith(
_preClearCellStaticOwnerScratch);
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_preClearCellStaticOwnerScratch));
}
passes.UseIndoorMembershipOnlyRouting();
DrawLandscapeDynamicsPhase(
ctx,
passes,
clipAssembly,
capturedPartition,
capturedViewcone,
capturedPasses,
in capturedView);
};
try
{
DrawWalkDrivenStatics(ctx, walkExecutor!, walkDriver!);
passes.UseIndoorMembershipOnlyRouting();
if (ctx.RootCell.IsOutdoorNode)
{
DrawLandscapeDynamicsPhase(
ctx,
passes,
clipAssembly,
partition,
viewcone,
frameEntityPasses,
in frameView);
}
}
finally
{
_walkPreClearDynamics = null;
}
}
else
{
DrawLandscapeThroughOutsideView(
ctx,
passes,
clipAssembly,
partition,
viewcone,
frameEntityPasses,
in frameView);
passes.UseIndoorMembershipOnlyRouting();
// Retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE
// its portal-only far-Z pass. In retail's strict far→near walk
// everything queued at that instant is FARTHER than the structure
// being punched, so no already-drained poly can meet a punched
// aperture's falsified depth, and everything drained later is
// NEARER than the punched structure and legitimately composites in
// front of it. The batched outdoor frame reproduces that invariant
// here: drain the far prefix — every entry at or beyond the
// nearest cell whose exit-portal mask is about to punch far-Z —
// against still-true landscape depth. Without this, an exterior
// waterfall beyond the cathedral drains after the punches and
// z-passes across every aperture pixel whose true depth the punch
// erased (#132 regression found at the 2026-08-29 cathedral gate).
// Interior roots keep their pre-clear stage-boundary drain.
if (ctx.RootCell.IsOutdoorNode)
{
passes.FlushLandscapeAlphaFartherThan(
ExitPortalMaskBarrierDistance(
pvFrame,
drawableCells,
ctx.Cells,
ctx.CameraWorldPosition));
}
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists(
ctx,
passes,
pvFrame,
clipAssembly,
drawableCells,
partition,
viewcone,
frameEntityPasses,
in frameView);
}
DrawDynamicsLast(
ctx,
passes,
partition,
viewcone,
ctx.RootCell.IsOutdoorNode,
frameEntityPasses,
in frameView);
// 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);
if (entityFrameOpen)
{
frameEntityPasses!.CompleteEntityFrame(in frameView);
entityFrameOpen = false;
}
_candidateObserver?.CompletePViewFrame();
_sceneFrameProduct?.CompleteProduction(in frameView);
return result;
}
catch
{
if (entityFrameOpen)
frameEntityPasses!.AbortEntityFrame();
_candidateObserver?.AbortPViewFrame();
throw;
}
finally
{
if (frameViewBorrowed)
_sceneFrameProduct!.Release(in frameView);
}
}
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict contains only this frame's keys; lists are pooled across frames.
private void MergeNearbyBuildingFloods(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame)
{
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group,
ctx.ViewerEyePos,
ctx.Cells.Find,
ctx.ViewProjection,
OutdoorBuildingSeedDistance,
reuseFrame: _outdoorBuildingFrameScratch);
MergeBuildingFrame(pvFrame, buildingFrame);
}
}
// T2 (BR-4): merge a per-building flood's cells + views into the frame as a
// UNION. Retail accumulates EVERY clipped portal polygon as a new view_poly
// on the cell (Render::copy_view appends + view_count++, Ghidra 0x0054dfc0;
// a cell visible through two apertures holds two views, all consumed
// downstream). The old first-wins (`ContainsKey -> continue`) dropped the
// second building flood's views whenever a cell was already in the frame —
// the multiview-loss-first-wins divergence (a named #109 suspect: per-frame
// winner flips between apertures). CellView.Add dedups exact/collinear
// re-emissions (the dac8f6a CanonicalKey), so unioning is convergent.
// OutsideView is NOT merged — the outdoor root already seeds full-screen
// terrain, and ConstructViewBuilding (BuildFromExterior) leaves OutsideView
// empty (it stops at exit portals once inside the building).
private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src)
{
foreach (uint cellId in src.OrderedVisibleCells)
{
if (!src.CellViews.TryGetValue(cellId, out var srcView))
continue;
if (!target.CellViews.TryGetValue(cellId, out var existing))
{
existing = target.RentCellView();
target.CellViews[cellId] = existing;
target.OrderedVisibleCells.Add(cellId);
}
// Copy the view polygons into storage owned by the target frame.
// Source building frames are reused immediately for the next
// building flood, so retaining their CellView reference would
// alias pooled scratch and mutate the merged main view.
foreach (var p in srcView.Polygons)
existing.Add(target.CopyPolygon(p.Vertices));
}
}
// #124: per-building look-in floods for an INTERIOR root, seeded clipped
// against the OutsideView (retail: GetClip runs under the INSTALLED view —
// the accumulated doorway region — so a far building floods only within the
// doorway, ConstructView(CBldPortal) 0x005a59a0 via PView::GetClip
// 0x005a4320). Same grouping as MergeNearbyBuildingFloods; the root's own
// building self-excludes via the seed eye-side test.
private void BuildInteriorRootLookIns(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame)
{
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
PortalVisibilityFrame frameScratch = _lookInFramePool.Count != 0
? _lookInFramePool.Pop()
: new PortalVisibilityFrame();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
LoadedCell sourceCell = group[0];
frame.SourceBuildingKey = sourceCell.BuildingId ?? sourceCell.CellId;
frame.SourceBuildingLandblockId = sourceCell.CellId & 0xFFFF0000u;
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
else
ReturnLookInFrame(frame);
}
}
/// <summary>
/// Conservative barrier drain threshold for one look-in frame: the viewer
/// distance to the frame's nearest anchor-cell ORIGIN. Cell origins sit
/// inside the building, so this over-estimates the building's
/// nearest-point distance and under-drains; anything conservatively
/// retained still composites correctly at the later depth-tested drains.
/// Retail needs no threshold — its far→near walk guarantees only farther
/// content is queued when DrawBuilding flushes (@0x0059F2A0). Returns 0
/// (full drain, today's behavior) when no cell resolves.
/// </summary>
/// <summary>
/// The pre-punch barrier threshold for <see cref="DrawExitPortalMasks"/>:
/// the nearest drawable cell whose exit-portal mask is about to write
/// far-Z. Every queued alpha entry at or beyond it must drain first
/// (retail DrawBuilding @0x0059F2A0's FlushAlphaList(0f) before the
/// portal-only pass), because after the punch those entries would z-pass
/// across aperture pixels whose true depth no longer exists. No punched
/// cells → MaxValue → the partial drain retains everything.
/// </summary>
internal static float ExitPortalMaskBarrierDistance(
PortalVisibilityFrame frame,
HashSet<uint> drawableCells,
IRetailPViewCellSource cells,
Vector3 viewerPosition)
{
float best = float.PositiveInfinity;
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
LoadedCell? cell = cells.Find(cellId);
if (cell is null)
continue;
float distance = Vector3.Distance(
cell.WorldTransform.Translation,
viewerPosition);
if (distance < best)
best = distance;
}
return float.IsFinite(best) ? best : float.MaxValue;
}
internal static float LookInBarrierDrainDistance(
PortalVisibilityFrame frame,
IRetailPViewCellSource cells,
Vector3 viewerPosition)
{
float best = float.PositiveInfinity;
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
{
LoadedCell? cell = cells.Find(frame.OrderedVisibleCells[i]);
if (cell is null)
continue;
float distance = Vector3.Distance(
cell.WorldTransform.Translation,
viewerPosition);
if (distance < best)
best = distance;
}
return float.IsFinite(best) ? best : 0f;
}
private void RecycleLookInFrames()
{
for (int i = 0; i < _lookInFrames.Count; i++)
ReturnLookInFrame(_lookInFrames[i]);
_scratchRetention.ClearFrameBuffers(
_lookInFrames,
_lookInPrepareScratch,
_drawableCellsScratch,
_shellBatch,
_orderedTransparentShellCells);
}
private void ReturnLookInFrame(PortalVisibilityFrame frame)
{
frame.ResetForBuild();
if (_lookInFramePool.Count < RetailPViewScratchRetention.MaxRetainedLookInFrames)
_lookInFramePool.Push(frame);
}
private void RebuildBuildingGroups(IReadOnlyList<LoadedCell> nearbyCells)
=> _buildingGroups.Rebuild(nearbyCells);
private void ResetBuildingGroups()
=> _buildingGroups.Reset();
// #124: draw the interior-root look-ins INSIDE the landscape stage —
// retail's placement (LScape::draw → DrawBlock → DrawSortCell →
// DrawBuilding runs as the FIRST call of DrawCells' outside-view branch,
// pc:432719, before the depth clear + seals). Per building: punch ALL
// apertures first (retail finishes build_draw_portals_only pass 1 — the
// far-Z maxZ1 punch — across the whole building BSP before pass 2 floods),
// then draw the flooded cells' shells + statics far→near (the nested
// DrawCells' DrawEnvCell + DrawObjCellForDummies; its outside_view is
// empty by construction — PView ctor draw_landscape=0 — so no recursive
// landscape/clear/seal). Retail CEnvCell::setup_view installs every cell's
// nested portal_view before DrawEnvCell, while DrawMesh iterates that same
// PortalList for cell objects. Preserve that per-slice gate here; drawing a
// nested cell whole lets its floor, details, and emitters escape the authored
// aperture even when the outer depth choreography is otherwise correct.
private void DrawBuildingLookIns(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (_lookInFrames.Count == 0)
return;
int outsideSliceCount = clipAssembly.OutsideViewSlices.Length;
int lookInRouteIndex = 0;
for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
// Retail enters DrawBuilding once per building and drains the
// alpha accumulated by the preceding building before punching the
// next building's portals — and because retail's far→near walk
// has only inserted FARTHER content by then, that drain can never
// composite an emitter nearer than this building
// (FlushAlphaList(0f) @0x0059F2A0 under the walk; AP-236).
// The first building uses the pre-look-in barrier in
// DrawLandscapeThroughOutsideView.
if (frameIndex > 0)
{
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
frame,
ctx.Cells,
ctx.CameraWorldPosition));
}
// Pass 1: far-Z punch every aperture of this building.
foreach (ExteriorPortalSeed seed in frame.ExteriorSeedPortals)
{
foreach (var poly in seed.View.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
seed.CellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
NoParticleOwners),
seed.PortalIndex);
}
}
// Pass 2: shells + objects, far→near, once per portal_view slice.
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? cellSlices)
|| cellSlices.Length == 0)
{
continue;
}
_cellStaticScratch.Clear();
if (partition is not null
&& partition.ByCell.TryGetValue(cellId, out var bucket))
{
_cellStaticScratch.AddRange(bucket);
}
// #131 ROOT CAUSE: DYNAMICS living in a look-in cell (the
// Holtburg hall-porch PORTAL, pCell 0xA9B4017A) draw NOWHERE
// under an interior root — DrawDynamicsLast viewcone-culls
// them (the main cone has no entries for look-in cells), and
// post-clear they would z-fail against the root's seal anyway
// (the #118 lesson). Retail draws a look-in cell's objects
// inside the NESTED DrawCells (DrawObjCellForDummies,
// pc:432878+), i.e. right here in the landscape stage.
// No double-draw: dynamics-last keeps culling them (their
// cell is absent from the main cone), and their emitters ride
// the DrawCellParticles call below, not DrawDynamicsParticles
// (which only sees dynamics-last cone survivors).
if (partition is not null)
{
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
}
bool cellDrewObjects = false;
_cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices)
{
int routeIndex = lookInRouteIndex++;
passes.UseCellPortalViewRouting(cellId, slice);
_oneCell.Clear();
_oneCell.Add(cellId);
passes.DrawOpaqueCellShells(_oneCell);
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch,
_oneCell);
cellDrewObjects = true;
_cellParticleUnionScratch.UnionWith(
_cellParticleOwnerScratch);
}
}
// The nested DrawCells object pass includes emitters: ONE
// unclipped submission per look-in cell (retail draws a
// particle during its cell's walk turn; the cell walls own
// occlusion by depth at alpha playback — never a view clip).
if (cellDrewObjects)
{
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, NoClipSlice, _cellParticleUnionScratch));
}
}
// The ordinary exterior building shell is clipped by the outer
// outside_view, not by the nested cell PortalList.
passes.UseIndoorMembershipOnlyRouting();
// Retail's ordinary shell pass immediately follows this same
// building's portal-only pass. Pair by the shell's authored
// anchor EnvCell; never let an unrelated building repaint a
// look-in merely because both happen to be nearby.
int sliceIndex = 0;
_staticParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in clipAssembly.OutsideViewSlices)
{
int shellRouteIndex = LookInBuildingShellRouteIndex(
frameIndex,
outsideSliceCount,
sliceIndex);
_buildingShellScratch.Clear();
if (partition is not null)
{
foreach (WorldEntity entity in partition.OutdoorStatic)
{
if (!entity.IsBuildingShell
|| FindLookInFrameIndex(
entity.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) != frameIndex)
{
continue;
}
EntitySphere(entity, out Vector3 center, out float radius);
if (viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
center,
radius))
{
_buildingShellScratch.Add(entity);
}
}
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
_buildingShellScratch);
bool hasPackedShell = frameEntityPasses is not null
&& HasExactRoute(
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
if (hasPackedShell || _buildingShellScratch.Count > 0)
{
RenderFrameEntityDrawRequest? shellDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0,
ctx.PlayerLandblockId ?? 0);
passes.DrawLandscapeBuildingShellSlice(
ctx,
new RetailPViewLandscapeBuildingShellSliceContext(
slice,
_buildingShellScratch)
{
EntityDraw = shellDraw,
});
_lateParticleOwnerScratch.Clear();
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeBuildingShell,
shellRouteIndex,
0);
}
else
{
ReplaceOwnerIds(
_lateParticleOwnerScratch,
_buildingShellScratch);
}
_staticParticleUnionScratch.UnionWith(
_lateParticleOwnerScratch);
}
sliceIndex++;
}
// ONE unclipped submission for this look-in frame's shell-route
// owners (retail: one alpha-list insertion per emitter,
// depth-occluded at the flush — never re-drawn per outside view).
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
}
}
/// <summary>Campaign FW3.2b-2 flip apparatus (the I5 dual-shadow
/// pattern): drive the production walk over the FW3.1 registries with a
/// set-collecting sink and print one <c>[walk-shadow]</c> line per frame
/// whose visited cells diverge from the old path's
/// <paramref name="oldPathCells"/> (main flood look-ins). Divergence is
/// EXPECTED where the walk's retail model deliberately differs from the
/// old builder — the probe's value is proving the production data
/// pipeline live and QUANTIFYING the difference for the flip review. A
/// probe exception prints loudly and never kills the frame (it is the
/// probe's own signal, not a production fault).</summary>
private void RunWalkShadowProbe(
RetailPViewFrameInput ctx, HashSet<uint> oldPathCells)
{
if (_walkBuildings is null || _walkLandscape is null
|| _walkCellRegistry is null)
{
return;
}
try
{
// Forward = -(view column 3): System.Numerics CreateLookAt's
// zaxis is eye-target (backward). Viewport: the walk's SETS are
// viewport-scale-tolerant (every screen projection shares the
// same constants), so the shadow pins retail's capture size; the
// flip itself will use the real attachment extent.
Matrix4x4 view = ctx.CameraView;
var forward = Vector3.Normalize(new Vector3(-view.M13, -view.M23, -view.M33));
var context = new Walk.WalkProductionFrameContext(
_walkCellRegistry,
_walkBuildings,
ctx.ViewerEyePos,
forward,
ctx.ViewProjection,
viewportWidth: 1024f,
viewportHeight: 720f);
Walk.WalkLandscape landscape = _walkLandscape.Landscape;
_walkLandscape.SetViewer(ctx.ViewerCellId, ctx.ViewerEyePos);
Walk.WalkCell? cameraCell = null;
if ((ctx.ViewerCellId & 0xFFFFu) >= 0x100)
{
cameraCell = _walkCellRegistry.TryGetCell(ctx.ViewerCellId, out LoadedCell? loaded)
? loaded?.Walk
: null;
if (cameraCell is null)
{
Console.WriteLine(
$"[walk-shadow] root={ctx.ViewerCellId:x8} interior camera cell has no walk data");
return;
}
}
var sink = new WalkVisitedSetCollector();
_frameWalk.WalkFrame(
ctx.ViewerCellId, cameraCell, landscape, context, sink);
int onlyWalk = 0;
foreach (uint id in sink.Cells)
if (!oldPathCells.Contains(id))
onlyWalk++;
int onlyOld = 0;
foreach (uint id in oldPathCells)
if (!sink.Cells.Contains(id))
onlyOld++;
if (onlyWalk != 0 || onlyOld != 0)
{
Console.WriteLine(
$"[walk-shadow] root={ctx.ViewerCellId:x8} walkCells={sink.Cells.Count} "
+ $"oldCells={oldPathCells.Count} onlyWalk={onlyWalk} onlyOld={onlyOld} "
+ $"walkBuildings={sink.BuildingCount} landscapeTurns={sink.LandscapeCount}");
}
}
catch (Exception failure)
{
Console.WriteLine($"[walk-shadow] PROBE FAULT root={ctx.ViewerCellId:x8}: {failure}");
}
}
// Campaign FW3.2b-2: the one RetailFrameWalk instance shared by the
// diagnostic shadow probe and the real Collect run (WalkFrameDriver.Collect,
// driven from DrawInside's walkActive block) — WalkFrame calls are never
// concurrent/re-entrant within a single-threaded render loop, so one
// shared instance is safe and avoids re-allocating the walk's own PView
// scratch per call site. Campaign FW3.4a retired the THIRD role this
// field used to serve (a dedicated pre-walk collection pass) — Collect
// now gathers the same visited sets itself, on WalkFrameDriver, as a
// side effect of the one walk it already runs.
private readonly Walk.RetailFrameWalk _frameWalk = new();
/// <summary>Campaign FW3.2b-2 (the I5 dual-shadow pattern): an
/// events-only <see cref="Walk.IWalkEventSink"/> that collects the SETS a
/// driven run would touch, without doing any leaf drawing itself. Used
/// ONLY by <see cref="RunWalkShadowProbe"/> now — Campaign FW3.4a moved
/// the production role (the walk's flood cell set for the
/// <c>prepareCells</c> union, the visited building list and
/// landscape-cell turn ids for particle re-sourcing) onto
/// <see cref="Walk.WalkFrameDriver"/> itself, which gathers the same sets
/// as a side effect of the one walk <see cref="Walk.WalkFrameDriver.Collect"/>
/// already runs, instead of a second dedicated pass.</summary>
private sealed class WalkVisitedSetCollector : Walk.IWalkEventSink
{
public readonly HashSet<uint> Cells = new();
public readonly List<Walk.WalkBuilding> Buildings = new();
public readonly HashSet<uint> LandscapeCellIds = new();
public int BuildingCount => Buildings.Count;
public int LandscapeCount => LandscapeCellIds.Count;
public void Reset()
{
Cells.Clear();
Buildings.Clear();
LandscapeCellIds.Clear();
}
public void Emit(in Walk.WalkEvent walkEvent)
{
switch (walkEvent.Kind)
{
case Walk.WalkEventKind.DrawInside:
Cells.Add(walkEvent.CellId);
break;
case Walk.WalkEventKind.DrawCells:
foreach (uint id in walkEvent.Cells)
Cells.Add(id);
break;
}
}
public void OnLandscapeCellTurn(uint cellId) => LandscapeCellIds.Add(cellId);
public void OnBuildingTurn(Walk.WalkBuilding building) => Buildings.Add(building);
}
/// <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>
/// <summary>The landscape-stage static-owner particle submission: the
/// union of every outdoor-static record from a landscape cell the walk
/// visited this frame, plus every shell record from a building the walk
/// visited (plan §FW3 item 4 — retail gates particles per cell turn,
/// <c>ShouldDrawParticles</c> @0x0050FE60, so this is MORE
/// retail-faithful than the old per-slice sphere filter it replaced).
/// Interior roots invoke this at the walk's pre-clear boundary; outdoor
/// roots post-replay.</summary>
private void SubmitWalkLandscapeStaticParticles(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
Walk.WalkFrameDriver driver)
{
_staticParticleUnionScratch.Clear();
foreach (uint cellId in driver.VisitedLandscapeCellIds)
UnionRecordOwners(_walkWorldData!.GetOutdoorStatics(cellId), _staticParticleUnionScratch);
foreach (Walk.WalkBuilding building in driver.VisitedBuildings)
UnionRecordOwners(_walkWorldData!.GetBuildingShellStatics(building), _staticParticleUnionScratch);
if (_staticParticleUnionScratch.Count > 0)
{
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
}
}
private void DrawWalkDrivenStatics(
RetailPViewFrameInput ctx,
RetailPViewPassExecutor passes,
Walk.WalkFrameDriver driver)
{
var (frame, encoder) = passes.RequireWalkSubmission();
driver.Replay(frame, encoder);
// Landscape-stage static-owner particles (candle flames on lamp and
// candle statics): for an OUTDOOR root they submit here, post-replay
// (drained at the frame-end flush over finished depth — the #132
// placement). For an INTERIOR root they must submit at the walk's
// PRE-CLEAR boundary instead — retail inserts each one during its
// cell's landscape walk turn, before the clear+seals; a post-seal
// drain z-fails them at the aperture (the visual-gate "no candle
// flames looking out" report) — so SubmitWalkLandscapeStaticParticles
// is invoked from _walkPreClearDynamics in that case, and skipped
// here.
if (ctx.RootCell.IsOutdoorNode)
SubmitWalkLandscapeStaticParticles(ctx, passes, driver);
// 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 (_lookInCellIds.Contains(cellId))
continue;
UnionRecordOwners(_walkWorldData!.GetCellStatics(cellId), _cellParticleOwnerScratch);
}
// FW4 slice 5: exit-plane straddlers (the falls) already emitted at
// the pre-clear boundary — retail's shadow-cell landscape draw;
// every owner emits exactly once.
_cellParticleOwnerScratch.ExceptWith(_preClearCellStaticOwnerScratch);
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 STATIC route (see
/// <see cref="DrawWalkDrivenStatics"/>); this method keeps ONLY what
/// stays on the OLD visibility pipeline per the plan's dual-compute
/// split: outdoor-cell unattached particles, LookInObject dynamics + their
/// per-cell particles, the late per-slice outside-dynamics/weather loop,
/// and the late particle union submission.</summary>
private void DrawLandscapeDynamicsPhase(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
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);
DrawBuildingLookInDynamics(
ctx, passes, clipAssembly, partition, frameEntityPasses, in frameView);
// LATE phase (per slice): outside-stage dynamics' meshes + weather —
// unchanged from DrawLandscapeThroughOutsideView's own late loop.
_staticParticleUnionScratch.Clear();
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
passes.ClearClipRouting();
_outdoorStaticScratch.Clear();
_lateParticleOwnerScratch.Clear();
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_lateParticleOwnerScratch.Add(e.Id);
}
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Union(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0);
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0,
_outdoorStaticScratch);
RenderFrameEntityDrawRequest? entityDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0,
ctx.PlayerLandblockId ?? 0);
probeSliceIndex++;
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate(
ctx,
new RetailPViewLandscapeLateSliceContext(slice, _outdoorStaticScratch)
{
EntityDraw = entityDraw,
});
}
// Late-particle union submission. OUTDOOR root: DynamicLast owners
// excluded (both stages drain at the same final flush there — a
// duplicate submission would double-composite), matching
// DrawLandscapeThroughOutsideView's own final submission. INTERIOR
// root (FW4 slice 4): the outside-stage owners — exit-plane
// STRADDLERS included — submit HERE, pre-clear, and the last pass
// subtracts them instead: retail submits an emitter's polys at its
// object's FIRST draw (the landscape stage) and drains them at the
// pre-clear boundary flush over true landscape depth; the former
// except-here/emit-last placement left a straddler's outside half
// with no depth to occlude it after the clear (the cathedral falls
// bleeding through terrain and water, probe-pinned:
// outside=1 cone=1 yet phase=post).
if (frameEntityPasses is not null && ctx.RootCell.IsOutdoorNode)
{
RenderFrameRouteOwnerSelector.ExceptRoute(
_staticParticleUnionScratch, in frameView, RenderFrameCandidateRoute.DynamicLast);
}
if (!ctx.RootCell.IsOutdoorNode)
_preClearParticleOwnerScratch.UnionWith(_staticParticleUnionScratch);
if (_staticParticleUnionScratch.Count > 0)
{
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
}
passes.UseIndoorMembershipOnlyRouting();
}
/// <summary>Campaign FW3.2b-2: the DYNAMICS-only remainder of the old
/// <see cref="DrawBuildingLookIns"/> — punches, shells, and look-in cell
/// STATICS are now walk-owned (<see cref="Walk.WalkFrameDriver"/>'s
/// Building/BuildingShell/LookInStatic turns); this method keeps ONLY the
/// LookInObject route (now dynamic-classified — see
/// <c>RenderScenePViewFrameBuilder.BuildLookInRoutes</c>) and the per-cell
/// particle union that route's owners feed, unioned with the walk's
/// static owners for that SAME cell (plan §FW3 item 4 — GetCellStatics
/// fills the gap the retired CellStatic-route particle submission left
/// for look-in cells specifically).</summary>
private void DrawBuildingLookInDynamics(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (_lookInFrames.Count == 0)
return;
int lookInRouteIndex = 0;
for (int frameIndex = 0; frameIndex < _lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = _lookInFrames[frameIndex];
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
var clipKey = new LookInClipCell(frameIndex, cellId);
if (!clipAssembly.LookInCellToViewSlices.TryGetValue(
clipKey,
out ClipViewSlice[]? cellSlices)
|| cellSlices.Length == 0)
{
continue;
}
_cellStaticScratch.Clear();
if (partition is not null)
{
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
}
bool cellDrewObjects = false;
_cellParticleUnionScratch.Clear();
foreach (ClipViewSlice slice in cellSlices)
{
int routeIndex = lookInRouteIndex++;
passes.UseCellPortalViewRouting(cellId, slice);
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_cellStaticScratch);
}
if (frameEntityPasses is not null
|| _cellStaticScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch);
_oneCell.Clear();
_oneCell.Add(cellId);
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.LookInObject,
routeIndex,
cellId,
_cellStaticScratch,
_oneCell);
cellDrewObjects = true;
_cellParticleUnionScratch.UnionWith(
_cellParticleOwnerScratch);
}
}
// The walk already drew this cell's STATIC content
// (WalkFrameDriver's LookInStatic turn) but never submits
// particles for it — GetCellStatics fills that gap, unioned
// with the dynamic route's own owners so ONE
// DrawCellParticles call covers both.
if (_walkWorldData is not null)
{
Walk.WalkFrameStaticRecords statics =
_walkWorldData.GetCellStatics(cellId);
foreach (RenderProjectionRecord record in statics.Records)
{
if (record.Source.LocalEntityId != 0)
{
_cellParticleUnionScratch.Add(record.Source.LocalEntityId);
cellDrewObjects = true;
}
}
}
if (cellDrewObjects)
{
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, NoClipSlice, _cellParticleUnionScratch));
}
}
passes.UseIndoorMembershipOnlyRouting();
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132: retail drains the remaining landscape alpha after
// LScape::draw (DrawCells pc:432720), while each DrawBuilding is also
// an earlier alpha barrier before its portal traversal (pc:427954).
// Our dispatcher batches outdoor content, so the stage is split into:
// EARLY sky/terrain/static meshes; an optional pre-look-in static-alpha
// barrier; building look-ins; then LATE outside-stage dynamics,
// remaining particles, and weather; followed by the outer flush.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip.
passes.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
if (partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
probeSliceIndex,
c,
r))
{
_outdoorStaticScratch.Add(e);
}
}
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0,
_outdoorStaticScratch);
RenderFrameEntityDrawRequest? entityDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0,
ctx.PlayerLandblockId ?? 0);
probeSliceIndex++;
passes.DrawLandscapeSlice(
ctx,
new RetailPViewLandscapeSliceContext(
slice,
_outdoorStaticScratch)
{
EntityDraw = entityDraw,
});
}
// Retail DrawBuilding flushes every alpha submission accumulated before
// the building immediately before its portal-only traversal
// (RenderDeviceD3D::DrawBuilding pc:427954-427956). That barrier is
// essential at open-air seams: foliage and static emitters encountered
// before the building must not be flushed after the look-in cell floor
// and repaint it. Our outdoor statics are one retained batch rather than
// retail's BSP-by-building walk, so use one barrier before the first
// look-in; DrawBuildingLookIns adds the corresponding barrier between
// each later building pair. Submit the early static owners' particles
// into the same alpha queue first; their mesh alpha was already
// submitted by the EARLY entity route above.
bool hasBuildingLookIns = _lookInFrames.Count > 0;
if (hasBuildingLookIns)
{
// Ownerless OUTDOOR-cell emitters cannot ride an entity route.
// Retail inserts each one into the single alpha list once, during
// its cell's landscape walk turn, with no portal-view clip; the
// interior-cell ownerless emitters submit in the final world
// scope instead (see DrawDynamicsLast).
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
_staticParticleUnionScratch.Clear();
int outsideSliceTotal = clipAssembly.OutsideViewSlices.Length;
for (int barrierSliceIndex = 0;
barrierSliceIndex < outsideSliceTotal;
barrierSliceIndex++)
{
_lateParticleOwnerScratch.Clear();
if (partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
if (e.IsBuildingShell
&& FindLookInFrameIndex(
e.BuildingShellAnchorCellId ?? 0,
_lookInFrames,
ctx.Cells) >= 0)
{
continue;
}
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(
barrierSliceIndex,
c,
r))
{
_lateParticleOwnerScratch.Add(e.Id);
}
}
}
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
barrierSliceIndex,
0);
}
_staticParticleUnionScratch.UnionWith(
_lateParticleOwnerScratch);
}
// ONE unclipped submission for the union of every slice's cone
// survivors, then retail's pre-building barrier drain. Under
// retail's far→near walk, DrawBuilding's FlushAlphaList(0f)
// @0x0059F2A0 can only ever flush content from cells FARTHER
// than the building it precedes — a nearer emitter (the Holtburg
// candle in front of a door) has not been inserted yet and
// composites at a later flush, after that building's opaques.
// Drain the far prefix only; nearer entries stay queued for the
// DrawCells-boundary flush, which runs after the late dynamics
// (AP-236 retirement).
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
passes.FlushLandscapeAlphaFartherThan(
LookInBarrierDrainDistance(
_lookInFrames[0],
ctx.Cells,
ctx.CameraWorldPosition));
}
// #124: far-building look-ins draw HERE — still inside the landscape
// stage (their punches mark against the terrain/exterior depth just
// drawn), strictly BEFORE the outer depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(
ctx,
passes,
clipAssembly,
partition,
viewcone,
frameEntityPasses,
in frameView);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
// look-ins so a translucent portal mesh blends over a far interior
// instead of being overpainted). The scene-particle owners (statics +
// dynamics cone survivors) accumulate across the slices and submit
// ONCE, unclipped, after the loop.
_staticParticleUnionScratch.Clear();
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
passes.SetTerrainClip(slice.Planes);
passes.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: dynamics, plus statics without look-ins
if (!hasBuildingLookIns && partition is not null)
{
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
bool ownerPass = viewcone.SphereVisibleInOutsideSlice(
probeSliceIndex,
c,
r);
if (ownerPass)
_lateParticleOwnerScratch.Add(e.Id);
}
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
// Particles emit in the stage matching the PARENT CELL:
// an INTERIOR dynamic whose sphere merely straddles an
// exit-portal plane keeps its mesh in both stages (#118)
// but its particles belong to the final pass — draining
// them at the pre-clear boundary lets the interior stage
// repaint over them except on seal-protected aperture
// pixels (the cathedral middle-cell spell-star cut).
if (!InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_lateParticleOwnerScratch.Add(e.Id);
}
}
if (frameEntityPasses is not null)
{
if (hasBuildingLookIns)
{
_lateParticleOwnerScratch.Clear();
}
else
{
RenderFrameRouteOwnerSelector.Replace(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutdoorStatic,
probeSliceIndex,
0);
}
RenderFrameRouteOwnerSelector.Union(
_lateParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0);
}
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0,
_outdoorStaticScratch);
RenderFrameEntityDrawRequest? entityDraw =
frameEntityPasses is null
? null
: new RenderFrameEntityDrawRequest(
frameView,
RenderFrameCandidateRoute.LandscapeOutsideDynamic,
probeSliceIndex,
0,
ctx.PlayerLandblockId ?? 0);
probeSliceIndex++;
_staticParticleUnionScratch.UnionWith(_lateParticleOwnerScratch);
passes.DrawLandscapeSliceLate(
ctx,
new RetailPViewLandscapeLateSliceContext(
slice,
_outdoorStaticScratch)
{
EntityDraw = entityDraw,
});
}
// ONE unclipped submission for every late-stage particle owner —
// OUTDOOR-parented outside-stage dynamics' emitters plus, without
// look-ins, the outdoor statics' emitters (retail: one alpha-list
// insertion per emitter during the landscape walk; per-slice
// re-submission with clip slots was the direction-dependent
// disappearance class). Interior-parented straddlers appear in BOTH
// the LandscapeOutsideDynamic and DynamicLast routes; their particles
// emit only in the final pass, so remove them here.
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.ExceptRoute(
_staticParticleUnionScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast);
}
if (_staticParticleUnionScratch.Count > 0)
{
passes.DrawLandscapeStaticParticles(
ctx,
new RetailPViewLandscapeStaticParticleContext(
_staticParticleUnionScratch));
_staticParticleUnionScratch.Clear();
}
// #131: UNATTACHED emitters (AttachedObjectId == 0 — portal swirls,
// campfires, ground effects anchored at a position) have no owner id
// to ride any of the id-filtered particle passes. OUTDOOR-cell ones
// submit ONCE in the landscape stage, unclipped — retail inserts each
// particle into the single alpha list during its owner cell's walk
// turn (ShouldDrawParticles @0x0050FE60 gates by cell + distance;
// FlushAlphaList @0x0059D2E0 depth-tests at composition). The former
// once-per-outside-slice submission with that slice's clip slot cut
// effects at aperture boundaries and drew NOTHING when no outside
// slice was in view. Interior-cell unattached emitters submit in the
// final world scope (DrawDynamicsLast) — in the landscape stage the
// upcoming depth clear + interior repaint would erase them.
if (!hasBuildingLookIns)
passes.DrawUnattachedSceneParticles(ctx, outdoorCells: true);
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear.
// The queue remains active for the post-clear/final-world scope.
//
// Only an INTERIOR root drains here: its full depth clear follows, and
// a flame drained after that clear would z-pass through every interior
// wall. An OUTDOOR root has no depth clear (retail gates it on
// portalsDrawnCount, pc:432731), and retail's LScape::draw walk has
// already drawn every building interior and every cell object via
// DrawSortCell 0x005A17C0 before that boundary — while our outdoor
// frame draws punches, interior shells, cell objects, and ALL dynamics
// (doors, creatures, NPCs) after this point. Draining here painted the
// flames first and let each of those later opaque meshes overwrite
// them (#132: "the door draws over the candle"); the outdoor drain
// therefore runs after DrawDynamicsLast, where world depth is complete
// and the one far-to-near list composites over everything, exactly as
// retail's boundary flush does relative to its finished walk.
if (!ctx.RootCell.IsOutdoorNode)
passes.FlushLandscapeAlpha();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
// Clear gated on portalsDrawnCount; exact gate semantics is a plan
// open question, staged as "any outside slice drawn"), then re-stamps
// every outside-leading portal's TRUE depth (the seals,
// DrawExitPortalMasks). Replaces the old per-slice scissored AABB
// clear (wrong shape, no seal after it).
if (clipAssembly.OutsideViewSlices.Length > 0 && !ctx.RootCell.IsOutdoorNode)
passes.ClearInteriorDepth();
passes.UseIndoorMembershipOnlyRouting();
}
internal static int LookInBuildingShellRouteIndex(
int frameIndex,
int outsideSliceCount,
int sliceIndex) =>
checked((frameIndex * outsideSliceCount) + sliceIndex);
internal static int FindLookInFrameIndex(
uint buildingShellAnchorCellId,
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
IRetailPViewCellSource cells)
{
if (buildingShellAnchorCellId == 0)
return -1;
LoadedCell? anchorCell = cells.Find(buildingShellAnchorCellId);
if (anchorCell is null)
return -1;
uint buildingKey = anchorCell.BuildingId ?? anchorCell.CellId;
uint landblockId = anchorCell.CellId & 0xFFFF0000u;
for (int frameIndex = 0; frameIndex < lookInFrames.Count; frameIndex++)
{
PortalVisibilityFrame frame = lookInFrames[frameIndex];
if (frame.SourceBuildingKey == buildingKey
&& frame.SourceBuildingLandblockId == landblockId)
{
return frameIndex;
}
}
return -1;
}
private static bool HasExactRoute(
in RenderFrameView view,
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId)
{
foreach (RenderFrameCandidateRange range in view.RouteRanges)
{
if (range.Route == route
&& range.RouteIndex == routeIndex
&& range.CellId == cellId
&& range.Count > 0)
{
return true;
}
}
return false;
}
private void DrawExitPortalMasks(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells)
{
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
passes.DrawExitPortalMask(
ctx,
new RetailPViewCellSliceContext(
cellId,
slice,
NoParticleOwners));
}
}
/// <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,
/// probe-pinned via ACDREAM_PROBE_WALK_ROOT's phase tags). Per-cell
/// slice clips still come from the old assembly where present; a cell
/// the old apparatus missed seals unclipped (the depth fan is the exact
/// dat aperture polygon and z-tests, so over-coverage is benign).</summary>
private void DrawWalkExitPortalMasks(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
Walk.WalkFrameDriver driver)
{
List<uint> floodCells = driver.InteriorFloodCells;
for (int i = floodCells.Count - 1; i >= 0; i--)
{
uint cellId = floodCells[i];
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
passes.DrawExitPortalMask(
ctx,
new RetailPViewCellSliceContext(
cellId,
slice,
NoParticleOwners));
}
}
private void DrawEnvCellShells(
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame)
{
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
// Retail NEVER clips cell geometry: the production path is the
// prebuilt mesh (DrawEnvCell use_built_mesh, pc:427905; the
// planeMask=0xffffffff legacy submit means skip-all-edges), and
// aperture exactness comes from the punch/seal depth writes + the
// z-buffer + this order. The former gl_ClipDistance chop
// (927fd8f/9ce335e, #114) is deleted with this rewrite.
// Per-cell opaque+transparent keeps the far→near transparent
// compositing the per-cell loop already provided.
passes.UseIndoorMembershipOnlyRouting();
// Opaque: ONE batched Render for all shell cells (was one heavy per-frame
// Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at
// Arwic). Opaque needs no draw order (z-buffer), and lighting is
// per-instance (CellId-keyed light SSBO in EnvCellRenderer.RenderModernMDI-
// Internal), so cross-cell batching is visually identical. The filtered
// Render path already groups all cells' instances into one MDI.
_shellBatch.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
_shellBatch.Add(entry.CellId);
if (_shellBatch.Count > 0)
passes.DrawOpaqueCellShells(_shellBatch);
// Transparent: far-to-near order matters for compositing. The ordered
// list retains ShellPass cell boundaries while EnvCellRenderer shares
// one instance/command/light upload across the complete pass.
_orderedTransparentShellCells.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
if (passes.CellHasTransparentShell(entry.CellId))
_orderedTransparentShellCells.Add(entry.CellId);
}
if (_orderedTransparentShellCells.Count > 0)
passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells);
}
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
// (player, NPCs, doors, items), indoor or out, drawn after the static
// world + punches + interior cells. Depth-tested, never hard-clipped
// (retail draws objects per cell AFTER cells and viewcone-culls them —
// PView::DrawCells epilogue Ghidra 0x005a4840; the sphere-vs-view cull is
// T3). Drawing dynamics last is what makes the aperture punch safe.
// T3 (BR-5): each dynamic is viewcone-culled like retail — sphere vs its
// cell's views; outdoor/unresolved vs the outside views (pass-all under
// the outdoor root's full-screen outside view). A dynamic in a NON-flooded
// room culls HERE — retail never reaches an object whose cell is not in
// the draw list; the partition keeps routing it so the CULL (not the
// visibility set) drops it, exactly retail's shape.
private void DrawDynamicsLast(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
bool rootIsOutdoor,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (partition is null)
{
RenderFrameRouteOwnerSelector.Replace(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0);
// FW4 slice 4: owners already emitted pre-clear (the outside
// stage's late union — straddlers included) emit ONCE; see
// _preClearParticleOwnerScratch's field comment.
_dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch);
passes.UseIndoorMembershipOnlyRouting();
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0,
Array.Empty<WorldEntity>(),
visibleCellIds: null);
// Particles emit exactly once, in the stage matching the parent
// cell. Pure-outdoor dynamics are absent from the DynamicLast
// route (they draw only in the outside stage), and interior
// straddlers — present in BOTH routes — emit their particles
// HERE so the interior stage cannot repaint over them; the late
// landscape submission excludes DynamicLast owners for the same
// reason.
if (_dynamicParticleOwnerScratch.Count > 0)
{
passes.DrawDynamicsParticles(
ctx,
_dynamicParticleOwnerScratch);
}
return;
}
if (partition.Dynamics.Count == 0
&& frameEntityPasses is null)
return;
_dynamicsScratch.Clear();
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
// TEMP (#138-B): trace the avatar's survival through this cull.
bool isProbePlayer = AcDream.App.Streaming.EntityVanishProbe.Enabled
&& AcDream.App.Streaming.EntityVanishProbe.PlayerGuid != 0
&& e.ServerGuid == AcDream.App.Streaming.EntityVanishProbe.PlayerGuid;
// #118: under an interior root, outdoor-classified dynamics drew in
// the outside stage (pre-clear, seal-protected) — retail draws them
// via LScape::draw's per-landcell DrawSortCell, never in the
// post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
// pc:432878). Drawing them here instead z-fails them against the
// seal. Indoor dynamics (incl. exit-portal straddlers, which drew
// in BOTH stages) stay — this pass is retail's loop C.
if (!rootIsOutdoor && !indoor)
{
if (isProbePlayer)
AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange(
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)");
continue;
}
// Drawn-once (retail DrawMeshInternal @0x0059F360 marks every
// non-player part for the frame): a dynamic whose cell drew as a
// building LOOK-IN already rendered with that cell inside the
// landscape stage (#131). Redrawing it here would land AFTER the
// boundary alpha drain and overpaint nearer flames — the Holtburg
// door repainting the candle in front of it.
if (indoor && _lookInCellIds.Contains(e.ParentCellId!.Value))
continue;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
if (isProbePlayer)
AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange(
$"cell=0x{(e.ParentCellId ?? 0):X8} indoor={indoor} rootOutdoor={rootIsOutdoor} viewcone={visible} -> {(visible ? "DRAWN" : "CULLED(viewcone)")}");
if (visible)
_dynamicsScratch.Add(e);
}
if (_dynamicsScratch.Count == 0
&& frameEntityPasses is null)
return;
if (_dynamicsScratch.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.DynamicLast,
0,
0,
_dynamicsScratch);
}
passes.UseIndoorMembershipOnlyRouting();
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0,
_dynamicsScratch,
visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes —
// retail draws emitters with the owner object. Before this callback,
// dynamics' emitters fell through EVERY particle filter under the pview
// path (the landscape slice carries outdoor statics + #118 outside-
// stage dynamics; the cell callback carries cell statics; T4 deleted
// the old clipRoot==null global pass from normal frames) — all world
// portals went invisible. Outside-stage dynamics are excluded here:
// their emitters already drew in the landscape slice (alpha-blended
// particles must not double-draw, unlike the depth-idempotent meshes).
if (frameEntityPasses is not null)
{
// Parent-cell stage split: every DynamicLast owner emits its
// particles here. Pure-outdoor dynamics are absent from this
// route (outside stage only), and interior straddlers — whose
// meshes drew in both stages — must emit HERE so the interior
// stage cannot repaint over them (matches the production
// partition-null path above).
RenderFrameRouteOwnerSelector.Replace(
_dynamicParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.DynamicLast,
0,
0);
}
else
{
_dynamicParticleOwnerScratch.Clear();
// Interior-parented dynamics — INCLUDING exit-portal straddlers
// whose mesh also drew in the outside stage — emit particles in
// this final pass; outdoor-parented ones emitted in the late
// landscape submission (parent-cell stage split).
foreach (var e in _dynamicsScratch)
if (InteriorEntityPartition.IsIndoorCellId(e.ParentCellId))
_dynamicParticleOwnerScratch.Add(e.Id);
}
// FW4 slice 4: pre-clear-emitted owners (straddlers) emit once —
// see _preClearParticleOwnerScratch's field comment.
_dynamicParticleOwnerScratch.ExceptWith(_preClearParticleOwnerScratch);
if (_dynamicParticleOwnerScratch.Count > 0)
passes.DrawDynamicsParticles(ctx, _dynamicParticleOwnerScratch);
}
private void DrawCellObjectLists(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result? partition,
ViewconeCuller viewcone,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView)
{
if (partition is null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.CellStatic,
0,
0);
passes.UseIndoorMembershipOnlyRouting();
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.CellStatic,
0,
0,
Array.Empty<WorldEntity>(),
visibleCellIds: null);
passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(
0u,
NoClipSlice,
_cellParticleOwnerScratch));
return;
}
// T1: per-cell STATIC object lists only (dat-baked 0x40 statics) —
// dynamics moved to DrawDynamicsLast. Far→near with the cells, after
// the shells (retail DrawCells epilogue: PortalList = cell's views →
// DrawObjCell, Ghidra 0x005a4840). T3 (BR-5): each static's sphere is
// tested against ITS CELL's views (retail viewconeCheck) — the
// statics-through-walls fix: a static whose sphere is outside every
// view of its cell no longer paints through the wall (the cottage
// phantom staircase's draw path).
// Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching):
// the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic
// (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs +
// full state setup). Collapse them into ONE cross-cell batched draw — the
// shipped cells-shell batching pattern applied to cell OBJECTS. Two loops
// preserve the statics-before-particles depth order: loop 1 culls +
// accumulates every cell's survivors and draws them once; loop 2 runs the
// per-cell particle passes AFTER the statics own the depth buffer (particles
// depth-test but write no depth). The dispatcher sorts opaque front-to-back
// and transparent back-to-front by group distance (WbDrawDispatcher.cs:
// 1469-1470), so cross-cell batching composites correctly — equal-or-better
// than the old per-cell-bucketed order. visibleCellIds = the union of cells,
// so the dispatcher admits exactly the same survivor set.
// Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells.
_allCellStatics.Clear();
_cellObjCells.Clear();
for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = pvFrame.OrderedVisibleCells[i];
if (!drawableCells.Contains(cellId))
continue;
if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0)
continue;
int survivorsBefore = _allCellStatics.Count;
foreach (var e in bucket)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInCell(cellId, c, r))
_allCellStatics.Add(e);
}
int survivors = _allCellStatics.Count - survivorsBefore;
if (survivors > 0)
_cellObjCells.Add(cellId);
}
// ONE batched static-object draw for every visible cell (was N per-cell
// WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics
// draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against
// ITS cell's views above (the statics-through-walls fix is preserved by the
// cull; only the draw is batched).
if (frameEntityPasses is not null
|| _allCellStatics.Count > 0)
{
_candidateObserver?.ObservePViewBucket(
CurrentRenderPViewRoute.CellStatic,
0,
0,
_allCellStatics);
passes.UseIndoorMembershipOnlyRouting();
DrawEntityRouteOrLegacy(
ctx,
passes,
frameEntityPasses,
in frameView,
RenderFrameCandidateRoute.CellStatic,
0,
0,
_allCellStatics,
_cellObjCells);
}
// Cell-particle pass — consolidated across ALL visible cells into ONE
// draw. Was per-cell, and each call re-walked the ENTIRE live particle set
// (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
// sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION
// (= _allCellStatics, already accumulated above for the batched draw) draws
// EXACTLY the same emitters: the callback gates on owner id (the cone-
// surviving set), the renderer sorts globally back-to-front, and the per-
// cell slice was never used for clipping (the scissor gate was deleted in
// T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
// the batched static draw so emitters depth-test against the statics now in
// the buffer (the statics-before-particles order). cellId/slice are unused
// by the particle pass — pass NoClipSlice + the union owner list. This also
// drops the per-cell BuildDrawList allocations (N → 1).
if (frameEntityPasses is not null
|| _allCellStatics.Count > 0)
{
if (frameEntityPasses is not null)
{
RenderFrameRouteOwnerSelector.Replace(
_cellParticleOwnerScratch,
in frameView,
RenderFrameCandidateRoute.CellStatic,
0,
0);
}
else
{
ReplaceOwnerIds(
_cellParticleOwnerScratch,
_allCellStatics);
}
passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(
0u,
NoClipSlice,
_cellParticleOwnerScratch));
}
}
private static void DrawEntityRouteOrLegacy(
RetailPViewFrameInput frame,
IRetailPViewPassExecutor passes,
IRenderFrameEntityPassExecutor? frameEntityPasses,
in RenderFrameView frameView,
RenderFrameCandidateRoute route,
int routeIndex,
uint cellId,
IReadOnlyList<WorldEntity> legacyEntities,
HashSet<uint>? visibleCellIds)
{
if (frameEntityPasses is not null)
{
frameEntityPasses.DrawEntityRoute(
frame.Camera,
in frameView,
route,
routeIndex,
cellId,
frame.PlayerLandblockId ?? 0);
return;
}
passes.DrawEntityBucket(
frame,
legacyEntities,
visibleCellIds);
}
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _buildingShellScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
private readonly List<WorldEntity> _outsideStageDynamics = new();
// Dense-town FPS iteration-1 (cellobject batching): all visible cells'
// viewcone-surviving statics accumulated for ONE batched DrawEntityBucket,
// plus the union of their cell ids for the dispatcher's visibleCellIds gate.
// Cleared at the top of DrawCellObjectLists.
private readonly List<WorldEntity> _allCellStatics = new();
private readonly HashSet<uint> _cellObjCells = new();
private bool LegacyPartitionDiagnosticsEnabled =>
_partitionObserver is not null
|| AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled
|| AcDream.App.Streaming.EntityVanishProbe.Enabled;
internal static RenderFrameDiagnosticCounts LegacyDiagnosticCounts(
InteriorEntityPartition.Result partition)
{
int cellStaticCount = 0;
foreach (List<WorldEntity> bucket in partition.ByCell.Values)
cellStaticCount = checked(cellStaticCount + bucket.Count);
return new RenderFrameDiagnosticCounts(
partition.OutdoorStatic.Count,
cellStaticCount,
partition.Dynamics.Count,
TransformCount: 0,
OpaqueClassificationCount: 0,
AlphaClassificationCount: 0,
LightSetCount: 0,
SelectionPartCount: 0,
RouteCandidateCount:
checked(
partition.OutdoorStatic.Count
+ cellStaticCount
+ partition.Dynamics.Count),
EntityCandidateCount: 0,
MeshPartCount: 0);
}
internal static RenderProjectionCounts LegacySourceCounts(
InteriorEntityPartition.Result partition)
{
int cellStaticCount = 0;
foreach (List<WorldEntity> bucket in partition.ByCell.Values)
cellStaticCount = checked(cellStaticCount + bucket.Count);
int total = checked(
partition.OutdoorStatic.Count
+ cellStaticCount
+ partition.Dynamics.Count);
return new RenderProjectionCounts(
total,
partition.OutdoorStatic.Count,
cellStaticCount,
partition.Dynamics.Count,
ActiveAnimatedStatic: 0,
EquippedChild: 0);
}
private static void ReplaceOwnerIds(
HashSet<uint> destination,
IReadOnlyList<WorldEntity> entities)
{
destination.Clear();
for (int index = 0; index < entities.Count; index++)
{
uint localEntityId = entities[index].Id;
if (localEntityId != 0)
destination.Add(localEntityId);
}
}
/// <summary>
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
/// exit-portal seals — like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// the top of PView::DrawCells pc:432719)?
///
/// True for outdoor-classified dynamics (their fragments lie beyond the
/// door plane and would z-fail the seal in the last pass), and for INDOOR
/// dynamics whose sphere straddles an exit-portal plane of their flood-
/// visible cell — retail draws an object once per overlapped shadow cell
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
/// both stages and neither half clips at the plane. Pure — also driven
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
/// </summary>
public static bool DynamicDrawsInOutsideStage(
uint? parentCellId,
Vector3 sphereCenter,
float sphereRadius,
HashSet<uint> drawableCells,
IRetailPViewCellSource cells)
{
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true;
uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId))
return false; // not in the flood — the last-pass cone cull owns it
var cell = cells.Find(cellId);
if (cell is null)
return false;
return SphereStraddlesExitPlane(cell, sphereCenter, sphereRadius);
}
/// <summary>The exit-plane straddle test shared by the #118 dynamic
/// stage split and the FW4 slice-5 cell-static particle split — retail
/// draws a straddling object once per overlapped outdoor shadow cell
/// inside LScape::draw (DrawBlock pc:430056-430064).</summary>
internal static bool SphereStraddlesExitPlane(
LoadedCell cell, Vector3 sphereCenter, float sphereRadius)
{
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
for (int i = 0; i < n; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue;
var plane = cell.ClipPlanes[i];
if (plane.Normal.LengthSquared() < 1e-8f)
continue;
float dist = Vector3.Dot(plane.Normal, localC) + plane.D;
if (MathF.Abs(dist) < sphereRadius)
return true; // sphere straddles the exit-portal plane
}
return false;
}
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.
private static void EntitySphere(WorldEntity e, out Vector3 center, out float radius)
{
if (e.AabbDirty)
e.RefreshAabb();
center = (e.AabbMin + e.AabbMax) * 0.5f;
radius = (e.AabbMax - e.AabbMin).Length() * 0.5f;
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return NoClipSlices;
}
}
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 interface IRetailPViewPassExecutor
{
void AbortFrame();
void BeginFrame();
ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly);
void AppendLookInClipFrames(
IReadOnlyList<PortalVisibilityFrame> lookInFrames,
ClipFrameAssembly assembly);
void PrepareClipFrame(int terrainUploadCount);
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
void ClearClipRouting();
void UseIndoorMembershipOnlyRouting();
void UseCellPortalViewRouting(uint cellId, ClipViewSlice slice);
void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds);
void DrawOpaqueCellShells(HashSet<uint> cellIds);
bool CellHasTransparentShell(uint cellId);
void DrawTransparentCellShells(HashSet<uint> cellIds);
void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds);
void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? visibleCellIds);
void EmitClipRouteProbe(
ClipFrameAssembly clipAssembly,
ClipViewSlice slice,
int sliceIndex);
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
void DrawLandscapeStaticParticles(
RetailPViewFrameInput frame,
RetailPViewLandscapeStaticParticleContext context);
void DrawLandscapeBuildingShellSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeBuildingShellSliceContext context);
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
void ClearInteriorDepth();
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context,
int portalIndex);
/// <summary>
/// One unclipped submission for every renderable UNATTACHED emitter whose
/// owner cell matches the scope: outdoor landcells in the landscape stage,
/// interior EnvCells in the final world scope. Retail inserts each such
/// particle into the single alpha list during its owner cell's walk turn
/// and never clips it to a portal view.
/// </summary>
void DrawUnattachedSceneParticles(
RetailPViewFrameInput frame,
bool outdoorCells);
void FlushLandscapeAlpha();
/// <summary>
/// Pre/inter-building barrier drain: composites only the queued alpha at
/// or beyond <paramref name="minViewerDistance"/> and retains nearer
/// entries for the later boundary flush — retail's far→near walk outcome
/// (DrawBuilding's FlushAlphaList(0f) @0x0059F2A0 can only ever flush
/// content from cells farther than that building; AP-236). The default
/// falls back to a full flush so non-production executors keep today's
/// behavior until they opt in.
/// </summary>
void FlushLandscapeAlphaFartherThan(float minViewerDistance) =>
FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlySet<uint> ownerIds);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
}
/// <summary>
/// Capacity policy for renderer-owned, one-frame scratch. Normal warmed frames
/// retain their storage; a pathological visibility spike is released at the
/// next frame boundary instead of becoming permanent process memory.
/// </summary>
internal sealed class RetailPViewScratchRetention
{
internal const int MaxRetainedLookInFrames = 32;
internal const int MaxRetainedCellItems = 512;
internal const int CapacityTrimIdleFrames = 120;
private int _lookInFramesUnderusedFrames;
private int _lookInPrepareUnderusedFrames;
private int _drawableCellsUnderusedFrames;
private int _shellBatchUnderusedFrames;
private int _orderedTransparentUnderusedFrames;
internal void ClearFrameBuffers(
List<PortalVisibilityFrame> lookInFrames,
HashSet<uint> lookInPrepare,
HashSet<uint> drawableCells,
HashSet<uint> shellBatch,
List<uint> orderedTransparentShellCells)
{
ClearCold(
lookInFrames,
MaxRetainedLookInFrames,
ref _lookInFramesUnderusedFrames);
ClearCold(
lookInPrepare,
MaxRetainedCellItems,
ref _lookInPrepareUnderusedFrames);
ClearCold(
drawableCells,
MaxRetainedCellItems,
ref _drawableCellsUnderusedFrames);
ClearCold(shellBatch, MaxRetainedCellItems, ref _shellBatchUnderusedFrames);
ClearCold(
orderedTransparentShellCells,
MaxRetainedCellItems,
ref _orderedTransparentUnderusedFrames);
}
private static void ClearCold<T>(
List<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.Capacity;
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.Capacity = 0;
}
private static void ClearCold<T>(
HashSet<T> values,
int maximumRetainedCapacity,
ref int underusedFrames)
{
int usedCount = values.Count;
int capacity = values.EnsureCapacity(0);
values.Clear();
if (!ShouldTrim(
capacity,
usedCount,
maximumRetainedCapacity,
ref underusedFrames))
return;
if (capacity > maximumRetainedCapacity)
values.TrimExcess();
}
private static bool ShouldTrim(
int capacity,
int usedCount,
int maximumRetainedCapacity,
ref int underusedFrames)
{
if (capacity <= maximumRetainedCapacity || (long)usedCount * 2L > capacity)
{
underusedFrames = 0;
return false;
}
underusedFrames++;
if (underusedFrames < CapacityTrimIdleFrames)
return false;
underusedFrames = 0;
return true;
}
}
/// <summary>
/// Frame-scoped grouping for retail's per-building portal floods. Active keys
/// are rebuilt in nearby-cell encounter order every frame; the value lists are
/// retained through a bounded pool so travelling through the world cannot turn
/// every historical building id into permanent memory or per-frame scan work.
/// </summary>
internal sealed class BuildingGroupScratch
{
internal const int MaxRetainedGroups = 256;
internal const int MaxRetainedCellsPerGroup = 256;
private readonly Dictionary<uint, List<LoadedCell>> _active = new();
private readonly Stack<List<LoadedCell>> _listPool = new();
internal Dictionary<uint, List<LoadedCell>>.ValueCollection Values => _active.Values;
internal IReadOnlyDictionary<uint, List<LoadedCell>> Groups => _active;
internal int ActiveGroupCount => _active.Count;
internal int RetainedListCount => _listPool.Count;
internal int MapCapacity => _active.EnsureCapacity(0);
internal void Rebuild(IReadOnlyList<LoadedCell> nearbyCells)
{
ArgumentNullException.ThrowIfNull(nearbyCells);
Reset();
for (int i = 0; i < nearbyCells.Count; i++)
{
LoadedCell cell = nearbyCells[i];
// R-A2 seam behavior: an unstamped cell still gets a singleton
// entrance flood keyed by CellId.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_active.TryGetValue(groupKey, out List<LoadedCell>? group))
{
group = _listPool.Count != 0
? _listPool.Pop()
: new List<LoadedCell>();
_active.Add(groupKey, group);
}
group.Add(cell);
}
}
internal void Reset()
{
foreach (List<LoadedCell> group in _active.Values)
{
group.Clear();
if (group.Capacity <= MaxRetainedCellsPerGroup
&& _listPool.Count < MaxRetainedGroups)
{
_listPool.Push(group);
}
}
_active.Clear();
if (_active.EnsureCapacity(0) > MaxRetainedGroups)
_active.TrimExcess();
}
}
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 PortalVisibilityFrame PortalFrame { get; private set; } = null!;
public ClipFrameAssembly ClipAssembly { get; private set; } = null!;
public HashSet<uint> DrawableCells { get; private set; } = null!;
/// <summary>
/// Every cell this completed view actually reached: the main flood
/// (<see cref="DrawableCells"/>) plus the building look-in cells. This is
/// retail's per-cell <c>in_view</c> answer for effect consumers —
/// <c>CPhysicsObj::ShouldDrawParticles</c> @0x0050FE60 gates on
/// <c>cell-&gt;IsInView()</c>, and a cell entered through a building portal
/// (<c>PView::ConstructView</c> @0x005A57B0, installed by
/// <c>RenderDeviceD3D::DrawBuilding</c> @0x0059F2A0) is drawn by the same
/// <c>PView::DrawCells</c> traversal as a flooded cell, so retail marks it
/// in view identically. acdream's look-in adaptation keeps those cells out
/// of <see cref="DrawableCells"/> (seals / outside-stage predicate stay
/// main-flood scoped, #124); particle and light visibility must consume
/// THIS set or look-in rooms render with frozen emitters and dark lights.
/// </summary>
public HashSet<uint> InViewCells { 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(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
HashSet<uint> inViewCells,
RenderFrameDiagnosticCounts diagnosticCounts,
RenderProjectionCounts sourceCounts,
InteriorEntityPartition.Result? diagnosticPartition)
{
PortalFrame = portalFrame;
ClipAssembly = clipAssembly;
DrawableCells = drawableCells;
InViewCells = inViewCells;
DiagnosticCounts = diagnosticCounts;
SourceCounts = sourceCounts;
DiagnosticPartition = diagnosticPartition;
return this;
}
internal RetailPViewFrameResult Reset(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result diagnosticPartition) =>
Reset(
portalFrame,
clipAssembly,
drawableCells,
drawableCells,
RetailPViewRenderer.LegacyDiagnosticCounts(
diagnosticPartition),
RetailPViewRenderer.LegacySourceCounts(
diagnosticPartition),
diagnosticPartition);
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <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>Retail DrawBuilding's ordinary exterior-shell pass, issued after
/// the same building's portal-only look-in traversal.</summary>
public readonly record struct RetailPViewLandscapeBuildingShellSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> BuildingShells)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
/// <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)
{
internal RenderFrameEntityDrawRequest? EntityDraw { get; init; }
}
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlySet<uint> ParticleOwnerIds);