using System.Numerics; using AcDream.App.Rendering.Wb; using AcDream.Core.Terrain; 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. /// /// Total ~6-8 GL calls per frame for terrain regardless of visible /// landblock count. /// public sealed unsafe class TerrainModernRenderer : IDisposable { // VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses // `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and // because we bake `slot * VertsPerLandblock` into indices CPU-side and // pass BaseVertex=0 to MultiDrawElementsIndirect, gl_VertexID becomes // `slot * VertsPerLandblock + local_index`. The shader's modulo-6 only // reduces to `local_index % 6` because 384 is a multiple of 6. Changing // either constant without auditing the shader will silently mis-render. private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 (= 64 cells * 6 verts) private const int IndicesPerLandblock = VertsPerLandblock; private const int VertexSize = 40; // sizeof(TerrainVertex) private const int IndexSize = sizeof(uint); private const float LandblockSize = LandblockMesh.LandblockSize; // 192 private readonly GL _gl; private readonly BindlessSupport _bindless; private readonly Shader _shader; private readonly TerrainAtlas _atlas; /// 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; private RetryableResourceReleaseLedger? _disposeResources; private bool _disposed; // Per-slot live data (index by slot integer; null entries are unused slots). private SlotData?[] _slots; // Reverse map: landblockId -> slot, for RemoveLandblock and replacement. private readonly Dictionary _idToSlot = new(); // 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; 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 // handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback // (count 0 = ungated) so the shader never reads an unbound UBO at binding=2. private TerrainClipBufferBinding _sharedClipBinding; private uint _fallbackClipUbo; // Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4). private int _uTerrainHandleLoc; private int _uAlphaHandleLoc; private int _uTexTilingLoc; private bool _textureTilingUploaded; // Reusable per-frame buffers. private readonly List _visibleSlots = new(); private readonly HashSet _visibleCellIds = new(); private DrawElementsIndirectCommand[] _deicScratch = Array.Empty(); // Diag. public int LoadedSlots => _alloc.LoadedCount; public int VisibleSlots => _visibleSlots.Count; public int CapacitySlots => _alloc.Capacity; /// /// Outdoor landcells admitted by the current landscape view. The set is /// accumulated across doorway landscape slices and consumed after the /// completed render frame by particle visibility. /// internal HashSet VisibleCellIds => _visibleCellIds; public void BeginVisibilityFrame() => _visibleCellIds.Clear(); public TerrainModernRenderer( GL gl, BindlessSupport bindless, Shader shader, TerrainAtlas atlas, int initialSlotCapacity = 64) : this( gl, bindless, shader, atlas, ImmediateGpuResourceRetirementQueue.Instance, initialSlotCapacity) { } internal TerrainModernRenderer( GL gl, BindlessSupport bindless, Shader shader, TerrainAtlas atlas, IGpuResourceRetirementQueue resourceRetirement, int initialSlotCapacity = 64) { _gl = gl; _bindless = bindless; _shader = shader; _atlas = atlas; ArgumentNullException.ThrowIfNull(resourceRetirement); _retirementLedger = new GpuRetirementLedger(resourceRetirement); _alloc = new GpuRetiredTerrainSlotAllocator(initialSlotCapacity, resourceRetirement); _slots = new SlotData?[initialSlotCapacity]; _uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle"); _uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle"); _uTexTilingLoc = _gl.GetUniformLocation(_shader.Program, "uTexTiling[0]"); if (_uTexTilingLoc < 0) throw new InvalidOperationException("terrain_modern.frag is missing the required uTexTiling uniform."); try { _globalVao = TrackedGlResource.CreateVertexArray( _gl, "creating terrain global VAO"); _globalVbo = TrackedGlResource.CreateBuffer( _gl, "creating terrain global vertex buffer"); _globalEbo = TrackedGlResource.CreateBuffer( _gl, "creating terrain global index buffer"); AllocateGpuBuffers(initialSlotCapacity); ConfigureVao(_globalVao, _globalVbo, _globalEbo); } catch { TrackedGlResource.DeleteVertexArray( _gl, _globalVao, "rolling back terrain global VAO"); TrackedGlResource.DeleteBuffer( _gl, _globalVbo, _globalVboCapacityBytes, "rolling back terrain global vertex buffer"); TrackedGlResource.DeleteBuffer( _gl, _globalEbo, _globalEboCapacityBytes, "rolling back terrain global index buffer"); _globalVao = 0; _globalVbo = 0; _globalEbo = 0; _globalVboCapacityBytes = 0; _globalEboCapacityBytes = 0; throw; } } /// /// 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; /// each draw receives storage that cannot overwrite an earlier command. /// public void BeginFrame(int frameSlot) { if ((uint)frameSlot >= (uint)_indirectBuffersByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); _retirementLedger.RetryPendingPublications(); _dynamicFrameSlot = frameSlot; _dynamicBufferCursor = 0; _dynamicFrameStarted = true; } private void ActivateNextIndirectBuffer() { 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; } /// /// Hand the renderer the current aligned terrain-clip range (binding=2). /// Each outside-view slice occupies a distinct range in the current /// GPU-fenced frame's UBO arena. /// public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) => _sharedClipBinding = sharedClipBinding; /// /// Two-tier streaming entry point. Accepts a prebuilt mesh from /// built on the worker /// thread, together with the world-space origin computed by the caller /// (render-thread GameWindow derives it from landblockId + liveCenterX/Y). /// /// Delegates to /// so both paths share one upload path. Per Phase A.5 spec T15. /// public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) => AddLandblock(landblockId, meshData, worldOrigin); public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin) { ArgumentNullException.ThrowIfNull(meshData); if (meshData.Vertices.Length != VertsPerLandblock) throw new ArgumentException( $"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}", nameof(meshData)); if (meshData.Indices.Length != IndicesPerLandblock) throw new ArgumentException( $"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}", nameof(meshData)); // A prior replacement may have committed the logical slot switch // before queue publication failed. Retry those retained physical-slot // transactions before allocating more terrain storage. _alloc.RetryPendingPublications(); bool replacing = _idToSlot.TryGetValue(landblockId, out int replacedSlot); int slot = _alloc.Allocate(out var needsGrow); bool published = false; try { if (needsGrow) { int newCap = Math.Max(_alloc.Capacity * 2, slot + 1); EnsureCapacity(newCap); } // Bake worldOrigin into vertex positions; capture min/max Z for AABB. var bakedVerts = new TerrainVertex[VertsPerLandblock]; float zMin = float.MaxValue, zMax = float.MinValue; for (int i = 0; i < VertsPerLandblock; i++) { var v = meshData.Vertices[i]; var worldPos = v.Position + worldOrigin; bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3); if (worldPos.Z < zMin) zMin = worldPos.Z; if (worldPos.Z > zMax) zMax = worldPos.Z; } if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } // Bake baseVertex into indices on the CPU side (driver-portable pattern). uint baseVertex = (uint)(slot * VertsPerLandblock); var bakedIndices = new uint[IndicesPerLandblock]; 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); 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 { LandblockId = landblockId, WorldOrigin = worldOrigin, FirstIndex = (uint)(slot * IndicesPerLandblock), IndexCount = IndicesPerLandblock, AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin), AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax), }; _idToSlot[landblockId] = slot; published = true; if (replacing) { _slots[replacedSlot] = null; _alloc.FreeAfterGpuUse(replacedSlot); } } finally { if (!published) _alloc.ReleaseUnsubmitted(slot); } } public void RemoveLandblock(uint landblockId) { // Removal clears the logical lookup before retirement publication. A // retry therefore has to advance retained publications even when the // landblock is no longer present in the map. _alloc.RetryPendingPublications(); if (!_idToSlot.TryGetValue(landblockId, out var slot)) return; _idToSlot.Remove(landblockId); _slots[slot] = null; _alloc.FreeAfterGpuUse(slot); // No GPU clear: the per-frame DEIC array won't reference this slot. } public void Draw( ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, ReadOnlySpan clipPlanes = default, Vector4? ndcClipAabb = null) { if (_alloc.LoadedCount == 0) return; Matrix4x4 viewProjection = camera.View * camera.Projection; // Build visible slot list with per-slot frustum cull. _visibleSlots.Clear(); for (int slot = 0; slot < _slots.Length; slot++) { var data = _slots[slot]; if (data is null) continue; if (frustum is not null && data.LandblockId != neverCullLandblockId) { if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax)) continue; } _visibleSlots.Add(slot); CollectVisibleCells( _visibleCellIds, data.LandblockId, data.WorldOrigin, data.AabbMin.Z, data.AabbMax.Z, frustum, viewProjection, clipPlanes, ndcClipAabb); } if (_visibleSlots.Count == 0) return; ActivateNextIndirectBuffer(); // Build DEIC array. if (_deicScratch.Length < _visibleSlots.Count) _deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)]; for (int i = 0; i < _visibleSlots.Count; i++) { var data = _slots[_visibleSlots[i]]!; _deicScratch[i] = new DrawElementsIndirectCommand { Count = (uint)data.IndexCount, InstanceCount = 1u, FirstIndex = data.FirstIndex, BaseVertex = 0, // baked into indices on upload BaseInstance = 0, }; } // 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(); _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles(); // Pass each 64-bit handle as a uvec2 (low 32 bits, high 32 bits). // GLSL constructs sampler2DArray(uTerrainHandle) at the use site — // see terrain_modern.frag for why this is the safe pattern. _gl.ProgramUniform2(_shader.Program, _uTerrainHandleLoc, (uint)(terrainHandle & 0xFFFFFFFFu), (uint)(terrainHandle >> 32)); _gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc, (uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32)); // Phase U.3: bind the terrain clip UBO (binding=2). Shared ClipFrame UBO // when wired, else the no-clip fallback (count 0 = ungated terrain). 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); _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); _gl.FrontFace(FrontFaceDirection.CW); _gl.Disable(EnableCap.CullFace); } public void Dispose() { if (_disposed) return; _retirementLedger.RetryPendingPublications(); 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 = TrackedGlResource.CreateRetryableBufferDeletion( _gl, _fallbackClipUbo, ClipFrame.TerrainUboBytes, "deleting terrain fallback clip UBO"); releases.Add(("fallback-clip-ubo", release.Run)); } _disposeResources = new RetryableResourceReleaseLedger(releases); } ResourceReleaseAttempt attempt = _disposeResources.Advance(); if (!_disposeResources.IsComplete) { throw attempt.ToException( "One or more terrain GPU resources could not be released."); } _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; _disposeResources = null; _disposed = true; if (attempt.HasFailures) { throw attempt.ToException( "Terrain GPU resources released with exceptional committed outcomes."); } } // ---------------------------------------------------------------- // Private helpers // ---------------------------------------------------------------- /// /// 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). /// 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 UploadTextureTilingOnce() { if (_textureTilingUploaded) return; 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]; _gl.Uniform1(_uTexTilingLoc, values); _textureTilingUploaded = true; } /// /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared /// UBO range (); otherwise lazily /// creates + binds a no-clip fallback (count 0 = ungated) so the shader never /// reads an unbound UBO. The fallback is std140-sized to /// and zero-filled (count 0). /// private void BindClipUboBinding2() { if (_sharedClipBinding.IsValid) { _sharedClipBinding.Bind(_gl); return; } if (_fallbackClipUbo == 0) { var zero = stackalloc byte[ClipFrame.TerrainUboBytes]; for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0; uint fallback = TrackedGlResource.CreateBuffer( _gl, "creating terrain fallback clip UBO"); try { TrackedGlResource.AllocateBufferStorage( _gl, BufferTargetARB.UniformBuffer, fallback, 0, ClipFrame.TerrainUboBytes, BufferUsageARB.DynamicDraw, zero, "allocating terrain fallback clip UBO"); _fallbackClipUbo = fallback; } catch { TrackedGlResource.DeleteBuffer( _gl, fallback, 0, "rolling back terrain fallback clip UBO"); throw; } } _gl.BindBufferBase(BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, _fallbackClipUbo); } 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, vboBytes, BufferUsageARB.DynamicDraw, "allocating terrain global vertex storage"); _globalVboCapacityBytes = vboBytes; TrackedGlResource.AllocateBufferStorage( _gl, BufferTargetARB.ElementArrayBuffer, _globalEbo, _globalEboCapacityBytes, eboBytes, 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, Vector3 worldOrigin, float zMin, float zMax, FrustumPlanes? frustum, Matrix4x4 viewProjection, ReadOnlySpan clipPlanes, Vector4? ndcClipAabb = null) { ArgumentNullException.ThrowIfNull(destination); const float cellSize = AcDream.Core.Physics.TerrainSurface.CellSize; const int cellsPerSide = AcDream.Core.Physics.TerrainSurface.CellsPerSide; uint prefix = landblockId & 0xFFFF0000u; for (int cellX = 0; cellX < cellsPerSide; cellX++) { float minX = worldOrigin.X + cellX * cellSize; float maxX = minX + cellSize; for (int cellY = 0; cellY < cellsPerSide; cellY++) { float minY = worldOrigin.Y + cellY * cellSize; float maxY = minY + cellSize; var cellMin = new Vector3(minX, minY, zMin); var cellMax = new Vector3(maxX, maxY, zMax); if (frustum is not null && !FrustumCuller.IsAabbVisible(frustum.Value, cellMin, cellMax)) { continue; } // Retail publishes landcell in_view from the clipped landscape // view, not merely from the camera frustum. The modern renderer // expresses each doorway slice as homogeneous clip-space planes // plus its scissor AABB; use both products here so particle // simulation follows the same visible terrain slice as the GPU. if (!IsAabbVisibleThroughClipRegion( cellMin, cellMax, viewProjection, clipPlanes, ndcClipAabb)) { continue; } uint low = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellLowId( cellX * cellSize, cellY * cellSize); destination.Add(prefix | low); } } } private static bool IsAabbVisibleThroughClipRegion( Vector3 min, Vector3 max, Matrix4x4 viewProjection, ReadOnlySpan clipPlanes, Vector4? ndcClipAabb) { Vector4 aabb = ndcClipAabb.GetValueOrDefault(); bool hasScissorConstraint = ndcClipAabb.HasValue && (aabb.X > -1f || aabb.Y > -1f || aabb.Z < 1f || aabb.W < 1f); if (clipPlanes.IsEmpty && !hasScissorConstraint) return true; Span clipCorners = stackalloc Vector4[8]; for (int corner = 0; corner < clipCorners.Length; corner++) { var world = new Vector4( (corner & 1) == 0 ? min.X : max.X, (corner & 2) == 0 ? min.Y : max.Y, (corner & 4) == 0 ? min.Z : max.Z, 1f); clipCorners[corner] = Vector4.Transform(world, viewProjection); } for (int planeIndex = 0; planeIndex < clipPlanes.Length; planeIndex++) { if (IsAabbOutsideHomogeneousPlane(clipCorners, clipPlanes[planeIndex])) { return false; } } if (!hasScissorConstraint) return true; Span scissorPlanes = stackalloc Vector4[4] { new( 1f, 0f, 0f, -aabb.X), new(-1f, 0f, 0f, aabb.Z), new( 0f, 1f, 0f, -aabb.Y), new( 0f, -1f, 0f, aabb.W), }; for (int planeIndex = 0; planeIndex < scissorPlanes.Length; planeIndex++) { if (IsAabbOutsideHomogeneousPlane(clipCorners, scissorPlanes[planeIndex])) { return false; } } return true; } private static bool IsAabbOutsideHomogeneousPlane( ReadOnlySpan clipCorners, Vector4 plane) { // A linear half-space reaches its maximum over the transformed AABB at // one of the eight corners. If every corner is negative, no point in // the cell box can survive this GPU clip plane. for (int corner = 0; corner < clipCorners.Length; corner++) { if (Vector4.Dot(plane, clipCorners[corner]) >= 0f) return false; } return true; } private void EnsureCapacity(int newCapacity) { if (newCapacity <= _alloc.Capacity) return; var grownSlots = new SlotData?[newCapacity]; Array.Copy(_slots, grownSlots, _slots.Length); 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; bool published = false; try { newVbo = TrackedGlResource.CreateBuffer( _gl, "creating grown terrain vertex buffer"); TrackedGlResource.AllocateBufferStorage( _gl, BufferTargetARB.ArrayBuffer, newVbo, 0, 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, newEboBytes, BufferUsageARB.DynamicDraw, "allocating grown terrain index buffer"); allocatedNewEboBytes = newEboBytes; 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"); newVao = TrackedGlResource.CreateVertexArray( _gl, "creating grown terrain VAO"); ConfigureVao(newVao, newVbo, newEbo); 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 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) { 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"); } } } private sealed class SlotData { public uint LandblockId; public Vector3 WorldOrigin; public uint FirstIndex; public int IndexCount; public Vector3 AabbMin; public Vector3 AabbMax; } }