Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.
F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.
F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).
F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.
F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.
T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.
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.
T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.
T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1818 lines
79 KiB
C#
1818 lines
79 KiB
C#
using System.Globalization;
|
|
using System.Numerics;
|
|
using System.Runtime.InteropServices;
|
|
using AcDream.App.Plugins;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.App.Rendering.Gpu;
|
|
using AcDream.App.Rendering.Packs;
|
|
using AcDream.App.Rendering.Wb;
|
|
using AcDream.App.Tests.Rendering.Gpu;
|
|
using AcDream.Core.World;
|
|
using AcDream.Plugin.Abstractions.Rendering;
|
|
using AcDream.UI.Abstractions.Panels.Settings;
|
|
|
|
namespace AcDream.App.Tests.Rendering.Packs;
|
|
|
|
public sealed class AtmosphericPostProcessGraphTests
|
|
{
|
|
[Fact]
|
|
public void LowSamplesEveryPostTimerTogetherOnEveryFourthFrame()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "low");
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1);
|
|
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
|
|
|
for (int serial = 1; serial <= 4; serial++)
|
|
{
|
|
device.Clear();
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
string[] measured = device.OfKind<GpuRecordedTimerScope>()
|
|
.Select(static scope => scope.Name)
|
|
.Where(static name => name.StartsWith(
|
|
"atmospheric-",
|
|
StringComparison.Ordinal))
|
|
.ToArray();
|
|
if (serial < AtmosphericGpuTimerSampling.LowIntervalFrames)
|
|
{
|
|
Assert.Empty(measured);
|
|
}
|
|
else
|
|
{
|
|
Assert.Equal(
|
|
[
|
|
"atmospheric-sun-occlusion",
|
|
"atmospheric-sun-rays",
|
|
"atmospheric-bloom-downsample",
|
|
"atmospheric-bloom-blur-horizontal",
|
|
"atmospheric-bloom-blur-vertical",
|
|
"atmospheric-filmic",
|
|
],
|
|
measured);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void LowUsesQuarterResolutionSeparableBloomWithoutDroppingHeadlineInputs()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "low");
|
|
Assert.True(Assert.IsType<DirectionalSunShadowRenderer>(
|
|
graph.DirectionalShadowReceivers).MultiviewCascadesEnabled);
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
Assert.Equal(
|
|
[
|
|
"test-world-hdr",
|
|
"atmospheric-sun-occlusion",
|
|
"atmospheric-sun-rays",
|
|
"atmospheric-bloom-downsample",
|
|
"atmospheric-bloom-blur-horizontal",
|
|
"atmospheric-bloom-blur-vertical",
|
|
"atmospheric-filmic",
|
|
],
|
|
device.OfKind<GpuRecordedPassBegin>().Select(call => call.Name));
|
|
GpuRecordedUniformBind[] passBlocks = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.Where(call => call.Binding == GpuBindingModel.UniformPackPass)
|
|
.ToArray();
|
|
Assert.Equal(6, passBlocks.Length);
|
|
Assert.All(
|
|
device.OfKind<GpuRecordedUniformBind>().Where(call =>
|
|
call.Binding is GpuBindingModel.UniformAtmosphericFrame
|
|
or GpuBindingModel.UniformPackPass
|
|
or GpuBindingModel.UniformPackSettings),
|
|
call => Assert.Equal(
|
|
0u,
|
|
call.OffsetBytes
|
|
% device.Capabilities.MinUniformBufferOffsetAlignment));
|
|
|
|
AtmosphericPackPassUniforms rays = ReadPass(device, passBlocks[1]);
|
|
Assert.Equal(Vector4.Zero, rays.Params1);
|
|
AtmosphericPackPassUniforms bloom = ReadPass(device, passBlocks[2]);
|
|
Assert.Equal(
|
|
new Vector4(
|
|
graph.Settings.BloomStrength,
|
|
AtmosphericPostProcessGraph.BloomThresholdLinear,
|
|
AtmosphericPostProcessGraph.BloomKneeLinear,
|
|
0f),
|
|
bloom.Params0);
|
|
AtmosphericPackPassUniforms horizontal = ReadPass(device, passBlocks[3]);
|
|
Assert.Equal(new Vector4(1f / 320f, 0f, 0f, 0f), horizontal.Params0);
|
|
AtmosphericPackPassUniforms vertical = ReadPass(device, passBlocks[4]);
|
|
Assert.Equal(new Vector4(0f, 1f / 180f, 0f, 0f), vertical.Params0);
|
|
AtmosphericPackPassUniforms filmic = ReadPass(device, passBlocks[5]);
|
|
Assert.Equal(0f, filmic.Params1.Z);
|
|
Assert.Equal(Vector4.Zero, filmic.Params2);
|
|
Assert.Equal(Vector4.Zero, filmic.Params3);
|
|
Assert.Contains(device.CreatedRenderTargets, target =>
|
|
target.Description.Name == "atmospheric-bloom-a"
|
|
&& target.Description.Width == 320
|
|
&& target.Description.Height == 180);
|
|
Assert.Contains(device.CreatedRenderTargets, target =>
|
|
target.Description.Name == "atmospheric-bloom-b"
|
|
&& target.Description.Width == 320
|
|
&& target.Description.Height == 180);
|
|
|
|
GpuRecordedPushConstants[] pushes = device
|
|
.OfKind<GpuRecordedPushConstants>()
|
|
.ToArray();
|
|
Assert.Equal(6, pushes.Length);
|
|
Assert.All(pushes, push =>
|
|
Assert.NotEqual(uint.MaxValue, push.Constants.TextureIndexA));
|
|
Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexB);
|
|
Assert.NotEqual(uint.MaxValue, pushes[5].Constants.TextureIndexB);
|
|
|
|
RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics();
|
|
Assert.Equal(6, diagnostics.DrawCalls);
|
|
Assert.Equal(7, diagnostics.ImageCount);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-sun-occlusion" && pass.DrawCalls == 1);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-sun-rays" && pass.DrawCalls == 1);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-bloom-downsample" && pass.DrawCalls == 1);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-bloom-blur-horizontal" && pass.DrawCalls == 1);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-bloom-blur-vertical" && pass.DrawCalls == 1);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == "atmospheric-filmic" && pass.DrawCalls == 1);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(
|
|
AuthoredCelestialShadowSourceKind.Sun,
|
|
5,
|
|
0x01001348u,
|
|
0.25f,
|
|
-0.5f,
|
|
0.8291562f,
|
|
0.8291562f)]
|
|
[InlineData(
|
|
AuthoredCelestialShadowSourceKind.DominantMoon,
|
|
3,
|
|
0x01001F6Au,
|
|
-0.6f,
|
|
0.2f,
|
|
0.7745967f,
|
|
0.7745967f)]
|
|
[InlineData(
|
|
AuthoredCelestialShadowSourceKind.SecondaryMoon,
|
|
2,
|
|
0x01001F67u,
|
|
0.4f,
|
|
0.8f,
|
|
0.4472136f,
|
|
0.4472136f)]
|
|
[InlineData(
|
|
AuthoredCelestialShadowSourceKind.None,
|
|
-1,
|
|
0u,
|
|
0f,
|
|
0f,
|
|
1f,
|
|
0f)]
|
|
internal void CaptureDiagnosticsPreservesSunMoonAndNoneSourceMetadata(
|
|
AuthoredCelestialShadowSourceKind sourceKind,
|
|
int sourceObjectIndex,
|
|
uint sourceGfxObjId,
|
|
float directionX,
|
|
float directionY,
|
|
float directionZ,
|
|
float elevationSin)
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1);
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
var direction = new Vector3(directionX, directionY, directionZ);
|
|
|
|
// Exercise only the graph's diagnostics projection. This is the exact
|
|
// value object that DirectionalSunShadowRenderer publishes after its
|
|
// separately-covered environment/render path.
|
|
SetLastShadowDiagnostics(
|
|
graph,
|
|
new DirectionalSunShadowDiagnostics(
|
|
GateReason: sourceKind is AuthoredCelestialShadowSourceKind.None
|
|
? DirectionalShadowGateReason.NoVisibleCelestial
|
|
: DirectionalShadowGateReason.Enabled,
|
|
Strength: sourceKind is AuthoredCelestialShadowSourceKind.None
|
|
? 0f
|
|
: 0.75f,
|
|
CascadeCount: 0,
|
|
DrawCalls: 0,
|
|
WorldOpaqueCommands: 0,
|
|
WorldAlphaCutoutCommands: 0,
|
|
TerrainCommands: 0,
|
|
WorldPreparationSequence: 0,
|
|
TerrainPreparationSequence: 0,
|
|
CpuMilliseconds: 0,
|
|
LastResolvedGpuMilliseconds: 0,
|
|
HasResolvedGpuMeasurement: false,
|
|
ResidentDepthBytes: 0,
|
|
SourceKind: sourceKind,
|
|
SourceObjectIndex: sourceObjectIndex,
|
|
SourceGfxObjId: sourceGfxObjId,
|
|
SurfaceToLightDirection: direction,
|
|
LightElevationSin: elevationSin));
|
|
|
|
RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics();
|
|
|
|
Assert.Equal(sourceKind, diagnostics.DirectionalShadowSourceKind);
|
|
Assert.Equal(
|
|
sourceObjectIndex,
|
|
diagnostics.DirectionalShadowSourceObjectIndex);
|
|
Assert.Equal(sourceGfxObjId, diagnostics.DirectionalShadowSourceGfxObjId);
|
|
Assert.Equal(
|
|
direction,
|
|
diagnostics.DirectionalShadowSurfaceToLightDirection);
|
|
Assert.Equal(
|
|
elevationSin,
|
|
diagnostics.DirectionalShadowLightElevationSin);
|
|
}
|
|
|
|
[Fact]
|
|
public void GraphRunsTheDeclaredHdrPassOrderAndBindsStablePackAbi()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 4);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
Assert.Equal(
|
|
[
|
|
"test-world-hdr",
|
|
"atmospheric-sun-occlusion",
|
|
"atmospheric-sun-rays",
|
|
"atmospheric-bloom-downsample",
|
|
"atmospheric-bloom-blur-horizontal",
|
|
"atmospheric-bloom-blur-vertical",
|
|
"atmospheric-filmic",
|
|
],
|
|
device.OfKind<GpuRecordedPassBegin>().Select(call => call.Name));
|
|
Assert.Equal(
|
|
6,
|
|
device.OfKind<GpuRecordedUniformBind>().Count(call =>
|
|
call.Binding == GpuBindingModel.UniformAtmosphericFrame
|
|
&& call.SizeBytes == AtmosphericFrameUniforms.SizeInBytes));
|
|
Assert.Equal(
|
|
6,
|
|
device.OfKind<GpuRecordedUniformBind>().Count(call =>
|
|
call.Binding == GpuBindingModel.UniformPackPass
|
|
&& call.SizeBytes == AtmosphericPackPassUniforms.SizeInBytes));
|
|
Assert.Equal(
|
|
6,
|
|
device.OfKind<GpuRecordedUniformBind>().Count(call =>
|
|
call.Binding == GpuBindingModel.UniformPackSettings
|
|
&& call.SizeBytes == PackSettingsUniforms.SizeInBytes));
|
|
GpuRecordedPushConstants[] pushes = device.OfKind<GpuRecordedPushConstants>().ToArray();
|
|
Assert.All(pushes[..^1], call =>
|
|
{
|
|
Assert.True(call.Constants.TextureIndexA != uint.MaxValue);
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(call.Constants.ParamA));
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(call.Constants.ParamB));
|
|
});
|
|
Assert.NotEqual(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamA));
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamB));
|
|
Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexB);
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamA));
|
|
|
|
RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics();
|
|
Assert.Equal(10, diagnostics.ImageCount);
|
|
Assert.Equal(6, diagnostics.DrawCalls);
|
|
Assert.Equal(0, diagnostics.ShadowCasterCount);
|
|
Assert.Equal(0, diagnostics.CascadeDrawCount);
|
|
Assert.Equal(0, diagnostics.CpuClassificationCalls);
|
|
Assert.Equal(8, diagnostics.Passes.Count);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == RenderPackPerformanceScopeNames.EnhancedWorldReceiver);
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == VolumetricShaftRenderer.TimerName
|
|
&& pass.DrawCalls == 0);
|
|
Assert.True(
|
|
diagnostics.RetainedGpuBytes
|
|
>= DirectionalShadowQuality.For(DirectionalShadowPreset.Medium)
|
|
.ApproximateDepthMapBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public void CurrentShadowRunsShaftsBeforeBloomAndFeedsBloomAndFilmicComposition()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1280, 720, 1);
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
PublishCurrentShadow(graph, frame);
|
|
device.Clear();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(1280, 720);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
Assert.Equal(
|
|
[
|
|
"test-world-hdr",
|
|
"atmospheric-sun-occlusion",
|
|
"atmospheric-sun-rays",
|
|
VolumetricShaftRenderer.TimerName,
|
|
"atmospheric-bloom-downsample",
|
|
"atmospheric-bloom-blur-horizontal",
|
|
"atmospheric-bloom-blur-vertical",
|
|
"atmospheric-filmic",
|
|
],
|
|
device.OfKind<GpuRecordedPassBegin>().Select(call => call.Name));
|
|
RenderPassSemantic[] declaredOrder = graph.Descriptor.Passes
|
|
.Where(pass => pass.Hook is RenderPassHook.AtmosphereBeforeToneMap
|
|
or RenderPassHook.ToneMap)
|
|
.Select(pass => pass.Semantic)
|
|
.ToArray();
|
|
RenderPassSemantic[] executedOrder = device.OfKind<GpuRecordedPassBegin>()
|
|
.Skip(1)
|
|
.Select(call => call.Name switch
|
|
{
|
|
"atmospheric-sun-occlusion" => RenderPassSemantic.SunOcclusion,
|
|
"atmospheric-sun-rays" => RenderPassSemantic.SunRays,
|
|
VolumetricShaftRenderer.TimerName => RenderPassSemantic.VolumetricShafts,
|
|
"atmospheric-bloom-downsample" => RenderPassSemantic.BloomDownsample,
|
|
"atmospheric-bloom-blur-horizontal" => RenderPassSemantic.BloomBlurHorizontal,
|
|
"atmospheric-bloom-blur-vertical" => RenderPassSemantic.BloomBlurVertical,
|
|
"atmospheric-filmic" => RenderPassSemantic.FilmicComposite,
|
|
_ => throw new InvalidOperationException($"Unexpected atmospheric pass '{call.Name}'."),
|
|
})
|
|
.ToArray();
|
|
Assert.Equal(declaredOrder, executedOrder);
|
|
RenderPassDeclaration bloom = Assert.Single(
|
|
graph.Descriptor.Passes,
|
|
value => value.Semantic == RenderPassSemantic.BloomDownsample);
|
|
RenderResourceSemantic[] bloomReads = bloom.ResourceReads
|
|
.Select(id => Assert.Single(
|
|
graph.Descriptor.Resources,
|
|
resource => string.Equals(resource.Id, id, StringComparison.OrdinalIgnoreCase)).Semantic)
|
|
.ToArray();
|
|
Assert.Equal(
|
|
[RenderResourceSemantic.SunRays, RenderResourceSemantic.VolumetricShafts],
|
|
bloomReads);
|
|
GpuRecordedPushConstants[] pushes = device
|
|
.OfKind<GpuRecordedPushConstants>()
|
|
.ToArray();
|
|
Assert.Equal(7, pushes.Length);
|
|
Assert.NotEqual(uint.MaxValue, pushes[2].Constants.TextureIndexA);
|
|
Assert.Equal(uint.MaxValue, pushes[2].Constants.TextureIndexB);
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamA));
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[2].Constants.ParamB));
|
|
Assert.NotEqual(uint.MaxValue, pushes[3].Constants.TextureIndexA);
|
|
Assert.NotEqual(uint.MaxValue, pushes[3].Constants.TextureIndexB);
|
|
Assert.NotEqual(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamA));
|
|
Assert.Equal(uint.MaxValue, BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamB));
|
|
Assert.Equal(
|
|
pushes[3].Constants.TextureIndexB,
|
|
BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamA));
|
|
Assert.Equal(
|
|
BitConverter.SingleToUInt32Bits(pushes[3].Constants.ParamA),
|
|
BitConverter.SingleToUInt32Bits(pushes[^1].Constants.ParamB));
|
|
|
|
RenderPackRuntimeDiagnostics diagnostics = graph.CaptureDiagnostics();
|
|
Assert.Contains(diagnostics.Passes, pass =>
|
|
pass.PassId == VolumetricShaftRenderer.TimerName
|
|
&& pass.DrawCalls == 1);
|
|
Assert.Equal(7, diagnostics.DrawCalls);
|
|
Assert.Equal(8, diagnostics.ImageCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void NeutralSettingsReachShaderBlocksWithoutHiddenResidualEffects()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(
|
|
device,
|
|
"medium",
|
|
AtmosphericPostProcessSettings.Neutral);
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(800, 600, 1);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(800, 600);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
GpuRecordedUniformBind[] passBlocks = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.Where(call => call.Binding == GpuBindingModel.UniformPackPass)
|
|
.ToArray();
|
|
AtmosphericPackPassUniforms bloom = ReadPass(device, passBlocks[2]);
|
|
AtmosphericPackPassUniforms filmic = ReadPass(device, passBlocks[^1]);
|
|
Assert.Equal(0f, bloom.Params0.X);
|
|
Assert.Equal(new Vector4(1f, 1f, 1f, 0f), filmic.Params0);
|
|
Assert.Equal(Vector4.Zero, filmic.Params1);
|
|
|
|
GpuRecordedUniformBind frameBlock = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
AtmosphericFrameUniforms atmospheric = MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
|
device.RingBytes.Slice((int)frameBlock.OffsetBytes, AtmosphericFrameUniforms.SizeInBytes));
|
|
Assert.Equal(0f, atmospheric.SunScreen.Z);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerformanceSourceSumsOnlyResolvedPackPassTimersWithoutAllocatingDiagnostics()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(800, 600, 1);
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(800, 600);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
string[] names =
|
|
[
|
|
RenderPackPerformanceScopeNames.EnhancedWorldReceiver,
|
|
"atmospheric-sun-occlusion",
|
|
"atmospheric-sun-rays",
|
|
"atmospheric-bloom-downsample",
|
|
"atmospheric-bloom-blur-horizontal",
|
|
"atmospheric-bloom-blur-vertical",
|
|
"atmospheric-filmic",
|
|
];
|
|
for (int i = 0; i < names.Length; i++)
|
|
device.RecordingTimers.SetResolved(names[i], i + 1);
|
|
|
|
RenderPackRuntimePerformanceMetrics metrics =
|
|
graph.CapturePerformanceMetrics();
|
|
|
|
Assert.Equal(1, metrics.ResourceGeneration);
|
|
Assert.True(metrics.HasResolvedGpuMeasurement);
|
|
Assert.Equal(28, metrics.InclusiveResolvedGpuMilliseconds);
|
|
Assert.True(metrics.RetainedGpuBytes > 0);
|
|
Assert.Equal(0, metrics.TransientGpuBytes);
|
|
Assert.False(graph.CapturePerformanceMetrics().HasResolvedGpuMeasurement);
|
|
|
|
graph.PrepareWorldTarget(1024, 768, 4);
|
|
Assert.Equal(2, graph.CapturePerformanceMetrics().ResourceGeneration);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResizePublishesOneCompleteReplacementAndRetiresTheOldSet()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
var graph = Graph(device, "medium");
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
|
|
IGpuRenderTarget first = graph.PrepareWorldTarget(1280, 720, 4);
|
|
Assert.Equal(1, graph.ResourceGeneration);
|
|
Assert.Same(first, graph.PrepareWorldTarget(1280, 720, 4));
|
|
Assert.Equal(1, graph.ResourceGeneration);
|
|
Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount);
|
|
RecordingGpuRenderTarget[] firstSet = device.CreatedRenderTargets.ToArray();
|
|
|
|
IGpuRenderTarget second = graph.PrepareWorldTarget(1920, 1080, 1);
|
|
|
|
Assert.NotSame(first, second);
|
|
Assert.Equal(2, graph.ResourceGeneration);
|
|
Assert.All(firstSet, target => Assert.True(target.IsDisposed));
|
|
Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount);
|
|
graph.Dispose();
|
|
Assert.Equal(baselineSlots - 1, device.LiveTextureSlotCount);
|
|
Assert.Empty(device.PipelineFormatLeases);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("low", 320, 180)]
|
|
[InlineData("medium", 640, 360)]
|
|
[InlineData("high", 640, 360)]
|
|
public void PresetDeclaredRayScaleControlsMaskAndRayTargets(
|
|
string presetId,
|
|
int expectedWidth,
|
|
int expectedHeight)
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, presetId);
|
|
|
|
graph.PrepareWorldTarget(1280, 720, 1);
|
|
|
|
RecordingGpuRenderTarget mask = Assert.Single(device.CreatedRenderTargets, target =>
|
|
target.Description.Name == "atmospheric-sun-mask");
|
|
RecordingGpuRenderTarget rays = Assert.Single(device.CreatedRenderTargets, target =>
|
|
target.Description.Name == "atmospheric-sun-rays");
|
|
Assert.Equal(expectedWidth, mask.Description.Width);
|
|
Assert.Equal(expectedHeight, mask.Description.Height);
|
|
Assert.Equal(expectedWidth, rays.Description.Width);
|
|
Assert.Equal(expectedHeight, rays.Description.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void PartialTargetAllocationFailureRollsBackAndCanBuildFreshCandidate()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
int allocation = 0;
|
|
device.RenderTargetFailure = _ => ++allocation == 3
|
|
? new InvalidOperationException("injected target failure")
|
|
: null;
|
|
|
|
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(
|
|
() => graph.PrepareWorldTarget(1024, 768, 4));
|
|
|
|
Assert.Equal("injected target failure", failure.Message);
|
|
Assert.Equal(0, graph.ResourceGeneration);
|
|
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
|
Assert.All(device.CreatedRenderTargets, target => Assert.True(target.IsDisposed));
|
|
|
|
device.RenderTargetFailure = null;
|
|
IGpuRenderTarget recovered = graph.PrepareWorldTarget(1024, 768, 4);
|
|
Assert.Equal(GpuTextureFormat.Rgba16FloatRenderTarget, recovered.Description.ColorFormat);
|
|
Assert.Equal(1, graph.ResourceGeneration);
|
|
Assert.Equal(baselineSlots + 7, device.LiveTextureSlotCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void DescriptorPassAssetsAndPresetOverridesDriveTheRuntime()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "low");
|
|
|
|
Assert.Equal(0.4f, graph.Settings.SunRayStrength);
|
|
Assert.Equal(
|
|
[
|
|
"acdream.atmospheric:sun-occlusion",
|
|
"acdream.atmospheric:sun-rays",
|
|
"acdream.atmospheric:bloom-downsample",
|
|
"acdream.atmospheric:bloom-blur-horizontal",
|
|
"acdream.atmospheric:filmic-composite",
|
|
"acdream.atmospheric:terrain-shadow-caster",
|
|
"acdream.atmospheric:world-shadow-opaque",
|
|
"acdream.atmospheric:world-shadow-cutout",
|
|
"acdream.atmospheric:terrain-shadow-caster-multiview",
|
|
"acdream.atmospheric:world-shadow-opaque-multiview",
|
|
"acdream.atmospheric:world-shadow-cutout-multiview",
|
|
"acdream.atmospheric:volumetric-shafts",
|
|
],
|
|
device.CreatedPipelines.Select(pipeline => pipeline.Description.Shaders.Name));
|
|
Assert.All(
|
|
device.CreatedPipelines,
|
|
pipeline => Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv));
|
|
Assert.Equal(1, device.PipelineFormatLeases[GpuTextureFormat.Rgba16FloatRenderTarget]);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInSampleCountDeclarationsMatchTheExecutorExactly()
|
|
{
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderSettingDeclaration pcf = Assert.Single(descriptor.Settings, value =>
|
|
value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps);
|
|
RenderSettingDeclaration volumetric = Assert.Single(descriptor.Settings, value =>
|
|
value.Semantic == RenderSettingSemantic.VolumetricRayMarchSteps);
|
|
|
|
Assert.Equal(RenderSettingKind.Choice, pcf.Kind);
|
|
Assert.Equal(["1", "9", "25"], pcf.Choices);
|
|
Assert.Equal("9", pcf.DefaultValue);
|
|
Assert.Equal(RenderSettingKind.Integer, volumetric.Kind);
|
|
Assert.Equal(8, volumetric.Minimum);
|
|
Assert.Equal(64, volumetric.Maximum);
|
|
Assert.Equal(8, volumetric.Step);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShadowFilterRejectsAnUndeclaredIntermediateSampleCount()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets, value =>
|
|
value.Semantic == RenderQualitySemantic.Medium);
|
|
string settingId = Assert.Single(descriptor.Settings, value =>
|
|
value.Semantic == RenderSettingSemantic.DirectionalShadowPcfTaps).Id;
|
|
|
|
NotSupportedException error = Assert.Throws<NotSupportedException>(() =>
|
|
new AtmosphericPostProcessGraph(
|
|
device,
|
|
descriptor,
|
|
BuiltInAssets(),
|
|
preset,
|
|
userSettingOverrides: new Dictionary<string, string>
|
|
{
|
|
[settingId] = "3",
|
|
}));
|
|
|
|
Assert.Contains("exactly 1, 9, or 25", error.Message, StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void SemanticPresetResourcesAndSettingsDriveShadowAndVolumetricQuality()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderResourceDeclaration shadowResource = Assert.Single(
|
|
source.Resources,
|
|
value => value.Semantic == RenderResourceSemantic.DirectionalShadowDepth);
|
|
RenderResourceDeclaration volumetricResource = Assert.Single(
|
|
source.Resources,
|
|
value => value.Semantic == RenderResourceSemantic.VolumetricShafts);
|
|
RenderQualityPreset original = Assert.Single(
|
|
source.QualityPresets,
|
|
value => value.Semantic == RenderQualitySemantic.Medium);
|
|
RenderQualityPreset preset = original with
|
|
{
|
|
ResourceOverrides = original.ResourceOverrides.Select(value =>
|
|
string.Equals(value.ResourceId, shadowResource.Id, StringComparison.OrdinalIgnoreCase)
|
|
? value with
|
|
{
|
|
Extent = new RenderExtentDeclaration(
|
|
RenderExtentMode.AbsolutePixels,
|
|
768,
|
|
768,
|
|
Layers: 2),
|
|
EstimatedResidentBytes = 2L * 768 * 768 * sizeof(float),
|
|
}
|
|
: string.Equals(value.ResourceId, volumetricResource.Id,
|
|
StringComparison.OrdinalIgnoreCase)
|
|
? value with
|
|
{
|
|
Extent = new RenderExtentDeclaration(
|
|
RenderExtentMode.RelativeToMainWorld,
|
|
0.375,
|
|
0.375),
|
|
}
|
|
: value).ToArray(),
|
|
};
|
|
string SettingId(RenderSettingSemantic semantic) => Assert.Single(
|
|
source.Settings,
|
|
value => value.Semantic == semantic).Id;
|
|
var overrides = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[SettingId(RenderSettingSemantic.DirectionalShadowReachMetres)] = "96",
|
|
[SettingId(RenderSettingSemantic.DirectionalShadowPcfTaps)] = "25",
|
|
[SettingId(RenderSettingSemantic.VolumetricRayMarchSteps)] = "64",
|
|
};
|
|
|
|
using var graph = new AtmosphericPostProcessGraph(
|
|
device,
|
|
source,
|
|
BuiltInAssets(),
|
|
preset,
|
|
userSettingOverrides: overrides);
|
|
var shadows = Assert.IsType<DirectionalSunShadowRenderer>(
|
|
graph.DirectionalShadowReceivers);
|
|
Assert.Equal(2, shadows.Quality.CascadeCount);
|
|
Assert.Equal(768, shadows.Quality.MapResolution);
|
|
Assert.Equal(96f, shadows.Quality.MaximumReachMeters);
|
|
Assert.Equal(2, shadows.Quality.PcfRadiusTexels);
|
|
Assert.Equal(64, graph.VolumetricQuality?.RayMarchSteps);
|
|
Assert.Equal(0.375f, graph.VolumetricQuality?.ResolutionScale);
|
|
|
|
graph.PrepareWorldTarget(800, 600, 1);
|
|
RecordingGpuRenderTarget volumetric = Assert.Single(
|
|
device.CreatedRenderTargets,
|
|
value => value.Description.Name == "atmospheric-volumetric");
|
|
Assert.Equal(300, volumetric.Description.Width);
|
|
Assert.Equal(225, volumetric.Description.Height);
|
|
}
|
|
|
|
[Fact]
|
|
public void AuthoredElevationDayGroupAndVisibilityGateSunEffects()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium");
|
|
|
|
AtmosphericFrameInputs dawn = Inputs(1280, 720, elevation: 4f, activeDayGroup: 0);
|
|
AtmosphericFrameInputs dusk = dawn with { ActiveDayGroup = 1 };
|
|
AtmosphericFrameInputs noon = dawn with { SunElevationDegrees = 55f };
|
|
AtmosphericFrameInputs behindCamera = dawn with { SunIsOnScreen = false };
|
|
AtmosphericFrameInputs overcast = dawn with
|
|
{
|
|
Weather = WeatherKind.Overcast,
|
|
WeatherIntensity = 1f,
|
|
};
|
|
AtmosphericFrameInputs indoor = dawn with { IsOutdoor = false };
|
|
|
|
Assert.Equal(1f, graph.EvaluateSunPolicy(in dawn), 3);
|
|
Assert.Equal(0.35f, graph.EvaluateSunPolicy(in dusk), 3);
|
|
Assert.Equal(0f, graph.EvaluateSunPolicy(in noon));
|
|
Assert.Equal(0f, graph.EvaluateSunPolicy(in behindCamera));
|
|
Assert.Equal(0.18f, graph.EvaluateSunPolicy(in overcast), 3);
|
|
Assert.Equal(0f, graph.EvaluateSunPolicy(in indoor));
|
|
}
|
|
|
|
[Fact]
|
|
public void VolumetricPipelineFailureRollsBackGraphCandidate()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
int baselineSlots = device.LiveTextureSlotCount;
|
|
device.PipelineFailure = description => description.Name.Contains(
|
|
"volumetric-shafts",
|
|
StringComparison.Ordinal)
|
|
? new InvalidOperationException("volumetric pipeline failed")
|
|
: null;
|
|
|
|
InvalidOperationException failure = Assert.Throws<InvalidOperationException>(() =>
|
|
Graph(device, "medium"));
|
|
|
|
Assert.Equal("volumetric pipeline failed", failure.Message);
|
|
Assert.Equal(baselineSlots, device.LiveTextureSlotCount);
|
|
Assert.Empty(device.PipelineFormatLeases);
|
|
Assert.All(device.CreatedPipelines, pipeline => Assert.True(pipeline.IsDisposed));
|
|
Assert.All(device.CreatedDirectionalDepthTargets, target => Assert.True(target.IsDisposed));
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalTierTwoPackCanRenameEveryOwnedIdAndShaderAsset()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
RenderPackDescriptor external = RenamedExternalTierTwoDescriptor();
|
|
Assert.True(
|
|
RenderPackValidator.ValidateDescriptor(
|
|
external,
|
|
RenderPackHostCapabilities.Conformance).Success);
|
|
RenderQualityPreset preset = Assert.Single(
|
|
external.QualityPresets,
|
|
value => value.Semantic == RenderQualitySemantic.Medium);
|
|
var factory = new AtmosphericRenderPackRuntimeFactory(device);
|
|
|
|
using IRenderPackRuntime runtime = factory.Build(
|
|
external,
|
|
new RenamedShaderAssets(BuiltInAssets()),
|
|
preset,
|
|
RenderPackSettingOverrides.Empty);
|
|
|
|
AtmosphericPostProcessGraph graph = Assert.IsType<AtmosphericPostProcessGraph>(runtime);
|
|
_ = graph.PrepareWorldTarget(1280, 720, 1);
|
|
Assert.All(device.CreatedPipelines, pipeline =>
|
|
{
|
|
Assert.StartsWith("example.external-atmosphere:", pipeline.Description.Shaders.Name);
|
|
Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv);
|
|
});
|
|
Assert.DoesNotContain(external.Passes, value =>
|
|
BuiltInAtmosphericRenderPack.Descriptor.Passes.Any(original =>
|
|
string.Equals(original.Id, value.Id, StringComparison.Ordinal)));
|
|
Assert.All(external.Passes, value => Assert.StartsWith("external/", value.VertexShaderAsset));
|
|
Assert.All(external.PipelineVariants, value =>
|
|
Assert.StartsWith("external/", value.FragmentShaderAsset));
|
|
Assert.Same(external, graph.Descriptor);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalNoOpPackNeedsNoRendererPrivateRuntime()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor with
|
|
{
|
|
Id = "example.no-op",
|
|
Passes = [],
|
|
SceneReplays = [],
|
|
PipelineVariants = [],
|
|
};
|
|
RenderQualityPreset preset = descriptor.QualityPresets[0];
|
|
var factory = new AtmosphericRenderPackRuntimeFactory(device);
|
|
|
|
using IRenderPackRuntime runtime = factory.Build(
|
|
descriptor,
|
|
new RejectingAssets(),
|
|
preset,
|
|
RenderPackSettingOverrides.Empty);
|
|
|
|
Assert.IsType<NoOpRenderPackRuntime>(runtime);
|
|
Assert.Empty(device.CreatedPipelines);
|
|
Assert.Empty(device.PipelineFormatLeases);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalTierOneFullscreenGraphSchedulesArbitraryDeclaredPassIdsAndResource()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
RenderPackDescriptor descriptor = ExternalTierOneDescriptor();
|
|
RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets);
|
|
var factory = new AtmosphericRenderPackRuntimeFactory(device);
|
|
using IRenderPackRuntime runtime = factory.Build(
|
|
descriptor,
|
|
BuiltInAssets(),
|
|
preset,
|
|
RenderPackSettingOverrides.Empty);
|
|
var graph = Assert.IsType<DeclaredFullscreenRenderPackGraph>(runtime);
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(1000, 600);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
Assert.Equal(
|
|
["test-world-hdr", "render-pack-example.generic-tier1-my-threshold",
|
|
"render-pack-example.generic-tier1-my-output"],
|
|
device.OfKind<GpuRecordedPassBegin>().Select(call => call.Name));
|
|
Assert.All(device.CreatedPipelines, pipeline =>
|
|
Assert.True(pipeline.Description.Shaders.HasEmbeddedSpirv));
|
|
Assert.Contains(device.CreatedRenderTargets, target =>
|
|
target.Description.Name.EndsWith("custom-half", StringComparison.Ordinal)
|
|
&& target.Description.Width == 500
|
|
&& target.Description.Height == 300);
|
|
|
|
device.RecordingTimers.SetResolved(
|
|
"render-pack-example.generic-tier1-my-threshold",
|
|
1.25);
|
|
device.RecordingTimers.SetResolved(
|
|
"render-pack-example.generic-tier1-my-output",
|
|
2.75);
|
|
RenderPackRuntimePerformanceMetrics metrics = graph.CapturePerformanceMetrics();
|
|
Assert.Equal(1, metrics.ResourceGeneration);
|
|
Assert.True(metrics.HasResolvedGpuMeasurement);
|
|
Assert.Equal(4, metrics.InclusiveResolvedGpuMilliseconds);
|
|
Assert.True(metrics.RetainedGpuBytes > 0);
|
|
Assert.False(graph.CapturePerformanceMetrics().HasResolvedGpuMeasurement);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalTierOnePolicyAndCompleteRuntimeDiagnosticsReachTheController()
|
|
{
|
|
var policy = new AtmospherePolicyDeclaration(
|
|
[
|
|
new SunElevationResponsePoint(-10, 0.2),
|
|
new SunElevationResponsePoint(10, 0.8),
|
|
],
|
|
[new ActiveDayGroupMultiplier(7, 0.4)]);
|
|
RenderPackDescriptor descriptor = ExternalTierOneDescriptor(policy);
|
|
var device = new RecordingGpuDevice();
|
|
using var registry = new BufferedRenderPackRegistry();
|
|
using IDisposable registration = registry.Register(descriptor, BuiltInAssets());
|
|
using var controller = new RenderPackController(
|
|
() => RenderPackCatalog.Build(
|
|
registry.Snapshot(),
|
|
RenderPackHostCapabilities.Conformance),
|
|
new AtmosphericRenderPackRuntimeFactory(device),
|
|
preparationScheduler: InlineRenderPackPreparationScheduler.Instance);
|
|
controller.Request(new RenderPackSelectionSettings(
|
|
descriptor.Id,
|
|
descriptor.PackVersion.ToString(),
|
|
"default"));
|
|
|
|
RenderPackActivationSnapshot activation = controller.ApplyAtFrameBoundary(
|
|
new RenderPackActivationExtent(1000, 600, 1));
|
|
var graph = Assert.IsType<DeclaredFullscreenRenderPackGraph>(
|
|
controller.ActiveRuntime);
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(
|
|
1000,
|
|
600,
|
|
elevation: 0f,
|
|
activeDayGroup: 7);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
GpuRecordedUniformBind frameBlock = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
AtmosphericFrameUniforms atmosphere = MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
|
device.RingBytes.Slice(
|
|
(int)frameBlock.OffsetBytes,
|
|
AtmosphericFrameUniforms.SizeInBytes));
|
|
Assert.Equal(new Vector4(7f, 0.4f, 0.5f, 0f), atmosphere.Policy);
|
|
Assert.Equal(0.2f, atmosphere.SunScreen.Z, 3);
|
|
Assert.Equal(0.2f, atmosphere.SunColor.W, 3);
|
|
|
|
device.RecordingTimers.SetResolved(
|
|
"render-pack-example.generic-tier1-my-threshold",
|
|
1.25);
|
|
device.RecordingTimers.SetResolved(
|
|
"render-pack-example.generic-tier1-my-output",
|
|
2.75);
|
|
RenderPackDiagnosticsSnapshot diagnostics = controller.CaptureDiagnostics();
|
|
|
|
Assert.Equal(RenderPackActivationState.Active, activation.State);
|
|
Assert.Equal("example.generic-tier1", diagnostics.PackId);
|
|
Assert.Equal("default", diagnostics.EffectiveQuality);
|
|
Assert.Equal(8_400_000L, diagnostics.RetainedGpuBytes);
|
|
Assert.Equal(0L, diagnostics.TransientGpuBytes);
|
|
Assert.Equal(3, diagnostics.ImageCount);
|
|
Assert.Equal(0, diagnostics.BufferCount);
|
|
Assert.Equal(2, diagnostics.DrawCalls);
|
|
Assert.Equal(0, diagnostics.DispatchCalls);
|
|
Assert.Equal(0, diagnostics.ShadowCasterCount);
|
|
Assert.Equal(0, diagnostics.CascadeDrawCount);
|
|
Assert.Equal(0, diagnostics.CpuClassificationCalls);
|
|
Assert.Equal(0, diagnostics.SunElevationDegrees);
|
|
Assert.Equal(7, diagnostics.ActiveDayGroup);
|
|
Assert.Equal(WeatherKind.Clear.ToString(), diagnostics.Weather);
|
|
Assert.Equal(0, diagnostics.WeatherIntensity);
|
|
Assert.True(diagnostics.Outdoor);
|
|
Assert.Equal(0, diagnostics.DirectionalShadowStrength);
|
|
Assert.Collection(
|
|
diagnostics.Passes,
|
|
pass =>
|
|
{
|
|
Assert.Equal("my-threshold", pass.PassId);
|
|
Assert.Equal(1.25, pass.GpuMilliseconds);
|
|
Assert.Equal(1, pass.DrawCalls);
|
|
Assert.Equal(0, pass.DispatchCalls);
|
|
},
|
|
pass =>
|
|
{
|
|
Assert.Equal("my-output", pass.PassId);
|
|
Assert.Equal(2.75, pass.GpuMilliseconds);
|
|
Assert.Equal(1, pass.DrawCalls);
|
|
Assert.Equal(0, pass.DispatchCalls);
|
|
});
|
|
string formatted = RenderPackDiagnosticsFormatter.Format(diagnostics);
|
|
Assert.Contains("resources=3i/0b", formatted, StringComparison.Ordinal);
|
|
Assert.Contains("worldTransforms=0used", formatted, StringComparison.Ordinal);
|
|
Assert.Contains("my-threshold:1.250ms/1d/0c", formatted, StringComparison.Ordinal);
|
|
Assert.Contains("atmosphere=0.00deg/day7/Clear:0.000/outdoor=True", formatted,
|
|
StringComparison.Ordinal);
|
|
RenderPackRuntimePerformanceMetrics performance = graph.CapturePerformanceMetrics();
|
|
Assert.True(performance.HasResolvedGpuMeasurement);
|
|
Assert.Equal(4, performance.InclusiveResolvedGpuMilliseconds);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalShadowsOnlyTierTwoValidatesAndExecutesDeclaredMovingCelestialGraph()
|
|
{
|
|
var policy = new AtmospherePolicyDeclaration(
|
|
[
|
|
new SunElevationResponsePoint(-90, 1),
|
|
new SunElevationResponsePoint(90, 1),
|
|
],
|
|
[new ActiveDayGroupMultiplier(7, 0.4)])
|
|
{
|
|
DirectionalShadowLightElevationResponse =
|
|
[
|
|
new SunElevationResponsePoint(-90, 0),
|
|
new SunElevationResponsePoint(0, 0),
|
|
new SunElevationResponsePoint(10, 0.1),
|
|
new SunElevationResponsePoint(20, 0.3),
|
|
new SunElevationResponsePoint(90, 0.3),
|
|
],
|
|
VolumetricShaftSunElevationResponse =
|
|
[
|
|
new SunElevationResponsePoint(-90, 0),
|
|
new SunElevationResponsePoint(10, 0.8),
|
|
new SunElevationResponsePoint(20, 0.4),
|
|
new SunElevationResponsePoint(90, 0),
|
|
],
|
|
};
|
|
RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy);
|
|
RenderPackValidationResult validation = RenderPackValidator.ValidateDescriptor(
|
|
descriptor,
|
|
RenderPackHostCapabilities.Conformance);
|
|
Assert.True(validation.Success, validation.Reason);
|
|
RenderQualityPreset preset = Assert.Single(descriptor.QualityPresets);
|
|
var device = new RecordingGpuDevice();
|
|
var factory = new AtmosphericRenderPackRuntimeFactory(device);
|
|
|
|
using IRenderPackRuntime runtime = factory.Build(
|
|
descriptor,
|
|
BuiltInAssets(),
|
|
preset,
|
|
RenderPackSettingOverrides.Empty);
|
|
var graph = Assert.IsType<DeclaredDirectionalShadowRenderPackGraph>(runtime);
|
|
Assert.False(Assert.IsType<DirectionalSunShadowRenderer>(
|
|
graph.DirectionalShadowReceivers).MultiviewCascadesEnabled);
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(1000, 600, 1);
|
|
device.Clear();
|
|
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
PublishCurrentShadow(graph.DirectionalShadowReceivers, frame);
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(
|
|
1000,
|
|
600,
|
|
elevation: 15f,
|
|
activeDayGroup: 7);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
|
|
GpuRecordedUniformBind frameBlock = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.First(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
AtmosphericFrameUniforms atmosphere = MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
|
device.RingBytes.Slice(
|
|
(int)frameBlock.OffsetBytes,
|
|
AtmosphericFrameUniforms.SizeInBytes));
|
|
Assert.Equal(7f, atmosphere.Policy.X);
|
|
Assert.Equal(0.4f, atmosphere.Policy.Y, 3);
|
|
Assert.Equal(0.20117f, atmosphere.Policy.Z, 5);
|
|
Assert.Equal(0.6f, atmosphere.Policy.W, 3);
|
|
Assert.Equal(0.4f, atmosphere.SunScreen.Z, 3);
|
|
Assert.Equal(0.4f, atmosphere.SunColor.W, 3);
|
|
Assert.Single(descriptor.Passes, value =>
|
|
value.Semantic == RenderPassSemantic.DirectionalShadowDepth);
|
|
Assert.DoesNotContain(descriptor.Passes, value => value.Semantic is
|
|
RenderPassSemantic.BloomDownsample
|
|
or RenderPassSemantic.BloomBlurHorizontal
|
|
or RenderPassSemantic.BloomBlurVertical
|
|
or RenderPassSemantic.SunOcclusion
|
|
or RenderPassSemantic.SunRays
|
|
or RenderPassSemantic.VolumetricShafts
|
|
or RenderPassSemantic.FilmicComposite);
|
|
Assert.Contains(device.OfKind<GpuRecordedPassBegin>(), value =>
|
|
value.Name == "render-pack-example.shadows-only-output-copy");
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalShadowsOnlyTierTwoFailsSafeWithoutCasterReplayOrDeclaredCurve()
|
|
{
|
|
AtmospherePolicyDeclaration policy = BuiltInAtmosphericRenderPack.Descriptor
|
|
.AtmospherePolicy!;
|
|
RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy);
|
|
|
|
RenderPackValidationResult missingReplay = RenderPackValidator.ValidateDescriptor(
|
|
descriptor with { SceneReplays = [] },
|
|
RenderPackHostCapabilities.Conformance);
|
|
RenderPackValidationResult missingCurve = RenderPackValidator.ValidateDescriptor(
|
|
descriptor with
|
|
{
|
|
AtmospherePolicy = policy with
|
|
{
|
|
DirectionalShadowLightElevationResponse = [],
|
|
},
|
|
},
|
|
RenderPackHostCapabilities.Conformance);
|
|
|
|
Assert.False(missingReplay.Success);
|
|
Assert.Contains("exactly one outdoor directional-shadow replay", missingReplay.Reason,
|
|
StringComparison.Ordinal);
|
|
Assert.False(missingCurve.Success);
|
|
Assert.Contains("directional-shadow light-elevation response curve", missingCurve.Reason,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExternalDirectionalShadowCurveMustRemainZeroAtAndBelowAuthoredHorizon()
|
|
{
|
|
AtmospherePolicyDeclaration policy = BuiltInAtmosphericRenderPack.Descriptor
|
|
.AtmospherePolicy!;
|
|
RenderPackDescriptor descriptor = ExternalShadowsOnlyTierTwoDescriptor(policy);
|
|
Assert.True(RenderPackValidator.ValidateDescriptor(
|
|
descriptor,
|
|
RenderPackHostCapabilities.Conformance).Success);
|
|
|
|
IReadOnlyList<SunElevationResponsePoint>[] invalidCurves =
|
|
[
|
|
[
|
|
new SunElevationResponsePoint(-90, 0.1),
|
|
new SunElevationResponsePoint(0, 0),
|
|
new SunElevationResponsePoint(90, 1),
|
|
],
|
|
[
|
|
new SunElevationResponsePoint(-90, 0),
|
|
new SunElevationResponsePoint(90, 1),
|
|
],
|
|
[
|
|
new SunElevationResponsePoint(0, 0.1),
|
|
new SunElevationResponsePoint(90, 1),
|
|
],
|
|
];
|
|
|
|
foreach (IReadOnlyList<SunElevationResponsePoint> invalidCurve in invalidCurves)
|
|
{
|
|
RenderPackValidationResult result = RenderPackValidator.ValidateDescriptor(
|
|
descriptor with
|
|
{
|
|
AtmospherePolicy = policy with
|
|
{
|
|
DirectionalShadowLightElevationResponse = invalidCurve,
|
|
},
|
|
},
|
|
RenderPackHostCapabilities.Conformance);
|
|
|
|
Assert.False(result.Success);
|
|
Assert.Contains("zero at and below the 0-degree authored horizon", result.Reason,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
RenderPackValidationResult clampedZero = RenderPackValidator.ValidateDescriptor(
|
|
descriptor with
|
|
{
|
|
AtmospherePolicy = policy with
|
|
{
|
|
DirectionalShadowLightElevationResponse =
|
|
[
|
|
new SunElevationResponsePoint(1, 0),
|
|
new SunElevationResponsePoint(12, 1),
|
|
new SunElevationResponsePoint(90, 1),
|
|
],
|
|
},
|
|
},
|
|
RenderPackHostCapabilities.Conformance);
|
|
Assert.True(clampedZero.Success, clampedZero.Reason);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuiltInShadowStrengthUsesTheDescriptorCurveExactlyOnce()
|
|
{
|
|
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderQualityPreset medium = Assert.Single(source.QualityPresets, value =>
|
|
value.Semantic == RenderQualitySemantic.Medium);
|
|
using var baseline = new AtmosphericPostProcessGraph(
|
|
new RecordingGpuDevice(),
|
|
source,
|
|
BuiltInAssets(),
|
|
medium);
|
|
const float elevation = 6f;
|
|
float expectedLegacyElevation =
|
|
(MathF.Sin(elevation * MathF.PI / 180f) - MathF.Sin(MathF.PI / 180f))
|
|
/ (MathF.Sin(12f * MathF.PI / 180f) - MathF.Sin(MathF.PI / 180f));
|
|
Assert.Equal(
|
|
expectedLegacyElevation * 0.72f,
|
|
baseline.EvaluateDirectionalShadowStrength(elevation, activeDayGroup: 0),
|
|
5);
|
|
|
|
RenderPackDescriptor changed = source with
|
|
{
|
|
AtmospherePolicy = source.AtmospherePolicy! with
|
|
{
|
|
DirectionalShadowLightElevationResponse =
|
|
[
|
|
new SunElevationResponsePoint(-90, 0.25),
|
|
new SunElevationResponsePoint(90, 0.25),
|
|
],
|
|
},
|
|
};
|
|
using var declared = new AtmosphericPostProcessGraph(
|
|
new RecordingGpuDevice(),
|
|
changed,
|
|
BuiltInAssets(),
|
|
medium);
|
|
|
|
Assert.Equal(
|
|
0.25f * 0.72f,
|
|
declared.EvaluateDirectionalShadowStrength(elevation, activeDayGroup: 0),
|
|
5);
|
|
}
|
|
|
|
[Fact]
|
|
public void FilmicAndBloomDownsampleShadersKeepTheirColourSpaceConversions()
|
|
{
|
|
// Campaign VM VM3: a future edit to either shader could silently drop
|
|
// the decode/encode calls that make the post stack run in linear
|
|
// light. Pin their presence so that regresses loudly instead of
|
|
// quietly reintroducing F4 (gamma-space bloom/ACES/grade).
|
|
string shaderRoot = Path.Combine(
|
|
RepositoryRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"Shaders");
|
|
string common = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_common.glsl"));
|
|
string filmic = File.ReadAllText(Path.Combine(shaderRoot, "atmospheric_filmic.frag"));
|
|
string downsample = File.ReadAllText(
|
|
Path.Combine(shaderRoot, "atmospheric_bloom_downsample.frag"));
|
|
|
|
int encodeInMainOutput = CountOccurrences(
|
|
filmic[filmic.IndexOf("void main()", StringComparison.Ordinal)..],
|
|
"acdreamEncodeDisplay(");
|
|
Assert.Equal(1, encodeInMainOutput);
|
|
Assert.Contains(
|
|
"oColor = vec4(acdreamEncodeDisplay(clamp(color, 0.0, 1.0)), 1.0);",
|
|
filmic,
|
|
StringComparison.Ordinal);
|
|
|
|
// Three in lowFusedScene (A/B/C) plus three in main's non-fused
|
|
// branch (A/C/D — sampleBloom's B is already linear).
|
|
Assert.Equal(6, CountOccurrences(filmic, "acdreamDecodeDisplay("));
|
|
|
|
int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay(");
|
|
Assert.True(
|
|
decodesInDownsample >= 3,
|
|
$"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}");
|
|
|
|
// The contrast pivot and the decode/encode exponent are formatted
|
|
// from the C# mirror's own constants so the shader literal and the
|
|
// CPU-tested value cannot silently drift apart.
|
|
string gamma = AtmosphericColorPipeline.DisplayGamma.ToString(CultureInfo.InvariantCulture);
|
|
Assert.Contains($"vec3({gamma})", common, StringComparison.Ordinal);
|
|
Assert.Contains($"vec3(1.0 / {gamma})", common, StringComparison.Ordinal);
|
|
string pivot = AtmosphericColorPipeline.LinearMidGrey.ToString(CultureInfo.InvariantCulture);
|
|
Assert.Contains($"const float LinearMidGrey = {pivot}", filmic, StringComparison.Ordinal);
|
|
|
|
// The linear bloom threshold/knee the graph writes into Params0/2 are
|
|
// pinned by their exact literal values here too (AtmosphericColorPipelineTests
|
|
// separately proves they derive correctly from the pre-VM3 gamma pair).
|
|
Assert.Equal(0.73f, AtmosphericPostProcessGraph.BloomKneeLinear);
|
|
Assert.Equal(1f, AtmosphericPostProcessGraph.BloomThresholdLinear);
|
|
}
|
|
|
|
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear()
|
|
{
|
|
// Campaign VM VM6 review fix round 5 (F1 BLOCKER, test T3): pins
|
|
// the shader SOURCE TEXT so a future edit that removes the
|
|
// flag-gated fallback (reintroducing "a shadow-gated-off frame
|
|
// lights outdoor terrain/objects from the disabled block's
|
|
// (0,0,1) placeholder instead of the authored sun") fails this
|
|
// test instead of only showing up in a pixel capture. Both
|
|
// receiver vertex shaders must (1) test the SAME flags bit
|
|
// acdreamDirectionalShadowVisibility already tests, and (2)
|
|
// reference the SAME uLights-derived expression the corresponding
|
|
// plain (mesh_modern.vert / terrain_modern.vert) pipeline uses,
|
|
// proving the fallback branch is not some other, unverified
|
|
// direction.
|
|
string shaderRoot = Path.Combine(
|
|
RepositoryRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"Shaders");
|
|
string meshAtmospheric = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.vert"));
|
|
string meshModern = File.ReadAllText(Path.Combine(shaderRoot, "mesh_modern.vert"));
|
|
string terrainAtmospheric = File.ReadAllText(Path.Combine(shaderRoot, "terrain_atmospheric.vert"));
|
|
string terrainModern = File.ReadAllText(Path.Combine(shaderRoot, "terrain_modern.vert"));
|
|
|
|
const string flagTest = "(uShadowTextureAndFlags.w & 1u) == 0u";
|
|
Assert.Contains(flagTest, meshAtmospheric, StringComparison.Ordinal);
|
|
Assert.Contains(flagTest, terrainAtmospheric, StringComparison.Ordinal);
|
|
|
|
// mesh: both files negate the per-light uniform inline (no
|
|
// intermediate variable), so the literal expression matches
|
|
// string-for-string.
|
|
const string meshPlainDirection = "-uLights[i].dirAndRange.xyz";
|
|
Assert.Contains(meshPlainDirection, meshModern, StringComparison.Ordinal);
|
|
Assert.Contains(meshPlainDirection, meshAtmospheric, StringComparison.Ordinal);
|
|
|
|
// terrain: terrain_modern.vert reads the uniform into `sunDir`
|
|
// then negates that variable two lines later, so only the
|
|
// shared uniform-access substring (not the exact negation
|
|
// placement) is asserted identical between the two files.
|
|
const string terrainLightUniform = "uLights[0].dirAndRange.xyz";
|
|
Assert.Contains(terrainLightUniform, terrainModern, StringComparison.Ordinal);
|
|
Assert.Contains(
|
|
"-" + terrainLightUniform,
|
|
terrainAtmospheric,
|
|
StringComparison.Ordinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void FirstAdvanceSnapsExactlyToTheWeatherTargetInsteadOfEasingFromZero()
|
|
{
|
|
// Campaign VM VM6 review fix round 3 (A6): before this fix, the
|
|
// very first advance treated the constructor-default _windMean/
|
|
// _windGust (0) as the "previous" strength and eased toward the
|
|
// target by a deltaSeconds-based step clamped to 1 second. Under a
|
|
// pinned clock (ACDREAM_SKY_PHASE_SECONDS, the determinism pin the
|
|
// offline pixel gate uses) that first step is the ONLY one that
|
|
// ever happens — deltaSeconds is 0 on every later call because the
|
|
// pinned clock never advances — so the wind would sit at whatever
|
|
// fraction that one step reached (at most 10% of the target, at
|
|
// the 10 s TransitionSeconds rate) forever. The fix snaps exactly
|
|
// to the target on the very first advance instead.
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
|
|
graph.PrepareWorldTarget(640, 480, 1);
|
|
AtmosphericFrameInputs outdoors = Inputs(640, 480) with { Weather = WeatherKind.Storm };
|
|
|
|
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
|
|
|
// Storm: mean 1.00, gust 0.75 (FoliageWindByWeather's built-in
|
|
// row), times the declared default wind-strength (1.0x) — exact,
|
|
// not a fraction of the way there.
|
|
Assert.Equal(1.00f, atmospheric.ClockWind.Y, 5);
|
|
Assert.Equal(0.75f, atmospheric.ClockWind.Z, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void SecondAdvanceStillEasesTowardTheNewTargetFromTheFirstAdvancesSnappedValue()
|
|
{
|
|
// Campaign VM VM6 review fix round 3 (A6): the snap-on-first-
|
|
// advance fix must not turn EVERY advance into a snap — only the
|
|
// very first one. Resolve once at Clear (snaps exactly to Clear's
|
|
// target), advance the pinned clock by exactly 1 real second
|
|
// (SetWindClockSecondsOverrideForTesting gives deterministic
|
|
// control no Thread.Sleep could), then resolve again at Storm on a
|
|
// fresh frame.Serial: this is the graph's SECOND advance, so it
|
|
// eases at rate = deltaSeconds / TransitionSeconds = 1 / 10 = 10%
|
|
// of the way from Clear's snapped value toward Storm's target,
|
|
// exactly like every advance did before this fix.
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium", windClockSecondsOverride: 0f);
|
|
graph.PrepareWorldTarget(640, 480, 1);
|
|
AtmosphericFrameInputs clearInputs = Inputs(640, 480) with { Weather = WeatherKind.Clear };
|
|
|
|
AtmosphericFrameUniforms first = RenderAndReadFrameBlock(device, graph, clearInputs);
|
|
Assert.Equal(0.25f, first.ClockWind.Y, 5); // Clear mean, snapped exactly
|
|
Assert.Equal(0.15f, first.ClockWind.Z, 5); // Clear gust, snapped exactly
|
|
|
|
graph.SetWindClockSecondsOverrideForTesting(1f);
|
|
AtmosphericFrameInputs stormInputs = Inputs(640, 480) with { Weather = WeatherKind.Storm };
|
|
AtmosphericFrameUniforms second = RenderAndReadFrameBlock(device, graph, stormInputs);
|
|
|
|
const float rate = 1f / 10f; // deltaSeconds(1) / TransitionSeconds(10)
|
|
float expectedMean = 0.25f + ((1.00f - 0.25f) * rate);
|
|
float expectedGust = 0.15f + ((0.75f - 0.15f) * rate);
|
|
Assert.Equal(expectedMean, second.ClockWind.Y, 5);
|
|
Assert.Equal(expectedGust, second.ClockWind.Z, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void IndoorGatesWindOutputToExactZeroRegardlessOfSmoothedState()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium", windClockSecondsOverride: 12f);
|
|
graph.PrepareWorldTarget(640, 480, 1);
|
|
// Storm is the highest declared mean/gust — the exact target does
|
|
// not matter here, only that the gate still zeroes the output
|
|
// despite a nonzero smoothed target.
|
|
AtmosphericFrameInputs indoors = Inputs(640, 480) with
|
|
{
|
|
Weather = WeatherKind.Storm,
|
|
IsOutdoor = false,
|
|
};
|
|
|
|
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, indoors);
|
|
|
|
Assert.Equal(0f, atmospheric.ClockWind.Y); // mean
|
|
Assert.Equal(0f, atmospheric.ClockWind.Z); // gust
|
|
}
|
|
|
|
[Fact]
|
|
public void WindEnabledFalseGatesWindOutputToExactZero()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(
|
|
device,
|
|
"medium",
|
|
windClockSecondsOverride: 12f,
|
|
userSettingOverrides: new Dictionary<string, string> { ["wind-enabled"] = "false" });
|
|
graph.PrepareWorldTarget(640, 480, 1);
|
|
AtmosphericFrameInputs outdoors = Inputs(640, 480) with { Weather = WeatherKind.Storm };
|
|
|
|
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
|
|
|
Assert.Equal(0f, atmospheric.ClockWind.Y);
|
|
Assert.Equal(0f, atmospheric.ClockWind.Z);
|
|
}
|
|
|
|
[Fact]
|
|
public void WindAmplitudeReflectsTheDeclaredSettingDefaults()
|
|
{
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium", windClockSecondsOverride: 5f);
|
|
graph.PrepareWorldTarget(640, 480, 1);
|
|
AtmosphericFrameInputs outdoors = Inputs(640, 480);
|
|
|
|
AtmosphericFrameUniforms atmospheric = RenderAndReadFrameBlock(device, graph, outdoors);
|
|
|
|
Assert.Equal(new Vector4(0.25f, 0.15f, 0.05f, 8f), atmospheric.WindAmplitude);
|
|
// 225 degrees, the declared default direction.
|
|
Assert.Equal(225f * (MathF.PI / 180f), atmospheric.ClockWind.W, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void RepeatedCallsOnTheSameFrameSerialProduceIdenticalWindBytes()
|
|
{
|
|
// RenderDirectionalShadows and RenderPostProcess both resolve the
|
|
// wind block for frame.Serial, and RenderDirectionalShadows runs
|
|
// first each frame. This proves the "advance once per Serial, later
|
|
// callers read the already-advanced state" invariant that makes the
|
|
// caster and receiver agree — without needing this hermetic
|
|
// harness's full WbDrawDispatcher/TerrainModernRenderer dependency
|
|
// chain to exercise RenderDirectionalShadows itself.
|
|
var device = new RecordingGpuDevice();
|
|
using var graph = Graph(device, "medium"); // real clock: proves it's the Serial guard, not a frozen override
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(640, 480, 1);
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
AtmosphericFrameInputs inputs = Inputs(640, 480) with { Weather = WeatherKind.Storm };
|
|
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
AtmosphericFrameUniforms first = ReadLastFrameBlock(device);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
AtmosphericFrameUniforms second = ReadLastFrameBlock(device);
|
|
frame.End();
|
|
|
|
Assert.Equal(first.ClockWind, second.ClockWind);
|
|
Assert.Equal(first.WindAmplitude, second.WindAmplitude);
|
|
}
|
|
|
|
private static AtmosphericFrameUniforms RenderAndReadFrameBlock(
|
|
RecordingGpuDevice device,
|
|
AtmosphericPostProcessGraph graph,
|
|
in AtmosphericFrameInputs inputs)
|
|
{
|
|
IGpuRenderTarget world = graph.PrepareWorldTarget(
|
|
inputs.ViewportWidth,
|
|
inputs.ViewportHeight,
|
|
1);
|
|
using IGpuFrame frame = device.BeginFrame();
|
|
RecordWorldPass(frame, world);
|
|
graph.RenderPostProcess(frame, in inputs);
|
|
frame.End();
|
|
return ReadLastFrameBlock(device);
|
|
}
|
|
|
|
private static AtmosphericFrameUniforms ReadLastFrameBlock(RecordingGpuDevice device)
|
|
{
|
|
GpuRecordedUniformBind frameBlock = device
|
|
.OfKind<GpuRecordedUniformBind>()
|
|
.Last(call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
|
|
return MemoryMarshal.Read<AtmosphericFrameUniforms>(
|
|
device.RingBytes.Slice(
|
|
(int)frameBlock.OffsetBytes,
|
|
AtmosphericFrameUniforms.SizeInBytes));
|
|
}
|
|
|
|
private static int CountOccurrences(string haystack, string needle)
|
|
{
|
|
int count = 0;
|
|
int index = 0;
|
|
while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0)
|
|
{
|
|
count++;
|
|
index += needle.Length;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static AtmosphericPostProcessGraph Graph(
|
|
RecordingGpuDevice device,
|
|
string presetId,
|
|
AtmosphericPostProcessSettings? settings = null,
|
|
float? windClockSecondsOverride = null,
|
|
IReadOnlyDictionary<string, string>? userSettingOverrides = null)
|
|
{
|
|
RenderPackDescriptor descriptor = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderQualityPreset preset = Assert.Single(
|
|
descriptor.QualityPresets,
|
|
value => string.Equals(value.Id, presetId, StringComparison.Ordinal));
|
|
return new AtmosphericPostProcessGraph(
|
|
device,
|
|
descriptor,
|
|
BuiltInAssets(),
|
|
preset,
|
|
settings,
|
|
userSettingOverrides,
|
|
windClockSecondsOverride);
|
|
}
|
|
|
|
private static RenderPackDescriptor ExternalTierOneDescriptor(
|
|
AtmospherePolicyDeclaration? policy = null)
|
|
{
|
|
var intermediate = new RenderResourceDeclaration(
|
|
"custom-half",
|
|
RenderResourceKind.Image2D,
|
|
RenderFormatClass.HdrColor,
|
|
new RenderExtentDeclaration(RenderExtentMode.RelativeToMainWorld, 0.5, 0.5),
|
|
SizeBytes: 0,
|
|
RenderResourceUsage.Sampled | RenderResourceUsage.ColorAttachment,
|
|
RenderResourceLifetime.ActivePack,
|
|
EstimatedResidentBytes: 8 * 1024 * 1024);
|
|
RenderPassDeclaration[] passes =
|
|
[
|
|
new RenderPassDeclaration(
|
|
"my-threshold",
|
|
RenderPassHook.AtmosphereBeforeToneMap,
|
|
"atmospheric_bloom_blur.vert.spv",
|
|
"atmospheric_bloom_blur.frag.spv",
|
|
[
|
|
RenderSemanticInput.WorldColor,
|
|
RenderSemanticInput.SunScreenPosition,
|
|
RenderSemanticInput.ActiveDayGroup,
|
|
RenderSemanticInput.Weather,
|
|
],
|
|
[],
|
|
[intermediate.Id]),
|
|
new RenderPassDeclaration(
|
|
"my-output",
|
|
RenderPassHook.ToneMap,
|
|
"atmospheric_bloom_blur.vert.spv",
|
|
"atmospheric_bloom_blur.frag.spv",
|
|
[],
|
|
[intermediate.Id],
|
|
[]),
|
|
];
|
|
var preset = new RenderQualityPreset(
|
|
"default", "Default", [], [], [],
|
|
32 * 1024 * 1024, 2, 3, 0.1, 0.2);
|
|
return BuiltInAtmosphericRenderPack.Descriptor with
|
|
{
|
|
Id = "example.generic-tier1",
|
|
DisplayName = "Generic Tier 1",
|
|
HighestTier = RenderPackTier.Tier1,
|
|
RequiredCapabilities =
|
|
[
|
|
RenderCapability.MainWorldColorIntermediate,
|
|
RenderCapability.FullscreenPasses,
|
|
RenderCapability.AuthoredSunScreenPosition,
|
|
RenderCapability.AuthoredWeather,
|
|
],
|
|
OptionalCapabilities = [],
|
|
Resources = [intermediate],
|
|
Passes = passes,
|
|
SceneReplays = [],
|
|
PipelineVariants = [],
|
|
QualityPresets = [preset],
|
|
Settings = [],
|
|
AtmospherePolicy = policy,
|
|
};
|
|
}
|
|
|
|
private static RenderPackDescriptor ExternalShadowsOnlyTierTwoDescriptor(
|
|
AtmospherePolicyDeclaration policy)
|
|
{
|
|
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
|
|
RenderResourceDeclaration shadowResource = source.Resources.Single(value =>
|
|
value.Semantic == RenderResourceSemantic.DirectionalShadowDepth) with
|
|
{
|
|
Id = "external-shadow-map",
|
|
};
|
|
RenderPassDeclaration shadowPass = source.Passes.Single(value =>
|
|
value.Semantic == RenderPassSemantic.DirectionalShadowDepth) with
|
|
{
|
|
Id = "external-shadow-depth",
|
|
ResourceWrites = [shadowResource.Id],
|
|
};
|
|
var outputCopy = new RenderPassDeclaration(
|
|
"output-copy",
|
|
RenderPassHook.ToneMap,
|
|
"atmospheric_bloom_blur.vert.spv",
|
|
"atmospheric_bloom_blur.frag.spv",
|
|
[RenderSemanticInput.WorldColor],
|
|
[],
|
|
[]);
|
|
RenderSettingSemantic[] settingSemantics =
|
|
[
|
|
RenderSettingSemantic.DirectionalShadowStrength,
|
|
RenderSettingSemantic.DirectionalShadowReachMetres,
|
|
RenderSettingSemantic.DirectionalShadowPcfTaps,
|
|
];
|
|
RenderSettingDeclaration[] settings = source.Settings
|
|
.Where(value => settingSemantics.Contains(value.Semantic))
|
|
.ToArray();
|
|
var preset = new RenderQualityPreset(
|
|
"medium",
|
|
"Medium",
|
|
[],
|
|
[],
|
|
[],
|
|
MaxResidentGpuBytes: 64L * 1024 * 1024,
|
|
MaxIncrementalGpuMillisecondsP50: 2.0,
|
|
MaxIncrementalGpuMillisecondsP99: 3.0,
|
|
MaxIncrementalCpuMillisecondsP50: 0.2,
|
|
MaxIncrementalCpuMillisecondsP99: 0.5)
|
|
{
|
|
Semantic = RenderQualitySemantic.Medium,
|
|
};
|
|
return new RenderPackDescriptor(
|
|
"example.shadows-only",
|
|
"External Shadows Only",
|
|
new Version(1, 0, 0),
|
|
RenderPackApi.Current,
|
|
RenderPackTier.Tier2,
|
|
[
|
|
RenderCapability.MainWorldColorIntermediate,
|
|
RenderCapability.FullscreenPasses,
|
|
RenderCapability.AuthoredSunDirection,
|
|
RenderCapability.AuthoredCelestialDirectionalLight,
|
|
RenderCapability.AuthoredWeather,
|
|
RenderCapability.DirectionalShadowMaps,
|
|
RenderCapability.OutdoorDirectionalShadowCasterReplay,
|
|
RenderCapability.AnimatedCasterTransforms,
|
|
RenderCapability.AlphaCutoutShadowCasters,
|
|
],
|
|
[RenderCapability.GpuTimestampQueries],
|
|
[shadowResource],
|
|
[shadowPass, outputCopy],
|
|
source.SceneReplays,
|
|
source.PipelineVariants.Where(value => value.Semantic is
|
|
RenderPipelineVariantSemantic.TerrainDirectionalShadowCaster
|
|
or RenderPipelineVariantSemantic.WorldOpaqueDirectionalShadowCaster
|
|
or RenderPipelineVariantSemantic.WorldAlphaCutoutDirectionalShadowCaster
|
|
or RenderPipelineVariantSemantic.TerrainDirectionalShadowReceiver
|
|
or RenderPipelineVariantSemantic.WorldDirectionalShadowReceiver).ToArray(),
|
|
[preset],
|
|
settings,
|
|
policy)
|
|
{
|
|
FeatureSummary = "Selected-celestial shadows with an HDR output copy and no post stack.",
|
|
};
|
|
}
|
|
|
|
private static void RecordWorldPass(IGpuFrame frame, IGpuRenderTarget world)
|
|
{
|
|
using IGpuPassEncoder _ = frame.BeginPass(new GpuPassDescription
|
|
{
|
|
Name = "test-world-hdr",
|
|
Color = new GpuColorAttachment(
|
|
world,
|
|
GpuLoadOp.Clear,
|
|
world.Description.SampleCount > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store,
|
|
Vector4.Zero),
|
|
Depth = new GpuDepthAttachment(
|
|
GpuLoadOp.Clear,
|
|
GpuStoreOp.Store,
|
|
1f,
|
|
0),
|
|
SampleCount = world.Description.SampleCount,
|
|
});
|
|
}
|
|
|
|
private static void PublishCurrentShadow(
|
|
AtmosphericPostProcessGraph graph,
|
|
IGpuFrame frame) => PublishCurrentShadow(
|
|
graph.DirectionalShadowReceivers,
|
|
frame);
|
|
|
|
private static void PublishCurrentShadow(
|
|
IDirectionalShadowReceiverSource receiverSource,
|
|
IGpuFrame frame)
|
|
{
|
|
var renderer = Assert.IsType<DirectionalSunShadowRenderer>(
|
|
receiverSource);
|
|
GpuRingAllocation transformAllocation = frame.AllocateRing(
|
|
checked((int)WorldTransformCapacityPolicy.InitialBindingSizeBytes),
|
|
GpuRingUsage.Storage);
|
|
var sharedTransforms = new WorldTransformFrameSlice(
|
|
frame.Serial,
|
|
transformAllocation.Buffer,
|
|
transformAllocation.OffsetBytes,
|
|
WorldTransformCapacityPolicy.InitialBindingSizeBytes,
|
|
FirstInstance: 0,
|
|
InstanceCount: 0);
|
|
DirectionalSunShadowDiagnostics diagnostics = renderer.RenderPrepared(
|
|
frame,
|
|
new DirectionalShadowEnvironmentState(
|
|
DirectionalShadowGateReason.Enabled,
|
|
Vector3.Normalize(new Vector3(0.2f, 0.3f, 1f)),
|
|
LightElevationSin: 0.94f,
|
|
Strength: 0.8f,
|
|
SoftnessMultiplier: 1.25f,
|
|
SourceKind: AuthoredCelestialShadowSourceKind.Sun),
|
|
Matrix4x4.Identity,
|
|
Matrix4x4.CreatePerspectiveFieldOfView(
|
|
MathF.PI / 3f,
|
|
16f / 9f,
|
|
0.1f,
|
|
500f),
|
|
cameraNearMeters: 0.1f,
|
|
casterDepthPaddingMeters: 48f,
|
|
worldDraws: new DirectionalShadowPreparedDraws(),
|
|
terrainDraws: new DirectionalShadowTerrainPreparedDraws(),
|
|
worldGeometry: null,
|
|
terrainGeometry: null,
|
|
sharedTransforms);
|
|
Assert.True(diagnostics.CascadeCount > 0);
|
|
}
|
|
|
|
private static void SetLastShadowDiagnostics(
|
|
AtmosphericPostProcessGraph graph,
|
|
DirectionalSunShadowDiagnostics diagnostics)
|
|
{
|
|
System.Reflection.FieldInfo field = typeof(AtmosphericPostProcessGraph)
|
|
.GetField(
|
|
"_lastShadowDiagnostics",
|
|
System.Reflection.BindingFlags.Instance
|
|
| System.Reflection.BindingFlags.NonPublic)
|
|
?? throw new InvalidOperationException(
|
|
"Atmospheric graph no longer owns its directional-shadow diagnostics.");
|
|
field.SetValue(graph, diagnostics);
|
|
}
|
|
|
|
private static AtmosphericFrameInputs Inputs(
|
|
int width,
|
|
int height,
|
|
float elevation = 4f,
|
|
int activeDayGroup = 0) => new(
|
|
new Vector2(0.5f, 0.35f),
|
|
SunIsOnScreen: true,
|
|
elevation,
|
|
new Vector3(1f, 0.85f, 0.65f),
|
|
Vector3.Normalize(new Vector3(0.2f, 0.5f, 0.8f)),
|
|
SunDirectionalBrightness: 1f,
|
|
Matrix4x4.Identity,
|
|
activeDayGroup,
|
|
WeatherKind.Clear,
|
|
WeatherIntensity: 0f,
|
|
DeltaSeconds: 1d / 60d,
|
|
width,
|
|
height,
|
|
IsOutdoor: true);
|
|
|
|
private static AtmosphericPackPassUniforms ReadPass(
|
|
RecordingGpuDevice device,
|
|
GpuRecordedUniformBind binding) =>
|
|
MemoryMarshal.Read<AtmosphericPackPassUniforms>(device.RingBytes.Slice(
|
|
(int)binding.OffsetBytes,
|
|
AtmosphericPackPassUniforms.SizeInBytes));
|
|
|
|
private static IRenderPackAssets BuiltInAssets() =>
|
|
BuiltInAtmosphericRenderPack.CreateAssets(Path.Combine(
|
|
RepositoryRoot(),
|
|
"src",
|
|
"AcDream.App",
|
|
"Rendering",
|
|
"Shaders",
|
|
"spv"));
|
|
|
|
private static RenderPackDescriptor RenamedExternalTierTwoDescriptor()
|
|
{
|
|
RenderPackDescriptor source = BuiltInAtmosphericRenderPack.Descriptor;
|
|
Dictionary<string, string> resources = source.Resources
|
|
.Select((value, index) => (value.Id, Renamed: $"external-resource-{index}"))
|
|
.ToDictionary(static value => value.Id, static value => value.Renamed,
|
|
StringComparer.OrdinalIgnoreCase);
|
|
Dictionary<string, string> settings = source.Settings
|
|
.Select((value, index) => (value.Id, Renamed: $"external-setting-{index}"))
|
|
.ToDictionary(static value => value.Id, static value => value.Renamed,
|
|
StringComparer.OrdinalIgnoreCase);
|
|
string Shader(string asset) => $"external/{asset}";
|
|
|
|
return source with
|
|
{
|
|
Id = "example.external-atmosphere",
|
|
Resources = source.Resources.Select(value => value with
|
|
{
|
|
Id = resources[value.Id],
|
|
}).ToArray(),
|
|
Passes = source.Passes.Select((value, index) => value with
|
|
{
|
|
Id = $"external-pass-{index}",
|
|
VertexShaderAsset = Shader(value.VertexShaderAsset),
|
|
FragmentShaderAsset = Shader(value.FragmentShaderAsset),
|
|
ResourceReads = value.ResourceReads.Select(id => resources[id]).ToArray(),
|
|
ResourceWrites = value.ResourceWrites.Select(id => resources[id]).ToArray(),
|
|
}).ToArray(),
|
|
SceneReplays = source.SceneReplays.Select((value, index) => value with
|
|
{
|
|
Id = $"external-replay-{index}",
|
|
}).ToArray(),
|
|
PipelineVariants = source.PipelineVariants.Select((value, index) => value with
|
|
{
|
|
Id = $"external-variant-{index}",
|
|
VertexShaderAsset = Shader(value.VertexShaderAsset),
|
|
FragmentShaderAsset = Shader(value.FragmentShaderAsset),
|
|
}).ToArray(),
|
|
QualityPresets = source.QualityPresets.Select((value, index) => value with
|
|
{
|
|
Id = $"external-quality-{index}",
|
|
ResourceOverrides = value.ResourceOverrides.Select(resource => resource with
|
|
{
|
|
ResourceId = resources[resource.ResourceId],
|
|
}).ToArray(),
|
|
SettingOverrides = value.SettingOverrides.Select(setting => setting with
|
|
{
|
|
SettingId = settings[setting.SettingId],
|
|
}).ToArray(),
|
|
}).ToArray(),
|
|
Settings = source.Settings.Select((value, index) => value with
|
|
{
|
|
Id = settings[value.Id],
|
|
}).ToArray(),
|
|
};
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
private sealed class RejectingAssets : IRenderPackAssets
|
|
{
|
|
public Stream OpenRead(string assetKey) =>
|
|
throw new InvalidOperationException("A no-op pack must not open shader assets.");
|
|
}
|
|
|
|
private sealed class RenamedShaderAssets(IRenderPackAssets inner) : IRenderPackAssets
|
|
{
|
|
public Stream OpenRead(string assetKey)
|
|
{
|
|
const string prefix = "external/";
|
|
if (!assetKey.StartsWith(prefix, StringComparison.Ordinal))
|
|
throw new InvalidOperationException($"Unexpected external asset '{assetKey}'.");
|
|
return inner.OpenRead(assetKey[prefix.Length..]);
|
|
}
|
|
}
|
|
}
|