acdream/src/AcDream.App/Rendering/RetailPViewRenderer.cs
Erik 91afea24b4 perf(pipeline): MP-Alloc Task 4 - reuse per-frame animatedIds + drawableCells sets
Completes the safe-batch (implementer finished this but died before
committing; coordinator independently verified bit-identity + safety
post-interruption). Two per-frame HashSet<uint> allocations replaced by
reused cleared-in-place fields:
- GameWindow._animatedIdsScratch: WalkEntitiesInto treats null and empty
  identically (line 682 `is null || Count == 0`), so an always-non-null
  empty set == the old null-when-empty local. Verified.
- RetailPViewRenderer._drawableCellsScratch: flows into
  RetailPViewFrameResult.DrawableCells (live ref) but every consumer reads
  it same-frame; sigDrawableCells is a per-frame OnRender local that
  EmitRenderSignatureIfChanged only FORMATS to a string (no reference
  retention, no _lastSig set field). Built frame N -> read frame N ->
  cleared frame N+1. No cross-frame corruption.

Build green, full suite 4116 passed / 4 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 00:09:03 +02:00

1130 lines
57 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.World;
using Silk.NET.OpenGL;
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 GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private static readonly ClipViewSlice NoClipSlice =
new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty<Vector4>());
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();
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = 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 HashSet<uint> _lookInPrepareScratch = new();
// #131/#132: the late landscape phase's scene-particle owner survivors
// (statics + outside-stage dynamics passing the slice cone).
private readonly List<WorldEntity> _lateParticleOwnerScratch = 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.
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();
// 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 RetailPViewRenderer(
GL gl,
ClipFrame clipFrame,
EnvCellRenderer envCells,
WbDrawDispatcher entities)
{
_gl = gl;
_clipFrame = clipFrame;
_envCells = envCells;
_entities = entities;
}
public RetailPViewFrameResult DrawInside(RetailPViewDrawContext ctx)
{
ArgumentNullException.ThrowIfNull(ctx);
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ);
// 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).
_lookInFrames.Clear();
if (!ctx.RootCell.IsOutdoorNode
&& ctx.NearbyBuildingCells is not null
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
UploadClipFrame(ctx.SetTerrainClipUbo);
// 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;
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;
if (_lookInFrames.Count > 0)
{
_lookInPrepareScratch.Clear();
_lookInPrepareScratch.UnionWith(drawableCells);
foreach (var f in _lookInFrames)
foreach (uint c in f.OrderedVisibleCells)
_lookInPrepareScratch.Add(c);
prepareCells = _lookInPrepareScratch;
}
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
var result = new RetailPViewFrameResult
{
PortalFrame = pvFrame,
ClipAssembly = clipAssembly,
DrawableCells = drawableCells,
Partition = partition,
};
ctx.EmitDiagnostics?.Invoke(result);
// 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);
// #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 (!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.CellLookup))
_outsideStageDynamics.Add(e);
}
}
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
// 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 is reused across frames; inner lists are cleared each frame so a building that left
// the near set simply contributes an empty (skipped) group.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
// R-A2 seam fix: a cell without a BuildingId (unstamped, or outdoor-adjacent with an exit
// portal) must STILL flood — the pre-R-A2 node flood reached it via a reverse portal, so
// dropping it (the original `continue`) left holes at building/terrain seams. Key it by its
// own CellId → a singleton per-entrance flood: a cell with an exit portal seeds from it, a
// cell with none contributes nothing (same as before). BuildingId/CellId key collisions are
// harmless — BuildFromExterior seeds each exit-portal cell in a group independently.
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance);
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))
{
foreach (var p in srcView.Polygons)
existing.Add(p);
continue;
}
target.CellViews[cellId] = srcView;
target.OrderedVisibleCells.Add(cellId);
}
}
// #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(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
{
foreach (var group in _buildingGroups.Values)
group.Clear();
foreach (var cell in ctx.NearbyBuildingCells!)
{
uint groupKey = cell.BuildingId ?? cell.CellId;
if (!_buildingGroups.TryGetValue(groupKey, out var group))
{
group = new List<LoadedCell>();
_buildingGroups[groupKey] = group;
}
group.Add(cell);
}
foreach (var group in _buildingGroups.Values)
{
if (group.Count == 0)
continue;
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons);
if (frame.OrderedVisibleCells.Count > 0)
_lookInFrames.Add(frame);
}
}
// #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). Anything rasterized outside an aperture is
// repainted by the root's own shells after the depth clear, so over-draw
// here is color-safe; statics draw whole (the main viewcone has no entry
// for look-in cells; over-include is the safe direction).
private void DrawBuildingLookIns(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
if (_lookInFrames.Count == 0)
return;
foreach (var frame in _lookInFrames)
{
// Pass 1: far-Z punch every aperture of this building.
if (ctx.DrawLookInPortalPunch is not null)
{
foreach (uint cellId in frame.OrderedVisibleCells)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
{
var single = new CellView();
single.Add(poly);
var cps = ClipPlaneSet.From(single);
if (cps.IsNothingVisible)
continue;
var planes = new Vector4[cps.Count];
for (int p = 0; p < cps.Count; p++)
planes[p] = cps.Planes[p];
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(0, new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY), planes),
Array.Empty<WorldEntity>()));
}
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and
// lighting is per-instance CellId-keyed) — was one heavy per-frame
// Render per cell. Per-cell entity/particle work stays in the loop.
_shellBatch.Clear();
foreach (uint cid in frame.OrderedVisibleCells)
_shellBatch.Add(cid);
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
// Opaque shell batched above. Transparent stays per-cell (far→near)
// for correct compositing; skipped for opaque-only cells.
if (_envCells.CellHasTransparent(cellId))
_envCells.Render(WbRenderPass.Transparent, _oneCell);
_cellStaticScratch.Clear();
if (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. Drawn
// WHOLE like the statics (AP-33's documented over-include).
// 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).
foreach (var e in partition.Dynamics)
if (e.ParentCellId == cellId)
_cellStaticScratch.Add(e);
if (_cellStaticScratch.Count > 0)
{
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
// The cell-particles pass for look-in cells — retail's
// nested DrawCells draws objects WITH their emitters.
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch));
}
}
}
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
if (clipAssembly.OutsideViewSlices.Length == 0)
return;
// #131/#132 (the FlushAlphaList deferral): retail collects ALL alpha
// draws of the landscape stage and flushes them ONCE after LScape::draw
// (D3DPolyRender::FlushAlphaList, DrawCells pc:432722) — so translucent
// landscape content (portal swirl meshes, flame particles) composites
// AFTER the building look-ins. Our dispatcher draws translucency inside
// each Draw call, so the stage is split in TWO phases instead: EARLY =
// sky + terrain + outdoor STATIC meshes (the look-in punches need their
// depth to mark against, the #117 lesson); then the look-ins; then
// LATE = outside-stage dynamics' meshes + ALL scene particles +
// weather. Content drawn early and overlapped by a look-in aperture
// was otherwise overpainted by the far interior (translucents write no
// depth to protect themselves) — the portal-swirl/candle-flame class.
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
// 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.
_entities.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
// #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 depth clear + seals below, matching
// retail's LScape::draw placement (DrawCells pc:432719 vs 432732/432785).
DrawBuildingLookIns(ctx, clipAssembly, partition);
// 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 — flames ride here for the same reason).
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadClipFrame(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
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);
// #131 owner watchlist (throwaway): ACDREAM_DUMP_ENTITY ids
// double as an ENTITY-id watchlist here — one line per watched
// outdoor-static owner per CHANGE of its cone verdict.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& AcDream.Core.Rendering.RenderingDiagnostics.DumpEntitySourceIds.Contains(e.Id)
&& (!_outStageOwnerVerdicts.TryGetValue(e.Id, out bool prev) || prev != ownerPass))
{
_outStageOwnerVerdicts[e.Id] = ownerPass;
Console.WriteLine(System.FormattableString.Invariant(
$"[outstage-own] id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} pos=({e.Position.X:F1},{e.Position.Y:F1},{e.Position.Z:F1}) c=({c.X:F1},{c.Y:F1},{c.Z:F1}) r={r:F1} slice={probeSliceIndex} {(ownerPass ? "PASS" : "CULL")}"));
}
}
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
{
_outdoorStaticScratch.Add(e);
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
// #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. The outdoor root
// has the dedicated T3 pass for them; an INTERIOR root had NO pass
// at all. Draw them ONCE per frame (not per slice — alpha particles
// must not double-draw, the #121 lesson), at the END of the landscape
// stage: after the clear they would z-fail against the doorway seal.
if (!ctx.RootCell.IsOutdoorNode)
ctx.DrawUnattachedSceneParticles?.Invoke();
// 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.ClearDepthForInterior?.Invoke();
UseIndoorMembershipOnlyRouting();
}
// #131 [outstage] probe state (2026-06-12, throwaway): print-on-change —
// which outdoor dynamics were routed to the outside stage and which
// survived the slice viewcone. Strip with the probe when #131 closes.
private string? _lastOutStageSig;
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = new();
private void EmitOutStageProbe(int sliceIndex, ViewconeCuller viewcone)
{
var sb = new System.Text.StringBuilder(192);
sb.Append("slice=").Append(sliceIndex)
.Append(" outStage=").Append(_outsideStageDynamics.Count).Append(" [");
for (int i = 0; i < _outsideStageDynamics.Count; i++)
{
var e = _outsideStageDynamics[i];
EntitySphere(e, out var c, out float r);
bool pass = viewcone.SphereVisibleInOutsideSlice(sliceIndex, c, r);
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant(
$"0x{(e.ServerGuid != 0 ? e.ServerGuid : e.Id):X8}(s{e.SourceGfxObjOrSetupId:X8}):{(pass ? "PASS" : "CULL")}:r={r:F1}"));
}
sb.Append(']');
string sig = sb.ToString();
if (sig == _lastOutStageSig) return;
_lastOutStageSig = sig;
Console.WriteLine("[outstage] " + sig);
}
// §4 flap [clip-route] probe state (2026-06-10, throwaway): print-on-change signature +
// monotonic sequence so held-flap vs healthy frames diff cleanly in one capture.
private string? _lastClipRouteSig;
private long _clipRouteSeq;
private readonly List<uint> _clipRouteCellKeys = new();
// §4 flap apparatus (2026-06-10): the decisive probe between the surviving suspects
// (handoff 2026-06-09 §1). Emits the EXACT clip inputs the landscape pass draws under:
// the outside slice's slot + NDC AABB + planes (CPU side), the region-SSBO bytes decoded
// at that slot (what mesh_modern.vert reads for routed instances), the terrain-UBO head
// (what terrain/sky gate against), and the CellIdToSlot routing table. Fires AFTER
// SetTerrainClip + UploadClipFrame + SetClipRouting, BEFORE DrawLandscapeSlice — so the
// printed bytes are exactly what this slice's draws consume.
private void EmitClipRouteProbe(ClipFrameAssembly clipAssembly, ClipViewSlice slice, int sliceIndex)
{
var sb = new System.Text.StringBuilder(256);
sb.Append(System.FormattableString.Invariant(
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
sb.Append(System.FormattableString.Invariant(
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
sb.Append(System.FormattableString.Invariant($" planes={slice.Planes.Length}["));
for (int i = 0; i < slice.Planes.Length; i++)
{
var p = slice.Planes[i];
if (i > 0) sb.Append(' ');
sb.Append(System.FormattableString.Invariant($"({p.X:F3},{p.Y:F3},{p.Z:F3},{p.W:F3})"));
}
// CellIdToSlot sorted by cell id so dictionary enumeration order can't fake a change.
sb.Append("] cells={");
_clipRouteCellKeys.Clear();
foreach (uint key in clipAssembly.CellIdToSlot.Keys)
_clipRouteCellKeys.Add(key);
_clipRouteCellKeys.Sort();
for (int i = 0; i < _clipRouteCellKeys.Count; i++)
{
if (i > 0) sb.Append(',');
sb.Append(System.FormattableString.Invariant(
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
}
sb.Append('}');
// Region-SSBO content decoded at the routed slot, from the packed bytes UploadClipFrame
// just uploaded — slot stride 144: count uint at +0, planes[8] at +16.
var rb = _clipFrame.RegionBytesForTest;
int off = slice.Slot * ClipFrame.CellClipStrideBytes;
if (off >= 0 && off + ClipFrame.CellClipStrideBytes <= rb.Length)
{
uint ssboCount = System.BitConverter.ToUInt32(rb.Slice(off, 4));
sb.Append(System.FormattableString.Invariant($" ssbo[{slice.Slot}]: n={ssboCount}"));
int planeN = (int)System.Math.Min(ssboCount, (uint)ClipFrame.MaxPlanes);
for (int i = 0; i < planeN; i++)
{
int po = off + ClipFrame.CellClipPlanesOffset + i * 16;
float px = System.BitConverter.ToSingle(rb.Slice(po, 4));
float py = System.BitConverter.ToSingle(rb.Slice(po + 4, 4));
float pz = System.BitConverter.ToSingle(rb.Slice(po + 8, 4));
float pw = System.BitConverter.ToSingle(rb.Slice(po + 12, 4));
sb.Append(System.FormattableString.Invariant($" ({px:F3},{py:F3},{pz:F3},{pw:F3})"));
}
}
else
{
sb.Append(System.FormattableString.Invariant(
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={rb.Length}"));
}
// Terrain-UBO head as uploaded (std140: int count at +0, planes[8] at +16).
var tb = _clipFrame.TerrainBytesForTest;
int uboCount = System.BitConverter.ToInt32(tb.Slice(0, 4));
float u0 = System.BitConverter.ToSingle(tb.Slice(16, 4));
float u1 = System.BitConverter.ToSingle(tb.Slice(20, 4));
float u2 = System.BitConverter.ToSingle(tb.Slice(24, 4));
float u3 = System.BitConverter.ToSingle(tb.Slice(28, 4));
sb.Append(System.FormattableString.Invariant(
$" ubo: n={uboCount} p0=({u0:F3},{u1:F3},{u2:F3},{u3:F3})"));
string sig = sb.ToString();
_clipRouteSeq++;
if (sig == _lastClipRouteSig)
return;
_lastClipRouteSig = sig;
Console.WriteLine($"[clip-route] n={_clipRouteSeq} {sig}");
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells)
{
if (ctx.DrawExitPortalMasks is null)
return;
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))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
}
}
private void DrawEnvCellShells(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.
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)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
// Transparent: far→near order matters for compositing, so keep these
// per-cell in ShellPass order — but skip cells with no transparent
// geometry (most are opaque-only walls/floors), removing the bulk of the
// per-cell transparent Render calls.
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
if (!_envCells.CellHasTransparent(entry.CellId)) continue;
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
}
// 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(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone,
bool rootIsOutdoor)
{
if (partition.Dynamics.Count == 0)
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;
}
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)
return;
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _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 (ctx.DrawDynamicsParticles is not null)
{
_dynamicsParticleScratch.Clear();
foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e))
_dynamicsParticleScratch.Add(e);
if (_dynamicsParticleScratch.Count > 0)
ctx.DrawDynamicsParticles(_dynamicsParticleScratch);
}
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
{
// 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);
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, survivors);
}
// 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 (_allCellStatics.Count > 0)
{
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _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
// (DrawRetailPViewCellParticles → 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 — DrawRetailPViewCellParticles 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 (_allCellStatics.Count > 0)
ctx.DrawCellParticles?.Invoke(
new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics));
}
// T3 scratch lists (render thread only; cleared per use).
private readonly List<WorldEntity> _outdoorStaticScratch = 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();
// #121: cone-surviving dynamics whose emitters draw in the dynamics
// particle pass (survivors minus outside-stage). Cleared per use.
private readonly List<WorldEntity> _dynamicsParticleScratch = 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();
/// <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,
Func<uint, LoadedCell?> cellLookup)
{
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 = cellLookup(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;
}
// BR-2 phantom-site probe state: print-on-change per cell so the log stays
// diffable while the condition persists. Throwaway apparatus — strip when
// the #113 phantom residual closes. (The [phantom-shell] half died with
// the T1 chop deletion — shells draw whole, there is no slice state left
// to report.)
private readonly Dictionary<uint, int> _phantomObjsSig = new();
private void EmitPhantomObjsProbe(uint cellId, int bucketCount)
{
if (_phantomObjsSig.TryGetValue(cellId, out var prev) && prev == bucketCount) return;
_phantomObjsSig[cellId] = bucketCount;
Console.WriteLine($"[phantom-objs] cell=0x{cellId:X8} entities={bucketCount} (drawn unclipped, no viewcone)");
}
private static ClipViewSlice[] GetCellSlicesOrNoClip(
ClipFrameAssembly clipAssembly,
uint cellId)
{
if (clipAssembly.CellIdToViewSlices.TryGetValue(cellId, out var slices)
&& slices.Length > 0)
return slices;
return new[] { NoClipSlice };
}
private void UseIndoorMembershipOnlyRouting()
{
// T1: NOTHING in the world passes hard-clips geometry anymore — retail
// viewcone-CHECKS meshes (sphere vs view planes, T3) and never clips
// cell shells (DrawEnvCell draws the whole prebuilt mesh, pc:427905).
// This clears any clip routing left by the landscape slices.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
private void DrawEntityBucket(
IRetailPViewCellDrawContext ctx,
IReadOnlyList<WorldEntity> bucket,
HashSet<uint>? visibleCellIds)
{
uint lbId = ctx.PlayerLandblockId ?? 0u;
var entry = (lbId, Vector3.Zero, Vector3.Zero,
(IReadOnlyList<WorldEntity>)bucket,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
ctx.Camera,
new[] { entry },
ctx.Frustum,
neverCullLandblockId: ctx.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: ctx.AnimatedEntityIds);
}
private void RestoreNoClip(Action<uint> setTerrainClipUbo)
{
_clipFrame.Reset();
UploadClipFrame(setTerrainClipUbo);
UseIndoorMembershipOnlyRouting();
}
private void UploadClipFrame(Action<uint> setTerrainClipUbo)
{
_clipFrame.UploadShared(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
setTerrainClipUbo(_clipFrame.TerrainUbo);
}
}
public interface IRetailPViewCellDrawCallbacks
{
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; }
/// <summary>#124: far-Z punch one look-in aperture (a clipped view polygon
/// of a looked-into building cell) — always the PUNCH variant regardless
/// of root kind (retail maxZ1; the root-keyed forceFarZ selector only
/// governs the MAIN frame's exit-portal masks).</summary>
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; }
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
{
public ICamera Camera { get; }
public FrustumPlanes? Frustum { get; }
public uint? PlayerLandblockId { get; }
public HashSet<uint>? AnimatedEntityIds { get; }
/// <summary>#121: draw the Scene-pass emitters attached to the frame's
/// cone-surviving dynamics (portal swirls, creature effects). Invoked once
/// per frame after the last entity pass with the survivor list.</summary>
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; }
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
{
public required LoadedCell RootCell { get; init; }
/// <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; init; }
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
public required uint? PlayerLandblockId { get; init; }
public required HashSet<uint>? AnimatedEntityIds { get; init; }
public required int RenderCenterLbX { get; init; }
public required int RenderCenterLbY { get; init; }
public required int RenderRadius { get; init; }
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<uint> SetTerrainClipUbo { get; init; }
public required Action<RetailPViewLandscapeSliceContext> DrawLandscapeSlice { get; init; }
/// <summary>#131/#132: the LATE landscape phase, per slice, after the #124
/// look-ins — outside-stage dynamics' meshes + all scene particles +
/// weather (the FlushAlphaList deferral; see DrawLandscapeThroughOutsideView).</summary>
public Action<RetailPViewLandscapeLateSliceContext>? DrawLandscapeSliceLate { get; init; }
/// <summary>T1: one full-buffer depth clear between the outside stage and the
/// interior stage (retail PView::DrawCells, Ghidra 0x005a4840). Null for outdoor
/// roots — outdoors the interiors must depth-test against terrain + exteriors and
/// appear only through punched apertures.</summary>
public Action? ClearDepthForInterior { get; init; }
public Action<RetailPViewCellSliceContext>? DrawExitPortalMasks { get; init; }
public Action<RetailPViewCellSliceContext>? DrawCellParticles { get; init; }
public Action<RetailPViewCellSliceContext>? DrawLookInPortalPunch { get; init; }
/// <summary>#131: Scene-pass draw of UNATTACHED emitters
/// (AttachedObjectId == 0) for interior-root frames — invoked once at the
/// end of the landscape stage (pre-clear). Outdoor roots draw them via
/// GameWindow's dedicated post-frame pass instead.</summary>
public Action? DrawUnattachedSceneParticles { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
}
public sealed class RetailPViewFrameResult
{
public required PortalVisibilityFrame PortalFrame { get; init; }
public required ClipFrameAssembly ClipAssembly { get; init; }
public required HashSet<uint> DrawableCells { get; init; }
public required InteriorEntityPartition.Result Partition { get; init; }
}
public readonly record struct RetailPViewLandscapeSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> OutdoorEntities);
/// <summary>#131/#132: the late landscape phase's per-slice payload —
/// outside-stage dynamics to mesh-draw, plus the full scene-particle owner
/// set (statics + dynamics cone survivors) the attached-emitter filter keys on.</summary>
public readonly record struct RetailPViewLandscapeLateSliceContext(
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> Dynamics,
IReadOnlyList<WorldEntity> ParticleOwners);
public readonly record struct RetailPViewCellSliceContext(
uint CellId,
ClipViewSlice Slice,
IReadOnlyList<WorldEntity> CellEntities);