diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs
index 94850718..cb2604f2 100644
--- a/src/AcDream.App/Composition/WorldRenderComposition.cs
+++ b/src/AcDream.App/Composition/WorldRenderComposition.cs
@@ -32,6 +32,7 @@ internal sealed record WorldRenderFoundation(
string ShadersDirectory,
BindlessSupport Bindless,
TerrainAtlas TerrainAtlas,
+ Shader TerrainShader,
SceneLightingUboBinding SceneLighting,
DebugLineRenderer DebugLines,
BitmapFont? DebugFont,
@@ -61,6 +62,7 @@ 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);
@@ -87,6 +89,7 @@ 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,
@@ -100,8 +103,8 @@ internal interface IWorldRenderCompositionFactory
string shadersDirectory);
TerrainModernRenderer CreateTerrain(
GL gl,
- AcDream.App.Rendering.Gpu.IGpuDevice device,
- ICurrentGpuFrameSource frameSource,
+ BindlessSupport bindless,
+ Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement);
WorldTerrainBuildContext CreateTerrainBuildContext(
@@ -212,6 +215,13 @@ 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(
@@ -234,11 +244,11 @@ internal sealed class RetailWorldRenderCompositionFactory
public TerrainModernRenderer CreateTerrain(
GL gl,
- AcDream.App.Rendering.Gpu.IGpuDevice device,
- ICurrentGpuFrameSource frameSource,
+ BindlessSupport bindless,
+ Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
- new(gl, device, frameSource, atlas, retirement);
+ new(gl, bindless, shader, atlas, retirement);
public WorldTerrainBuildContext CreateTerrainBuildContext(
uint initialCenterLandblockId,
@@ -386,6 +396,7 @@ internal enum WorldRenderCompositionPoint
EnvironmentInitialized,
BindlessPublished,
TerrainAtlasAcquired,
+ TerrainShaderPublished,
SceneLightingPublished,
DebugLinesPublished,
DebugFontCreated,
@@ -472,9 +483,12 @@ internal sealed class WorldRenderCompositionPhase
AppContext.BaseDirectory,
"Rendering",
"Shaders");
- // 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.
+ Shader terrainShader = AcquireAndPublish(
+ scope,
+ "terrain shader",
+ () => _factory.CreateTerrainShader(gl, shadersDirectory),
+ _publication.PublishTerrainShader,
+ WorldRenderCompositionPoint.TerrainShaderPublished);
SceneLightingUboBinding sceneLighting = AcquireAndPublish(
scope,
"scene lighting",
@@ -499,8 +513,8 @@ internal sealed class WorldRenderCompositionPhase
"terrain renderer",
() => _factory.CreateTerrain(
gl,
- _dependencies.GpuDevice,
- _dependencies.GpuFrameSource,
+ bindless,
+ terrainShader,
terrainAtlas,
_dependencies.ResourceRetirement),
_publication.PublishTerrain,
@@ -573,6 +587,7 @@ internal sealed class WorldRenderCompositionPhase
shadersDirectory,
bindless,
terrainAtlas,
+ terrainShader,
sceneLighting,
debugLines,
debugFont,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7c1de8e2..e16fa047 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -48,6 +48,9 @@ public sealed class GameWindow :
private GL? _gl;
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
+ /// Phase N.5b: terrain_modern.vert/.frag program. Owned by
+ /// at draw time but allocated + disposed here.
+ private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IDatReaderWriter? _dats;
private IPreparedAssetSource? _preparedAssets;
@@ -894,6 +897,12 @@ public sealed class GameWindow :
value,
"bindless support");
+ void IGameWindowWorldRenderPublication.PublishTerrainShader(Shader value) =>
+ PublishCompositionOwner(
+ ref _terrainModernShader,
+ value,
+ "terrain shader");
+
void IGameWindowWorldRenderPublication.PublishSceneLighting(
SceneLightingUboBinding value) =>
PublishCompositionOwner(
@@ -1672,6 +1681,7 @@ public sealed class GameWindow :
_wbMeshAdapter,
_meshShader,
_terrain,
+ _terrainModernShader,
_sceneLightingUbo,
_debugLines,
_textRenderer,
diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs
index 4129f99d..2fc22eb9 100644
--- a/src/AcDream.App/Rendering/GameWindowLifetime.cs
+++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs
@@ -118,6 +118,7 @@ internal sealed record RenderShutdownRoots(
WbMeshAdapter? MeshAdapter,
Shader? MeshShader,
TerrainModernRenderer? Terrain,
+ Shader? TerrainShader,
SceneLightingUboBinding? SceneLighting,
DebugLineRenderer? DebugLines,
TextRenderer? TextRenderer,
@@ -454,6 +455,7 @@ 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()),
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
index ef0536c3..d1ceb477 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
+++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
@@ -30,26 +30,15 @@ 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 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.
+// 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(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;
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
index a9658192..6f5bb8c3 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
@@ -7,22 +7,13 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
///
-/// Phase N.5b modern terrain dispatcher. Single global vertex/index arena with
-/// a slot allocator (one slot per landblock, 384 verts × 40 bytes = 15,360
-/// bytes per slot). Per-frame: build a DrawElementsIndirectCommand array from
-/// visible slots into the frame ring, then dispatch one
-/// MultiDrawIndexedIndirect.
+/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
+/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
+/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
+/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
+/// textures bound via bindless handles set per-frame as sampler uniforms.
///
-/// Campaign V slice V4d records that dispatch through
-/// instead of calling GL directly. The arena is
-/// an pair, the indirect array is a ring allocation,
-/// the atlas table slots ride in , and the
-/// imperative cull/depth bracket is baked into one .
-/// Two bindings stay raw GL on purpose (campaign doc §5.3): the terrain clip
-/// UBO at binding 2 and the SceneLighting UBO at binding 1 are shared with
-/// renderers that are still raw GL until V4h.
-///
-/// Total ~6-8 GPU calls per frame for terrain regardless of visible
+/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
///
public sealed unsafe class TerrainModernRenderer : IDisposable
@@ -40,25 +31,17 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private const int IndexSize = sizeof(uint);
private const float LandblockSize = LandblockMesh.LandblockSize; // 192
- // Still raw GL, and only for the two shared bindings campaign doc §5.3
- // leaves globally bound until V4h, plus the command barrier before the
- // multi-draw. Everything else this renderer does goes through the RHI.
private readonly GL _gl;
- private readonly IGpuDevice _device;
- private readonly ICurrentGpuFrameSource _frameSource;
+ private readonly BindlessSupport _bindless;
+ private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
- private IGpuPipeline? _pipeline;
/// A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via .
public TerrainAtlas Atlas => _atlas;
private readonly GpuRetiredTerrainSlotAllocator _alloc;
- // Campaign V slice V4d retired this renderer's own GpuRetirementLedger:
- // every resource it used to hold retryable releases for is an IGpuBuffer or
- // an IGpuPipeline now, and their Dispose already routes the physical free
- // through the device's retirement queue. The slot allocator keeps its own
- // retryable publication path, which is unrelated and untouched.
+ private readonly GpuRetirementLedger _retirementLedger;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
@@ -68,25 +51,29 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary _idToSlot = new();
- // GPU buffers. The arena keeps its own storage rather than sharing
- // GlobalMeshBuffer: terrain packs a 40-byte vertex with four integer
- // attribute words, not the 32-byte world-mesh vertex.
- private IGpuBuffer? _vertexBuffer;
- private IGpuBuffer? _indexBuffer;
+ // GPU buffers.
+ private uint _globalVao;
+ private uint _globalVbo;
+ private uint _globalEbo;
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
+ private uint _indirectBuffer;
+ private int _indirectCapacity;
+ private sealed class DynamicIndirectBuffer
+ {
+ public uint Buffer;
+ public int Capacity;
+ }
+
+ private readonly List[] _indirectBuffersByFrame =
+ [[], [], []];
+ private int _dynamicFrameSlot;
+ private int _dynamicBufferCursor;
private bool _dynamicFrameStarted;
- ///
- /// Campaign V slice V4d retired the per-frame-slot indirect buffer pool.
- /// That pool existed so a second terrain draw within one frame 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. Reporting 0 is the truth —
- /// this renderer owns no such pool any more — rather than a silent change.
- ///
- internal int DynamicIndirectBufferCount => 0;
+ internal int DynamicIndirectBufferCount =>
+ _indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and
@@ -95,28 +82,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
- // Campaign V slice V2b (2026-07-27) turned uTerrainHandle/uAlphaHandle
- // (uvec2) into uTextureIndexA/uTextureIndexB (uint table slots). Slice V4d
- // moved both into the shared GpuPushConstants block, whose fields were
- // deliberately named to match, so this is a rename rather than a redesign
- // and no uniform locations are cached here any more.
-
- // The 36-float per-layer tiling table. Immutable once the atlas is built,
- // so it is a long-lived uniform buffer uploaded on the first draw rather
- // than per-frame ring data. It cannot ride in GpuPushConstants: at 144
- // bytes it exceeds both the 96-byte block and Vulkan's guaranteed 128-byte
- // ceiling, which is why GpuBindingModel.UniformTerrainTiling exists.
- private IGpuBuffer? _tilingBuffer;
+ // 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.
- // Retiring the table itself in favour of IGpuDevice's own is slice V4t.
private readonly GlBindlessHandleTable _textureTable = new();
- private IGpuBuffer? _textureTableBuffer;
- private int _textureTableBufferBytes;
+ private uint _textureTableSsbo;
+ private int _textureTableSsboCapacityBytes;
// Reusable per-frame buffers.
private readonly List _visibleSlots = new();
@@ -136,47 +116,16 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
- ///
- /// The terrain vertex, matching TerrainVertex and the
- /// terrain_modern.vert input declarations: position, normal, then
- /// four packed integer words.
- ///
- /// Locations 2–5 are , not
- /// UByte4Normalized. They are uvec4 in the shader and carry
- /// terrain-type, road and split-direction codes; normalising them to [0,1]
- /// floats would not be an approximation of those codes, it would be
- /// garbage. GL requires glVertexAttribIPointer for an integer shader
- /// input and leaves the value undefined otherwise.
- ///
- private static readonly GpuVertexLayout TerrainVertexLayout = new(
- StrideBytes: VertexSize,
- [
- 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),
- ]);
-
- ///
- /// Campaign V slice V4d narrowed this from public to
- /// internal: and
- /// are internal RHI types, so a public
- /// constructor cannot name them. The class itself stays public and no other
- /// member changed visibility — every caller is already inside this assembly.
- /// EnvCellRenderer's constructor is internal for the same reason.
- ///
- internal TerrainModernRenderer(
+ public TerrainModernRenderer(
GL gl,
- IGpuDevice device,
- ICurrentGpuFrameSource frameSource,
+ BindlessSupport bindless,
+ Shader shader,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
: this(
gl,
- device,
- frameSource,
+ bindless,
+ shader,
atlas,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
@@ -185,80 +134,95 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
internal TerrainModernRenderer(
GL gl,
- IGpuDevice device,
- ICurrentGpuFrameSource frameSource,
+ BindlessSupport bindless,
+ Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{
_gl = gl;
- _device = device ?? throw new ArgumentNullException(nameof(device));
- _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource));
+ _bindless = bindless;
+ _shader = shader;
_atlas = atlas;
ArgumentNullException.ThrowIfNull(resourceRetirement);
+ _retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
- // Every buffer here is an IGpuBuffer, whose Dispose routes the physical
- // free through the device's retirement queue, so the hand-rolled
- // construction rollback group the raw GL names needed is gone: a
- // partially-built set is released by disposing what exists.
+ _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.");
+
+ var constructionResources = new ResourceCleanupGroup();
try
{
- _pipeline = CreateTerrainPipeline(_device);
- AllocateGpuBuffers(initialSlotCapacity);
- }
- catch
- {
- _pipeline?.Dispose();
- _pipeline = null;
- _vertexBuffer?.Dispose();
- _vertexBuffer = null;
- _indexBuffer?.Dispose();
- _indexBuffer = null;
- throw;
- }
- }
+ // 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);
- ///
- /// The single terrain pipeline. Every piece of fixed state here is what the
- /// draw used to assert imperatively or inherit from the frame default, and
- /// each was checked against what terrain actually observes rather than
- /// assumed:
- ///
- ///
- /// - Depth compare is ,
- /// 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,
- /// depth write on: all inherited from the same frame default, which
- /// terrain never changed.
- /// - Cull back / front-face CCW is the #108-residual
- /// single-sided terrain rule the draw used to set by hand; see the
- /// comment at the multi-draw.
- ///
- ///
- private static IGpuPipeline CreateTerrainPipeline(IGpuDevice device) =>
- device.CreatePipeline(new GpuPipelineDescription
+ _globalVao = TrackedGlResource.CreateVertexArray(
+ _gl,
+ "creating terrain global VAO");
+ RetryableGpuResourceRelease globalVaoRelease =
+ TrackedGlResource.CreateRetryableVertexArrayDeletion(
+ _gl,
+ _globalVao,
+ "rolling back terrain global VAO");
+ constructionResources.Add(
+ "terrain global VAO",
+ globalVaoRelease.Run);
+ _globalVbo = TrackedGlResource.CreateBuffer(
+ _gl,
+ "creating terrain global vertex buffer");
+ RetryableGpuResourceRelease globalVboRelease =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ _globalVbo,
+ () => _globalVboCapacityBytes,
+ "rolling back terrain global vertex buffer");
+ constructionResources.Add(
+ "terrain global vertex buffer",
+ globalVboRelease.Run);
+ _globalEbo = TrackedGlResource.CreateBuffer(
+ _gl,
+ "creating terrain global index buffer");
+ RetryableGpuResourceRelease globalEboRelease =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ _globalEbo,
+ () => _globalEboCapacityBytes,
+ "rolling back terrain global index buffer");
+ constructionResources.Add(
+ "terrain global index buffer",
+ globalEboRelease.Run);
+ AllocateGpuBuffers(initialSlotCapacity);
+ GlResourceCommand.Execute(
+ _gl,
+ "configure terrain global vertex array",
+ () => ConfigureVao(_globalVao, _globalVbo, _globalEbo));
+ constructionResources.TransferAll();
+ }
+ catch (Exception constructionFailure)
{
- Name = "terrain",
- Shaders = new GpuShaderSet("terrain_modern"),
- VertexLayout = TerrainVertexLayout,
- Topology = GpuPrimitiveTopology.TriangleList,
- Blend = GpuBlendMode.None,
- Depth = new GpuDepthState(Test: true, Write: true, GpuCompareOp.Less),
- Cull = GpuCullMode.Back,
- FrontFace = GpuFrontFace.CounterClockwise,
- AlphaToCoverage = false,
- ColorWrite = true,
- SampleCount = 1,
- });
+ constructionResources.RollbackConstructionAndThrow(
+ "TerrainModernRenderer construction failed and its GL prefix did not cleanly roll back.",
+ constructionFailure);
+ }
+
+ }
///
/// Resets the indirect-command submission cursor for a GPU-fenced frame
@@ -267,32 +231,49 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
///
public void BeginFrame(int frameSlot)
{
- if ((uint)frameSlot >= (uint)FrameSlotCount)
+ if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
- // Campaign V slice V4d: the slot no longer selects a buffer set —
- // IGpuDevice.BeginFrame already rotated the fence-gated ring slot. The
- // argument and its range check remain this renderer's published
- // contract with the frame spine.
+ _retirementLedger.RetryPendingPublications();
+ _dynamicFrameSlot = frameSlot;
+ _dynamicBufferCursor = 0;
_dynamicFrameStarted = true;
}
- ///
- /// Frames in flight, retained purely so keeps
- /// validating the same slot range it always did.
- ///
- private const int FrameSlotCount = 3;
-
- private IGpuFrame RequireFrame()
+ private void ActivateNextIndirectBuffer()
{
- // The same precondition ActivateNextIndirectBuffer enforced before the
- // per-slot indirect pool was retired.
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
- return _frameSource.CurrentFrame
- ?? throw new InvalidOperationException(
- "TerrainModernRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime) — "
- + "the host must drive IGpuDevice.BeginFrame() before drawing terrain.");
+ List frameBuffers = _indirectBuffersByFrame[_dynamicFrameSlot];
+ if (_dynamicBufferCursor == frameBuffers.Count)
+ {
+ uint buffer = TrackedGlResource.CreateBuffer(
+ _gl,
+ $"creating terrain indirect buffer for frame slot {_dynamicFrameSlot}");
+ try
+ {
+ frameBuffers.Add(new DynamicIndirectBuffer { Buffer = buffer });
+ }
+ catch
+ {
+ TrackedGlResource.DeleteBuffer(
+ _gl,
+ buffer,
+ 0,
+ "rolling back terrain indirect buffer");
+ throw;
+ }
+ }
+
+ DynamicIndirectBuffer active = frameBuffers[_dynamicBufferCursor++];
+ _indirectBuffer = active.Buffer;
+ _indirectCapacity = active.Capacity;
+ }
+
+ private void PersistIndirectCapacity()
+ {
+ _indirectBuffersByFrame[_dynamicFrameSlot][_dynamicBufferCursor - 1].Capacity =
+ _indirectCapacity;
}
///
@@ -362,18 +343,33 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
- // Upload into the slot's vertex + index regions. Not ring data: a
- // landblock's mesh is uploaded once on stream-in and read by every
- // later frame, so it lives in the long-lived arena.
- long vboByteOffset = (long)slot * VertsPerLandblock * VertexSize;
- long eboByteOffset = (long)slot * IndicesPerLandblock * IndexSize;
+ // glBufferSubData into the slot's VBO + EBO regions.
+ nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
+ nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
- RequireVertexBuffer().Upload(
- vboByteOffset,
- System.Runtime.InteropServices.MemoryMarshal.AsBytes(bakedVerts));
- RequireIndexBuffer().Upload(
- eboByteOffset,
- System.Runtime.InteropServices.MemoryMarshal.AsBytes(bakedIndices));
+ fixed (TerrainVertex* p = bakedVerts)
+ {
+ TrackedGlResource.UpdateBufferSubData(
+ _gl,
+ BufferTargetARB.ArrayBuffer,
+ _globalVbo,
+ vboByteOffset,
+ VertsPerLandblock * VertexSize,
+ p,
+ $"uploading terrain vertices for 0x{landblockId:X8}");
+ }
+
+ fixed (uint* p = bakedIndices)
+ {
+ TrackedGlResource.UpdateBufferSubData(
+ _gl,
+ BufferTargetARB.ElementArrayBuffer,
+ _globalEbo,
+ eboByteOffset,
+ IndicesPerLandblock * IndexSize,
+ p,
+ $"uploading terrain indices for 0x{landblockId:X8}");
+ }
_slots[slot] = new SlotData
{
@@ -449,6 +445,7 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
ndcClipAabb);
}
if (_visibleSlots.Count == 0) return;
+ ActivateNextIndirectBuffer();
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
@@ -466,136 +463,153 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
};
}
- // Campaign V slice V2b: pass each atlas handle's binding=9 table slot
+ // Grow indirect buffer if needed.
+ if (_visibleSlots.Count > _indirectCapacity)
+ {
+ int grownCapacity = Math.Max(64, _visibleSlots.Count * 2);
+ TrackedGlResource.AllocateBufferStorage(
+ _gl,
+ GLEnum.DrawIndirectBuffer,
+ _indirectBuffer,
+ checked((long)_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
+ checked((long)grownCapacity * sizeof(DrawElementsIndirectCommand)),
+ GLEnum.DynamicDraw,
+ "growing terrain indirect command buffer");
+ _indirectCapacity = grownCapacity;
+ }
+
+ // Upload DEIC array.
+ fixed (DrawElementsIndirectCommand* p = _deicScratch)
+ {
+ TrackedGlResource.UpdateBufferSubData(
+ _gl,
+ GLEnum.DrawIndirectBuffer,
+ _indirectBuffer,
+ 0,
+ checked((long)_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)),
+ p,
+ "uploading terrain indirect commands");
+ }
+ PersistIndirectCapacity();
+
+ // Bind shader + uniforms + atlas handles.
+ // Verified Phase W Stage 4 (T4.2): terrain projects from the camera view-proj;
+ // no separate landscape viewpoint to sync. Both uView and uProjection derive
+ // from the ICamera passed into this method — the same camera used for all other
+ // renderers in the unified pipeline. Retail's LScape::update_viewpoint
+ // pre-positions terrain to the outdoor landcell, but acdream uses the
+ // unified camera matrix everywhere, so no separate viewpoint divergence can occur.
+ _shader.Use();
+ UploadTextureTilingOnce();
+ // Campaign V slice V4d-1: one uViewProjection, matching the field
+ // GpuPushConstants already carries, instead of the separate uView and
+ // uProjection the shader used to combine per vertex. viewProjection is
+ // the same product the visibility pass above already computed.
+ _shader.SetMatrix4("uViewProjection", viewProjection);
+
+ var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
+ // 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.
- var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
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).
- // Stays a raw global bind outside the pass: ClipFrame owns the buffer
- // and its other consumers are raw GL until V4h (campaign doc §5.3).
BindClipUboBinding2();
- IGpuFrame frame = RequireFrame();
- using (IGpuPassEncoder encoder = frame.BeginPass(TerrainPass))
- {
- // Verified Phase W Stage 4 (T4.2): terrain projects from the camera
- // view-proj; no separate landscape viewpoint to sync. The matrix
- // derives from the ICamera passed into this method — the same camera
- // used for all other renderers in the unified pipeline. Retail's
- // LScape::update_viewpoint pre-positions terrain to the outdoor
- // landcell, but acdream uses the unified camera matrix everywhere,
- // so no separate viewpoint divergence can occur.
- var pushConstants = new GpuPushConstants
- {
- ViewProjection = viewProjection,
- DrawIdOffset = 0,
- LightingMode = 0,
- RenderPass = 0,
- LightDebug = 0,
- TextureIndexA = terrainSlot,
- TextureIndexB = alphaSlot,
- ParamA = 0f,
- ParamB = 0f,
- };
+ // #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
+ // (0x006b7040) draws each land triangle ONLY when the camera is on the
+ // POSITIVE (upper) side of its plane (Plane::which_side2 vs
+ // Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
+ // evaluates the same per-triangle eye-side predicate at rasterization.
+ // LandblockMesh emits every triangle CCW in world XY seen from above
+ // (LandblockMeshTests winding pin), which the unified camera chain
+ // (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
+ // winding from above / CW from below (TerrainCullOrientationTests) —
+ // so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
+ // underside. WB drew the whole world with culling DISABLED
+ // frame-globally (WB GameScene.cs:841 — an editor camera goes
+ // underground); inheriting that drew terrain DOUBLE-SIDED, and a
+ // below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
+ // sheet through the exit-door aperture — the #108 grass window.
+ // Self-contained state per feedback_render_self_contained_gl_state;
+ // the frame-global CW + cull-off baseline is restored after the draw.
+ _gl.Enable(EnableCap.CullFace);
+ _gl.CullFace(TriangleFace.Back);
+ _gl.FrontFace(FrontFaceDirection.Ccw);
- // #108-residual: retail terrain is SINGLE-SIDED — ACRender::landPolysDraw
- // (0x006b7040) draws each land triangle ONLY when the camera is on the
- // POSITIVE (upper) side of its plane (Plane::which_side2 vs
- // Render::FrameCurrent, zFightTerrainAdjust bias). GL backface culling
- // evaluates the same per-triangle eye-side predicate at rasterization.
- // LandblockMesh emits every triangle CCW in world XY seen from above
- // (LandblockMeshTests winding pin), which the unified camera chain
- // (CreateLookAt up=+Z + Numerics perspective) maps to CCW window
- // winding from above / CW from below (TerrainCullOrientationTests) —
- // so FrontFace(Ccw)+Cull(Back) keeps the top side and culls the
- // underside. WB drew the whole world with culling DISABLED
- // frame-globally (WB GameScene.cs:841 — an editor camera goes
- // underground); inheriting that drew terrain DOUBLE-SIDED, and a
- // below-grade eye (cellar ascent) saw the UNDERSIDE of the grade
- // sheet through the exit-door aperture — the #108 grass window.
- // The Enable/CullFace/FrontFace triple is now baked into the
- // pipeline (see CreateTerrainPipeline) rather than asserted here;
- // the frame-global CW + cull-off baseline is still restored below.
- encoder.BindPipeline(_pipeline!);
- encoder.SetPushConstants(in pushConstants);
+ _gl.BindVertexArray(_globalVao);
+ _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
+ _gl.MultiDrawElementsIndirect(
+ PrimitiveType.Triangles, DrawElementsType.UnsignedInt,
+ (void*)0,
+ (uint)_visibleSlots.Count,
+ (uint)sizeof(DrawElementsIndirectCommand));
+ _gl.BindVertexArray(0);
+ _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
- // Vertex attribute pointers and the index binding are vertex-array
- // state owned by the pipeline's VAO, so they must be re-established
- // after every BindPipeline — see BindArena.
- BindArena(encoder);
- BindTextureTable(encoder); // Campaign V slice V2 (binding=9)
- BindTextureTiling(encoder); // Campaign V slice V4d (uniform binding=3)
-
- GpuRingAllocation commands = frame.AllocateRing(
- _visibleSlots.Count * sizeof(DrawElementsIndirectCommand),
- GpuRingUsage.Indirect);
- System.Runtime.InteropServices.MemoryMarshal
- .AsBytes(_deicScratch.AsSpan(0, _visibleSlots.Count))
- .CopyTo(commands.Data);
-
- // The barrier stays a raw call: it has no RHI verb, and it guards
- // incoherent shader writes that acdream does not make, so it was
- // already a no-op against client-side uploads, which GL orders
- // implicitly. Kept rather than quietly dropped; the Vulkan backend
- // expresses real ordering with barriers it inserts itself.
- _gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
-
- encoder.MultiDrawIndexedIndirect(
- commands.Buffer,
- commands.OffsetBytes,
- (uint)_visibleSlots.Count,
- (uint)sizeof(DrawElementsIndirectCommand));
- }
-
- // The encoder's Dispose restores the capability state that was ambient
- // on ENTRY, which is what terrain used to leave behind anyway — the
- // frame default is cull-off / front-face CW. Reasserting it explicitly
- // keeps the guarantee independent of what ran before this draw, since
- // sky and particles are still raw GL and inherit whatever they find.
- // Goes at V4h with the last raw-GL renderer.
_gl.FrontFace(FrontFaceDirection.CW);
_gl.Disable(EnableCap.CullFace);
}
- ///
- /// The terrain pass. Load/Store against a null colour target, which on GL
- /// means "the framebuffer the spine already bound" — clears and framebuffer
- /// management stay with the spine until slice V4h (campaign doc §5.4).
- ///
- private static readonly GpuPassDescription TerrainPass = new()
- {
- Name = "terrain",
- Color = new GpuColorAttachment(
- Target: null,
- Load: GpuLoadOp.Load,
- Store: GpuStoreOp.Store,
- ClearColor: default),
- Depth = new GpuDepthAttachment(
- Load: GpuLoadOp.Load,
- Store: GpuStoreOp.Store,
- ClearDepth: 1f,
- ClearStencil: 0),
- SampleCount = 1,
- };
-
public void Dispose()
{
if (_disposed)
return;
+ _retirementLedger.RetryPendingPublications();
- // Only the fallback clip UBO is still a raw GL name, so it is the only
- // member left needing the retryable release ledger. Every IGpuBuffer
- // and the pipeline route their physical free through the device's
- // retirement queue on Dispose, which is that same guarantee expressed
- // by the contract instead of by hand.
if (_disposeResources is null)
{
var releases = new List<(string Name, Action Release)>();
+ if (_globalVao != 0)
+ {
+ RetryableGpuResourceRelease release =
+ TrackedGlResource.CreateRetryableVertexArrayDeletion(
+ _gl,
+ _globalVao,
+ "deleting terrain global VAO");
+ releases.Add(("global-vao", release.Run));
+ }
+ if (_globalVbo != 0)
+ {
+ RetryableGpuResourceRelease release =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ _globalVbo,
+ _globalVboCapacityBytes,
+ "deleting terrain global vertex buffer");
+ releases.Add(("global-vbo", release.Run));
+ }
+ if (_globalEbo != 0)
+ {
+ RetryableGpuResourceRelease release =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ _globalEbo,
+ _globalEboCapacityBytes,
+ "deleting terrain global index buffer");
+ releases.Add(("global-ebo", release.Run));
+ }
+ for (int frame = 0; frame < _indirectBuffersByFrame.Length; frame++)
+ {
+ List frameBuffers = _indirectBuffersByFrame[frame];
+ for (int index = 0; index < frameBuffers.Count; index++)
+ {
+ DynamicIndirectBuffer buffer = frameBuffers[index];
+ RetryableGpuResourceRelease release =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ buffer.Buffer,
+ checked((long)buffer.Capacity * sizeof(DrawElementsIndirectCommand)),
+ "deleting terrain indirect command buffer");
+ releases.Add(($"indirect-{frame}-{index}", release.Run));
+ }
+ }
if (_fallbackClipUbo != 0)
{
RetryableGpuResourceRelease release =
@@ -606,6 +620,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);
}
@@ -616,23 +640,19 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
"One or more terrain GPU resources could not be released.");
}
- _vertexBuffer?.Dispose();
- _vertexBuffer = null;
- _indexBuffer?.Dispose();
- _indexBuffer = null;
- _textureTableBuffer?.Dispose();
- _textureTableBuffer = null;
- _tilingBuffer?.Dispose();
- _tilingBuffer = null;
- _pipeline?.Dispose();
- _pipeline = null;
-
+ _globalVao = 0;
+ _globalVbo = 0;
+ _globalEbo = 0;
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
+ foreach (List frameBuffers in _indirectBuffersByFrame)
+ frameBuffers.Clear();
+ _indirectBuffer = 0;
+ _indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
- _textureTableBufferBytes = 0;
- _textureTilingUploaded = false;
+ _textureTableSsbo = 0;
+ _textureTableSsboCapacityBytes = 0;
_disposeResources = null;
_disposed = true;
@@ -648,136 +668,73 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// ----------------------------------------------------------------
///
- /// std140 stride of one float in the tiling array. std140 pads every
- /// array element to 16 bytes, which is why the block is 576 bytes rather
- /// than 144. Declaring it as packed vec4s would be tighter, but it
- /// would also change the shader's use site; keeping the element type means
- /// uTexTiling[int(layer)] reads exactly as it did before.
- ///
- private const int TilingElementStrideBytes = 16;
-
- private const int TilingBlockBytes =
- TerrainTextureTilingTable.LayerCapacity * TilingElementStrideBytes;
-
- ///
- /// Upload the texture-array adapter for retail's per-surface repeat count,
- /// then bind it at .
+ /// Upload the texture-array adapter for retail's per-surface repeat count.
/// Retail passes TerrainTex::tex_tiling directly to
/// ImgTex::TileCSI / ImgTex::MergeTexture
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
- ///
- /// The table is immutable once the atlas is built, so it is uploaded on the
- /// first draw and only rebound afterwards — the same "upload once" property
- /// the loose uniform float[36] had by virtue of living in the linked
- /// program, now expressed as a long-lived uniform buffer instead.
+ /// Uniform values persist for the lifetime of this linked shader program,
+ /// so the immutable atlas table is uploaded on its first bound draw.
///
- private void BindTextureTiling(IGpuPassEncoder encoder)
+ private void UploadTextureTilingOnce()
{
- if (_tilingBuffer is null)
+ if (_textureTilingUploaded)
+ return;
+
+ if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
{
- _tilingBuffer = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-tiling",
- TilingBlockBytes,
- GpuBufferUsage.Uniform,
- GpuMemoryResidency.DeviceLocal));
+ throw new InvalidOperationException(
+ $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
+ $"expected {TerrainTextureTilingTable.LayerCapacity}.");
}
- if (!_textureTilingUploaded)
- {
- if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
- {
- throw new InvalidOperationException(
- $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
- $"expected {TerrainTextureTilingTable.LayerCapacity}.");
- }
+ Span values = stackalloc float[TerrainTextureTilingTable.LayerCapacity];
+ for (int i = 0; i < values.Length; i++)
+ values[i] = _atlas.TilingByLayer[i];
- Span block = stackalloc byte[TilingBlockBytes];
- block.Clear();
- for (int i = 0; i < TerrainTextureTilingTable.LayerCapacity; i++)
- {
- BitConverter.TryWriteBytes(
- block[(i * TilingElementStrideBytes)..],
- _atlas.TilingByLayer[i]);
- }
- _tilingBuffer.Upload(0, block);
- _textureTilingUploaded = true;
- }
-
- encoder.BindUniformBuffer(
- GpuBindingModel.UniformTerrainTiling,
- _tilingBuffer,
- 0,
- TilingBlockBytes);
+ _gl.Uniform1(_uTexTilingLoc, values);
+ _textureTilingUploaded = true;
}
///
- /// Campaign V slice V2b's handle table (binding=9), held as an
- /// since slice V4d and bound through the encoder.
- /// Not a ring allocation: terrain registers at most two handles ever (the
- /// terrain and alpha atlases), so the buffer is long-lived and re-uploaded
- /// only when is set or growth
- /// forced a fresh allocation whose contents would otherwise be undefined.
- /// Growth is create-and-retire, so a frame still reading the old buffer
- /// never has it freed underneath.
+ /// 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 void BindTextureTable(IGpuPassEncoder encoder)
+ private unsafe void FlushAndBindTextureTable()
{
- ReadOnlySpan handles = _textureTable.Handles;
- int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong));
-
- bool reallocated = false;
- if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount)
+ if (_textureTable.Dirty)
{
- _textureTableBuffer?.Dispose();
- _textureTableBufferBytes = DynamicBufferCapacity.Grow(_textureTableBufferBytes, byteCount);
- _textureTableBuffer = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-texture-table",
- _textureTableBufferBytes,
- GpuBufferUsage.Storage,
- GpuMemoryResidency.DeviceLocal));
- reallocated = true;
- }
-
- if ((reallocated || _textureTable.Dirty) && !handles.IsEmpty)
- {
- _textureTableBuffer.Upload(
- 0,
- System.Runtime.InteropServices.MemoryMarshal.AsBytes(handles));
+ 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();
}
-
- encoder.BindStorageBuffer(
+ _gl.BindBufferBase(
+ GLEnum.ShaderStorageBuffer,
GpuBindingModel.StorageTextureTable,
- _textureTableBuffer,
- 0,
- (uint)byteCount);
+ _textureTableSsbo);
}
- ///
- /// Binds the terrain arena as this pass's vertex and index source,
- /// replacing glBindVertexArray(globalVao).
- ///
- /// Every owns its own vertex array, and vertex
- /// attribute pointers plus the index binding are vertex-array state — so a
- /// pipeline bind establishes the attribute shape but not the buffers, and
- /// this must follow every BindPipeline. Terrain binds exactly one
- /// pipeline per pass, so it cannot hit the mid-pass switch that dropped the
- /// mesh source in slice V4c, but the ordering requirement is the same.
- ///
- private void BindArena(IGpuPassEncoder encoder)
- {
- encoder.BindVertexBuffer(RequireVertexBuffer(), 0);
- encoder.BindIndexBuffer(RequireIndexBuffer(), 0, GpuIndexType.UInt32);
- }
-
- private IGpuBuffer RequireVertexBuffer() =>
- _vertexBuffer ?? throw new InvalidOperationException(
- "The terrain vertex arena is not allocated.");
-
- private IGpuBuffer RequireIndexBuffer() =>
- _indexBuffer ?? throw new InvalidOperationException(
- "The terrain index arena is not allocated.");
-
///
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
/// UBO range (); otherwise lazily
@@ -827,37 +784,61 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
}
- ///
- /// Allocates the arena at landblocks.
- /// Both buffers name TransferSource/TransferDestination as well as their
- /// primary role because migrates through a
- /// device-side copy, and Vulkan must know every usage at creation time.
- ///
- /// The VAO that used to be configured alongside these is gone: the pipeline
- /// owns one shaped by , and the encoder
- /// re-issues the attribute pointers on every
- /// .
- ///
private void AllocateGpuBuffers(int capacitySlots)
{
long vboBytes = checked((long)capacitySlots * VertsPerLandblock * VertexSize);
long eboBytes = checked((long)capacitySlots * IndicesPerLandblock * IndexSize);
- _vertexBuffer = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-vertices",
+ TrackedGlResource.AllocateBufferStorage(
+ _gl,
+ BufferTargetARB.ArrayBuffer,
+ _globalVbo,
+ _globalVboCapacityBytes,
vboBytes,
- GpuBufferUsage.Vertex | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
- GpuMemoryResidency.DeviceLocal));
+ BufferUsageARB.DynamicDraw,
+ "allocating terrain global vertex storage");
_globalVboCapacityBytes = vboBytes;
- _indexBuffer = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-indices",
+ TrackedGlResource.AllocateBufferStorage(
+ _gl,
+ BufferTargetARB.ElementArrayBuffer,
+ _globalEbo,
+ _globalEboCapacityBytes,
eboBytes,
- GpuBufferUsage.Index | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
- GpuMemoryResidency.DeviceLocal));
+ BufferUsageARB.DynamicDraw,
+ "allocating terrain global index storage");
_globalEboCapacityBytes = eboBytes;
}
+ private void ConfigureVao(uint vao, uint vbo, uint ebo)
+ {
+ _gl.BindVertexArray(vao);
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo);
+ _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo);
+
+ uint stride = (uint)VertexSize;
+
+ // location 0: Position
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
+ // location 1: Normal
+ _gl.EnableVertexAttribArray(1);
+ _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
+ // locations 2-5: Data0..Data3 (uvec4 byte attributes)
+ nint dataOffset = 6 * sizeof(float);
+ _gl.EnableVertexAttribArray(2);
+ _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
+ _gl.EnableVertexAttribArray(3);
+ _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
+ _gl.EnableVertexAttribArray(4);
+ _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
+ _gl.EnableVertexAttribArray(5);
+ _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
+
+ _gl.BindVertexArray(0);
+ GLHelpers.ThrowOnResourceError(_gl, "configuring terrain VAO");
+ }
+
internal static void CollectVisibleCells(
HashSet destination,
uint landblockId,
@@ -992,53 +973,119 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
- IGpuBuffer? newVertices = null;
- IGpuBuffer? newIndices = null;
+ uint newVbo = 0;
+ uint newEbo = 0;
+ uint newVao = 0;
+ long allocatedNewVboBytes = 0;
+ long allocatedNewEboBytes = 0;
bool published = false;
try
{
- newVertices = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-vertices",
+ newVbo = TrackedGlResource.CreateBuffer(
+ _gl,
+ "creating grown terrain vertex buffer");
+ TrackedGlResource.AllocateBufferStorage(
+ _gl,
+ BufferTargetARB.ArrayBuffer,
+ newVbo,
+ 0,
newVboBytes,
- GpuBufferUsage.Vertex | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
- GpuMemoryResidency.DeviceLocal));
- newIndices = _device.CreateBuffer(new GpuBufferDescription(
- "terrain-indices",
+ BufferUsageARB.DynamicDraw,
+ "allocating grown terrain vertex buffer");
+ allocatedNewVboBytes = newVboBytes;
+
+ newEbo = TrackedGlResource.CreateBuffer(
+ _gl,
+ "creating grown terrain index buffer");
+ TrackedGlResource.AllocateBufferStorage(
+ _gl,
+ BufferTargetARB.ElementArrayBuffer,
+ newEbo,
+ 0,
newEboBytes,
- GpuBufferUsage.Index | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
- GpuMemoryResidency.DeviceLocal));
+ BufferUsageARB.DynamicDraw,
+ "allocating grown terrain index buffer");
+ allocatedNewEboBytes = newEboBytes;
- // Device-side migration, so arena growth never round-trips the
- // resident landblock meshes through system memory.
- RequireVertexBuffer().CopyTo(newVertices, 0, 0, _globalVboCapacityBytes);
- RequireIndexBuffer().CopyTo(newIndices, 0, 0, _globalEboCapacityBytes);
+ GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers (precondition)");
+ _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
+ _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
+ _gl.CopyBufferSubData(
+ CopyBufferSubDataTarget.CopyReadBuffer,
+ CopyBufferSubDataTarget.CopyWriteBuffer,
+ 0,
+ 0,
+ checked((nuint)_globalVboCapacityBytes));
+ _gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
+ _gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
+ _gl.CopyBufferSubData(
+ CopyBufferSubDataTarget.CopyReadBuffer,
+ CopyBufferSubDataTarget.CopyWriteBuffer,
+ 0,
+ 0,
+ checked((nuint)_globalEboCapacityBytes));
+ GLHelpers.ThrowOnResourceError(_gl, "copying terrain buffers");
- IGpuBuffer oldVertices = RequireVertexBuffer();
- IGpuBuffer oldIndices = RequireIndexBuffer();
+ newVao = TrackedGlResource.CreateVertexArray(
+ _gl,
+ "creating grown terrain VAO");
+ ConfigureVao(newVao, newVbo, newEbo);
- _vertexBuffer = newVertices;
- _indexBuffer = newIndices;
+ uint oldVao = _globalVao;
+ uint oldVbo = _globalVbo;
+ uint oldEbo = _globalEbo;
+ long oldVboBytes = _globalVboCapacityBytes;
+ long oldEboBytes = _globalEboCapacityBytes;
+
+ _globalVao = newVao;
+ _globalVbo = newVbo;
+ _globalEbo = newEbo;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
- // Older submitted draws still reference the former buffers, so the
- // release must outlive them. IGpuBuffer.Dispose is exactly that
- // guarantee — it routes the physical free through the device's
- // retirement queue — which is why the hand-rolled retryable
- // releases this used to build are gone. Retire only after the
- // replacement is valid.
- oldVertices.Dispose();
- oldIndices.Dispose();
+ // Older submitted draws captured the former VAO/buffer bindings.
+ // Retire the complete old set only after the replacement is valid.
+ RetryableGpuResourceRelease oldVaoRelease =
+ TrackedGlResource.CreateRetryableVertexArrayDeletion(
+ _gl,
+ oldVao,
+ "retiring terrain VAO after growth");
+ RetryableGpuResourceRelease oldVboRelease =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ oldVbo,
+ oldVboBytes,
+ "retiring terrain vertex buffer after growth");
+ RetryableGpuResourceRelease oldEboRelease =
+ TrackedGlResource.CreateRetryableBufferDeletion(
+ _gl,
+ oldEbo,
+ oldEboBytes,
+ "retiring terrain index buffer after growth");
+ _retirementLedger.RetireMany(
+ [oldVaoRelease, oldVboRelease, oldEboRelease]);
}
finally
{
if (!published)
{
- newVertices?.Dispose();
- newIndices?.Dispose();
+ TrackedGlResource.DeleteVertexArray(
+ _gl,
+ newVao,
+ "rolling back grown terrain VAO");
+ TrackedGlResource.DeleteBuffer(
+ _gl,
+ newVbo,
+ allocatedNewVboBytes,
+ "rolling back grown terrain vertex buffer");
+ TrackedGlResource.DeleteBuffer(
+ _gl,
+ newEbo,
+ allocatedNewEboBytes,
+ "rolling back grown terrain index buffer");
}
}
}
diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
index 087020ee..4824c96f 100644
--- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
@@ -90,11 +90,7 @@ public sealed class WorldRenderCompositionTests
}
[Theory]
- // 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("terrain shader", "terrain shader")]
[InlineData("scene lighting", "scene lighting")]
[InlineData("debug lines", "debug lines")]
[InlineData("HUD", "text renderer|debug font")]
@@ -251,6 +247,9 @@ public sealed class WorldRenderCompositionTests
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
+ public Shader CreateTerrainShader(GL gl, string shadersDirectory) =>
+ Resource("terrain shader");
+
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource("scene lighting");
@@ -269,8 +268,8 @@ public sealed class WorldRenderCompositionTests
public TerrainModernRenderer CreateTerrain(
GL gl,
- IGpuDevice device,
- ICurrentGpuFrameSource frameSource,
+ BindlessSupport bindless,
+ Shader shader,
TerrainAtlas atlas,
IGpuResourceRetirementQueue retirement) =>
Resource("terrain");
@@ -351,6 +350,8 @@ 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) =>
diff --git a/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
index 38ab0302..e2bec44f 100644
--- a/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
@@ -55,14 +55,7 @@ public sealed class TerrainTextureTilingTableTests
"terrain_modern.frag");
string shader = File.ReadAllText(shaderPath);
- // 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("uniform 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);