Round 4 (eec95535) fixed wind but introduced a new lighting bug: the
receiver VERTEX shaders source the sun direction from the shadow block,
not only the shadow visibility term. mesh_atmospheric.vert's
accumulateLights read uShadowLightDirectionAndSource unconditionally for
every directional light; terrain_atmospheric.vert did the same for its
single sun term. Round 4's PublishDisabledReceiverBinding writes
direction (0,0,1) into that block on every shadow-gated-off frame (user
sun-shadow-strength 0 in daylight, indoor/portal cover, night), so every
such frame was lighting outdoor terrain and objects from straight
overhead instead of the authored sun. Publishing the environment's real
direction would not have restored parity either -- the celestial shadow
source direction (sun/moon disc) is not the authored light direction.
F1 (BLOCKER): fixed in the shaders themselves, exact parity with the
plain pipeline. Both receiver verts now branch on the same flag bit
acdreamDirectionalShadowVisibility already reads
((uShadowTextureAndFlags.w & 1u) == 0u) and, when clear, use the EXACT
plain-pipeline expression instead of the shadow block's direction:
-uLights[i].dirAndRange.xyz in mesh_atmospheric.vert (matching
mesh_modern.vert, hoisted out of the light loop as a uniform branch);
-uLights[0].dirAndRange.xyz in terrain_atmospheric.vert (matching
terrain_modern.vert's sunDir/-sunDir form). The (0,0,1) word in the
disabled block stays as the documented normalize()-cannot-NaN guard; its
comment now says so explicitly since it is no longer read as a light
direction when the flag is clear.
F2: RenderPrepared's cascadeCount == 0 return is a third bufferless-
disabled path reachable from a frame that already passed Render's own
two gates (the cascade fitter can still find zero usable cascades) --
publishes the same disabled binding now, via the same
PublishDisabledReceiverBinding helper (re-signatured to take a bare
AtmosphericFrameBufferBinding so all three call sites -- Render's two
early-outs plus this one -- share it).
F3: removed a stray duplicated " -- Closeout and merge" fragment under
the plan's VM7 heading.
F4: corrected the false "the flag bit makes it numerically the plain
lighting sum" claim in the plan's round-4 paragraph and in
WbDrawDispatcher.DirectionalShadowReceivers.cs -- the flag bit alone
only fixed the shadow VISIBILITY term (already correct before round 4);
it took both that AND round 5's light-DIRECTION fallback to actually
match the plain pipeline.
T1: extracted Render's gate prologue (environment evaluate -> two
early-outs -> PublishDisabledReceiverBinding) into internal
EvaluateGateAndPublishDisabledBinding(frame, in input, out environment,
out environmentGateTicks), behaviour-preserving, called by Render before
it touches world/terrain -- the ArgumentNullException.ThrowIfNull(world)/
ThrowIfNull(terrain) calls keep their exact position relative to the
gate. No test in this suite constructs a real WbDrawDispatcher +
TerrainModernRenderer pair (still true), so this extraction is what
makes the gate itself testable; two new tests drive it directly with
PlayerInsideCell: true and with ResidentMaximumReachMeters <=
CameraNearMeters, asserting TryGetCurrentFrameBinding true / IsValidFor
false for both.
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.
T3: shader-source guard (same style as AtmosphericPostProcessGraphTests'
existing shader-text tests) pinning that both receiver verts contain the
flag-gated fallback and reference the same uLights expression the plain
verts use, so a future edit that drops the fallback fails this test
instead of only showing up in a pixel capture.
T4: the (0,0,1) test's doc comment and an inline assertion comment now
say the value is a NaN guard, not a light direction.
Regenerated SPIR-V: mesh_atmospheric.vert and terrain_atmospheric.vert
recompiled to different bytes this time (a real code change, not a
comment); manifest updated to match.
Verify: Release build 0 warnings/0 errors. App hermetic-lane filter
6,054/0 failed. Core.Tests 4,695/0 failed. RenderPackValidator 30/30.
Full hermetic-filtered solution: 15,282/0 failed across 15 projects.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
184 lines
8.1 KiB
C#
184 lines
8.1 KiB
C#
using AcDream.App.Rendering.Gpu;
|
|
|
|
namespace AcDream.App.Rendering.Wb;
|
|
|
|
public sealed partial class WbDrawDispatcher
|
|
{
|
|
internal sealed class DirectionalShadowReceiverPipelineState : IDisposable
|
|
{
|
|
private readonly MeshPipelineSet _backbuffer;
|
|
private readonly MeshPipelineSet _offscreen;
|
|
|
|
internal DirectionalShadowReceiverPipelineState(
|
|
IDirectionalShadowReceiverSource source,
|
|
MeshPipelineSet backbuffer,
|
|
MeshPipelineSet offscreen)
|
|
{
|
|
Source = source;
|
|
_backbuffer = backbuffer;
|
|
_offscreen = offscreen;
|
|
}
|
|
|
|
internal IDirectionalShadowReceiverSource Source { get; }
|
|
|
|
internal MeshPipelineSet ForSampleCount(int sampleCount) =>
|
|
sampleCount > 1 ? _backbuffer : _offscreen;
|
|
|
|
public void Dispose()
|
|
{
|
|
DisposeMeshPipelineSet(_backbuffer);
|
|
if (!ReferenceEquals(_offscreen, _backbuffer))
|
|
DisposeMeshPipelineSet(_offscreen);
|
|
}
|
|
}
|
|
|
|
private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver;
|
|
|
|
/// <summary>
|
|
/// Builds both sample-count variants without publishing either. A different
|
|
/// pack always gets pipelines compiled from that candidate's shader blobs;
|
|
/// no prior pack pipeline is reused by shader name or nullable caching.
|
|
/// </summary>
|
|
internal DirectionalShadowReceiverPipelineState? PrepareDirectionalShadowReceiver(
|
|
IDirectionalShadowReceiverSource? source,
|
|
int sampleCount)
|
|
{
|
|
if (source is null)
|
|
return null;
|
|
IGpuDevice device = _device
|
|
?? throw new InvalidOperationException("Directional receivers require the modern RHI device.");
|
|
if (_scope is null || sampleCount != _scope.SampleCount)
|
|
throw new InvalidOperationException("Receiver and world-pass sample counts must match.");
|
|
|
|
MeshPipelineSet? backbuffer = null;
|
|
MeshPipelineSet? offscreen = null;
|
|
try
|
|
{
|
|
backbuffer = CreateMeshPipelineSet(
|
|
device,
|
|
sampleCount,
|
|
baseShaders: source.PipelineShaders.WorldReceiver,
|
|
namePrefix: "wb-mesh-atmospheric",
|
|
usesRenderPackShaderAbi: true);
|
|
offscreen = sampleCount == 1
|
|
? backbuffer
|
|
: CreateMeshPipelineSet(
|
|
device,
|
|
1,
|
|
baseShaders: source.PipelineShaders.WorldReceiver,
|
|
namePrefix: "wb-mesh-atmospheric",
|
|
usesRenderPackShaderAbi: true);
|
|
return new DirectionalShadowReceiverPipelineState(source, backbuffer, offscreen);
|
|
}
|
|
catch
|
|
{
|
|
DisposeMeshPipelineSet(backbuffer);
|
|
if (!ReferenceEquals(offscreen, backbuffer))
|
|
DisposeMeshPipelineSet(offscreen);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Assignment-only publication; returned state retires after the coupled swap.</summary>
|
|
internal DirectionalShadowReceiverPipelineState? SwapDirectionalShadowReceiver(
|
|
DirectionalShadowReceiverPipelineState? candidate)
|
|
{
|
|
DirectionalShadowReceiverPipelineState? prior = _directionalShadowReceiver;
|
|
_directionalShadowReceiver = candidate;
|
|
return prior;
|
|
}
|
|
|
|
private MeshPipelineSet PipelinesFor(
|
|
IGpuPassEncoder encoder,
|
|
IGpuFrame frame,
|
|
out DirectionalShadowFrameBinding shadowBinding)
|
|
{
|
|
DirectionalShadowReceiverPipelineState? receiver = _directionalShadowReceiver;
|
|
IDirectionalShadowReceiverSource? source = receiver?.Source;
|
|
shadowBinding = DirectionalShadowFrameBinding.Disabled;
|
|
bool bindingValid = source is not null
|
|
&& source.TryGetCurrentFrameBinding(frame, out shadowBinding);
|
|
if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
|
|
encoder.Pass.Name,
|
|
source is not null,
|
|
bindingValid))
|
|
{
|
|
return receiver!.ForSampleCount(encoder.Pass.SampleCount);
|
|
}
|
|
return PipelinesFor(encoder);
|
|
}
|
|
|
|
// Campaign VM VM6 review fix round 2 (A3 test gap): internal, not
|
|
// private, so DirectionalShadowReceiverTests can drive it directly with
|
|
// a bare RecordingGpuDevice pass encoder instead of standing up the
|
|
// full WbDrawDispatcher/mesh-manager/world-pass-scope dependency chain
|
|
// just to prove this one bind call is correct.
|
|
internal static void BindDirectionalShadowReceiver(
|
|
IGpuPassEncoder encoder,
|
|
in DirectionalShadowFrameBinding binding)
|
|
{
|
|
// 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 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(
|
|
GpuBindingModel.UniformDirectionalShadow,
|
|
binding.Buffer,
|
|
binding.OffsetBytes,
|
|
binding.SizeBytes);
|
|
// Campaign VM VM6 review fix round (A3): mesh_atmospheric.vert reads
|
|
// uAtmosphereClockWind/uAtmosphereWindAmplitude from set 3/binding 5
|
|
// — bind it here explicitly with the EXACT buffer/offset the caster
|
|
// pass bound earlier this same frame (carried on
|
|
// DirectionalShadowFrameBinding.AtmosphericFrame), rather than
|
|
// depending on that earlier bind surviving un-reset until this pass
|
|
// runs. Unbound (the declared-pack / no-caster-this-frame case) is a
|
|
// no-op here exactly like the caster side.
|
|
//
|
|
// Review fix round 2 (F5): the buffer bound here is the CASTER's own
|
|
// independent allocation (AtmosphericPostProcessGraph
|
|
// .BuildShadowAtmosphericFrameBinding), NOT the receiver's/post-
|
|
// process's fuller frame block. Its seven ABI v1 members (sun
|
|
// screen/color, viewport, weather, sun direction, policy, inverse-
|
|
// view-projection) are ALL ZERO/Identity by construction — only the
|
|
// two appended v2 members (clock/wind, wind amplitude) carry real
|
|
// content. That is safe today because mesh_atmospheric.vert reads
|
|
// this binding SOLELY for foliage_wind.glsl's wind displacement; if
|
|
// a future change makes mesh_atmospheric.vert or .frag read any v1
|
|
// member (sun/weather/policy/etc.) during this same world-mesh
|
|
// pass, it will read zeros here and must get its own real binding
|
|
// instead of assuming this one is fully populated.
|
|
if (binding.AtmosphericFrame.IsBound)
|
|
{
|
|
encoder.BindUniformBuffer(
|
|
GpuBindingModel.UniformAtmosphericFrame,
|
|
binding.AtmosphericFrame.Buffer!,
|
|
binding.AtmosphericFrame.OffsetBytes,
|
|
binding.AtmosphericFrame.SizeBytes);
|
|
}
|
|
}
|
|
|
|
private void DisposeDirectionalShadowReceiverPipelines()
|
|
{
|
|
DirectionalShadowReceiverPipelineState? state =
|
|
SwapDirectionalShadowReceiver(null);
|
|
state?.Dispose();
|
|
}
|
|
}
|