fix #225: stabilize render pacing and frame CPU

Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal.

Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality.

Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 06:29:30 +02:00
parent 47d7086a74
commit 3718e341be
17 changed files with 1103 additions and 225 deletions

View file

@ -10,6 +10,9 @@ namespace AcDream.App.Rendering.Wb;
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
/// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by
/// retail's delayed-alpha viewer-distance ordering on cache hits.
/// <see cref="Group"/> is a registration-checked fast handle into the
/// dispatcher's bounded group table; a retired or synthetic entry falls back
/// to key lookup and refreshes this array entry.
///
/// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
@ -21,7 +24,19 @@ internal readonly record struct CachedBatch(
GroupKey Key,
ulong BindlessTextureHandle,
Matrix4x4 RestPose,
Vector3 LocalSortCenter = default);
Vector3 LocalSortCenter = default,
WbDrawDispatcher.InstanceGroup? Group = null,
long GroupRegistration = 0);
/// <summary>
/// Immutable retail-picking descriptor for one static entity part. The cache
/// stores only authored identity and rest pose; the current entity transform
/// and the selection system's live frustum/geometry verdict remain per-frame.
/// </summary>
internal readonly record struct CachedSelectionPart(
int PartIndex,
uint GfxObjId,
Matrix4x4 RestPose);
/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across
@ -39,4 +54,5 @@ internal sealed class EntityCacheEntry
public required uint EntityId { get; init; }
public required uint LandblockHint { get; init; }
public required CachedBatch[] Batches { get; init; }
public CachedSelectionPart[] SelectionParts { get; init; } = [];
}

View file

@ -76,13 +76,18 @@ internal sealed class EntityClassificationCache
/// <c>(<paramref name="entityId"/>, <paramref name="landblockHint"/>)</c>
/// tuple. Defensive: if an entry already exists, replaces it.
/// </summary>
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
public void Populate(
uint entityId,
uint landblockHint,
CachedBatch[] batches,
CachedSelectionPart[]? selectionParts = null)
{
_entries[(entityId, landblockHint)] = new EntityCacheEntry
{
EntityId = entityId,
LandblockHint = landblockHint,
Batches = batches,
SelectionParts = selectionParts ?? [],
};
}

View file

