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);