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