@ -354,6 +354,29 @@ namespace AcDream.App.Rendering.Wb
private readonly ManualResetEventSlim _preparationWorkAvailable = new(false);
private const int MaxParallelLoads = 4;
internal enum PreparationWorkerWakeAction
{
Process,
ResetAndWait,
Exit,
}
internal static PreparationWorkerWakeAction DecidePreparationWorkerWake(
bool isDisposed,
bool hasPendingRequests,
bool stagingAtHighWater,
bool arenaBackpressured)
{
// Shutdown has priority over every ordinary idle/backpressure state.
// Dispose sets one shared manual-reset signal for all persistent
// workers; no worker may reset that signal before its peers wake.
if (isDisposed)
return PreparationWorkerWakeAction.Exit;
if (!hasPendingRequests || stagingAtHighWater || arenaBackpressured)
return PreparationWorkerWakeAction.ResetAndWait;
return PreparationWorkerWakeAction.Process;
}
private sealed class ObjectReleaseTicket(
ulong id,
ObjectRenderData data,
@ -911,14 +934,20 @@ namespace AcDream.App.Rendering.Wb
{
// IsDisposed re-check lets Dispose cancel and join every
// tracked worker before the DAT mappings are released.
if (IsDisposed
|| _pendingRequests.Count == 0
|| _stagedMeshData.IsAtHighWater
|| _arenaBackpressured)
// Exit WITHOUT resetting the shared manual-reset event:
// Dispose sets it once to wake all four persistent workers.
// If the first worker reset it, the remaining three slept
// forever and graceful client shutdown deadlocked.
PreparationWorkerWakeAction wakeAction = DecidePreparationWorkerWake(
IsDisposed,
_pendingRequests.Count != 0,
_stagedMeshData.IsAtHighWater,
_arenaBackpressured);
if (wakeAction == PreparationWorkerWakeAction.Exit)
return;
if (wakeAction == PreparationWorkerWakeAction.ResetAndWait)
{
_preparationWorkAvailable.Reset();
if (IsDisposed)
return;
continue;
}

View file

@ -470,7 +470,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot.
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
private readonly int[] _currentEntityLightSetScratch = new int[LightManager.MaxLightsPerObject];
private InstanceLightSet _currentEntityLightSet = InstanceLightSet.Disabled;
// #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet,
// parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires
@ -517,7 +518,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private CullMode[] _drawCullModes = new CullMode[256];
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
private readonly Action<GroupKey, Matrix4x4, Vector3> _cacheHitAppender;
private readonly List<GroupKey> _retiredGroupKeys = new();
private long _nextGroupRegistration = 1;
private long _groupFrame;
private int _opaqueDrawCount;
private int _transparentDrawCount;
@ -541,15 +544,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
GroupKey Key,
Matrix4x4 Model,
uint ClipSlot,
AlphaLightSet Lights,
InstanceLightSet Lights,
uint Indoor,
float Opacity,
Vector2 SelectionLighting);
private readonly record struct AlphaLightSet(
internal readonly record struct InstanceLightSet(
int L0, int L1, int L2, int L3,
int L4, int L5, int L6, int L7)
{
public static InstanceLightSet Disabled { get; } = new(
-1, -1, -1, -1, -1, -1, -1, -1);
public static InstanceLightSet From(ReadOnlySpan<int> source)
{
if (source.Length < LightManager.MaxLightsPerObject)
throw new ArgumentException("A retail object-light set requires eight entries.", nameof(source));
return new InstanceLightSet(
source[0], source[1], source[2], source[3],
source[4], source[5], source[6], source[7]);
}
public void CopyTo(int[] destination, int offset)
{
destination[offset + 0] = L0;
destination[offset + 1] = L1;
destination[offset + 2] = L2;
destination[offset + 3] = L3;
destination[offset + 4] = L4;
destination[offset + 5] = L5;
destination[offset + 6] = L6;
destination[offset + 7] = L7;
}
public int this[int index] => index switch
{
0 => L0, 1 => L1, 2 => L2, 3 => L3,
@ -590,6 +618,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// are contiguous — accumulate them all before flushing one Populate call.
// Animated entities skip this scratch entirely (collector = null).
private readonly List<CachedBatch> _populateScratch = new();
private readonly List<CachedSelectionPart> _populateSelectionScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled.
@ -720,8 +749,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this);
_cacheHitAppender = AppendInstanceToGroup;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
}
@ -735,6 +762,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
_groupFrame++;
PruneInstanceGroupsUnusedBeforeFrame(
_groups,
_retiredGroupKeys,
_groupFrame - 1);
_dynamicFrameSlot = frameSlot;
_dynamicBufferSetCursor = 0;
_dynamicFrameStarted = true;
@ -1317,7 +1352,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ──────────────
foreach (var grp in _groups.Values) grp.ClearPerInstanceData();
// Draw is invoked several times per frame (landscape slices, late
// dynamics, paperdoll). Per-dispatch payloads reset here, while group
// retirement happens once in BeginFrame from whole-frame liveness.
foreach (InstanceGroup group in _groups.Values)
group.ClearPerInstanceData();
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
@ -1467,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (populateEntityId.HasValue && currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
currentEntityIncomplete = false;
@ -1513,7 +1553,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// entities (including this-entity-culled) so the PREVIOUS entity's
// cache always flushes at the boundary.
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch);
populateEntityId, populateLandblockId, entity.Id, _cache,
_populateScratch, _populateSelectionScratch);
// Phase U.4: a culled entity (cell not visible, or no outdoors visible
// for an outdoor stab) contributes NO instances. Skip after the
@ -1548,14 +1589,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry))
{
ApplyCacheHit(cachedEntry!, entityWorld, _cacheHitAppender);
ApplyCacheHitDirect(cachedEntry!, entityWorld);
// The cache is populated only after every MeshRef rendered
// successfully. Publish the same parts for retail picking now;
// CPhysicsPart::Draw only participates after the visible draw
// path has accepted a real part.
if (_selectionSink is not null)
PublishCachedSelectionParts(entity, entityWorld);
PublishCachedSelectionParts(cachedEntry!, entity, entityWorld);
// anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated
@ -1673,6 +1714,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// their MeshRefs; the flush at next-entity-boundary (or
// end-of-loop) commits them as a single Populate call.
var collector = isAnimated ? null : _populateScratch;
var selectionCollector = isAnimated ? null : _populateSelectionScratch;
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
@ -1748,6 +1790,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
restPose));
drewAny = true;
}
}
@ -1774,6 +1820,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
partIdx,
(uint)gfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
partIdx,
(uint)gfxObjId,
meshRef.PartTransform));
drewAny = true;
}
}
@ -1801,6 +1851,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
@ -1808,7 +1859,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// to trigger the entity-change flush, so commit its accumulated batches
// here. No-op when the last entity was animated (populateEntityId stays
// null) or when no entities walked at all.
FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch);
FinalFlushPopulate(
populateEntityId, populateLandblockId, _cache,
_populateScratch, _populateSelectionScratch);
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance
// histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the
@ -1829,8 +1882,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
// ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
int totalInstances = 0;
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
bool deferTransparent = _alphaQueue?.IsCollecting == true;
var instanceCounts = PartitionInstanceGroups(
_groups.Values,
deferTransparent,
camPos,
_opaqueDraws,
_translucentDraws);
int totalInstances = instanceCounts.VisibleInstances;
int immediateInstances = instanceCounts.ImmediateInstances;
if (totalInstances == 0)
{
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
@ -1839,7 +1899,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
return;
}
int needed = totalInstances * 16;
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
if (deferTransparent)
DeferTransparentGroups(camPos, vp);
else
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
int needed = immediateInstances * 16;
if (_instanceData.Length < needed)
_instanceData = new float[needed + 256 * 16];
@ -1847,77 +1913,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// count and lay it out in the SAME group order / cursor as _instanceData,
// so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On
// the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3.
if (_clipSlotData.Length < totalInstances)
_clipSlotData = new uint[totalInstances + 256];
if (_clipSlotData.Length < immediateInstances)
_clipSlotData = new uint[immediateInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
if (_lightSetData.Length < immediateInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(immediateInstances + 256) * LightManager.MaxLightsPerObject];
// #142: per-instance indoor flag buffer, one uint per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_indoorData.Length < totalInstances)
_indoorData = new uint[totalInstances + 256];
if (_indoorData.Length < immediateInstances)
_indoorData = new uint[immediateInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < totalInstances)
_alphaData = new float[totalInstances + 256];
if (_alphaData.Length < immediateInstances)
_alphaData = new float[immediateInstances + 256];
if (_selectionLightingData.Length < totalInstances)
_selectionLightingData = new Vector2[totalInstances + 256];
_opaqueDraws.Clear();
_translucentDraws.Clear();
if (_selectionLightingData.Length < immediateInstances)
_selectionLightingData = new Vector2[immediateInstances + 256];
int cursor = 0;
foreach (var grp in _groups.Values)
foreach (InstanceGroup grp in _opaqueDraws)
StageImmediateGroup(grp, ref cursor);
if (!deferTransparent)
{
if (grp.Matrices.Count == 0) continue;
grp.FirstInstance = cursor;
grp.InstanceCount = grp.Matrices.Count;
// Use the first instance's translation as the group's representative
// position for front-to-back sort (perf #2). Cheap heuristic; works
// well when instances of one group are spatially coherent
// (typical for trees in one landblock area, NPCs at one spawn).
var first = grp.Matrices[0];
var grpPos = new Vector3(first.M41, first.M42, first.M43);
grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
for (int i = 0; i < grp.Matrices.Count; i++)
{
WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]);
// Slots[] is parallel to Matrices[] within the group; write the
// slot at the same cursor so binding=3 stays aligned with binding=0.
_clipSlotData[cursor] = grp.Slots[i];
// Fix B: LightSets[] holds 8 ints per instance, parallel to
// Matrices[]; copy this instance's block to the same cursor so
// binding=5 stays aligned with binding=0.
int lsDst = cursor * LightManager.MaxLightsPerObject;
int lsSrc = i * LightManager.MaxLightsPerObject;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
_indoorData[cursor] = grp.IndoorFlags[i];
// #188: Opacities[] is parallel to Matrices[]; write at the same
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
_alphaData[cursor] = grp.Opacities[i];
// SmartBox CMaterial replacement, parallel to Matrices.
_selectionLightingData[cursor] = grp.SelectionLighting[i];
cursor++;
}
if (IsOpaque(grp.Translucency))
_opaqueDraws.Add(grp);
else
_translucentDraws.Add(grp);
foreach (InstanceGroup grp in _translucentDraws)
StageImmediateGroup(grp, ref cursor);
}
System.Diagnostics.Debug.Assert(cursor == immediateInstances);
// Front-to-back sort within each cull mode. DrawIndirectRange must
// split MDI calls whenever CullMode changes because GL state is not
@ -1931,13 +1958,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// submission so the shared queue can interleave it with particles.
// Immediate mode remains for sealed off-screen consumers such as the
// paperdoll and UI Studio render stack.
bool deferTransparent = _alphaQueue?.IsCollecting == true;
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
if (deferTransparent)
DeferTransparentGroups(camPos, vp);
else
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
@ -1998,7 +2018,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ActivateNextDynamicBufferSet();
fixed (float* ip = _instanceData)
UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes,
ip, totalInstances * 16 * sizeof(float));
ip, immediateInstances * 16 * sizeof(float));
fixed (BatchData* bp = _batchData)
UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes,
@ -2009,33 +2029,33 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the
// U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to
// U.3); under indoor routing it holds the per-instance slot from
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly totalInstances
// entries; only [0..totalInstances) is uploaded, so any stale tail is
// never read by the shader (BaseInstance + gl_InstanceID < totalInstances).
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances
// entries; only [0..immediateInstances) is uploaded, so any stale tail is
// never read by the shader.
fixed (uint* sp = _clipSlotData)
UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes,
sp, totalInstances * sizeof(uint));
sp, immediateInstances * sizeof(uint));
// #142: per-instance indoor flag buffer (binding=6), one uint per instance,
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
// is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (uint* dp = _indoorData)
UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes,
dp, totalInstances * sizeof(uint));
dp, immediateInstances * sizeof(uint));
// #188: per-instance opacity buffer (binding=7), one float per instance,
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
// is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (float* ap = _alphaData)
UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes,
ap, totalInstances * sizeof(float));
ap, immediateInstances * sizeof(float));
// SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert
// reads this only for the object path (uLightingMode=0), so EnvCell's
// independent mode-1 renderer does not consume this binding.
fixed (Vector2* hp = _selectionLightingData)
UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes,
hp, totalInstances * sizeof(float) * 2);
hp, immediateInstances * sizeof(float) * 2);
// Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
@ -2045,7 +2065,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
UploadGlobalLights();
fixed (int* lp = _lightSetData)
UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes,
lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int));
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{
@ -2234,41 +2254,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
set: set);
}
private void PublishCachedSelectionParts(WorldEntity entity, Matrix4x4 entityWorld)
private void PublishCachedSelectionParts(
EntityCacheEntry cachedEntry,
WorldEntity entity,
Matrix4x4 entityWorld)
{
for (int outerPartIndex = 0; outerPartIndex < entity.MeshRefs.Count; outerPartIndex++)
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
{
var meshRef = entity.MeshRefs[outerPartIndex];
var renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
continue;
if (!renderData.IsSetup || renderData.SetupParts.Count == 0)
{
_selectionSink!.AddVisiblePart(
entity,
outerPartIndex,
meshRef.GfxObjId,
meshRef.PartTransform * entityWorld);
continue;
}
for (int setupPartIndex = 0;
setupPartIndex < renderData.SetupParts.Count;
setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
if (_meshAdapter.TryGetRenderData(partGfxObjId) is null)
continue;
_selectionSink!.AddVisiblePart(
entity,
unchecked((outerPartIndex << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
ComposePartWorldMatrix(
entityWorld,
meshRef.PartTransform,
partTransform));
}
_selectionSink!.AddVisiblePart(
entity,
part.PartIndex,
part.GfxObjId,
part.RestPose * entityWorld);
}
}
@ -2283,6 +2280,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Translucency: g.Translucency,
CullMode: g.CullMode);
internal readonly record struct InstanceLayoutCounts(
int VisibleInstances,
int ImmediateInstances);
internal static InstanceLayoutCounts PartitionInstanceGroups(
IEnumerable<InstanceGroup> groups,
bool deferTransparent,
Vector3 cameraWorldPosition,
List<InstanceGroup> opaque,
List<InstanceGroup> transparent)
{
opaque.Clear();
transparent.Clear();
int visibleInstances = 0;
int immediateInstances = 0;
foreach (InstanceGroup group in groups)
{
int count = group.Matrices.Count;
if (count == 0)
continue;
group.InstanceCount = count;
Matrix4x4 first = group.Matrices[0];
var groupPosition = new Vector3(first.M41, first.M42, first.M43);
group.SortDistance = Vector3.DistanceSquared(cameraWorldPosition, groupPosition);
visibleInstances += count;
if (IsOpaque(group.Translucency))
{
opaque.Add(group);
immediateInstances += count;
}
else
{
transparent.Add(group);
if (!deferTransparent)
immediateInstances += count;
}
}
return new InstanceLayoutCounts(visibleInstances, immediateInstances);
}
private void StageImmediateGroup(InstanceGroup group, ref int cursor)
{
group.FirstInstance = cursor;
for (int i = 0; i < group.Matrices.Count; i++)
{
WriteMatrix(_instanceData, cursor * 16, group.Matrices[i]);
_clipSlotData[cursor] = group.Slots[i];
group.LightSets[i].CopyTo(
_lightSetData,
cursor * LightManager.MaxLightsPerObject);
_indoorData[cursor] = group.IndoorFlags[i];
_alphaData[cursor] = group.Opacities[i];
_selectionLightingData[cursor] = group.SelectionLighting[i];
cursor++;
}
}
private static GroupKey ToKey(InstanceGroup g) => new(
g.FirstIndex,
g.BaseVertex,
@ -2312,12 +2370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
localSortCenter,
model,
cameraWorldPosition);
int lightOffset = i * LightManager.MaxLightsPerObject;
var lights = new AlphaLightSet(
group.LightSets[lightOffset + 0], group.LightSets[lightOffset + 1],
group.LightSets[lightOffset + 2], group.LightSets[lightOffset + 3],
group.LightSets[lightOffset + 4], group.LightSets[lightOffset + 5],
group.LightSets[lightOffset + 6], group.LightSets[lightOffset + 7]);
InstanceLightSet lights = group.LightSets[i];
int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance(
@ -2353,8 +2406,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject;
for (int light = 0; light < LightManager.MaxLightsPerObject; light++)
_lightSetData[lightOffset + light] = entry.Lights[light];
entry.Lights.CopyTo(_lightSetData, lightOffset);
GroupKey key = entry.Key;
_batchData[i] = new BatchData
@ -2876,6 +2928,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
}
internal static bool TryResolveCachedGroup(
CachedBatch cached,
out InstanceGroup? group)
{
group = cached.Group;
return group is not null
&& cached.GroupRegistration != 0
&& cached.GroupRegistration == group.Registration;
}
private void ApplyCacheHitDirect(EntityCacheEntry entry, Matrix4x4 entityWorld)
{
for (int i = 0; i < entry.Batches.Length; i++)
{
CachedBatch cached = entry.Batches[i];
Matrix4x4 model = cached.RestPose * entityWorld;
if (!TryResolveCachedGroup(cached, out InstanceGroup? group))
{
group = GetOrCreateInstanceGroup(cached.Key);
entry.Batches[i] = cached with
{
Group = group,
GroupRegistration = group.Registration,
};
}
AppendInstanceToGroup(group!, model, cached.LocalSortCenter);
}
}
/// <summary>
/// Retires groups that were absent for the entire preceding frame.
/// Retiring sets the registration to zero before the dictionary reference
/// is removed, invalidating every cached direct handle to that exact group
/// without invalidating unrelated live groups. The retired list storage is
/// released because stale classification entries may retain the small group
/// object until their next cache hit.
/// </summary>
internal static int PruneInstanceGroupsUnusedBeforeFrame(
Dictionary<GroupKey, InstanceGroup> groups,
List<GroupKey> retiredKeys,
long oldestLiveFrame)
{
retiredKeys.Clear();
foreach ((GroupKey key, InstanceGroup group) in groups)
{
if (group.LastUsedFrame < oldestLiveFrame)
{
group.Registration = 0;
group.ReleasePerInstanceStorage();
retiredKeys.Add(key);
}
}
foreach (GroupKey key in retiredKeys)
groups.Remove(key);
int retiredCount = retiredKeys.Count;
retiredKeys.Clear();
return retiredCount;
}
/// <summary>
/// Per-tuple flush check. If <paramref name="populateEntityId"/> is set
/// AND differs from <paramref name="currentEntityId"/>, the previous
@ -2897,15 +3010,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
uint populateLandblockId,
uint currentEntityId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch)
List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
{
if (populateScratch.Count > 0)
{
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
}
populateScratch.Clear();
selectionScratch?.Clear();
return (null, 0u);
}
return (populateEntityId, populateLandblockId);
@ -2925,13 +3044,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
uint? populateEntityId,
uint populateLandblockId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch)
List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{
if (populateEntityId.HasValue && populateScratch.Count > 0)
{
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
populateScratch.Clear();
}
selectionScratch?.Clear();
}
/// <summary>
@ -2941,20 +3066,46 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// </summary>
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter)
{
if (!_groups.TryGetValue(key, out var grp))
InstanceGroup grp = GetOrCreateInstanceGroup(key);
AppendInstanceToGroup(grp, model, localSortCenter);
}
private InstanceGroup GetOrCreateInstanceGroup(GroupKey key)
{
if (_groups.TryGetValue(key, out InstanceGroup? group))
{
grp = new InstanceGroup
{
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
IndexCount = key.IndexCount,
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
};
_groups[key] = grp;
group.LastUsedFrame = _groupFrame;
return group;
}
if (_nextGroupRegistration == long.MaxValue)
{
throw new InvalidOperationException(
"Instance-group registration space was exhausted before a safe identity could be assigned.");
}
group = new InstanceGroup
{
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
IndexCount = key.IndexCount,
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
Registration = _nextGroupRegistration++,
LastUsedFrame = _groupFrame,
};
_groups.Add(key, group);
return group;
}
private void AppendInstanceToGroup(
InstanceGroup grp,
Matrix4x4 model,
Vector3 localSortCenter)
{
grp.LastUsedFrame = _groupFrame;
grp.Matrices.Add(model);
grp.LocalSortCenters.Add(localSortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
@ -3014,7 +3165,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false;
for (int k = 0; k < _currentEntityLightSet.Length; k++)
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
{
int idx = _currentEntityLightSet[k];
if (idx < 0) continue;
@ -3040,7 +3191,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// so they can't disagree — one call, one truth.
_currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId);
Array.Fill(_currentEntityLightSet, -1);
_currentEntityLightSet = InstanceLightSet.Disabled;
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
@ -3050,7 +3201,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
Array.Fill(_currentEntityLightSetScratch, -1);
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch);
_currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch);
}
/// <summary>
@ -3076,8 +3229,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
grp.LightSets.Add(_currentEntityLightSet[k]);
grp.LightSets.Add(_currentEntityLightSet);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
}
@ -3133,27 +3285,20 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
if (!_groups.TryGetValue(key, out var grp))
{
grp = new InstanceGroup
{
FirstIndex = batch.FirstIndex,
BaseVertex = (int)batch.BaseVertex,
IndexCount = batch.IndexCount,
BindlessTextureHandle = texHandle,
TextureLayer = texLayer,
Translucency = translucency,
CullMode = batch.CullMode,
};
_groups[key] = grp;
}
InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model);
grp.LocalSortCenters.Add(renderData.SortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
collector?.Add(new CachedBatch(key, texHandle, restPose, renderData.SortCenter));
collector?.Add(new CachedBatch(
key,
texHandle,
restPose,
renderData.SortCenter,
grp,
grp.Registration));
}
return allTexturesReady;
}
@ -3577,6 +3722,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
internal sealed class InstanceGroup
{
// Nonzero only while this exact object is registered in _groups.
// CachedBatch stores the value alongside the reference; retirement
// zeros it before removal so stale handles cannot append off-table.
public long Registration;
public long LastUsedFrame;
public uint FirstIndex;
public int BaseVertex;
public int IndexCount;
@ -3601,13 +3751,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new();
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
// light index block for the instance whose matrix is Matrices[i]). At
// Fix B (A7 #3): one packed eight-index light set per instance, parallel
// to Matrices (LightSets[i] belongs to Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot.
public readonly List<int> LightSets = new();
public readonly List<InstanceLightSet> LightSets = new();
// #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is
// 1 when the instance's entity is parented to an EnvCell (skip the sun); 0
@ -3646,5 +3795,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Opacities.Clear();
SelectionLighting.Clear();
}
public void ReleasePerInstanceStorage()
{
ClearPerInstanceData();
Matrices.TrimExcess();
LocalSortCenters.TrimExcess();
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}
}
}