refactor(render): extract typed retail pview passes

This commit is contained in:
Erik 2026-07-22 06:40:09 +02:00
parent 6d6e5b5fa5
commit 85239fb373
13 changed files with 1888 additions and 841 deletions

View file

@ -83,7 +83,6 @@ public sealed class GameWindow : IDisposable
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
System.StringComparison.Ordinal);
private long _frameDiagLastPublicationMilliseconds;
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
@ -224,6 +223,10 @@ public sealed class GameWindow : IDisposable
// _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt
// each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.RetailPViewPassExecutor? _retailPViewPassExecutor;
private AcDream.App.Rendering.RetailPViewCellSource? _retailPViewCells;
private AcDream.App.Rendering.TerrainDrawDiagnosticsController?
_terrainDrawDiagnostics;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
@ -237,11 +240,10 @@ public sealed class GameWindow : IDisposable
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
private ClipFrame? _clipFrame;
// PView pass-local particle classifications move with the pass executor in
// Checkpoint E; GameWindow still owns them until that cutover.
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new();
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
// Flat-world fallback particle scratch. PView classifications belong to
// RetailPViewPassExecutor.
private readonly HashSet<uint> _visibleSceneParticleEntityIds = [];
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
@ -2311,8 +2313,7 @@ public sealed class GameWindow : IDisposable
landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip();
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer(
_gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!);
_retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer();
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
@ -2892,6 +2893,37 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!,
_cellVisibility));
_terrainDrawDiagnostics =
new AcDream.App.Rendering.TerrainDrawDiagnosticsController(
_frameDiag,
_worldRenderDiagnostics!,
new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource(
_terrain,
_landblockPresentationPipeline,
_renderFrameLivePreparation,
_wbDrawDispatcher,
_streamingController,
_liveEntities!,
_worldState),
_renderDiagnosticLog);
_retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource(
_cellVisibility);
_retailPViewPassExecutor =
new AcDream.App.Rendering.RetailPViewPassExecutor(
_gl!,
new AcDream.App.Rendering.SilkRetailPViewFramebufferSource(
_window!),
_clipFrame!,
_terrain,
_envCellRenderer!,
_wbDrawDispatcher!,
_skyRenderer,
_particleSystem,
_particleRenderer,
_portalDepthMask,
_retailAlphaQueue,
_worldRenderDiagnostics!,
_terrainDrawDiagnostics);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
@ -3527,7 +3559,6 @@ public sealed class GameWindow : IDisposable
int sigOutdoorRootObjectCount = 0;
int sigLiveDynamicDrawnCount = 0;
string sigSceneParticles = "none";
_outdoorSceneParticleEntityIds.Clear();
_visibleSceneParticleEntityIds.Clear();
// Retail entry ownership: GameWindow never builds a second indoor PView product.
// Outdoor frames begin no-clip; indoor frames skip the global landscape block and let
@ -3591,10 +3622,10 @@ public sealed class GameWindow : IDisposable
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup.
EnableClipDistances();
_worldRenderDiagnostics?.BeginTerrainDraw();
_terrainDrawDiagnostics!.Begin();
sigTerrainDrawn = true;
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
PublishTerrainDrawDiagnostics();
_terrainDrawDiagnostics.Complete();
}
@ -3617,7 +3648,7 @@ public sealed class GameWindow : IDisposable
if (_retailPViewRenderer is null)
throw new InvalidOperationException("Retail PView renderer is required for indoor frames.");
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewFrameInput
{
RootCell = clipRoot,
// R-A2: outdoor root floods each nearby building per-building (not via the root).
@ -3626,7 +3657,7 @@ public sealed class GameWindow : IDisposable
NearbyBuildingCells = nearbyBuildingCells,
ViewerEyePos = viewerEyePos,
ViewProjection = envCellViewProj,
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
Cells = _retailPViewCells!,
Camera = camera,
CameraWorldPosition = camPos,
// (#176 correction: the former RebuildScopedLights callback —
@ -3640,94 +3671,20 @@ public sealed class GameWindow : IDisposable
RenderCenterLbY = renderCenterLbY,
RenderRadius = _nearRadius,
LandblockEntries = _worldState.LandblockEntries,
SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding),
DrawLandscapeSlice = sliceCtx =>
DrawRetailPViewLandscapeSlice(
sliceCtx,
camera,
frustum,
camPos,
playerLb,
animatedIds,
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive),
// #131/#132: the late phase — dynamics meshes + scene
// particles + weather AFTER the look-ins (FlushAlphaList
// deferral).
DrawLandscapeSliceLate = lateCtx =>
DrawRetailPViewLandscapeSliceLate(
lateCtx,
camera,
frustum,
camPos,
playerLb,
animatedIds,
renderSky,
renderWeather: playerSeenOutside,
kf,
environOverrideActive,
isOutdoorRoot: clipRoot.IsOutdoorNode),
// T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840).
// INTERIOR roots: one FULL depth clear between the outside stage and
// the interior stage, then SEALS re-stamp every outside-leading
// portal's TRUE depth (#108's protective mechanism). OUTDOOR roots:
// no clear (the world's depth must survive) — instead each flooded
// building's entry aperture gets a far-Z PUNCH so its interior shows
// through the doorway. Both are safe ONLY because dynamics draw LAST
// (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics
// and erased the player (reverted 88be519).
ClearDepthForInterior = clipRoot.IsOutdoorNode
? null
: () =>
{
gl.Disable(EnableCap.ScissorTest);
gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson)
gl.Clear(ClearBufferMask.DepthBufferBit);
},
DrawExitPortalMasks = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: clipRoot.IsOutdoorNode),
// #124: look-in apertures are ALWAYS the punch (retail
// maxZ1), independent of the root-keyed selector above.
DrawLookInPortalPunch = sliceCtx =>
DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj,
forceFarZ: true),
// #131: unattached emitters under an interior root — the
// landscape-stage pass (the outdoor T3 pass below is gated
// IsOutdoorNode, so the two never both run).
DrawUnattachedSceneParticles = () =>
{
if (_particleSystem is null || _particleRenderer is null)
return;
DisableClipDistances();
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true);
},
FlushLandscapeAlpha = _retailAlphaQueue.Flush,
DrawCellParticles = sliceCtx =>
DrawRetailPViewCellParticles(sliceCtx, camera, camPos),
DrawDynamicsParticles = survivors =>
DrawRetailPViewDynamicsParticles(survivors, camera, camPos),
EmitDiagnostics = result =>
_worldRenderDiagnostics?.EmitRetailPViewDiagnostics(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled,
AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled,
AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled,
result,
clipRoot,
viewerCellId,
playerCellId,
camPos,
playerViewPos,
camera.View,
_cellVisibility.LastCameraCellResolution),
});
RenderSky = renderSky,
RenderWeather = playerSeenOutside,
DayFraction = (float)WorldTime.DayFraction,
ActiveDayGroup = _activeDayGroup,
SkyKeyframe = kf,
EnvironOverrideActive = environOverrideActive,
ViewerCellId = viewerCellId,
PlayerCellId = playerCellId,
PlayerViewPosition = playerViewPos,
CameraView = camera.View,
CameraCellResolution = _cellVisibility.LastCameraCellResolution,
}, _retailPViewPassExecutor
?? throw new InvalidOperationException(
"Retail PView pass executor is required for indoor frames."));
pvFrame = pviewResult.PortalFrame;
clipAssembly = pviewResult.ClipAssembly;
envCellShellFilter = pviewResult.DrawableCells;
@ -3833,7 +3790,7 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_visibleSceneParticleEntityIds,
includeUnattached: true,
excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds);
excludedAttachedOwnerIds: _noSceneParticleEntityIds);
}
else
{
@ -3865,13 +3822,13 @@ public sealed class GameWindow : IDisposable
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_outdoorSceneParticleEntityIds,
_retailPViewPassExecutor!.OutdoorSceneParticleEntityIds,
includeUnattached: true);
}
// Bug A fix (post-#26 worktree, 2026-04-26): weather sky
// Outdoor LScape post-scene weather. Indoor weather through an exit portal is
// drawn by RetailPViewRenderer.DrawInside via DrawRetailPViewLandscapeSlice.
// drawn by RetailPViewRenderer.DrawInside via RetailPViewPassExecutor.
if (clipRoot is null && drawSkyThisFrame)
{
sigSkyDrawn = true;
@ -4257,308 +4214,6 @@ public sealed class GameWindow : IDisposable
}
}
private void DrawRetailPViewLandscapeSlice(
AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? animatedIds,
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive)
{
var slice = sliceCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
_worldRenderDiagnostics?.EmitClipRouteScissorProbe(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled,
scissor,
slice.NdcAabb);
_clipFrame!.BindTerrainClip(_gl!);
EnableClipDistances();
if (renderSky)
_skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
DisableClipDistances();
if (renderSky && _particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
EnableClipDistances();
_worldRenderDiagnostics?.BeginTerrainDraw();
_terrain?.Draw(
camera,
frustum,
neverCullLandblockId: playerLb,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb);
PublishTerrainDrawDiagnostics();
// T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes
// are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the
// sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities
// is the per-slice survivor set).
DisableClipDistances();
if (sliceCtx.OutdoorEntities.Count > 0)
{
var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
sliceCtx.OutdoorEntities,
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
}
// #131/#132: scene particles + weather MOVED to the LATE phase
// (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the
// #124 look-ins (retail's FlushAlphaList deferral, DrawCells
// pc:432722); drawn here they were overpainted by far-building
// interiors wherever a look-in aperture sat behind them.
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances();
}
// #131/#132: the LATE landscape phase — per slice, invoked by the renderer
// AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage
// dynamics' meshes (a translucent portal swirl blends over a far interior
// instead of being overpainted by it — translucents write no depth to
// protect themselves) + ALL attached scene particles (statics' flames
// included — the #132 candle) + weather. Retail equivalent: alpha draws
// collected during LScape::draw flush ONCE after it
// (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722).
private void DrawRetailPViewLandscapeSliceLate(
AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? animatedIds,
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive,
bool isOutdoorRoot)
{
var slice = lateCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
_clipFrame!.BindTerrainClip(_gl!);
// Outside-stage dynamics' meshes — viewcone pre-filtered by the
// renderer, never hard-clipped (T3).
DisableClipDistances();
if (lateCtx.Dynamics.Count > 0)
{
var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero,
lateCtx.Dynamics,
(IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity>?)null);
_wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: null,
animatedEntityIds: animatedIds);
}
_outdoorSceneParticleEntityIds.Clear();
foreach (var entity in lateCtx.ParticleOwners)
_outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity));
// #131 [outstage-pt] probe: the slice Scene-particle id set + how many
// live emitters the filter would actually match, plus the distinct
// UNMATCHED attached owner ids (the portal-identification handle —
// an emitter whose owner never lands in the set draws nowhere
// indoors). Print-on-change.
_worldRenderDiagnostics?.EmitOutStageParticles(
AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled,
_particleSystem,
_outdoorSceneParticleEntityIds);
// #132 outdoor sibling: under an OUTDOOR root the merged building
// interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn
// here is overpainted whenever a punched aperture sits behind it
// (user-confirmed at the outdoor candle). Outdoor roots therefore
// SKIP the slice Scene pass and draw attached scene particles in the
// post-frame pass alongside the T3 unattached pass (the id set built
// above carries over — the outdoor root has a single full-screen
// slice). Interior roots draw here: the look-ins already ran and the
// post-clear seal discipline owns the rest of the frame.
if (!isOutdoorRoot
&& _outdoorSceneParticleEntityIds.Count > 0
&& _particleSystem is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_outdoorSceneParticleEntityIds);
}
// T3 (BR-5): weather gates on the PLAYER being outside, not the viewer
// root — retail draws weather only when is_player_outside (the rain
// cylinder rides the player; an indoor player gets NO rain even while
// looking out a doorway). Closes the rain-through-doorways divergence
// (weather-gate-player-vs-viewer, adjusted-confirmed).
EnableClipDistances();
if (renderSky && renderWeather)
{
_skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf, environOverrideActive);
DisableClipDistances();
if (_particleSystem is not null && _particleRenderer is not null)
_particleRenderer.Draw(camera, camPos,
AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene);
}
else
{
DisableClipDistances();
}
if (scissor)
_gl!.Disable(EnableCap.ScissorTest);
DisableClipDistances();
}
// T1: retail's invisible portal depth writes on every outside-leading
// portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view
// region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90),
// dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is
// retail's maxZ1(true)/maxZ2(false) selector:
// • INTERIOR root (false → SEAL, true depth): after the full depth clear,
// stamp the door plane so interior geometry beyond the door z-fails
// inside the aperture and the terrain drawn through the outside view
// keeps its pixels (#108's protective mechanism).
// • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's
// depth inside a flooded building's entry aperture so the interior
// drawn next shows THROUGH the doorway.
// Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the
// first BR-2 attempt punched after dynamics and erased the player
// (reverted 88be519). Wiring only — the draw lives in
// PortalDepthMaskRenderer.
private void DrawRetailPViewPortalDepthWrite(
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
System.Numerics.Matrix4x4 viewProjection,
bool forceFarZ)
{
if (_portalDepthMask is null)
return;
if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null)
return;
Span<System.Numerics.Vector3> world = stackalloc System.Numerics.Vector3[32];
for (int i = 0; i < cell.Portals.Count; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue; // depth writes apply to portals leading OUTSIDE only
if (i >= cell.PortalPolygons.Count)
break;
var localVerts = cell.PortalPolygons[i];
if (localVerts.Length < 3)
continue;
// cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b);
// the shell that rasterizes this aperture draws +ShellDrawLiftZ
// higher. The seal/punch is a DRAW — stamp depth in the same lifted
// space or the stamp sits 2 cm below the drawn hole (#130 family).
int n = System.Math.Min(localVerts.Length, world.Length);
for (int v = 0; v < n; v++)
{
world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform);
world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ;
}
// #176 seam-draw probe: a sealed dungeon must emit ZERO depth fans
// (only OtherCellId==0xFFFF portals reach here) — any line in a
// target cell is a finding (a depth stamp fighting the shell floor).
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(sliceCtx.CellId))
{
float seamZMin = float.MaxValue, seamZMax = float.MinValue;
for (int v = 0; v < n; v++)
{
seamZMin = System.Math.Min(seamZMin, world[v].Z);
seamZMax = System.Math.Max(seamZMax, world[v].Z);
}
Console.WriteLine(System.FormattableString.Invariant(
$"[seam-mask] t={System.Environment.TickCount64} cell=0x{sliceCtx.CellId:X8} portal={i} far={forceFarZ} n={n} z=[{seamZMin:F3},{seamZMax:F3}]"));
}
_portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ);
}
}
private void DrawRetailPViewCellParticles(
AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx,
ICamera camera,
System.Numerics.Vector3 camPos)
{
if (_particleSystem is null || _particleRenderer is null || sliceCtx.CellEntities.Count == 0)
return;
_visibleSceneParticleEntityIds.Clear();
foreach (var entity in sliceCtx.CellEntities)
_visibleSceneParticleEntityIds.Add(ParticleEntityKey(entity));
if (_visibleSceneParticleEntityIds.Count == 0)
return;
// T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles
// like meshes (viewcone on the owner; depth does the pixels). The
// CellEntities set is already the cone-surviving owner list, so the
// id-predicate below IS the cone gate; the punch/seal depth discipline
// composites the pixels.
DisableClipDistances();
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_visibleSceneParticleEntityIds);
DisableClipDistances();
}
// #121: the dynamics-owner particle pass — Scene-pass emitters attached to
// the frame's cone-surviving dynamics (portal swirls on server-spawned
// portal entities, creature effects). Retail draws emitters with their
// owner object; before this pass, dynamics' emitters fell through every
// pview particle filter (landscape slice = outdoor statics + #118
// outside-stage dynamics; cell callback = cell statics) once T4 deleted
// the clipRoot==null global pass from normal frames — every world portal
// went invisible. Mirror of DrawRetailPViewCellParticles with the
// survivors' ids as the filter.
private readonly HashSet<uint> _dynamicsSceneParticleEntityIds = new();
private void DrawRetailPViewDynamicsParticles(
IReadOnlyList<AcDream.Core.World.WorldEntity> survivors,
ICamera camera,
System.Numerics.Vector3 camPos)
{
if (_particleSystem is null || _particleRenderer is null || survivors.Count == 0)
return;
_dynamicsSceneParticleEntityIds.Clear();
foreach (var entity in survivors)
_dynamicsSceneParticleEntityIds.Add(ParticleEntityKey(entity));
if (_dynamicsSceneParticleEntityIds.Count == 0)
return;
DisableClipDistances();
_particleRenderer.DrawForOwners(
camera,
camPos,
AcDream.Core.Vfx.ParticleRenderPass.Scene,
_dynamicsSceneParticleEntityIds);
DisableClipDistances();
}
private void EnableClipDistances()
{
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
@ -4571,23 +4226,6 @@ public sealed class GameWindow : IDisposable
_gl!.Disable(EnableCap.ClipDistance0 + i);
}
// Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in
// framebuffer pixels and enable the scissor test; returns true iff applied (the caller then
// disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice
// (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false
// (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer
// bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel
// off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip.
private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb)
{
if (!apply || _window is null) return false;
var fb = _window.FramebufferSize;
var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y);
_gl!.Enable(EnableCap.ScissorTest);
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
//
// The ImGui DebugPanel reads through DebugVM closures that ask
@ -5414,60 +5052,6 @@ public sealed class GameWindow : IDisposable
}
}
private void PublishTerrainDrawDiagnostics()
{
long now = _frameDiag ? Environment.TickCount64 : 0L;
_worldRenderDiagnostics?.EndTerrainDraw();
if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000)
return;
// Preserve the original one-timestamp diagnostic transaction. A
// failure in either write leaves the shared cadence uncommitted, so
// the next terrain draw retries both TERRAIN and FRAME diagnostics.
_worldRenderDiagnostics?.PublishTerrainDiagnostics(
new AcDream.App.Rendering.TerrainRenderDiagnosticFacts(
_terrain?.VisibleSlots ?? 0,
_terrain?.LoadedSlots ?? 0,
_terrain?.CapacitySlots ?? 0));
PublishFrameDiagnostics();
_frameDiagLastPublicationMilliseconds = now;
}
private void PublishFrameDiagnostics()
{
AcDream.App.Streaming.LandblockPresentationDiagnostics presentation =
_landblockPresentationPipeline?.Diagnostics ?? default;
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render =
presentation.Render;
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics =
presentation.Physics;
AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics =
presentation.Statics;
double ticksToMicros =
1_000_000.0 / System.Diagnostics.Stopwatch.Frequency;
AcDream.App.Rendering.RollingTimingPercentiles upload =
_renderFrameLivePreparation?.UploadTiming ?? default;
double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0;
double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
_renderDiagnosticLog.WriteLine(
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " +
$"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " +
$"begin={render.BeginPublishTicks * ticksToMicros:F1} " +
$"complete={render.CompletePublishTicks * ticksToMicros:F1}] " +
$"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " +
$"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " +
$"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " +
$"static={statics.BeginCount}/{statics.CompleteCount}" +
$"(active={statics.ActiveEntityCount}) " +
$"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " +
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
$"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" +
$"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " +
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
$"resident={_worldState.Entities.Count} walked={walked}");
}
private void OnClosing()
=> CompleteShutdown();
@ -5521,6 +5105,10 @@ public sealed class GameWindow : IDisposable
{
_worldRenderFrameBuilder = null;
_skyPesFrame = null;
_retailPViewPassExecutor = null;
_retailPViewCells = null;
_terrainDrawDiagnostics = null;
_retailPViewRenderer = null;
}),
]),
new ResourceShutdownStage("session dependents",

View file

@ -12,7 +12,7 @@ namespace AcDream.App.Rendering;
/// (Ghidra 0x0059bc90, pc:424490).
///
/// <para><b>Wired by T1 (BR-3, `579c8b0`):</b> seal on interior roots, punch
/// on outdoor / look-in roots, via <c>GameWindow.DrawRetailPViewPortalDepthWrite</c>
/// on outdoor / look-in roots, via <c>RetailPViewPassExecutor.DrawPortalDepthWrite</c>
/// (the <c>DrawExitPortalMasks</c> slice callback) — safe alongside the
/// dynamics-drawn-LAST frame order (the first BR-2 attempt punched after
/// dynamics and erased the player; reverted 88be519). #117 (2026-06-11)

View file

@ -0,0 +1,576 @@
using System.Numerics;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
internal readonly record struct RetailPViewFramebufferSize(int Width, int Height);
internal interface IRetailPViewFramebufferSource
{
RetailPViewFramebufferSize Capture();
}
internal sealed class SilkRetailPViewFramebufferSource(IWindow window) :
IRetailPViewFramebufferSource
{
private readonly IWindow _window = window
?? throw new ArgumentNullException(nameof(window));
public RetailPViewFramebufferSize Capture()
{
var size = _window.FramebufferSize;
return new RetailPViewFramebufferSize(size.X, size.Y);
}
}
internal sealed class RetailPViewCellSource : IRetailPViewCellSource
{
private readonly CellVisibility _cells;
public RetailPViewCellSource(CellVisibility cells) =>
_cells = cells ?? throw new ArgumentNullException(nameof(cells));
public LoadedCell? Find(uint cellId) =>
_cells.TryGetCell(cellId, out LoadedCell? cell) ? cell : null;
}
internal sealed class RetailPViewParticleClassifications
{
private readonly HashSet<uint> _outdoor = [];
private readonly HashSet<uint> _visible = [];
private readonly HashSet<uint> _dynamics = [];
public IReadOnlySet<uint> Outdoor => _outdoor;
public HashSet<uint> Visible => _visible;
public HashSet<uint> Dynamics => _dynamics;
public void BeginFrame()
{
_outdoor.Clear();
_visible.Clear();
_dynamics.Clear();
}
public void ReplaceOutdoor(IReadOnlyList<WorldEntity> owners)
{
_outdoor.Clear();
foreach (WorldEntity owner in owners)
_outdoor.Add(owner.Id);
}
}
/// <summary>
/// Concrete GL implementation of the named passes ordered by
/// <see cref="RetailPViewRenderer"/>. It owns reusable pass-local particle
/// classifications but borrows every renderer and world source.
/// The order it implements is retail <c>PView::DrawCells @ 0x005A4840</c>:
/// landscape, delayed-alpha flush, optional interior depth clear, exit masks,
/// cell shells/objects, then surviving dynamics. Landscape sky/terrain/weather
/// placement follows <c>LScape::draw @ 0x00506330</c>.
/// </summary>
internal sealed class RetailPViewPassExecutor : IRetailPViewPassExecutor
{
private readonly GL _gl;
private readonly IRetailPViewFramebufferSource _framebuffer;
private readonly ClipFrame _clipFrame;
private readonly TerrainModernRenderer? _terrain;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private readonly SkyRenderer? _sky;
private readonly ParticleSystem? _particles;
private readonly ParticleRenderer? _particleRenderer;
private readonly PortalDepthMaskRenderer? _portalDepthMask;
private readonly RetailAlphaQueue _alpha;
private readonly WorldRenderDiagnostics _diagnostics;
private readonly TerrainDrawDiagnosticsController _terrainDiagnostics;
private readonly RetailPViewParticleClassifications _particleClassifications = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = [];
/// <summary>
/// Borrowed until the next late landscape pass. The outdoor-root post-world
/// particle pass consumes this synchronously before another PView frame.
/// </summary>
public IReadOnlySet<uint> OutdoorSceneParticleEntityIds =>
_particleClassifications.Outdoor;
public RetailPViewPassExecutor(
GL gl,
IRetailPViewFramebufferSource framebuffer,
ClipFrame clipFrame,
TerrainModernRenderer? terrain,
EnvCellRenderer envCells,
WbDrawDispatcher entities,
SkyRenderer? sky,
ParticleSystem? particles,
ParticleRenderer? particleRenderer,
PortalDepthMaskRenderer? portalDepthMask,
RetailAlphaQueue alpha,
WorldRenderDiagnostics diagnostics,
TerrainDrawDiagnosticsController terrainDiagnostics)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
_framebuffer = framebuffer
?? throw new ArgumentNullException(nameof(framebuffer));
_clipFrame = clipFrame ?? throw new ArgumentNullException(nameof(clipFrame));
_terrain = terrain;
_envCells = envCells ?? throw new ArgumentNullException(nameof(envCells));
_entities = entities ?? throw new ArgumentNullException(nameof(entities));
_sky = sky;
_particles = particles;
_particleRenderer = particleRenderer;
_portalDepthMask = portalDepthMask;
_alpha = alpha ?? throw new ArgumentNullException(nameof(alpha));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_terrainDiagnostics = terrainDiagnostics
?? throw new ArgumentNullException(nameof(terrainDiagnostics));
}
public void BeginFrame()
{
_particleClassifications.BeginFrame();
}
public ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly) =>
ClipFrameAssembler.Assemble(_clipFrame, portalFrame, reuseAssembly);
public void PrepareClipFrame(int terrainUploadCount)
{
// Allocate every terrain record before issuing the first draw. BufferData
// must not replace the arena while an earlier slice can reference it.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
_clipFrame.UploadRegions(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
UploadTerrainClip();
}
public void SetTerrainClip(ReadOnlySpan<Vector4> planes)
{
_clipFrame.SetTerrainClip(planes);
UploadTerrainClip();
}
public void ClearClipRouting() => _entities.ClearClipRouting();
public void UseIndoorMembershipOnlyRouting()
{
// Retail viewcone-checks meshes and draws whole cell shells. This clears
// any terrain-slice routing before the indoor membership passes.
_envCells.SetClipRouting(null);
_entities.ClearClipRouting();
}
public void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds) =>
_envCells.PrepareRenderBatches(
frame.ViewProjection,
frame.CameraWorldPosition,
filter: visibleCellIds,
centerLbX: frame.RenderCenterLbX,
centerLbY: frame.RenderCenterLbY,
renderRadius: frame.RenderRadius);
public void DrawOpaqueCellShells(HashSet<uint> cellIds) =>
_envCells.Render(WbRenderPass.Opaque, cellIds);
public bool CellHasTransparentShell(uint cellId) =>
_envCells.CellHasTransparent(cellId);
public void DrawTransparentCellShells(HashSet<uint> cellIds) =>
_envCells.Render(WbRenderPass.Transparent, cellIds);
public void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds) =>
_envCells.RenderTransparentOrdered(cellIds);
public void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? visibleCellIds)
{
uint landblockId = frame.PlayerLandblockId ?? 0u;
var entry = (
landblockId,
Vector3.Zero,
Vector3.Zero,
entities,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
frame.Camera,
new[] { entry },
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
visibleCellIds: visibleCellIds,
animatedEntityIds: frame.AnimatedEntityIds);
}
public void EmitClipRouteProbe(
ClipFrameAssembly clipAssembly,
ClipViewSlice slice,
int sliceIndex) =>
_diagnostics.EmitClipRouteProbe(
RenderingDiagnostics.ProbeClipRouteEnabled,
_clipFrame,
clipAssembly,
slice,
sliceIndex);
public void EmitOutStageOwner(
WorldEntity entity,
Vector3 sphereCenter,
float sphereRadius,
int sliceIndex,
bool passed) =>
_diagnostics.EmitOutStageOwner(
RenderingDiagnostics.ProbeOutStageEnabled,
RenderingDiagnostics.DumpEntitySourceIds,
entity,
sphereCenter,
sphereRadius,
sliceIndex,
passed);
public void EmitOutStageRouting(
int sliceIndex,
IReadOnlyList<WorldEntity> entities,
ViewconeCuller viewcone) =>
_diagnostics.EmitOutStageRouting(
RenderingDiagnostics.ProbeOutStageEnabled,
sliceIndex,
entities,
viewcone);
public void EmitPhantomObjects(uint cellId, int survivorCount) =>
_diagnostics.EmitPhantomObjects(
RenderingDiagnostics.ProbePhantomEnabled,
cellId,
survivorCount);
public void DrawLandscapeSlice(
RetailPViewFrameInput frame,
RetailPViewLandscapeSliceContext context)
{
ClipViewSlice slice = context.Slice;
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
_diagnostics.EmitClipRouteScissorProbe(
RenderingDiagnostics.ProbeClipRouteEnabled,
scissor,
slice.NdcAabb);
_clipFrame.BindTerrainClip(_gl);
EnableClipDistances();
if (frame.RenderSky)
{
_sky?.RenderSky(
frame.Camera,
frame.CameraWorldPosition,
frame.DayFraction,
frame.ActiveDayGroup,
frame.SkyKeyframe,
frame.EnvironOverrideActive);
}
DisableClipDistances();
if (frame.RenderSky && _particles is not null && _particleRenderer is not null)
{
_particleRenderer.Draw(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.SkyPreScene);
}
EnableClipDistances();
_terrainDiagnostics.Begin();
_terrain?.Draw(
frame.Camera,
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb);
_terrainDiagnostics.Complete();
DisableClipDistances();
if (context.OutdoorEntities.Count > 0)
{
var sceneryEntry = (
frame.PlayerLandblockId ?? 0u,
Vector3.Zero,
Vector3.Zero,
context.OutdoorEntities,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
frame.Camera,
new[] { sceneryEntry },
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
visibleCellIds: null,
animatedEntityIds: frame.AnimatedEntityIds);
}
if (scissor)
_gl.Disable(EnableCap.ScissorTest);
DisableClipDistances();
}
public void DrawLandscapeSliceLate(
RetailPViewFrameInput frame,
RetailPViewLandscapeLateSliceContext context)
{
ClipViewSlice slice = context.Slice;
bool scissor = BeginDoorwayScissor(slice.NdcAabb);
_clipFrame.BindTerrainClip(_gl);
DisableClipDistances();
if (context.Dynamics.Count > 0)
{
var dynamicsEntry = (
frame.PlayerLandblockId ?? 0u,
Vector3.Zero,
Vector3.Zero,
context.Dynamics,
(IReadOnlyDictionary<uint, WorldEntity>?)null);
_entities.Draw(
frame.Camera,
new[] { dynamicsEntry },
frame.Frustum,
neverCullLandblockId: frame.PlayerLandblockId,
visibleCellIds: null,
animatedEntityIds: frame.AnimatedEntityIds);
}
_particleClassifications.ReplaceOutdoor(context.ParticleOwners);
_diagnostics.EmitOutStageParticles(
RenderingDiagnostics.ProbeOutStageEnabled,
_particles,
_particleClassifications.Outdoor);
if (!frame.RootCell.IsOutdoorNode
&& _particleClassifications.Outdoor.Count > 0
&& _particles is not null
&& _particleRenderer is not null)
{
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_particleClassifications.Outdoor);
}
EnableClipDistances();
if (frame.RenderSky && frame.RenderWeather)
{
_sky?.RenderWeather(
frame.Camera,
frame.CameraWorldPosition,
frame.DayFraction,
frame.ActiveDayGroup,
frame.SkyKeyframe,
frame.EnvironOverrideActive);
DisableClipDistances();
if (_particles is not null && _particleRenderer is not null)
{
_particleRenderer.Draw(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.SkyPostScene);
}
}
else
{
DisableClipDistances();
}
if (scissor)
_gl.Disable(EnableCap.ScissorTest);
DisableClipDistances();
}
public void ClearInteriorDepth()
{
_gl.Disable(EnableCap.ScissorTest);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.DepthBufferBit);
}
public void DrawExitPortalMask(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) =>
DrawPortalDepthWrite(context, frame, forceFarZ: frame.RootCell.IsOutdoorNode);
public void DrawLookInPortalPunch(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context) =>
DrawPortalDepthWrite(context, frame, forceFarZ: true);
public void DrawUnattachedSceneParticles(RetailPViewFrameInput frame)
{
if (_particles is null || _particleRenderer is null)
return;
DisableClipDistances();
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
_noSceneParticleEntityIds,
includeUnattached: true);
}
public void FlushLandscapeAlpha() => _alpha.Flush();
public void DrawCellParticles(
RetailPViewFrameInput frame,
RetailPViewCellSliceContext context)
{
if (_particles is null
|| _particleRenderer is null
|| context.CellEntities.Count == 0)
{
return;
}
HashSet<uint> visible = _particleClassifications.Visible;
visible.Clear();
foreach (WorldEntity entity in context.CellEntities)
visible.Add(entity.Id);
if (visible.Count == 0)
return;
DisableClipDistances();
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
visible);
DisableClipDistances();
}
public void DrawDynamicsParticles(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> survivors)
{
if (_particles is null || _particleRenderer is null || survivors.Count == 0)
return;
HashSet<uint> dynamics = _particleClassifications.Dynamics;
dynamics.Clear();
foreach (WorldEntity entity in survivors)
dynamics.Add(entity.Id);
if (dynamics.Count == 0)
return;
DisableClipDistances();
_particleRenderer.DrawForOwners(
frame.Camera,
frame.CameraWorldPosition,
ParticleRenderPass.Scene,
dynamics);
DisableClipDistances();
}
public void EmitDiagnostics(
RetailPViewFrameInput frame,
RetailPViewFrameResult result) =>
_diagnostics.EmitRetailPViewDiagnostics(
RenderingDiagnostics.ProbeViewerEnabled,
RenderingDiagnostics.ProbeVisibilityEnabled,
RenderingDiagnostics.ProbeFlapEnabled,
result,
frame.RootCell,
frame.ViewerCellId,
frame.PlayerCellId,
frame.CameraWorldPosition,
frame.PlayerViewPosition,
frame.CameraView,
frame.CameraCellResolution);
private void UploadTerrainClip()
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
_terrain?.SetClipUbo(binding);
}
private void DrawPortalDepthWrite(
RetailPViewCellSliceContext context,
RetailPViewFrameInput frame,
bool forceFarZ)
{
// Retail D3DPolyRender::DrawPortalPolyInternal @ 0x0059BC90.
// Main interior roots stamp true depth (seal); outdoor and look-in
// apertures stamp far depth (punch). The renderer owns that choice.
if (_portalDepthMask is null)
return;
LoadedCell? cell = frame.Cells.Find(context.CellId);
if (cell is null)
return;
Span<Vector3> world = stackalloc Vector3[32];
for (int index = 0; index < cell.Portals.Count; index++)
{
if (cell.Portals[index].OtherCellId != 0xFFFF)
continue;
if (index >= cell.PortalPolygons.Count)
break;
Vector3[] localVertices = cell.PortalPolygons[index];
if (localVertices.Length < 3)
continue;
int count = Math.Min(localVertices.Length, world.Length);
for (int vertex = 0; vertex < count; vertex++)
{
world[vertex] = Vector3.Transform(
localVertices[vertex],
cell.WorldTransform);
world[vertex].Z += PortalVisibilityBuilder.ShellDrawLiftZ;
}
_diagnostics.EmitSeamMask(
RenderingDiagnostics.ProbeSeamDrawEnabled,
RenderingDiagnostics.SeamDrawTargetCells,
context.CellId,
index,
forceFarZ,
world[..count]);
_portalDepthMask.DrawDepthFan(
world[..count],
frame.ViewProjection,
context.Slice.Planes,
forceFarZ);
}
}
private bool BeginDoorwayScissor(Vector4 ndcAabb)
{
RetailPViewFramebufferSize framebuffer = _framebuffer.Capture();
var box = NdcScissorRect.ToPixels(
ndcAabb,
framebuffer.Width,
framebuffer.Height);
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height);
return true;
}
private void EnableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Enable(EnableCap.ClipDistance0 + index);
}
private void DisableClipDistances()
{
for (int index = 0; index < ClipFrame.MaxPlanes; index++)
_gl.Disable(EnableCap.ClipDistance0 + index);
}
}

