acdream/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs
Erik ad69558908 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>
2026-08-24 09:16:58 +02:00

684 lines
29 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
public sealed class DirectionalShadowPreparedDrawTests
{
[Theory]
[InlineData(TranslucencyKind.Opaque, true, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.ClipMap, true, DirectionalShadowCasterMaterial.AlphaCutout)]
[InlineData(TranslucencyKind.AlphaBlend, false, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.Additive, false, DirectionalShadowCasterMaterial.Opaque)]
[InlineData(TranslucencyKind.InvAlpha, false, DirectionalShadowCasterMaterial.Opaque)]
internal void MaterialPolicy_PreservesCutoutsAndExcludesTrueTransparency(
TranslucencyKind source,
bool expectedAccepted,
DirectionalShadowCasterMaterial expectedMaterial)
{
bool accepted = DirectionalShadowPreparedDraws.TryClassifyMaterial(
source,
out DirectionalShadowCasterMaterial material);
Assert.Equal(expectedAccepted, accepted);
if (accepted)
Assert.Equal(expectedMaterial, material);
}
[Theory]
[InlineData(0f, false)]
[InlineData(0.0001f, true)]
[InlineData(1f, true)]
[InlineData(float.NaN, true)]
internal void FadePolicy_OnlyExactOpaquePartsCast(
float translucency,
bool excluded)
{
Assert.Equal(
excluded,
DirectionalShadowPreparedDraws.FadeExcludesCaster(translucency));
}
[Fact]
public void Complete_GroupsCommandsAndRetainsExactCurrentTransforms()
{
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(4);
Assert.True(product.TryBegin(generation, 7, estimatedInstances: 3));
Matrix4x4 first = Matrix4x4.CreateRotationZ(0.25f)
* Matrix4x4.CreateTranslation(10f, 20f, 30f);
Matrix4x4 second = Matrix4x4.CreateScale(1.25f)
* Matrix4x4.CreateTranslation(-2f, 3f, 7f);
Matrix4x4 leaves = Matrix4x4.CreateRotationX(0.5f)
* Matrix4x4.CreateTranslation(100f, 200f, 12f);
product.Add(
firstIndex: 40,
baseVertex: 3,
indexCount: 18,
GpuTextureSlot.Unassigned,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque,
in first);
product.Add(
firstIndex: 40,
baseVertex: 3,
indexCount: 18,
GpuTextureSlot.Unassigned,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.Opaque,
in second);
product.Add(
firstIndex: 90,
baseVertex: 11,
indexCount: 24,
new GpuTextureSlot(17),
textureLayer: 6,
CullMode.None,
DirectionalShadowCasterMaterial.AlphaCutout,
in leaves);
var inputStats = new DirectionalShadowPreparationStats(
SourceCasters: 2,
SourceMeshRefs: 3,
SourceParts: 3,
SourceBatches: 5,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 2,
RejectedFadedParts: 1,
MissingMeshes: 0,
UnresolvedAlphaCutoutTextures: 0);
product.Complete(generation, 7, in inputStats);
Assert.Equal(3, product.Transforms.Length);
Assert.Equal(2, product.Commands.Length);
Assert.Equal(1, product.OpaqueCommandCount);
Assert.Equal(1, product.AlphaCutoutCommandCount);
Assert.Single(product.OpaqueCommands.ToArray());
Assert.Single(product.AlphaCutoutCommands.ToArray());
Assert.Single(product.OpaqueBatches.ToArray());
Assert.Single(product.AlphaCutoutBatches.ToArray());
Assert.Single(product.OpaqueRuns.ToArray());
Assert.Single(product.AlphaCutoutRuns.ToArray());
Assert.Equal(0, product.OpaqueRuns[0].StartCommand);
Assert.Equal(1, product.AlphaCutoutRuns[0].StartCommand);
Assert.Equal(2u, product.Commands[0].InstanceCount);
Assert.Equal(0u, product.Commands[0].BaseInstance);
Assert.Equal(1u, product.Commands[1].InstanceCount);
Assert.Equal(2u, product.Commands[1].BaseInstance);
Assert.Equal(DirectionalShadowCasterMaterial.Opaque, product.Batches[0].Material);
Assert.False(product.Batches[0].TextureSlot.IsAssigned);
Assert.Equal(DirectionalShadowCasterMaterial.AlphaCutout, product.Batches[1].Material);
Assert.Equal(17u, product.Batches[1].TextureSlot.Index);
Assert.Equal(6u, product.Batches[1].TextureLayer);
Assert.Contains(first, product.Transforms.ToArray());
Assert.Contains(second, product.Transforms.ToArray());
Assert.Equal(leaves, product.Transforms[2]);
Assert.Equal(3, product.Stats.PreparedInstances);
Assert.Equal(2, product.Stats.RejectedTransparentBatches);
Assert.Equal(1, product.Stats.RejectedFadedParts);
}
/// <summary>
/// Campaign VM VM6 review fix round (A2): two instances of the IDENTICAL
/// mesh subset (same index range, texture, material, cull mode) — one a
/// procedural-scenery entity (top nibble 0x8), one a
/// LandblockStaticEntityIdAllocator entity (top nibble 0xC) sharing the
/// SAME GfxObj — classify to different FoliageWindClassification flags
/// (0x2 cutout vs 0x0) via the identical Classify call the world
/// receiver uses, and must land as two SEPARATE prepared caster
/// batches/commands rather than coalescing into one. This is the
/// caster-side half of "casters and receivers agree by construction":
/// DirectionalShadowDrawKey (the caster's sort/group key, unlike the
/// pre-fix receiver GroupKey) already includes FoliageFlags, so this
/// pins that it stays correct.
/// </summary>
[Fact]
public void SameSubsetDifferentFoliageClassificationNeverCoalescesIntoOneCasterBatch()
{
const uint sceneryEntityId = 0x80010203u; // ProceduralSceneryIdAllocator
const uint landblockStaticEntityId = 0xC0010203u; // LandblockStaticEntityIdAllocator
uint sceneryFlags = FoliageWindClassification.Classify(
sceneryEntityId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
uint landblockStaticFlags = FoliageWindClassification.Classify(
landblockStaticEntityId,
isExcluded: false,
TranslucencyKind.ClipMap,
meshHasCutoutSubset: true);
Assert.Equal(FoliageWindClassification.CutoutFoliageFlag, sceneryFlags);
Assert.Equal(0u, landblockStaticFlags);
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(1);
Assert.True(product.TryBegin(generation, 1, estimatedInstances: 2));
Matrix4x4 sceneryTransform = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 landblockStaticTransform = Matrix4x4.CreateTranslation(9f, 8f, 7f);
GpuTextureSlot sharedTexture = new(42);
product.Add(
firstIndex: 100,
baseVertex: 5,
indexCount: 12,
sharedTexture,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.AlphaCutout,
in sceneryTransform,
sceneryFlags);
product.Add(
firstIndex: 100,
baseVertex: 5,
indexCount: 12,
sharedTexture,
textureLayer: 0,
CullMode.CounterClockwise,
DirectionalShadowCasterMaterial.AlphaCutout,
in landblockStaticTransform,
landblockStaticFlags);
product.Complete(
generation,
1,
new DirectionalShadowPreparationStats(
SourceCasters: 2,
SourceMeshRefs: 2,
SourceParts: 2,
SourceBatches: 2,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 0,
RejectedFadedParts: 0,
MissingMeshes: 0,
UnresolvedAlphaCutoutTextures: 0));
// Two distinct commands/batches, NOT one command with InstanceCount=2
// — the identical geometry/texture/material would have coalesced
// pre-fix, since only entity-level classification (which the caster
// key did not carry before A2) tells them apart.
Assert.Equal(2, product.Commands.Length);
Assert.Equal(2, product.Batches.Length);
Assert.All(product.Commands.ToArray(), command => Assert.Equal(1u, command.InstanceCount));
int sceneryIndex = product.Batches.ToArray()
.ToList()
.FindIndex(batch => batch.FoliageFlags == FoliageWindClassification.CutoutFoliageFlag);
int landblockStaticIndex = product.Batches.ToArray()
.ToList()
.FindIndex(batch => batch.FoliageFlags == 0u);
Assert.True(sceneryIndex >= 0, "expected one prepared batch carrying the cutout foliage flag");
Assert.True(landblockStaticIndex >= 0, "expected one prepared batch carrying zero foliage flags");
Assert.NotEqual(sceneryIndex, landblockStaticIndex);
Assert.Equal(
sceneryTransform,
product.Transforms[(int)product.Commands[sceneryIndex].BaseInstance]);
Assert.Equal(
landblockStaticTransform,
product.Transforms[(int)product.Commands[landblockStaticIndex].BaseInstance]);
}
[Fact]
public void SameCasterBuild_ReplaysWithoutReclassificationOrStorageGrowth()
{
var product = new DirectionalShadowPreparedDraws();
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(8);
Matrix4x4 exactMeshRefTransform = new(
1, 2, 3, 4,
5, 6, 7, 8,
9, 10, 11, 12,
13, 14, 15, 16);
Assert.True(product.TryBegin(generation, 20, estimatedInstances: 1));
product.Add(
1,
2,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in exactMeshRefTransform);
DirectionalShadowPreparationStats stats = default;
product.Complete(generation, 20, in stats);
long retained = product.RetainedScratchBytes;
ulong buildSequence = product.BuildSequence;
Assert.False(product.TryBegin(generation, 20, estimatedInstances: 100));
Assert.Equal(buildSequence, product.BuildSequence);
Assert.Equal(retained, product.RetainedScratchBytes);
Assert.Equal(exactMeshRefTransform, product.Transforms[0]);
Assert.Single(product.Commands.ToArray());
}
[Fact]
public void StableTopology_ComposesSlimPoseWithoutMutatingCasterOrAllocating()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(10);
Matrix4x4 originalRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 originalPart = Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 setupPart = Matrix4x4.CreateRotationX(0.25f)
* Matrix4x4.CreateTranslation(7f, 8f, 9f);
DirectionalShadowCaster[] casters =
[
new DirectionalShadowCaster(
Projection(originalRoot, originalPart),
DirectionalShadowCasterKind.LiveDynamic),
];
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(
generation,
casterBuildSequence: 12,
estimatedInstances: 2,
renderDataAvailabilityVersion: 5,
translucencyFadeRevision: 7));
product.MapCasterIdentity(
0,
casters[0].Projection.Id,
casters[0].Projection.ProjectionClass);
DirectionalShadowTransformSource direct =
DirectionalShadowTransformSource.Dynamic(
casterIndex: 0,
meshIndex: 0,
isSetupPart: false,
in setupPart);
DirectionalShadowTransformSource setup =
DirectionalShadowTransformSource.Dynamic(
casterIndex: 0,
meshIndex: 0,
isSetupPart: true,
in setupPart);
Matrix4x4 originalDirect = originalPart * originalRoot;
Matrix4x4 originalSetup = setupPart * originalPart * originalRoot;
product.Add(
10,
0,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in originalDirect,
in direct);
product.Add(
20,
0,
3,
GpuTextureSlot.Unassigned,
0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in originalSetup,
in setup);
DirectionalShadowPreparationStats stats = default;
product.Complete(
generation,
12,
in stats,
renderDataAvailabilityVersion: 5,
translucencyFadeRevision: 7);
ulong topologyBuild = product.BuildSequence;
float exactRootX = BitConverter.Int32BitsToSingle(0x41234567);
float exactPartY = BitConverter.Int32BitsToSingle(0x40ABCDEF);
Matrix4x4 currentRoot = Matrix4x4.CreateRotationZ(0.15f)
* Matrix4x4.CreateTranslation(exactRootX, 12f, 13f);
Matrix4x4 currentPart = Matrix4x4.CreateRotationY(0.35f)
* Matrix4x4.CreateTranslation(14f, exactPartY, 16f);
RenderProjectionRecord current = Projection(currentRoot, currentPart);
DirectionalShadowTransformSnapshot snapshot =
DirectionalShadowTransformSnapshot.Capture(in current);
DirectionalShadowChangedPose[] changed = [new(0, snapshot)];
product.RefreshDynamicTransforms(changed);
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
AssertMatrixBitsEqual(
setupPart * currentPart * currentRoot,
product.Transforms[1]);
Assert.Equal(topologyBuild, product.BuildSequence);
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
Assert.False(product.RequiresTopologyBuild(generation, 12, 5, 7));
AssertMatrixBitsEqual(
originalRoot,
casters[0].Projection.Transform.LocalToWorld);
AssertMatrixBitsEqual(
originalPart,
casters[0].Projection.EntityPayload.MeshRefs[0].PartTransform);
product.RefreshDynamicTransforms(
ReadOnlySpan<DirectionalShadowChangedPose>.Empty);
Assert.Empty(product.DynamicTransformSlots.ToArray());
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(currentPart * currentRoot, product.Transforms[0]);
AssertMatrixBitsEqual(
setupPart * currentPart * currentRoot,
product.Transforms[1]);
product.RefreshDynamicTransforms(changed, denseRefresh: true);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
product.RefreshDynamicTransforms(changed);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
product.RefreshDynamicTransforms(changed);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
RenderProjectionRecord wrongId = current with
{
Id = RenderProjectionId.FromRaw(99),
};
DirectionalShadowTransformSnapshot wrongSnapshot =
DirectionalShadowTransformSnapshot.Capture(in wrongId);
DirectionalShadowChangedPose[] wrongIdentity = [new(0, wrongSnapshot)];
InvalidOperationException identityFailure = Assert.Throws<
InvalidOperationException>(
() => product.RefreshDynamicTransforms(wrongIdentity));
Assert.Contains("does not match", identityFailure.Message);
DirectionalShadowChangedPose[] staleSlot = [new(1, snapshot)];
InvalidOperationException slotFailure = Assert.Throws<
InvalidOperationException>(
() => product.RefreshDynamicTransforms(staleSlot));
Assert.Contains("stale or unmapped caster index", slotFailure.Message);
}
[Fact]
public void StableTopology_MapsChangedCasterToOnlyItsRetainedTransformSlots()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(12);
Matrix4x4 firstRoot = Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 secondRoot = Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 firstPart = Matrix4x4.CreateTranslation(7f, 8f, 9f);
Matrix4x4 secondPart = Matrix4x4.CreateTranslation(10f, 11f, 12f);
DirectionalShadowCaster[] casters =
[
new DirectionalShadowCaster(
Projection(firstRoot, firstPart),
DirectionalShadowCasterKind.LiveDynamic),
new DirectionalShadowCaster(
Projection(secondRoot, secondPart) with
{
Id = RenderProjectionId.FromRaw(2),
},
DirectionalShadowCasterKind.EquippedChild),
];
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(generation, 13, estimatedInstances: 2));
product.MapCasterIdentity(
0,
casters[0].Projection.Id,
casters[0].Projection.ProjectionClass);
product.MapCasterIdentity(
1,
casters[1].Projection.Id,
casters[1].Projection.ProjectionClass);
Matrix4x4 setupPart = Matrix4x4.Identity;
DirectionalShadowTransformSource firstSource =
DirectionalShadowTransformSource.Dynamic(
0,
0,
false,
in setupPart);
DirectionalShadowTransformSource secondSource =
DirectionalShadowTransformSource.Dynamic(
1,
0,
false,
in setupPart);
Matrix4x4 firstWorld = firstPart * firstRoot;
Matrix4x4 secondWorld = secondPart * secondRoot;
product.Add(
1, 0, 3, GpuTextureSlot.Unassigned, 0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in firstWorld,
in firstSource);
product.Add(
2, 0, 3, GpuTextureSlot.Unassigned, 0,
CullMode.Clockwise,
DirectionalShadowCasterMaterial.Opaque,
in secondWorld,
in secondSource);
DirectionalShadowPreparationStats stats = default;
product.Complete(generation, 13, in stats);
Matrix4x4 movedRoot = Matrix4x4.CreateTranslation(40f, 50f, 60f);
Matrix4x4 movedPart = Matrix4x4.CreateTranslation(70f, 80f, 90f);
casters[1] = casters[1] with
{
Projection = Projection(movedRoot, movedPart) with
{
Id = RenderProjectionId.FromRaw(2),
},
};
product.RefreshDynamicTransforms(casters, [1]);
Assert.Equal([0, 1], product.AllDynamicTransformSlots.ToArray());
Assert.Equal([1], product.DynamicTransformSlots.ToArray());
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
Assert.Equal(1, product.LastDynamicTransformRefreshCount);
product.RefreshDenseDynamicTransforms(casters);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(firstWorld, product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
Matrix4x4 denseFirstRoot = Matrix4x4.CreateTranslation(100f, 101f, 102f);
Matrix4x4 denseFirstPart = Matrix4x4.CreateTranslation(103f, 104f, 105f);
casters[0] = casters[0] with
{
Projection = Projection(denseFirstRoot, denseFirstPart),
};
RenderProjectionRecord firstProjection = casters[0].Projection;
RenderProjectionRecord secondProjection = casters[1].Projection;
DirectionalShadowTransformSnapshot firstPose =
DirectionalShadowTransformSnapshot.Capture(in firstProjection);
DirectionalShadowTransformSnapshot secondPose =
DirectionalShadowTransformSnapshot.Capture(in secondProjection);
DirectionalShadowChangedPose[] reversedDenseChanges =
[
new(1, secondPose),
new(0, firstPose),
];
product.RefreshDynamicTransforms(reversedDenseChanges, denseRefresh: true);
Assert.True(product.LastDynamicTransformRefreshWasDense);
Assert.Equal([0, 1], product.DynamicTransformSlots.ToArray());
Assert.Equal(2, product.LastDynamicTransformRefreshCount);
AssertMatrixBitsEqual(
denseFirstPart * denseFirstRoot,
product.Transforms[0]);
AssertMatrixBitsEqual(movedPart * movedRoot, product.Transforms[1]);
long beforeDense = GC.GetAllocatedBytesForCurrentThread();
for (int iteration = 0; iteration < 256; iteration++)
{
product.RefreshDynamicTransforms(
reversedDenseChanges,
denseRefresh: true);
}
long denseAllocated =
GC.GetAllocatedBytesForCurrentThread() - beforeDense;
Assert.Equal(0, denseAllocated);
Assert.True(product.TryBegin(generation, 14, estimatedInstances: 0));
product.Complete(generation, 14, in stats);
product.RefreshDynamicTransforms(casters, [0]);
Assert.Empty(product.DynamicTransformSlots.ToArray());
Assert.Equal(0, product.LastDynamicTransformRefreshCount);
}
[Fact]
public void ResourceAvailabilityFadeAndPendingTextureInvalidateRetainedTopology()
{
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(11);
var product = new DirectionalShadowPreparedDraws();
Assert.True(product.TryBegin(generation, 2, 0, 20, 30));
var pending = new DirectionalShadowPreparationStats(
SourceCasters: 1,
SourceMeshRefs: 1,
SourceParts: 1,
SourceBatches: 1,
PreparedInstances: 0,
PreparedOpaqueCommands: 0,
PreparedAlphaCutoutCommands: 0,
RejectedTransparentBatches: 0,
RejectedFadedParts: 0,
MissingMeshes: 1,
UnresolvedAlphaCutoutTextures: 0);
product.Complete(generation, 2, in pending, 20, 30);
Assert.False(product.RequiresTopologyBuild(generation, 2, 20, 30));
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
Assert.True(product.RequiresTopologyBuild(generation, 2, 20, 31));
Assert.True(product.TryBegin(generation, 2, 0, 21, 30));
pending = pending with { UnresolvedAlphaCutoutTextures = 1 };
product.Complete(generation, 2, in pending, 21, 30);
Assert.True(product.RequiresTopologyBuild(generation, 2, 21, 30));
}
[Fact]
public void SetupComposition_UsesPublishedMeshRefTransformWithoutAnotherPose()
{
Matrix4x4 root = Matrix4x4.CreateRotationZ(0.1f)
* Matrix4x4.CreateTranslation(50f, 60f, 70f);
Matrix4x4 currentMeshRef = Matrix4x4.CreateRotationY(0.2f)
* Matrix4x4.CreateTranslation(4f, 5f, 6f);
Matrix4x4 authoredSetupPart = Matrix4x4.CreateRotationX(0.3f)
* Matrix4x4.CreateTranslation(1f, 2f, 3f);
Matrix4x4 actual = WbDrawDispatcher.ComposePartWorldMatrix(
root,
currentMeshRef,
authoredSetupPart);
Assert.Equal(authoredSetupPart * currentMeshRef * root, actual);
}
private static RenderProjectionRecord Projection(
in Matrix4x4 root,
in Matrix4x4 part) =>
new RenderProjectionRecord() with
{
Id = RenderProjectionId.FromRaw(1),
ProjectionClass = RenderProjectionClass.LiveDynamicRoot,
Transform = new RenderTransform(root),
EntityPayload = new RenderEntityPayload(
[new MeshRef(0x01000001, part)],
PaletteOverride: null,
IsBuildingShell: false),
};
private static void AssertMatrixBitsEqual(
Matrix4x4 expected,
Matrix4x4 actual)
{
ReadOnlySpan<byte> expectedBits = MemoryMarshal.AsBytes(
MemoryMarshal.CreateReadOnlySpan(ref expected, 1));
ReadOnlySpan<byte> actualBits = MemoryMarshal.AsBytes(
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));
}
}