fix #429: allocation-free shadow topology rebuild + churn-frame pipelining

The directional-shadow topology rebuilt on every streaming-churn frame
and was the measured body of the run-hitch stalls (701 of 708 baseline
stalls alloc-correlated):

- The draw sort comparer's enum-vs-enum CompareTo bound to
  Enum.CompareTo(object) and boxed BOTH operands on every comparison —
  a constant ~38.9 MB of garbage per topology rebuild (~4M boxes),
  handing the GC a forced gen0 collection mid-frame. The full ~100k-draw
  sort is replaced outright: draws hash-group by exact DrawKey in one
  O(n) pass over retained chained-index arrays, and only the
  few-thousand DISTINCT group keys sort (order-preserving packed
  material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys,
  first-appearance tie-break) — bit-identical emission order to the old
  stable sort, near-zero allocation, and no per-draw comparisons at all.
- The caster frame sorts 4-byte indices keyed on SortKey.Value instead
  of shuffling multi-hundred-byte records through a boxing comparer.
- Owner-approved pipelining: on a frame whose shadow inputs just changed
  (the same frame already paying frame-view/landscape rebuilds), the
  caster-frame and prepared-draws topology rebuilds defer to the next
  quieter frame, capped at two consecutive deferrals — inside the GPU
  fence depth, so retained draws never reference a released arena range.
  First build, generation change, caster BuildSequence change, and
  journal overflow force the immediate path; deferred refreshes skip
  identity-mismatched journal rows.

Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s
uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames
near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed
topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests).
docs/ISSUES.md carries the full evidence trail; the residual
content-proportional rebuild milliseconds are filed as the
incremental-topology successor, and the pre-existing town-view scaling
latch is filed as #432.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 09:16:58 +02:00
parent 4873c10673
commit ad69558908
6 changed files with 652 additions and 69 deletions

View file

@ -38,7 +38,12 @@ internal readonly record struct DirectionalSunShadowRenderInput(
float ResidentMaximumReachMeters = float.PositiveInfinity,
bool MeasureGpuTimers = true,
bool MeasureCpuStages = false,
AtmosphericFrameBufferBinding AtmosphericFrame = default);
AtmosphericFrameBufferBinding AtmosphericFrame = default,
// #429 owner-approved pipelining: false keeps the retained caster/draw
// topology this frame (transform refresh only) so the rebuild lands on a
// quieter frame. The prepare seams below re-validate and rebuild anyway
// whenever deferral would be unsafe.
bool AllowTopologyRebuild = true);
internal readonly record struct DirectionalSunShadowCpuStageTicks(
long EnvironmentGateTicks,
@ -394,7 +399,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowPreparedDraws worldDraws =
world.PrepareDirectionalShadowDraws(input.Casters);
world.PrepareDirectionalShadowDraws(
input.Casters,
input.AllowTopologyRebuild);
DirectionalShadowTerrainPreparedDraws terrainDraws =
terrain.PrepareDirectionalShadowDraws();
DirectionalShadowMeshGeometry? worldGeometry =

View file

@ -197,6 +197,10 @@ internal sealed class AtmosphericPostProcessGraph :
private readonly bool _fuseLowPostProcess;
private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler;
private readonly DirectionalShadowCasterFrame _shadowCasters = new();
// #429 owner-approved pipelining state — see RenderDirectionalShadows.
private ulong _lastObservedSceneShadowRevision;
private long _lastObservedAvailabilityVersion;
private int _shadowRebuildDeferrals;
private TargetSet? _targets;
private AtmosphericFrameInputs _lastInputs;
private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
@ -395,7 +399,33 @@ internal sealed class AtmosphericPostProcessGraph :
bool measureCpuStages = _cpuStageProfiler is not null
&& AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial);
long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
_shadowCasters.Build(in scene);
// #429 owner-approved pipelining: on a frame where the shadow inputs
// just CHANGED (streaming publish churn — the same frame already pays
// the frame-view and landscape rebuilds), keep the retained shadow
// topology and let the rebuild land on the next quieter frame. Capped
// at two consecutive deferrals: the GPU frame fence is deeper than
// that, so retained prepared draws can never reference an arena range
// that was released AND reused while deferred. A caster rebuild the
// frame forces anyway (first build, generation change, journal
// overflow) re-enables the draws rebuild in the same frame — the
// prepared draws must never index a caster frame they were not built
// from.
ulong sceneShadowRevision = scene.DirectionalShadowTopologyRevision;
long availabilityVersion = worldMeshes.DirectionalShadowAvailabilityVersion;
bool shadowInputsChanged =
sceneShadowRevision != _lastObservedSceneShadowRevision
|| availabilityVersion != _lastObservedAvailabilityVersion;
_lastObservedSceneShadowRevision = sceneShadowRevision;
_lastObservedAvailabilityVersion = availabilityVersion;
bool allowTopologyRebuild =
!shadowInputsChanged || _shadowRebuildDeferrals >= 2;
ulong casterSequenceBefore = _shadowCasters.BuildSequence;
_shadowCasters.Build(in scene, allowTopologyRebuild);
if (_shadowCasters.BuildSequence != casterSequenceBefore)
allowTopologyRebuild = true;
_shadowRebuildDeferrals = allowTopologyRebuild
? 0
: _shadowRebuildDeferrals + 1;
long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
AuthoredCelestialShadowSource source = world.CelestialShadowSource;
var environment = new DirectionalShadowEnvironmentInput(
@ -433,7 +463,8 @@ internal sealed class AtmosphericPostProcessGraph :
Preset.Semantic,
frame.Serial),
MeasureCpuStages: measureCpuStages,
AtmosphericFrame: shadowAtmosphericFrame);
AtmosphericFrame: shadowAtmosphericFrame,
AllowTopologyRebuild: allowTopologyRebuild);
long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
_lastShadowCasterCount = _shadowCasters.Stats.Accepted;
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;

