feat(render): Campaign V slice V2b - terrain texture-index migration

Continues the V2a mesh-path conversion onto TerrainModernRenderer: its two
per-pass bindless texture handles (the terrain atlas and the alpha-mask
atlas) now travel as table indices instead of raw 64-bit
ARB_bindless_texture handles, with zero pixel change.

Terrain differs structurally from the mesh path: it has no per-batch SSBO at
all, just two handles set once per draw as plain uniforms
(terrain_modern.frag's uTerrainHandle/uAlphaHandle, reconstructed via the
sampler2DArray(handle) macros uTerrain/uAlpha). So instead of a BatchData
struct field, the two uniforms became uTextureIndexA/uTextureIndexB - named
to match the pinned GpuPushConstants.TextureIndexA/B fields (campaign doc
section 3.4) so V4d's eventual move to push constants is a rename, not a
redesign. There is no push-constant plumbing yet, so these stay plain
uniforms for now, set via ProgramUniform1 instead of ProgramUniform2.

TerrainModernRenderer owns its own GlBindlessHandleTable and binding=9 SSBO
(the same GL-only handle-table emulation V2a introduced), independent of
WbDrawDispatcher's and EnvCellRenderer's - nothing requires index agreement
between renderers, and terrain only ever registers two handles per draw
(the atlas's terrain/alpha textures), so its table is dirty only once, on
first draw. Unlike WbDrawDispatcher/EnvCellRenderer, TerrainModernRenderer
already eagerly creates its other GL resources in the constructor with a
ResourceCleanupGroup rollback, so the texture-table SSBO is created there
too rather than lazily.

TerrainAtlas needed no change: GetBindlessHandles() keeps returning the raw
(ulong terrain, ulong alpha) pair unchanged - the table lookup is entirely a
TerrainModernRenderer-side concern, added at the one draw-call site that
already converts those handles into shader state.

Shader-side: terrain_modern.frag's uTerrain/uAlpha macros now expand through
common.glsl's ACDREAM_TEXTURE_HANDLE(idx) lookup; both terrain_modern.vert
and .frag opted into the common.glsl preamble (Shader's
includeCommonPreamble, introduced at V2a) so their SceneLighting UBO
declarations could also pick up the ACDREAM_UBO_SET scaffolding macro -
terrain_modern.vert doesn't touch the texture table itself, but sharing the
same preamble across both stages of a technique is simpler to reason about
than deciding per-stage.

Gate: dotnet build -c Release green, dotnet test tests/AcDream.App.Tests
-c Release green (3843 passed / 3 skipped, matching V2a), and
tools/run-offline-pixel-gate.ps1 passed against the V2a commit's build with
a 2.49e-05 differing-pixel fraction - within the documented ~33x same-commit
noise margin. No divergence-register row: this introduces no retail
behavior deviation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 15:55:18 +02:00
parent d365476ebb
commit 1f1f6c088b
4 changed files with 106 additions and 19 deletions

View file

@ -209,7 +209,8 @@ internal sealed class RetailWorldRenderCompositionFactory
new(
gl,
Path.Combine(shadersDirectory, "terrain_modern.vert"),
Path.Combine(shadersDirectory, "terrain_modern.frag"));
Path.Combine(shadersDirectory, "terrain_modern.frag"),
includeCommonPreamble: true);
public SceneLightingUboBinding CreateSceneLighting(GL gl) => new(gl);

View file

