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>
684 lines
29 KiB
C#
684 lines
29 KiB
C#
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));
|
||
}
|
||
}
|