View file

@ -103,6 +103,9 @@ internal sealed class DirectionalShadowCasterFrame
private RenderProjectionClass[] _casterClasses = [];
private RenderProjectionId[] _denseIdScratch = [];
private RenderProjectionRecord[] _denseRecordScratch = [];
private int[] _sortIndices = [];
private ulong[] _sortKeys = [];
private DirectionalShadowCaster[] _sortScratch = [];
private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
new DirectionalShadowTransformSnapshot[
DirectionalShadowTransformChangeJournal.Capacity];
@ -164,14 +167,34 @@ internal sealed class DirectionalShadowCasterFrame
+ System.Runtime.CompilerServices.Unsafe.SizeOf<
KeyValuePair<RenderProjectionId, int>>()));
public void Build(in RenderSceneQuery query)
/// <summary>
/// #429 owner-approved pipelining: with
/// <paramref name="allowTopologyRebuild"/> false, a topology-stale frame
/// keeps the retained caster product and only refreshes transforms, so the
/// copy+classify cost moves off the streaming-churn frame that triggered
/// it. The deferral is best-effort: the FIRST build, a generation change,
/// and a transform journal that demands a full refresh (dense re-copy by
/// id would dereference removed scene entries) all rebuild immediately
/// regardless. While deferred, journal rows whose caster identity no
/// longer matches the retained topology are skipped instead of throwing —
/// the immediately following rebuild reconciles them.
/// </summary>
public void Build(in RenderSceneQuery query, bool allowTopologyRebuild = true)
{
ulong topologyRevision = query.DirectionalShadowTopologyRevision;
if (BuildSequence != 0
bool current = BuildSequence != 0
&& Generation == query.Generation
&& _topologyRevision == topologyRevision)
&& _topologyRevision == topologyRevision;
bool deferStale = !allowTopologyRebuild
&& !current
&& BuildSequence != 0
&& Generation == query.Generation
&& !RefreshRequiresFullCopy(in query);
if (current || deferStale)
{
int refreshes = RefreshChangedTransforms(in query);
int refreshes = RefreshChangedTransforms(
in query,
tolerateStaleTopology: deferStale);
Stats = Stats with
{
IndexCopies = 0,
@ -230,11 +253,7 @@ internal sealed class DirectionalShadowCasterFrame
for (int i = 0; i < dynamicCount; i++)
Add(_outdoorDynamicScratch[i]);
Array.Sort(
_casters,
0,
_casterCount,
DirectionalShadowCasterComparer.Instance);
SortCasters();
int refreshCasterCount = 0;
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
{
@ -375,7 +394,26 @@ internal sealed class DirectionalShadowCasterFrame
}
}
private int RefreshChangedTransforms(in RenderSceneQuery query)
/// <summary>
/// Pure pre-check for the deferral gate: would refreshing from the journal
/// demand the dense by-id re-copy? The journal copy is a read; the state
/// consuming it (<see cref="_transformRevision"/>) only advances inside
/// <see cref="RefreshChangedTransforms"/>.
/// </summary>
private bool RefreshRequiresFullCopy(in RenderSceneQuery query)
{
if (query.DirectionalShadowTransformRevision == _transformRevision)
return false;
DirectionalShadowTransformChanges changes =
query.CopyDirectionalShadowTransformChanges(
_transformRevision,
_transformChangeScratch);
return changes.RequiresFullRefresh;
}
private int RefreshChangedTransforms(
in RenderSceneQuery query,
bool tolerateStaleTopology = false)
{
_changedCasterPoseCount = 0;
ulong latest = query.DirectionalShadowTransformRevision;
@ -397,6 +435,15 @@ internal sealed class DirectionalShadowCasterFrame
_lastTransformChanges = changes;
if (changes.RequiresFullRefresh)
{
if (tolerateStaleTopology)
{
// Unreachable through Build's deferral gate (it pre-checks via
// RefreshRequiresFullCopy); kept as a hard stop because the
// dense by-id copy below would throw on scene entries the
// stale topology still names.
throw new InvalidOperationException(
"A stale-topology refresh cannot perform the dense full re-copy.");
}
_lastBatchedProjectionCopyCalls = 1;
for (int index = 0; index < _refreshCasterSlotCount; index++)
{
@ -442,6 +489,17 @@ internal sealed class DirectionalShadowCasterFrame
{
continue;
}
if (tolerateStaleTopology
&& (records[index].Id != _casterIds[casterIndex]
|| records[index].ProjectionClass
!= _casterClasses[casterIndex]))
{
// A replaced scene entry (destroy + recreate under a new
// class) can journal against a retained slot while the
// topology rebuild is deferred; the rebuild on the next
// allowed frame reconciles it.
continue;
}
_changedCasterFlags[casterIndex] = true;
ValidateStablePose(in records[index], casterIndex);
_changedCasterPoses[_changedCasterPoseCount++] =
@ -525,6 +583,51 @@ internal sealed class DirectionalShadowCasterFrame
Array.Resize(ref values, capacity);
}
/// <summary>
/// #429 residual-stall fix: same packed-key index sort as
/// <c>DirectionalShadowPreparedDraws.SortSourceDraws</c>. The caster
/// comparer orders by the 64-bit traversal <c>SortKey.Value</c> with the
/// projection id as tie-break, so the key needs no packing at all —
/// almost every pair resolves on one integer compare and the sort swaps
/// 4-byte indices instead of the multi-hundred-byte caster records.
/// Equal keys fall back to the exact comparer plus an index tie-break,
/// preserving the previous total order.
/// </summary>
private void SortCasters()
{
int count = _casterCount;
EnsureCapacity(ref _sortIndices, count);
EnsureCapacity(ref _sortKeys, count);
EnsureCapacity(ref _sortScratch, count);
for (int i = 0; i < count; i++)
{
_sortKeys[i] = _casters[i].Projection.SortKey.Value;
_sortIndices[i] = i;
}
_sortIndices.AsSpan(0, count).Sort(
new CasterIndexComparer(_sortKeys, _casters));
for (int i = 0; i < count; i++)
_sortScratch[i] = _casters[_sortIndices[i]];
(_casters, _sortScratch) = (_sortScratch, _casters);
}
private readonly struct CasterIndexComparer(
ulong[] keys,
DirectionalShadowCaster[] casters) : IComparer<int>
{
public int Compare(int x, int y)
{
ulong left = keys[x];
ulong right = keys[y];
if (left != right)
return left < right ? -1 : 1;
int order = DirectionalShadowCasterComparer.Instance.Compare(
casters[x],
casters[y]);
return order != 0 ? order : x.CompareTo(y);
}
}
private sealed class DirectionalShadowCasterComparer
: IComparer<DirectionalShadowCaster>
{

View file

@ -89,6 +89,15 @@ internal sealed class DirectionalShadowPreparedDraws
private DrawElementsIndirectCommand[] _commands = [];
private DirectionalShadowPreparedBatch[] _batches = [];
private DirectionalShadowPreparedRun[] _runs = [];
private int[] _drawNextInGroup = [];
private int[] _groupHead = [];
private int[] _groupTail = [];
private int[] _groupCountByGroup = [];
private ulong[] _groupKeyHi = [];
private ulong[] _groupKeyLo = [];
private int[] _groupFirstDraw = [];
private int[] _groupOrder = [];
private readonly Dictionary<DirectionalShadowDrawKey, int> _groupByKey = [];
private int _sourceCount;
private int _commandCount;
private int _runCount;
@ -183,7 +192,18 @@ internal sealed class DirectionalShadowPreparedDraws
+ _mappedCasterIdentityPresent.Length
+ (long)_commands.Length * Unsafe.SizeOf<DrawElementsIndirectCommand>()
+ (long)_batches.Length * Unsafe.SizeOf<DirectionalShadowPreparedBatch>()
+ (long)_runs.Length * Unsafe.SizeOf<DirectionalShadowPreparedRun>());
+ (long)_runs.Length * Unsafe.SizeOf<DirectionalShadowPreparedRun>()
+ (long)_drawNextInGroup.Length * sizeof(int)
+ (long)_groupHead.Length * sizeof(int)
+ (long)_groupTail.Length * sizeof(int)
+ (long)_groupCountByGroup.Length * sizeof(int)
+ (long)_groupKeyHi.Length * sizeof(ulong)
+ (long)_groupKeyLo.Length * sizeof(ulong)
+ (long)_groupFirstDraw.Length * sizeof(int)
+ (long)_groupOrder.Length * sizeof(int)
+ (long)_groupByKey.EnsureCapacity(0)
* (sizeof(int)
+ Unsafe.SizeOf<KeyValuePair<DirectionalShadowDrawKey, int>>()));
/// <summary>
/// Returns false when this exact resident-caster build was already
@ -358,11 +378,63 @@ internal sealed class DirectionalShadowPreparedDraws
if (casterBuildSequence == 0)
throw new ArgumentOutOfRangeException(nameof(casterBuildSequence));
Array.Sort(
_source,
0,
_sourceCount,
DirectionalShadowSourceDrawComparer.Instance);
// #429 residual-stall fix, round 2. The full draw sort existed only to
// make equal keys contiguous for the grouping walk below — but the
// draw list is ~100k entries while the DISTINCT keys number in the
// low thousands. Hash-group the draws in one O(n) pass (chained
// per-group index lists over retained arrays), then order the GROUPS
// by their packed keys (an O(g log g) sort of small integers). The
// emitted product is identical to the former stable full sort in
// every reachable state: group membership keys on exact DrawKey
// equality via the dictionary; group order follows the same packed
// material | cull | firstIndex | baseVertex (hi) and indexCount |
// slot | layer | foliage (lo) chain with first-appearance as the
// final tie-break; instances within a group keep insertion order,
// exactly as the former index tie-break produced.
int groupCount = 0;
_groupByKey.Clear();
EnsureCapacity(ref _drawNextInGroup, _sourceCount);
EnsureCapacity(ref _groupHead, _sourceCount);
EnsureCapacity(ref _groupTail, _sourceCount);
EnsureCapacity(ref _groupCountByGroup, _sourceCount);
EnsureCapacity(ref _groupKeyHi, _sourceCount);
EnsureCapacity(ref _groupKeyLo, _sourceCount);
EnsureCapacity(ref _groupFirstDraw, _sourceCount);
EnsureCapacity(ref _groupOrder, _sourceCount);
for (int i = 0; i < _sourceCount; i++)
{
DirectionalShadowDrawKey key = _source[i].Key;
if (!_groupByKey.TryGetValue(key, out int group))
{
group = groupCount++;
_groupByKey.Add(key, group);
_groupHead[group] = i;
_groupTail[group] = i;
_groupCountByGroup[group] = 0;
_groupFirstDraw[group] = i;
_groupKeyHi[group] =
((ulong)(byte)key.Material << 62)
| ((ulong)((uint)key.CullMode & 0x3u) << 60)
| ((ulong)key.FirstIndex << 28)
| ((ulong)(uint)key.BaseVertex & 0x0FFF_FFFFul);
_groupKeyLo[group] =
((ulong)Math.Min((uint)key.IndexCount, 0xF_FFFFu) << 44)
| ((ulong)key.TextureSlot.Index << 12)
| ((ulong)Math.Min(key.TextureLayer, 0x3FFu) << 2)
| (key.FoliageFlags & 0x3u);
}
else
{
_drawNextInGroup[_groupTail[group]] = i;
_groupTail[group] = i;
}
_drawNextInGroup[i] = -1;
_groupCountByGroup[group]++;
}
for (int g = 0; g < groupCount; g++)
_groupOrder[g] = g;
_groupOrder.AsSpan(0, groupCount).Sort(
new GroupOrderComparer(_groupKeyHi, _groupKeyLo, _groupFirstDraw));
EnsureCapacity(ref _transforms, _sourceCount);
EnsureCapacity(ref _transformSources, _sourceCount);
EnsureCapacity(ref _dynamicTransformSlots, _sourceCount);
@ -395,24 +467,24 @@ internal sealed class DirectionalShadowPreparedDraws
_mappedCasterCount);
}
int sourceIndex = 0;
int transformIndex = 0;
int commandIndex = 0;
int opaqueCommands = 0;
while (sourceIndex < _sourceCount)
for (int orderIndex = 0; orderIndex < groupCount; orderIndex++)
{
DirectionalShadowDrawKey key = _source[sourceIndex].Key;
int groupStart = sourceIndex;
do
int group = _groupOrder[orderIndex];
DirectionalShadowDrawKey key = _source[_groupFirstDraw[group]].Key;
int instanceCount = _groupCountByGroup[group];
for (int draw = _groupHead[group]; draw >= 0; draw = _drawNextInGroup[draw])
{
_transforms[transformIndex++] = _source[sourceIndex].Transform;
_transforms[transformIndex++] = _source[draw].Transform;
_transformSources[transformIndex - 1] =
_source[sourceIndex].TransformSource;
if (_source[sourceIndex].TransformSource.Refreshable)
_source[draw].TransformSource;
if (_source[draw].TransformSource.Refreshable)
{
int dynamicTransformIndex = transformIndex - 1;
DirectionalShadowTransformSource transformSource =
_source[sourceIndex].TransformSource;
_source[draw].TransformSource;
if (transformSource.CasterIndex < 0)
{
throw new InvalidOperationException(
@ -425,11 +497,8 @@ internal sealed class DirectionalShadowPreparedDraws
_firstDynamicTransformByCaster[transformSource.CasterIndex] =
dynamicTransformIndex;
}
sourceIndex++;
}
while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key);
int instanceCount = sourceIndex - groupStart;
_commands[commandIndex] = new DrawElementsIndirectCommand
{
Count = checked((uint)key.IndexCount),
@ -863,6 +932,35 @@ internal sealed class DirectionalShadowPreparedDraws
Array.Resize(ref values, capacity);
}
/// <summary>
/// Orders the hash-built groups for emission: the packed hi key carries
/// Material (2) | CullMode (2) | FirstIndex (32) | BaseVertex low 28, the
/// lo key IndexCount (20, clamped) | TextureSlot (32) | TextureLayer (10,
/// clamped) | FoliageFlags (2) — the exact field chain the former full
/// draw sort compared — with first-appearance as the final deterministic
/// tie-break. Clamp collisions (unreachable with the configured arena and
/// atlas maxima) can only reorder whole groups inside one material+cull
/// run; group membership itself keys on exact DrawKey equality.
/// </summary>
private readonly struct GroupOrderComparer(
ulong[] keyHi,
ulong[] keyLo,
int[] firstDraw) : IComparer<int>
{
public int Compare(int x, int y)
{
ulong left = keyHi[x];
ulong right = keyHi[y];
if (left != right)
return left < right ? -1 : 1;
left = keyLo[x];
right = keyLo[y];
if (left != right)
return left < right ? -1 : 1;
return firstDraw[x].CompareTo(firstDraw[y]);
}
}
private readonly record struct DirectionalShadowDrawKey(
uint FirstIndex,
int BaseVertex,
@ -881,42 +979,15 @@ internal sealed class DirectionalShadowPreparedDraws
Matrix4x4 Transform,
DirectionalShadowTransformSource TransformSource);
private sealed class DirectionalShadowSourceDrawComparer
: IComparer<DirectionalShadowSourceDraw>
{
public static DirectionalShadowSourceDrawComparer Instance { get; } = new();
public int Compare(
DirectionalShadowSourceDraw left,
DirectionalShadowSourceDraw right)
{
DirectionalShadowDrawKey x = left.Key;
DirectionalShadowDrawKey y = right.Key;
int order = x.Material.CompareTo(y.Material);
if (order != 0) return order;
order = x.CullMode.CompareTo(y.CullMode);
if (order != 0) return order;
order = x.FirstIndex.CompareTo(y.FirstIndex);
if (order != 0) return order;
order = x.BaseVertex.CompareTo(y.BaseVertex);
if (order != 0) return order;
order = x.IndexCount.CompareTo(y.IndexCount);
if (order != 0) return order;
order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index);
if (order != 0) return order;
order = x.TextureLayer.CompareTo(y.TextureLayer);
// Campaign VM VM6: tie-break on FoliageFlags so entries sharing
// every other key field but differing only in classification
// (the rare case a mesh subset is reachable from both a
// procedural-scenery and a non-scenery placement) still sort
// into one contiguous, exact-key-matched run instead of an
// unstable-sort-dependent scatter. The grouping loop below keys
// on exact DirectionalShadowDrawKey equality regardless.
return order != 0
? order
: x.FoliageFlags.CompareTo(y.FoliageFlags);
}
}
// The former full-draw sort comparer is gone with the sort itself.
// #429 postmortem, preserved here because the lesson is easy to lose:
// its original `x.Material.CompareTo(y.Material)` bound to
// Enum.CompareTo(object) and boxed BOTH operands on every comparison —
// measured at 38.9 MB of garbage per topology rebuild (~4M boxes across
// the N·log N sort), rebuilt on every streaming-churn frame while the
// player moves. Compare enums through their underlying integers, or
// better, do not sort 100k draws when hash-grouping plus a small
// group-key sort produces the identical product (see Complete).
}
public sealed partial class WbDrawDispatcher
@ -939,8 +1010,17 @@ public sealed partial class WbDrawDispatcher
/// returned owner is renderer-retained and remains valid until the next
/// distinct caster build is prepared.
/// </summary>
/// <summary>
/// #429: the version the shadow pipelining policy observes — the same
/// counter <see cref="PrepareDirectionalShadowDraws"/> keys its topology
/// gate on.
/// </summary>
internal long DirectionalShadowAvailabilityVersion =>
_meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L;
internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws(
DirectionalShadowCasterFrame casters)
DirectionalShadowCasterFrame casters,
bool allowTopologyRebuild = true)
{
ArgumentNullException.ThrowIfNull(casters);
ReadOnlySpan<DirectionalShadowCaster> source = casters.Casters;
@ -956,6 +1036,19 @@ public sealed partial class WbDrawDispatcher
_directionalShadowDraws.RefreshDynamicTransforms(casters);
return _directionalShadowDraws;
}
// #429 owner-approved pipelining: a deferred frame keeps the retained
// prepared draws and only refreshes transforms — valid ONLY while the
// product was built from this exact caster frame; a caster rebuild or
// generation change invalidates the caster-slot mapping the transform
// refresh indexes by, so those rebuild immediately regardless.
if (!allowTopologyRebuild
&& _directionalShadowDraws.SourceCasterBuildSequence
== casters.BuildSequence
&& _directionalShadowDraws.SourceGeneration == casters.Generation)
{
_directionalShadowDraws.RefreshDynamicTransforms(casters);
return _directionalShadowDraws;
}
int estimatedInstances = 0;
for (int i = 0; i < source.Length; i++)