Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "creating world textures through IGpuTexture is real remaining work and
it belongs with the Vulkan world arm, which is the first thing that cannot use a
GL handle at all." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.
IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.
Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.
Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.
TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.
The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.
Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
0ca802cd 3.20e-05 (18 px of 563,200, inside the documented 9–31 px control band);
GL connected tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the
desktop witness AND 3/3 on the client capture; one Vulkan composition-host run
with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors,
zero warnings, no [shutdown] diagnostic, and a captured frame. That run built
terrain-atlas 512x512x33 with 10 mip levels, terrain-alpha-atlas 512x512x8, RGBA8
64x64x32 (slots 3/4, 174,720 mip bytes blitted), BC1 64x64x32 (slots 5/6, 696 mip
bytes encoded) and composite 32x32x8 (slot 7).
One whole-suite run failed Issue181WallPressEquilibriumTests once; it passed
alone and did not recur in five further runs. Seven test classes mutate the same
process-global CameraDiagnostics switches with no xUnit collection isolation, and
this diff touches no camera, visibility or physics code. A separate run of the
UNCHANGED parent tree failed a different zero-allocation test, which is `#250`'s
documented class. Both are filed rather than attributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
180 lines
7.7 KiB
C#
180 lines
7.7 KiB
C#
using AcDream.App.Rendering.Gpu;
|
||
using AcDream.App.Rendering.Wb;
|
||
using Chorizite.Core.Render.Enums;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
/// <summary>
|
||
/// Campaign V slice V6i-2: one shared world texture array of each format family
|
||
/// and one composite array, created through the RHI at startup on a backend
|
||
/// that has no GL context.
|
||
///
|
||
/// <para><b>Why this exists.</b> The slice's whole claim is that world texture
|
||
/// CREATION now reaches <see cref="IGpuTexture"/>. A creation path nothing
|
||
/// constructs is a claim, not a fact — and plan §5.5.12 recorded the cost of
|
||
/// exactly that shape twice over: <c>UniformSkyParams</c> and the terrain clip
|
||
/// block were both wrong for months because no Vulkan pipeline had ever been
|
||
/// built from them. The parked <c>TerrainAtlas</c> draft this slice reuses was
|
||
/// itself reverted for the same reason — "built then reverted because nothing
|
||
/// exercised it".</para>
|
||
///
|
||
/// <para>So the Vulkan composition host builds these at startup. They are never
|
||
/// drawn — no world renderer exists on that arm until the slice that ports the
|
||
/// dispatchers — but they ARE created, uploaded, mip-chained and registered into
|
||
/// the device's texture table, which puts every one of those calls under the
|
||
/// validation layer and inside the ownership ledger that teardown converges.
|
||
/// That is the difference between a gated path and an untested one.</para>
|
||
///
|
||
/// <para><b>What the two arrays cover between them.</b> RGBA8 exercises
|
||
/// <see cref="IGpuTexture.GenerateMipChain"/> — the device-side blit — and BC1
|
||
/// exercises the CPU chain, because Vulkan cannot blit into a compressed image
|
||
/// and <see cref="Gpu.Vk.BlockCompressionMipChain"/> is what stands in for that.
|
||
/// Those are the two upload shapes the world's shared atlases actually take.</para>
|
||
/// </summary>
|
||
internal sealed class BackendNeutralWorldTextures : IDisposable
|
||
{
|
||
private readonly IWorldTextureArray _rgba;
|
||
private readonly IWorldTextureArray _compressed;
|
||
private readonly ICompositeTextureArrayBackend _compositeBackend;
|
||
private readonly CompositeTextureArrayResource _composite;
|
||
private bool _disposed;
|
||
|
||
/// <summary>
|
||
/// The size class the exercise uses. Small enough to cost nothing at
|
||
/// startup, large enough that both arrays have a real multi-level mip chain
|
||
/// (64×64 is 7 levels) rather than the degenerate single-level case.
|
||
/// </summary>
|
||
private const int ArrayExtent = 64;
|
||
|
||
/// <summary>Composite surfaces are the retail 32×32 item art size class.</summary>
|
||
private const int CompositeExtent = 32;
|
||
|
||
private const int CompositeLayers = 8;
|
||
|
||
internal static BackendNeutralWorldTextures Create(IGpuDevice device, Action<string> log)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(device);
|
||
ArgumentNullException.ThrowIfNull(log);
|
||
return new BackendNeutralWorldTextures(device, log);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates the bundle and releases it in the same statement.
|
||
///
|
||
/// <para>Nothing is retained on purpose. The terrain atlas — which the same
|
||
/// slice moved onto <see cref="IGpuTexture"/> — is the retained case: two
|
||
/// real arrays registered in the device table for the whole run and torn
|
||
/// down at shutdown. What this covers instead is the shared-atlas and
|
||
/// composite CREATION shapes, and creating-then-releasing exercises one more
|
||
/// thing a retained bundle would not: that both slot pairs come back and the
|
||
/// images route through the retirement queue, so the ownership ledger this
|
||
/// gate reads converges by construction rather than by assertion.</para>
|
||
/// </summary>
|
||
internal static void Exercise(IGpuDevice device, Action<string> log)
|
||
{
|
||
using BackendNeutralWorldTextures textures = Create(device, log);
|
||
}
|
||
|
||
private BackendNeutralWorldTextures(IGpuDevice device, Action<string> log)
|
||
{
|
||
var arrays = new RhiWorldTextureArrayFactory(device);
|
||
|
||
// Capacity comes from the same target-bytes policy the shared atlases
|
||
// use on GL, so the exercise allocates what production would.
|
||
int rgbaLayers = TextureAtlasManager.CalculateInitialCapacity(
|
||
ArrayExtent,
|
||
ArrayExtent,
|
||
TextureFormat.RGBA8);
|
||
int compressedLayers = TextureAtlasManager.CalculateInitialCapacity(
|
||
ArrayExtent,
|
||
ArrayExtent,
|
||
TextureFormat.DXT1);
|
||
|
||
IWorldTextureArray? rgba = null;
|
||
IWorldTextureArray? compressed = null;
|
||
ICompositeTextureArrayBackend? compositeBackend = null;
|
||
CompositeTextureArrayResource? composite = null;
|
||
try
|
||
{
|
||
rgba = arrays.CreateClampedArray(TextureFormat.RGBA8, ArrayExtent, ArrayExtent, rgbaLayers);
|
||
rgba.UpdateLayer(0, OpaqueRgba(ArrayExtent, ArrayExtent), null, null);
|
||
long rgbaMipBytes = rgba.ProcessDirtyUpdates();
|
||
|
||
compressed = arrays.CreateClampedArray(TextureFormat.DXT1, ArrayExtent, ArrayExtent, compressedLayers);
|
||
compressed.UpdateLayer(0, OpaqueBc1(ArrayExtent, ArrayExtent), null, null);
|
||
long compressedMipBytes = compressed.ProcessDirtyUpdates();
|
||
|
||
compositeBackend = new RhiCompositeTextureArrayBackend(device);
|
||
composite = compositeBackend.Create(CompositeExtent, CompositeExtent, CompositeLayers);
|
||
compositeBackend.Upload(composite, 0, OpaqueRgba(CompositeExtent, CompositeExtent));
|
||
|
||
_rgba = rgba;
|
||
_compressed = compressed;
|
||
_compositeBackend = compositeBackend;
|
||
_composite = composite;
|
||
|
||
log(
|
||
"[V6i-2] world texture creation on the RHI: "
|
||
+ $"RGBA8 {ArrayExtent}x{ArrayExtent}x{rgbaLayers} "
|
||
+ $"(wrap {rgba.ResolveSlot(true)}, clamp {rgba.ResolveSlot(false)}, "
|
||
+ $"{rgbaMipBytes} mip bytes blitted); "
|
||
+ $"BC1 {ArrayExtent}x{ArrayExtent}x{compressedLayers} "
|
||
+ $"(wrap {compressed.ResolveSlot(true)}, clamp {compressed.ResolveSlot(false)}, "
|
||
+ $"{compressedMipBytes} mip bytes encoded); "
|
||
+ $"composite {CompositeExtent}x{CompositeExtent}x{CompositeLayers} ({composite.Slot}).");
|
||
}
|
||
catch
|
||
{
|
||
if (composite is not null)
|
||
{
|
||
compositeBackend!.MakeNonResident(composite);
|
||
compositeBackend.Delete(composite);
|
||
}
|
||
compressed?.Dispose();
|
||
rgba?.Dispose();
|
||
throw;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed)
|
||
return;
|
||
_disposed = true;
|
||
_compositeBackend.MakeNonResident(_composite);
|
||
_compositeBackend.Delete(_composite);
|
||
_compressed.Dispose();
|
||
_rgba.Dispose();
|
||
}
|
||
|
||
private static byte[] OpaqueRgba(int width, int height)
|
||
{
|
||
var pixels = new byte[width * height * 4];
|
||
Array.Fill(pixels, (byte)0xFF);
|
||
return pixels;
|
||
}
|
||
|
||
/// <summary>
|
||
/// A BC1 block whose two endpoints are white and whose selectors are all
|
||
/// zero, repeated across the level — a legal, fully-opaque payload of
|
||
/// exactly the size the codec expects. The point is the upload and the
|
||
/// chain, not the pixels.
|
||
/// </summary>
|
||
private static byte[] OpaqueBc1(int width, int height)
|
||
{
|
||
int blocks = Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4);
|
||
var data = new byte[blocks * 8];
|
||
for (int block = 0; block < blocks; block++)
|
||
{
|
||
int at = block * 8;
|
||
// Two RGB565 endpoints, both white (0xFFFF), then four selector
|
||
// bytes of zero: every texel takes endpoint 0.
|
||
data[at + 0] = 0xFF;
|
||
data[at + 1] = 0xFF;
|
||
data[at + 2] = 0xFF;
|
||
data[at + 3] = 0xFF;
|
||
}
|
||
|
||
return data;
|
||
}
|
||
}
|