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

@ -598,4 +598,87 @@ public sealed class DirectionalShadowPreparedDrawTests
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
Assert.True(expectedBits.SequenceEqual(actualBits));
}
/// <summary>
/// #429 allocation gate (I1 style). A warmed topology rebuild owns every
/// retained buffer it needs, so the whole
/// TryBegin → Add×N → Complete transaction must allocate near zero.
/// The regression this pins: the Complete sort's comparer used
/// <c>enum.CompareTo(enum)</c>, which binds to
/// <c>Enum.CompareTo(object)</c> and boxes BOTH operands on every
/// comparison — measured at 38.9 MB of garbage per rebuild in a
/// production window (~4M boxes across the N·log N sort), rebuilt on
/// every streaming-churn frame while the player moves. That was the
/// #429 run-hitch.
/// </summary>
[Fact]
public void AWarmedTopologyRebuildAllocatesNearZero()
{
const int drawCount = 4096;
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(9);
BuildVariedTopology(product, generation, buildSequence: 1, drawCount);
long before = GC.GetAllocatedBytesForCurrentThread();
BuildVariedTopology(product, generation, buildSequence: 2, drawCount);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(drawCount, product.Stats.PreparedInstances);
// One small constant covers the sort's comparison-delegate wrapper.
// The boxing regression allocates ~2 MB at this draw count and fails
// this gate by three orders of magnitude.
Assert.True(
allocated < 2048,
$"A warmed directional-shadow topology rebuild allocated {allocated} bytes.");
}
private static void BuildVariedTopology(
DirectionalShadowPreparedDraws product,
RenderSceneGeneration generation,
ulong buildSequence,
int drawCount)
{
Assert.True(product.TryBegin(
generation,
buildSequence,
estimatedInstances: drawCount));
Matrix4x4 transform = Matrix4x4.Identity;
for (int i = 0; i < drawCount; i++)
{
// Vary every sort-key dimension so Complete's sort exercises the
// full comparison chain (material, cull mode, then the integers).
bool cutout = (i & 1) != 0;
product.Add(
firstIndex: (uint)((i * 37) % 1024),
baseVertex: (i * 13) % 512,
indexCount: 3 + (i % 5) * 3,
cutout ? new GpuTextureSlot((uint)(i % 7)) : GpuTextureSlot.Unassigned,
textureLayer: (uint)(i % 11),
(i % 3) switch
{
0 => CullMode.None,
1 => CullMode.Clockwise,
_ => CullMode.CounterClockwise,
},
cutout
? DirectionalShadowCasterMaterial.AlphaCutout
: DirectionalShadowCasterMaterial.Opaque,
in transform);
}
product.Complete(
generation,
buildSequence,
new DirectionalShadowPreparationStats(
SourceCasters: drawCount,
SourceMeshRefs: drawCount,
SourceParts: drawCount,
SourceBatches: drawCount,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 0,
RejectedFadedParts: 0,
MissingMeshes: 0,
UnresolvedAlphaCutoutTextures: 0));
}
}