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>
3184 lines
145 KiB
C#
3184 lines
145 KiB
C#
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->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);
|