feat(render): Campaign V slice V6i-2 commit 2 — world texture creation crosses to IGpuTexture

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>
This commit is contained in:
Erik 2026-07-28 13:57:43 +02:00
parent f7344758f8
commit c8d0f70bbe
12 changed files with 1662 additions and 95 deletions

View file

@ -244,6 +244,30 @@ public sealed class WorldRenderCompositionTests
BindlessSupport bindless) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
/// <summary>
/// Campaign V slice V6i-2: the arm a backend with no GL context takes.
/// Returns the same stub atlas through the same lifetime owner, so the
/// composition assertions do not care which arm ran.
/// </summary>
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
IGameRenderResourceLifetime lifetime,
IGpuDevice device,
IDatReaderWriter dats) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
/// <summary>
/// Recorded rather than run: the exercise needs a real
/// <see cref="IGpuDevice"/> to create images through. Its behaviour is
/// covered by <c>RhiWorldTextureArrayTests</c> and by the Vulkan
/// composition-host run — see plan §5.5.13.
/// </summary>
public void ExerciseBackendNeutralWorldTextures(
IGpuDevice device,
Action<string> log) =>
WorldTextureExerciseCount++;
public int WorldTextureExerciseCount { get; private set; }
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;

View file

@ -153,8 +153,19 @@ internal sealed class RecordingGpuDevice : IGpuDevice
public IGpuBuffer CreateBuffer(in GpuBufferDescription description) =>
new RecordingGpuBuffer(description);
public IGpuTexture CreateTexture(in GpuTextureDescription description) =>
new RecordingGpuTexture(
/// <summary>
/// Campaign V slice V6i-2: every image this device made, in creation order.
/// A caller that creates its own textures internally — the world texture
/// arrays and the terrain atlas do — has no other way to assert what landed
/// on them.
/// </summary>
public IReadOnlyList<RecordingGpuTexture> CreatedTextures => _createdTextures;
private readonly List<RecordingGpuTexture> _createdTextures = [];
public IGpuTexture CreateTexture(in GpuTextureDescription description)
{
RecordingGpuTexture texture = new(
description.Name,
description.Kind,
description.Format,
@ -162,6 +173,9 @@ internal sealed class RecordingGpuDevice : IGpuDevice
description.Height,
description.LayerCount,
description.MipLevelCount);
_createdTextures.Add(texture);
return texture;
}
public IGpuSampler CreateSampler(in GpuSamplerDescription description)
{

View file

@ -0,0 +1,115 @@
using System;
using System.Linq;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Campaign V slice V6i-2: the composite array pool's backend-neutral arm.
///
/// <para>Plan §5.5.12 item 1 recorded that <c>ICompositeTextureArrayBackend</c>
/// "is already a seam and takes an RHI backend directly", which is why this arm
/// is four small methods rather than a port. What is worth pinning is the
/// contract the cache above depends on: create returns a resource whose slot is
/// live, the release order is entry-then-image, and a resource made by one
/// backend is never handed to the other.</para>
/// </summary>
public sealed class RhiCompositeTextureArrayBackendTests
{
private static byte[] Rgba(int width, int height) => new byte[width * height * 4];
[Fact]
public void CreateProducesASingleLevelArrayRegisteredIntoTheTable()
{
using var device = new RecordingGpuDevice();
var backend = new RhiCompositeTextureArrayBackend(device);
CompositeTextureArrayResource resource = backend.Create(32, 32, 8);
Assert.True(resource.Slot.IsAssigned);
Assert.Equal(32 * 32 * 4 * 8, resource.Bytes);
// The GL identity fields are meaningless on this arm and say so.
Assert.Equal(0u, resource.Name);
Assert.Equal(0ul, resource.Handle);
RecordingGpuTexture image = Assert.IsType<RecordingGpuTexture>(resource.Image);
Assert.Equal(GpuTextureKind.Texture2DArray, image.Kind);
Assert.Equal(GpuTextureFormat.Rgba8Unorm, image.Format);
Assert.Equal(8, image.LayerCount);
// Composites are the surfaces retail releases the moment they are built;
// a mip chain would be paid for nothing.
Assert.Equal(1, image.MipLevelCount);
}
[Fact]
public void UploadWritesLevelZeroOfTheNamedLayer()
{
using var device = new RecordingGpuDevice();
var backend = new RhiCompositeTextureArrayBackend(device);
CompositeTextureArrayResource resource = backend.Create(32, 32, 4);
backend.Upload(resource, 3, Rgba(32, 32));
RecordingGpuTexture image = Assert.IsType<RecordingGpuTexture>(resource.Image);
Assert.Equal([(0, 3, 32 * 32 * 4)], image.Uploads);
}
[Fact]
public void ReleaseRetiresTheTableEntryBeforeTheImage()
{
using var device = new RecordingGpuDevice();
var backend = new RhiCompositeTextureArrayBackend(device);
int before = device.LiveTextureSlotCount;
CompositeTextureArrayResource resource = backend.Create(32, 32, 4);
Assert.Equal(before + 1, device.LiveTextureSlotCount);
backend.MakeNonResident(resource);
Assert.Equal(before, device.LiveTextureSlotCount);
Assert.False(Assert.IsType<RecordingGpuTexture>(resource.Image).IsDisposed);
backend.Delete(resource);
Assert.True(Assert.IsType<RecordingGpuTexture>(resource.Image).IsDisposed);
}
/// <summary>
/// A GL-made resource reaching this backend is a composition error, not a
/// runtime condition — and the message says which backend owns it rather
/// than dereferencing null.
/// </summary>
[Fact]
public void AGlResourceIsRefusedRatherThanDereferenced()
{
using var device = new RecordingGpuDevice();
var backend = new RhiCompositeTextureArrayBackend(device);
var foreign = new CompositeTextureArrayResource
{
Name = 7,
Handle = 0xDEAD,
Slot = new GpuTextureSlot(3),
Width = 32,
Height = 32,
Capacity = 1,
Bytes = 32 * 32 * 4,
};
Assert.Throws<InvalidOperationException>(() => backend.Upload(foreign, 0, Rgba(32, 32)));
Assert.Throws<InvalidOperationException>(() => backend.Delete(foreign));
}
/// <summary>
/// The cache caps every array at 64 layers, so the layer ceiling this
/// backend reports is never the binding constraint — which is what lets it
/// report Vulkan's guaranteed minimum instead of a capability field the
/// pinned record does not carry.
/// </summary>
[Fact]
public void TheReportedLayerCeilingExceedsTheCachesOwnCap()
{
using var device = new RecordingGpuDevice();
var backend = new RhiCompositeTextureArrayBackend(device);
Assert.True(backend.MaximumArrayLayers >= CompositeTextureArrayCache.MaximumLayersPerArray);
}
}

View file

@ -0,0 +1,188 @@
using System;
using System.Linq;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Wb;
using AcDream.App.Tests.Rendering.Gpu;
using Chorizite.Core.Render.Enums;
namespace AcDream.App.Tests.Rendering.Wb;
/// <summary>
/// Campaign V slice V6i-2: the backend-neutral world texture array, driven
/// against <see cref="RecordingGpuDevice"/>.
///
/// <para>These assert the two things the GL array and this one genuinely have to
/// agree on — what a well-formed layer payload is, and that every atlas ends up
/// addressable from a shader through both address modes — plus the one thing
/// they deliberately do NOT share: how a mip chain is produced. Vulkan cannot
/// blit into a compressed image, so a BC array's levels come from
/// <c>BlockCompressionMipChain</c> while an RGBA8 array's come from the device's
/// blit. Getting that backwards would compile, run, and render a texture with
/// undefined mips.</para>
/// </summary>
public sealed class RhiWorldTextureArrayTests
{
private const int Extent = 64;
private static byte[] Rgba(int width, int height)
{
var pixels = new byte[width * height * 4];
Array.Fill(pixels, (byte)0xFF);
return pixels;
}
private static byte[] Bc1(int width, int height) =>
new byte[Math.Max(1, (width + 3) / 4) * Math.Max(1, (height + 3) / 4) * 8];
[Fact]
public void AnUncompressedArrayLetsTheDeviceBlitItsMipChain()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 4);
array.UpdateLayer(2, Rgba(Extent, Extent), null, null);
Assert.Equal(1, array.PendingUpdateCount);
long generated = array.ProcessDirtyUpdates();
RecordingGpuTexture texture = LastCreatedTexture(device);
Assert.True(texture.MipChainGenerated);
// Exactly one upload: level 0 of the layer that was staged. Every other
// level is the device's blit.
Assert.Equal([(0, 2, Extent * Extent * 4)], texture.Uploads);
Assert.True(generated > 0);
Assert.Equal(0, array.PendingUpdateCount);
}
[Fact]
public void ABlockCompressedArrayUploadsACpuBuiltChainAndNeverBlits()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.DXT1, Extent, Extent, 2);
array.UpdateLayer(0, Bc1(Extent, Extent), null, null);
array.ProcessDirtyUpdates();
RecordingGpuTexture texture = LastCreatedTexture(device);
Assert.False(texture.MipChainGenerated);
// 64x64 is seven levels; level 0 was staged and 1..6 were encoded, all
// for the one layer that was written.
Assert.Equal(7, texture.Uploads.Count);
Assert.All(texture.Uploads, upload => Assert.Equal(0, upload.Layer));
Assert.Equal([0, 1, 2, 3, 4, 5, 6], texture.Uploads.Select(u => u.MipLevel));
}
[Fact]
public void EveryArrayIsAddressableThroughBothAddressModes()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
GpuTextureSlot wrap = array.ResolveSlot(wrapping: true);
GpuTextureSlot clamp = array.ResolveSlot(wrapping: false);
Assert.True(wrap.IsAssigned);
Assert.True(clamp.IsAssigned);
Assert.NotEqual(wrap, clamp);
GpuSamplerDescription[] samplers =
[
.. device.Calls.OfType<GpuRecordedTextureRegistration>()
.Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal))
.Select(registration => registration.Sampler),
];
Assert.Contains(GpuSamplerDescription.WorldClamp, samplers);
Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers);
}
/// <summary>
/// The slot pair must come back to the device, or a session that churns
/// atlases exhausts the table's fixed capacity — the leak-with-an-end V4t
/// introduced when it capped the table.
/// </summary>
[Fact]
public void DisposalReturnsBothSlotsAndTheImage()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
int before = device.LiveTextureSlotCount;
IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
Assert.Equal(before + 2, device.LiveTextureSlotCount);
array.Dispose();
Assert.Equal(before, device.LiveTextureSlotCount);
Assert.True(LastCreatedTexture(device).IsDisposed);
Assert.True(array.IsPhysicalRetirementComplete);
Assert.True(array.HasDurableDisposeOwnership);
// ReleaseTextureSlots after Dispose is idempotent: the retiring-atlas
// path calls it once physical retirement completes, which is necessarily
// after disposal.
array.ReleaseTextureSlots();
Assert.Equal(before, device.LiveTextureSlotCount);
}
/// <summary>
/// The GL array rejects a payload whose length contradicts the format. The
/// RHI array reuses that validator rather than writing a second one, so a
/// mis-sized layer fails the same way on both arms.
/// </summary>
[Fact]
public void AMisSizedLayerIsRejectedByTheSharedValidator()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 1);
Assert.Throws<ArgumentException>(() => array.UpdateLayer(0, new byte[16], null, null));
Assert.Equal(0, array.PendingUpdateCount);
}
/// <summary>
/// The formats with no member of <c>GpuTextureFormat</c> fail loudly at
/// creation rather than silently substituting. A8 in particular needs the
/// component swizzle the GL array applies, which lives in a Vulkan image view
/// and is not part of the pinned texture description.
/// </summary>
[Theory]
[InlineData(TextureFormat.A8)]
[InlineData(TextureFormat.RGB8)]
[InlineData(TextureFormat.Rgba32f)]
public void AFormatWithNoRhiEquivalentIsRefusedAtCreation(TextureFormat format)
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
Assert.Throws<NotSupportedException>(
() => arrays.CreateClampedArray(format, Extent, Extent, 1));
}
/// <summary>
/// Both arms meter the same bytes, because the eviction budget that reads
/// this number is shared policy above the seam.
/// </summary>
[Fact]
public void AllocatedBytesMatchTheSharedMipChainAccounting()
{
using var device = new RecordingGpuDevice();
var arrays = new RhiWorldTextureArrayFactory(device);
using IWorldTextureArray array = arrays.CreateClampedArray(TextureFormat.RGBA8, Extent, Extent, 3);
Assert.Equal(
TextureAtlasManager.CalculateMipChainBytes(Extent, Extent, TextureFormat.RGBA8) * 3,
array.TotalSizeInBytes);
}
private static RecordingGpuTexture LastCreatedTexture(RecordingGpuDevice device) =>
device.CreatedTextures.Count > 0
? device.CreatedTextures[^1]
: throw new InvalidOperationException("The device created no texture.");
}