diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs
index cb2604f2..94850718 100644
--- a/src/AcDream.App/Composition/WorldRenderComposition.cs
+++ b/src/AcDream.App/Composition/WorldRenderComposition.cs
@@ -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,
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index e16fa047..7c1de8e2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -48,9 +48,6 @@ 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;
@@ -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,
diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs
index 2fc22eb9..4129f99d 100644
--- a/src/AcDream.App/Rendering/GameWindowLifetime.cs
+++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs
@@ -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()),
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
index d1ceb477..ef0536c3 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
+++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.frag
@@ -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;
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
index 6f5bb8c3..a9658192 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
@@ -7,13 +7,22 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
///
-/// 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.
+/// 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.
///
-/// Total ~6-8 GL calls per frame for terrain regardless of visible
+/// 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
/// landblock count.
///
public sealed unsafe class TerrainModernRenderer : IDisposable
@@ -31,17 +40,25 @@ 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 BindlessSupport _bindless;
- private readonly Shader _shader;
+ private readonly IGpuDevice _device;
+ private readonly ICurrentGpuFrameSource _frameSource;
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;
- private readonly GpuRetirementLedger _retirementLedger;
+ // 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 RetryableResourceReleaseLedger? _disposeResources;
private bool _disposed;
@@ -51,29 +68,25 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary _idToSlot = new();
- // GPU buffers.
- private uint _globalVao;
- private uint _globalVbo;
- private uint _globalEbo;
+ // 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;
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;
- internal int DynamicIndirectBufferCount =>
- _indirectBuffersByFrame.Sum(frameBuffers => frameBuffers.Count);
+ ///
+ /// 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;
// Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
// The shared one is created + uploaded by the GameWindow-level ClipFrame and
@@ -82,21 +95,28 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private TerrainClipBufferBinding _sharedClipBinding;
private uint _fallbackClipUbo;
- // 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;
+ // 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;
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 uint _textureTableSsbo;
- private int _textureTableSsboCapacityBytes;
+ private IGpuBuffer? _textureTableBuffer;
+ private int _textureTableBufferBytes;
// Reusable per-frame buffers.
private readonly List _visibleSlots = new();
@@ -116,16 +136,47 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
- public TerrainModernRenderer(
+ ///
+ /// 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(
GL gl,
- BindlessSupport bindless,
- Shader shader,
+ IGpuDevice device,
+ ICurrentGpuFrameSource frameSource,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
: this(
gl,
- bindless,
- shader,
+ device,
+ frameSource,
atlas,
ImmediateGpuResourceRetirementQueue.Instance,
initialSlotCapacity)
@@ -134,96 +185,81 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
internal TerrainModernRenderer(
GL gl,
- BindlessSupport bindless,
- Shader shader,
+ IGpuDevice device,
+ ICurrentGpuFrameSource frameSource,
TerrainAtlas atlas,
IGpuResourceRetirementQueue resourceRetirement,
int initialSlotCapacity = 64)
{
_gl = gl;
- _bindless = bindless;
- _shader = shader;
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _frameSource = frameSource ?? throw new ArgumentNullException(nameof(frameSource));
_atlas = atlas;
ArgumentNullException.ThrowIfNull(resourceRetirement);
- _retirementLedger = new GpuRetirementLedger(resourceRetirement);
_alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement);
_slots = new SlotData?[initialSlotCapacity];
- _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();
+ // 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.
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");
- 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);
+ _pipeline = CreateTerrainPipeline(_device);
AllocateGpuBuffers(initialSlotCapacity);
- GlResourceCommand.Execute(
- _gl,
- "configure terrain global vertex array",
- () => ConfigureVao(_globalVao, _globalVbo, _globalEbo));
- constructionResources.TransferAll();
}
- catch (Exception constructionFailure)
+ catch
{
- constructionResources.RollbackConstructionAndThrow(
- "TerrainModernRenderer construction failed and its GL prefix did not cleanly roll back.",
- constructionFailure);
+ _pipeline?.Dispose();
+ _pipeline = null;
+ _vertexBuffer?.Dispose();
+ _vertexBuffer = null;
+ _indexBuffer?.Dispose();
+ _indexBuffer = null;
+ throw;
}
-
}
+ ///
+ /// 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
+ {
+ 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,
+ });
+
///
/// Resets the indirect-command submission cursor for a GPU-fenced frame
/// slot. A retail outside view may draw terrain more than once in a frame;
@@ -231,49 +267,32 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
///
public void BeginFrame(int frameSlot)
{
- if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length)
+ if ((uint)frameSlot >= (uint)FrameSlotCount)
throw new ArgumentOutOfRangeException(nameof(frameSlot));
- _retirementLedger.RetryPendingPublications();
- _dynamicFrameSlot = frameSlot;
- _dynamicBufferCursor = 0;
+ // 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.
_dynamicFrameStarted = true;
}
- private void ActivateNextIndirectBuffer()
+ ///
+ /// Frames in flight, retained purely so keeps
+ /// validating the same slot range it always did.
+ ///
+ private const int FrameSlotCount = 3;
+
+ private IGpuFrame RequireFrame()
{
+ // 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.");
- 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;
+ return _frameSource.CurrentFrame
+ ?? throw new InvalidOperationException(
+ "TerrainModernRenderer requires an open IGpuFrame (see GpuDeviceFrameLifetime) — "
+ + "the host must drive IGpuDevice.BeginFrame() before drawing terrain.");
}
///
@@ -343,33 +362,18 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
- // glBufferSubData into the slot's VBO + EBO regions.
- nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
- nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
+ // 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;
- 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}");
- }
+ RequireVertexBuffer().Upload(
+ vboByteOffset,
+ System.Runtime.InteropServices.MemoryMarshal.AsBytes(bakedVerts));
+ RequireIndexBuffer().Upload(
+ eboByteOffset,
+ System.Runtime.InteropServices.MemoryMarshal.AsBytes(bakedIndices));
_slots[slot] = new SlotData
{
@@ -445,7 +449,6 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
ndcClipAabb);
}
if (_visibleSlots.Count == 0) return;
- ActivateNextIndirectBuffer();
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
@@ -463,153 +466,136 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
};
}
- // 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
+ // Campaign V slice V2b: pass each atlas 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();
- // #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);
+ 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,
+ };
- _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);
+ // #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);
+ // 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 =
@@ -620,16 +606,6 @@ 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);
}
@@ -640,19 +616,23 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
"One or more terrain GPU resources could not be released.");
}
- _globalVao = 0;
- _globalVbo = 0;
- _globalEbo = 0;
+ _vertexBuffer?.Dispose();
+ _vertexBuffer = null;
+ _indexBuffer?.Dispose();
+ _indexBuffer = null;
+ _textureTableBuffer?.Dispose();
+ _textureTableBuffer = null;
+ _tilingBuffer?.Dispose();
+ _tilingBuffer = null;
+ _pipeline?.Dispose();
+ _pipeline = null;
+
_globalVboCapacityBytes = 0;
_globalEboCapacityBytes = 0;
- foreach (List frameBuffers in _indirectBuffersByFrame)
- frameBuffers.Clear();
- _indirectBuffer = 0;
- _indirectCapacity = 0;
_dynamicFrameStarted = false;
_fallbackClipUbo = 0;
- _textureTableSsbo = 0;
- _textureTableSsboCapacityBytes = 0;
+ _textureTableBufferBytes = 0;
+ _textureTilingUploaded = false;
_disposeResources = null;
_disposed = true;
@@ -668,73 +648,136 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// ----------------------------------------------------------------
///
- /// Upload the texture-array adapter for retail's per-surface repeat count.
+ /// 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 .
/// Retail passes TerrainTex::tex_tiling directly to
/// ImgTex::TileCSI / ImgTex::MergeTexture
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
- /// Uniform values persist for the lifetime of this linked shader program,
- /// so the immutable atlas table is uploaded on its first bound draw.
+ ///
+ /// 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.
///
- private void UploadTextureTilingOnce()
+ private void BindTextureTiling(IGpuPassEncoder encoder)
{
- if (_textureTilingUploaded)
- return;
-
- if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
+ if (_tilingBuffer is null)
{
- throw new InvalidOperationException(
- $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
- $"expected {TerrainTextureTilingTable.LayerCapacity}.");
+ _tilingBuffer = _device.CreateBuffer(new GpuBufferDescription(
+ "terrain-tiling",
+ TilingBlockBytes,
+ GpuBufferUsage.Uniform,
+ GpuMemoryResidency.DeviceLocal));
}
- Span values = stackalloc float[TerrainTextureTilingTable.LayerCapacity];
- for (int i = 0; i < values.Length; i++)
- values[i] = _atlas.TilingByLayer[i];
+ if (!_textureTilingUploaded)
+ {
+ if (_atlas.TilingByLayer.Count != TerrainTextureTilingTable.LayerCapacity)
+ {
+ throw new InvalidOperationException(
+ $"Terrain tiling table has {_atlas.TilingByLayer.Count} entries; " +
+ $"expected {TerrainTextureTilingTable.LayerCapacity}.");
+ }
- _gl.Uniform1(_uTexTilingLoc, values);
- _textureTilingUploaded = true;
+ 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);
}
///
- /// 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.
+ /// 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.
///
- private unsafe void FlushAndBindTextureTable()
+ private void BindTextureTable(IGpuPassEncoder encoder)
{
- if (_textureTable.Dirty)
+ ReadOnlySpan handles = _textureTable.Handles;
+ int byteCount = Math.Max(handles.Length * sizeof(ulong), sizeof(ulong));
+
+ bool reallocated = false;
+ if (_textureTableBuffer is null || _textureTableBufferBytes < byteCount)
{
- 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);
- }
+ _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));
_textureTable.MarkFlushed();
}
- _gl.BindBufferBase(
- GLEnum.ShaderStorageBuffer,
+
+ encoder.BindStorageBuffer(
GpuBindingModel.StorageTextureTable,
- _textureTableSsbo);
+ _textureTableBuffer,
+ 0,
+ (uint)byteCount);
}
+ ///
+ /// 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
@@ -784,61 +827,37 @@ 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);
- TrackedGlResource.AllocateBufferStorage(
- _gl,
- BufferTargetARB.ArrayBuffer,
- _globalVbo,
- _globalVboCapacityBytes,
+ _vertexBuffer = _device.CreateBuffer(new GpuBufferDescription(
+ "terrain-vertices",
vboBytes,
- BufferUsageARB.DynamicDraw,
- "allocating terrain global vertex storage");
+ GpuBufferUsage.Vertex | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
_globalVboCapacityBytes = vboBytes;
- TrackedGlResource.AllocateBufferStorage(
- _gl,
- BufferTargetARB.ElementArrayBuffer,
- _globalEbo,
- _globalEboCapacityBytes,
+ _indexBuffer = _device.CreateBuffer(new GpuBufferDescription(
+ "terrain-indices",
eboBytes,
- BufferUsageARB.DynamicDraw,
- "allocating terrain global index storage");
+ GpuBufferUsage.Index | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
_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,
@@ -973,119 +992,53 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
long newVboBytes = checked((long)newCapacity * VertsPerLandblock * VertexSize);
long newEboBytes = checked((long)newCapacity * IndicesPerLandblock * IndexSize);
- uint newVbo = 0;
- uint newEbo = 0;
- uint newVao = 0;
- long allocatedNewVboBytes = 0;
- long allocatedNewEboBytes = 0;
+ IGpuBuffer? newVertices = null;
+ IGpuBuffer? newIndices = null;
bool published = false;
try
{
- newVbo = TrackedGlResource.CreateBuffer(
- _gl,
- "creating grown terrain vertex buffer");
- TrackedGlResource.AllocateBufferStorage(
- _gl,
- BufferTargetARB.ArrayBuffer,
- newVbo,
- 0,
+ newVertices = _device.CreateBuffer(new GpuBufferDescription(
+ "terrain-vertices",
newVboBytes,
- 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,
+ GpuBufferUsage.Vertex | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
+ newIndices = _device.CreateBuffer(new GpuBufferDescription(
+ "terrain-indices",
newEboBytes,
- BufferUsageARB.DynamicDraw,
- "allocating grown terrain index buffer");
- allocatedNewEboBytes = newEboBytes;
+ GpuBufferUsage.Index | GpuBufferUsage.TransferSource | GpuBufferUsage.TransferDestination,
+ GpuMemoryResidency.DeviceLocal));
- 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");
+ // 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);
- newVao = TrackedGlResource.CreateVertexArray(
- _gl,
- "creating grown terrain VAO");
- ConfigureVao(newVao, newVbo, newEbo);
+ IGpuBuffer oldVertices = RequireVertexBuffer();
+ IGpuBuffer oldIndices = RequireIndexBuffer();
- uint oldVao = _globalVao;
- uint oldVbo = _globalVbo;
- uint oldEbo = _globalEbo;
- long oldVboBytes = _globalVboCapacityBytes;
- long oldEboBytes = _globalEboCapacityBytes;
-
- _globalVao = newVao;
- _globalVbo = newVbo;
- _globalEbo = newEbo;
+ _vertexBuffer = newVertices;
+ _indexBuffer = newIndices;
_globalVboCapacityBytes = newVboBytes;
_globalEboCapacityBytes = newEboBytes;
_slots = grownSlots;
_alloc.GrowTo(newCapacity);
published = true;
- // 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]);
+ // 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();
}
finally
{
if (!published)
{
- 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");
+ newVertices?.Dispose();
+ newIndices?.Dispose();
}
}
}
diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
index 4824c96f..087020ee 100644
--- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
@@ -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("terrain shader");
-
public SceneLightingUboBinding CreateSceneLighting(GL gl) =>
Resource("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("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) =>
diff --git a/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
index e2bec44f..38ab0302 100644
--- a/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/TerrainTextureTilingTableTests.cs
@@ -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);