acdream/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
Erik 87677f9c4f feat(render): Atmospheric post stack runs in linear light; neutral preset is numerically the pack-off image (Campaign VM VM3)
Closes review finding F4 (docs/research/2026-08-22-campaign-ar-review.md):
retail's main-world colour, sun rays and volumetric shafts are all
gamma-encoded display-space values (the 2013 client has no linear
lighting pipeline), but bloom thresholding, ACES (Narkowicz fit),
Rec.709 luma saturation, the contrast pivot and the vignette were all
operating directly on those gamma values, then writing the result to
the UNORM swapchain without re-encoding.

- atmospheric_common.glsl gains acdreamDecodeDisplay/acdreamEncodeDisplay
  (pow(c, 2.2) / pow(c, 1/2.2)). 2.2 is the retail-era CRT/early-LCD
  display-gamma assumption, deliberately not the sRGB piecewise curve,
  which would claim a precision retail's authoring pipeline never had.
  uAtmosphereSunColor's comment is corrected from "authored linear rgb"
  to "authored display-space rgb (retail has no linear pipeline)".
- atmospheric_bloom_downsample.frag, atmospheric_filmic.frag (both the
  fused-Low and non-fused paths) decode every world/ray/volumetric read
  before summing/thresholding; atmospheric_bloom_blur.frag is unchanged
  (it already reads the now-linear bloom buffer); atmospheric_sun_rays.frag
  and atmospheric_volumetric.frag are documented as writing display-space
  colour that the consumers decode.
- The contrast pivot moves from 0.5 (a gamma-space midpoint) to 0.18
  (linear mid-grey, the standard 18%-grey-card exposure convention).
  The final filmic output is clamped in linear, then re-encoded before
  the UNORM write.
- Bloom threshold/knee are re-derived for linear light: the pre-VM3
  gamma-space pair was threshold 1.0 / knee 0.45, i.e. a soft range of
  [0.55, 1.0] in gamma. Decoding both ends with the same 2.2 assumption
  gives decode(1.0) = 1.0 (threshold unchanged) and
  decode(0.55) = 0.55^2.2 ~= 0.27, so linear knee = 1.0 - 0.27 ~= 0.73.
  Replaced the inline 0.45f literals with named constants
  BloomThresholdLinear = 1f / BloomKneeLinear = 0.73f on
  AtmosphericPostProcessGraph. bloom-strength's 0.65 default is
  untouched.
- Exposure stays at its accepted 0.80 default: in linear,
  encode(acesFitted(0.80 * decode(0.46))) ~= 0.50, reproducing the same
  accepted midtone the old gamma-space pipeline produced as 0.51 for the
  same 0.46 input (0.46 * 0.80 fed straight into acesFitted, no
  decode/encode). Highlights now retain more (gamma 0.9 input moves from
  ~0.74 to ~0.85 through the full pipeline) and blacks deepen slightly
  (gamma 0.1 moves from ~0.09 to ~0.05) — the owner's visual gate judges.
- Added AtmosphericColorPipeline, a CPU mirror of the GLSL decode/encode/
  ACES/grade/filmic math (line-for-line, with a header comment requiring
  it stay mirrored), and AtmosphericColorPipelineTests: neutral-preset
  identity within half an 8-bit step for a 0..255 grey sweep (proving the
  neutral preset is numerically the pack-off image), decode/encode
  round-trip within 1e-6, monotonic-in-exposure, the pinned midtone/
  highlight/shadow numbers above, and the bloom-knee derivation.
- Added a shader-source pinning test so a future edit cannot silently
  drop the colour-space conversions: atmospheric_filmic.frag must
  contain exactly one acdreamEncodeDisplay( call in main()'s output,
  atmospheric_bloom_downsample.frag must contain at least three
  acdreamDecodeDisplay( calls.
- Regenerated SPIR-V (tools/compile-shaders.ps1, glslc from the
  installed Vulkan SDK). Only atmospheric_bloom_downsample.frag.spv and
  atmospheric_filmic.frag.spv changed in bytes; every other pack shader
  that includes atmospheric_common.glsl recompiled to a byte-identical
  binary (the new decode/encode helpers are unreferenced dead code for
  them). VulkanShaderManifestTests' retail-oracle SHA-256 set
  (mesh_modern, terrain_modern, mesh_detail, etc.) is untouched and
  still passes — the retail default path did not change.
- Docs: noted the linear-light move in the AR plan's Slice 1 section,
  and added a "Colour space" section to the render-pack ABI doc
  (docs/render-packs/semantic-bindings-v1.md) naming which inputs are
  display-space and pointing at atmospheric_common.glsl as the
  reference implementation. No ABI version bump — the binding layout
  is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:10:57 +02:00

1573 lines
66 KiB
C#

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 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);
int decodesInDownsample = CountOccurrences(downsample, "acdreamDecodeDisplay(");
Assert.True(
decodesInDownsample >= 3,
$"expected at least 3 acdreamDecodeDisplay( calls in atmospheric_bloom_downsample.frag, found {decodesInDownsample}");
}
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)
{
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);
}
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..]);
}
}
}