acdream/src/AcDream.App/Rendering/DirectionalShadowReceiver.cs
Erik eec9553582 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>
2026-08-23 03:29:57 +02:00

205 lines
8.6 KiB
C#

using System.Numerics;
using AcDream.App.Rendering.Gpu;
namespace AcDream.App.Rendering;
/// <summary>
/// The ordinary, non-ref view of the directional-shadow allocation produced for
/// one frame. The serial prevents a ring slice from leaking into a later frame.
/// </summary>
internal readonly record struct DirectionalShadowFrameBinding(
long FrameSerial,
bool Enabled,
IGpuBuffer? Buffer,
uint OffsetBytes,
uint SizeBytes,
GpuTextureSlot TextureSlot,
int CascadeCount,
// Campaign VM VM6 review fix round (A3): the SAME AtmosphericFrame
// binding the caster pass bound this frame (see
// DirectionalSunShadowRenderer.RenderPrepared's atmosphericFrame
// parameter), carried on this existing per-frame seam so the world
// receiver pass (mesh_atmospheric.vert, which reads
// uAtmosphereClockWind/uAtmosphereWindAmplitude) binds set 3/binding 5
// itself instead of relying on whatever the caster pass happened to
// leave bound earlier in the frame. Unbound (default) is valid — it
// just means no caster ran this frame (or the source never supplied
// one, e.g. a declared pack); BindDirectionalShadowReceiver skips
// binding 5 in that case exactly like the caster side does.
AtmosphericFrameBufferBinding AtmosphericFrame = default)
{
internal static DirectionalShadowFrameBinding Disabled => default;
/// <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;
/// <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;
}
/// <summary>
/// Receiver-side seam. A pack runtime may publish this source at a stable frame
/// boundary without exposing the producer's target or ref-struct allocation.
/// </summary>
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);
}
internal readonly record struct DirectionalShadowPipelineShaders(
GpuShaderSet TerrainCaster,
GpuShaderSet WorldOpaqueCaster,
GpuShaderSet WorldAlphaCutoutCaster,
GpuShaderSet TerrainReceiver,
GpuShaderSet WorldReceiver)
{
internal DirectionalShadowMultiviewPipelineShaders? MultiviewCasters { get; init; }
internal static DirectionalShadowPipelineShaders Local { get; } = new(
new GpuShaderSet("directional_shadow_terrain"),
new GpuShaderSet("directional_shadow_world_opaque"),
new GpuShaderSet("directional_shadow_world_cutout"),
new GpuShaderSet("terrain_atmospheric"),
new GpuShaderSet("mesh_atmospheric"))
{
MultiviewCasters = new DirectionalShadowMultiviewPipelineShaders(
new GpuShaderSet("directional_shadow_terrain_multiview"),
new GpuShaderSet("directional_shadow_world_opaque_multiview"),
new GpuShaderSet("directional_shadow_world_cutout_multiview")),
};
}
internal readonly record struct DirectionalShadowMultiviewPipelineShaders(
GpuShaderSet TerrainCaster,
GpuShaderSet WorldOpaqueCaster,
GpuShaderSet WorldAlphaCutoutCaster);
internal readonly record struct DirectionalShadowCascadeBlend(
int PrimaryCascade,
int SecondaryCascade,
float SecondaryWeight,
bool WithinShadowReach);
/// <summary>CPU mirror of receiver-only cascade and world-metre bias policy.</summary>
internal static class DirectionalShadowReceiverPolicy
{
internal const string AtmosphericWorldPassName = "atmospheric-world-hdr";
internal static bool ShouldSelectReceiverPipeline(
string passName,
bool sourcePresent,
bool bindingValid) =>
sourcePresent
&& bindingValid
&& string.Equals(
passName,
AtmosphericWorldPassName,
StringComparison.Ordinal);
internal static DirectionalShadowCascadeBlend SelectCascade(
float cameraDistanceMeters,
Vector4 splitFarMeters,
int cascadeCount,
float blendWidthMeters)
{
if (!float.IsFinite(cameraDistanceMeters) || cameraDistanceMeters < 0f)
throw new ArgumentOutOfRangeException(nameof(cameraDistanceMeters));
if (cascadeCount is < 2 or > 4)
throw new ArgumentOutOfRangeException(nameof(cascadeCount));
if (!float.IsFinite(blendWidthMeters) || blendWidthMeters < 0f)
throw new ArgumentOutOfRangeException(nameof(blendWidthMeters));
Span<float> splits = stackalloc float[4]
{
splitFarMeters.X,
splitFarMeters.Y,
splitFarMeters.Z,
splitFarMeters.W,
};
for (int i = 0; i < cascadeCount; i++)
{
if (!float.IsFinite(splits[i])
|| splits[i] <= 0f
|| (i > 0 && splits[i] < splits[i - 1]))
{
throw new ArgumentException(
"Directional-shadow split distances must be finite, positive, and monotonic.",
nameof(splitFarMeters));
}
}
int primary = 0;
while (primary < cascadeCount && cameraDistanceMeters > splits[primary])
primary++;
if (primary == cascadeCount)
return new DirectionalShadowCascadeBlend(cascadeCount - 1, cascadeCount - 1, 0f, false);
if (primary == cascadeCount - 1 || blendWidthMeters <= 0f)
return new DirectionalShadowCascadeBlend(primary, primary, 0f, true);
float blendStart = MathF.Max(0f, splits[primary] - blendWidthMeters);
float t = Math.Clamp(
(cameraDistanceMeters - blendStart) / MathF.Max(blendWidthMeters, 1e-6f),
0f,
1f);
float smooth = t * t * (3f - 2f * t);
return new DirectionalShadowCascadeBlend(primary, primary + 1, smooth, true);
}
internal static float ReceiverBiasMeters(
in DirectionalShadowWorldBias bias,
float normalDotSurfaceToLight) =>
bias.ConstantDepthMeters
+ bias.SlopeDepthMeters * (1f - Math.Clamp(normalDotSurfaceToLight, 0f, 1f));
internal static bool ShouldSample(
bool bindingEnabled,
bool indoor,
bool hasSelectedCelestialDirectionalLight) =>
bindingEnabled && !indoor && hasSelectedCelestialDirectionalLight;
}