From 1f1f6c088b8cf3dd729da1f7b0b14d3ac961601f Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 15:55:18 +0200 Subject: [PATCH] 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 --- .../Composition/WorldRenderComposition.cs | 3 +- .../Rendering/Shaders/terrain_modern.frag | 16 ++- .../Rendering/Shaders/terrain_modern.vert | 2 +- .../Rendering/TerrainModernRenderer.cs | 104 ++++++++++++++++-- 4 files changed, 106 insertions(+), 19 deletions(-) diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index d034c1af..bdbc10f6 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -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); diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag index 6120be6c..d1ceb477 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag @@ -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; diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert index 66565f96..db8224ef 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert @@ -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; diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs index 2a4cfd5c..643a2d1a 100644 --- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs @@ -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 _visibleSlots = new(); private readonly HashSet _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; } + /// + /// Campaign V slice V2b: uploads 's handles to + /// when a new one was registered since the + /// last flush, then (re)binds it at + /// . 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. + /// + private unsafe void FlushAndBindTextureTable() + { + if (_textureTable.Dirty) + { + ReadOnlySpan 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); + } + /// /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared /// UBO range (); otherwise lazily