fix(render): foliage wind no longer depends on the directional-shadow gate (Campaign VM VM6 review 4)

The reviewer's offline pixel apparatus found a real design defect, not a
test artefact: foliage wind was welded to "directional shadows rendered
this frame." Evidence: offline High preset, sun-shadow-strength=0,
wind-strength 2 + lean/branch 1 m — wind-on vs wind-off at the same
pinned clock differed by only 49-65 px, inside the apparatus's own 22 px
run-to-run noise floor (no measurable motion). A CPU probe independently
confirmed ResolveFoliageWind was correct (first advance snaps to Clear
0.25/0.15, gate 1, one graph) — the correct uniform never reached the
world pass.

Root cause: DirectionalSunShadowRenderer.Render's two early-out paths
(!environment.ShouldRender, ResidentWindowUnavailable) left
_currentFrameBinding at its pure Disabled (no-buffer) default.
WbDrawDispatcher.PipelinesFor and TerrainModernRenderer's matching
selection logic only choose the atmospheric receiver pipeline
(mesh_atmospheric, the only pipeline that #includes foliage_wind.glsl)
when TryGetCurrentFrameBinding returns true; with no buffer it always
returned false, so the world pass silently fell back to the plain
mesh_modern pipeline, which has no wind code at all. Because the shadow
gate is ActiveDayGroupMultiplier = dayGroupPolicy x elevationResponse x
strength, this killed wind every night (elevation response -> 0), at
user sun-shadow-strength 0, and under the portal/login cover.

Fix (decouple, not patch): DirectionalShadowFrameBinding gained
IsBindableFor ("a real current-frame allocation exists") separate from
IsValidFor ("...and it is Enabled with real shadow content" -- kept
exactly as VolumetricShaftRenderer's own gate needs it).
TryGetCurrentFrameBinding now returns IsBindableFor. When the built-in
pack supplies an AtmosphericFrame binding (declared packs never do, so
their receiver shaders -- which never declare set 3 binding 5 -- are
unaffected), Render's two early-out paths call a new
PublishDisabledReceiverBinding: it allocates one real ring slice and
writes a DISABLED DirectionalShadowUniforms block -- every matrix
Identity, every control/bias term zero, TextureAndFlags all zero (bit 0
clear is exactly what directional_shadow_receiver.glsl's
acdreamDirectionalShadowVisibility already reads as "no shadow, full
visibility" via its existing early return 1.0), and a unit light
direction (0,0,1) so a fragment shader's normalize() can never produce
NaN. BindDirectionalShadowReceiver and TerrainModernRenderer's
shadow-buffer bind now check Buffer is not null instead of Enabled, so
the disabled block actually gets bound once it is selected.

PublishDisabledReceiverBinding is internal (not private) specifically so
it is testable without standing up a real WbDrawDispatcher/
TerrainModernRenderer pair -- no test in this suite constructs either.
New tests: (a)/(b) PublishDisabledReceiverBinding is bindable-not-valid
with a bound AtmosphericFrame and a genuine no-op with an unbound one;
(c) BindDirectionalShadowReceiver emits both UniformDirectionalShadow and
UniformAtmosphericFrame binds for a disabled binding; (d)
VolumetricShaftRenderer's gate still reports NoCurrentDirectionalShadow
for a disabled binding. ShouldSelectReceiverPipeline itself is untouched
and its existing tests (parametrized directly on bindingValid) remain
valid; no existing test asserted the old "disabled shadows -> plain
pipeline / no binding" behaviour in a way this fix invalidates -- every
existing caller either bypasses Render (calls RenderPrepared directly)
or uses a stale-serial binding IsBindableFor still correctly rejects.

Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,050/0 failed. Core.Tests 4,695/0 failed. Full hermetic-filtered
solution: 15,278/0 failed across 15 projects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-23 03:29:57 +02:00
parent fccba8390d
commit eec9553582
7 changed files with 381 additions and 7 deletions

View file

@ -653,7 +653,54 @@ deterministic amount between two resolves — proving both the first-advance
snap and that a SECOND advance still eases normally — without a real-time
`Thread.Sleep`.
**Review fix round 4 (2026-08-23): wind decoupled from the shadow gate.**
The reviewer's offline pixel apparatus caught a real design defect the
first three rounds' CPU-side reasoning could not see: foliage wind was
welded to "directional shadows rendered this frame." Evidence: at the
High preset with `sun-shadow-strength=0` and wind-strength 2 + 1 m
lean/branch amplitude, wind-on vs wind-off at the same pinned clock
differed by only 4965 px — inside the apparatus's own 22 px run-to-run
noise floor, i.e. no measurable motion at all. A CPU probe independently
confirmed `ResolveFoliageWind` itself was correct (first advance snaps
exactly to Clear's 0.25/0.15, the gate is 1, one graph instance) — the
correct uniform was computed but never reached the world pass. Root
cause: `DirectionalSunShadowRenderer.Render` left `_currentFrameBinding`
at its pure `Disabled` (no-buffer) default on both early-out paths
(`!environment.ShouldRender`, `ResidentWindowUnavailable`);
`WbDrawDispatcher.PipelinesFor`/`TerrainModernRenderer`'s matching
selection logic only chose the atmospheric receiver pipeline
(`mesh_atmospheric`, which alone `#include`s `foliage_wind.glsl`) when
`TryGetCurrentFrameBinding` returned true; with no buffer it always
returned false, so the world pass silently ran the plain `mesh_modern`
pipeline instead — which has no wind code at all. Because the shadow
gate is `ActiveDayGroupMultiplier = dayGroupPolicy × elevationResponse ×
strength`, this killed wind every NIGHT (elevation response → 0), at
user `sun-shadow-strength` 0, and under the portal/login cover — not
just in the artificial `strength=0` repro.
Fix (decouple, don't patch): `DirectionalShadowFrameBinding` gained
`IsBindableFor` ("a real current-frame allocation exists") separate from
`IsValidFor` ("...and it is Enabled with real shadow content" — the
volumetric pass still gates on this, unchanged);
`TryGetCurrentFrameBinding` now returns `IsBindableFor`. When the
built-in pack supplies an `AtmosphericFrame` binding (declared packs
never do, so they are unaffected), `Render`'s two early-out paths call a
new `PublishDisabledReceiverBinding`: it allocates one real ring slice
and writes a DISABLED `DirectionalShadowUniforms` block — every matrix
Identity, every control/bias term zero, `TextureAndFlags` all zero (bit
0 clear is exactly what `directional_shadow_receiver.glsl`'s
`acdreamDirectionalShadowVisibility` already reads as "no shadow, full
visibility" via its existing early `return 1.0`), and a UNIT light
direction `(0,0,1)` so a fragment shader's `normalize()` can never
produce NaN. `BindDirectionalShadowReceiver` and
`TerrainModernRenderer`'s shadow-buffer bind now check `Buffer is not
null` instead of `Enabled`, so this disabled block actually gets bound
once it is selected. Consequence in one sentence: the built-in pack's
world pass now always runs the receiver shader; with shadows gated off,
the shadow block's own flag bit makes its shadow term numerically the
plain lighting sum, while wind keeps moving correctly.
## VM7 — Closeout and merge
— Closeout and merge
- Full gates: `tools/run-release-gate.ps1` (hermetic lanes), the AR
reference matrix re-run for the changed presets, VM0's masked comparison

View file

@ -30,11 +30,36 @@ internal readonly record struct DirectionalShadowFrameBinding(
{
internal static DirectionalShadowFrameBinding Disabled => default;
internal bool IsValidFor(IGpuFrame frame) =>
Enabled
&& Buffer is not null
/// <summary>
/// Campaign VM VM6 review fix round 4 (item 1): "there IS a real,
/// current-frame ring allocation here" — independent of whether the
/// shadow content it carries is Enabled. A directional-shadow-gated-off
/// frame (indoors, portal cover, night, sun-shadow-strength 0, ...)
/// still publishes a DISABLED-content block via
/// PublishDisabledReceiverBinding when an AtmosphericFrame binding is
/// available, specifically so the world receiver pipeline (which reads
/// wind from set 3/binding 5, not the shadow block) keeps running
/// regardless of shadow gating. This is the predicate consumers use to
/// decide "is there something here to bind" — see
/// BindDirectionalShadowReceiver and TerrainModernRenderer's own
/// receiver-pipeline selection.
/// </summary>
internal bool IsBindableFor(IGpuFrame frame) =>
Buffer is not null
&& FrameSerial == frame.Serial
&& SizeBytes == DirectionalShadowUniforms.SizeInBytes
&& SizeBytes == DirectionalShadowUniforms.SizeInBytes;
/// <summary>
/// "There is a real, current-frame, ENABLED shadow map here" — the
/// stricter predicate consumers that need actual shadow content (texture
/// slot, cascade count) must use, e.g. VolumetricShaftRenderer's own
/// gate. A disabled block published by PublishDisabledReceiverBinding
/// deliberately fails this (TextureSlot.Unassigned, CascadeCount 0) even
/// though it passes IsBindableFor.
/// </summary>
internal bool IsValidFor(IGpuFrame frame) =>
IsBindableFor(frame)
&& Enabled
&& TextureSlot.IsAssigned
&& CascadeCount is >= 2 and <= 4;
}
@ -47,6 +72,20 @@ internal interface IDirectionalShadowReceiverSource
{
DirectionalShadowPipelineShaders PipelineShaders { get; }
/// <summary>
/// Campaign VM VM6 review fix round 4 (item 2): returns TRUE whenever
/// <paramref name="binding"/> is bindable for <paramref name="frame"/>
/// (<see cref="DirectionalShadowFrameBinding.IsBindableFor"/>) — NOT
/// whether shadows are Enabled this frame. This deliberately decouples
/// world-receiver pipeline selection (which needs binding 5's wind
/// data, unconditionally, from any bound AtmosphericFrame) from the
/// directional-shadow gate (indoors, portal cover, night, strength 0,
/// ...), which used to also silently disable foliage wind because it
/// left the world pass on the plain (no-wind) pipeline. A caller that
/// needs actual shadow CONTENT — texture slot, cascade count — must
/// additionally check <see cref="DirectionalShadowFrameBinding.IsValidFor"/>
/// on the returned binding (e.g. VolumetricShaftRenderer's own gate).
/// </summary>
bool TryGetCurrentFrameBinding(
IGpuFrame frame,
out DirectionalShadowFrameBinding binding);

View file

@ -313,7 +313,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
{
ArgumentNullException.ThrowIfNull(frame);
binding = _currentFrameBinding;
return !_disposed && binding.IsValidFor(frame);
return !_disposed && binding.IsBindableFor(frame);
}
internal DirectionalSunShadowDiagnostics Render(
@ -336,16 +336,20 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
if (!environment.ShouldRender)
{
PublishDisabledReceiverBinding(frame, in input);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
if (input.ResidentMaximumReachMeters <= input.CameraNearMeters)
{
environment = environment with
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
PublishDisabledReceiverBinding(frame, in input);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
@ -447,6 +451,81 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
}
}
/// <summary>
/// Campaign VM VM6 review fix round 4 (item 3): the offline pixel gate
/// found wind welded to "directional shadows rendered this frame" —
/// <see cref="Render"/>'s two early-disabled paths left
/// <see cref="_currentFrameBinding"/> at its pure
/// <see cref="DirectionalShadowFrameBinding.Disabled"/> default (no
/// buffer at all), so <see cref="TryGetCurrentFrameBinding"/> returned
/// false, the world receiver pipeline selection
/// (<c>DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline</c>)
/// fell back to the plain <c>mesh_modern</c>/<c>mesh_atmospheric</c>-less
/// pipeline, and foliage_wind.glsl's include never ran — even though
/// the built-in pack's CPU wind state (<c>ResolveFoliageWind</c>) kept
/// ticking correctly the whole time. The shadow gate fires far more
/// often than "shadows visibly missing" suggests: every night
/// (elevation response goes to 0), at authored sun-shadow-strength 0,
/// indoors, and under the portal/login cover.
///
/// <para>Fix: when the built-in pack supplies an AtmosphericFrame
/// binding (declared packs never do — their receiver shaders don't
/// declare set 3 binding 5, so they correctly keep today's plain-
/// pipeline behaviour when shadows are gated off), publish a REAL ring
/// allocation carrying a DISABLED shadow block — every matrix Identity,
/// every control/bias term zero, <c>TextureAndFlags</c> all zero (bit 0
/// clear is exactly what <c>directional_shadow_receiver.glsl</c>'s
/// <c>acdreamDirectionalShadowVisibility</c> already reads as "no
/// shadow, full visibility" — see its early <c>return 1.0</c>), and a
/// UNIT light direction (never the zero vector a fragment shader's
/// <c>normalize()</c> could turn into NaN). This binding passes
/// <see cref="DirectionalShadowFrameBinding.IsBindableFor"/> (there IS
/// a current-frame allocation) but fails
/// <see cref="DirectionalShadowFrameBinding.IsValidFor"/> (Enabled is
/// false, TextureSlot is Unassigned, CascadeCount is 0) — so the world
/// receiver pipeline runs (and reads correct wind data from the
/// AtmosphericFrame half of the same binding), while anything that
/// actually needs shadow content (VolumetricShaftRenderer's own gate)
/// still correctly reports no current directional shadow.</para>
/// <para><c>internal</c>, not <c>private</c>: a hermetic test drives
/// this directly (constructing the full environment gate plus a real
/// WbDrawDispatcher/TerrainModernRenderer pair just to reach Render's
/// early-out paths is not a cheap test — no test in this suite
/// constructs either) instead of standing up that dependency chain.</para>
/// </summary>
internal void PublishDisabledReceiverBinding(
IGpuFrame frame,
in DirectionalSunShadowRenderInput input)
{
if (!input.AtmosphericFrame.IsBound)
return;
var disabledUniforms = new DirectionalShadowUniforms(
Matrix4x4.Identity,
Matrix4x4.Identity,
Matrix4x4.Identity,
Matrix4x4.Identity,
Vector4.Zero,
Vector4.Zero,
Vector4.Zero,
new UInt4(0u, 0u, 0u, 0u),
new Vector4(0f, 0f, 1f, 0f));
GpuRingAllocation allocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
MemoryMarshal.Write(allocation.Data, in disabledUniforms);
_currentFrameBinding = new DirectionalShadowFrameBinding(
frame.Serial,
Enabled: false,
allocation.Buffer,
allocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
GpuTextureSlot.Unassigned,
CascadeCount: 0,
AtmosphericFrame: input.AtmosphericFrame);
}
internal static DirectionalShadowCasterClassDiagnostics
CompleteCasterClassDiagnostics(
in DirectionalShadowCasterBuildStats casterStats,

View file

@ -255,7 +255,19 @@ public sealed unsafe partial class TerrainModernRenderer
BindTilingTable(encoder);
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
if (shadowBinding.Enabled && shadowBinding.Buffer is not null)
// Campaign VM VM6 review fix round 4 (item 4): bind on BINDABLE,
// not Enabled — same rule as WbDrawDispatcher.BindDirectionalShadowReceiver.
// TryGetCurrentFrameBinding now returns true for a disabled-content
// binding whenever the built-in pack published one (shadows gated
// off but AtmosphericFrame still bound), which also switches
// ShouldSelectReceiverPipeline to the receiver pipeline above —
// that pipeline expects SOMETHING bound at set 3/binding 6. Without
// this fix the stale `Enabled` check would skip the bind here,
// leaving binding 6 reading whatever a prior pass left there
// instead of the safe all-zero disabled block. Terrain has no wind
// (only the world mesh receiver reads AtmosphericFrame), so this
// fix only concerns the shadow block, not foliage.
if (shadowBinding.Buffer is not null)
{
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,

View file

@ -117,7 +117,19 @@ public sealed partial class WbDrawDispatcher
IGpuPassEncoder encoder,
in DirectionalShadowFrameBinding binding)
{
if (!binding.Enabled || binding.Buffer is null)
// Campaign VM VM6 review fix round 4 (item 4): bind on BINDABLE,
// not Enabled. A directional-shadow-gated-off frame still publishes
// a real (disabled-content) binding via
// DirectionalSunShadowRenderer.PublishDisabledReceiverBinding so
// the world receiver pipeline (selected on the exact same
// TryGetCurrentFrameBinding predicate — see
// WbDrawDispatcher.PipelinesFor above) can bind BOTH words: the
// disabled shadow block (its flags bit 0 clear makes
// directional_shadow_receiver.glsl's acdreamDirectionalShadowVisibility
// return 1.0 unconditionally — numerically the plain lighting sum)
// and the real AtmosphericFrame wind data mesh_atmospheric.vert
// needs regardless of shadow gating.
if (binding.Buffer is null)
return;
encoder.BindUniformBuffer(
GpuBindingModel.UniformDirectionalShadow,

View file

@ -538,6 +538,145 @@ public sealed class DirectionalShadowGpuTests
Assert.Equal("shadow-frame", shadowBind.BufferName);
}
/// <summary>
/// Campaign VM VM6 review fix round 4, test (a): the offline pixel gate
/// found foliage wind welded to "directional shadows rendered this
/// frame" — a shadow-gated-off frame used to leave
/// TryGetCurrentFrameBinding returning false, which fell the world
/// receiver pipeline back to the plain (no-wind) mesh pipeline. This
/// proves the fix directly against PublishDisabledReceiverBinding
/// (made internal for exactly this reason — see its doc comment):
/// given a bound AtmosphericFrame, it publishes a binding that IS
/// bindable (TryGetCurrentFrameBinding true) but is NOT a valid shadow
/// (IsValidFor false, Enabled false), and the written block's flags
/// word is exactly 0 (directional_shadow_receiver.glsl's bit-0-clear
/// "full visibility" contract) with a unit light direction (0,0,1) —
/// never the zero vector a shader's normalize() could turn into NaN.
/// </summary>
[Fact]
public void PublishDisabledReceiverBinding_IsBindableButNotValidWithZeroFlagsAndUnitDirection()
{
using var device = new RecordingGpuDevice();
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
using IGpuBuffer atmosphericBuffer = Buffer(device, "wind-only-frame", GpuBufferUsage.Uniform);
var atmosphericFrame = new AtmosphericFrameBufferBinding(
atmosphericBuffer,
OffsetBytes: 0u,
SizeBytes: 192u);
var input = new DirectionalSunShadowRenderInput(
default,
Matrix4x4.Identity,
Matrix4x4.Identity,
new DirectionalShadowCasterFrame(),
AtmosphericFrame: atmosphericFrame);
device.Clear();
using IGpuFrame frame = device.BeginFrame();
renderer.PublishDisabledReceiverBinding(frame, in input);
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
Assert.True(binding.IsBindableFor(frame));
Assert.False(binding.IsValidFor(frame));
Assert.False(binding.Enabled);
Assert.Equal(0, binding.CascadeCount);
Assert.False(binding.TextureSlot.IsAssigned);
Assert.True(binding.AtmosphericFrame.IsBound);
Assert.Same(atmosphericBuffer, binding.AtmosphericFrame.Buffer);
DirectionalShadowUniforms written = MemoryMarshal.Read<DirectionalShadowUniforms>(
device.RingBytes.Slice(
(int)binding.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes));
Assert.Equal(0u, written.TextureAndFlags.W);
Assert.Equal(new Vector4(0f, 0f, 1f, 0f), written.LightDirectionAndSource);
}
[Fact]
public void PublishDisabledReceiverBinding_NoOpsWhenAtmosphericFrameIsUnboundPreservingTodaysBehaviour()
{
// Campaign VM VM6 review fix round 4, test (b): a declared (non
// built-in) pack never supplies an AtmosphericFrame binding, so
// this must stay a pure no-op for it — TryGetCurrentFrameBinding
// keeps returning false exactly like before this fix, and a
// declared pack's receiver shader (which never declares set 3
// binding 5) is unaffected.
using var device = new RecordingGpuDevice();
using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
var input = new DirectionalSunShadowRenderInput(
default,
Matrix4x4.Identity,
Matrix4x4.Identity,
new DirectionalShadowCasterFrame());
device.Clear();
using IGpuFrame frame = device.BeginFrame();
renderer.PublishDisabledReceiverBinding(frame, in input);
Assert.False(renderer.TryGetCurrentFrameBinding(frame, out _));
Assert.Empty(device.OfKind<GpuRecordedRingAllocation>());
}
[Fact]
public void BindDirectionalShadowReceiver_WithADisabledBindingEmitsBothShadowAndAtmosphericBinds()
{
// Campaign VM VM6 review fix round 4, test (c): the whole point of
// publishing a disabled-content binding instead of leaving
// TryGetCurrentFrameBinding at false is that BindDirectionalShadowReceiver
// (fixed in this same round to check Buffer, not Enabled) actually
// emits BOTH binds for it — the safe all-zero shadow block AND the
// real wind data — so the world receiver pipeline (selected because
// the binding is bindable) has everything it declares.
using var device = new RecordingGpuDevice();
using IGpuBuffer shadowBuffer = Buffer(device, "disabled-shadow-frame", GpuBufferUsage.Uniform);
using IGpuBuffer atmosphericBuffer = Buffer(device, "disabled-atmospheric-frame", GpuBufferUsage.Uniform);
var binding = new DirectionalShadowFrameBinding(
FrameSerial: 1,
Enabled: false,
Buffer: shadowBuffer,
OffsetBytes: 0u,
SizeBytes: DirectionalShadowUniforms.SizeInBytes,
TextureSlot: GpuTextureSlot.Unassigned,
CascadeCount: 0,
AtmosphericFrame: new AtmosphericFrameBufferBinding(
atmosphericBuffer,
OffsetBytes: 0u,
SizeBytes: 192u));
device.Clear();
var target = device.CreateRenderTarget(new GpuRenderTargetDescription(
"test-world-hdr",
640,
480,
GpuTextureFormat.Rgba16FloatRenderTarget,
GpuTextureFormat.Depth24Stencil8,
SampleCount: 1));
using IGpuFrame frame = device.BeginFrame();
using (IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
{
Name = "test-world-hdr",
Color = new GpuColorAttachment(
target,
GpuLoadOp.Clear,
GpuStoreOp.Store,
Vector4.Zero),
Depth = new GpuDepthAttachment(GpuLoadOp.Clear, GpuStoreOp.Store, 1f, 0),
SampleCount = 1,
}))
{
WbDrawDispatcher.BindDirectionalShadowReceiver(encoder, in binding);
}
frame.End();
GpuRecordedUniformBind shadowBind = Assert.Single(
device.OfKind<GpuRecordedUniformBind>(),
call => call.Binding == GpuBindingModel.UniformDirectionalShadow);
Assert.Equal("disabled-shadow-frame", shadowBind.BufferName);
GpuRecordedUniformBind atmosphericBind = Assert.Single(
device.OfKind<GpuRecordedUniformBind>(),
call => call.Binding == GpuBindingModel.UniformAtmosphericFrame);
Assert.Equal("disabled-atmospheric-frame", atmosphericBind.BufferName);
}
[Fact]
public void StableTopology_ReusesRetainedCommandBuffersWithoutFrameRingCopies()
{

View file

@ -134,6 +134,52 @@ public sealed class VolumetricShaftRendererTests
Assert.Empty(device.CreatedRenderTargets);
}
[Fact]
public void DisabledShadowContentBindingStillGatesToNoCurrentDirectionalShadow()
{
// Campaign VM VM6 review fix round 4, test (d): PublishDisabledReceiverBinding
// (DirectionalSunShadowRenderer) now publishes a REAL, bindable
// ring allocation even when shadows are gated off, specifically so
// the world receiver pipeline keeps reading wind data. This proves
// the volumetric pass is unaffected by that change: its own gate
// reads IsValidFor, which a disabled-content binding (Enabled
// false, TextureSlot Unassigned, CascadeCount 0) still fails, so it
// reports NoCurrentDirectionalShadow exactly as it would for no
// binding at all — never mistaking "bindable" for "has real shadow
// content."
var device = new RecordingGpuDevice();
using var renderer = Renderer(device, "high");
GpuTextureSlot depth = TextureSlot(device, "scene-depth");
using IGpuFrame frame = device.BeginFrame();
GpuRingAllocation allocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
GpuRingUsage.Uniform);
allocation.Data.Clear();
var disabledContent = new DirectionalShadowFrameBinding(
frame.Serial,
Enabled: false,
allocation.Buffer,
allocation.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes,
GpuTextureSlot.Unassigned,
CascadeCount: 0);
AtmosphericFrameInputs inputs = Inputs(1280, 720);
VolumetricShaftOutput output = renderer.Render(
frame,
in inputs,
in disabledContent,
depth);
frame.End();
Assert.True(disabledContent.IsBindableFor(frame));
Assert.False(disabledContent.IsValidFor(frame));
Assert.Equal(
VolumetricShaftGateReason.NoCurrentDirectionalShadow,
output.Diagnostics.GateReason);
Assert.False(output.HasTexture);
}
[Fact]
public void ResizeAtomicallyReplacesTargetAndResetsMixedResolutionPerformanceWindow()
{