feat(render): Campaign V slice V4d-2 - move terrain onto the RHI

TerrainModernRenderer records through IGpuPassEncoder instead of calling GL
directly. V4d-1 already converged its two matrix uniforms; this is the plumbing.

What moved. The per-frame indirect command array became an
IGpuFrame.AllocateRing slice, which retires the three-deep per-frame-slot
indirect buffer pool outright. That pool existed so a second terrain draw within
one frame - a retail outside view can issue several - could not overwrite an
earlier draw's still-pending commands; the frame ring gives that structurally,
because every allocation within a frame is distinct memory that lives until the
frame retires. DynamicIndirectBufferCount now reports 0, which is the truth
rather than a silent change.

The vertex and index arena became an IGpuBuffer pair. AddLandblock's two
BufferSubData calls are Upload, and EnsureCapacity's grow-and-copy is
IGpuBuffer.CopyTo, still device-side so resident landblock meshes never
round-trip through system memory. The global VAO is gone: the pipeline owns one
shaped by the vertex layout, and the encoder re-issues attribute pointers on
every BindVertexBuffer.

Locations 2-5 use GpuVertexFormat.UByte4UInt, added at c7f5f251 for exactly
this. They are uvec4 in the shader and carry terrain-type, road and
split-direction codes; UByte4Normalized would have delivered [0,1] floats to an
integer input, which GL leaves undefined - garbage, not an approximation.

uTextureIndexA/uTextureIndexB became GpuPushConstants.TextureIndexA/B. Slice V2b
named those uniforms to match the pinned block, so this was the rename it was
meant to be. uTexTiling moved from a loose uniform float[36] into a std140 block
at GpuBindingModel.UniformTerrainTiling: at 144 bytes of payload it cannot ride
in the 96-byte push-constant block, and no RHI verb sets a uniform array. std140
pads each element to 16 bytes so the block is 576, but the element type is
unchanged, so uTexTiling[int(layer)] reads exactly as before. It is a long-lived
uniform buffer uploaded on the first draw, preserving the upload-once property
the linked-program uniform had.

The imperative Enable(CullFace)/CullFace(Back)/FrontFace(Ccw) triple and the
inherited depth state are baked into one pipeline. Depth compare is GL_LESS, not
the contract's LessOrEqual default: the world frame runs under GL_LESS
(RenderFrameGlStateController.RestoreFrameDefaults) and terrain never called
glDepthFunc, so it inherited it. Baking LessOrEqual would change which of two
coplanar retail surfaces wins - visible exactly where terrain meets roads and
building footings, which is what the shader's zFightTerrainAdjust nudge is
about. Blend off, alpha-to-coverage off, colour write on and depth write on come
from the same frame default, each checked against what terrain observes rather
than assumed. GL_MULTISAMPLE is untouched by pipeline binds, so MSAA does not
leak away from the still-raw-GL sky and particles.

Deliberately unmoved. The terrain clip UBO at binding 2 and the SceneLighting
UBO at binding 1 stay raw global binds - ClipFrame owns one and the viewport and
portal renderers read the other, and both are raw GL until V4h (campaign doc
5.3). The interim GlBindlessHandleTable stays, now held as an IGpuBuffer and
bound through the encoder at binding 9; retiring it is V4t. glMemoryBarrier
stays a raw call: it has no RHI verb and was already a no-op against
client-side uploads. The trailing FrontFace(CW)/Disable(CullFace) restore stays
so sky and particles see what they see today. TerrainAtlas is untouched - it
belongs to V4t. Terrain has no GPU timer to port; its diagnostics use a CPU
stopwatch.

Three consequences worth naming rather than leaving to be discovered.

The convenience constructor narrowed from public to internal, because IGpuDevice
and ICurrentGpuFrameSource are internal RHI types and a public constructor
cannot name them. The class stays public, no other member changed visibility,
and every caller was already in this assembly - EnvCellRenderer's constructor is
internal for the same reason. That is the only visibility change in the diff.

Terrain no longer needs a Shader composed for it, since its pipeline compiles
terrain_modern from the same sources with the same shared preamble. That removes
the terrain-shader composition step, its publication, its lifetime field and the
WorldRenderCompositionPoint member. Two data-driven test cases went with it: one
InlineData row naming "terrain shader" as a publication to fail, and one case
from the theory that enumerates every composition point. App tests therefore
read 3,844 rather than the 3,846 baseline. No invariant lost coverage - both
theories still exercise every remaining resource and point; the two cases were
parameterisations over a step that no longer exists.

The renderer's own GpuRetirementLedger is gone. Every resource it held retryable
releases for is an IGpuBuffer or IGpuPipeline now, and their Dispose already
routes the physical free through the device's retirement queue. Only the
fallback clip UBO is still a raw GL name, so it is all the dispose ledger
carries. The slot allocator's separate retryable publication path is untouched.
Also dropped: a dead BindlessSupport field, assigned and never read.

Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed /
3 skipped over four consecutive runs. Offline pixel gate against 0cb10597: 20
differing pixels of 563,200 (fraction 3.55e-05, 28x under the threshold),
against a same-commit control at this commit of 26 - the change differs from its
parent by LESS than the capture differs from itself, which is as close to proof
of no systematic shift as this gate can give. Compared against all three V4d-1
captures the numbers are 20, 32 and 34, against a same-commit V4d-1 spread of 8,
27 and 28: the same distribution. The gate run's client log has zero exceptions
and an empty stderr.

