feat(render): Campaign V slice V4t-1 — terrain crosses to GpuTextureSlot

V4t moves the world texture stack off the raw 64-bit ARB_bindless_texture
handle and onto GpuTextureSlot. This first commit does terrain only, because
terrain is the one branch of that stack whose producer and consumer are a
single pair — TerrainAtlas and TerrainModernRenderer — so it can carry the
new device seam on its own pixel gate before the mesh/composite/particle
retype lands on top of it.

Why the device's table can now be reached, when §5.2 said it could not.
That paragraph's reason was the flush: GlGpuDevice drains its dirty table
runs inside FlushBeforeDraw, which only an encoder-recorded draw reaches,
so a raw-GL renderer would sample a stale table. §5.5.6 then closed the GL
re-land of V4c/V4d, which means the world renderers stay raw GL through to
V10 — so "wait for the encoder" stopped being a plan and became an
indefinite block on V4t, which the Vulkan world arm cannot be written
without. The resolution is the smallest one that keeps the seam honest: the
drain is factored out as GlGpuDevice.FlushTextureTable, and a raw-GL
renderer calls it and binds TextureTableGlName at binding 9 itself,
immediately before its own draw — the same shape its retired private
GlBindlessHandleTable had, against a table that is now the device's. Nothing
else of the backend is exposed, and both members are deleted with the raw-GL
world path.

Residency ownership deliberately does NOT move. RegisterWorldTextureHandle
interns an already-resident handle and owns only the table entry; the atlas
still creates, makes resident and destroys its own textures. That is what
separates it from RegisterTexture, which owns the residency it creates, and
it is why this slice can retype the data model without also porting GL
texture creation onto IGpuTexture.

TerrainAtlas.GetBindlessHandles becomes GetTextureSlots(GlGpuDevice).
Registration is idempotent by handle, so the per-draw call is two dictionary
lookups — the cadence GetOrAdd already had. It is conditional on the handle
having changed because SetAnisotropic makes both textures non-resident and
re-acquires them: without that check a quality-preset change would strand a
slot holding a non-resident handle, so the superseded entry is retired in
the same step through the device's retirement queue.

Ordering is unaffected. Terrain's two slots travel as loose uniforms
(uTextureIndexA/B) and enter no sort and no bucket key, so a different slot
NUMBER changes nothing about what is drawn or in what order — only which
table index resolves to the same handle.

Gates. GL offline pixel gate vs cb2a70b8: 3.02e-05 (17 of 563,200 pixels),
exactly a same-commit control value and inside the documented 15-23 px /
<=4.1e-05 band. tools/run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on
both the desktop witness and the client capture. One Vulkan composition-host
run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero
errors, zero warnings, empty validation log, converged ownership ledger. App
tests 4,075 / 3 skips (#250's zero-allocation test reran green singly).

One connected run of an earlier 3-run attempt died in the render loop with
"OpenGL returned unexpected fence wait status NoError (0x0)" from
GpuFrameFlightController.RetireFence. It did not reproduce in the following
three runs at this tree nor in three interleaved runs at cb2a70b8, and this
diff creates, deletes and waits on no fence. Filed as #251 rather than
attributed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 12:22:45 +02:00
parent cb2a70b867
commit b8bcaa3ef2
6 changed files with 228 additions and 83 deletions

View file

@ -1,3 +1,5 @@
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Gl;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
@ -66,20 +68,49 @@ public sealed unsafe class TerrainAtlas : IDisposable
private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new();
private ResourceShutdownTransaction? _shutdown;
private ulong _registeredTerrainHandle;
private ulong _registeredAlphaHandle;
private GpuTextureSlot _terrainSlot = GpuTextureSlot.Unassigned;
private GpuTextureSlot _alphaSlot = GpuTextureSlot.Unassigned;
/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
/// Throws <see cref="InvalidOperationException"/> if the atlas was constructed
/// without a <see cref="Wb.BindlessSupport"/> instance. Handles are generated
/// lazily on first call and cached for the atlas's lifetime; both textures
/// are made resident.
/// Campaign V slice V4t: the device texture-table slots for the terrain and
/// alpha arrays — the backend-neutral replacement for the raw 64-bit
/// <c>ARB_bindless_texture</c> handles this used to return. Residency still
/// belongs to this atlas (acquired lazily here, released by
/// <see cref="Dispose"/>); the device owns only the two table entries.
///
/// <para>Throws <see cref="InvalidOperationException"/> if the atlas was
/// constructed without a <see cref="Wb.BindlessSupport"/> instance.</para>
///
/// <para><see cref="SetAnisotropic"/> makes both textures non-resident and
/// re-acquires them, which yields new handles. Re-registering is therefore
/// conditional on the handle actually having changed, and the superseded
/// table entry is retired in the same step — otherwise a quality-preset
/// change would leak a slot holding a non-resident handle. Registration is
/// idempotent, so the common per-draw call is two dictionary lookups.</para>
/// </summary>
public (ulong terrain, ulong alpha) GetBindlessHandles()
internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) GetTextureSlots(GlGpuDevice device)
{
ArgumentNullException.ThrowIfNull(device);
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
"TerrainAtlas was constructed without BindlessSupport; cannot return texture slots.");
(ulong terrain, ulong alpha) = _bindlessHandles!.Acquire();
return (terrain, alpha);
if (terrain != _registeredTerrainHandle)
{
device.ReleaseWorldTextureHandle(_registeredTerrainHandle);
_terrainSlot = device.RegisterWorldTextureHandle(terrain);
_registeredTerrainHandle = terrain;
}
if (alpha != _registeredAlphaHandle)
{
device.ReleaseWorldTextureHandle(_registeredAlphaHandle);
_alphaSlot = device.RegisterWorldTextureHandle(alpha);
_registeredAlphaHandle = alpha;
}
return (_terrainSlot, _alphaSlot);
}
private TerrainAtlas(