@ -27,11 +27,17 @@ flat in float vBaseTexIdx;
out vec4 fragColor;
uniform uvec2 uTerrainHandle;
uniform uvec2 uAlphaHandle;
// 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.
uniform uint uTextureIndexA;
uniform uint uTextureIndexB;
uniform float uTexTiling[36];
#define uTerrain sampler2DArray(uTerrainHandle)
#define uAlpha sampler2DArray(uAlphaHandle)
#define uTerrain sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA))
#define uAlpha sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexB))
struct Light {
vec4 posAndKind;
@ -39,7 +45,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;

View file

@ -22,7 +22,7 @@ struct Light {
vec4 colorAndIntensity;
vec4 coneAngleEtc;
};
layout(std140, binding = 1) uniform SceneLighting {
layout(std140, ACDREAM_UBO_SET binding = 1) uniform SceneLighting {
Light uLights[8];
vec4 uCellAmbient;
vec4 uFogParams;

View file

@ -1,4 +1,5 @@
using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
@ -81,12 +82,22 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
// Campaign V slice V2b (2026-07-27): uTerrainHandle/uAlphaHandle (uvec2)
// became uTextureIndexA/uTextureIndexB (uint table slots) — cached
// uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTextureIndexALoc;
private int _uTextureIndexBLoc;
private int _uTexTilingLoc;
private bool _textureTilingUploaded;
// GL-only emulation of the eventual Vulkan global texture descriptor array
// (binding=9, GpuBindingModel.StorageTextureTable). Owns its own table —
// see GlBindlessHandleTable's doc comment and the campaign doc's §5.2 for
// why terrain doesn't share WbDrawDispatcher's/EnvCellRenderer's tables.
private readonly GlBindlessHandleTable _textureTable = new();
private uint _textureTableSsbo;
private int _textureTableSsboCapacityBytes;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
private readonly HashSet<uint> _visibleCellIds = new();
@ -138,8 +149,8 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle");
_uTextureIndexALoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexA");
_uTextureIndexBLoc = _gl.GetUniformLocation(_shader.Program, "uTextureIndexB");
_uTexTilingLoc = _gl.GetUniformLocation(_shader.Program, "uTexTiling[0]");
if (_uTexTilingLoc < 0)
throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform.");
@ -147,6 +158,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
var constructionResources = new ResourceCleanupGroup();
try
{
// Campaign V slice V2b: binding=9 texture-table SSBO (GL-only
// emulation of the eventual Vulkan descriptor array).
_textureTableSsbo = TrackedGlResource.CreateBuffer(
_gl,
"creating terrain texture-table SSBO");
RetryableGpuResourceRelease textureTableRelease =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_textureTableSsbo,
() => _textureTableSsboCapacityBytes,
"rolling back terrain texture-table SSBO");
constructionResources.Add(
"terrain texture-table SSBO",
textureTableRelease.Run);
_globalVao = TrackedGlResource.CreateVertexArray(
_gl,
"creating terrain global VAO");
@ -479,13 +505,15 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_shader.SetMatrix4("uProjection", camera.Projection);
var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
// Pass each 64-bit handle as a uvec2 (low 32 bits, high 32 bits).
// GLSL constructs sampler2DArray(uTerrainHandle) at the use site —
// see terrain_modern.frag for why this is the safe pattern.
_gl.ProgramUniform2(_shader.Program, _uTerrainHandleLoc,
(uint)(terrainHandle & 0xFFFFFFFFu), (uint)(terrainHandle >> 32));
_gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc,
(uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32));
// Campaign V slice V2b: pass each handle's binding=9 table slot
// instead of the raw uvec2 handle. GLSL reconstructs
// sampler2DArray(ACDREAM_TEXTURE_HANDLE(uTextureIndexA)) at the use
// site — see terrain_modern.frag.
uint terrainSlot = _textureTable.GetOrAdd(terrainHandle);
uint alphaSlot = _textureTable.GetOrAdd(alphaHandle);
FlushAndBindTextureTable();
_gl.ProgramUniform1(_shader.Program, _uTextureIndexALoc, terrainSlot);
_gl.ProgramUniform1(_shader.Program, _uTextureIndexBLoc, alphaSlot);
// Phase U.3: bind the terrain clip UBO (binding=2). Shared ClipFrame UBO
// when wired, else the no-clip fallback (count 0 = ungated terrain).
@ -589,6 +617,16 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
"deleting terrain fallback clip UBO");
releases.Add(("fallback-clip-ubo", release.Run));
}
if (_textureTableSsbo != 0)
{
RetryableGpuResourceRelease release =
TrackedGlResource.CreateRetryableBufferDeletion(
_gl,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
"deleting terrain texture-table SSBO");
releases.Add(("texture-table", release.Run));
}
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
@ -610,6 +648,8 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
_textureTableSsbo = 0;
_textureTableSsboCapacityBytes = 0;
_disposeResources = null;
_disposed = true;
@ -652,6 +692,46 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_textureTilingUploaded = true;
}
/// <summary>
/// Campaign V slice V2b: uploads <see cref="_textureTable"/>'s handles to
/// <see cref="_textureTableSsbo"/> when a new one was registered since the
/// last flush, then (re)binds it at
/// <see cref="GpuBindingModel.StorageTextureTable"/>. Terrain registers at
/// most two handles per draw (the terrain and alpha atlases), so this is
/// dirty only on the atlas's first draw and stays clean afterward.
/// </summary>
private unsafe void FlushAndBindTextureTable()
{
if (_textureTable.Dirty)
{
ReadOnlySpan<ulong> handles = _textureTable.Handles;
int byteCount = handles.Length * sizeof(ulong);
fixed (ulong* p = handles)
{
if (_textureTableSsboCapacityBytes < byteCount)
{
int grown = DynamicBufferCapacity.Grow(_textureTableSsboCapacityBytes, byteCount);
TrackedGlResource.AllocateBufferStorage(
_gl,
GLEnum.ShaderStorageBuffer,
_textureTableSsbo,
_textureTableSsboCapacityBytes,
grown,
GLEnum.DynamicDraw,
"growing terrain texture-table SSBO");
_textureTableSsboCapacityBytes = grown;
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _textureTableSsbo);
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, (nuint)byteCount, p);
}
_textureTable.MarkFlushed();
}
_gl.BindBufferBase(
GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
_textureTableSsbo);
}
/// <summary>
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// <see cref="ClipFrame"/> UBO range (<see cref="SetClipUbo"/>); otherwise lazily