Round-5 narrow re-review of 754d59d9: APPROVE (verified to the SPIR-V
disassembly; shadows-on path proven opcode-identical). Four
non-blocking nits.
N1 (test robustness): T3 (ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear)
was satisfied by comment prose -- round 5's own explanatory comments
quote the exact plain-pipeline direction substring
("-uLights[i].dirAndRange.xyz, ... matched bit-for-bit"), so the
substring-only assertion passed even with the code mutated. Fixed:
comment lines are now stripped (new StripLineComments helper) before
any assertion, and a new ordered regex per shader asserts the exact
branch SHAPE -- shadowGatedOff ? -uLights[...] : normalize(uShadow...)
-- not just substring presence. Mutation-tested locally against the
rewritten test: (1) swapping the ternary's true/false operands --
FAILED (previously passed); (2) deleting the fallback entirely,
collapsing to the pre-round-5 buggy expression -- FAILED (previously
passed). Original file restored and reverified passing after each
mutation.
N2 (doc accuracy): "numerically the plain pipeline" overstated the
round-5 fix in three places (plan doc, mesh_atmospheric.vert,
terrain_atmospheric.vert). The direction expression is bit-for-bit;
the SUM is not, because the atmospheric shaders' split ambient+point
vs directional accumulation (and terrain's two varyings vs the plain
pipeline's one) reassociates float summation order by ~1 ulp -- which
is exactly the measured mean |Delta| 0.007 the coordinator's own
pixel-proof evidence already recorded (well under the 65 px noise
floor). All three rewritten to say the receiver "matches the plain
pipeline to within float summation-order rounding (measured mean
|Delta| 0.007 on the offline scene)."
N3 (coverage): RenderPrepared's own cascadeCount == 0 exit (the F2
fix) had no direct test even though RenderPrepared already has 8
direct call sites in this file. Added one: ResidentMaximumReachMeters
at/below CameraNearMeters, passed straight to RenderPrepared with an
otherwise-fully-valid environment (so the fitter, not the environment
gate, is what returns zero cascades), asserting IsBindableFor true /
IsValidFor false.
N4 (latent): RenderPrepared's OWN "if (!environment.ShouldRender)"
exit is a fourth bufferless-disabled path -- unreachable via Render
(whose own gate already validates ShouldRender first) but the same
shape, and RenderPrepared is called directly by tests and any future
caller. Took the preferred fix: publishes the disabled binding there
too, via the same helper, so every exit on a frame that draws the
world publishes when the pack's AtmosphericFrame is bound. Also made
EvaluateGateAndPublishDisabledBinding self-contained: it now resets
_currentFrameBinding to Disabled on its own entry instead of relying
on Render having done so first (idempotent with Render's own reset).
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
are comment-only changes (N2), so only the manifest's source hashes
changed -- compiled .spv bytes are unchanged, consistent with round 3's
precedent for comment-only shader edits.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,055/0 failed. Full hermetic-filtered solution: 15,283/0 failed
across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1280 lines
58 KiB
C#
1280 lines
58 KiB
C#
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Packs;
|
|
using AcDream.App.Rendering.Scene;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.Enums;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public sealed class DirectionalShadowGpuTests
|
|
{
|
|
[Fact]
|
|
public void CompleteCasterClassDiagnostics_AddsExactTerrainCommandCount()
|
|
{
|
|
DirectionalShadowCasterBuildStats stats = default;
|
|
stats = stats with
|
|
{
|
|
CasterClasses = new DirectionalShadowCasterClassDiagnostics(
|
|
TerrainCommands: 0,
|
|
OutdoorStatics: 2,
|
|
Buildings: 3,
|
|
AnimatedStatics: 4,
|
|
LocalPlayers: 5,
|
|
RemotePlayers: 6,
|
|
NonPlayerCreatures: 7,
|
|
OtherLiveDynamics: 8,
|
|
EquippedChildren: 9),
|
|
};
|
|
|
|
DirectionalShadowCasterClassDiagnostics completed =
|
|
DirectionalSunShadowRenderer.CompleteCasterClassDiagnostics(
|
|
in stats,
|
|
terrainCommandCount: 11);
|
|
|
|
Assert.Equal(11, completed.TerrainCommands);
|
|
Assert.Equal(stats.CasterClasses with { TerrainCommands = 11 }, completed);
|
|
}
|
|
|
|
[Fact]
|
|
public void Binding6HostLayout_MatchesCheckedInStd140Block()
|
|
{
|
|
Assert.Equal(336, DirectionalShadowUniforms.SizeInBytes);
|
|
Assert.Equal(DirectionalShadowUniforms.SizeInBytes, Marshal.SizeOf<DirectionalShadowUniforms>());
|
|
Assert.Equal(0, Offset(nameof(DirectionalShadowUniforms.WorldToClip0)));
|
|
Assert.Equal(64, Offset(nameof(DirectionalShadowUniforms.WorldToClip1)));
|
|
Assert.Equal(128, Offset(nameof(DirectionalShadowUniforms.WorldToClip2)));
|
|
Assert.Equal(192, Offset(nameof(DirectionalShadowUniforms.WorldToClip3)));
|
|
Assert.Equal(256, Offset(nameof(DirectionalShadowUniforms.SplitFarMeters)));
|
|
Assert.Equal(272, Offset(nameof(DirectionalShadowUniforms.Control)));
|
|
Assert.Equal(288, Offset(nameof(DirectionalShadowUniforms.BiasMeters)));
|
|
Assert.Equal(304, Offset(nameof(DirectionalShadowUniforms.TextureAndFlags)));
|
|
Assert.Equal(320, Offset(nameof(DirectionalShadowUniforms.LightDirectionAndSource)));
|
|
Assert.Equal(16, Marshal.SizeOf<UInt4>());
|
|
|
|
string common = File.ReadAllText(Path.Combine(
|
|
RepositoryRoot(),
|
|
"src", "AcDream.App", "Rendering", "Shaders",
|
|
"directional_shadow_common.glsl"));
|
|
Assert.Contains("ACDREAM_PACK_UBO_SET binding = 6", common, StringComparison.Ordinal);
|
|
Assert.Contains("mat4 uShadowWorldToClip[4]", common, StringComparison.Ordinal);
|
|
Assert.Contains("uvec4 uShadowTextureAndFlags", common, StringComparison.Ordinal);
|
|
Assert.Contains("vec4 uShadowLightDirectionAndSource", common, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ConservativeReceiverBias_UsesFarthestCascadeAndShaderScalesInnerMaps()
|
|
{
|
|
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
|
DirectionalShadowPreset.Low);
|
|
var nearBias = new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f);
|
|
var farBias = new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f);
|
|
DirectionalShadowCascade[] cascades =
|
|
[
|
|
Cascade(0, 20f, nearBias),
|
|
Cascade(1, quality.MaximumReachMeters, farBias),
|
|
];
|
|
var environment = new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f)),
|
|
1f,
|
|
1f,
|
|
1f,
|
|
AuthoredCelestialShadowSourceKind.DominantMoon,
|
|
SourceObjectIndex: 2,
|
|
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
|
|
|
|
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
|
cascades,
|
|
environment,
|
|
quality,
|
|
new GpuTextureSlot(7));
|
|
|
|
Assert.Equal(farBias.ConstantDepthMeters, uniforms.BiasMeters.X);
|
|
Assert.Equal(farBias.SlopeDepthMeters, uniforms.BiasMeters.Y);
|
|
Assert.Equal(farBias.NormalOffsetMeters, uniforms.BiasMeters.Z);
|
|
|
|
string receiver = File.ReadAllText(Path.Combine(
|
|
RepositoryRoot(),
|
|
"src", "AcDream.App", "Rendering", "Shaders",
|
|
"directional_shadow_receiver.glsl"));
|
|
Assert.Contains("farDensity / max(cascadeDensity, 1e-7)", receiver,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("uShadowBiasMeters.xyz * acdreamShadowBiasScale(cascade)", receiver,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Uniforms_CarrySelectedCelestialDirectionAndSourceKind()
|
|
{
|
|
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
|
DirectionalShadowPreset.Low);
|
|
DirectionalShadowCascade[] cascades =
|
|
[
|
|
Cascade(0, 20f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
|
|
Cascade(1, quality.MaximumReachMeters,
|
|
new DirectionalShadowWorldBias(0.11f, 0.12f, 0.13f)),
|
|
];
|
|
Vector3 direction = Vector3.Normalize(new Vector3(0.3f, 0.4f, 0.8f));
|
|
var environment = new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
direction,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
AuthoredCelestialShadowSourceKind.DominantMoon,
|
|
SourceObjectIndex: 2,
|
|
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.DominantMoonGfxObjId);
|
|
|
|
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
|
cascades,
|
|
environment,
|
|
quality,
|
|
new GpuTextureSlot(7));
|
|
|
|
Assert.Equal(
|
|
direction,
|
|
new Vector3(
|
|
uniforms.LightDirectionAndSource.X,
|
|
uniforms.LightDirectionAndSource.Y,
|
|
uniforms.LightDirectionAndSource.Z));
|
|
Assert.Equal(
|
|
(float)AuthoredCelestialShadowSourceKind.DominantMoon,
|
|
uniforms.LightDirectionAndSource.W);
|
|
}
|
|
|
|
[Fact]
|
|
public void UniformReachAndTerminalFadeUseResidentClampedFinalSplit()
|
|
{
|
|
DirectionalShadowQuality quality = DirectionalShadowQuality.For(
|
|
DirectionalShadowPreset.Low);
|
|
DirectionalShadowCascade[] cascades =
|
|
[
|
|
Cascade(0, 18f, new DirectionalShadowWorldBias(0.01f, 0.02f, 0.03f)),
|
|
Cascade(1, 48f, new DirectionalShadowWorldBias(0.04f, 0.05f, 0.06f)),
|
|
];
|
|
|
|
DirectionalShadowUniforms uniforms = DirectionalShadowUniforms.Create(
|
|
cascades,
|
|
EnabledEnvironment(),
|
|
quality,
|
|
new GpuTextureSlot(7));
|
|
|
|
Assert.Equal(48f, uniforms.Control.Z);
|
|
Assert.Equal(1f, uniforms.Control.W);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiviewShadersSelectExactViewMatrixAndPreserveCutout()
|
|
{
|
|
string shaderRoot = Path.Combine(
|
|
RepositoryRoot(),
|
|
"src", "AcDream.App", "Rendering", "Shaders");
|
|
string vertex = File.ReadAllText(Path.Combine(
|
|
shaderRoot,
|
|
"directional_shadow_world_cutout_multiview.vert"));
|
|
string fragment = File.ReadAllText(Path.Combine(
|
|
shaderRoot,
|
|
"directional_shadow_world_cutout_multiview.frag"));
|
|
Assert.Contains("GL_EXT_multiview", vertex, StringComparison.Ordinal);
|
|
Assert.Contains("uShadowWorldToClip[int(gl_ViewIndex)]", vertex,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("Instances[instanceIndex].transform", vertex,
|
|
StringComparison.Ordinal);
|
|
Assert.Contains("texel.a < 0.05", fragment, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1)]
|
|
[InlineData(5)]
|
|
public void DirectionalDepthTarget_RejectsLayerCountsOutsideTwoThroughFour(int layers)
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
device.CreateDirectionalDepthTarget(
|
|
new GpuDirectionalDepthTargetDescription("bad", 1024, layers)));
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectionalDepthTarget_ExposesOneSampleableArrayAndLayerPasses()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
|
|
new GpuDirectionalDepthTargetDescription("shadow", 1536, 3));
|
|
|
|
Assert.Equal(GpuTextureKind.Texture2DArray, target.DepthTexture.Kind);
|
|
Assert.Equal(3, target.DepthTexture.LayerCount);
|
|
Assert.Equal(1536, target.DepthTexture.Width);
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
using (frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-2", target, 2)))
|
|
{
|
|
}
|
|
Assert.Throws<ArgumentOutOfRangeException>(() =>
|
|
frame.BeginPass(GpuPassDescription.DirectionalDepth("cascade-3", target, 3)));
|
|
}
|
|
|
|
[Fact]
|
|
public void DirectionalDepthMultiview_RequiresExactFullMaskAndDeviceCapability()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using IGpuDirectionalDepthTarget target = device.CreateDirectionalDepthTarget(
|
|
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
using (frame.BeginPass(GpuPassDescription.DirectionalDepthMultiview(
|
|
"both-cascades", target, 0b11)))
|
|
{
|
|
}
|
|
Assert.Equal(0b11u, Assert.Single(device.OfKind<GpuRecordedPassBegin>()).ViewMask);
|
|
Assert.Throws<NotSupportedException>(() => frame.BeginPass(
|
|
GpuPassDescription.DirectionalDepthMultiview("partial", target, 0b01)));
|
|
|
|
using var unsupported = new RecordingGpuDevice
|
|
{
|
|
Capabilities = device.Capabilities with { SupportsMultiview = false },
|
|
};
|
|
using IGpuDirectionalDepthTarget unsupportedTarget = unsupported.CreateDirectionalDepthTarget(
|
|
new GpuDirectionalDepthTargetDescription("shadow", 1024, 2));
|
|
using IGpuFrame unsupportedFrame = unsupported.BeginFrame();
|
|
Assert.Throws<NotSupportedException>(() => unsupportedFrame.BeginPass(
|
|
GpuPassDescription.DirectionalDepthMultiview(
|
|
"unsupported", unsupportedTarget, 0b11)));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DirectionalShadowPreset.Low, 2, 768, true)]
|
|
[InlineData(DirectionalShadowPreset.Low, 2, 768, false)]
|
|
[InlineData(DirectionalShadowPreset.Medium, 3, 1536, false)]
|
|
[InlineData(DirectionalShadowPreset.High, 4, 2048, false)]
|
|
internal void Renderer_ReplaysOnePreparedProductAcrossEveryQualityCascade(
|
|
DirectionalShadowPreset preset,
|
|
int expectedCascades,
|
|
int expectedResolution,
|
|
bool multiviewCascades)
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
using var renderer = new DirectionalSunShadowRenderer(
|
|
device,
|
|
preset,
|
|
multiviewCascades: multiviewCascades);
|
|
Assert.Equal(baselineSlots + 1, device.LiveTextureSlotCount);
|
|
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
|
|
Assert.Equal(expectedCascades, target.Description.LayerCount);
|
|
Assert.Equal(expectedResolution, target.Description.Resolution);
|
|
Assert.All(device.CreatedPipelines, pipeline => Assert.False(pipeline.Description.HasColorAttachment));
|
|
|
|
DirectionalShadowPreparedDraws world = CreateWorldDraws(device.DefaultTextureSlot);
|
|
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
|
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
|
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
|
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
|
|
var terrainGeometry = new DirectionalShadowTerrainGeometry(terrainVertices, terrainIndices);
|
|
var environment = new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
|
0.94f,
|
|
0.8f,
|
|
1.25f,
|
|
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
|
SourceObjectIndex: 7,
|
|
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SecondaryMoonGfxObjId);
|
|
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
|
|
frame,
|
|
world.Transforms);
|
|
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 500f),
|
|
cameraNearMeters: 0.1f,
|
|
casterDepthPaddingMeters: 48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
sharedTransforms);
|
|
|
|
int expectedDraws = (multiviewCascades ? 1 : expectedCascades) * 3;
|
|
Assert.Equal(expectedCascades, diagnostics.CascadeCount);
|
|
Assert.Equal(expectedDraws, diagnostics.DrawCalls);
|
|
Assert.Equal(environment.Strength, diagnostics.Strength);
|
|
Assert.Equal(1, diagnostics.WorldOpaqueCommands);
|
|
Assert.Equal(1, diagnostics.WorldAlphaCutoutCommands);
|
|
Assert.Equal(1, diagnostics.TerrainCommands);
|
|
Assert.Equal(1ul, diagnostics.WorldPreparationSequence);
|
|
Assert.Equal(1ul, diagnostics.TerrainPreparationSequence);
|
|
int expectedPasses = multiviewCascades ? 1 : expectedCascades;
|
|
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedPassBegin>().Count());
|
|
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedTimerScope>().Count());
|
|
Assert.Equal(expectedPasses, device.OfKind<GpuRecordedUniformBind>()
|
|
.Count(call => call.Binding == GpuBindingModel.UniformDirectionalShadow));
|
|
GpuRecordedUniformBind shadowUniformBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>()
|
|
.DistinctBy(call => (call.BufferName, call.OffsetBytes, call.SizeBytes)),
|
|
call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
|
|
DirectionalShadowUniforms shadowUniforms = MemoryMarshal.Read<DirectionalShadowUniforms>(
|
|
device.RingBytes.Slice(
|
|
checked((int)shadowUniformBind.OffsetBytes),
|
|
checked((int)shadowUniformBind.SizeBytes)));
|
|
Assert.Equal(
|
|
DirectionalShadowQuality.For(preset).MaximumReachMeters,
|
|
shadowUniforms.BiasMeters.W);
|
|
Assert.Equal(expectedDraws, device.OfKind<GpuRecordedMultiDrawIndirect>().Count());
|
|
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
|
GpuRecordedRingAllocation transformAllocation = Assert.Single(
|
|
device.OfKind<GpuRecordedRingAllocation>(),
|
|
call => call.Usage == GpuRingUsage.Storage
|
|
&& call.ByteCount == WorldTransformCapacityPolicy.InitialBindingSizeBytes);
|
|
RecordingGpuBuffer batchBuffer = Assert.Single(
|
|
device.CreatedBuffers,
|
|
buffer => buffer.Name == "directional-shadow-world-batches-1"
|
|
&& buffer.Usage.HasFlag(GpuBufferUsage.Storage)
|
|
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
|
Span<byte> batchBytes = stackalloc byte[32];
|
|
batchBuffer.Read(0, batchBytes);
|
|
ReadOnlySpan<uint> batchWords = MemoryMarshal.Cast<byte, uint>(batchBytes);
|
|
Assert.Equal(
|
|
0u,
|
|
batchWords[3]);
|
|
Assert.Equal(
|
|
DirectionalShadowBatchFlags.AlphaCutout,
|
|
batchWords[7]);
|
|
Assert.Contains(
|
|
device.CreatedBuffers,
|
|
buffer => buffer.Name == "directional-shadow-world-commands-1"
|
|
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
|
|
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
|
Assert.Contains(
|
|
device.CreatedBuffers,
|
|
buffer => buffer.Name == "directional-shadow-terrain-commands-1"
|
|
&& buffer.Usage.HasFlag(GpuBufferUsage.Indirect)
|
|
&& buffer.Residency == GpuMemoryResidency.DeviceLocal);
|
|
Assert.DoesNotContain(
|
|
device.OfKind<GpuRecordedRingAllocation>(),
|
|
call => call.Usage == GpuRingUsage.Storage
|
|
&& call.ByteCount == world.Transforms.Length * Marshal.SizeOf<Matrix4x4>());
|
|
Assert.All(
|
|
device.OfKind<GpuRecordedStorageBind>()
|
|
.Where(call => call.Binding == GpuBindingModel.StorageInstances),
|
|
call =>
|
|
{
|
|
Assert.Equal(transformAllocation.OffsetBytes, call.OffsetBytes);
|
|
Assert.Equal(WorldTransformCapacityPolicy.InitialBindingSizeBytes, call.SizeBytes);
|
|
});
|
|
for (int cascade = 0; cascade < (multiviewCascades ? 1 : expectedCascades); cascade++)
|
|
{
|
|
Assert.Contains(
|
|
device.OfKind<GpuRecordedPushConstants>(),
|
|
call => call.Constants.RenderPass == cascade);
|
|
}
|
|
if (multiviewCascades)
|
|
{
|
|
GpuRecordedPassBegin pass = Assert.Single(device.OfKind<GpuRecordedPassBegin>());
|
|
Assert.Equal(0b11u, pass.ViewMask);
|
|
Assert.Equal(
|
|
1,
|
|
device.OfKind<GpuRecordedPipelineBind>().Count(call =>
|
|
call.PipelineName == "directional-shadow-world-cutout-multiview"));
|
|
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
|
|
call.PipelineName == "directional-shadow-world-opaque-multiview");
|
|
Assert.Contains(device.OfKind<GpuRecordedPipelineBind>(), call =>
|
|
call.PipelineName == "directional-shadow-terrain-multiview");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round (A3): the receiver pass must not
|
|
/// depend on the caster pass's own AtmosphericFrame bind surviving
|
|
/// un-reset until the receiver pass runs later in the frame — it must
|
|
/// bind set 3/binding 5 itself, from the EXACT buffer/offset/size the
|
|
/// caster bound. This asserts the seam that makes that possible:
|
|
/// TryGetCurrentFrameBinding's DirectionalShadowFrameBinding.AtmosphericFrame
|
|
/// carries the identical buffer identity, offset, and size the caster's
|
|
/// own recorded GpuRecordedUniformBind calls used — the value
|
|
/// WbDrawDispatcher.BindDirectionalShadowReceiver reads to issue its own
|
|
/// bind.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CasterFrameBindingCarriesTheExactAtmosphericFrameBufferForTheReceiverSeam()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Medium);
|
|
DirectionalShadowPreparedDraws world = CreateWorldDraws(device.DefaultTextureSlot);
|
|
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
|
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
|
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
|
var worldGeometry = new DirectionalShadowMeshGeometry(worldVertices, worldIndices);
|
|
var terrainGeometry = new DirectionalShadowTerrainGeometry(terrainVertices, terrainIndices);
|
|
var environment = new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.Normalize(new Vector3(0.1f, 0.2f, 1f)),
|
|
0.9f,
|
|
0.75f,
|
|
1.1f,
|
|
AuthoredCelestialShadowSourceKind.Sun,
|
|
SourceObjectIndex: -1,
|
|
SourceGfxObjId: 0);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "test-atmospheric-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(
|
|
atmosphericBuffer,
|
|
OffsetBytes: 64u,
|
|
SizeBytes: 192u);
|
|
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(frame, world.Transforms);
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 0.1f, 500f),
|
|
cameraNearMeters: 0.1f,
|
|
casterDepthPaddingMeters: 48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
sharedTransforms,
|
|
atmosphericFrame: atmosphericFrame);
|
|
|
|
// The caster pass's OWN recorded bind used exactly this buffer/offset/size.
|
|
GpuRecordedUniformBind casterAtmosphericBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>()
|
|
.DistinctBy(call => (call.BufferName, call.OffsetBytes, call.SizeBytes)),
|
|
call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
Assert.Equal("test-atmospheric-frame", casterAtmosphericBind.BufferName);
|
|
Assert.Equal(64u, casterAtmosphericBind.OffsetBytes);
|
|
Assert.Equal(192u, casterAtmosphericBind.SizeBytes);
|
|
|
|
// The receiver seam carries forward the IDENTICAL binding — this is
|
|
// what BindDirectionalShadowReceiver reads to bind set 3/binding 5
|
|
// itself, rather than depending on the caster's bind surviving
|
|
// un-reset until the receiver pass runs.
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
|
|
Assert.True(binding.AtmosphericFrame.IsBound);
|
|
Assert.Same(atmosphericBuffer, binding.AtmosphericFrame.Buffer);
|
|
Assert.Equal(atmosphericFrame.OffsetBytes, binding.AtmosphericFrame.OffsetBytes);
|
|
Assert.Equal(atmosphericFrame.SizeBytes, binding.AtmosphericFrame.SizeBytes);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round 2 (A3 test gap): the previous test
|
|
/// proves the caster's real bind lands unchanged in
|
|
/// DirectionalShadowFrameBinding.AtmosphericFrame. This proves the OTHER
|
|
/// half of the seam — that WbDrawDispatcher.BindDirectionalShadowReceiver,
|
|
/// given that exact binding, actually emits an encoder bind for
|
|
/// UniformAtmosphericFrame with the SAME buffer/offset/size, not a stale
|
|
/// or default one. Together the two tests prove the caster and receiver
|
|
/// agree end to end without standing up the full
|
|
/// WbDrawDispatcher/mesh-manager/world-pass-scope dependency chain —
|
|
/// BindDirectionalShadowReceiver only needs a bare pass encoder.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BindDirectionalShadowReceiverEmitsAtmosphericFrameWithTheExactCasterBufferOffsetAndSize()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using IGpuBuffer shadowBuffer = Buffer(device, "shadow-frame", GpuBufferUsage.Uniform);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "test-atmospheric-frame", GpuBufferUsage.Uniform);
|
|
var binding = new DirectionalShadowFrameBinding(
|
|
FrameSerial: 1,
|
|
Enabled: true,
|
|
Buffer: shadowBuffer,
|
|
OffsetBytes: 0u,
|
|
SizeBytes: 128u,
|
|
TextureSlot: GpuTextureSlot.Unassigned,
|
|
CascadeCount: 4,
|
|
AtmosphericFrame: new AtmosphericFrameBufferBinding(
|
|
atmosphericBuffer,
|
|
OffsetBytes: 64u,
|
|
SizeBytes: 192u));
|
|
|
|
device.Clear();
|
|
var target = device.CreateRenderTarget(new GpuRenderTargetDescription(
|
|
"test-world-hdr",
|
|
640,
|
|
480,
|
|
GpuTextureFormat.Rgba16FloatRenderTarget,
|
|
GpuTextureFormat.Depth24Stencil8,
|
|
SampleCount: 1));
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
|
|
{
|
|
Name = "test-world-hdr",
|
|
Color = new GpuColorAttachment(
|
|
target,
|
|
GpuLoadOp.Clear,
|
|
GpuStoreOp.Store,
|
|
Vector4.Zero),
|
|
Depth = new GpuDepthAttachment(GpuLoadOp.Clear, GpuStoreOp.Store, 1f, 0),
|
|
SampleCount = 1,
|
|
}))
|
|
{
|
|
WbDrawDispatcher.BindDirectionalShadowReceiver(encoder, in binding);
|
|
}
|
|
frame.End();
|
|
|
|
GpuRecordedUniformBind atmosphericBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>(),
|
|
call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
Assert.Equal("test-atmospheric-frame", atmosphericBind.BufferName);
|
|
Assert.Equal(64u, atmosphericBind.OffsetBytes);
|
|
Assert.Equal(192u, atmosphericBind.SizeBytes);
|
|
|
|
// The shadow-map binding fired too — BindDirectionalShadowReceiver
|
|
// is not a no-op that only happens to satisfy the assertion above.
|
|
GpuRecordedUniformBind shadowBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>(),
|
|
call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
|
|
Assert.Equal("shadow-frame", shadowBind.BufferName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round 4, test (a): the offline pixel gate
|
|
/// found foliage wind welded to "directional shadows rendered this
|
|
/// frame" — a shadow-gated-off frame used to leave
|
|
/// TryGetCurrentFrameBinding returning false, which fell the world
|
|
/// receiver pipeline back to the plain (no-wind) mesh pipeline. This
|
|
/// proves the fix directly against PublishDisabledReceiverBinding
|
|
/// (made internal for exactly this reason — see its doc comment):
|
|
/// given a bound AtmosphericFrame, it publishes a binding that IS
|
|
/// bindable (TryGetCurrentFrameBinding true) but is NOT a valid shadow
|
|
/// (IsValidFor false, Enabled false), and the written block's flags
|
|
/// word is exactly 0 (directional_shadow_receiver.glsl's bit-0-clear
|
|
/// "full visibility" contract). Round 5 correction: the written
|
|
/// direction (0,0,1) is asserted below purely as the documented
|
|
/// normalize()-cannot-NaN GUARD value it is — round 4 mistakenly
|
|
/// treated writing a plausible-looking direction as sufficient; round
|
|
/// 5 fixed the receiver vertex shaders (mesh_atmospheric.vert,
|
|
/// terrain_atmospheric.vert) to ignore this value entirely and fall
|
|
/// back to the authored uLights direction whenever the flags bit is
|
|
/// clear, so (0,0,1) is never read as a real light direction.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PublishDisabledReceiverBinding_IsBindableButNotValidWithZeroFlagsAndUnitDirection()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "wind-only-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(
|
|
atmosphericBuffer,
|
|
OffsetBytes: 0u,
|
|
SizeBytes: 192u);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
renderer.PublishDisabledReceiverBinding(frame, atmosphericFrame);
|
|
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
|
|
Assert.True(binding.IsBindableFor(frame));
|
|
Assert.False(binding.IsValidFor(frame));
|
|
Assert.False(binding.Enabled);
|
|
Assert.Equal(0, binding.CascadeCount);
|
|
Assert.False(binding.TextureSlot.IsAssigned);
|
|
Assert.True(binding.AtmosphericFrame.IsBound);
|
|
Assert.Same(atmosphericBuffer, binding.AtmosphericFrame.Buffer);
|
|
|
|
DirectionalShadowUniforms written = MemoryMarshal.Read<DirectionalShadowUniforms>(
|
|
device.RingBytes.Slice(
|
|
(int)binding.OffsetBytes,
|
|
DirectionalShadowUniforms.SizeInBytes));
|
|
Assert.Equal(0u, written.TextureAndFlags.W);
|
|
// (0,0,1) is the NaN guard, not a light direction — see the doc
|
|
// comment above and PublishDisabledReceiverBinding's own comment.
|
|
Assert.Equal(new Vector4(0f, 0f, 1f, 0f), written.LightDirectionAndSource);
|
|
}
|
|
|
|
[Fact]
|
|
public void PublishDisabledReceiverBinding_NoOpsWhenAtmosphericFrameIsUnboundPreservingTodaysBehaviour()
|
|
{
|
|
// Campaign VM VM6 review fix round 4, test (b): a declared (non
|
|
// built-in) pack never supplies an AtmosphericFrame binding, so
|
|
// this must stay a pure no-op for it — TryGetCurrentFrameBinding
|
|
// keeps returning false exactly like before this fix, and a
|
|
// declared pack's receiver shader (which never declares set 3
|
|
// binding 5) is unaffected.
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
renderer.PublishDisabledReceiverBinding(frame, default);
|
|
|
|
Assert.False(renderer.TryGetCurrentFrameBinding(frame, out _));
|
|
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round 5, test T1 (indoor early-out):
|
|
/// no test calls DirectionalSunShadowRenderer.Render because it needs
|
|
/// a real WbDrawDispatcher + TerrainModernRenderer to construct (still
|
|
/// true — nothing in this suite stands either up), so the gate
|
|
/// prologue Render runs before touching either is extracted into
|
|
/// EvaluateGateAndPublishDisabledBinding (behaviour-preserving; see
|
|
/// its own doc comment) and tested directly here. PlayerInsideCell is
|
|
/// the simplest trigger for Render's FIRST early-out
|
|
/// (!environment.ShouldRender).
|
|
/// </summary>
|
|
[Fact]
|
|
public void EvaluateGateAndPublishDisabledBinding_PlayerInsideCellPublishesBindableNotValidBinding()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-indoor-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
|
|
var input = new DirectionalSunShadowRenderInput(
|
|
new DirectionalShadowEnvironmentInput(
|
|
PackEnabled: true,
|
|
PortalOrLoginCoverVisible: false,
|
|
PlayerInsideCell: true,
|
|
Source: AuthoredCelestialShadowSource.None(),
|
|
Atmosphere: default),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
new DirectionalShadowCasterFrame(),
|
|
AtmosphericFrame: atmosphericFrame);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
DirectionalSunShadowDiagnostics? gated = renderer.EvaluateGateAndPublishDisabledBinding(
|
|
frame,
|
|
in input,
|
|
out DirectionalShadowEnvironmentState environment,
|
|
out long environmentGateTicks);
|
|
|
|
Assert.NotNull(gated);
|
|
Assert.Equal(DirectionalShadowGateReason.Indoor, gated.Value.GateReason);
|
|
Assert.Equal(DirectionalShadowGateReason.Indoor, environment.Reason);
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
|
|
Assert.True(binding.IsBindableFor(frame));
|
|
Assert.False(binding.IsValidFor(frame));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign VM VM6 review fix round 5, test T1 (resident-window
|
|
/// early-out): Render's SECOND early-out. Reaching it needs a fully
|
|
/// valid environment (ShouldRender true), so the environment input
|
|
/// below constructs a real celestial source above the horizon with
|
|
/// energy, and a fully-progressed atmosphere snapshot — anything less
|
|
/// would gate off at the FIRST check instead of reaching
|
|
/// ResidentMaximumReachMeters.
|
|
/// </summary>
|
|
[Fact]
|
|
public void EvaluateGateAndPublishDisabledBinding_ResidentWindowUnavailablePublishesBindableNotValidBinding()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-resident-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
|
|
var validSource = new AuthoredCelestialShadowSource(
|
|
AuthoredCelestialShadowSourceKind.Sun,
|
|
ObjectIndex: 0,
|
|
GfxObjId: 1u,
|
|
SurfaceToLightDirection: Vector3.UnitZ,
|
|
ElevationSin: 0.5f,
|
|
AuthoredEnergy: 1f);
|
|
var validAtmosphere = new AtmosphereSnapshot(
|
|
WeatherKind.Clear,
|
|
Intensity: 1f,
|
|
FogColor: Vector3.Zero,
|
|
FogStart: 0f,
|
|
FogEnd: 0f,
|
|
FogMode: default,
|
|
LightningFlash: 0f,
|
|
Override: default);
|
|
var input = new DirectionalSunShadowRenderInput(
|
|
new DirectionalShadowEnvironmentInput(
|
|
PackEnabled: true,
|
|
PortalOrLoginCoverVisible: false,
|
|
PlayerInsideCell: false,
|
|
Source: validSource,
|
|
Atmosphere: validAtmosphere,
|
|
ActiveDayGroupMultiplier: 1f),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
new DirectionalShadowCasterFrame(),
|
|
CameraNearMeters: 0.1f,
|
|
ResidentMaximumReachMeters: 0.05f, // <= CameraNearMeters
|
|
AtmosphericFrame: atmosphericFrame);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
DirectionalSunShadowDiagnostics? gated = renderer.EvaluateGateAndPublishDisabledBinding(
|
|
frame,
|
|
in input,
|
|
out DirectionalShadowEnvironmentState environment,
|
|
out long environmentGateTicks);
|
|
|
|
Assert.NotNull(gated);
|
|
Assert.Equal(DirectionalShadowGateReason.ResidentWindowUnavailable, gated.Value.GateReason);
|
|
Assert.Equal(DirectionalShadowGateReason.ResidentWindowUnavailable, environment.Reason);
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
|
|
Assert.True(binding.IsBindableFor(frame));
|
|
Assert.False(binding.IsValidFor(frame));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldSelectReceiverPipelineComposesWithTheRealBindingSourceAfterADisabledPublish()
|
|
{
|
|
// Campaign VM VM6 review fix round 5, test T2: proves the ACTUAL
|
|
// composition WbDrawDispatcher.PipelinesFor and
|
|
// TerrainModernRenderer both use — TryGetCurrentFrameBinding
|
|
// feeding ShouldSelectReceiverPipeline — selects the receiver
|
|
// pipeline for the atmospheric world pass once a disabled binding
|
|
// is published, and still refuses a non-atmospheric pass name.
|
|
// ShouldSelectReceiverPipeline itself is unchanged this round;
|
|
// this is the composition its own DirectionalShadowReceiverTests
|
|
// parametrized coverage does not exercise.
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-pipeline-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
renderer.PublishDisabledReceiverBinding(frame, atmosphericFrame);
|
|
bool bindingValid = renderer.TryGetCurrentFrameBinding(frame, out _);
|
|
|
|
Assert.True(bindingValid);
|
|
Assert.True(DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
|
|
DirectionalShadowReceiverPolicy.AtmosphericWorldPassName,
|
|
sourcePresent: true,
|
|
bindingValid));
|
|
Assert.False(DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
|
|
"vk-world",
|
|
sourcePresent: true,
|
|
bindingValid));
|
|
}
|
|
|
|
[Fact]
|
|
public void RenderPrepared_CascadeFitterReturningZeroCascadesPublishesBindableNotValidBinding()
|
|
{
|
|
// Campaign VM VM6 review fix round 5 (F2), coverage added round 6
|
|
// (N3): RenderPrepared's OWN cascadeCount == 0 exit is reachable
|
|
// from a frame that already passed the environment gate
|
|
// (ShouldRender true) but whose cascade fitter still finds zero
|
|
// usable cascades — DirectionalShadowCascadeFitter.Fit returns 0
|
|
// whenever min(Quality.MaximumReachMeters,
|
|
// ResidentMaximumReachMeters) <= CameraNearMeters, the same
|
|
// effective constraint Render's own early-out checks, but
|
|
// reached HERE via the fitter's own arithmetic on a frame that
|
|
// got PAST that early-out (this test drives RenderPrepared
|
|
// directly, bypassing Render's gate entirely, to isolate the
|
|
// fitter's own zero-cascade path). Proves it publishes the same
|
|
// disabled receiver binding Render's two early-outs do.
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "cascade-zero-frame", GpuBufferUsage.Uniform);
|
|
var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
WorldTransformFrameSlice transforms = PublishSharedTransforms(
|
|
frame,
|
|
ReadOnlySpan<Matrix4x4>.Empty);
|
|
|
|
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
|
frame,
|
|
EnabledEnvironment(),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
|
0.1f,
|
|
48f,
|
|
new DirectionalShadowPreparedDraws(),
|
|
new DirectionalShadowTerrainPreparedDraws(),
|
|
null,
|
|
null,
|
|
transforms,
|
|
residentMaximumReachMeters: 0.05f, // <= cameraNearMeters (0.1f): the fitter returns 0
|
|
atmosphericFrame: atmosphericFrame);
|
|
|
|
Assert.Equal(DirectionalShadowGateReason.ResidentWindowUnavailable, diagnostics.GateReason);
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
|
|
Assert.True(binding.IsBindableFor(frame));
|
|
Assert.False(binding.IsValidFor(frame));
|
|
}
|
|
|
|
[Fact]
|
|
public void BindDirectionalShadowReceiver_WithADisabledBindingEmitsBothShadowAndAtmosphericBinds()
|
|
{
|
|
// Campaign VM VM6 review fix round 4, test (c): the whole point of
|
|
// publishing a disabled-content binding instead of leaving
|
|
// TryGetCurrentFrameBinding at false is that BindDirectionalShadowReceiver
|
|
// (fixed in this same round to check Buffer, not Enabled) actually
|
|
// emits BOTH binds for it — the safe all-zero shadow block AND the
|
|
// real wind data — so the world receiver pipeline (selected because
|
|
// the binding is bindable) has everything it declares.
|
|
using var device = new RecordingGpuDevice();
|
|
using IGpuBuffer shadowBuffer = Buffer(device, "disabled-shadow-frame", GpuBufferUsage.Uniform);
|
|
using IGpuBuffer atmosphericBuffer = Buffer(device, "disabled-atmospheric-frame", GpuBufferUsage.Uniform);
|
|
var binding = new DirectionalShadowFrameBinding(
|
|
FrameSerial: 1,
|
|
Enabled: false,
|
|
Buffer: shadowBuffer,
|
|
OffsetBytes: 0u,
|
|
SizeBytes: DirectionalShadowUniforms.SizeInBytes,
|
|
TextureSlot: GpuTextureSlot.Unassigned,
|
|
CascadeCount: 0,
|
|
AtmosphericFrame: new AtmosphericFrameBufferBinding(
|
|
atmosphericBuffer,
|
|
OffsetBytes: 0u,
|
|
SizeBytes: 192u));
|
|
|
|
device.Clear();
|
|
var target = device.CreateRenderTarget(new GpuRenderTargetDescription(
|
|
"test-world-hdr",
|
|
640,
|
|
480,
|
|
GpuTextureFormat.Rgba16FloatRenderTarget,
|
|
GpuTextureFormat.Depth24Stencil8,
|
|
SampleCount: 1));
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
|
|
{
|
|
Name = "test-world-hdr",
|
|
Color = new GpuColorAttachment(
|
|
target,
|
|
GpuLoadOp.Clear,
|
|
GpuStoreOp.Store,
|
|
Vector4.Zero),
|
|
Depth = new GpuDepthAttachment(GpuLoadOp.Clear, GpuStoreOp.Store, 1f, 0),
|
|
SampleCount = 1,
|
|
}))
|
|
{
|
|
WbDrawDispatcher.BindDirectionalShadowReceiver(encoder, in binding);
|
|
}
|
|
frame.End();
|
|
|
|
GpuRecordedUniformBind shadowBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>(),
|
|
call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
|
|
Assert.Equal("disabled-shadow-frame", shadowBind.BufferName);
|
|
GpuRecordedUniformBind atmosphericBind = Assert.Single(
|
|
device.OfKind<GpuRecordedUniformBind>(),
|
|
call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
Assert.Equal("disabled-atmospheric-frame", atmosphericBind.BufferName);
|
|
}
|
|
|
|
[Fact]
|
|
public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(
|
|
device,
|
|
DirectionalShadowPreset.Low,
|
|
multiviewCascades: true);
|
|
DirectionalShadowPreparedDraws world = CreateWorldDraws(
|
|
device.DefaultTextureSlot);
|
|
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
|
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
|
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
|
var worldGeometry = new DirectionalShadowMeshGeometry(
|
|
worldVertices,
|
|
worldIndices);
|
|
var terrainGeometry = new DirectionalShadowTerrainGeometry(
|
|
terrainVertices,
|
|
terrainIndices);
|
|
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
|
|
|
|
using (IGpuFrame frame = device.BeginFrame())
|
|
{
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
|
0.1f,
|
|
48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
PublishSharedTransforms(frame, world.Transforms));
|
|
}
|
|
|
|
RecordingGpuBuffer[] retained = device.CreatedBuffers
|
|
.Where(buffer => buffer.Name.StartsWith(
|
|
"directional-shadow-",
|
|
StringComparison.Ordinal))
|
|
.ToArray();
|
|
Assert.Equal(3, retained.Length);
|
|
Assert.Equal(3, renderer.RetainedCommandBufferCount);
|
|
Assert.Equal(retained.Sum(buffer => buffer.SizeBytes),
|
|
renderer.RetainedCommandBufferBytes);
|
|
|
|
device.Clear();
|
|
int createdBefore = device.CreatedBuffers.Count;
|
|
using (IGpuFrame frame = device.BeginFrame())
|
|
{
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
|
0.1f,
|
|
48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
PublishSharedTransforms(frame, world.Transforms));
|
|
}
|
|
|
|
Assert.Equal(createdBefore, device.CreatedBuffers.Count);
|
|
Assert.All(retained, buffer => Assert.False(buffer.IsDisposed));
|
|
Assert.Equal(2, device.OfKind<GpuRecordedRingAllocation>().Count());
|
|
Assert.DoesNotContain(
|
|
device.OfKind<GpuRecordedRingAllocation>(),
|
|
allocation => allocation.Usage == GpuRingUsage.Indirect);
|
|
}
|
|
|
|
[Fact]
|
|
public void TopologyRebuild_SwapsRetainedBuffersAndDisposalReleasesTheCurrentSet()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var renderer = new DirectionalSunShadowRenderer(
|
|
device,
|
|
DirectionalShadowPreset.Low);
|
|
DirectionalShadowPreparedDraws world = CreateWorldDraws(
|
|
device.DefaultTextureSlot);
|
|
DirectionalShadowTerrainPreparedDraws terrain = CreateTerrainDraws();
|
|
using IGpuBuffer worldVertices = Buffer(device, "world-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer worldIndices = Buffer(device, "world-i", GpuBufferUsage.Index);
|
|
using IGpuBuffer terrainVertices = Buffer(device, "terrain-v", GpuBufferUsage.Vertex);
|
|
using IGpuBuffer terrainIndices = Buffer(device, "terrain-i", GpuBufferUsage.Index);
|
|
var worldGeometry = new DirectionalShadowMeshGeometry(
|
|
worldVertices,
|
|
worldIndices);
|
|
var terrainGeometry = new DirectionalShadowTerrainGeometry(
|
|
terrainVertices,
|
|
terrainIndices);
|
|
DirectionalShadowEnvironmentState environment = EnabledEnvironment();
|
|
|
|
using (IGpuFrame frame = device.BeginFrame())
|
|
{
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
|
0.1f,
|
|
48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
PublishSharedTransforms(frame, world.Transforms));
|
|
}
|
|
RecordingGpuBuffer[] firstSet = device.CreatedBuffers
|
|
.Where(buffer => buffer.Name.StartsWith(
|
|
"directional-shadow-",
|
|
StringComparison.Ordinal))
|
|
.ToArray();
|
|
|
|
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
|
|
Assert.True(world.TryBegin(generation, 8, 1));
|
|
Matrix4x4 moved = Matrix4x4.CreateTranslation(20f, 30f, 40f);
|
|
world.Add(
|
|
30,
|
|
2,
|
|
9,
|
|
GpuTextureSlot.Unassigned,
|
|
0,
|
|
CullMode.Clockwise,
|
|
DirectionalShadowCasterMaterial.Opaque,
|
|
in moved);
|
|
DirectionalShadowPreparationStats stats = default;
|
|
world.Complete(generation, 8, in stats);
|
|
|
|
Assert.True(terrain.TryBegin(2, 1));
|
|
var terrainRange = new DirectionalShadowTerrainRange(80, 90);
|
|
terrain.Add(in terrainRange);
|
|
terrain.Complete(2);
|
|
using (IGpuFrame frame = device.BeginFrame())
|
|
{
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
environment,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 500f),
|
|
0.1f,
|
|
48f,
|
|
world,
|
|
terrain,
|
|
worldGeometry,
|
|
terrainGeometry,
|
|
PublishSharedTransforms(frame, world.Transforms));
|
|
}
|
|
|
|
Assert.All(firstSet, buffer => Assert.True(buffer.IsDisposed));
|
|
RecordingGpuBuffer[] currentSet = device.CreatedBuffers
|
|
.Where(buffer => buffer.Name.EndsWith("-2", StringComparison.Ordinal))
|
|
.ToArray();
|
|
Assert.Equal(3, currentSet.Length);
|
|
Assert.All(currentSet, buffer => Assert.False(buffer.IsDisposed));
|
|
|
|
renderer.Dispose();
|
|
|
|
Assert.All(currentSet, buffer => Assert.True(buffer.IsDisposed));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DirectionalShadowGateReason.Indoor)]
|
|
[InlineData(DirectionalShadowGateReason.SelectedLightBelowHorizon)]
|
|
[InlineData(DirectionalShadowGateReason.SelectedLightHasNoEnergy)]
|
|
[InlineData(DirectionalShadowGateReason.NoVisibleCelestial)]
|
|
[InlineData(DirectionalShadowGateReason.PackDisabled)]
|
|
internal void DisabledEnvironment_RecordsNoPassOrUpload(DirectionalShadowGateReason reason)
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
|
|
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
|
frame,
|
|
new DirectionalShadowEnvironmentState(reason, Vector3.UnitZ, 0f, 0f, 1f),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
0.1f,
|
|
48f,
|
|
new DirectionalShadowPreparedDraws(),
|
|
new DirectionalShadowTerrainPreparedDraws(),
|
|
null,
|
|
null,
|
|
default);
|
|
|
|
Assert.Equal(reason, diagnostics.GateReason);
|
|
Assert.Empty(device.OfKind<GpuRecordedPassBegin>());
|
|
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
|
|
}
|
|
|
|
[Fact]
|
|
public void Disposal_ReleasesTextureSlotAndEveryOwnedResource()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.High);
|
|
RecordingGpuDirectionalDepthTarget target = Assert.Single(device.CreatedDirectionalDepthTargets);
|
|
RecordingGpuPipeline[] pipelines = device.CreatedPipelines.ToArray();
|
|
RecordingGpuSampler sampler = device.CreatedSamplers[^1];
|
|
|
|
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, sampler.Description);
|
|
Assert.Equal(GpuFilter.Nearest, sampler.Description.MinFilter);
|
|
Assert.Equal(GpuFilter.Nearest, sampler.Description.MagFilter);
|
|
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressU);
|
|
Assert.Equal(GpuAddressMode.ClampToEdge, sampler.Description.AddressV);
|
|
|
|
renderer.Dispose();
|
|
|
|
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
|
Assert.True(target.IsDisposed);
|
|
Assert.True(sampler.IsDisposed);
|
|
Assert.All(pipelines, pipeline => Assert.True(pipeline.IsDisposed));
|
|
}
|
|
|
|
[Fact]
|
|
public void ConstructionFailure_RollsBackTargetSlotSamplerAndEarlierPipelines()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
device.PipelineFailure = description =>
|
|
description.Name == "directional-shadow-world-opaque"
|
|
? new InvalidOperationException("injected pipeline failure")
|
|
: null;
|
|
|
|
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
|
new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Medium));
|
|
|
|
Assert.Equal("injected pipeline failure", failure.Message);
|
|
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
|
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
|
|
Assert.True(device.CreatedSamplers[^1].IsDisposed);
|
|
Assert.True(Assert.Single(device.CreatedPipelines).IsDisposed);
|
|
}
|
|
|
|
[Fact]
|
|
public void MultiviewConstructionFailure_RetiresAllOrdinaryAndLayeredCandidates()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
device.PipelineFailure = description =>
|
|
description.Name == "directional-shadow-world-cutout-multiview"
|
|
? new InvalidOperationException("injected multiview failure")
|
|
: null;
|
|
|
|
Assert.Throws<InvalidOperationException>(() =>
|
|
new DirectionalSunShadowRenderer(
|
|
device,
|
|
DirectionalShadowPreset.Low,
|
|
multiviewCascades: true));
|
|
|
|
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
|
Assert.True(Assert.Single(device.CreatedDirectionalDepthTargets).IsDisposed);
|
|
Assert.True(device.CreatedSamplers[^1].IsDisposed);
|
|
Assert.Equal(5, device.CreatedPipelines.Count);
|
|
Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed));
|
|
}
|
|
|
|
[Fact]
|
|
public void RebuildAfterDisposal_UsesANewLiveShadowSampler()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
var first = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
RecordingGpuSampler firstSampler = device.CreatedSamplers[^1];
|
|
first.Dispose();
|
|
|
|
using var second = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
|
|
RecordingGpuSampler secondSampler = device.CreatedSamplers[^1];
|
|
|
|
Assert.NotSame(firstSampler, secondSampler);
|
|
Assert.True(firstSampler.IsDisposed);
|
|
Assert.False(secondSampler.IsDisposed);
|
|
Assert.Equal(GpuSamplerDescription.ShadowNearestClamp, secondSampler.Description);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReceiverBinding_IsValidOnlyForTheProducingFrame()
|
|
{
|
|
using var device = new RecordingGpuDevice();
|
|
using var renderer = new DirectionalSunShadowRenderer(
|
|
device,
|
|
DirectionalShadowPreset.Low);
|
|
using (IGpuFrame frame = device.BeginFrame())
|
|
{
|
|
WorldTransformFrameSlice sharedTransforms = PublishSharedTransforms(
|
|
frame,
|
|
ReadOnlySpan<Matrix4x4>.Empty);
|
|
renderer.RenderPrepared(
|
|
frame,
|
|
new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.UnitZ,
|
|
1f,
|
|
1f,
|
|
1f,
|
|
AuthoredCelestialShadowSourceKind.Sun,
|
|
SourceObjectIndex: 0,
|
|
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(1f, 1f, 0.1f, 100f),
|
|
0.1f,
|
|
48f,
|
|
new DirectionalShadowPreparedDraws(),
|
|
new DirectionalShadowTerrainPreparedDraws(),
|
|
null,
|
|
null,
|
|
sharedTransforms);
|
|
|
|
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out var binding));
|
|
Assert.Equal(frame.Serial, binding.FrameSerial);
|
|
Assert.Equal((uint)DirectionalShadowUniforms.SizeInBytes, binding.SizeBytes);
|
|
Assert.Equal(2, binding.CascadeCount);
|
|
}
|
|
|
|
using IGpuFrame later = device.BeginFrame();
|
|
Assert.False(renderer.TryGetCurrentFrameBinding(later, out _));
|
|
}
|
|
|
|
private static DirectionalShadowPreparedDraws CreateWorldDraws(GpuTextureSlot cutoutSlot)
|
|
{
|
|
var draws = new DirectionalShadowPreparedDraws();
|
|
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(3);
|
|
Assert.True(draws.TryBegin(generation, 7, 2));
|
|
Matrix4x4 opaque = Matrix4x4.CreateTranslation(1f, 2f, 3f);
|
|
Matrix4x4 cutout = Matrix4x4.CreateRotationZ(0.3f) * Matrix4x4.CreateTranslation(4f, 5f, 6f);
|
|
draws.Add(0, 0, 6, GpuTextureSlot.Unassigned, 0, CullMode.CounterClockwise,
|
|
DirectionalShadowCasterMaterial.Opaque, in opaque);
|
|
draws.Add(6, 4, 12, cutoutSlot, 2, CullMode.None,
|
|
DirectionalShadowCasterMaterial.AlphaCutout, in cutout);
|
|
DirectionalShadowPreparationStats stats = default;
|
|
draws.Complete(generation, 7, in stats);
|
|
return draws;
|
|
}
|
|
|
|
private static DirectionalShadowCascade Cascade(
|
|
int index,
|
|
float splitFarMeters,
|
|
DirectionalShadowWorldBias bias) =>
|
|
new(
|
|
index,
|
|
index == 0 ? 0.1f : 20f,
|
|
splitFarMeters,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.Identity,
|
|
Vector2.Zero,
|
|
10f,
|
|
0.1f,
|
|
48f,
|
|
bias);
|
|
|
|
private static DirectionalShadowEnvironmentState EnabledEnvironment() =>
|
|
new(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
|
0.94f,
|
|
0.8f,
|
|
1.25f,
|
|
AuthoredCelestialShadowSourceKind.Sun,
|
|
SourceObjectIndex: 0,
|
|
SourceGfxObjId: AuthoredCelestialShadowSourceResolver.SunGfxObjId);
|
|
|
|
private static DirectionalShadowTerrainPreparedDraws CreateTerrainDraws()
|
|
{
|
|
var draws = new DirectionalShadowTerrainPreparedDraws();
|
|
Assert.True(draws.TryBegin(1, 1));
|
|
var range = new DirectionalShadowTerrainRange(20, 60);
|
|
draws.Add(in range);
|
|
draws.Complete(1);
|
|
return draws;
|
|
}
|
|
|
|
private static IGpuBuffer Buffer(RecordingGpuDevice device, string name, GpuBufferUsage usage) =>
|
|
device.CreateBuffer(new GpuBufferDescription(
|
|
name,
|
|
4096,
|
|
usage | GpuBufferUsage.TransferDestination,
|
|
GpuMemoryResidency.DeviceLocal));
|
|
|
|
private static WorldTransformFrameSlice PublishSharedTransforms(
|
|
IGpuFrame frame,
|
|
ReadOnlySpan<Matrix4x4> transforms)
|
|
{
|
|
GpuRingAllocation allocation = frame.AllocateRing(
|
|
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
|
|
GpuRingUsage.Storage);
|
|
if (!transforms.IsEmpty)
|
|
MemoryMarshal.AsBytes(transforms).CopyTo(allocation.Data);
|
|
return new WorldTransformFrameSlice(
|
|
frame.Serial,
|
|
allocation.Buffer,
|
|
allocation.OffsetBytes,
|
|
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
|
FirstInstance: 0,
|
|
checked((uint)transforms.Length));
|
|
}
|
|
|
|
private static int Offset(string field) =>
|
|
checked((int)Marshal.OffsetOf<DirectionalShadowUniforms>(field));
|
|
|
|
private static string RepositoryRoot()
|
|
{
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
|
directory = directory.Parent;
|
|
return directory?.FullName
|
|
?? throw new InvalidOperationException("Could not locate repository root.");
|
|
}
|
|
}
|