View file

@ -1,9 +1,7 @@
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;
@ -14,10 +12,6 @@ namespace AcDream.App.Rendering;
/// </summary>
public sealed class RetailPViewRenderer
{
private readonly GL _gl;
private readonly ClipFrame _clipFrame;
private readonly EnvCellRenderer _envCells;
private readonly WbDrawDispatcher _entities;
private readonly PortalVisibilityFrame _mainPortalFrameScratch = new();
private readonly ClipFrameAssembly _clipAssemblyScratch = new();
private readonly ViewconeCuller _viewconeScratch = new();
@ -76,28 +70,20 @@ public sealed class RetailPViewRenderer
// 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)
public RetailPViewFrameResult DrawInside(
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes)
{
ArgumentNullException.ThrowIfNull(ctx);
ArgumentNullException.ThrowIfNull(passes);
passes.BeginFrame();
RecycleLookInFrames();
ResetBuildingGroups();
var pvFrame = PortalVisibilityBuilder.Build(
ctx.RootCell,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.Cells.Find,
ctx.ViewProjection,
buildingMembership: null,
drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ,
@ -131,12 +117,11 @@ public sealed class RetailPViewRenderer
&& pvFrame.OutsideView.Polygons.Count > 0)
BuildInteriorRootLookIns(ctx, pvFrame);
var clipAssembly = ClipFrameAssembler.Assemble(
_clipFrame,
var clipAssembly = passes.AssembleClipFrame(
pvFrame,
_clipAssemblyScratch);
int terrainUploadCount = checked(1 + clipAssembly.OutsideViewSlices.Length * 2);
PrepareClipFrame(ctx.SetTerrainClipUbo, terrainUploadCount);
passes.PrepareClipFrame(terrainUploadCount);
// R1: draw EVERY visible cell (retail cell_draw_list), not only the cells the
// assembler handed a clip-slot. This feeds the Prepare filter + entity partition,
@ -146,7 +131,7 @@ public sealed class RetailPViewRenderer
_drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch;
UseIndoorMembershipOnlyRouting();
passes.UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed
// into partition.ByCell (consumed ONLY by DrawBuildingLookIns — the main
@ -169,13 +154,7 @@ public sealed class RetailPViewRenderer
// the RESIDENT-cell registry, not the frame flood — and is deleted. The pool
// is built once per frame in GameWindow, player-anchored.)
_envCells.PrepareRenderBatches(
ctx.ViewProjection,
ctx.CameraWorldPosition,
filter: prepareCells,
centerLbX: ctx.RenderCenterLbX,
centerLbY: ctx.RenderCenterLbY,
renderRadius: ctx.RenderRadius);
passes.PrepareCellBatches(ctx, prepareCells);
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
var partition = _partitionResult;
@ -185,7 +164,7 @@ public sealed class RetailPViewRenderer
drawableCells,
partition);
ctx.EmitDiagnostics?.Invoke(result);
passes.EmitDiagnostics(ctx, result);
// T1 (fused BR-2/3): retail's frame order — static world, then the
// aperture depth writes, then interior cells WHOLE far→near, then
@ -226,17 +205,17 @@ public sealed class RetailPViewRenderer
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup))
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.Cells))
_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);
DrawLandscapeThroughOutsideView(ctx, passes, clipAssembly, partition, viewcone);
passes.UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, passes, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(passes, pvFrame);
DrawCellObjectLists(ctx, passes, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, passes, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
@ -244,7 +223,7 @@ public sealed class RetailPViewRenderer
// R-A2: group the nearby building cells by BuildingId and run one per-building flood per group
// (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The
// grouping dict contains only this frame's keys; lists are pooled across frames.
private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame)
private void MergeNearbyBuildingFloods(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame)
{
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
@ -255,7 +234,7 @@ public sealed class RetailPViewRenderer
var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding(
group,
ctx.ViewerEyePos,
ctx.CellLookup,
ctx.Cells.Find,
ctx.ViewProjection,
OutdoorBuildingSeedDistance,
reuseFrame: _outdoorBuildingFrameScratch);
@ -304,7 +283,7 @@ public sealed class RetailPViewRenderer
// 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)
private void BuildInteriorRootLookIns(RetailPViewFrameInput ctx, PortalVisibilityFrame pvFrame)
{
RebuildBuildingGroups(ctx.NearbyBuildingCells!);
@ -316,7 +295,7 @@ public sealed class RetailPViewRenderer
? _lookInFramePool.Pop()
: new PortalVisibilityFrame();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection,
group, ctx.ViewerEyePos, ctx.Cells.Find, ctx.ViewProjection,
OutdoorBuildingSeedDistance, pvFrame.OutsideView.Polygons,
reuseFrame: frameScratch);
if (frame.OrderedVisibleCells.Count > 0)
@ -365,7 +344,8 @@ public sealed class RetailPViewRenderer
// 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,
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition)
{
@ -375,30 +355,27 @@ public sealed class RetailPViewRenderer
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)
{
foreach (uint cellId in frame.OrderedVisibleCells)
if (!frame.CellViews.TryGetValue(cellId, out var view))
continue;
foreach (var poly in view.Polygons)
{
if (!frame.CellViews.TryGetValue(cellId, out var view))
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
foreach (var poly in view.Polygons)
{
var cps = ClipPlaneSet.From(poly);
if (cps.IsNothingVisible)
continue;
ctx.DrawLookInPortalPunch(new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
Array.Empty<WorldEntity>()));
}
passes.DrawLookInPortalPunch(ctx, new RetailPViewCellSliceContext(
cellId,
new ClipViewSlice(
0,
new Vector4(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY),
cps.PlaneArray),
Array.Empty<WorldEntity>()));
}
}
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
passes.UseIndoorMembershipOnlyRouting();
// Opaque shells batched per building into ONE Render (this building's
// aperture punches above already ran; z-buffer handles order and
@ -408,7 +385,7 @@ public sealed class RetailPViewRenderer
foreach (uint cid in frame.OrderedVisibleCells)
_shellBatch.Add(cid);
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
passes.DrawOpaqueCellShells(_shellBatch);
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
@ -417,8 +394,8 @@ public sealed class RetailPViewRenderer
_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);
if (passes.CellHasTransparentShell(cellId))
passes.DrawTransparentCellShells(_oneCell);
_cellStaticScratch.Clear();
if (partition.ByCell.TryGetValue(cellId, out var bucket))
@ -443,12 +420,12 @@ public sealed class RetailPViewRenderer
if (_cellStaticScratch.Count > 0)
{
DrawEntityBucket(ctx, _cellStaticScratch, _oneCell);
passes.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(
passes.DrawCellParticles(ctx, new RetailPViewCellSliceContext(
cellId, slice, _cellStaticScratch));
}
}
@ -456,7 +433,8 @@ public sealed class RetailPViewRenderer
}
private void DrawLandscapeThroughOutsideView(
RetailPViewDrawContext ctx,
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
ClipFrameAssembly clipAssembly,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
@ -479,16 +457,15 @@ public sealed class RetailPViewRenderer
int probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadTerrainClip(ctx.SetTerrainClipUbo);
passes.SetTerrainClip(slice.Planes);
// T3 (BR-5): entities are never hard-clipped — retail viewcone-
// CHECKS each mesh's sphere against the view (Ghidra 0x0054c250)
// and draws it whole. The old per-slice entity clip routing
// (gl_ClipDistance via SetClipRouting) is replaced by the sphere
// pre-filter below; terrain/sky keep their per-slice plane clip.
_entities.ClearClipRouting();
passes.ClearClipRouting();
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
passes.EmitClipRouteProbe(clipAssembly, slice, probeSliceIndex);
_outdoorStaticScratch.Clear();
foreach (var e in partition.OutdoorStatic)
@ -498,14 +475,14 @@ public sealed class RetailPViewRenderer
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
passes.DrawLandscapeSlice(ctx, 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);
DrawBuildingLookIns(ctx, passes, clipAssembly, partition);
// LATE phase (per slice): outside-stage dynamics' meshes (#118 — drawn
// pre-clear so the seal protects their aperture pixels; AFTER the
@ -515,9 +492,8 @@ public sealed class RetailPViewRenderer
probeSliceIndex = 0;
foreach (var slice in clipAssembly.OutsideViewSlices)
{
_clipFrame.SetTerrainClip(slice.Planes);
UploadTerrainClip(ctx.SetTerrainClipUbo);
_entities.ClearClipRouting();
passes.SetTerrainClip(slice.Planes);
passes.ClearClipRouting();
_outdoorStaticScratch.Clear(); // late: dynamics survivors
_lateParticleOwnerScratch.Clear(); // late: statics + dynamics survivors
@ -530,14 +506,12 @@ public sealed class RetailPViewRenderer
// #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")}"));
}
passes.EmitOutStageOwner(
e,
c,
r,
probeSliceIndex,
ownerPass);
}
foreach (var e in _outsideStageDynamics)
{
@ -548,10 +522,12 @@ public sealed class RetailPViewRenderer
_lateParticleOwnerScratch.Add(e);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled)
EmitOutStageProbe(probeSliceIndex, viewcone);
passes.EmitOutStageRouting(
probeSliceIndex,
_outsideStageDynamics,
viewcone);
probeSliceIndex++;
ctx.DrawLandscapeSliceLate?.Invoke(new RetailPViewLandscapeLateSliceContext(
passes.DrawLandscapeSliceLate(ctx, new RetailPViewLandscapeLateSliceContext(
slice, _outdoorStaticScratch, _lateParticleOwnerScratch));
}
@ -563,12 +539,12 @@ public sealed class RetailPViewRenderer
// 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();
passes.DrawUnattachedSceneParticles(ctx);
// Retail PView::DrawCells 0x005A4872 drains the landscape alpha list
// immediately after LScape::draw and before the optional depth clear.
// The queue remains active for the post-clear/final-world scope.
ctx.FlushLandscapeAlpha?.Invoke();
passes.FlushLandscapeAlpha();
// T1: retail clears the FULL depth buffer ONCE between the outside
// stage and the interior stage (PView::DrawCells, Ghidra 0x005a4840 —
@ -577,133 +553,19 @@ public sealed class RetailPViewRenderer
// 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();
if (clipAssembly.OutsideViewSlices.Length > 0 && !ctx.RootCell.IsOutdoorNode)
passes.ClearInteriorDepth();
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 + UploadTerrainClip + 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 UploadRegions
// 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}");
passes.UseIndoorMembershipOnlyRouting();
}
private void DrawExitPortalMasks(
IRetailPViewCellDrawCallbacks ctx,
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
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];
@ -711,11 +573,15 @@ public sealed class RetailPViewRenderer
continue;
foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId))
ctx.DrawExitPortalMasks(new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
passes.DrawExitPortalMask(
ctx,
new RetailPViewCellSliceContext(cellId, slice, Array.Empty<WorldEntity>()));
}
}
private void DrawEnvCellShells(PortalVisibilityFrame pvFrame)
private void DrawEnvCellShells(
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame)
{
// T1 (fused BR-2/3): retail DrawCells Loop 2 — every visible cell's
// shell drawn WHOLE, reverse cell_draw_list (far→near), drawn once.
@ -727,7 +593,7 @@ public sealed class RetailPViewRenderer
// (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();
passes.UseIndoorMembershipOnlyRouting();
// Opaque: ONE batched Render for all shell cells (was one heavy per-frame
// Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at
@ -739,7 +605,7 @@ public sealed class RetailPViewRenderer
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
_shellBatch.Add(entry.CellId);
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
passes.DrawOpaqueCellShells(_shellBatch);
// Transparent: far-to-near order matters for compositing. The ordered
// list retains ShellPass cell boundaries while EnvCellRenderer shares
@ -747,11 +613,11 @@ public sealed class RetailPViewRenderer
_orderedTransparentShellCells.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
if (_envCells.CellHasTransparent(entry.CellId))
if (passes.CellHasTransparentShell(entry.CellId))
_orderedTransparentShellCells.Add(entry.CellId);
}
if (_orderedTransparentShellCells.Count > 0)
_envCells.RenderTransparentOrdered(_orderedTransparentShellCells);
passes.DrawTransparentCellShellsOrdered(_orderedTransparentShellCells);
}
// T1: the frame's single LAST entity pass — ALL server-spawned dynamics
@ -767,7 +633,8 @@ public sealed class RetailPViewRenderer
// 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,
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone,
bool rootIsOutdoor)
@ -811,8 +678,8 @@ public sealed class RetailPViewRenderer
if (_dynamicsScratch.Count == 0)
return;
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
passes.UseIndoorMembershipOnlyRouting();
passes.DrawEntityBucket(ctx, _dynamicsScratch, visibleCellIds: null);
// #121: dynamics' attached emitters (portal swirls, creature effects)
// gate through the SAME cone-surviving owner set as their meshes —
@ -824,19 +691,17 @@ public sealed class RetailPViewRenderer
// 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);
}
_dynamicsParticleScratch.Clear();
foreach (var e in _dynamicsScratch)
if (!_outsideStageDynamics.Contains(e))
_dynamicsParticleScratch.Add(e);
if (_dynamicsParticleScratch.Count > 0)
passes.DrawDynamicsParticles(ctx, _dynamicsParticleScratch);
}
private void DrawCellObjectLists(
IRetailPViewCellDrawContext ctx,
RetailPViewFrameInput ctx,
IRetailPViewPassExecutor passes,
PortalVisibilityFrame pvFrame,
ClipFrameAssembly clipAssembly,
HashSet<uint> drawableCells,
@ -889,8 +754,7 @@ public sealed class RetailPViewRenderer
_cellObjCells.Add(cellId);
// BR-2 phantom-site probe (T3-updated): post-viewcone survivors.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled)
EmitPhantomObjsProbe(cellId, survivors);
passes.EmitPhantomObjects(cellId, survivors);
}
// ONE batched static-object draw for every visible cell (was N per-cell
@ -900,26 +764,27 @@ public sealed class RetailPViewRenderer
// cull; only the draw is batched).
if (_allCellStatics.Count > 0)
{
UseIndoorMembershipOnlyRouting();
DrawEntityBucket(ctx, _allCellStatics, _cellObjCells);
passes.UseIndoorMembershipOnlyRouting();
passes.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
// (RetailPViewPassExecutor.DrawCellParticles → ParticleRenderer.Draw enumerates every
// live emitter), i.e. O(cells × particles) — the dense-town cellobjects
// sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION
// (= _allCellStatics, already accumulated above for the batched draw) draws
// EXACTLY the same emitters: the callback gates on owner id (the cone-
// surviving set), the renderer sorts globally back-to-front, and the per-
// cell slice was never used for clipping (the scissor gate was deleted in
// T3 — DrawRetailPViewCellParticles disables clip distances). Runs after
// T3 — RetailPViewPassExecutor.DrawCellParticles disables clip distances). Runs after
// the batched static draw so emitters depth-test against the statics now in
// the buffer (the statics-before-particles order). cellId/slice are unused
// by the particle pass — pass NoClipSlice + the union owner list. This also
// drops the per-cell BuildDrawList allocations (N → 1).
if (_allCellStatics.Count > 0)
ctx.DrawCellParticles?.Invoke(
passes.DrawCellParticles(
ctx,
new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics));
}
@ -960,7 +825,7 @@ public sealed class RetailPViewRenderer
Vector3 sphereCenter,
float sphereRadius,
HashSet<uint> drawableCells,
Func<uint, LoadedCell?> cellLookup)
IRetailPViewCellSource cells)
{
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true;
@ -968,7 +833,7 @@ public sealed class RetailPViewRenderer
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);
var cell = cells.Find(cellId);
if (cell is null)
return false;
@ -998,20 +863,6 @@ public sealed class RetailPViewRenderer
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)
@ -1023,80 +874,65 @@ public sealed class RetailPViewRenderer
return NoClipSlices;
}
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 PrepareClipFrame(
Action<TerrainClipBufferBinding> setTerrainClipUbo,
int terrainUploadCount)
{
// Allocate every terrain record before issuing the first draw. BufferData
// therefore never replaces the arena's backing store while an earlier
// slice can still reference it. The large region table is immutable for
// this assembled PView frame and is uploaded exactly once.
_clipFrame.ReserveTerrainUploads(_gl, terrainUploadCount);
_clipFrame.UploadRegions(_gl);
_entities.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCells.SetClipRegionSsbo(_clipFrame.RegionSsbo);
UploadTerrainClip(setTerrainClipUbo);
}
private void UploadTerrainClip(Action<TerrainClipBufferBinding> setTerrainClipUbo)
{
TerrainClipBufferBinding binding = _clipFrame.UploadTerrainClip(_gl);
setTerrainClipUbo(binding);
}
}
public interface IRetailPViewCellDrawCallbacks
public interface IRetailPViewCellSource
{
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; }
LoadedCell? Find(uint cellId);
}
public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
/// <summary>
/// Typed execution seam for the GL passes ordered by
/// <see cref="RetailPViewRenderer"/>. Implementations execute the requested
/// pass only; visibility construction and draw ordering remain renderer-owned.
/// All frame inputs and results are borrowed for the duration of the call.
/// </summary>
public interface IRetailPViewPassExecutor
{
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; }
void BeginFrame();
ClipFrameAssembly AssembleClipFrame(
PortalVisibilityFrame portalFrame,
ClipFrameAssembly reuseAssembly);
void PrepareClipFrame(int terrainUploadCount);
void SetTerrainClip(ReadOnlySpan<Vector4> planes);
void ClearClipRouting();
void UseIndoorMembershipOnlyRouting();
void PrepareCellBatches(
RetailPViewFrameInput frame,
HashSet<uint> visibleCellIds);
void DrawOpaqueCellShells(HashSet<uint> cellIds);
bool CellHasTransparentShell(uint cellId);
void DrawTransparentCellShells(HashSet<uint> cellIds);
void DrawTransparentCellShellsOrdered(IReadOnlyList<uint> cellIds);
void DrawEntityBucket(
RetailPViewFrameInput frame,
IReadOnlyList<WorldEntity> entities,
HashSet<uint>? visibleCellIds);
void EmitClipRouteProbe(
ClipFrameAssembly clipAssembly,
ClipViewSlice slice,
int sliceIndex);
void EmitOutStageOwner(
WorldEntity entity,
Vector3 sphereCenter,
float sphereRadius,
int sliceIndex,
bool passed);
void EmitOutStageRouting(
int sliceIndex,
IReadOnlyList<WorldEntity> entities,
ViewconeCuller viewcone);
void EmitPhantomObjects(uint cellId, int survivorCount);
void DrawLandscapeSlice(RetailPViewFrameInput frame, RetailPViewLandscapeSliceContext context);
void DrawLandscapeSliceLate(RetailPViewFrameInput frame, RetailPViewLandscapeLateSliceContext context);
void ClearInteriorDepth();
void DrawExitPortalMask(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawLookInPortalPunch(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawUnattachedSceneParticles(RetailPViewFrameInput frame);
void FlushLandscapeAlpha();
void DrawCellParticles(RetailPViewFrameInput frame, RetailPViewCellSliceContext context);
void DrawDynamicsParticles(RetailPViewFrameInput frame, IReadOnlyList<WorldEntity> survivors);
void EmitDiagnostics(RetailPViewFrameInput frame, RetailPViewFrameResult result);
}
/// <summary>
@ -1261,7 +1097,7 @@ internal sealed class BuildingGroupScratch
}
}
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public sealed class RetailPViewFrameInput
{
public required LoadedCell RootCell { get; init; }
@ -1271,7 +1107,7 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public required Vector3 ViewerEyePos { get; init; }
public required Matrix4x4 ViewProjection { get; init; }
public required Func<uint, LoadedCell?> CellLookup { get; init; }
public required IRetailPViewCellSource Cells { get; init; }
public required ICamera Camera { get; init; }
public required Vector3 CameraWorldPosition { get; init; }
public required FrustumPlanes? Frustum { get; init; }
@ -1283,37 +1119,20 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
public required IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries { get; init; }
public required Action<TerrainClipBufferBinding> 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; }
/// <summary>Drains retail's delayed landscape alpha list before the
/// optional outside-to-inside depth clear. The same frame-scoped queue
/// remains open for the final world alpha scope.</summary>
public Action? FlushLandscapeAlpha { get; init; }
public Action<IReadOnlyList<WorldEntity>>? DrawDynamicsParticles { get; init; }
/// <summary>
/// Synchronous observation of the borrowed current-frame result. The
/// callback must copy anything it needs to retain beyond this invocation.
/// </summary>
public Action<RetailPViewFrameResult>? EmitDiagnostics { get; init; }
// Pass-presentation and diagnostic values consumed synchronously by the
// typed executor. This input is data-only and is never retained.
public required bool RenderSky { get; init; }
public required bool RenderWeather { get; init; }
public required float DayFraction { get; init; }
public required DayGroupData? ActiveDayGroup { get; init; }
public required SkyKeyframe SkyKeyframe { get; init; }
public required bool EnvironOverrideActive { get; init; }
public required uint ViewerCellId { get; init; }
public required uint PlayerCellId { get; init; }
public required Vector3 PlayerViewPosition { get; init; }
public required Matrix4x4 CameraView { get; init; }
public required CameraCellResolution CameraCellResolution { get; init; }
}
/// <summary>

