Retail draws every cell's objects inside LScape::draw's far-to-near walk (DrawSortCell @0x005A17C0) and drains the one delayed-alpha list at the DrawCells boundary AFTER the finished walk (@0x005A4872). Our outdoor frame drained at the landscape-stage end and then drew punches, interior shells, cell objects, and ALL dynamics — every one of those opaque passes overwrote the already-composited flames (the reopened #132 candle class: "the door draws over the candle", creatures at openings). Depth and barrier A/Bs were no-ops because the eraser is opaque color painted after the drain. Two retail-cited ordering corrections, outdoor-node roots only: 1. The stage-boundary drain is skipped and FlushLandscapeAlpha() runs after DrawDynamicsLast, where the frame's opaque world depth is complete — the one far-to-near list composites over everything, exactly like retail's boundary flush relative to its finished walk. 2. Before DrawExitPortalMasks, FlushLandscapeAlphaFartherThan( ExitPortalMaskBarrierDistance(...)) drains everything at or beyond the nearest cell whose exit-portal mask is about to write far-Z — retail DrawBuilding @0x0059F2A0 runs FlushAlphaList(0f) BEFORE its portal-only pass, so in the far-to-near walk nothing already drained can meet a punched aperture's falsified depth. Without this, the first correction let exterior waterfalls z-pass across punched apertures whose true landscape depth the punch erased (found live at the cathedral gate). Nearer content stays queued and legitimately composites in front of punched structures. Interior roots keep the pre-clear stage-boundary drain unchanged. User-gated live: Holtburg sign candle whole in front of the sign and tower door at the aligned pose; cathedral waterfalls contained at every camera zoom, inside and outside. Register row AP-236 retired (the walk-order outcome reconstruction is complete; AP-34 remains the umbrella for the CYpt-sort reconstruction itself). Filed #456 for the separate occluded-distant-building/creature admission residual this session diagnosed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2269 lines
100 KiB
C#
2269 lines
100 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();
|
||
|
||
// 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()
|
||
{
|
||
}
|
||
|
||
internal RetailPViewRenderer(
|
||
InteriorEntityPartition.IObserver? partitionObserver,
|
||
RenderScenePViewFrameProductController? sceneFrameProduct = 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();
|
||
|
||
var pvFrame = PortalVisibilityBuilder.Build(
|
||
ctx.RootCell,
|
||
ctx.ViewerEyePos,
|
||
ctx.Cells.Find,
|
||
ctx.ViewProjection,
|
||
buildingMembership: null,
|
||
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
|
||
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);
|
||
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
|
||
passes.PrepareClipFrame(terrainUploadCount);
|
||
|
||
// 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();
|
||
|
||
// #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)
|
||
{
|
||
_lookInPrepareScratch.Clear();
|
||
_lookInPrepareScratch.UnionWith(drawableCells);
|
||
foreach (var f in _lookInFrames)
|
||
{
|
||
foreach (uint c in f.OrderedVisibleCells)
|
||
{
|
||
_lookInPrepareScratch.Add(c);
|
||
_lookInCellIds.Add(c);
|
||
}
|
||
}
|
||
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
|
||
{
|
||
if (_sceneFrameProduct is not null)
|
||
{
|
||
frameView = _sceneFrameProduct.BuildAndBorrow(
|
||
pvFrame,
|
||
clipAssembly,
|
||
viewcone,
|
||
_lookInFrames,
|
||
drawableCells,
|
||
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);
|
||
|
||
// #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);
|
||
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells))
|
||
_outsideStageDynamics.Add(e);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
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);
|
||
}
|
||
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;
|
||
|
||
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);
|