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() {