acdream/src/AcDream.App/Rendering/TerrainModernRenderer.cs
Erik 8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00

414 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
namespace AcDream.App.Rendering;
/// <summary>
/// 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 and dispatch via one multi-draw-indirect call. Atlas
/// textures bound via the device's global texture table.
///
/// <para>Campaign V slice V11 deleted the raw-GL submission arm
/// (<c>TerrainModernRenderer.cs</c>'s former GL fields/constructor/Draw/Dispose
/// bodies); the RHI arm this file now exclusively hosts the shared allocator
/// and visibility logic for is defined in <c>TerrainModernRenderer.Rhi.cs</c>.</para>
/// </summary>
public sealed 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
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 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();
// Backing-store capacity bookkeeping, shared with the RHI arm's arena.
private long _globalVboCapacityBytes;
private long _globalEboCapacityBytes;
// Per-GPU-fenced-frame-slot draw bookkeeping, shared with the RHI arm.
private int _dynamicFrameSlot;
private bool _dynamicFrameStarted;
/// <summary>
/// The dynamic per-frame-slot indirect-command buffer pool this used to
/// report was raw-GL-only bookkeeping, deleted with that arm at Campaign V
/// slice V11. The RHI arm allocates its indirect-command storage from the
/// GPU frame's own upload ring instead, so there is no separate pool to
/// count.
/// </summary>
internal int DynamicIndirectBufferCount => 0;
// 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();
/// <summary>
/// Resets the per-GPU-fenced-frame-slot draw state. A retail outside view
/// may draw terrain more than once in a frame.
/// </summary>
public void BeginFrame(int frameSlot)
{
ArgumentOutOfRangeException.ThrowIfNegative(frameSlot);
_retirementLedger.RetryPendingPublications();
_dynamicFrameSlot = frameSlot;
_dynamicFrameStarted = true;
}
/// <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;
UploadRhiLandblock(slot, bakedVerts, bakedIndices);
_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;
BuildIndirectCommands();
if (!_dynamicFrameStarted)
throw new InvalidOperationException("BeginFrame must be called before drawing terrain.");
DrawRhi(viewProjection, _visibleSlots.Count);
}
/// <summary>
/// Builds this frame's <c>DrawElementsIndirectCommand</c> array from the
/// visible slot list. Pure CPU.
/// </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();
DisposeRhi();
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
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;
EnsureRhiCapacity(newCapacity);
}
private sealed class SlotData
{
public uint LandblockId;
public Vector3 WorldOrigin;
public uint FirstIndex;
public int IndexCount;
public Vector3 AabbMin;
public Vector3 AabbMax;
}
}