The headless replay of the captured indoor frame proved the look-in flood ADMITS the porch 0x017A (Diagnostic_LookInFlood_AdmitsHallPorchFromCottage: 14 cells). So the portal (a SERVER object - the teleport proves it - with ParentCellId 0xA9B4017A) routes to partition.Dynamics and draws NOWHERE under an interior root: dynamics-last viewcone-culls it (the main cone has no look-in cells) and post-seal it would z-fail beyond the root's door plane (the #118 lesson). This is AP-33's own recorded deferral - 'look-in DYNAMICS are not drawn' - the deferred case was the most-stared-at object in town. Outdoors the merge path puts the porch in the main cone -> drawn -> 'appears when I walk out'. Fix: DrawBuildingLookIns pass 2 draws look-in-cell dynamics with the statics (whole, AP-33 over-include) and their emitters ride the same DrawCellParticles call. No double-draw: dynamics-last keeps culling them; DrawDynamicsParticles only sees its cone survivors. #124 CLOSED by user gate same session. AP-33 row updated. Suites: App 261+1skip / Core 1439+2skip / UI 420 / Net 294 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1026 lines
50 KiB
C#
1026 lines
50 KiB
C#
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);
|
|
|
|
// 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();
|
|
|
|
// 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).
|
|
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells);
|
|
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);
|
|
|
|
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
|
|
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();
|
|
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
|
{
|
|
uint cellId = frame.OrderedVisibleCells[i];
|
|
_oneCell.Clear();
|
|
_oneCell.Add(cellId);
|
|
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
|
_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();
|
|
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
|
|
{
|
|
_oneCell.Clear();
|
|
_oneCell.Add(entry.CellId);
|
|
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
|
_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);
|
|
// #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)
|
|
continue;
|
|
bool visible = indoor
|
|
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
|
|
: viewcone.SphereVisibleOutside(c, r);
|
|
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).
|
|
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;
|
|
|
|
_cellStaticScratch.Clear();
|
|
foreach (var e in bucket)
|
|
{
|
|
EntitySphere(e, out var c, out float r);
|
|
if (viewcone.SphereVisibleInCell(cellId, c, r))
|
|
_cellStaticScratch.Add(e);
|
|
}
|
|
|
|
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
|
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
|
|
EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count);
|
|
|
|
if (_cellStaticScratch.Count > 0)
|
|
{
|
|
_oneCell.Clear();
|
|
_oneCell.Add(cellId);
|
|
UseIndoorMembershipOnlyRouting();
|
|
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
|
|
}
|
|
|
|
// T3 (BR-5): particles gate through the SAME viewcone as their
|
|
// owners — the callback receives the cone-surviving entity set, so
|
|
// an emitter attached to a culled static no longer draws through
|
|
// the wall (the candle-flames-through-walls fix). Consumed
|
|
// synchronously within this iteration (scratch list reuse).
|
|
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
|
|
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch));
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
/// <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);
|