feat(streaming): worker pre-reads ApplyLoadedTerrain dats into the bundle
BuildPhysicsDatBundle mirrors the apply's six Get<T> sites (LandBlockInfo, EnvCell, Environment, building Setup, entity GfxObj, entity Setup) under the worker's existing _datLock and attaches them to LoadedLandblock. Far tier gets PhysicsDatBundle.Empty. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a0e349c6e
commit
4a99b55a73
1 changed files with 233 additions and 2 deletions
|
|
@ -81,6 +81,47 @@ public sealed class GameWindow : IDisposable
|
|||
private int _terrainCpuSampleCursor;
|
||||
private long _terrainLastDiagTick;
|
||||
|
||||
// [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive.
|
||||
// The crux this measures: terrain APPLY runs in OnUpdate (under _datLock —
|
||||
// dat reads + physics/ShadowObjects/BSP registration + the terrain GPU
|
||||
// upload), while the DRAW + the title-bar ms run in OnRender, so a heavy
|
||||
// apply stalls the loop turn yet is INVISIBLE to the render-only ms. We
|
||||
// accumulate apply cost across each OnUpdate and flush it next to the
|
||||
// [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring,
|
||||
// reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the
|
||||
// probes stripped in 92e95be). Gated once at construction — zero cost off.
|
||||
private readonly bool _frameDiag = string.Equals(
|
||||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal);
|
||||
private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate
|
||||
private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate
|
||||
private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate
|
||||
private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window
|
||||
private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update
|
||||
private int _applyCpuSampleCursor;
|
||||
private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update
|
||||
private int _applyUploadSampleCursor;
|
||||
private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render
|
||||
private int _entityUploadSampleCursor;
|
||||
// Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms
|
||||
// apply dominates): cell-build (EnvCell dat reads + physics surfaces +
|
||||
// buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights
|
||||
// registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked.
|
||||
private long _applyCellAccumTicks;
|
||||
private long _applyBspAccumTicks;
|
||||
private long _applyShadowAccumTicks;
|
||||
private readonly long[] _applyCellSamples = new long[256];
|
||||
private int _applyCellSampleCursor;
|
||||
private readonly long[] _applyBspSamples = new long[256];
|
||||
private int _applyBspSampleCursor;
|
||||
private readonly long[] _applyShadowSamples = new long[256];
|
||||
private int _applyShadowSampleCursor;
|
||||
// Direct measure of the _datLock acquisition wait (the streaming worker holds
|
||||
// the lock for its full per-landblock build, stalling this apply). Confirms the
|
||||
// apply≫sub-span gap is lock contention, not in-lock work.
|
||||
private long _applyLockWaitAccumTicks;
|
||||
private readonly long[] _applyLockWaitSamples = new long[256];
|
||||
private int _applyLockWaitSampleCursor;
|
||||
|
||||
// Phase A.1: streaming fields replacing the one-shot _entities list.
|
||||
private AcDream.App.Streaming.LandblockStreamer? _streamer;
|
||||
private AcDream.App.Streaming.GpuWorldState _worldState = new();
|
||||
|
|
@ -5896,7 +5937,8 @@ public sealed class GameWindow : IDisposable
|
|||
return new AcDream.Core.World.LoadedLandblock(
|
||||
landblockId,
|
||||
heightmapOnly,
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>());
|
||||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||||
AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities
|
||||
}
|
||||
|
||||
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
|
||||
|
|
@ -5975,10 +6017,91 @@ public sealed class GameWindow : IDisposable
|
|||
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
|
||||
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
|
||||
|
||||
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
|
||||
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
||||
// `merged` so entity GfxObj/Setup ids are known.
|
||||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||||
return new AcDream.Core.World.LoadedLandblock(
|
||||
baseLoaded.LandblockId,
|
||||
baseLoaded.Heightmap,
|
||||
merged);
|
||||
merged,
|
||||
physicsDats);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
||||
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
||||
/// <c>DatCollection</c> calls and the update thread never blocks on the
|
||||
/// worker's lock. MIRRORS the apply's six read sites — gather and apply must
|
||||
/// enumerate the same ids. Reads only; no build / registration.
|
||||
/// </summary>
|
||||
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
|
||||
uint landblockId,
|
||||
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
|
||||
{
|
||||
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
|
||||
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
|
||||
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
|
||||
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
|
||||
|
||||
// (1) LandBlockInfo
|
||||
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
|
||||
if (lbInfo is not null)
|
||||
{
|
||||
// (2)+(3) EnvCell + Environment, per cell
|
||||
if (lbInfo.NumCells > 0)
|
||||
{
|
||||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||||
{
|
||||
uint envCellId = firstCellId + offset;
|
||||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||||
if (envCell is null) continue;
|
||||
envCells[envCellId] = envCell;
|
||||
if (envCell.EnvironmentId == 0) continue;
|
||||
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||||
if (!environments.ContainsKey(envId))
|
||||
{
|
||||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||||
if (environment is not null) environments[envId] = environment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (4) building shell Setup, per building
|
||||
foreach (var building in lbInfo.Buildings)
|
||||
{
|
||||
uint modelId = building.ModelId;
|
||||
if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
|
||||
{
|
||||
var bldSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
|
||||
if (bldSetup is not null) setups[modelId] = bldSetup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
uint src = entity.SourceGfxObjOrSetupId;
|
||||
if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
|
||||
{
|
||||
var s = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||||
if (s is not null) setups[src] = s;
|
||||
}
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
{
|
||||
uint gid = meshRef.GfxObjId;
|
||||
if ((gid & 0xFF000000u) != 0x01000000u) continue;
|
||||
if (gfxObjs.ContainsKey(gid)) continue;
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(gid);
|
||||
if (gfx is not null) gfxObjs[gid] = gfx;
|
||||
}
|
||||
}
|
||||
|
||||
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -6493,10 +6616,22 @@ public sealed class GameWindow : IDisposable
|
|||
// Hold the lock across the entity hydration below (GfxObj sub-mesh
|
||||
// builds). The terrain mesh is pre-built by the worker (T12) and passed
|
||||
// in via meshData, so LandblockMesh.Build no longer runs under this lock.
|
||||
// [FRAME-DIAG]: time the full locked apply (dat reads + physics/registry +
|
||||
// the terrain GPU upload) — this is the OnUpdate cost the title-bar ms misses.
|
||||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
lock (_datLock)
|
||||
{
|
||||
// [FRAME-DIAG]: time blocked acquiring _datLock — the streaming worker
|
||||
// holds it for the full per-landblock build (tens of ms), stalling us.
|
||||
if (_frameDiag)
|
||||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
ApplyLoadedTerrainLocked(lb, meshData);
|
||||
}
|
||||
if (_frameDiag)
|
||||
{
|
||||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||||
_appliesThisUpdate++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -6667,7 +6802,16 @@ public sealed class GameWindow : IDisposable
|
|||
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
|
||||
// two-tier entry point. Delegates to AddLandblock internally; both
|
||||
// paths share one GPU upload path.
|
||||
// [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell
|
||||
// the (tiny ~17KB) GPU upload apart from the dat-read/registration tail.
|
||||
long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
|
||||
if (_frameDiag)
|
||||
_applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0;
|
||||
|
||||
// [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via
|
||||
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
|
||||
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
|
||||
// Step 4: drain pending LoadedCells from the worker thread.
|
||||
// Also collect into a local dict for the BuildingLoader stamping pass below.
|
||||
|
|
@ -6916,6 +7060,8 @@ public sealed class GameWindow : IDisposable
|
|||
// (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
|
||||
// Cache GfxObj physics data (BSP trees) for the physics engine — this
|
||||
// loop is physics-only, not renderer-side.
|
||||
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
|
||||
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyCellAccumTicks += t - fdCheck; fdCheck = t; }
|
||||
foreach (var entity in lb.Entities)
|
||||
{
|
||||
foreach (var meshRef in entity.MeshRefs)
|
||||
|
|
@ -6930,6 +7076,8 @@ public sealed class GameWindow : IDisposable
|
|||
// The data is no longer consumed (WB handles EnvCell geometry through
|
||||
// its own pipeline), but the worker thread still populates this dict.
|
||||
_pendingCellMeshes.Clear();
|
||||
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
|
||||
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
|
||||
|
||||
// Task 7: register static entities into the ShadowObjectRegistry so the
|
||||
// Transition system can find and collide against them during movement.
|
||||
|
|
@ -7459,10 +7607,37 @@ public sealed class GameWindow : IDisposable
|
|||
_worldGameState.Add(snapshot);
|
||||
_worldEvents.FireEntitySpawned(snapshot);
|
||||
}
|
||||
// [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail).
|
||||
if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck;
|
||||
}
|
||||
|
||||
private void OnUpdate(double dt)
|
||||
{
|
||||
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
|
||||
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
|
||||
// boundary, independent of where in OnUpdate the applies landed) and reset.
|
||||
if (_frameDiag)
|
||||
{
|
||||
if (_appliesThisUpdate > 0)
|
||||
{
|
||||
FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks);
|
||||
FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks);
|
||||
FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks);
|
||||
FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks);
|
||||
FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks);
|
||||
FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks);
|
||||
if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate)
|
||||
_frameDiagMaxAppliesPerUpdate = _appliesThisUpdate;
|
||||
}
|
||||
_applyAccumTicks = 0;
|
||||
_applyUploadAccumTicks = 0;
|
||||
_applyCellAccumTicks = 0;
|
||||
_applyBspAccumTicks = 0;
|
||||
_applyShadowAccumTicks = 0;
|
||||
_applyLockWaitAccumTicks = 0;
|
||||
_appliesThisUpdate = 0;
|
||||
}
|
||||
|
||||
// Phase A.1: advance the streaming controller FIRST so the initial
|
||||
// landblocks are loaded into GpuWorldState before live-session
|
||||
// CreateObject events drain. The earlier order (live tick first,
|
||||
|
|
@ -8224,7 +8399,13 @@ public sealed class GameWindow : IDisposable
|
|||
// GL thread queue). Must happen before any draw work so that
|
||||
// resources uploaded this frame are available immediately.
|
||||
// No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
|
||||
// [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit
|
||||
// the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply.
|
||||
long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||||
_wbMeshAdapter?.Tick();
|
||||
if (_frameDiag)
|
||||
FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor,
|
||||
System.Diagnostics.Stopwatch.GetTimestamp() - fdE0);
|
||||
|
||||
// Phase D.2a — begin ImGui frame. Paired with the Render() call
|
||||
// after the scene draws (below). ImGuiController.Update()
|
||||
|
|
@ -13136,6 +13317,45 @@ public sealed class GameWindow : IDisposable
|
|||
$"visible={_terrain?.VisibleSlots ?? 0} " +
|
||||
$"loaded={_terrain?.LoadedSlots ?? 0} " +
|
||||
$"capacity={_terrain?.CapacitySlots ?? 0}");
|
||||
|
||||
// [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters.
|
||||
// apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects
|
||||
// registration + terrain upload) — INVISIBLE to the title-bar ms (H1).
|
||||
// upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny).
|
||||
// entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2).
|
||||
// For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3).
|
||||
double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0;
|
||||
double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0;
|
||||
double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0;
|
||||
double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0;
|
||||
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
|
||||
double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
|
||||
// apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names
|
||||
// WHICH part of the apply dominates (decides the fix shape).
|
||||
double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0;
|
||||
double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0;
|
||||
double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0;
|
||||
double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0;
|
||||
double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0;
|
||||
double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0;
|
||||
double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0;
|
||||
double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0;
|
||||
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
|
||||
Console.WriteLine(
|
||||
$"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " +
|
||||
$"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " +
|
||||
$"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " +
|
||||
$"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " +
|
||||
$"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " +
|
||||
$"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
|
||||
$"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " +
|
||||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
||||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={_lastSpawnByGuid.Count} " +
|
||||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
||||
|
||||
_terrainLastDiagTick = now;
|
||||
}
|
||||
|
||||
|
|
@ -13167,6 +13387,17 @@ public sealed class GameWindow : IDisposable
|
|||
return copy[copy.Length - 1 - offset];
|
||||
}
|
||||
|
||||
/// <summary>[FRAME-DIAG] helper: convert a <see cref="System.Diagnostics.Stopwatch"/>
|
||||
/// timestamp-tick delta to the microseconds×100 fixed-point used by the rolling
|
||||
/// sample rings and store it (zeros are ignored by the median/p95 helpers, so an
|
||||
/// idle frame's 0-sample doesn't dilute the non-zero cost distribution).</summary>
|
||||
private static void FrameDiagPush(long[] samples, ref int cursor, long ticks)
|
||||
{
|
||||
// µs×100 = ticks × 1e8 / Stopwatch.Frequency.
|
||||
samples[cursor] = (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency);
|
||||
cursor = (cursor + 1) % samples.Length;
|
||||
}
|
||||
|
||||
/// <summary>A.5 T22: parse a float environment variable, returning
|
||||
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
|
||||
private static float ParseEnvFloat(string name, float defaultValue)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue