acdream/src/AcDream.App/Rendering/TerrainModernRenderer.Rhi.cs
Erik 7df0b94c9f fix(render): S3 chunk 4 round 2 — draw-gate condition pinned, scissor and terrain-clip stacks deleted, one outside-view slice constructor
Round-1 three-lens FAIL at d60ca4ea0 found: the K2 fix was right in
production but nothing pinned the draw side (restoring the pre-fix gate
left every lane green); a stale "still ends an active scissor" comment
asserted a mechanism the same round had already deleted; the sky.vert
comment claimed retail clips the sky when it draws unclipped; the K6
pin ran through ClipFrameAssembler.Assemble, which has zero production
callers, so it proved nothing about the producer that actually runs.
This round closes L1-L9.

L1 (BLOCKING) — draw-side weather-gate pin. Added
RetailPViewPassExecutorTests.DrawLandscapeDynamicsPhase_GatesDrawWeatherOnceOnWalkDriverWeatherTurnFired:
reads DrawLandscapeDynamicsPhase's compiled call graph and asserts (a)
the call immediately before DrawWeatherOnce is
WalkFrameDriver.get_WeatherTurnFired and (b) exactly one
brfalse/brfalse.s branch sits between that call and the draw, jumping
forward past it. Three mutations, each shown failing:

  M1 (restore `if (clipAssembly.OutsideViewSlices.Length != 0)`):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.ClipFrameAssembly)

  M2 (drop the gate, unconditional call):
    Assert.Equal() Failure: Values differ
    Expected: typeof(AcDream.App.Rendering.Walk.WalkFrameDriver)
    Actual:   typeof(AcDream.App.Rendering.RetailPViewPassExecutor)

  M3 (invert to `if (!walkDriver.WeatherTurnFired)`):
    Assert.Single() Failure: The collection did not contain any matching items
    Expected:   (predicate expression)
    Collection: [CompiledBranch { Offset = 7, OpCode = brfalse.s, TargetOffset = 17 }, CompiledBranch { Offset = 24, OpCode = brtrue.s, TargetOffset = 33 }]

Corrected WalkFrameDriverTranscriptTests.cs's doc comment (the
Collect_WeatherTurnFiredMatchesThePrintedOcLineExactly block): it pins
the print half and the flag only, and now names the real draw-side pin
instead of falsely claiming to double as one.

L2 (MAJOR) — scissor stack deleted, K4 comment corrected. Deleted
IWorldPassSurface.BeginScissor/EndScissor and their RhiWorldPassSurface
bodies, the scissor call inside ClearInteriorDepth, NdcScissorRect.cs +
NdcScissorRectTests.cs (BeginScissor had zero remaining callers, and
RhiWorldPassSurface.BeginScissor was NdcScissorRect.ToPixels' only
production caller). This left RhiWorldPassSurface's 4th constructor
parameter (IRetailPViewFramebufferSource) and RetailPViewPassExecutor.cs's
RetailPViewFramebufferSize/IRetailPViewFramebufferSource/
SilkRetailPViewFramebufferSource types entirely dead (framebuffer size
was needed only for the scissor's NDC-to-pixel conversion) — removed
them and their one call site in FrameRootComposition.cs. Rewrote
RetailPViewPassExecutor.cs's K4 comment: it no longer asserts a live
scissor mechanism; it states that VulkanGpuPassEncoder's constructor
sets the full-attachment scissor once, at pass begin
(VulkanGpuPassEncoder.cs:87), and nothing narrows it after that. KEPT
per the round-1 verdict: ScissorFallbacks, OutsidePlaneCount,
OutsideViewNdcAabb, HasOutsideView, OutdoorVisible, OutdoorSlot,
ClipViewSlice.NdcAabb (the >8-edge zero-plane slice case the punch fans
still consume) and the VulkanViewportMapping.ScissorToVulkan pass-begin
path (IGpuPassEncoder.SetScissor stays). Also deleted
ClipFrameAssembly.TerrainMode/TerrainScissorNdcAabb and their writers
in ClipFrameAssembler.cs (both Assemble and ReassembleOutsideViewFromWalk);
OutsidePlaneCount's formula reduces to `outsideHasScissorFallback ? 0 :
outsideMaxPlaneCount` without needing the deleted TerrainClipMode
comparison (a scissor-fallback slice can only exist when outdoorVisible
is already true, so the three original cases collapse identically).
Deleted the two "terrain=" / "outMode=" diagnostic reads in
WorldRenderDiagnostics.cs (:345, :409) and the now-vestigial
EmitClipRouteProbe "ubo: n=..." segment that read ClipFrame's deleted
terrain bytes.

DEVIATION from L2's literal text: kept the TerrainClipMode enum type
itself — WorldSceneRenderer.cs's flat-world safety path (explicitly
"out of this chunk's scope" per K4's own round-1 comment) still uses it
for an unrelated "did the flat terrain draw" diagnostic flag, with its
own WorldSceneDiagnosticsController/WorldRenderDiagnostics/test
consumers. Deleting the type would require rewriting files outside
every round's declared file list. Reworded its doc comment to state
this plainly.

L3 (MAJOR, completes K3) — dead TerrainClip UBO deleted from the sky
and terrain shaders. Deleted the block, both gl_ClipDistance loops, and
the gl_PerVertex redeclaration (nothing else needs it) from sky.vert,
terrain_modern.vert, terrain_atmospheric.vert. Recompiled via
tools/compile-shaders.ps1 (glslc via the Vulkan SDK, managed shaderc
fallback also runs) — 24/24 pairs compiled; only sky.vert.spv,
terrain_atmospheric.vert.spv and terrain_modern.vert.spv changed.
Re-pinned VulkanShaderManifestTests.cs's frozen retail-oracle hashes
for sky.vert.spv (7d67a9e3624d198b370d402b5c12e4ce925bf9b8e646ef5123636a86d5985ab5)
and terrain_modern.vert.spv (8a73d89ef0e51e550327b9ff8c24857e309103b1d491030cf0d4d8594b45068c)
with dated comments, matching the existing re-pin convention.

Deleted WorldFrameSectionBinding.BindTerrainClip and
WorldFrameSections.TerrainClip (+ its Reset) with its two callers
(SkyRenderer.Rhi.cs:258, TerrainModernRenderer.Rhi.cs:259). DEVIATION
from L3's literal text: kept the shared Zeroed(...) helper in
WorldPassScope.cs — it is also called by BindSceneLighting and
BindClipRegions, both of which stay; deleting it would have broken
those two live bindings. Deleted IWorldPassSurface.BindTerrainClip
(no-op interface method + RhiWorldPassSurface body) and
WorldScenePassExecutor.cs's two calls to it (:121, :235).

Deleted ClipFrame's _terrainBytes field, TerrainBytes/TerrainBytesForTest
properties, Reset's Array.Clear(_terrainBytes), and the stale
header/K3 comment paragraphs. DEVIATION from L3's literal text: kept
ClipFrame.TerrainUboBytes and ClipFrame.TerrainClipUboBinding —
PortalDepthMaskRenderer.Rhi.cs (:140, :187) is a live production
consumer of both constants for the KEPT exit-seal/punch-fan clip block,
which was never in scope for deletion (portal_depth.vert's own
TerrainClip UBO declaration is the KEEP block, untouched). Reworded
both constants' doc comments (ClipFrame.cs, VulkanPipelineLayouts.cs's
UniformTerrainClip) to say only the portal-depth clip block uses
binding 2 now, and corrected the two GpuBindingModel.cs comments that
explained why terrain-tiling/sky-params bindings are 3/4 by naming "the
terrain clip block". Updated VulkanShaderDescriptorContractTests.cs's
TerrainVertexShaderDeclaresItsClipBlockInTheUniformSet (renamed
TerrainVertexShaderDeclaresOnlySceneLightingInTheUniformSet):
terrain_modern.vert's uniform set is now {SceneLighting} only. Deleted
ClipFrameLayoutTests.NoClip_TerrainBytes_Count0_AllZeros and its K3
comment; corrected the class doc comment and LayoutConstants_MatchShaderStruct's
"terrain UBO"/binding-contract comments to describe the portal-depth
consumer instead.

REQUIRED L3 fact (declared-but-never-bound dynamic uniform binding):
VulkanFrameBindings's constructor seeds EVERY declared uniform binding
(0..UniformBindingCount-1, including binding 2) with the shared dummy
buffer's range before any renderer runs (VulkanFrameBindings.cs:128-130,
`_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange)`
in a loop over every binding) — "Every binding is always bound, whether
a renderer uses it or not... unused ones point at a shared dummy range"
(VulkanFrameBindings.cs:27-31). So after this round, when the sky or
terrain pipeline draws, set 1's shared descriptor layout still declares
binding 2 (portal_depth.vert's own declaration keeps
IsDeclaredUniformBinding(2) true), and its descriptor still points at a
valid (dummy) range from that seed — vkCmdBindDescriptorSets stays
legal even though neither shader statically uses binding 2 any more.
No SPIR-V-side change was needed to keep this legal.

L4 (MAJOR) — one outside-view slice constructor. Extracted
ClipFrameAssembler.AppendOutsideSlice (frame, ViewPolygon,
outsideSlicesList, ref maxPlaneCount, ref hasScissorFallback, ref
scissorFallbacks) — the exact ClipPlaneSet.From/AppendSlot/
ClipViewSlice-construction body — and call it from both Assemble's
outside_view loop and ReassembleOutsideViewFromWalk's outside_view
loop. Rewrote ClipFrameLayoutTests's K6 pin
(ClipViewSlicePlanes_PunchFanPath_EqualsCpuViewPolygonEdgePlanes_ForASyntheticView):
builds a synthetic WalkPortalView from pixel-space points (the
WalkCopyViewTests pattern) via a trivial IWalkRayCaster, runs
BeginWalkFrame(frame, outdoorRoot: false) then
ReassembleOutsideViewFromWalk(assembly, walkView, 640, 480), and reads
assembly.OutsideViewSlices[0].Planes — the exact production pair
RetailPViewPassExecutor.cs's BeginWalkFrame call and
RetailPViewRenderer.cs's ReassembleOutsideViewFromWalk call make.
Mutation (perturbed planes[0].W inside the shared helper, right after
`planes = cps.PlaneArray;`):

  edge 0 midpoint (-0.3,0.10000001) should lie ~on its OWN GPU plane; the closest plane was only 0.5251073 away

The pin's own source contains no `Assemble(` call — grep-checked by
extracting the method body and searching it for the literal text; no
match.

L5 — shader comments corrected. sky.vert/terrain_modern.vert/
terrain_atmospheric.vert now state retail draws the sky once
(LScape::draw @0x00506330 -> GameSky::Draw(sky,0) @0x0050633c, before
draw_check_blocks) and the landscape is view-culled per cell, never
GPU-clipped (RenderDeviceD3D::DrawBlock @0x005a17c0). The "Phase W
Stage 4"/"U.3 default" clip narratives are deleted along with the
blocks they described.

L6 — grep sweep. `grep -rn "<token>" src tests docs/architecture`
returns nothing for SetTerrainClip, BeginDoorwayScissor,
BindTerrainClip, TerrainBytes, "active scissor", and NdcScissorRect —
confirmed after this commit (docs/plans and docs/research keep the
historical record, untouched). TerrainClipMode is the one deliberate
exception (see the L2 deviation note above); every one of its 9
remaining hits is a live, non-stale reference (the enum declaration,
WorldSceneRenderer's flat-path local, or their diagnostic/test
plumbing), not a stale mention of a deleted mechanism.

L7 — Issue130DoorwayStripTests. Deleted AnySliceAdmitsScissor,
worstScissorGapPx and its PIN 1 assertion, the header's scissor
sentences, and the scissorGap half of MeasureTopEdgeGap (dropped the
fbW parameter it alone needed). Rewrote the header: the scissor
mechanism is retired (the sky and landscape draw unclipped; aperture
exactness comes from the depth clear, the exit seals and the interior
repaint). The remaining plane-gap half (the canary PIN,
`worstPlaneGapPx <= 1.2f`) still pins something production reads:
AnySliceAdmitsPlanes walks slice.Planes from the SAME ProjectToClip ->
ClipToRegion -> ClipPlaneSet.From pipeline
RetailPViewPassExecutor.DrawWalkPunchFan reads through
clipAssembly.OutsideViewSlices[activeViewIndex].Planes — so the test
was kept, not deleted.

L8 — DrawWalkSky loop-shape pin. Added
DrawWalkSky_RenderSkyCallSiteHasNoEnclosingBackwardBranch (same
backward-branch-span shape as K1's DrawWeatherOnce pin), plus a note in
both pins that CompiledCallGraph.ReadBranches does not decode a
compiled switch jump table, but no C# loop construct compiles to one.
Mutation (wrapped the RenderSky call in `for (int i = 0; i < 2; i++)`):

  Assert.DoesNotContain() Failure: Filter matched in collection
  Collection: [..., CompiledBranch { Offset = 20, OpCode = brtrue.s, TargetOffset = 25 }, CompiledBranch { Offset = 23, OpCode = br.s, TargetOffset = 66 }, CompiledBranch { Offset = 72, OpCode = blt.s, TargetOffset = 13 }, CompiledBranch { Offset = 80, OpCode = brfalse.s, TargetOffset = 116 }, CompiledBranch { Offset = 88, OpCode = brfalse.s, TargetOffset = 116 }]

(the Offset=72 -> TargetOffset=13 entry is the injected loop's backward
branch spanning the call).

L9 — this message.

Gates: dotnet build 0 warnings / 0 errors. Hermetic filter
(Lane!=InstalledDat&...&Status!=KnownFailure): 6827 passed, 0 failed.
InstalledDat lane against a real DAT directory: 244 passed, 4 failed —
exactly the four known failures (LayoutImporterMediaBearingChildSweepTests
+ LayoutImporterInvisibleSweepTests, both #383; TowerAscentReplayTests,
KnownFailure; WalkTraceConformanceTests.Oh_doorway_still_first_frame_diff,
#458 KnownFailure). No register row added (every deletion here removes
an acdream-only rule; the KEEP items already had their equivalence
pins from round 1). No new flag/probe.

OWED: the lead's ACDREAM_DEVTOOLS=1 four-pose visual self-gate against
the running client is not run by this agent — CLAUDE.md and this
task's own instructions forbid launching the graphical client from
here; that visual/validation-layer pass remains the user's to run
before this round is accepted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:18:27 +02:00

370 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 25 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,
// WorldDepthContract.WorldCompare (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. See WorldDepthContract for the
// full citation.
Depth = new GpuDepthState(Test: true, Write: true, WorldDepthContract.WorldCompare),
// #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);
// 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;
}
}