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

@ -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,