From b064668b63621daae9fe286a3568be5a479d123b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 27 Jul 2026 21:31:53 +0200 Subject: [PATCH] feat(render): Campaign V slice V4d-2 - move terrain onto the RHI TerrainModernRenderer records through IGpuPassEncoder instead of calling GL directly. V4d-1 already converged its two matrix uniforms; this is the plumbing. What moved. The per-frame indirect command array became an IGpuFrame.AllocateRing slice, which retires the three-deep per-frame-slot indirect buffer pool outright. That pool existed so a second terrain draw within one frame - a retail outside view can issue several - 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. DynamicIndirectBufferCount now reports 0, which is the truth rather than a silent change. The vertex and index arena became an IGpuBuffer pair. AddLandblock's two BufferSubData calls are Upload, and EnsureCapacity's grow-and-copy is IGpuBuffer.CopyTo, still device-side so resident landblock meshes never round-trip through system memory. The global VAO is gone: the pipeline owns one shaped by the vertex layout, and the encoder re-issues attribute pointers on every BindVertexBuffer. Locations 2-5 use GpuVertexFormat.UByte4UInt, added at c7f5f251 for exactly this. They are uvec4 in the shader and carry terrain-type, road and split-direction codes; UByte4Normalized would have delivered [0,1] floats to an integer input, which GL leaves undefined - garbage, not an approximation. uTextureIndexA/uTextureIndexB became GpuPushConstants.TextureIndexA/B. Slice V2b named those uniforms to match the pinned block, so this was the rename it was meant to be. uTexTiling moved from a loose uniform float[36] into a std140 block at GpuBindingModel.UniformTerrainTiling: at 144 bytes of payload it cannot ride in the 96-byte push-constant block, and no RHI verb sets a uniform array. std140 pads each element to 16 bytes so the block is 576, but the element type is unchanged, so uTexTiling[int(layer)] reads exactly as before. It is a long-lived uniform buffer uploaded on the first draw, preserving the upload-once property the linked-program uniform had. The imperative Enable(CullFace)/CullFace(Back)/FrontFace(Ccw) triple and the inherited depth state are baked into one pipeline. Depth compare is GL_LESS, 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 and depth write on come from the same frame default, each checked against what terrain observes rather than assumed. GL_MULTISAMPLE is untouched by pipeline binds, so MSAA does not leak away from the still-raw-GL sky and particles. Deliberately unmoved. The terrain clip UBO at binding 2 and the SceneLighting UBO at binding 1 stay raw global binds - ClipFrame owns one and the viewport and portal renderers read the other, and both are raw GL until V4h (campaign doc 5.3). The interim GlBindlessHandleTable stays, now held as an IGpuBuffer and bound through the encoder at binding 9; retiring it is V4t. glMemoryBarrier stays a raw call: it has no RHI verb and was already a no-op against client-side uploads. The trailing FrontFace(CW)/Disable(CullFace) restore stays so sky and particles see what they see today. TerrainAtlas is untouched - it belongs to V4t. Terrain has no GPU timer to port; its diagnostics use a CPU stopwatch. Three consequences worth naming rather than leaving to be discovered. The convenience constructor narrowed from public to internal, because IGpuDevice and ICurrentGpuFrameSource are internal RHI types and a public constructor cannot name them. The class stays public, no other member changed visibility, and every caller was already in this assembly - EnvCellRenderer's constructor is internal for the same reason. That is the only visibility change in the diff. Terrain no longer needs a Shader composed for it, since its pipeline compiles terrain_modern from the same sources with the same shared preamble. That removes the terrain-shader composition step, its publication, its lifetime field and the WorldRenderCompositionPoint member. Two data-driven test cases went with it: one InlineData row naming "terrain shader" as a publication to fail, and one case from the theory that enumerates every composition point. App tests therefore read 3,844 rather than the 3,846 baseline. No invariant lost coverage - both theories still exercise every remaining resource and point; the two cases were parameterisations over a step that no longer exists. The renderer's own GpuRetirementLedger is gone. Every resource it held retryable releases for is an IGpuBuffer or IGpuPipeline now, and their Dispose already routes the physical free through the device's retirement queue. Only the fallback clip UBO is still a raw GL name, so it is all the dispose ledger carries. The slot allocator's separate retryable publication path is untouched. Also dropped: a dead BindlessSupport field, assigned and never read. Gates. Release build green with TreatWarningsAsErrors. App tests 3,844 passed / 3 skipped over four consecutive runs. Offline pixel gate against 0cb10597: 20 differing pixels of 563,200 (fraction 3.55e-05, 28x under the threshold), against a same-commit control at this commit of 26 - the change differs from its parent by LESS than the capture differs from itself, which is as close to proof of no systematic shift as this gate can give. Compared against all three V4d-1 captures the numbers are 20, 32 and 34, against a same-commit V4d-1 spread of 8, 27 and 28: the same distribution. The gate run's client log has zero exceptions and an empty stderr. Coverage gap, stated rather than assumed: the offline gate's scene is a fixed outdoor view. It exercises terrain heavily - terrain blending, road overlays and the water edge are most of the frame - but it does not cover terrain seen through a doorway clip region, which is the one terrain path with its own binding (the clip UBO at binding 2). That wants a user visual check. No divergence-register row: this slice changes no retail-facing behaviour. Co-Authored-By: Claude Fable 5 --- .../Composition/WorldRenderComposition.cs | 35 +- src/AcDream.App/Rendering/GameWindow.cs | 10 - .../Rendering/GameWindowLifetime.cs | 2 - .../Rendering/Shaders/terrain_modern.frag | 19 +- .../Rendering/TerrainModernRenderer.cs | 953 +++++++++--------- .../WorldRenderCompositionTests.cs | 15 +- .../TerrainTextureTilingTableTests.cs | 9 +- 7 files changed, 493 insertions(+), 550 deletions(-) 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);