The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.
What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.
The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.
Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.
That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.
Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.
Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.
Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.
The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.
Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.
What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.
Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
847f14ae: 5.50e-05, 31 differing pixels of 563,200, inside the documented 9-31
band and 18x under the threshold. Characterised rather than accepted, because 31
is the band's top: cross-commit pairs measured 21, 29 and 31 while same-commit
controls measured 12 and 20, and maximumChannelDelta is 46-52 in every comparison
INCLUDING the pure controls - so the few large-delta pixels are a property of the
capture, and a cross-commit pair at 21 against a same-commit pair at 20 is not
what a systematic shift looks like. GL connected repeat gate at 3 runs: 3/3
RENDERED on the desktop witness and 3/3 on the client capture. One offline Vulkan
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
validation errors, zero warnings, a captured world frame, and a graceful close.
Coverage gap, stated rather than assumed. The offline scene is a fixed outdoor
view, so EnvCellRenderer's Vulkan arm draws nothing in it - dungeon interiors are
half of this slice and are unproven by anything automated, exactly as they were
for V4c. The deferred-alpha path and the doorway scissor are likewise untouched
by this scene. They join the accumulated user-gate debt in plan section 5.1.
No divergence-register row: no retail-facing behaviour changes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1178 lines
47 KiB
C#
1178 lines
47 KiB
C#
using System.Numerics;
|
||
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Rendering.Gpu.Gl;
|
||
using AcDream.App.Rendering.Wb;
|
||
using AcDream.Core.Terrain;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public sealed unsafe partial 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
|
||
|
||
// Campaign V slice V6j: null on the RHI arm, where every statement below that
|
||
// reaches one of these is forked into TerrainModernRenderer.Rhi.cs. The GL arm
|
||
// executes exactly what it did before.
|
||
private readonly GL? _gl;
|
||
private readonly BindlessSupport? _bindless;
|
||
private readonly Shader? _shader;
|
||
private readonly TerrainAtlas _atlas;
|
||
|
||
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
|
||
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
|
||
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<uint, int> _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<DynamicIndirectBuffer>[] _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;
|
||
|
||
// 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 bool _textureTilingUploaded;
|
||
|
||
// Campaign V slice V6f-2: the 36 per-layer tiling factors used to be a loose
|
||
// `uniform float uTexTiling[36]`, which Vulkan GLSL cannot declare at all.
|
||
// They now live in the uniform buffer GpuBindingModel reserved
|
||
// UniformTerrainTiling (binding 3) for — see terrain_modern.frag for the
|
||
// std140 packing and why it is vec4[9] rather than float[36].
|
||
private uint _tilingUbo;
|
||
|
||
// Campaign V slice V4t: the interim per-renderer GlBindlessHandleTable is
|
||
// retired. TerrainAtlas hands out GpuTextureSlots from the device's one
|
||
// table and this renderer flushes and binds that table at
|
||
// GpuBindingModel.StorageTextureTable itself, because it still submits
|
||
// through raw GL and so never reaches GlGpuDevice.FlushBeforeDraw. Null on
|
||
// a backend with no GL device, where this renderer is never constructed.
|
||
private readonly GlGpuDevice? _gpuDevice;
|
||
|
||
// Reusable per-frame buffers.
|
||
private readonly List<int> _visibleSlots = new();
|
||
private readonly HashSet<uint> _visibleCellIds = new();
|
||
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
|
||
|
||
// Diag.
|
||
public int LoadedSlots => _alloc.LoadedCount;
|
||
public int VisibleSlots => _visibleSlots.Count;
|
||
public int CapacitySlots => _alloc.Capacity;
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
internal HashSet<uint> VisibleCellIds => _visibleCellIds;
|
||
|
||
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
|
||
|
||
internal TerrainModernRenderer(
|
||
GL gl,
|
||
BindlessSupport bindless,
|
||
Shader shader,
|
||
TerrainAtlas atlas,
|
||
GlGpuDevice? gpuDevice,
|
||
int initialSlotCapacity = 64)
|
||
: this(
|
||
gl,
|
||
bindless,
|
||
shader,
|
||
atlas,
|
||
gpuDevice,
|
||
ImmediateGpuResourceRetirementQueue.Instance,
|
||
initialSlotCapacity)
|
||
{
|
||
}
|
||
|
||
internal TerrainModernRenderer(
|
||
GL gl,
|
||
BindlessSupport bindless,
|
||
Shader shader,
|
||
TerrainAtlas atlas,
|
||
GlGpuDevice? gpuDevice,
|
||
IGpuResourceRetirementQueue resourceRetirement,
|
||
int initialSlotCapacity = 64)
|
||
{
|
||
_gl = gl;
|
||
_bindless = bindless;
|
||
_shader = shader;
|
||
_atlas = atlas;
|
||
_gpuDevice = gpuDevice;
|
||
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");
|
||
|
||
var constructionResources = new ResourceCleanupGroup();
|
||
try
|
||
{
|
||
// Campaign V slice V6f-2: the tiling UBO. Fixed size — the table is
|
||
// 36 immutable floats packed four to a vec4 — so it is allocated
|
||
// once here and written once on the first bound draw, which is the
|
||
// same cadence the glUniform1fv it replaces already had.
|
||
_tilingUbo = TrackedGlResource.CreateBuffer(
|
||
_gl,
|
||
"creating terrain tiling UBO");
|
||
RetryableGpuResourceRelease tilingUboRelease =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl,
|
||
_tilingUbo,
|
||
TerrainTextureTilingTable.UniformBufferBytes,
|
||
"rolling back terrain tiling UBO");
|
||
constructionResources.Add("terrain tiling UBO", tilingUboRelease.Run);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
BufferTargetARB.UniformBuffer,
|
||
_tilingUbo,
|
||
0,
|
||
TerrainTextureTilingTable.UniformBufferBytes,
|
||
BufferUsageARB.StaticDraw,
|
||
"allocating terrain tiling UBO");
|
||
|
||
_globalVao = TrackedGlResource.CreateVertexArray(
|
||
_gl,
|
||
"creating terrain global VAO");
|
||
RetryableGpuResourceRelease globalVaoRelease =
|
||
TrackedGlResource.CreateRetryableVertexArrayDeletion(
|
||
_gl,
|
||
_globalVao,
|
||
"rolling back terrain global VAO");
|
||
constructionResources.Add(
|
||
"terrain global VAO",
|
||
globalVaoRelease.Run);
|
||
_globalVbo = TrackedGlResource.CreateBuffer(
|
||
_gl,
|
||
"creating terrain global vertex buffer");
|
||
RetryableGpuResourceRelease globalVboRelease =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl,
|
||
_globalVbo,
|
||
() => _globalVboCapacityBytes,
|
||
"rolling back terrain global vertex buffer");
|
||
constructionResources.Add(
|
||
"terrain global vertex buffer",
|
||
globalVboRelease.Run);
|
||
_globalEbo = TrackedGlResource.CreateBuffer(
|
||
_gl,
|
||
"creating terrain global index buffer");
|
||
RetryableGpuResourceRelease globalEboRelease =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl,
|
||
_globalEbo,
|
||
() => _globalEboCapacityBytes,
|
||
"rolling back terrain global index buffer");
|
||
constructionResources.Add(
|
||
"terrain global index buffer",
|
||
globalEboRelease.Run);
|
||
AllocateGpuBuffers(initialSlotCapacity);
|
||
GlResourceCommand.Execute(
|
||
_gl,
|
||
"configure terrain global vertex array",
|
||
() => ConfigureVao(_globalVao, _globalVbo, _globalEbo));
|
||
constructionResources.TransferAll();
|
||
}
|
||
catch (Exception constructionFailure)
|
||
{
|
||
constructionResources.RollbackConstructionAndThrow(
|
||
"TerrainModernRenderer construction failed and its GL prefix did not cleanly roll back.",
|
||
constructionFailure);
|
||
}
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<DynamicIndirectBuffer> 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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public void SetClipUbo(TerrainClipBufferBinding sharedClipBinding) =>
|
||
_sharedClipBinding = sharedClipBinding;
|
||
|
||
/// <summary>
|
||
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
|
||
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> 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 <see cref="AddLandblock(uint,LandblockMeshData,Vector3)"/>
|
||
/// so both paths share one upload path. Per Phase A.5 spec T15.
|
||
/// </summary>
|
||
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);
|
||
|
||
if (_gl is null)
|
||
{
|
||
UploadRhiLandblock(slot, bakedVerts, bakedIndices);
|
||
}
|
||
else
|
||
{
|
||
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<Vector4> 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;
|
||
|
||
// Campaign V slice V6j: the command array is built the same way on both
|
||
// arms; only where it lands differs. The RHI arm writes it into a frame
|
||
// ring slice, which retires the per-frame-slot indirect buffer pool
|
||
// structurally — every allocation within a frame is already distinct
|
||
// memory that outlives the draw recorded against it.
|
||
BuildIndirectCommands();
|
||
if (_gl is null)
|
||
{
|
||
if (!_dynamicFrameStarted)
|
||
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
|
||
DrawRhi(viewProjection, _visibleSlots.Count);
|
||
return;
|
||
}
|
||
|
||
ActivateNextIndirectBuffer();
|
||
|
||
// 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. uViewProjection 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.
|
||
_shader!.Use();
|
||
UploadTextureTilingOnce();
|
||
// Campaign V slice V6f-2: bind the tiling UBO every draw, not once. GL's
|
||
// uniform-buffer binding points are global and shared with the sky's
|
||
// params block and the SceneLighting block, so a renderer that runs
|
||
// between two terrain draws can take binding 3 out from under us.
|
||
// Self-contained state, per feedback_render_self_contained_gl_state.
|
||
_gl.BindBufferBase(
|
||
BufferTargetARB.UniformBuffer,
|
||
Gpu.GpuBindingModel.UniformTerrainTiling,
|
||
_tilingUbo);
|
||
// Campaign V slice V6f-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);
|
||
|
||
// Campaign V slice V2b: pass each texture'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. Slice V4t: the slots come from the
|
||
// device's one table rather than a table private to this renderer.
|
||
(GpuTextureSlot terrainSlot, GpuTextureSlot alphaSlot) =
|
||
_atlas.GetTextureSlots(GpuDevice);
|
||
FlushAndBindTextureTable();
|
||
_gl.ProgramUniform1(_shader.Program, _uTextureIndexALoc, terrainSlot.Index);
|
||
_gl.ProgramUniform1(_shader.Program, _uTextureIndexBLoc, alphaSlot.Index);
|
||
|
||
// 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds this frame's <c>DrawElementsIndirectCommand</c> array from the
|
||
/// visible slot list. Pure CPU, identical on both arms.
|
||
/// </summary>
|
||
private void BuildIndirectCommands()
|
||
{
|
||
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,
|
||
};
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
_retirementLedger.RetryPendingPublications();
|
||
if (_gl is null)
|
||
{
|
||
DisposeRhi();
|
||
return;
|
||
}
|
||
|
||
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<DynamicIndirectBuffer> 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));
|
||
}
|
||
if (_tilingUbo != 0)
|
||
{
|
||
RetryableGpuResourceRelease release =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl,
|
||
_tilingUbo,
|
||
TerrainTextureTilingTable.UniformBufferBytes,
|
||
"deleting terrain tiling UBO");
|
||
releases.Add(("tiling-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<DynamicIndirectBuffer> 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
|
||
// ----------------------------------------------------------------
|
||
|
||
/// <summary>
|
||
/// Upload the texture-array adapter for retail's per-surface repeat count.
|
||
/// Retail passes <c>TerrainTex::tex_tiling</c> directly to
|
||
/// <c>ImgTex::TileCSI</c> / <c>ImgTex::MergeTexture</c>
|
||
/// (`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.
|
||
/// </summary>
|
||
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}.");
|
||
}
|
||
|
||
// Campaign V slice V6f-2: one whole-buffer write into the binding=3
|
||
// uniform buffer, replacing the glUniform1fv into the loose array. The
|
||
// block is std140, so each value sits at a 16-byte stride with three
|
||
// dead words after it; the span is cleared first so those words are
|
||
// zero rather than whatever the stack held.
|
||
Span<byte> block = stackalloc byte[TerrainTextureTilingTable.UniformBufferBytes];
|
||
block.Clear();
|
||
for (int i = 0; i < TerrainTextureTilingTable.LayerCapacity; i++)
|
||
{
|
||
BitConverter.TryWriteBytes(
|
||
block[(i * TerrainTextureTilingTable.UniformElementStrideBytes)..],
|
||
_atlas.TilingByLayer[i]);
|
||
}
|
||
|
||
fixed (byte* p = block)
|
||
{
|
||
TrackedGlResource.UpdateBufferSubData(
|
||
_gl!,
|
||
BufferTargetARB.UniformBuffer,
|
||
_tilingUbo,
|
||
0,
|
||
TerrainTextureTilingTable.UniformBufferBytes,
|
||
p,
|
||
"uploading terrain tiling UBO");
|
||
}
|
||
|
||
_textureTilingUploaded = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The GL device whose texture table this renderer samples through.
|
||
/// Campaign V slice V4t: a terrain renderer without one could not resolve a
|
||
/// single texture, so the failure names the composition that built it rather
|
||
/// than dereferencing null mid-draw.
|
||
/// </summary>
|
||
private GlGpuDevice GpuDevice => _gpuDevice ?? throw new InvalidOperationException(
|
||
"TerrainModernRenderer was constructed without a GL GPU device: its texture " +
|
||
"slots come from that device's table (Campaign V slice V4t).");
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V4t: drains the device texture table's dirty runs and
|
||
/// (re)binds it at <see cref="GpuBindingModel.StorageTextureTable"/>.
|
||
/// Terrain registers at most two slots (the terrain and alpha atlases), so
|
||
/// the table is dirty only on the atlas's first draw — but the bind is
|
||
/// unconditional, because GL storage-buffer binding points are global and
|
||
/// another renderer's binding 9 sits there between two terrain draws.
|
||
/// Deleted with the raw-GL world path when the Vulkan world arm lands and
|
||
/// this renderer's draws go through the encoder, which binds the same table
|
||
/// on every pipeline bind.
|
||
/// </summary>
|
||
private void FlushAndBindTextureTable()
|
||
{
|
||
GlGpuDevice device = GpuDevice;
|
||
device.FlushTextureTable();
|
||
_gl!.BindBufferBase(
|
||
GLEnum.ShaderStorageBuffer,
|
||
GpuBindingModel.StorageTextureTable,
|
||
device.TextureTableGlName);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
|
||
/// <see cref="ClipFrame"/> UBO range (<see cref="SetClipUbo"/>); 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
|
||
/// <see cref="ClipFrame.TerrainUboBytes"/> and zero-filled (count 0).
|
||
/// </summary>
|
||
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<uint> destination,
|
||
uint landblockId,
|
||
Vector3 worldOrigin,
|
||
float zMin,
|
||
float zMax,
|
||
FrustumPlanes? frustum,
|
||
Matrix4x4 viewProjection,
|
||
ReadOnlySpan<Vector4> 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<Vector4> 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<Vector4> 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<Vector4> 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<Vector4> 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;
|
||
if (_gl is null)
|
||
{
|
||
EnsureRhiCapacity(newCapacity);
|
||
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;
|
||
}
|
||
}
|