diff --git a/docs/plans/2026-08-22-visualmaster-campaign.md b/docs/plans/2026-08-22-visualmaster-campaign.md
index 8127b462..b3f6b676 100644
--- a/docs/plans/2026-08-22-visualmaster-campaign.md
+++ b/docs/plans/2026-08-22-visualmaster-campaign.md
@@ -82,10 +82,10 @@ single-pass path darkens by ~10 %. Neither explains a scene-wide level.
| VM3 | F4 — linear-light post stack | **CODE-COMPLETE 2026-08-22** — `87677f9c`, `51178f7c`, closeout; Opus APPROVE; **owner visual gate OWED** (brief below) |
| VM4 | F5 — overclaiming docs, incl. the reviewer's own | **CLOSED 2026-08-22** |
| VM5 | F8 — volumetric banding; F7 filed | **CLOSED 2026-08-22** (#421) |
-| VM6 | Foliage wind | automated + **owner visual** |
+| VM6 | Foliage wind | **CODE-COMPLETE 2026-08-23** — `0930c35d`, `39e8408c`, `43e3abed`, `a82959f1`, `fccba839`, `eec95535`; four Opus review rounds (round 3 APPROVE, round 4 narrow re-review of `fccba839`+`eec95535`); pixel proof [note](../research/2026-08-23-vm6-foliage-wind-pixel-proof.md) (13,854 robust wind px vs 19 floor, scenery only); **owner visual gate OWED** (§VM6 Acceptance) |
| VM7 | Closeout: full gates, register, roadmap, merge | **owner** |
-Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (code-complete, gate owed) → VM6 → VM7. VM0 goes first because everything after it is measured
+Order is VM2 (done) → VM0 (done) → VM1 (done) → VM4 (done) → VM5 (done) → VM3 (code-complete, gate owed) → VM6 (code-complete, gate owed) → VM7. VM0 goes first because everything after it is measured
against the baseline it establishes.
---
@@ -495,7 +495,7 @@ direction to read, and the register row says so.
indoors — nothing moves; (6) pack off — nothing moves. The owner's words
decide; the numbers above are starting points to tune live.
-### VM6 outcome (implementation landed 2026-08-22; owner visual gate outstanding)
+### VM6 outcome (implementation landed 2026-08-22; code-complete 2026-08-23 at `eec95535`; owner visual gate outstanding)
Two commits (VM6a shader ABI v2 plumbing, VM6b the feature) shipped the
design above; a same-day fix-round commit corrected the weather-table key.
@@ -694,13 +694,53 @@ 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.
+once it is selected.
+
+**Review fix round 5 (2026-08-23) correction to the paragraph above:** the
+claim "the flag bit makes it numerically the plain lighting sum" was
+false as written — F1 BLOCKER, found by the reviewer's own offline pixel
+apparatus reading the shader source, not by a repro capture (the disabled
+block's `(0,0,1)` direction differs too little from straight-overhead to
+show up above the apparatus's noise floor at these amplitudes, so the
+mislighting was invisible to that specific test even though it is real).
+Both receiver VERTEX shaders (`mesh_atmospheric.vert`,
+`terrain_atmospheric.vert`) sourced the sun direction used to compute
+`directionalLit`/`vDirectionalLit` from the shadow block's own
+`uShadowLightDirectionAndSource`, unconditionally — not only the
+*visibility* term, which was already correctly flag-gated. Every shadow-
+gated-off frame was lighting outdoor terrain and objects from the
+disabled block's `(0,0,1)` placeholder — straight overhead — regardless
+of the authored sun's actual position. The celestial shadow source
+direction is not the authored light direction either, so publishing it
+instead would not have restored parity. Fixed in the shaders themselves:
+both receiver verts now branch on the SAME flag bit
+(`(uShadowTextureAndFlags.w & 1u) == 0u`) and, when clear, use the EXACT
+plain-pipeline expression (`-uLights[i].dirAndRange.xyz` in
+`mesh_atmospheric.vert`, matching `mesh_modern.vert`;
+`-uLights[0].dirAndRange.xyz` in `terrain_atmospheric.vert`, matching
+`terrain_modern.vert`) instead of the shadow block's direction. Corrected
+statement: with the flag bit clear the receiver shaders take visibility
+1.0 AND fall back to the authored `uLights` direction (round 5), so a
+gated-off frame is numerically the plain pipeline. Wind is unaffected —
+`foliage_wind.glsl` reads only the AtmosphericFrame half of set 3, never
+the shadow block.
+
+**Pixel proof after round 4 (2026-08-23, `eec95535`):** the same
+apparatus that found the defect — High preset, `sun-shadow-strength=0` so
+no shadow term can move, same pinned clock, wind on vs off, two captures per
+arm so the repeat pairs measure the run-to-run floor — gives a robust wind
+mask of **13,854 px** (amplified: strength 2, lean/branch 1 m, flutter
+0.5 m) against a **19 px** floor, and **1,402 px** at the default Clear
+strength; every marked pixel is a treeline tree, hillside tree or shoreline
+bush, and no house, fence, road, lifestone, ground, water or UI pixel moves.
+The first method tried (clock pin 0 s vs 3 s, wind-off pair as the control)
+is recorded as confounded in the note and must not be reused: the treeline
+silhouette carries a bimodal 0-or-~280 px rasterisation churn between runs
+that lands on either side of the subtraction by luck. Tool:
+`tools/vm6/wind-pixel-proof.py`; note:
+`docs/research/2026-08-23-vm6-foliage-wind-pixel-proof.md`.
## 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
diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
index 152b7657..f2691404 100644
--- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
+++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs
@@ -316,28 +316,37 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
return !_disposed && binding.IsBindableFor(frame);
}
- internal DirectionalSunShadowDiagnostics Render(
+ ///
+ /// Campaign VM VM6 review fix round 5 (T1): the gate prologue Render
+ /// runs before it touches world/terrain, extracted into its own
+ /// internal method so a hermetic test can drive it directly —
+ /// constructing a real WbDrawDispatcher/TerrainModernRenderer pair
+ /// just to reach these two early-out paths is not a cheap test (see
+ /// PublishDisabledReceiverBinding's own doc comment). Returns the
+ /// disabled diagnostics Render should return immediately, or null when
+ /// the gate passed and Render should continue (in which case
+ /// and
+ /// are the validated values
+ /// Render's remaining stages use). Behaviour-preserving: identical
+ /// order, identical PublishDisabledReceiverBinding calls, identical
+ /// Disabled(...) construction as before the extraction.
+ ///
+ internal DirectionalSunShadowDiagnostics? EvaluateGateAndPublishDisabledBinding(
IGpuFrame frame,
in DirectionalSunShadowRenderInput input,
- WbDrawDispatcher world,
- TerrainModernRenderer terrain)
+ out DirectionalShadowEnvironmentState environment,
+ out long environmentGateTicks)
{
- ObjectDisposedException.ThrowIf(_disposed, this);
- ArgumentNullException.ThrowIfNull(frame);
- _currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
- ArgumentNullException.ThrowIfNull(world);
- ArgumentNullException.ThrowIfNull(terrain);
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
- DirectionalShadowEnvironmentState environment =
- DirectionalShadowEnvironmentGate.Evaluate(
- input.Environment,
- _atmospherePolicy);
- long environmentGateTicks = input.MeasureCpuStages
+ environment = DirectionalShadowEnvironmentGate.Evaluate(
+ input.Environment,
+ _atmospherePolicy);
+ environmentGateTicks = input.MeasureCpuStages
? Stopwatch.GetTimestamp() - cpuStageStarted
: 0L;
if (!environment.ShouldRender)
{
- PublishDisabledReceiverBinding(frame, in input);
+ PublishDisabledReceiverBinding(frame, input.AtmosphericFrame);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
@@ -349,14 +358,35 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
- PublishDisabledReceiverBinding(frame, in input);
+ PublishDisabledReceiverBinding(frame, input.AtmosphericFrame);
return Disabled(
in environment,
new DirectionalSunShadowCpuStageTicks(
environmentGateTicks, 0L, 0L, 0L, 0L));
}
+ return null;
+ }
- cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
+ internal DirectionalSunShadowDiagnostics Render(
+ IGpuFrame frame,
+ in DirectionalSunShadowRenderInput input,
+ WbDrawDispatcher world,
+ TerrainModernRenderer terrain)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(frame);
+ _currentFrameBinding = DirectionalShadowFrameBinding.Disabled;
+ ArgumentNullException.ThrowIfNull(world);
+ ArgumentNullException.ThrowIfNull(terrain);
+ DirectionalSunShadowDiagnostics? gated = EvaluateGateAndPublishDisabledBinding(
+ frame,
+ in input,
+ out DirectionalShadowEnvironmentState environment,
+ out long environmentGateTicks);
+ if (gated is not null)
+ return gated.Value;
+
+ long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
DirectionalShadowPreparedDraws worldDraws =
world.PrepareDirectionalShadowDraws(input.Casters);
DirectionalShadowTerrainPreparedDraws terrainDraws =
@@ -495,9 +525,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
///
internal void PublishDisabledReceiverBinding(
IGpuFrame frame,
- in DirectionalSunShadowRenderInput input)
+ AtmosphericFrameBufferBinding atmosphericFrame)
{
- if (!input.AtmosphericFrame.IsBound)
+ if (!atmosphericFrame.IsBound)
return;
var disabledUniforms = new DirectionalShadowUniforms(
@@ -509,6 +539,14 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
Vector4.Zero,
Vector4.Zero,
new UInt4(0u, 0u, 0u, 0u),
+ // Campaign VM VM6 review fix round 5 (F1): (0,0,1) is a
+ // normalize()-cannot-NaN guard ONLY — round 5 fixed
+ // mesh_atmospheric.vert/terrain_atmospheric.vert to fall back
+ // to the plain pipeline's uLights-derived direction whenever
+ // this block's flags bit 0 is clear, so this vector is never
+ // actually read as a light direction any more. It stays a
+ // unit vector regardless, in case a future reader forgets that
+ // rule and reads it directly.
new Vector4(0f, 0f, 1f, 0f));
GpuRingAllocation allocation = frame.AllocateRing(
DirectionalShadowUniforms.SizeInBytes,
@@ -523,7 +561,7 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
DirectionalShadowUniforms.SizeInBytes,
GpuTextureSlot.Unassigned,
CascadeCount: 0,
- AtmosphericFrame: input.AtmosphericFrame);
+ AtmosphericFrame: atmosphericFrame);
}
internal static DirectionalShadowCasterClassDiagnostics
@@ -632,6 +670,16 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
{
Reason = DirectionalShadowGateReason.ResidentWindowUnavailable,
};
+ // Campaign VM VM6 review fix round 5 (F2): a third bufferless-
+ // disabled exit reachable from a frame that already passed
+ // Render's own two gates (ShouldRender, the constructor-level
+ // ResidentMaximumReachMeters check) — the cascade fitter can
+ // still find zero usable cascades. Publish the same disabled
+ // receiver binding as Render's two early-outs so this path
+ // doesn't reintroduce the F1/round-4 bug for the rarer case
+ // where a frame draws the world but the shadow fitter itself
+ // bails out.
+ PublishDisabledReceiverBinding(frame, atmosphericFrame);
return Disabled(in unavailable, cpuStages);
}
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
index 7dd43406..967b40ae 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert
@@ -269,11 +269,26 @@ vec3 accumulateAmbientLocalLights(
// get no sun even in windowed buildings where the player's frame is not sun-killed.
if (uLightingMode == 0) {
if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun
+ // Campaign VM VM6 review fix round 5 (F1 BLOCKER): when the
+ // shadow block's flag bit is clear (a gated-off frame — see
+ // DirectionalSunShadowRenderer.PublishDisabledReceiverBinding),
+ // uShadowLightDirectionAndSource carries only a (0,0,1) NaN
+ // guard, NOT a real light direction — using it here regardless
+ // lit every gated-off frame (every night, sun-shadow-strength
+ // 0, indoors, portal cover) from straight overhead. Fall back
+ // to the EXACT plain mesh_modern.vert expression
+ // (-uLights[i].dirAndRange.xyz, unnormalized — matched
+ // bit-for-bit) so a gated-off frame is numerically the plain
+ // pipeline. Hoisted out of the loop: a uniform branch,
+ // evaluated once per vertex regardless of light count.
+ bool shadowGatedOff = (uShadowTextureAndFlags.w & 1u) == 0u;
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
- vec3 Ldir = normalize(uShadowLightDirectionAndSource.xyz);
+ vec3 Ldir = shadowGatedOff
+ ? -uLights[i].dirAndRange.xyz
+ : normalize(uShadowLightDirectionAndSource.xyz);
float ndl = max(0.0, dot(N, Ldir));
directionalLit += uLights[i].colorAndIntensity.xyz
* uLights[i].colorAndIntensity.w * ndl;
diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv
index 3cc72ed5..8ffa95f0 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index d6d0d9ca..c8cd1094 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -215,7 +215,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "a00aa83a8bd8ab4791b60bfb967a80b43bafcb0bf758f5e2e056b4272f3c8b1b",
+ "sourceSha256": "a6b7aa2b0c1c38f92e4b35e76a58acdf5adc916f6ed0cebf94c8bea3f8843666",
"compiled": true
},
{
@@ -327,7 +327,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "06258c7ead0e123740e325c802156987ed17dabe355d591d0e90facb420b8e04",
+ "sourceSha256": "8cc199b3c6582831e5c9614e19afdff4f0f5fcdb8d96271c8cb3f427e29c68c9",
"compiled": true
},
{
diff --git a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv
index d672dcb9..171b4460 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/terrain_atmospheric.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
index 10fb57aa..a4399b9c 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
+++ b/src/AcDream.App/Rendering/Shaders/terrain_atmospheric.vert
@@ -160,7 +160,17 @@ void main() {
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
- vec3 surfaceToLight = normalize(uShadowLightDirectionAndSource.xyz);
+ // Campaign VM VM6 review fix round 5 (F1 BLOCKER): when the shadow
+ // block's flag bit is clear (a gated-off frame), uShadowLightDirectionAndSource
+ // carries only a (0,0,1) NaN guard, NOT a real light direction. Fall
+ // back to the EXACT plain terrain_modern.vert expression
+ // (-uLights[0].dirAndRange.xyz, unnormalized — matched bit-for-bit)
+ // so a gated-off frame is numerically the plain pipeline — see the
+ // matching comment in mesh_atmospheric.vert's accumulateLights.
+ bool shadowGatedOff = (uShadowTextureAndFlags.w & 1u) == 0u;
+ vec3 surfaceToLight = shadowGatedOff
+ ? -uLights[0].dirAndRange.xyz
+ : normalize(uShadowLightDirectionAndSource.xyz);
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, surfaceToLight), MIN_FACTOR);
// Preserve retail's authored lighting values, but keep the outdoor
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs
index 244e7d95..5fe6c082 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadowReceivers.cs
@@ -124,11 +124,18 @@ public sealed partial class WbDrawDispatcher
// 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.
+ // disabled shadow block and the real AtmosphericFrame wind data
+ // mesh_atmospheric.vert needs regardless of shadow gating. Review
+ // fix round 5 (F4 correction): with the flag bit clear the
+ // receiver shaders take visibility 1.0 AND fall back to the
+ // authored uLights direction (mesh_atmospheric.vert/
+ // terrain_atmospheric.vert, round 5) — NOT "the flag bit alone
+ // makes it numerically the plain lighting sum" as an earlier round
+ // claimed; the vertex shaders sourced the sun direction from this
+ // very block unconditionally until round 5's shader fix, so the
+ // fallback needed BOTH the flag-gated visibility (already correct)
+ // and the flag-gated direction (round 5) to actually match the
+ // plain pipeline.
if (binding.Buffer is null)
return;
encoder.BindUniformBuffer(
diff --git a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
index 7e9467f6..e99b0f34 100644
--- a/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/DirectionalShadowGpuTests.cs
@@ -6,6 +6,7 @@ using AcDream.App.Rendering.Packs;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.Core.World;
using DatReaderWriter.Enums;
namespace AcDream.App.Tests.Rendering;
@@ -550,8 +551,14 @@ public sealed class DirectionalShadowGpuTests
/// 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.
+ /// "full visibility" contract). Round 5 correction: the written
+ /// direction (0,0,1) is asserted below purely as the documented
+ /// normalize()-cannot-NaN GUARD value it is — round 4 mistakenly
+ /// treated writing a plausible-looking direction as sufficient; round
+ /// 5 fixed the receiver vertex shaders (mesh_atmospheric.vert,
+ /// terrain_atmospheric.vert) to ignore this value entirely and fall
+ /// back to the authored uLights direction whenever the flags bit is
+ /// clear, so (0,0,1) is never read as a real light direction.
///
[Fact]
public void PublishDisabledReceiverBinding_IsBindableButNotValidWithZeroFlagsAndUnitDirection()
@@ -563,16 +570,10 @@ public sealed class DirectionalShadowGpuTests
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);
+ renderer.PublishDisabledReceiverBinding(frame, atmosphericFrame);
Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
Assert.True(binding.IsBindableFor(frame));
@@ -588,6 +589,8 @@ public sealed class DirectionalShadowGpuTests
(int)binding.OffsetBytes,
DirectionalShadowUniforms.SizeInBytes));
Assert.Equal(0u, written.TextureAndFlags.W);
+ // (0,0,1) is the NaN guard, not a light direction — see the doc
+ // comment above and PublishDisabledReceiverBinding's own comment.
Assert.Equal(new Vector4(0f, 0f, 1f, 0f), written.LightDirectionAndSource);
}
@@ -602,20 +605,157 @@ public sealed class DirectionalShadowGpuTests
// 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);
+ renderer.PublishDisabledReceiverBinding(frame, default);
Assert.False(renderer.TryGetCurrentFrameBinding(frame, out _));
Assert.Empty(device.OfKind());
}
+ ///
+ /// Campaign VM VM6 review fix round 5, test T1 (indoor early-out):
+ /// no test calls DirectionalSunShadowRenderer.Render because it needs
+ /// a real WbDrawDispatcher + TerrainModernRenderer to construct (still
+ /// true — nothing in this suite stands either up), so the gate
+ /// prologue Render runs before touching either is extracted into
+ /// EvaluateGateAndPublishDisabledBinding (behaviour-preserving; see
+ /// its own doc comment) and tested directly here. PlayerInsideCell is
+ /// the simplest trigger for Render's FIRST early-out
+ /// (!environment.ShouldRender).
+ ///
+ [Fact]
+ public void EvaluateGateAndPublishDisabledBinding_PlayerInsideCellPublishesBindableNotValidBinding()
+ {
+ using var device = new RecordingGpuDevice();
+ using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
+ using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-indoor-frame", GpuBufferUsage.Uniform);
+ var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
+ var input = new DirectionalSunShadowRenderInput(
+ new DirectionalShadowEnvironmentInput(
+ PackEnabled: true,
+ PortalOrLoginCoverVisible: false,
+ PlayerInsideCell: true,
+ Source: AuthoredCelestialShadowSource.None(),
+ Atmosphere: default),
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ new DirectionalShadowCasterFrame(),
+ AtmosphericFrame: atmosphericFrame);
+ device.Clear();
+ using IGpuFrame frame = device.BeginFrame();
+
+ DirectionalSunShadowDiagnostics? gated = renderer.EvaluateGateAndPublishDisabledBinding(
+ frame,
+ in input,
+ out DirectionalShadowEnvironmentState environment,
+ out long environmentGateTicks);
+
+ Assert.NotNull(gated);
+ Assert.Equal(DirectionalShadowGateReason.Indoor, gated.Value.GateReason);
+ Assert.Equal(DirectionalShadowGateReason.Indoor, environment.Reason);
+ Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
+ Assert.True(binding.IsBindableFor(frame));
+ Assert.False(binding.IsValidFor(frame));
+ }
+
+ ///
+ /// Campaign VM VM6 review fix round 5, test T1 (resident-window
+ /// early-out): Render's SECOND early-out. Reaching it needs a fully
+ /// valid environment (ShouldRender true), so the environment input
+ /// below constructs a real celestial source above the horizon with
+ /// energy, and a fully-progressed atmosphere snapshot — anything less
+ /// would gate off at the FIRST check instead of reaching
+ /// ResidentMaximumReachMeters.
+ ///
+ [Fact]
+ public void EvaluateGateAndPublishDisabledBinding_ResidentWindowUnavailablePublishesBindableNotValidBinding()
+ {
+ using var device = new RecordingGpuDevice();
+ using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
+ using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-resident-frame", GpuBufferUsage.Uniform);
+ var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
+ var validSource = new AuthoredCelestialShadowSource(
+ AuthoredCelestialShadowSourceKind.Sun,
+ ObjectIndex: 0,
+ GfxObjId: 1u,
+ SurfaceToLightDirection: Vector3.UnitZ,
+ ElevationSin: 0.5f,
+ AuthoredEnergy: 1f);
+ var validAtmosphere = new AtmosphereSnapshot(
+ WeatherKind.Clear,
+ Intensity: 1f,
+ FogColor: Vector3.Zero,
+ FogStart: 0f,
+ FogEnd: 0f,
+ FogMode: default,
+ LightningFlash: 0f,
+ Override: default);
+ var input = new DirectionalSunShadowRenderInput(
+ new DirectionalShadowEnvironmentInput(
+ PackEnabled: true,
+ PortalOrLoginCoverVisible: false,
+ PlayerInsideCell: false,
+ Source: validSource,
+ Atmosphere: validAtmosphere,
+ ActiveDayGroupMultiplier: 1f),
+ Matrix4x4.Identity,
+ Matrix4x4.Identity,
+ new DirectionalShadowCasterFrame(),
+ CameraNearMeters: 0.1f,
+ ResidentMaximumReachMeters: 0.05f, // <= CameraNearMeters
+ AtmosphericFrame: atmosphericFrame);
+ device.Clear();
+ using IGpuFrame frame = device.BeginFrame();
+
+ DirectionalSunShadowDiagnostics? gated = renderer.EvaluateGateAndPublishDisabledBinding(
+ frame,
+ in input,
+ out DirectionalShadowEnvironmentState environment,
+ out long environmentGateTicks);
+
+ Assert.NotNull(gated);
+ Assert.Equal(DirectionalShadowGateReason.ResidentWindowUnavailable, gated.Value.GateReason);
+ Assert.Equal(DirectionalShadowGateReason.ResidentWindowUnavailable, environment.Reason);
+ Assert.True(renderer.TryGetCurrentFrameBinding(frame, out DirectionalShadowFrameBinding binding));
+ Assert.True(binding.IsBindableFor(frame));
+ Assert.False(binding.IsValidFor(frame));
+ }
+
+ [Fact]
+ public void ShouldSelectReceiverPipelineComposesWithTheRealBindingSourceAfterADisabledPublish()
+ {
+ // Campaign VM VM6 review fix round 5, test T2: proves the ACTUAL
+ // composition WbDrawDispatcher.PipelinesFor and
+ // TerrainModernRenderer both use — TryGetCurrentFrameBinding
+ // feeding ShouldSelectReceiverPipeline — selects the receiver
+ // pipeline for the atmospheric world pass once a disabled binding
+ // is published, and still refuses a non-atmospheric pass name.
+ // ShouldSelectReceiverPipeline itself is unchanged this round;
+ // this is the composition its own DirectionalShadowReceiverTests
+ // parametrized coverage does not exercise.
+ using var device = new RecordingGpuDevice();
+ using var renderer = new DirectionalSunShadowRenderer(device, DirectionalShadowPreset.Low);
+ using IGpuBuffer atmosphericBuffer = Buffer(device, "gate-pipeline-frame", GpuBufferUsage.Uniform);
+ var atmosphericFrame = new AtmosphericFrameBufferBinding(atmosphericBuffer, 0u, 192u);
+ device.Clear();
+ using IGpuFrame frame = device.BeginFrame();
+
+ renderer.PublishDisabledReceiverBinding(frame, atmosphericFrame);
+ bool bindingValid = renderer.TryGetCurrentFrameBinding(frame, out _);
+
+ Assert.True(bindingValid);
+ Assert.True(DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
+ DirectionalShadowReceiverPolicy.AtmosphericWorldPassName,
+ sourcePresent: true,
+ bindingValid));
+ Assert.False(DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
+ "vk-world",
+ sourcePresent: true,
+ bindingValid));
+ }
+
[Fact]
public void BindDirectionalShadowReceiver_WithADisabledBindingEmitsBothShadowAndAtmosphericBinds()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
index e84bc758..0c8293ea 100644
--- a/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Packs/AtmosphericPostProcessGraphTests.cs
@@ -1222,6 +1222,55 @@ public sealed class AtmosphericPostProcessGraphTests
// ── Campaign VM VM6: foliage wind ────────────────────────────────────
+ [Fact]
+ public void ReceiverVertexShadersFallBackToThePlainLightDirectionWhenTheShadowFlagIsClear()
+ {
+ // Campaign VM VM6 review fix round 5 (F1 BLOCKER, test T3): pins
+ // the shader SOURCE TEXT so a future edit that removes the
+ // flag-gated fallback (reintroducing "a shadow-gated-off frame
+ // lights outdoor terrain/objects from the disabled block's
+ // (0,0,1) placeholder instead of the authored sun") fails this
+ // test instead of only showing up in a pixel capture. Both
+ // receiver vertex shaders must (1) test the SAME flags bit
+ // acdreamDirectionalShadowVisibility already tests, and (2)
+ // reference the SAME uLights-derived expression the corresponding
+ // plain (mesh_modern.vert / terrain_modern.vert) pipeline uses,
+ // proving the fallback branch is not some other, unverified
+ // direction.
+ string shaderRoot = Path.Combine(
+ RepositoryRoot(),
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "Shaders");
+ string meshAtmospheric = File.ReadAllText(Path.Combine(shaderRoot, "mesh_atmospheric.vert"));
+ string meshModern = File.ReadAllText(Path.Combine(shaderRoot, "mesh_modern.vert"));
+ string terrainAtmospheric = File.ReadAllText(Path.Combine(shaderRoot, "terrain_atmospheric.vert"));
+ string terrainModern = File.ReadAllText(Path.Combine(shaderRoot, "terrain_modern.vert"));
+
+ const string flagTest = "(uShadowTextureAndFlags.w & 1u) == 0u";
+ Assert.Contains(flagTest, meshAtmospheric, StringComparison.Ordinal);
+ Assert.Contains(flagTest, terrainAtmospheric, StringComparison.Ordinal);
+
+ // mesh: both files negate the per-light uniform inline (no
+ // intermediate variable), so the literal expression matches
+ // string-for-string.
+ const string meshPlainDirection = "-uLights[i].dirAndRange.xyz";
+ Assert.Contains(meshPlainDirection, meshModern, StringComparison.Ordinal);
+ Assert.Contains(meshPlainDirection, meshAtmospheric, StringComparison.Ordinal);
+
+ // terrain: terrain_modern.vert reads the uniform into `sunDir`
+ // then negates that variable two lines later, so only the
+ // shared uniform-access substring (not the exact negation
+ // placement) is asserted identical between the two files.
+ const string terrainLightUniform = "uLights[0].dirAndRange.xyz";
+ Assert.Contains(terrainLightUniform, terrainModern, StringComparison.Ordinal);
+ Assert.Contains(
+ "-" + terrainLightUniform,
+ terrainAtmospheric,
+ StringComparison.Ordinal);
+ }
+
[Fact]
public void FirstAdvanceSnapsExactlyToTheWeatherTargetInsteadOfEasingFromZero()
{