Coverage gap, stated rather than assumed: the offline gate's scene is a fixed
outdoor view. It exercises terrain heavily - terrain blending, road overlays and
the water edge are most of the frame - but it does not cover terrain seen
through a doorway clip region, which is the one terrain path with its own
binding (the clip UBO at binding 2). That wants a user visual check.

No divergence-register row: this slice changes no retail-facing behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 21:31:53 +02:00
parent 0b12bc5799
commit b064668b63
7 changed files with 493 additions and 550 deletions

View file

@ -32,7 +32,6 @@ internal sealed record WorldRenderFoundation(
string ShadersDirectory,
BindlessSupport Bindless,
TerrainAtlas TerrainAtlas,
Shader TerrainShader,
SceneLightingUboBinding SceneLighting,
DebugLineRenderer DebugLines,
BitmapFont? DebugFont,
@ -62,7 +61,6 @@ internal sealed record WorldRenderDependencies(
internal interface IGameWindowWorldRenderPublication
{
void PublishBindlessSupport(BindlessSupport value);
void PublishTerrainShader(Shader value);
void PublishSceneLighting(SceneLightingUboBinding value);
void PublishDebugLines(DebugLineRenderer value);
void PublishHudResources(BitmapFont font, TextRenderer text);
@ -89,7 +87,6 @@ internal interface IWorldRenderCompositionFactory
IDatReaderWriter dats,
BindlessSupport bindless);
void SetTerrainAnisotropic(TerrainAtlas atlas, int level);
Shader CreateTerrainShader(GL gl, string shadersDirectory);
SceneLightingUboBinding CreateSceneLighting(GL gl);
DebugLineRenderer CreateDebugLines(
AcDream.App.Rendering.Gpu.IGpuDevice device,
@ -103,8 +100,8 @@ internal interface IWorldRenderCompositionFactory
string shadersDirectory);
TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frameSource,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement);
WorldTerrainBuildContext CreateTerrainBuildContext(
@ -215,13 +212,6 @@ internal sealed class RetailWorldRenderCompositionFactory
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
atlas.SetAnisotropic(level);
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
new(
gl,
Path.Combine(shadersDirectory, "terrain_modern.vert"),
Path.Combine(shadersDirectory, "terrain_modern.frag"),
includeCommonPreamble: true);
public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl);
public DebugLineRenderer CreateDebugLines(
@ -244,11 +234,11 @@ internal sealed class RetailWorldRenderCompositionFactory
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
AcDream.App.Rendering.Gpu.IGpuDevice device,
ICurrentGpuFrameSource frameSource,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
new(gl, bindless, shader, atlas, retirement);
new(gl, device, frameSource, atlas, retirement);
public WorldTerrainBuildContext CreateTerrainBuildContext(
uint initialCenterLandblockId,
@ -396,7 +386,6 @@ internal enum WorldRenderCompositionPoint
EnvironmentInitialized,
BindlessPublished,
TerrainAtlasAcquired,
TerrainShaderPublished,
SceneLightingPublished,
DebugLinesPublished,
DebugFontCreated,
@ -483,12 +472,9 @@ internal sealed class WorldRenderCompositionPhase
AppContext.BaseDirectory,
"Rendering",
"Shaders");
Shader terrainShader = AcquireAndPublish(
scope,
"terrain shader",
() => _factory.CreateTerrainShader(gl, shadersDirectory),
_publication.PublishTerrainShader,
WorldRenderCompositionPoint.TerrainShaderPublished);
// Campaign V slice V4d: terrain no longer needs a Shader composed
// for it — its IGpuPipeline compiles terrain_modern itself, from
// the same sources with the same shared preamble.
SceneLightingUboBinding sceneLighting = AcquireAndPublish(
scope,
"scene lighting",
@ -513,8 +499,8 @@ internal sealed class WorldRenderCompositionPhase
"terrain renderer",
() => _factory.CreateTerrain(
gl,
bindless,
terrainShader,
_dependencies.GpuDevice,
_dependencies.GpuFrameSource,
terrainAtlas,
_dependencies.ResourceRetirement),
_publication.PublishTerrain,
@ -587,7 +573,6 @@ internal sealed class WorldRenderCompositionPhase
shadersDirectory,
bindless,
terrainAtlas,
terrainShader,
sceneLighting,
debugLines,
debugFont,

View file

@ -48,9 +48,6 @@ public sealed class GameWindow :
private GL? _gl;
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _preparedAssets;
@ -897,12 +894,6 @@ public sealed class GameWindow :
value,
"bindless support");
void IGameWindowWorldRenderPublication.PublishTerrainShader(Shader value) =>
PublishCompositionOwner(
ref _terrainModernShader,
value,
"terrain shader");
void IGameWindowWorldRenderPublication.PublishSceneLighting(
SceneLightingUboBinding value) =>
PublishCompositionOwner(
@ -1681,7 +1672,6 @@ public sealed class GameWindow :
_wbMeshAdapter,
_meshShader,
_terrain,
_terrainModernShader,
_sceneLightingUbo,
_debugLines,
_textRenderer,

View file

@ -118,7 +118,6 @@ internal sealed record RenderShutdownRoots(
WbMeshAdapter? MeshAdapter,
Shader? MeshShader,
TerrainModernRenderer? Terrain,
Shader? TerrainShader,
SceneLightingUboBinding? SceneLighting,
DebugLineRenderer? DebugLines,
TextRenderer? TextRenderer,
@ -455,7 +454,6 @@ internal static class GameWindowShutdownManifest
[
Hard("mesh shader", () => render.MeshShader?.Dispose()),
Hard("terrain", () => render.Terrain?.Dispose()),
Hard("terrain shader", () => render.TerrainShader?.Dispose()),
Hard("scene lighting", () => render.SceneLighting?.Dispose()),
Hard("debug lines", () => render.DebugLines?.Dispose()),
Hard("text renderer", () => render.TextRenderer?.Dispose()),

View file

@ -30,15 +30,26 @@ out vec4 fragColor;
// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2, raw
// ARB_bindless_texture handles) became uTextureIndexA/uTextureIndexB (slots
// into the binding=9 handle table, ACDREAM_TEXTURE_HANDLE in common.glsl).
// Named to match the pinned GpuPushConstants.TextureIndexA/B fields so V4d's
// move to push constants is a rename, not a redesign — there is no
// push-constant plumbing yet, so these stay plain uniforms for now.
// Named to match the pinned GpuPushConstants.TextureIndexA/B fields, so slice
// V4d's move onto push constants was a rename rather than a redesign. The GL
// backend maps each push-constant field to the correspondingly named uniform,
// which is why these stay declared exactly as they are.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
uniform float uTexTiling[36];
#define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
#define uAlpha sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))
// Campaign V slice V4d: the per-layer tiling table moved out of a loose
// `uniform float uTexTiling[36]` and into a uniform block at
// GpuBindingModel.UniformTerrainTiling. At 144 bytes of payload it cannot ride
// in the 96-byte GpuPushConstants block (nor Vulkan's guaranteed 128-byte
// ceiling), and there is no RHI verb for setting a uniform array. std140 pads
// each array element to 16 bytes, so the block is 576 bytes; the element type
// is unchanged so uTexTiling[i] reads exactly as it did before.
layout(std140, ACDREAM_UBO_SET binding = 3) uniform TerrainTiling {
float uTexTiling[36];
};
struct Light {
vec4 posAndKind;
vec4 dirAndRange;

File diff suppressed because it is too large Load diff

View file

@ -90,7 +90,11 @@ public sealed class WorldRenderCompositionTests
}
[Theory]
[InlineData("terrain shader", "terrain shader")]
// The "terrain shader" row went with Campaign V slice V4d: terrain's
// IGpuPipeline compiles terrain_modern itself, so there is no longer a
// terrain-shader publication step for a failure to be injected into. The
// invariant this theory pins is unchanged and still covered by every row
// below.
[InlineData("scene lighting", "scene lighting")]
[InlineData("debug lines", "debug lines")]
[InlineData("HUD", "text renderer|debug font")]
@ -247,9 +251,6 @@ public sealed class WorldRenderCompositionTests
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
Resource<Shader>("terrain shader");
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource<SceneLightingUboBinding>("scene lighting");
@ -268,8 +269,8 @@ public sealed class WorldRenderCompositionTests
public TerrainModernRenderer CreateTerrain(
GL gl,
BindlessSupport bindless,
Shader shader,
IGpuDevice device,
ICurrentGpuFrameSource frameSource,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
Resource<TerrainModernRenderer>("terrain");
@ -350,8 +351,6 @@ public sealed class WorldRenderCompositionTests
public void PublishBindlessSupport(BindlessSupport value) =>
Fail("bindless");
public void PublishTerrainShader(Shader value) =>
Fail("terrain shader");
public void PublishSceneLighting(SceneLightingUboBinding value) =>
Fail("scene lighting");
public void PublishDebugLines(DebugLineRenderer value) =>

View file

@ -55,7 +55,14 @@ public sealed class TerrainTextureTilingTableTests
"terrain_modern.frag");
string shader = File.ReadAllText(shaderPath);
Assert.Contains("uniform float uTexTiling[36];", shader);
// Campaign V slice V4d moved the table out of a loose
// `uniform float uTexTiling[36]` and into a std140 block at
// GpuBindingModel.UniformTerrainTiling. The element type and count are
// what every sample site below depends on, so both are still pinned —
// and the binding number is now pinned too, because the shader and
// GpuBindingModel have to agree.
Assert.Contains("binding = 3) uniform TerrainTiling {", shader);
Assert.Contains("float uTexTiling[36];", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay0.z)", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay1.z)", shader);
Assert.Contains("baseUV * terrainTiling(pOverlay2.z)", shader);