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
|
|
@ -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>
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue