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>
369 lines
16 KiB
C#
369 lines
16 KiB
C#
using System.Collections.Immutable;
|
||
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Terrain;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6j: terrain's RHI submission arm.
|
||
///
|
||
/// <para>This is V4d-2's content, re-landed as a SECOND arm rather than as a
|
||
/// replacement. §5.5.6 selected option (B) after NVIDIA rendered the V4c binary
|
||
/// 10/10 and AMD's GL stack did not: GL keeps its raw world path through to V10
|
||
/// as a documented, scoped fork confined to the submission seam, and the RHI
|
||
/// world path ships on Vulkan. So every GL statement in the sibling file is
|
||
/// untouched, and everything here runs only when there is no GL context.</para>
|
||
///
|
||
/// <para>Three things differ from V4d-2, each because the tree moved under it.
|
||
/// The texture slots come from <c>TerrainAtlas</c>'s device table (V4t) rather
|
||
/// than a per-renderer bindless table, so there is no binding-9 table to bind at
|
||
/// all — the Vulkan texture table is set 2 and the encoder binds it. The tiling
|
||
/// block is the shared <c>TerrainTextureTilingTable</c> constants (V6f-2) rather
|
||
/// than locals. And the pass is BORROWED from <see cref="IWorldPassScope"/>
|
||
/// rather than opened, because the frame's one backbuffer pass resolves and a
|
||
/// second pass could not load what it left.</para>
|
||
/// </summary>
|
||
public sealed unsafe partial class TerrainModernRenderer
|
||
{
|
||
/// <summary>
|
||
/// Terrain's vertex layout: the same 40-byte record <c>ConfigureVao</c>
|
||
/// describes with <c>glVertexAttribPointer</c>/<c>glVertexAttribIPointer</c>.
|
||
///
|
||
/// <para>Locations 2–5 are <see cref="GpuVertexFormat.UByte4UInt"/>, not
|
||
/// <c>UByte4Normalized</c>. They are <c>uvec4</c> in the shader and carry
|
||
/// terrain-type, road and split-direction codes; normalising them would not
|
||
/// be an approximation, it would be garbage.</para>
|
||
/// </summary>
|
||
internal static readonly GpuVertexLayout TerrainVertexLayout = GpuVertexLayout.Interleaved(
|
||
strideBytes: VertexSize,
|
||
ImmutableArray.Create(
|
||
new GpuVertexAttribute(0, GpuVertexFormat.Float3, 0),
|
||
new GpuVertexAttribute(1, GpuVertexFormat.Float3, 12),
|
||
new GpuVertexAttribute(2, GpuVertexFormat.UByte4UInt, 24),
|
||
new GpuVertexAttribute(3, GpuVertexFormat.UByte4UInt, 28),
|
||
new GpuVertexAttribute(4, GpuVertexFormat.UByte4UInt, 32),
|
||
new GpuVertexAttribute(5, GpuVertexFormat.UByte4UInt, 36)));
|
||
|
||
private readonly IGpuDevice? _device;
|
||
private readonly ICurrentGpuFrameSource? _frames;
|
||
private readonly IWorldPassScope? _scope;
|
||
private IGpuPipeline? _pipeline;
|
||
private DirectionalShadowReceiverPipelineState? _directionalShadowReceiver;
|
||
private IGpuBuffer? _vertexStore;
|
||
private IGpuBuffer? _indexStore;
|
||
private IGpuBuffer? _tilingBuffer;
|
||
|
||
/// <summary>
|
||
/// The RHI arm's constructor. No GL context, no <c>Shader</c>, no
|
||
/// <c>BindlessSupport</c>: the pipeline compiles <c>terrain_modern</c> from
|
||
/// the committed SPIR-V and the atlas's slots index the device's one table.
|
||
/// </summary>
|
||
internal TerrainModernRenderer(
|
||
IGpuDevice device,
|
||
ICurrentGpuFrameSource frames,
|
||
IWorldPassScope scope,
|
||
TerrainAtlas atlas,
|
||
IGpuResourceRetirementQueue resourceRetirement,
|
||
int initialSlotCapacity = 64)
|
||
{
|
||
_device = device ?? throw new ArgumentNullException(nameof(device));
|
||
_frames = frames ?? throw new ArgumentNullException(nameof(frames));
|
||
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
||
_atlas = atlas ?? throw new ArgumentNullException(nameof(atlas));
|
||
ArgumentNullException.ThrowIfNull(resourceRetirement);
|
||
_retirementLedger = new GpuRetirementLedger(resourceRetirement);
|
||
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
|
||
_slots = new SlotData?[initialSlotCapacity];
|
||
|
||
_pipeline = device.CreatePipeline(new GpuPipelineDescription
|
||
{
|
||
Name = "terrain",
|
||
Shaders = new GpuShaderSet("terrain_modern"),
|
||
VertexLayout = TerrainVertexLayout,
|
||
Topology = GpuPrimitiveTopology.TriangleList,
|
||
Blend = GpuBlendMode.None,
|
||
// GL_LESS, not the contract's LessOrEqual default: the world frame
|
||
// runs under GL_LESS and terrain never called glDepthFunc, so it
|
||
// inherited it. LessOrEqual would change which of two coplanar retail
|
||
// surfaces wins — visible exactly where terrain meets roads and
|
||
// building footings, which is what zFightTerrainAdjust is about.
|
||
Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less),
|
||
// #108-residual: retail terrain is SINGLE-SIDED. See the GL arm's
|
||
// Draw for the full reasoning; this bakes the same triple.
|
||
Cull = GpuCullMode.Back,
|
||
FrontFace = GpuFrontFace.CounterClockwise,
|
||
AlphaToCoverage = false,
|
||
ColorWrite = true,
|
||
SampleCount = scope.SampleCount,
|
||
});
|
||
AllocateRhiBuffers(initialSlotCapacity);
|
||
}
|
||
|
||
private void AllocateRhiBuffers(int capacitySlots)
|
||
{
|
||
long vertexBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
|
||
long indexBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
|
||
IGpuDevice device = RequireDevice();
|
||
_vertexStore = device.CreateBuffer(new GpuBufferDescription(
|
||
"terrain-vertices",
|
||
vertexBytes,
|
||
GpuBufferUsage.Vertex
|
||
| GpuBufferUsage.TransferSource
|
||
| GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
_globalVboCapacityBytes = vertexBytes;
|
||
_indexStore = device.CreateBuffer(new GpuBufferDescription(
|
||
"terrain-indices",
|
||
indexBytes,
|
||
GpuBufferUsage.Index
|
||
| GpuBufferUsage.TransferSource
|
||
| GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
_globalEboCapacityBytes = indexBytes;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Grow-and-copy, device-side. <see cref="IGpuBuffer.CopyTo"/> keeps resident
|
||
/// landblock meshes from round-tripping through system memory, exactly as the
|
||
/// GL arm's <c>glCopyBufferSubData</c> does.
|
||
/// </summary>
|
||
private void EnsureRhiCapacity(int newCapacitySlots)
|
||
{
|
||
if (newCapacitySlots <= _alloc.Capacity)
|
||
return;
|
||
|
||
long vertexBytes = checked((long)newCapacitySlots * VertsPerLandblock * VertexSize);
|
||
long indexBytes = checked((long)newCapacitySlots * IndicesPerLandblock * IndexSize);
|
||
IGpuDevice device = RequireDevice();
|
||
IGpuBuffer oldVertices = RequireVertexStore();
|
||
IGpuBuffer oldIndices = RequireIndexStore();
|
||
|
||
IGpuBuffer newVertices = device.CreateBuffer(new GpuBufferDescription(
|
||
"terrain-vertices",
|
||
vertexBytes,
|
||
GpuBufferUsage.Vertex
|
||
| GpuBufferUsage.TransferSource
|
||
| GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
IGpuBuffer newIndices;
|
||
try
|
||
{
|
||
newIndices = device.CreateBuffer(new GpuBufferDescription(
|
||
"terrain-indices",
|
||
indexBytes,
|
||
GpuBufferUsage.Index
|
||
| GpuBufferUsage.TransferSource
|
||
| GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
}
|
||
catch
|
||
{
|
||
newVertices.Dispose();
|
||
throw;
|
||
}
|
||
|
||
oldVertices.CopyTo(newVertices, 0, 0, _globalVboCapacityBytes);
|
||
oldIndices.CopyTo(newIndices, 0, 0, _globalEboCapacityBytes);
|
||
|
||
_vertexStore = newVertices;
|
||
_indexStore = newIndices;
|
||
_globalVboCapacityBytes = vertexBytes;
|
||
_globalEboCapacityBytes = indexBytes;
|
||
|
||
// Dispose routes the physical free through the device's retirement queue,
|
||
// so the old arena outlives every frame that can still reference it.
|
||
oldVertices.Dispose();
|
||
oldIndices.Dispose();
|
||
|
||
var grownSlots = new SlotData?[newCapacitySlots];
|
||
Array.Copy(_slots, grownSlots, _slots.Length);
|
||
_slots = grownSlots;
|
||
_alloc.GrowTo(newCapacitySlots);
|
||
}
|
||
|
||
private void UploadRhiLandblock(
|
||
int slot,
|
||
TerrainVertex[] bakedVerts,
|
||
uint[] bakedIndices)
|
||
{
|
||
RequireVertexStore().Upload(
|
||
(long)slot * VertsPerLandblock * VertexSize,
|
||
MemoryMarshal.AsBytes<TerrainVertex>(bakedVerts));
|
||
RequireIndexStore().Upload(
|
||
(long)slot * IndicesPerLandblock * IndexSize,
|
||
MemoryMarshal.AsBytes<uint>(bakedIndices));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Records terrain's multi-draw into the borrowed world pass.
|
||
///
|
||
/// <para>Order matters twice. <c>BindPipeline</c> re-issues the pipeline's own
|
||
/// cull/front-face/depth-write defaults, so anything dynamic has to come
|
||
/// after it. And the frame-global sections — SceneLighting and the terrain
|
||
/// clip block — are bound HERE, after this renderer's own binds, because its
|
||
/// own binds are what select the descriptor scope those sections must land in
|
||
/// (plan §5.5.14 item 2).</para>
|
||
/// </summary>
|
||
private void DrawRhi(Matrix4x4 viewProjection, int drawCount)
|
||
{
|
||
IWorldPassScope scope = _scope!;
|
||
IGpuPassEncoder encoder = scope.RequireEncoder();
|
||
IGpuFrame frame = _frames!.CurrentFrame
|
||
?? throw new InvalidOperationException(
|
||
"TerrainModernRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
|
||
|
||
// V6i-2's backend-neutral atlas registers both slots at construction, so
|
||
// there is no per-draw acquire-and-reregister step and no binding-9 table
|
||
// to flush — the Vulkan texture table is set 2 and the encoder binds it.
|
||
(GpuTextureSlot terrainSlot, GpuTextureSlot alphaSlot) = _atlas.TextureSlots;
|
||
|
||
var pushConstants = new GpuPushConstants
|
||
{
|
||
ViewProjection = viewProjection,
|
||
DrawIdOffset = 0,
|
||
LightingMode = 0,
|
||
RenderPass = 0,
|
||
LightDebug = 0,
|
||
TextureIndexA = terrainSlot.Index,
|
||
TextureIndexB = alphaSlot.Index,
|
||
ParamA = 0f,
|
||
ParamB = 0f,
|
||
};
|
||
|
||
IGpuPipeline pipeline = _pipeline!;
|
||
DirectionalShadowFrameBinding shadowBinding = default;
|
||
DirectionalShadowReceiverPipelineState? receiver =
|
||
_directionalShadowReceiver;
|
||
IDirectionalShadowReceiverSource? receiverSource = receiver?.Source;
|
||
bool bindingValid = receiverSource is not null
|
||
&& receiverSource.TryGetCurrentFrameBinding(frame, out shadowBinding);
|
||
if (DirectionalShadowReceiverPolicy.ShouldSelectReceiverPipeline(
|
||
encoder.Pass.Name,
|
||
receiverSource is not null,
|
||
bindingValid))
|
||
{
|
||
pipeline = receiver!.Pipeline;
|
||
}
|
||
|
||
encoder.BindPipeline(pipeline);
|
||
encoder.SetPushConstants(in pushConstants);
|
||
encoder.BindVertexBuffer(0, RequireVertexStore(), 0);
|
||
encoder.BindIndexBuffer(RequireIndexStore(), 0, GpuIndexType.UInt32);
|
||
BindTilingTable(encoder);
|
||
WorldFrameSectionBinding.BindSceneLighting(encoder, scope.Sections, frame);
|
||
WorldFrameSectionBinding.BindTerrainClip(encoder, scope.Sections, frame);
|
||
// 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,
|
||
shadowBinding.Buffer,
|
||
shadowBinding.OffsetBytes,
|
||
shadowBinding.SizeBytes);
|
||
}
|
||
|
||
GpuRingAllocation commands = frame.AllocateRing(
|
||
drawCount * sizeof(DrawElementsIndirectCommand),
|
||
GpuRingUsage.Indirect);
|
||
MemoryMarshal.AsBytes(_deicScratch.AsSpan(0, drawCount))
|
||
.CopyTo(commands.Data);
|
||
encoder.MultiDrawIndexedIndirect(
|
||
commands.Buffer,
|
||
commands.OffsetBytes,
|
||
(uint)drawCount,
|
||
(uint)sizeof(DrawElementsIndirectCommand));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Binds the immutable 36-entry tiling table. Long-lived and written once, so
|
||
/// its range never moves — which also keeps it out of the descriptor-scope
|
||
/// key's moving parts.
|
||
/// </summary>
|
||
private void BindTilingTable(IGpuPassEncoder encoder)
|
||
{
|
||
if (_tilingBuffer is null)
|
||
{
|
||
if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
|
||
$"expected {TerrainTextureTilingTable.LayerCapacity}.");
|
||
}
|
||
|
||
Span<byte> block = stackalloc byte[TerrainTextureTilingTable.UniformBufferBytes];
|
||
block.Clear();
|
||
for (int i = 0; i < TerrainTextureTilingTable.LayerCapacity; i++)
|
||
{
|
||
BitConverter.TryWriteBytes(
|
||
block[(i * TerrainTextureTilingTable.UniformElementStrideBytes)..],
|
||
_atlas.TilingByLayer[i]);
|
||
}
|
||
|
||
IGpuBuffer buffer = RequireDevice().CreateBuffer(new GpuBufferDescription(
|
||
"terrain-tiling",
|
||
TerrainTextureTilingTable.UniformBufferBytes,
|
||
GpuBufferUsage.Uniform | GpuBufferUsage.TransferDestination,
|
||
GpuMemoryResidency.DeviceLocal));
|
||
try
|
||
{
|
||
buffer.Upload(0, block);
|
||
}
|
||
catch
|
||
{
|
||
buffer.Dispose();
|
||
throw;
|
||
}
|
||
_tilingBuffer = buffer;
|
||
}
|
||
|
||
encoder.BindUniformBuffer(
|
||
GpuBindingModel.UniformTerrainTiling,
|
||
_tilingBuffer,
|
||
0,
|
||
TerrainTextureTilingTable.UniformBufferBytes);
|
||
}
|
||
|
||
private IGpuDevice RequireDevice() =>
|
||
_device ?? throw new InvalidOperationException(
|
||
"TerrainModernRenderer's RHI arm was reached without an IGpuDevice.");
|
||
|
||
private IGpuBuffer RequireVertexStore() =>
|
||
_vertexStore ?? throw new InvalidOperationException(
|
||
"The terrain vertex arena has not been created.");
|
||
|
||
private IGpuBuffer RequireIndexStore() =>
|
||
_indexStore ?? throw new InvalidOperationException(
|
||
"The terrain index arena has not been created.");
|
||
|
||
private void DisposeRhi()
|
||
{
|
||
_directionalShadowReceiver?.Dispose();
|
||
_directionalShadowReceiver = null;
|
||
_pipeline?.Dispose();
|
||
_pipeline = null;
|
||
_tilingBuffer?.Dispose();
|
||
_tilingBuffer = null;
|
||
_vertexStore?.Dispose();
|
||
_vertexStore = null;
|
||
_indexStore?.Dispose();
|
||
_indexStore = null;
|
||
_globalVboCapacityBytes = 0;
|
||
_globalEboCapacityBytes = 0;
|
||
_dynamicFrameStarted = false;
|
||
_disposed = true;
|
||
}
|
||
}
|