View file

@ -0,0 +1,148 @@
using System.Diagnostics;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.App.Rendering.Wb;
namespace AcDream.App.Rendering;
internal readonly record struct FramePipelineDiagnosticFacts(
LandblockPresentationDiagnostics Presentation,
RollingTimingPercentiles UploadTiming,
int EntitiesWalked,
int DeferredApplyBacklog,
int FullWindowRetirementCount,
int LastFullWindowRetirementLandblockCount,
int MaterializedEntityCount,
int SpawnSnapshotCount,
int ResidentEntityCount);
internal interface IFramePipelineDiagnosticFactsSource
{
TerrainRenderDiagnosticFacts CaptureTerrain();
FramePipelineDiagnosticFacts CaptureFrame();
}
/// <summary>
/// Samples the existing canonical runtime owners only when the five-second
/// frame diagnostic cadence publishes. It owns no world or render resource.
/// </summary>
internal sealed class RuntimeFramePipelineDiagnosticFactsSource :
IFramePipelineDiagnosticFactsSource
{
private readonly TerrainModernRenderer? _terrain;
private readonly LandblockPresentationPipeline? _presentation;
private readonly RuntimeRenderFrameLivePreparation? _livePreparation;
private readonly WbDrawDispatcher? _dispatcher;
private readonly StreamingController? _streaming;
private readonly LiveEntityRuntime _liveEntities;
private readonly GpuWorldState _world;
public RuntimeFramePipelineDiagnosticFactsSource(
TerrainModernRenderer? terrain,
LandblockPresentationPipeline? presentation,
RuntimeRenderFrameLivePreparation? livePreparation,
WbDrawDispatcher? dispatcher,
StreamingController? streaming,
LiveEntityRuntime liveEntities,
GpuWorldState world)
{
_terrain = terrain;
_presentation = presentation;
_livePreparation = livePreparation;
_dispatcher = dispatcher;
_streaming = streaming;
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_world = world ?? throw new ArgumentNullException(nameof(world));
}
public TerrainRenderDiagnosticFacts CaptureTerrain() => new(
_terrain?.VisibleSlots ?? 0,
_terrain?.LoadedSlots ?? 0,
_terrain?.CapacitySlots ?? 0);
public FramePipelineDiagnosticFacts CaptureFrame() => new(
_presentation?.Diagnostics ?? default,
_livePreparation?.UploadTiming ?? default,
_dispatcher?.LastDrawStats.EntitiesWalked ?? 0,
_streaming?.DeferredApplyBacklog ?? 0,
_streaming?.FullWindowRetirementCount ?? 0,
_streaming?.LastFullWindowRetirementLandblockCount ?? 0,
_liveEntities.MaterializedWorldEntities.Count,
_liveEntities.Snapshots.Count,
_world.Entities.Count);
}
/// <summary>
/// Keeps the terrain timing sample and the frame-pipeline rollup on one
/// failure-atomic publication cadence. The owner is shared by fallback and
/// PView terrain passes, so extraction cannot silently split their telemetry.
/// </summary>
internal sealed class TerrainDrawDiagnosticsController
{
internal const long PublicationIntervalMilliseconds = 5_000;
private readonly bool _enabled;
private readonly WorldRenderDiagnostics _world;
private readonly IFramePipelineDiagnosticFactsSource _facts;
private readonly IRenderFrameDiagnosticLog _log;
private long _lastPublicationMilliseconds;
public TerrainDrawDiagnosticsController(
bool enabled,
WorldRenderDiagnostics world,
IFramePipelineDiagnosticFactsSource facts,
IRenderFrameDiagnosticLog log)
{
_enabled = enabled;
_world = world ?? throw new ArgumentNullException(nameof(world));
_facts = facts ?? throw new ArgumentNullException(nameof(facts));
_log = log ?? throw new ArgumentNullException(nameof(log));
}
public void Begin() => _world.BeginTerrainDraw();
public void Complete() => Complete(_enabled ? Environment.TickCount64 : 0L);
internal void Complete(long nowMilliseconds)
{
_world.EndTerrainDraw();
if (!_enabled
|| nowMilliseconds - _lastPublicationMilliseconds <= PublicationIntervalMilliseconds)
{
return;
}
// Both writes are one diagnostic transaction. Any failure leaves the
// cadence uncommitted so a later terrain draw retries both publications.
_world.PublishTerrainDiagnostics(_facts.CaptureTerrain());
_log.WriteLine(FormatFrame(_facts.CaptureFrame()));
_lastPublicationMilliseconds = nowMilliseconds;
}
internal static string FormatFrame(FramePipelineDiagnosticFacts facts)
{
LandblockRenderPublisherDiagnostics render = facts.Presentation.Render;
LandblockPhysicsPublisherDiagnostics physics = facts.Presentation.Physics;
LandblockStaticPresentationDiagnostics statics = facts.Presentation.Statics;
double ticksToMicros = 1_000_000.0 / Stopwatch.Frequency;
double uploadMedian = facts.UploadTiming.MedianHundredthsMicroseconds / 100.0;
double uploadP95 = facts.UploadTiming.Percentile95HundredthsMicroseconds / 100.0;
return
$"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} "
+ $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} "
+ $"begin={render.BeginPublishTicks * ticksToMicros:F1} "
+ $"complete={render.CompletePublishTicks * ticksToMicros:F1}] "
+ $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} "
+ $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} "
+ $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] "
+ $"static={statics.BeginCount}/{statics.CompleteCount}"
+ $"(active={statics.ActiveEntityCount}) "
+ $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 "
+ $"deferred={facts.DeferredApplyBacklog} "
+ $"fullRetire={facts.FullWindowRetirementCount}"
+ $"(landblocks={facts.LastFullWindowRetirementLandblockCount}) "
+ $"esg={facts.MaterializedEntityCount} spawn={facts.SpawnSnapshotCount} "
+ $"resident={facts.ResidentEntityCount} walked={facts.EntitiesWalked}";
}
}

