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:
parent
4873c10673
commit
ad69558908
6 changed files with 652 additions and 69 deletions
|
|
@ -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++)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue