feat(render): Phase A8 — indoor visibility + streaming fixes batch

Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:14:50 +02:00
parent e415bb3863
commit 5dc4140c11
38 changed files with 3965 additions and 277 deletions

View file

@ -171,6 +171,159 @@ public sealed class GameWindow : IDisposable
// around each RenderBuildingStencilMask call.
private AcDream.App.Rendering.IndoorCellStencilPipeline? _indoorStencilPipeline;
private void CollectVisiblePortalBuildings(
System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building> output,
int centerLbX,
int centerLbY,
int radius)
{
output.Clear();
foreach (var (landblockId, reg) in _buildingRegistries)
{
int lbX = (int)((landblockId >> 24) & 0xFFu);
int lbY = (int)((landblockId >> 16) & 0xFFu);
if (System.Math.Abs(lbX - centerLbX) > radius ||
System.Math.Abs(lbY - centerLbY) > radius)
{
continue;
}
foreach (var b in reg.All())
{
if (!b.HasPortalBounds)
continue;
// WB PortalRenderManager.GetVisibleBuildingPortals frustum-culls
// each building's portal AABB with ignoreNearPlane=true. That
// prevents a doorway/window clipped by the camera near plane from
// dropping out of the portal visibility list.
if (_envCellFrustum is not null &&
_envCellFrustum.TestBox(b.PortalBounds, ignoreNearPlane: true)
== AcDream.App.Rendering.Wb.FrustumTestResult.Outside)
{
continue;
}
output.Add(b);
}
}
}
private void RenderOutsideInAcdream(
System.Numerics.Matrix4x4 viewProj,
AcDream.App.Rendering.ICamera camera,
AcDream.App.Rendering.FrustumPlanes? frustum,
uint? playerLb,
System.Collections.Generic.HashSet<uint>? animatedIds,
System.Collections.Generic.IReadOnlyList<AcDream.App.Rendering.Wb.Building> visibleBuildings)
{
if (visibleBuildings.Count == 0)
return;
var gl = _gl!;
var visibleEnvCellIds = new System.Collections.Generic.HashSet<uint>();
foreach (var b in visibleBuildings)
{
foreach (var id in b.EnvCellIds)
visibleEnvCellIds.Add(id);
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
{
Console.WriteLine(
$"[outside-in] portals={visibleBuildings.Count} cells={visibleEnvCellIds.Count}");
}
// WB VisibilityManager.RenderOutsideIn, but fed by the same
// frustum-visible portal list prepared above instead of every loaded
// building. Terrain/scenery are already drawn by the caller; this pass
// opens portal silhouettes, repairs wall depth, then draws EnvCells
// through those silhouettes. WB's outside-in EnvCell render passes a
// null cell filter; the stencil/depth mask is the visibility gate.
gl.Enable(EnableCap.StencilTest);
gl.ClearStencil(0);
gl.Clear(ClearBufferMask.StencilBufferBit);
// Step 1: mark visible building portals where the exterior depth test
// says the portal surface is actually visible.
gl.Disable(EnableCap.CullFace);
gl.StencilFunc(StencilFunction.Always, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Replace);
gl.StencilMask(0xFFu);
gl.ColorMask(false, false, false, false);
gl.DepthMask(false);
gl.Enable(EnableCap.DepthTest);
gl.DepthFunc(DepthFunction.Less);
foreach (var b in visibleBuildings)
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
// Step 2: punch portal depth to the far plane.
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
gl.StencilMask(0x00u);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
foreach (var b in visibleBuildings)
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
// Step 3: depth-only repair for exterior walls that overlap portal
// silhouettes, matching WB's staticObjectManager depth repair pass.
// In acdream, the dispatcher can target just building shells here;
// walking the full OutdoorScenery set would reprocess every tree and
// outdoor static object only to write depth under portal masks.
gl.Enable(EnableCap.CullFace);
gl.FrontFace(FrontFaceDirection.CW);
gl.DepthFunc(DepthFunction.Less);
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleEnvCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.BuildingShells);
_a8PerfLastOutsideShellStats = _wbDrawDispatcher.LastDrawStats;
// Step 4/5: render EnvCells through the repaired stencil mask.
//
// WB EnvCellRenderManager owns BOTH cell geometry and EnvCell static
// objects. A8 split that in acdream: EnvCellRenderer owns only the
// CellStruct mesh, while static objects remain dispatcher WorldEntity
// records with ParentCellId. Mirror WB's combined manager by drawing
// the dispatcher IndoorPass through the same portal stencil and the
// same WB-derived visible cell set used to prepare EnvCellRenderer.
gl.StencilFunc(StencilFunction.Equal, 1, 0xFFu);
gl.StencilOp(StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
gl.StencilMask(0x00u);
gl.ColorMask(true, true, true, false);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.Disable(EnableCap.Blend);
_meshShader!.Use();
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, filter: null);
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, filter: null);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleEnvCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
_a8PerfLastOutsideIndoorStats = _wbDrawDispatcher.LastDrawStats;
gl.Disable(EnableCap.StencilTest);
gl.StencilMask(0xFFu);
gl.ColorMask(true, true, true, true);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.FrontFace(FrontFaceDirection.CW);
}
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@ -2281,23 +2434,27 @@ public sealed class GameWindow : IDisposable
// its value is the live WorldEntity we need to dispose.
RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false);
// Log every spawn that arrives so we can inventory what the server
// When requested, log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
// is the critical one — we can grep the log for "Nullified Statue
// of a Drudge" or similar to find a specific weenie by its
// in-game name.
string posStr = spawn.Position is { } sp
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
: "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
bool dumpLiveSpawns = _options.DumpLiveSpawns;
if (dumpLiveSpawns)
{
string posStr = spawn.Position is { } sp
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
: "no-pos";
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
int subPalCount = spawn.SubPalettes?.Count ?? 0;
Console.WriteLine(
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
}
_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(
spawn.Name,
@ -2308,7 +2465,9 @@ public sealed class GameWindow : IDisposable
// Target the statue specifically for full diagnostic dump: Name match
// is cheap and gives us exactly one entity's worth of log regardless
// of arrival order.
bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
bool isStatue = dumpLiveSpawns
&& spawn.Name is not null
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
if (isStatue)
{
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
@ -2388,8 +2547,9 @@ public sealed class GameWindow : IDisposable
if (setup is null)
{
_liveDropReasonSetupDatMissing++;
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
$"(guid=0x{spawn.Guid:X8})");
if (dumpLiveSpawns)
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
$"(guid=0x{spawn.Guid:X8})");
return;
}
@ -2627,7 +2787,9 @@ public sealed class GameWindow : IDisposable
// Second pass: resolve each affected part's Surface chain and
// build the Surface-id-keyed override map the renderer consumes.
bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
bool isStatueDiag = dumpLiveSpawns
&& spawn.Name is not null
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
for (int pi = 0; pi < parts.Count; pi++)
{
@ -2720,8 +2882,9 @@ public sealed class GameWindow : IDisposable
if (meshRefs.Count == 0)
{
_liveDropReasonNoMeshRefs++;
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
$"(guid=0x{spawn.Guid:X8})");
if (dumpLiveSpawns)
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
$"(guid=0x{spawn.Guid:X8})");
return;
}
if (dumpClothing)
@ -3008,7 +3171,7 @@ public sealed class GameWindow : IDisposable
// Dump a summary periodically so we can see drop breakdowns without
// waiting for a graceful shutdown.
if (_liveSpawnReceived % 20 == 0)
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
{
Console.WriteLine(
$"live: animated={_animatedEntities.Count} " +
@ -5173,6 +5336,7 @@ public sealed class GameWindow : IDisposable
Rotation = e.Rotation,
MeshRefs = meshRefs,
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
};
hydrated.Add(entity);
}
@ -6415,7 +6579,7 @@ public sealed class GameWindow : IDisposable
lbNoneCount++;
}
}
if (scTried > 0)
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
Console.WriteLine(
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " +
$"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})");
@ -6439,7 +6603,7 @@ public sealed class GameWindow : IDisposable
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
}
}
if (sceneryNoCache > 0)
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
{
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
@ -6965,8 +7129,56 @@ public sealed class GameWindow : IDisposable
private double _perfAccum;
private int _perfFrameCount;
private long _a8PerfLastLogTick;
private long _a8PerfFrames;
private long _a8PerfInsideFrames;
private long _a8PerfOutsideInFrames;
private long _a8PerfTickAnimTicks;
private long _a8PerfCollectTicks;
private long _a8PerfEnvPrepareTicks;
private long _a8PerfTerrainTicks;
private long _a8PerfStaticTicks;
private long _a8PerfOutsideInTicks;
private long _a8PerfLiveTicks;
private long _a8PerfInsideOutTicks;
private long _a8PerfInsideLiveTicks;
private int _a8PerfLastPortalBuildings;
private int _a8PerfMaxPortalBuildings;
private int _a8PerfLastPortalCells;
private int _a8PerfMaxPortalCells;
private int _a8PerfLastVisibleLandblocks;
private int _a8PerfLastTotalLandblocks;
private readonly System.Collections.Generic.HashSet<uint> _a8PerfCellScratch = new();
private const int A8PerfGpuRingDepth = 4;
private const int A8PerfGpuPassCount = 6;
private const int A8PerfGpuTerrain = 0;
private const int A8PerfGpuStatic = 1;
private const int A8PerfGpuOutsideIn = 2;
private const int A8PerfGpuLive = 3;
private const int A8PerfGpuInsideOut = 4;
private const int A8PerfGpuInsideLive = 5;
private readonly uint[] _a8PerfGpuQueries = new uint[A8PerfGpuRingDepth * A8PerfGpuPassCount];
private bool _a8PerfGpuQueriesInitialized;
private int _a8PerfGpuFrameIndex;
private readonly bool[] _a8PerfGpuIssued = new bool[A8PerfGpuRingDepth * A8PerfGpuPassCount];
private long _a8PerfTerrainGpuNs;
private long _a8PerfStaticGpuNs;
private long _a8PerfOutsideInGpuNs;
private long _a8PerfLiveGpuNs;
private long _a8PerfInsideOutGpuNs;
private long _a8PerfInsideLiveGpuNs;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastStaticStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastLiveStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideShellStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastOutsideIndoorStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideStats;
private AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats _a8PerfLastInsideLiveStats;
private void OnRender(double deltaSeconds)
{
bool a8Perf = A8PerfEnabled();
int a8GpuSlot = A8PerfBeginGpuFrame(a8Perf);
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
@ -6990,6 +7202,13 @@ public sealed class GameWindow : IDisposable
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// WB GameScene.cs:830-843 establishes CW as the frame-global
// front-face convention; per-batch CullMode changes only the culled
// side. A8 indoor/env-cell geometry and setup meshes share that
// convention, so keep the GL state aligned before any scene pass.
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
@ -7008,8 +7227,10 @@ public sealed class GameWindow : IDisposable
// Phase 6.4: advance per-entity animation playback before drawing
// so the renderer always sees the up-to-date per-part transforms.
long a8PerfStart = A8PerfStart(a8Perf);
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
A8PerfStop(a8Perf, ref _a8PerfTickAnimTicks, a8PerfStart);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
@ -7126,6 +7347,9 @@ public sealed class GameWindow : IDisposable
var camBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
var otherBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
var visiblePortalBuildings = new System.Collections.Generic.List<AcDream.App.Rendering.Wb.Building>();
System.Collections.Generic.HashSet<uint>? envCellPrepareFilter = null;
int visiblePortalCellCount = 0;
if (cameraInsideBuilding)
{
@ -7135,12 +7359,6 @@ public sealed class GameWindow : IDisposable
foreach (var b in reg.GetBuildingsContainingCell(visibility.CameraCell.CellId))
camBuildings.Add(b);
}
var camCellId = visibility!.CameraCell!.CellId;
foreach (var rr in _buildingRegistries.Values)
foreach (var b in rr.All())
if (!b.EnvCellIds.Contains(camCellId))
otherBuildings.Add(b);
}
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
@ -7249,11 +7467,67 @@ public sealed class GameWindow : IDisposable
playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
}
int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
// Phase A8: prepare EnvCellRenderer's per-frame visibility snapshot.
// Always called — cheap when no cells loaded, cheap when frustum culls all.
var envCellViewProj = camera.View * camera.Projection;
_envCellFrustum?.Update(envCellViewProj);
_envCellRenderer?.PrepareRenderBatches(envCellViewProj, camPos);
if (a8IndoorBranchEnabled)
{
a8PerfStart = A8PerfStart(a8Perf);
CollectVisiblePortalBuildings(
visiblePortalBuildings,
renderCenterLbX,
renderCenterLbY,
_nearRadius);
// WB VisibilityManager.PrepareVisibility builds the EnvCell
// set before EnvCellRenderManager.PrepareRenderBatches, then
// RenderOutsideIn calls Render(..., null) against that already-
// narrowed snapshot. Keep that two-stage shape: the stencil is
// the render gate, but the prepared workload remains limited
// to camera-building cells, runtime portal-visible cells, plus
// frustum-visible portal cells.
envCellPrepareFilter = new System.Collections.Generic.HashSet<uint>();
foreach (var b in camBuildings)
foreach (var id in b.EnvCellIds)
envCellPrepareFilter.Add(id);
if (visibility?.VisibleCellIds is not null)
foreach (var id in visibility.VisibleCellIds)
envCellPrepareFilter.Add(id);
foreach (var b in visiblePortalBuildings)
foreach (var id in b.EnvCellIds)
envCellPrepareFilter.Add(id);
visiblePortalCellCount = envCellPrepareFilter.Count;
if (a8Perf)
{
_a8PerfCellScratch.Clear();
foreach (var id in envCellPrepareFilter)
_a8PerfCellScratch.Add(id);
}
A8PerfStop(a8Perf, ref _a8PerfCollectTicks, a8PerfStart);
if (cameraInsideBuilding && visibility?.CameraCell is not null)
{
var camCellId = visibility.CameraCell.CellId;
foreach (var b in visiblePortalBuildings)
{
if (!b.EnvCellIds.Contains(camCellId))
otherBuildings.Add(b);
}
}
}
a8PerfStart = A8PerfStart(a8Perf);
_envCellRenderer?.PrepareRenderBatches(
envCellViewProj,
camPos,
envCellPrepareFilter,
centerLbX: renderCenterLbX,
centerLbY: renderCenterLbY,
renderRadius: _nearRadius);
A8PerfStop(a8Perf, ref _a8PerfEnvPrepareTicks, a8PerfStart);
// Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests
@ -7315,8 +7589,12 @@ public sealed class GameWindow : IDisposable
// The proper fix is to NOT draw terrain here when indoor; Step 4
// is the single, stencil-gated terrain pass.
_terrainCpuStopwatch.Restart();
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuTerrain);
if (!cameraInsideBuilding)
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfTerrainTicks, a8PerfStart);
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
@ -7357,10 +7635,14 @@ public sealed class GameWindow : IDisposable
// but aren't stencil-clipped.
if (cameraInsideBuilding)
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideOut);
RenderInsideOutAcdream(envCellViewProj, camPos, visibility!.CameraCell!,
camBuildings, otherBuildings,
camera, frustum, playerLb, animatedIds,
visibility?.VisibleCellIds);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfInsideOutTicks, a8PerfStart);
// Phase A8 fix (2026-05-28 visual-gate-#2 follow-up): LiveDynamic
// entities (player char, NPCs, dropped items, doors) were missing
@ -7372,19 +7654,63 @@ public sealed class GameWindow : IDisposable
// stencil + state restored to defaults at its cleanup block. Same
// shape as the outdoor branch's Draw(All) for the LiveDynamic
// subset only.
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuInsideLive);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
_a8PerfLastInsideLiveStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfInsideLiveTicks, a8PerfStart);
}
else
{
// N.5: WbDrawDispatcher is always non-null (modern path mandatory).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
if (a8IndoorBranchEnabled)
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuOutsideIn);
RenderOutsideInAcdream(envCellViewProj, camera, frustum, playerLb,
animatedIds, visiblePortalBuildings);
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfOutsideInTicks, a8PerfStart);
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuLive);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.LiveDynamic);
_a8PerfLastLiveStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfLiveTicks, a8PerfStart);
}
else
{
a8PerfStart = A8PerfStart(a8Perf);
A8PerfBeginGpuQuery(a8Perf, a8GpuSlot, A8PerfGpuStatic);
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
A8PerfEndGpuQuery(a8Perf, a8GpuSlot);
A8PerfStop(a8Perf, ref _a8PerfStaticTicks, a8PerfStart);
}
}
// Phase G.1 / E.3: draw all live particles after opaque
@ -7488,12 +7814,19 @@ public sealed class GameWindow : IDisposable
}
// Count visible vs total for the perf overlay.
foreach (var entry in _worldState.LandblockEntries)
foreach (var entry in _worldState.LandblockBounds)
{
totalLandblocks++;
if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax))
visibleLandblocks++;
}
MaybeFlushA8Perf(
a8Perf,
cameraInsideBuilding,
visiblePortalBuildings.Count,
visiblePortalCellCount,
visibleLandblocks,
totalLandblocks);
// Phase I.2: refresh per-frame fields that DebugVM closures
// can't compute lazily (frustum-derived counters + nearest-
@ -7547,6 +7880,8 @@ public sealed class GameWindow : IDisposable
// GL state it touches (blend, scissor, VAO, shader, texture); any
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
// today) would need manual protection.
A8PerfEndGpuFrame(a8Perf);
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
{
// Phase I.3 — prefer the live command bus when a live session
@ -10691,8 +11026,32 @@ public sealed class GameWindow : IDisposable
EmitBuildingsProbe(visibilityCellId: cameraCell.CellId, camBuildings, otherBuildings);
// Steps 1+2: stencil bit 1 + far-depth punch at camera-buildings' portals.
if (camBuildings.Count > 0)
bool diagDisableStep4Terrain = _options.A8DiagDisableInsideStep4Terrain;
bool diagDisableStep4Outdoor = _options.A8DiagDisableInsideStep4Outdoor;
bool diagDisableStep3EnvCellOpaque = _options.A8DiagDisableInsideStep3EnvCellOpaque;
bool diagDisableStep3IndoorPass = _options.A8DiagDisableInsideStep3IndoorPass;
bool diagDisableStep2Punch = _options.A8DiagDisableInsideStep2Punch;
bool diagDisablePortalDepthClamp = _options.A8DiagDisablePortalDepthClamp;
var visiblePortalCells = new System.Collections.Generic.List<AcDream.App.Rendering.LoadedCell>();
if (visibleCellIds is not null)
{
foreach (uint cellId in visibleCellIds)
{
if (_cellVisibility.TryGetCell(cellId, out var cell) && cell is not null)
visiblePortalCells.Add(cell);
}
}
int insidePortalVertexCount = visiblePortalCells.Count > 0
? _indoorStencilPipeline!.UploadPortalMesh(visiblePortalCells, camPos)
: 0;
// Steps 1+2: stencil bit 1 + far-depth punch at portal-visible exits only.
// WB builds its outside view from the current portal traversal; using every
// exit on the camera building over-punches terrain through indoor openings
// when an unrelated window/door portal overlaps them in screen space.
if (insidePortalVertexCount > 0)
{
didInsideStencil = true;
gl.Enable(EnableCap.StencilTest);
@ -10711,26 +11070,30 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 1, sub: ' ');
foreach (var b in camBuildings)
{
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: false);
EmitStencilProbe(op: "mark");
}
_indoorStencilPipeline!.DrawUploadedPortalMesh(
viewProj,
writeFarDepth: false,
enableDepthClamp: !diagDisablePortalDepthClamp);
EmitStencilProbe(op: "mark-visible");
// Step 2: punch depth at portals.
// WB VisibilityManager.cs:99-104
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 2, sub: ' ');
foreach (var b in camBuildings)
if (!diagDisableStep2Punch)
{
_indoorStencilPipeline!.RenderBuildingStencilMask(b, viewProj, writeFarDepth: true);
EmitStencilProbe(op: "punch");
// Step 2: punch depth at portals.
// WB VisibilityManager.cs:99-104
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Always);
EmitDrawOrderProbe(step: 2, sub: ' ');
_indoorStencilPipeline!.DrawUploadedPortalMesh(
viewProj,
writeFarDepth: true,
enableDepthClamp: !diagDisablePortalDepthClamp);
EmitStencilProbe(op: "punch-visible");
}
}
// Step 3: render camera-buildings' cells (stencil off, DepthFunc.Less).
// Step 3: render the indoor cells visible from the camera cell
// (stencil off, DepthFunc.Less).
// WB VisibilityManager.cs:107-127
gl.ColorMask(true, true, true, false);
gl.DepthMask(true);
@ -10744,12 +11107,27 @@ public sealed class GameWindow : IDisposable
{
foreach (var b in camBuildings)
foreach (var id in b.EnvCellIds) currentEnvCellIds.Add(id);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
// A8 cellar-flap provenance (2026-05-28): disabling Step 4
// terrain removes the green flap, proving terrain is the writer.
// The leak happens because indoor-to-indoor portal cells reached
// by the runtime visibility BFS can be outside the static
// BuildingInfo cell set. Render them in Step 3 so their depth
// blocks Step 4 terrain, while real exterior openings still show
// terrain through the portal mask.
if (visibleCellIds is not null)
foreach (var id in visibleCellIds)
currentEnvCellIds.Add(id);
gl.Disable(EnableCap.Blend);
if (!diagDisableStep3EnvCellOpaque)
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, currentEnvCellIds);
// Transparency pass.
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, currentEnvCellIds);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
}
// FIX 2026-05-28 (post-third-visual-gate): render IndoorPass entities.
@ -10764,20 +11142,22 @@ public sealed class GameWindow : IDisposable
// Result with the missing call: user reports "house missing lots of
// walls" — the cottage's exterior wall slabs aren't drawn.
//
// Render IndoorPass between Step 3 and Step 4, with the
// currentEnvCellIds filter narrowing cell stabs but NOT the building
// shells (they have no ParentCellId and pass through). Depth-test
// with DepthFunc.Less so cottage-A's near walls occlude cottage-B's
// far walls. NO stencil — we want them rendered unconditionally
// inside the camera-building.
if (camBuildings.Count > 0)
// Render IndoorPass between Step 3 and Step 4. The currentEnvCellIds
// filter now narrows both cell stabs and building shells: shells have
// no ParentCellId, but carry BuildingShellAnchorCellId from
// LandBlockInfo.Buildings[]. Depth-test with DepthFunc.Less so the
// current cottage shell occludes any farther geometry. NO stencil: we
// want the active building shell rendered unconditionally inside the
// camera-building.
if (camBuildings.Count > 0 && !diagDisableStep3IndoorPass)
{
_meshShader!.Use();
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: currentEnvCellIds,
animatedEntityIds: animatedIds,
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.IndoorPass);
_a8PerfLastInsideStats = _wbDrawDispatcher.LastDrawStats;
}
EmitEnvCellProbe(camBuildings.Count, otherBuildings.Count, currentEnvCellIds.Count);
@ -10794,31 +11174,52 @@ public sealed class GameWindow : IDisposable
gl.DepthMask(true);
gl.Enable(EnableCap.CullFace);
gl.DepthFunc(DepthFunction.Less);
EmitDrawOrderProbe(step: 4, sub: ' ');
// Terrain (WB line 143).
// acdream's retail/ACME terrain mesh is CCW from the visible top side
// (see terrain_modern.vert's LandblockMesh order comment), while WB's
// editor terrain uses the opposite vertex order under its global CW
// convention. Step 4 enables culling before terrain, so temporarily
// use terrain's own front-face convention or ground disappears through
// indoor portal silhouettes.
if (!diagDisableStep4Terrain)
{
gl.FrontFace(FrontFaceDirection.Ccw);
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
gl.FrontFace(FrontFaceDirection.CW);
}
else
{
gl.FrontFace(FrontFaceDirection.CW);
}
_meshShader!.Use();
// Scenery + static objects via dispatcher (WB lines 148-154).
if (!diagDisableStep4Outdoor)
{
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntriesWithoutAnimatedIndex, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleCellIds, // OK - outdoor cells outside the building
animatedEntityIds: null,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
_a8PerfLastStaticStats = _wbDrawDispatcher.LastDrawStats;
}
}
EmitDrawOrderProbe(step: 4, sub: ' ');
// Terrain (WB line 143).
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_meshShader!.Use();
// Scenery + static objects via dispatcher (WB lines 148-154).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibleCellIds, // OK — outdoor cells outside the building
animatedEntityIds: animatedIds,
set: AcDream.App.Rendering.Wb.WbDrawDispatcher.EntitySet.OutdoorScenery);
// Step 5: per-other-building 3-bit stencil pipeline.
// WB VisibilityManager.cs:157-232
//
// FIX 2026-05-28 (post-first-visual-gate): Step 5 is GATED OFF BY DEFAULT.
// First visual gate showed perf collapse + texture flicker indoors because
// Step 5 iterates EVERY loaded other-building per frame (109 at Holtburg),
// each doing 5 GL draws (mark/end-query/punch/render-opaque/render-trans/reset)
// = ~545 extra draws/frame with no frustum culling. The driver hit TDR
// limits. Set ACDREAM_A8_STEP5=1 to re-enable for cross-building visibility
// testing once we add per-building frustum culling.
bool step5Enabled = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1", StringComparison.Ordinal);
// GATED OFF BY DEFAULT. Current acdream does not yet have WB's
// portal-manager visibility/occlusion lifecycle; feeding this loop
// directly from all loaded building registries causes unrelated
// buildings' EnvCells to overwrite exterior walls through stale or
// over-broad portal masks. Keep the apparatus, but require an explicit
// opt-in until the portal list is proven equivalent to WB's
// _visibleBuildingPortals.
bool step5Enabled = string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_A8_STEP5"), "1",
StringComparison.Ordinal);
if (step5Enabled && didInsideStencil && otherBuildings.Count > 0)
{
gl.Enable(EnableCap.StencilTest);
@ -10861,10 +11262,14 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
_meshShader!.Use();
gl.Disable(EnableCap.Blend);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, b.EnvCellIds);
gl.Enable(EnableCap.Blend);
gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
gl.DepthMask(false);
_envCellRenderer!.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, b.EnvCellIds);
gl.DepthMask(true);
gl.Disable(EnableCap.Blend);
// Step 5d: reset bit 2 (Ref=1, Mask=0x02) for next iteration. WB VisibilityManager.cs:222-228
EmitDrawOrderProbe(step: 5, sub: 'd');
@ -10896,6 +11301,13 @@ public sealed class GameWindow : IDisposable
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
}
// If no visible exit portal was uploaded, Step 4 is skipped but Step 3
// still leaves alpha writes disabled. Restore the outer-frame defaults.
gl.ColorMask(true, true, true, true);
gl.DepthMask(true);
gl.DepthFunc(DepthFunction.Less);
gl.Enable(EnableCap.CullFace);
}
// ── Phase A8 Task 9 (2026-05-28): probe trail for RenderInsideOutAcdream ──
@ -10915,7 +11327,8 @@ public sealed class GameWindow : IDisposable
// - [buildings] camBldgs=[0x...] non-empty when inside a cottage
// - [envcells] cells>=1 tris>=1 filterCnt>=1 for at least one indoor frame
// - [stencil] op=mark verts>0 fires per camera-building
// - [draworder] shows steps 1 → 2 → 3 → 4 → 5{a,b,c,d} per indoor frame
// - [draworder] shows steps 1 → 2 → 3 → 4 per indoor frame
// (and 5{a,b,c,d} only when ACDREAM_A8_STEP5=1)
private int _phaseA8DrawOrderFrame = 0;
@ -10942,6 +11355,7 @@ public sealed class GameWindow : IDisposable
gl.GetBoolean(Silk.NET.OpenGL.GLEnum.DepthWritemask, out var depthMask);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFace, out int cullEnabled);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.CullFaceMode, out int cullMode);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.FrontFace, out int frontFace);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendSrc, out int blendSrc);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.BlendDst, out int blendDst);
gl.GetInteger(Silk.NET.OpenGL.GLEnum.StencilFunc, out int sFunc);
@ -10971,6 +11385,7 @@ public sealed class GameWindow : IDisposable
$"[draworder] frame={_phaseA8DrawOrderFrame} step={step}{subStr} " +
$"stencil={(stOn != 0 ? "on" : "off")} depthFn=0x{depthFn:X} depthMask={depthMask} " +
$"cull={(cullEnabled != 0 ? "on" : "off")}({(cullMode == (int)Silk.NET.OpenGL.GLEnum.Front ? "front" : cullMode == (int)Silk.NET.OpenGL.GLEnum.Back ? "back" : "f+b")}) " +
$"front={(frontFace == (int)Silk.NET.OpenGL.GLEnum.CW ? "cw" : "ccw")} " +
$"blend=0x{blendSrc:X}/0x{blendDst:X} " +
$"sFunc=0x{sFunc:X}:{sRef}:0x{sValMask:X} " +
$"sOp=0x{sFail:X}/0x{sPdFail:X}/0x{sPdPass:X} sMask=0x{sWriteMask:X} " +
@ -11035,6 +11450,152 @@ public sealed class GameWindow : IDisposable
/// rolling 256-sample buffer of microseconds, median + p95 reported.
/// Sample buffer is NOT cleared on flush — it's a moving window so the
/// next 5s window already has 256 frames of recent history.</summary>
private static bool A8PerfEnabled()
=> string.Equals(Environment.GetEnvironmentVariable("ACDREAM_A8_PERF"), "1", StringComparison.Ordinal);
private static long A8PerfStart(bool enabled)
=> enabled ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
private static void A8PerfStop(bool enabled, ref long bucket, long startTick)
{
if (!enabled || startTick == 0) return;
bucket += System.Diagnostics.Stopwatch.GetTimestamp() - startTick;
}
private int A8PerfBeginGpuFrame(bool enabled)
{
if (!enabled || _gl is null) return -1;
if (!_a8PerfGpuQueriesInitialized)
{
for (int i = 0; i < _a8PerfGpuQueries.Length; i++)
_a8PerfGpuQueries[i] = _gl.GenQuery();
_a8PerfGpuQueriesInitialized = true;
}
int slot = _a8PerfGpuFrameIndex % A8PerfGpuRingDepth;
if (_a8PerfGpuFrameIndex >= A8PerfGpuRingDepth)
{
for (int pass = 0; pass < A8PerfGpuPassCount; pass++)
{
int queryIndex = slot * A8PerfGpuPassCount + pass;
if (!_a8PerfGpuIssued[queryIndex]) continue;
uint query = _a8PerfGpuQueries[queryIndex];
_gl.GetQueryObject(query, QueryObjectParameterName.ResultAvailable, out int available);
if (available == 0) continue;
_gl.GetQueryObject(query, QueryObjectParameterName.Result, out ulong elapsedNs);
A8PerfAccumulateGpu(pass, elapsedNs);
_a8PerfGpuIssued[queryIndex] = false;
}
}
return slot;
}
private void A8PerfEndGpuFrame(bool enabled)
{
if (enabled && _a8PerfGpuQueriesInitialized)
_a8PerfGpuFrameIndex++;
}
private void A8PerfBeginGpuQuery(bool enabled, int slot, int pass)
{
if (!enabled || slot < 0 || _gl is null) return;
int queryIndex = slot * A8PerfGpuPassCount + pass;
_a8PerfGpuIssued[queryIndex] = true;
uint query = _a8PerfGpuQueries[queryIndex];
_gl.BeginQuery(QueryTarget.TimeElapsed, query);
}
private void A8PerfEndGpuQuery(bool enabled, int slot)
{
if (!enabled || slot < 0 || _gl is null) return;
_gl.EndQuery(QueryTarget.TimeElapsed);
}
private void A8PerfAccumulateGpu(int pass, ulong elapsedNs)
{
long ns = elapsedNs > long.MaxValue ? long.MaxValue : (long)elapsedNs;
switch (pass)
{
case A8PerfGpuTerrain: _a8PerfTerrainGpuNs += ns; break;
case A8PerfGpuStatic: _a8PerfStaticGpuNs += ns; break;
case A8PerfGpuOutsideIn: _a8PerfOutsideInGpuNs += ns; break;
case A8PerfGpuLive: _a8PerfLiveGpuNs += ns; break;
case A8PerfGpuInsideOut: _a8PerfInsideOutGpuNs += ns; break;
case A8PerfGpuInsideLive: _a8PerfInsideLiveGpuNs += ns; break;
}
}
private static string A8DrawStats(AcDream.App.Rendering.Wb.WbDrawDispatcher.DrawStats stats)
=> $"{stats.Draws}d/{stats.CullRuns}r/{stats.Instances}i/{stats.Triangles}t";
private void MaybeFlushA8Perf(
bool enabled,
bool cameraInsideBuilding,
int portalBuildings,
int portalCells,
int visibleLandblocks,
int totalLandblocks)
{
if (!enabled) return;
_a8PerfFrames++;
if (cameraInsideBuilding) _a8PerfInsideFrames++;
if (portalBuildings > 0) _a8PerfOutsideInFrames++;
_a8PerfLastPortalBuildings = portalBuildings;
_a8PerfMaxPortalBuildings = System.Math.Max(_a8PerfMaxPortalBuildings, portalBuildings);
_a8PerfLastPortalCells = portalCells;
_a8PerfMaxPortalCells = System.Math.Max(_a8PerfMaxPortalCells, portalCells);
_a8PerfLastVisibleLandblocks = visibleLandblocks;
_a8PerfLastTotalLandblocks = totalLandblocks;
long now = Environment.TickCount64;
if (now - _a8PerfLastLogTick <= 3000) return;
double Ms(long ticks) => _a8PerfFrames == 0
? 0.0
: ticks * 1000.0 / System.Diagnostics.Stopwatch.Frequency / _a8PerfFrames;
double GpuMs(long ns) => _a8PerfFrames == 0 ? 0.0 : ns / 1_000_000.0 / _a8PerfFrames;
Console.WriteLine(string.Create(System.Globalization.CultureInfo.InvariantCulture,
$"[A8-PERF] frames={_a8PerfFrames} inside={_a8PerfInsideFrames} outsideIn={_a8PerfOutsideInFrames} " +
$"portals={_a8PerfLastPortalBuildings}/{_a8PerfMaxPortalBuildings} cells={_a8PerfLastPortalCells}/{_a8PerfMaxPortalCells} " +
$"lb={_a8PerfLastVisibleLandblocks}/{_a8PerfLastTotalLandblocks} " +
$"avg_ms anim={Ms(_a8PerfTickAnimTicks):F3} collect={Ms(_a8PerfCollectTicks):F3} " +
$"envPrep={Ms(_a8PerfEnvPrepareTicks):F3} terrain={Ms(_a8PerfTerrainTicks):F3} " +
$"static={Ms(_a8PerfStaticTicks):F3} outsideIn={Ms(_a8PerfOutsideInTicks):F3} " +
$"live={Ms(_a8PerfLiveTicks):F3} insideOut={Ms(_a8PerfInsideOutTicks):F3} " +
$"insideLive={Ms(_a8PerfInsideLiveTicks):F3} " +
$"gpu_ms terrain={GpuMs(_a8PerfTerrainGpuNs):F3} static={GpuMs(_a8PerfStaticGpuNs):F3} " +
$"outsideIn={GpuMs(_a8PerfOutsideInGpuNs):F3} live={GpuMs(_a8PerfLiveGpuNs):F3} " +
$"insideOut={GpuMs(_a8PerfInsideOutGpuNs):F3} insideLive={GpuMs(_a8PerfInsideLiveGpuNs):F3} " +
$"draws static={A8DrawStats(_a8PerfLastStaticStats)} live={A8DrawStats(_a8PerfLastLiveStats)} " +
$"outsideShell={A8DrawStats(_a8PerfLastOutsideShellStats)} outsideIndoor={A8DrawStats(_a8PerfLastOutsideIndoorStats)}"));
_a8PerfFrames = 0;
_a8PerfInsideFrames = 0;
_a8PerfOutsideInFrames = 0;
_a8PerfTickAnimTicks = 0;
_a8PerfCollectTicks = 0;
_a8PerfEnvPrepareTicks = 0;
_a8PerfTerrainTicks = 0;
_a8PerfStaticTicks = 0;
_a8PerfOutsideInTicks = 0;
_a8PerfLiveTicks = 0;
_a8PerfInsideOutTicks = 0;
_a8PerfInsideLiveTicks = 0;
_a8PerfTerrainGpuNs = 0;
_a8PerfStaticGpuNs = 0;
_a8PerfOutsideInGpuNs = 0;
_a8PerfLiveGpuNs = 0;
_a8PerfInsideOutGpuNs = 0;
_a8PerfInsideLiveGpuNs = 0;
_a8PerfMaxPortalBuildings = portalBuildings;
_a8PerfMaxPortalCells = portalCells;
_a8PerfLastLogTick = now;
}
private void MaybeFlushTerrainDiag()
{
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))