View file

@ -2,6 +2,7 @@ using System.Numerics;
using System.Diagnostics;
using System.Text;
using AcDream.Core.Vfx;
using AcDream.Core.World;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
@ -123,6 +124,12 @@ internal sealed class WorldRenderDiagnostics
private string? _lastScissorSignature;
private long _scissorSequence;
private string? _lastOutStageSignature;
private string? _lastOutStageRoutingSignature;
private readonly Dictionary<uint, bool> _outStageOwnerVerdicts = [];
private readonly Dictionary<uint, int> _phantomObjectSignatures = [];
private string? _lastClipRouteSignature;
private long _clipRouteSequence;
private readonly List<uint> _clipRouteCellKeys = [];
public WorldRenderDiagnostics(
IRenderGlStateReader gl,
@ -192,6 +199,177 @@ internal sealed class WorldRenderDiagnostics
_log.WriteLine($"[clip-route-scis] n={_scissorSequence} {signature}");
}
public void EmitClipRouteProbe(
bool enabled,
ClipFrame clipFrame,
ClipFrameAssembly clipAssembly,
ClipViewSlice slice,
int sliceIndex)
{
if (!enabled)
return;
var text = new StringBuilder(256);
text.Append(FormattableString.Invariant(
$"slice={sliceIndex}/{clipAssembly.OutsideViewSlices.Length} slot={slice.Slot}"));
text.Append(FormattableString.Invariant(
$" ndc=({slice.NdcAabb.X:F3},{slice.NdcAabb.Y:F3},{slice.NdcAabb.Z:F3},{slice.NdcAabb.W:F3})"));
text.Append(FormattableString.Invariant($" planes={slice.Planes.Length}["));
for (int i = 0; i < slice.Planes.Length; i++)
{
Vector4 plane = slice.Planes[i];
if (i > 0)
text.Append(' ');
text.Append(FormattableString.Invariant(
$"({plane.X:F3},{plane.Y:F3},{plane.Z:F3},{plane.W:F3})"));
}
text.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)
text.Append(',');
text.Append(FormattableString.Invariant(
$"0x{_clipRouteCellKeys[i]:X8}:{clipAssembly.CellIdToSlot[_clipRouteCellKeys[i]]}"));
}
text.Append('}');
ReadOnlySpan<byte> regionBytes = clipFrame.RegionBytesForTest;
int offset = slice.Slot * ClipFrame.CellClipStrideBytes;
if (offset >= 0 && offset + ClipFrame.CellClipStrideBytes <= regionBytes.Length)
{
uint count = BitConverter.ToUInt32(regionBytes.Slice(offset, 4));
text.Append(FormattableString.Invariant($" ssbo[{slice.Slot}]: n={count}"));
int planeCount = (int)Math.Min(count, (uint)ClipFrame.MaxPlanes);
for (int i = 0; i < planeCount; i++)
{
int planeOffset = offset + ClipFrame.CellClipPlanesOffset + i * 16;
float x = BitConverter.ToSingle(regionBytes.Slice(planeOffset, 4));
float y = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 4, 4));
float z = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 8, 4));
float w = BitConverter.ToSingle(regionBytes.Slice(planeOffset + 12, 4));
text.Append(FormattableString.Invariant($" ({x:F3},{y:F3},{z:F3},{w:F3})"));
}
}
else
{
text.Append(FormattableString.Invariant(
$" ssbo[{slice.Slot}]: OUT-OF-RANGE len={regionBytes.Length}"));
}
ReadOnlySpan<byte> terrainBytes = clipFrame.TerrainBytesForTest;
int terrainCount = BitConverter.ToInt32(terrainBytes[..4]);
float p0 = BitConverter.ToSingle(terrainBytes.Slice(16, 4));
float p1 = BitConverter.ToSingle(terrainBytes.Slice(20, 4));
float p2 = BitConverter.ToSingle(terrainBytes.Slice(24, 4));
float p3 = BitConverter.ToSingle(terrainBytes.Slice(28, 4));
text.Append(FormattableString.Invariant(
$" ubo: n={terrainCount} p0=({p0:F3},{p1:F3},{p2:F3},{p3:F3})"));
string signature = text.ToString();
_clipRouteSequence++;
if (signature == _lastClipRouteSignature)
return;
_lastClipRouteSignature = signature;
_log.WriteLine($"[clip-route] n={_clipRouteSequence} {signature}");
}
public void EmitOutStageOwner(
bool enabled,
IReadOnlySet<uint> watchedEntityIds,
WorldEntity entity,
Vector3 sphereCenter,
float sphereRadius,
int sliceIndex,
bool passed)
{
if (!enabled
|| !watchedEntityIds.Contains(entity.Id)
|| (_outStageOwnerVerdicts.TryGetValue(entity.Id, out bool previous)
&& previous == passed))
{
return;
}
_outStageOwnerVerdicts[entity.Id] = passed;
_log.WriteLine(FormattableString.Invariant(
$"[outstage-own] id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) c=({sphereCenter.X:F1},{sphereCenter.Y:F1},{sphereCenter.Z:F1}) r={sphereRadius:F1} slice={sliceIndex} {(passed ? "PASS" : "CULL")}"));
}
public void EmitOutStageRouting(
bool enabled,
int sliceIndex,
IReadOnlyList<WorldEntity> entities,
ViewconeCuller viewcone)
{
if (!enabled)
return;
var text = new StringBuilder(192);
text.Append("slice=").Append(sliceIndex)
.Append(" outStage=").Append(entities.Count).Append(" [");
for (int i = 0; i < entities.Count; i++)
{
WorldEntity entity = entities[i];
EntitySphere(entity, out Vector3 center, out float radius);
bool passed = viewcone.SphereVisibleInOutsideSlice(
sliceIndex,
center,
radius);
if (i > 0)
text.Append(' ');
text.Append(FormattableString.Invariant(
$"0x{(entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id):X8}(s{entity.SourceGfxObjOrSetupId:X8}):{(passed ? "PASS" : "CULL")}:r={radius:F1}"));
}
text.Append(']');
string signature = text.ToString();
if (signature == _lastOutStageRoutingSignature)
return;
_lastOutStageRoutingSignature = signature;
_log.WriteLine("[outstage] " + signature);
}
public void EmitPhantomObjects(bool enabled, uint cellId, int survivorCount)
{
if (!enabled
|| (_phantomObjectSignatures.TryGetValue(cellId, out int previous)
&& previous == survivorCount))
{
return;
}
_phantomObjectSignatures[cellId] = survivorCount;
_log.WriteLine(
$"[phantom-objs] cell=0x{cellId:X8} entities={survivorCount} (drawn unclipped, no viewcone)");
}
public void EmitSeamMask(
bool enabled,
IReadOnlySet<uint> targetCells,
uint cellId,
int portalIndex,
bool forceFarZ,
ReadOnlySpan<Vector3> vertices)
{
if (!enabled || !targetCells.Contains(cellId))
return;
float minimumZ = float.MaxValue;
float maximumZ = float.MinValue;
foreach (Vector3 vertex in vertices)
{
minimumZ = Math.Min(minimumZ, vertex.Z);
maximumZ = Math.Max(maximumZ, vertex.Z);
}
_log.WriteLine(FormattableString.Invariant(
$"[seam-mask] t={Environment.TickCount64} cell=0x{cellId:X8} portal={portalIndex} far={forceFarZ} n={vertices.Length} z=[{minimumZ:F3},{maximumZ:F3}]"));
}
public void EmitPViewInput(
bool enabled,
PortalVisibilityFrame portalFrame,
@ -590,4 +768,15 @@ internal sealed class WorldRenderDiagnostics
.Append(" live=").Append(partition.Dynamics.Count)
.ToString();
}
private static void EntitySphere(
WorldEntity entity,
out Vector3 center,
out float radius)
{
if (entity.AabbDirty)
entity.RefreshAabb();
center = (entity.AabbMin + entity.AabbMax) * 0.5f;
radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
}
}