diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs
index e5a1f74c..cd603f1a 100644
--- a/src/AcDream.App/Composition/WorldRenderComposition.cs
+++ b/src/AcDream.App/Composition/WorldRenderComposition.cs
@@ -98,6 +98,27 @@ internal interface IWorldRenderCompositionFactory
GL gl,
IDatReaderWriter dats,
BindlessSupport bindless);
+ ///
+ /// Campaign V slice V6i-2: the terrain atlas built through
+ /// rather than raw GL.
+ /// Same DATs, same decode, same layer ordering — only the upload differs.
+ ///
+ TerrainAtlas AcquireBackendNeutralTerrainAtlas(
+ IGameRenderResourceLifetime lifetime,
+ AcDream.App.Rendering.Gpu.IGpuDevice device,
+ IDatReaderWriter dats);
+
+ ///
+ /// Campaign V slice V6i-2: one shared world array of each format family and
+ /// one composite array, created and released through the RHI so the new
+ /// creation path is exercised under the validation layer and inside the
+ /// ownership ledger rather than merely existing. Creation only — nothing
+ /// draws these.
+ ///
+ void ExerciseBackendNeutralWorldTextures(
+ AcDream.App.Rendering.Gpu.IGpuDevice device,
+ Action log);
+
void SetTerrainAnisotropic(TerrainAtlas atlas, int level);
Shader CreateTerrainShader(GL gl, string shadersDirectory);
SceneLightingUboBinding CreateSceneLighting(GL gl);
@@ -231,6 +252,21 @@ internal sealed class RetailWorldRenderCompositionFactory
() => TerrainAtlas.Build(gl, dats, bindless));
}
+ public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
+ IGameRenderResourceLifetime lifetime,
+ AcDream.App.Rendering.Gpu.IGpuDevice device,
+ IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(lifetime);
+ return lifetime.AcquireTerrainAtlas(
+ () => TerrainAtlas.BuildBackendNeutral(device, dats));
+ }
+
+ public void ExerciseBackendNeutralWorldTextures(
+ AcDream.App.Rendering.Gpu.IGpuDevice device,
+ Action log) =>
+ BackendNeutralWorldTextures.Exercise(device, log);
+
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
atlas.SetAnisotropic(level);
@@ -527,21 +563,42 @@ internal sealed class WorldRenderCompositionPhase
_publication.PublishBindlessSupport(bindless);
Fault(WorldRenderCompositionPoint.BindlessPublished);
- TerrainAtlas? terrainAtlas = gl is null || bindless is null
- ? null
- : _factory.AcquireTerrainAtlas(
+ // Campaign V slice V6i-2: the atlas exists on BOTH arms now. GL
+ // builds it from raw texture names as before; a backend with no GL
+ // context builds the same layers through IGpuDevice.CreateTexture.
+ // That is what makes the blending/T-code tables below real on
+ // Vulkan — and, more to the point, it is what puts the new creation
+ // path under the validation layer instead of leaving it a claim.
+ TerrainAtlas? terrainAtlas = gl is not null && bindless is not null
+ ? _factory.AcquireTerrainAtlas(
_dependencies.RenderResources,
gl,
content.Dats,
- bindless);
- if (terrainAtlas is not null)
- {
- _factory.SetTerrainAnisotropic(
- terrainAtlas,
- settings.ResolvedQuality.AnisotropicLevel);
- }
+ bindless)
+ : _factory.AcquireBackendNeutralTerrainAtlas(
+ _dependencies.RenderResources,
+ _dependencies.GpuDevice,
+ content.Dats);
+ _factory.SetTerrainAnisotropic(
+ terrainAtlas,
+ settings.ResolvedQuality.AnisotropicLevel);
Fault(WorldRenderCompositionPoint.TerrainAtlasAcquired);
+ // The rest of the world texture stack's creation path, exercised on
+ // the arm that has no GL: one shared array of each format family and
+ // one composite array, created and released here. Nothing draws
+ // them; see the type's own documentation for why they are built
+ // anyway. It claims no composition point and enters no acquisition
+ // scope — the frozen publication order is a pinned assertion, and an
+ // exercise that owns nothing past its own statement is not a
+ // published owner.
+ if (gl is null)
+ {
+ _factory.ExerciseBackendNeutralWorldTextures(
+ _dependencies.GpuDevice,
+ _dependencies.Log);
+ }
+
string shadersDirectory = Path.Combine(
AppContext.BaseDirectory,
"Rendering",
diff --git a/src/AcDream.App/Rendering/BackendNeutralWorldTextures.cs b/src/AcDream.App/Rendering/BackendNeutralWorldTextures.cs
new file mode 100644
index 00000000..065f0ba0
--- /dev/null
+++ b/src/AcDream.App/Rendering/BackendNeutralWorldTextures.cs
@@ -0,0 +1,180 @@
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Wb;
+using Chorizite.Core.Render.Enums;
+
+namespace AcDream.App.Rendering;
+
+///
+/// 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.
+///
+/// Why this exists. The slice's whole claim is that world texture
+/// CREATION now reaches . 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: UniformSkyParams and the terrain clip
+/// block were both wrong for months because no Vulkan pipeline had ever been
+/// built from them. The parked TerrainAtlas draft this slice reuses was
+/// itself reverted for the same reason — "built then reverted because nothing
+/// exercised it".
+///
+/// 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.
+///
+/// What the two arrays cover between them. RGBA8 exercises
+/// — the device-side blit — and BC1
+/// exercises the CPU chain, because Vulkan cannot blit into a compressed image
+/// and is what stands in for that.
+/// Those are the two upload shapes the world's shared atlases actually take.
+///
+internal sealed class BackendNeutralWorldTextures : IDisposable
+{
+ private readonly IWorldTextureArray _rgba;
+ private readonly IWorldTextureArray _compressed;
+ private readonly ICompositeTextureArrayBackend _compositeBackend;
+ private readonly CompositeTextureArrayResource _composite;
+ private bool _disposed;
+
+ ///
+ /// 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.
+ ///
+ private const int ArrayExtent = 64;
+
+ /// Composite surfaces are the retail 32×32 item art size class.
+ private const int CompositeExtent = 32;
+
+ private const int CompositeLayers = 8;
+
+ internal static BackendNeutralWorldTextures Create(IGpuDevice device, Action log)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(log);
+ return new BackendNeutralWorldTextures(device, log);
+ }
+
+ ///
+ /// Creates the bundle and releases it in the same statement.
+ ///
+ /// Nothing is retained on purpose. The terrain atlas — which the same
+ /// slice moved onto — 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.
+ ///
+ internal static void Exercise(IGpuDevice device, Action log)
+ {
+ using BackendNeutralWorldTextures textures = Create(device, log);
+ }
+
+ private BackendNeutralWorldTextures(IGpuDevice device, Action 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+}
diff --git a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
index cb328e20..405c796a 100644
--- a/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
+++ b/src/AcDream.App/Rendering/CompositeTextureArrayCache.cs
@@ -127,9 +127,20 @@ internal readonly record struct CompositeTextureKey(
internal sealed class CompositeTextureArrayResource
{
+ /// The GL texture name, or 0 on the backend-neutral arm.
public required uint Name { get; init; }
+
+ /// The resident bindless handle, or 0 on the backend-neutral arm.
public required ulong Handle { get; init; }
+ ///
+ /// Campaign V slice V6i-2: the RHI image, on the arm that owns one. Null on
+ /// GL, where and are the identity.
+ /// The cache above touches neither — it only ever hands a resource back to
+ /// the backend that made it.
+ ///
+ public Gpu.IGpuTexture? Image { get; init; }
+
///
/// Campaign V slice V4t: this array's entry in the device texture table.
/// The backend that made resident also interned it, so
@@ -338,6 +349,108 @@ internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureA
}
}
+///
+/// Campaign V slice V6i-2: the backend-neutral composite array backend.
+///
+/// Plan §5.5.12 item 1 noted that
+/// "is already a seam and takes an RHI backend directly" — this is that arm. It
+/// is deliberately the smallest of the three texture paths: one mip level, one
+/// sampler, RGBA8 only, no residency negotiation. The composited 32×32 item art
+/// and per-entity material surfaces it holds are exactly the textures retail
+/// releases the moment the surface is built, so a mip chain would be paid for
+/// nothing.
+///
+/// Clamped, matching what the GL backend's texture parameters say for
+/// the modes that matter. The GL backend sets Repeat, but a composite
+/// array's neighbouring layers are unrelated surfaces; the wrap mode only
+/// affects UVs outside [0,1], which the composite path does not generate. The
+/// slice that draws these on Vulkan is the one that can see a difference, and
+/// it inherits a named decision rather than an accident.
+///
+internal sealed class RhiCompositeTextureArrayBackend : ICompositeTextureArrayBackend
+{
+ private readonly Gpu.IGpuDevice _device;
+ private readonly Gpu.IGpuSampler _sampler;
+
+ internal RhiCompositeTextureArrayBackend(Gpu.IGpuDevice device)
+ {
+ _device = device ?? throw new ArgumentNullException(nameof(device));
+ _sampler = device.CreateSampler(Gpu.GpuSamplerDescription.WorldClamp with
+ {
+ MipFilter = Gpu.GpuMipFilter.None,
+ });
+ }
+
+ ///
+ /// The GL backend reads GL_MAX_ARRAY_TEXTURE_LAYERS. The pinned
+ /// has no array-layer field and §3.3 is
+ /// frozen, so this reports Vulkan's guaranteed maxImageArrayLayers
+ /// minimum of 256. That is not a limitation in practice:
+ /// caps every
+ /// array at 64, so the true device limit is never the binding constraint.
+ ///
+ public int MaximumArrayLayers => 256;
+
+ public CompositeTextureArrayResource Create(int width, int height, int capacity)
+ {
+ Gpu.IGpuTexture? image = null;
+ try
+ {
+ image = _device.CreateTexture(new Gpu.GpuTextureDescription(
+ $"composite-array-{width}x{height}x{capacity}",
+ Gpu.GpuTextureKind.Texture2DArray,
+ Gpu.GpuTextureFormat.Rgba8Unorm,
+ width,
+ height,
+ capacity,
+ MipLevelCount: 1));
+ Gpu.GpuTextureSlot slot = _device.RegisterTexture(image, _sampler);
+ return new CompositeTextureArrayResource
+ {
+ Name = 0,
+ Handle = 0,
+ Image = image,
+ Slot = slot,
+ Width = width,
+ Height = height,
+ Capacity = capacity,
+ Bytes = checked((long)width * height * 4L * capacity),
+ };
+ }
+ catch
+ {
+ image?.Dispose();
+ throw;
+ }
+ }
+
+ public void Upload(CompositeTextureArrayResource resource, int layer, byte[] rgba) =>
+ RequireImage(resource).Upload(0, layer, rgba);
+
+ ///
+ /// On GL this makes a bindless handle non-resident after retiring its table
+ /// entry. There is no residency on the RHI arm, so retiring the entry is the
+ /// whole of it — and it is a slot the device defers behind its own
+ /// retirement queue, exactly as the GL arm's release does.
+ ///
+ public void MakeNonResident(CompositeTextureArrayResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ if (resource.Slot.IsAssigned)
+ _device.ReleaseTextureSlot(resource.Slot);
+ }
+
+ public void Delete(CompositeTextureArrayResource resource) => RequireImage(resource).Dispose();
+
+ private static Gpu.IGpuTexture RequireImage(CompositeTextureArrayResource resource)
+ {
+ ArgumentNullException.ThrowIfNull(resource);
+ return resource.Image
+ ?? throw new InvalidOperationException(
+ "This composite resource was created by the GL backend and has no RHI image.");
+ }
+}
+
///
/// Pools per-entity material composites into dimension-compatible texture
/// arrays. Retail releases the owning CSurface reference immediately. This
diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs
index aff1a281..1b4286aa 100644
--- a/src/AcDream.App/Rendering/TerrainAtlas.cs
+++ b/src/AcDream.App/Rendering/TerrainAtlas.cs
@@ -31,7 +31,7 @@ namespace AcDream.App.Rendering;
///
public sealed unsafe class TerrainAtlas : IDisposable
{
- private readonly GL _gl;
+ private readonly GL? _gl;
// --- Terrain atlas (unchanged public API from Phase 2b) ---
public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer
@@ -93,6 +93,12 @@ public sealed unsafe class TerrainAtlas : IDisposable
internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) GetTextureSlots(GlGpuDevice device)
{
ArgumentNullException.ThrowIfNull(device);
+ if (_rhi is not null)
+ {
+ throw new InvalidOperationException(
+ "This TerrainAtlas owns IGpuTexture arrays; ask it for TextureSlots, not for GL handles.");
+ }
+
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return texture slots.");
@@ -113,6 +119,98 @@ public sealed unsafe class TerrainAtlas : IDisposable
return (_terrainSlot, _alphaSlot);
}
+ ///
+ /// Campaign V slice V6i-2: the backend-neutral arm. When present, both
+ /// arrays are s the device created and both slots
+ /// were registered at build time, so is a field
+ /// read rather than a residency negotiation. and
+ /// are 0 and no GL handle exists at all.
+ ///
+ /// V4t moved the TABLE ENTRY to the device but left CREATION with this
+ /// class, which is exactly the remainder plan §5.5.11 recorded: "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." This is that work, expressed as a second construction
+ /// path rather than a rewrite, so the GL path's calls are textually
+ /// unchanged.
+ ///
+ private sealed class RhiArrays(
+ IGpuDevice device,
+ IGpuTexture terrain,
+ IGpuTexture alpha,
+ IGpuSampler alphaSampler)
+ {
+ public IGpuDevice Device { get; } = device;
+ public IGpuTexture Terrain { get; } = terrain;
+ public IGpuTexture Alpha { get; } = alpha;
+ public IGpuSampler AlphaSampler { get; } = alphaSampler;
+ public IGpuSampler? TerrainSampler { get; set; }
+ public GpuTextureSlot TerrainSlot { get; set; } = GpuTextureSlot.Unassigned;
+ public GpuTextureSlot AlphaSlot { get; set; } = GpuTextureSlot.Unassigned;
+ }
+
+ private readonly RhiArrays? _rhi;
+
+ ///
+ /// True when this atlas owns arrays rather than raw
+ /// GL names. The two construction paths are mutually exclusive.
+ ///
+ internal bool IsBackendNeutral => _rhi is not null;
+
+ ///
+ /// The device-table slots for the terrain and alpha arrays on the
+ /// backend-neutral arm. Registered once at build time and re-registered only
+ /// by , which changes the sampler.
+ ///
+ internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) TextureSlots =>
+ _rhi is null
+ ? throw new InvalidOperationException(
+ "This TerrainAtlas owns GL names; ask it for GetTextureSlots(GlGpuDevice).")
+ : (_rhi.TerrainSlot, _rhi.AlphaSlot);
+
+ ///
+ /// Retail's terrain arrays are trilinear-filtered with the highest anisotropy
+ /// the quality preset allows; lowers it. The GL
+ /// path sets GL_TEXTURE_MAX_ANISOTROPY to 16 at build time, so the
+ /// backend-neutral path starts at the same value.
+ ///
+ private const float RetailMaxAnisotropy = 16f;
+
+ private TerrainAtlas(
+ IGpuDevice device,
+ IGpuTexture terrain,
+ IGpuTexture alpha,
+ IGpuSampler alphaSampler,
+ IReadOnlyDictionary map,
+ int layerCount,
+ IReadOnlyList tilingByLayer,
+ int alphaLayerCount,
+ IReadOnlyList cornerLayers,
+ IReadOnlyList sideLayers,
+ IReadOnlyList roadLayers,
+ IReadOnlyList cornerTCodes,
+ IReadOnlyList sideTCodes,
+ IReadOnlyList roadRCodes)
+ {
+ _gl = null;
+ _bindless = null;
+ _rhi = new RhiArrays(device, terrain, alpha, alphaSampler);
+ GlTexture = 0;
+ GlAlphaTexture = 0;
+ TerrainTypeToLayer = map;
+ LayerCount = layerCount;
+ TilingByLayer = tilingByLayer;
+ AlphaLayerCount = alphaLayerCount;
+ CornerAlphaLayers = cornerLayers;
+ SideAlphaLayers = sideLayers;
+ RoadAlphaLayers = roadLayers;
+ CornerAlphaTCodes = cornerTCodes;
+ SideAlphaTCodes = sideTCodes;
+ RoadAlphaRCodes = roadRCodes;
+ _rhi.AlphaSlot = device.RegisterTexture(alpha, alphaSampler);
+ ApplyAnisotropic(RetailMaxAnisotropy);
+ }
+
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
@@ -179,24 +277,21 @@ public sealed unsafe class TerrainAtlas : IDisposable
}
}
- private static TerrainAtlas BuildCore(
- GL gl,
+ ///
+ /// Campaign V slice V6i-2: the decode both construction paths share.
+ /// Splitting it out is what keeps the CPU logic single while the upload
+ /// forks — plan §3.1's rule. Nothing here touches a graphics API.
+ ///
+ private readonly record struct TerrainLayerDecode(
+ Dictionary DecodedByType,
+ Dictionary TilingByType,
+ int MaxWidth,
+ int MaxHeight);
+
+ private static TerrainLayerDecode DecodeTerrainLayers(
IDatReaderWriter dats,
- Wb.BindlessSupport? bindless,
- GlTextureConstructionTransaction textures)
+ IReadOnlyList terrainDesc)
{
- var region = dats.Get(0x13000000u)
- ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
-
- var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
- var terrainDesc = texMerge?.TerrainDesc;
- if (terrainDesc is null || terrainDesc.Count == 0)
- {
- Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
- return BuildFallback(gl, bindless, textures);
- }
-
- // ---- Terrain atlas (unchanged Phase 2b logic) ----
var decodedByType = new Dictionary();
var tilingByType = new Dictionary();
int maxW = 1, maxH = 1;
@@ -242,6 +337,32 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (decoded.Height > maxH) maxH = decoded.Height;
}
+ return new TerrainLayerDecode(decodedByType, tilingByType, maxW, maxH);
+ }
+
+ private static TerrainAtlas BuildCore(
+ GL gl,
+ IDatReaderWriter dats,
+ Wb.BindlessSupport? bindless,
+ GlTextureConstructionTransaction textures)
+ {
+ var region = dats.Get(0x13000000u)
+ ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
+
+ var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
+ var terrainDesc = texMerge?.TerrainDesc;
+ if (terrainDesc is null || terrainDesc.Count == 0)
+ {
+ Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
+ return BuildFallback(gl, bindless, textures);
+ }
+
+ // ---- Terrain atlas (unchanged Phase 2b logic) ----
+ TerrainLayerDecode decode = DecodeTerrainLayers(dats, terrainDesc);
+ Dictionary decodedByType = decode.DecodedByType;
+ Dictionary tilingByType = decode.TilingByType;
+ int maxW = decode.MaxWidth, maxH = decode.MaxHeight;
+
int layerCount = decodedByType.Count;
var map = new Dictionary();
uint tex = TrackedTextureConstruction.Create(
@@ -321,11 +442,24 @@ public sealed unsafe class TerrainAtlas : IDisposable
IReadOnlyList corner, IReadOnlyList side, IReadOnlyList road,
IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes);
- private static AlphaAtlasBuildResult BuildAlphaAtlas(
- GL gl,
+ ///
+ /// Slice V6i-2: the alpha-map decode, shared by both construction paths for
+ /// the same reason is.
+ ///
+ private sealed record AlphaLayerDecode(
+ List Decoded,
+ List CornerLayers,
+ List SideLayers,
+ List RoadLayers,
+ List CornerTCodes,
+ List SideTCodes,
+ List RoadRCodes,
+ int MaxWidth,
+ int MaxHeight);
+
+ private static AlphaLayerDecode DecodeAlphaLayers(
IDatReaderWriter dats,
- DatReaderWriter.Types.TexMerge texMerge,
- GlTextureConstructionTransaction textures)
+ DatReaderWriter.Types.TexMerge texMerge)
{
var decoded = new List();
var cornerLayers = new List();
@@ -375,6 +509,42 @@ public sealed unsafe class TerrainAtlas : IDisposable
}
}
+ // Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
+ // Fall back to the max observed so a stray mismatch doesn't crash us.
+ int decodedMaxW = 1, decodedMaxH = 1;
+ foreach (var d in decoded)
+ {
+ if (d.Width > decodedMaxW) decodedMaxW = d.Width;
+ if (d.Height > decodedMaxH) decodedMaxH = d.Height;
+ }
+
+ return new AlphaLayerDecode(
+ decoded,
+ cornerLayers,
+ sideLayers,
+ roadLayers,
+ cornerTCodes,
+ sideTCodes,
+ roadRCodes,
+ decodedMaxW,
+ decodedMaxH);
+ }
+
+ private static AlphaAtlasBuildResult BuildAlphaAtlas(
+ GL gl,
+ IDatReaderWriter dats,
+ DatReaderWriter.Types.TexMerge texMerge,
+ GlTextureConstructionTransaction textures)
+ {
+ AlphaLayerDecode decode = DecodeAlphaLayers(dats, texMerge);
+ List decoded = decode.Decoded;
+ List cornerLayers = decode.CornerLayers;
+ List sideLayers = decode.SideLayers;
+ List roadLayers = decode.RoadLayers;
+ List cornerTCodes = decode.CornerTCodes;
+ List sideTCodes = decode.SideTCodes;
+ List roadRCodes = decode.RoadRCodes;
+
if (decoded.Count == 0)
{
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
@@ -399,14 +569,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
cornerTCodes, sideTCodes, roadRCodes);
}
- // Alpha maps should all be uniform size (WorldBuilder asserts 512×512).
- // Fall back to the max observed so a stray mismatch doesn't crash us.
- int aMaxW = 1, aMaxH = 1;
- foreach (var d in decoded)
- {
- if (d.Width > aMaxW) aMaxW = d.Width;
- if (d.Height > aMaxH) aMaxH = d.Height;
- }
+ int aMaxW = decode.MaxWidth, aMaxH = decode.MaxHeight;
uint glAlpha = TrackedTextureConstruction.Create(
textures,
@@ -450,6 +613,172 @@ public sealed unsafe class TerrainAtlas : IDisposable
cornerTCodes, sideTCodes, roadRCodes);
}
+ ///
+ /// Campaign V slice V6i-2: build both arrays through
+ /// .
+ ///
+ /// Same DAT reads, same decode, same layer ordering and the same
+ /// resize-to-max policy as — only the upload differs,
+ /// which is the whole point of splitting the decode out. The terrain array
+ /// gets a full mip chain ( blits
+ /// it, because RGBA8 is a legal blit destination) and a repeat/anisotropic
+ /// sampler; the alpha array is single-level and clamped, exactly as the GL
+ /// texture parameters say.
+ ///
+ internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentNullException.ThrowIfNull(dats);
+
+ var region = dats.Get(0x13000000u)
+ ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
+ var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
+ var terrainDesc = texMerge?.TerrainDesc;
+
+ Dictionary decodedByType;
+ Dictionary tilingByType;
+ int maxW, maxH;
+ if (terrainDesc is null || terrainDesc.Count == 0)
+ {
+ Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
+ decodedByType = new Dictionary { [0u] = WhitePixel() };
+ tilingByType = [];
+ maxW = 1;
+ maxH = 1;
+ }
+ else
+ {
+ TerrainLayerDecode decode = DecodeTerrainLayers(dats, terrainDesc);
+ decodedByType = decode.DecodedByType;
+ tilingByType = decode.TilingByType;
+ maxW = decode.MaxWidth;
+ maxH = decode.MaxHeight;
+ }
+
+ AlphaLayerDecode alpha = texMerge is null
+ ? new AlphaLayerDecode([], [], [], [], [], [], [], 1, 1)
+ : DecodeAlphaLayers(dats, texMerge);
+ List alphaDecoded = alpha.Decoded;
+ int alphaLayerCount = Math.Max(1, alphaDecoded.Count);
+ int alphaW = alphaDecoded.Count == 0 ? 1 : alpha.MaxWidth;
+ int alphaH = alphaDecoded.Count == 0 ? 1 : alpha.MaxHeight;
+
+ int layerCount = decodedByType.Count;
+ int mipLevels = Wb.RhiWorldTextureArray.MipLevelsFor(maxW, maxH);
+ IGpuTexture? terrainTexture = null;
+ IGpuTexture? alphaTexture = null;
+ try
+ {
+ terrainTexture = device.CreateTexture(new GpuTextureDescription(
+ "terrain-atlas",
+ GpuTextureKind.Texture2DArray,
+ GpuTextureFormat.Rgba8Unorm,
+ maxW,
+ maxH,
+ layerCount,
+ mipLevels));
+
+ var map = new Dictionary(layerCount);
+ int layerIdx = 0;
+ foreach (var kvp in decodedByType)
+ {
+ byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
+ terrainTexture.Upload(0, layerIdx, buffer);
+ map[kvp.Key] = (uint)layerIdx;
+ layerIdx++;
+ }
+
+ // A.5 T19's mip chain, built by the device rather than by
+ // glGenerateMipmap. RGBA8 blits, so this is the GPU path.
+ terrainTexture.GenerateMipChain();
+
+ var tilingByLayer = TerrainTextureTilingTable.Build(
+ map.Select(entry =>
+ (entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount)
+ ? repeatCount
+ : 1u)));
+
+ alphaTexture = device.CreateTexture(new GpuTextureDescription(
+ "terrain-alpha-atlas",
+ GpuTextureKind.Texture2DArray,
+ GpuTextureFormat.Rgba8Unorm,
+ alphaW,
+ alphaH,
+ alphaLayerCount,
+ MipLevelCount: 1));
+ if (alphaDecoded.Count == 0)
+ {
+ Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
+ alphaTexture.Upload(0, 0, [0xFF, 0xFF, 0xFF, 0xFF]);
+ }
+ else
+ {
+ for (int i = 0; i < alphaDecoded.Count; i++)
+ alphaTexture.Upload(0, i, ResizeRgba8Nearest(alphaDecoded[i], alphaW, alphaH));
+ }
+
+ IGpuSampler alphaSampler = device.CreateSampler(GpuSamplerDescription.WorldClamp with
+ {
+ MipFilter = GpuMipFilter.None,
+ });
+
+ Console.WriteLine(
+ $"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} ({mipLevels} mip levels)");
+ Console.WriteLine(
+ $"AlphaAtlas: {alphaLayerCount} layers at {alphaW}x{alphaH} "
+ + $"(corners={alpha.CornerLayers.Count}, sides={alpha.SideLayers.Count}, "
+ + $"roads={alpha.RoadLayers.Count})");
+
+ return new TerrainAtlas(
+ device,
+ terrainTexture,
+ alphaTexture,
+ alphaSampler,
+ map,
+ layerCount,
+ tilingByLayer,
+ alphaLayerCount,
+ alpha.CornerLayers,
+ alpha.SideLayers,
+ alpha.RoadLayers,
+ alpha.CornerTCodes,
+ alpha.SideTCodes,
+ alpha.RoadRCodes);
+ }
+ catch
+ {
+ alphaTexture?.Dispose();
+ terrainTexture?.Dispose();
+ throw;
+ }
+ }
+
+ private static DecodedTexture WhitePixel() =>
+ new([0xFF, 0xFF, 0xFF, 0xFF], 1, 1);
+
+ ///
+ /// Slice V6i-2: the backend-neutral anisotropy change. GL mutates a texture
+ /// parameter; Vulkan bakes filtering into the sampler, so the level change
+ /// is a new sampler and a re-registration of the terrain slot. The
+ /// superseded slot is released in the same step, exactly as the bindless arm
+ /// does when a handle changes.
+ ///
+ private void ApplyAnisotropic(float level)
+ {
+ RhiArrays rhi = _rhi!;
+ IGpuSampler sampler = rhi.Device.CreateSampler(GpuSamplerDescription.WorldRepeat with
+ {
+ MaxAnisotropy = Math.Max(1f, level),
+ });
+ if (ReferenceEquals(rhi.TerrainSampler, sampler) && rhi.TerrainSlot.IsAssigned)
+ return;
+
+ if (rhi.TerrainSlot.IsAssigned)
+ rhi.Device.ReleaseTextureSlot(rhi.TerrainSlot);
+ rhi.TerrainSampler = sampler;
+ rhi.TerrainSlot = rhi.Device.RegisterTexture(rhi.Terrain, sampler);
+ }
+
private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded)
{
decoded = DecodedTexture.Magenta;
@@ -553,25 +882,33 @@ public sealed unsafe class TerrainAtlas : IDisposable
///
public void SetAnisotropic(int level)
{
+ if (_rhi is not null)
+ {
+ ApplyAnisotropic(level);
+ Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
+ return;
+ }
+
+ GL gl = RequireGl();
void Mutate()
{
_anisotropyBindingMutation.Execute(
() => unchecked((uint)GlResourceCommand.Execute(
- _gl,
+ gl,
"read terrain-array binding before anisotropy mutation",
- () => _gl.GetInteger(GetPName.TextureBinding2DArray))),
+ () => gl.GetInteger(GetPName.TextureBinding2DArray))),
binding => GlResourceCommand.Execute(
- _gl,
+ gl,
"set terrain-array binding for anisotropy mutation",
- () => _gl.BindTexture(TextureTarget.Texture2DArray, binding)),
+ () => gl.BindTexture(TextureTarget.Texture2DArray, binding)),
GlTexture,
() => GlResourceCommand.Execute(
- _gl,
+ gl,
"set terrain atlas anisotropy",
() =>
{
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
- _gl.TexParameter(
+ gl.TexParameter(
TextureTarget.Texture2DArray,
(TextureParameterName)0x84FE,
(float)level);
@@ -586,8 +923,30 @@ public sealed unsafe class TerrainAtlas : IDisposable
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
+ ///
+ /// Slice V6i-2: the GL arm's context. The two construction paths are
+ /// mutually exclusive, so a null here means a backend-neutral atlas reached
+ /// GL-only code — a programming error, not a runtime condition.
+ ///
+ private GL RequireGl() =>
+ _gl ?? throw new InvalidOperationException(
+ "This TerrainAtlas owns IGpuTexture arrays and has no GL context.");
+
public void Dispose()
{
+ if (_rhi is not null)
+ {
+ // Slice V4t's teardown rule holds on both arms: the device dies with
+ // its callers, so the table entries are not released here —
+ // deferring through a possibly-disposed retirement queue would turn
+ // a clean shutdown into a throw. The images themselves route through
+ // the device's retirement queue, which is what IGpuTexture.Dispose
+ // does.
+ _rhi.Alpha.Dispose();
+ _rhi.Terrain.Dispose();
+ return;
+ }
+
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage(
"terrain atlas bindless residency",
@@ -602,13 +961,13 @@ public sealed unsafe class TerrainAtlas : IDisposable
new ResourceShutdownOperation(
"delete terrain texture",
() => GlResourceCommand.DeleteTexture(
- _gl,
+ RequireGl(),
GlTexture,
$"delete terrain atlas texture {GlTexture}")),
new ResourceShutdownOperation(
"delete alpha texture",
() => GlResourceCommand.DeleteTexture(
- _gl,
+ RequireGl(),
GlAlphaTexture,
$"delete terrain alpha texture {GlAlphaTexture}")),
]));
diff --git a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs
index 1115cede..4a6e263f 100644
--- a/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs
+++ b/src/AcDream.App/Rendering/Wb/ManagedGLTextureArray.cs
@@ -9,11 +9,21 @@ using System.Runtime.InteropServices;
using AcDream.App.Rendering;
namespace AcDream.App.Rendering.Wb {
- public class ManagedGLTextureArray : ITextureArray {
+ public class ManagedGLTextureArray : ITextureArray, IWorldTextureArray {
private readonly bool[] _usedLayers;
private readonly GL GL;
private readonly OpenGLGraphicsDevice _device;
private readonly ILogger _logger;
+ ///
+ /// Campaign V slice V6i-2: the device whose one texture table this
+ /// array's two resident handles are interned into. Before this slice
+ /// ObjectMeshManager read the handles off this object and did the
+ /// interning itself; a 64-bit bindless handle cannot cross to Vulkan, so
+ /// the array now answers instead. Null only
+ /// for the legacy OpenGLGraphicsDevice.CreateTextureArrayInternal
+ /// entry points, which no shared atlas uses.
+ ///
+ private readonly AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? _worldTextureTable;
private static int _nextId = 0;
private bool _needsMipmapRegeneration = false;
private readonly bool _isCompressed;
@@ -53,7 +63,15 @@ namespace AcDream.App.Rendering.Wb {
}
public ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
- int size, ILogger logger, TextureParameters? texParams = null) {
+ int size, ILogger logger, TextureParameters? texParams = null)
+ : this(graphicsDevice, format, width, height, size, logger, worldTextureTable: null, texParams) {
+ }
+
+ internal ManagedGLTextureArray(OpenGLGraphicsDevice graphicsDevice, TextureFormat format, int width, int height,
+ int size, ILogger logger,
+ AcDream.App.Rendering.Gpu.Gl.GlGpuDevice? worldTextureTable,
+ TextureParameters? texParams = null) {
+ _worldTextureTable = worldTextureTable;
var p = texParams ?? TextureParameters.Default;
if (width <= 0 || height <= 0 || size <= 0) {
throw new ArgumentException($"Invalid texture array dimensions: {width}x{height}x{size}");
@@ -532,6 +550,48 @@ namespace AcDream.App.Rendering.Wb {
Volatile.Read(ref _disposeQueued) != 0
&& Volatile.Read(ref _disposeRelease) is null;
+ bool IWorldTextureArray.HasDurableDisposeOwnership => HasDurableDisposeOwnership;
+
+ bool IWorldTextureArray.IsPhysicalRetirementComplete => IsPhysicalRetirementComplete;
+
+ ///
+ /// Campaign V slice V6i-2: this array's device-table slot for the
+ /// requested address mode.
+ ///
+ /// The interning call is the one ObjectMeshManager made
+ /// itself before this slice, moved one level down so the caller can be
+ /// written against instead of against a
+ /// 64-bit ARB_bindless_texture handle that has no Vulkan
+ /// spelling. It is idempotent by handle, which is why it stays a per-batch
+ /// call rather than becoming cached state — exactly as before.
+ ///
+ AcDream.App.Rendering.Gpu.GpuTextureSlot IWorldTextureArray.ResolveSlot(bool wrapping) {
+ if (_worldTextureTable is null)
+ return AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
+ ulong handle = wrapping ? _retiredWrapHandle : _retiredClampHandle;
+ if (handle == 0)
+ handle = wrapping ? BindlessWrapHandle : BindlessClampHandle;
+ return _worldTextureTable.RegisterWorldTextureHandle(handle);
+ }
+
+ ///
+ /// Retires both table entries. zeroes the public
+ /// handle properties, so the values are captured there and read from the
+ /// captures here — this is called after physical retirement completes,
+ /// which is necessarily after Dispose.
+ ///
+ public void ReleaseTextureSlots() {
+ if (_worldTextureTable is null)
+ return;
+ _worldTextureTable.ReleaseWorldTextureHandle(_retiredWrapHandle);
+ _worldTextureTable.ReleaseWorldTextureHandle(_retiredClampHandle);
+ _retiredWrapHandle = 0;
+ _retiredClampHandle = 0;
+ }
+
+ private ulong _retiredWrapHandle;
+ private ulong _retiredClampHandle;
+
public void Unbind() {
GL.BindTexture(GLEnum.Texture2DArray, 0);
GLHelpers.CheckErrors(GL);
@@ -555,6 +615,12 @@ namespace AcDream.App.Rendering.Wb {
ulong bindlessClampHandle = BindlessClampHandle;
long textureBytes = CalculateTotalSize();
+ // Slice V6i-2: the handles the two table entries are keyed by. The
+ // properties are zeroed below, so ReleaseTextureSlots — which runs
+ // only once physical retirement completes — reads these captures.
+ _retiredWrapHandle = bindlessWrapHandle;
+ _retiredClampHandle = bindlessClampHandle;
+
NativePtr = 0;
BindlessWrapHandle = 0;
BindlessClampHandle = 0;
diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
index e3dd2b17..5e6691ca 100644
--- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
+++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs
@@ -133,6 +133,12 @@ namespace AcDream.App.Rendering.Wb
internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => _worldTextureTable;
+ ///
+ /// Campaign V slice V6i-2: how a shared atlas's physical array is made.
+ /// Composed once; see .
+ ///
+ private readonly IWorldTextureArrayFactory _atlasArrays;
+
///
/// The immutable prepared-payload source is injected by composition.
/// Production uses the validated pak; UI Studio explicitly supplies the
@@ -190,13 +196,13 @@ namespace AcDream.App.Rendering.Wb
// the owners here makes that overlap both retryable and observable.
private readonly List _retiringAtlases = [];
- // Campaign V slice V4t: the two bindless handles a retiring atlas had
- // when it left the live set. ManagedGLTextureArray.Dispose zeroes its
- // own copies as its first act, so the values must be snapshotted at the
- // moment of eviction to be releasable from the device's texture table
- // once physical retirement completes.
- private readonly Dictionary
- _retiringAtlasTextureHandles = [];
+ // Campaign V slice V4t recorded the two bindless handles a retiring
+ // atlas held so its table entries could be released once physical
+ // retirement completed. Slice V6i-2 moved that bookkeeping into the
+ // array itself — a 64-bit ARB_bindless_texture handle has no Vulkan
+ // spelling, so the array answers IWorldTextureArray.ReleaseTextureSlots
+ // and each implementation snapshots whatever it needs. The retiring set
+ // still carries the owners, which is what makes the release retryable.
// CPU-side cache for prepared mesh data (to avoid re-reading/decoding from DAT)
private readonly CpuMeshUploadCache _cpuMeshCache;
@@ -459,6 +465,14 @@ namespace AcDream.App.Rendering.Wb
// OpenGLGraphicsDevice — so the backend cast states that fact rather
// than narrowing anything.
_worldTextureTable = (AcDream.App.Rendering.Gpu.Gl.GlGpuDevice)gpuDevice;
+ // Slice V6i-2: which physical array a shared atlas gets is decided
+ // once, here. Everything below — capacity, slot allocation, ref
+ // counting, layer retirement, eviction — is written against
+ // IWorldTextureArray and does not branch on the backend.
+ _atlasArrays = new GlWorldTextureArrayFactory(
+ graphicsDevice,
+ _worldTextureTable,
+ logger ?? throw new ArgumentNullException(nameof(logger)));
_preparedAssets = preparedAssets
?? throw new ArgumentNullException(nameof(preparedAssets));
_logger = logger
@@ -582,9 +596,6 @@ namespace AcDream.App.Rendering.Wb
}
_dirtyAtlases.Remove(victim);
_retiringAtlases.Add(victim);
- _retiringAtlasTextureHandles[victim] = (
- victim.TextureArray.BindlessWrapHandle,
- victim.TextureArray.BindlessClampHandle);
victim.Dispose();
RemoveCompletedAtlasRetirements();
return true;
@@ -611,19 +622,11 @@ namespace AcDream.App.Rendering.Wb
// the interim per-renderer tables grew without bound instead, so
// this is stricter than what it replaces, not looser. The
// device defers the index itself behind its retirement queue.
- ReleaseAtlasTextureSlots(_retiringAtlases[i]);
+ _retiringAtlases[i].TextureArray.ReleaseTextureSlots();
_retiringAtlases.RemoveAt(i);
}
}
- private void ReleaseAtlasTextureSlots(TextureAtlasManager atlas)
- {
- if (!_retiringAtlasTextureHandles.Remove(atlas, out (ulong Wrap, ulong Clamp) handles))
- return;
- _worldTextureTable.ReleaseWorldTextureHandle(handles.Wrap);
- _worldTextureTable.ReleaseWorldTextureHandle(handles.Clamp);
- }
-
private void OnAtlasGpuSafeEmpty(TextureAtlasManager atlas)
{
if (IsDisposed || !atlas.IsGpuSafeEmpty || _safeEmptyAtlases.Contains(atlas))
@@ -2042,7 +2045,7 @@ namespace AcDream.App.Rendering.Wb
if (atlasManager == null)
{
atlasManager = new TextureAtlasManager(
- _graphicsDevice,
+ _atlasArrays,
format.Width,
format.Height,
format.Format,
@@ -2095,18 +2098,16 @@ namespace AcDream.App.Rendering.Wb
legacyIndexBuffers.Add((ibo, indexArray.Length * sizeof(ushort)));
}
- // Campaign V slice V4t: intern the atlas's resident
- // handle into the device's one texture table and carry
- // the slot. Registration is idempotent by handle, so the
- // many batches sharing an atlas share its entry;
- // ManagedGLTextureArray still owns the residency and the
- // GL texture, and the entry is retired when the array's
- // physical retirement completes.
- ulong bindlessHandle = batch.HasWrappingUVs
- ? atlasManager.TextureArray.BindlessWrapHandle
- : atlasManager.TextureArray.BindlessClampHandle;
+ // Campaign V slice V4t interned the atlas's resident
+ // handle into the device's one texture table here and
+ // carried the slot. Slice V6i-2 asks the array for the
+ // slot instead: the GL array makes the same idempotent
+ // interning call one level down, and the RHI array
+ // returns the entry it registered at construction. The
+ // array still owns residency and the image; the entry is
+ // retired when its physical retirement completes.
AcDream.App.Rendering.Gpu.GpuTextureSlot textureSlot =
- _worldTextureTable.RegisterWorldTextureHandle(bindlessHandle);
+ atlasManager.TextureArray.ResolveSlot(batch.HasWrappingUVs);
renderBatches.Add(new ObjectRenderBatch
{
@@ -2804,13 +2805,12 @@ namespace AcDream.App.Rendering.Wb
_uploadRollbacks.Clear();
_uploadRollbackQueue.Clear();
_globalAtlases.Clear();
+ // Slice V4t: teardown drops the retiring owners without releasing
+ // their table entries. The device is torn down alongside this
+ // manager, so there is nothing left to recycle a slot into — and
+ // asking a possibly-already-disposed device to defer work through
+ // its retirement queue would turn a clean shutdown into a throw.
_retiringAtlases.Clear();
- // Slice V4t: teardown drops the snapshots without releasing their
- // table entries. The device is torn down alongside this manager, so
- // there is nothing left to recycle a slot into — and asking a
- // possibly-already-disposed device to defer work through its
- // retirement queue would turn a clean shutdown into a throw.
- _retiringAtlasTextureHandles.Clear();
_dirtyAtlases.Clear();
_safeEmptyAtlases.Clear();
_currentNonArenaGpuMemory = 0;
diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs
index ee9ed87f..00ceb795 100644
--- a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs
+++ b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs
@@ -44,7 +44,6 @@ namespace AcDream.App.Rendering.Wb {
///
public class TextureAtlasManager : IDisposable {
private static uint _nextSlot = 1;
- private readonly OpenGLGraphicsDevice _graphicsDevice;
private readonly int _textureWidth;
private readonly int _textureHeight;
private readonly TextureFormat _format;
@@ -59,7 +58,16 @@ namespace AcDream.App.Rendering.Wb {
internal const int MaximumArrayLayers = 32;
public uint Slot { get; }
- public ManagedGLTextureArray TextureArray { get; private set; } = null!;
+
+ ///
+ /// Campaign V slice V6i-2: the physical array, no longer typed to a
+ /// backend. Which implementation this is was decided once, at
+ /// composition, by the handed to
+ /// the constructor — nothing in this class or in
+ /// ObjectMeshManager's atlas policy branches on it.
+ ///
+ internal IWorldTextureArray TextureArray { get; private set; } = null!;
+
public int UsedSlots => _textureIndices.Count;
public int TotalSlots => TextureArray?.Size ?? 0;
public int AvailableSlots => _slots.AvailableCount;
@@ -76,21 +84,21 @@ namespace AcDream.App.Rendering.Wb {
internal int Height => _textureHeight;
internal TextureFormat Format => _format;
- public TextureAtlasManager(
- OpenGLGraphicsDevice graphicsDevice,
+ internal TextureAtlasManager(
+ IWorldTextureArrayFactory arrays,
int width,
int height,
TextureFormat format = TextureFormat.RGBA8,
Action? onGpuSafeEmpty = null) {
+ ArgumentNullException.ThrowIfNull(arrays);
Slot = _nextSlot++;
- _graphicsDevice = graphicsDevice;
_textureWidth = width;
_textureHeight = height;
_format = format;
_onGpuSafeEmpty = onGpuSafeEmpty;
- _layerRetirement = new TextureAtlasLayerRetirement(graphicsDevice.ResourceRetirement);
+ _layerRetirement = new TextureAtlasLayerRetirement(arrays.Retirement);
int capacity = CalculateInitialCapacity(width, height, format);
- TextureArray = (ManagedGLTextureArray)graphicsDevice.CreateTextureArrayInternal(format, width, height, capacity, TextureParameters.ClampToEdge);
+ TextureArray = arrays.CreateClampedArray(format, width, height, capacity);
_slots = new TextureAtlasSlotAllocator(TextureArray.Size);
}
diff --git a/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
new file mode 100644
index 00000000..846832ee
--- /dev/null
+++ b/src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
@@ -0,0 +1,443 @@
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Rendering.Gpu.Gl;
+using AcDream.App.Rendering.Gpu.Vk;
+using Chorizite.Core.Render.Enums;
+using Microsoft.Extensions.Logging;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering.Wb;
+
+///
+/// Campaign V slice V6i-2: the shared world texture array, expressed without
+/// naming a backend.
+///
+/// V4t moved the TABLE ENTRY of every world texture to the device and
+/// deliberately left CREATION with the caches — plan §5.5.11 records why, and
+/// §5.5.12 item 1 hands the remainder forward: "the missing piece is an
+/// ITextureArray implementation over , not a
+/// codec." This is that interface. and
+/// implement it, and which one exists is
+/// decided once at composition by —
+/// never per call, so the GL path executes exactly the statements it executed
+/// before.
+///
+/// The slot, not the handle, is the seam. Before this slice
+/// ObjectMeshManager read BindlessWrapHandle/
+/// BindlessClampHandle off the concrete GL array and interned them into
+/// the device table itself. A 64-bit ARB_bindless_texture handle is
+/// unspellable on Vulkan, so the array now answers the question the caller was
+/// really asking — — and each implementation gets
+/// there its own way: the GL array interns its resident handle (the same
+/// idempotent call, one level down), while the RHI array registered its two
+/// (texture, sampler) pairs at construction and returns a field.
+///
+internal interface IWorldTextureArray : IDisposable
+{
+ /// Array layers allocated. Immutable for the array's lifetime.
+ int Size { get; }
+
+ /// Bytes the whole array occupies including its mip chain.
+ long TotalSizeInBytes { get; }
+
+ ///
+ /// Staged layer payloads not yet handed to the GPU. #105's white-walls
+ /// diagnostic: a count stuck non-zero at standstill means the flush is not
+ /// running.
+ ///
+ int PendingUpdateCount { get; }
+
+ ///
+ /// True once disposal is durably owned by the backend's retirement path.
+ /// refuses to commit its own logical
+ /// disposal until this is true, so a synchronous enqueue failure still
+ /// retries.
+ ///
+ bool HasDurableDisposeOwnership { get; }
+
+ ///
+ /// True only after every physical release stage has completed. Logical
+ /// disposal can become durable earlier, while a frame fence still owns the
+ /// image.
+ ///
+ bool IsPhysicalRetirementComplete { get; }
+
+ ///
+ /// Stages one layer's decoded payload. Both backends retain it until
+ /// so a burst of layer writes costs one
+ /// GPU submission rather than one per layer.
+ ///
+ void UpdateLayer(int layer, byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType);
+
+ ///
+ /// Flushes staged layers and refreshes the mip chain. Returns the bytes of
+ /// mip data generated, which is what the residency accounting meters.
+ ///
+ long ProcessDirtyUpdates();
+
+ ///
+ /// This array's entry in the device texture table for the requested address
+ /// mode. Idempotent, so a caller may ask per batch rather than caching it.
+ ///
+ GpuTextureSlot ResolveSlot(bool wrapping);
+
+ ///
+ /// Retires both table entries. Called when physical retirement completes,
+ /// never before: until then a submitted frame may still sample through the
+ /// slot. Idempotent.
+ ///
+ void ReleaseTextureSlots();
+}
+
+///
+/// Campaign V slice V6i-2: construction-time backend selection for world texture
+/// arrays.
+///
+/// Plan §3.1 forbids a runtime fork in shared logic, and this is how the
+/// texture stack obeys it: everything above — 's
+/// slot allocation, ref counting, layer retirement and eviction, and
+/// ObjectMeshManager's whole atlas policy — is written once against
+/// , and the only branch in the system is which
+/// factory composition built.
+///
+internal interface IWorldTextureArrayFactory
+{
+ /// The retirement queue array layers and images are released through.
+ IGpuResourceRetirementQueue Retirement { get; }
+
+ ///
+ /// Creates a clamped, mip-mapped 2-D array of
+ /// layers. Clamping is the shared-atlas policy: a layer's neighbours are
+ /// unrelated textures, so wrapping across them would bleed.
+ ///
+ IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers);
+}
+
+///
+/// The GL arm. Delegates to the same OpenGLGraphicsDevice entry point
+/// called directly before this slice, so the
+/// shipping backend's construction is textually unchanged.
+///
+internal sealed class GlWorldTextureArrayFactory(
+ OpenGLGraphicsDevice graphicsDevice,
+ GlGpuDevice worldTextureTable,
+ ILogger logger) : IWorldTextureArrayFactory
+{
+ private readonly OpenGLGraphicsDevice _graphicsDevice = graphicsDevice
+ ?? throw new ArgumentNullException(nameof(graphicsDevice));
+ private readonly GlGpuDevice _worldTextureTable = worldTextureTable
+ ?? throw new ArgumentNullException(nameof(worldTextureTable));
+ private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ public IGpuResourceRetirementQueue Retirement => _graphicsDevice.ResourceRetirement;
+
+ public IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers) =>
+ new ManagedGLTextureArray(
+ _graphicsDevice,
+ format,
+ width,
+ height,
+ layers,
+ _logger,
+ _worldTextureTable,
+ TextureParameters.ClampToEdge);
+}
+
+///
+/// The backend-neutral arm. Creates through
+/// and registers both address modes into the device's one texture table, so an
+/// array is usable from a shader the moment it exists.
+///
+internal sealed class RhiWorldTextureArrayFactory(IGpuDevice device) : IWorldTextureArrayFactory
+{
+ private readonly IGpuDevice _device = device ?? throw new ArgumentNullException(nameof(device));
+
+ public IGpuResourceRetirementQueue Retirement => _device.Retirement;
+
+ public IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers) =>
+ new RhiWorldTextureArray(_device, format, width, height, layers);
+}
+
+///
+/// Campaign V slice V6i-2: a shared world texture array owned as an
+/// .
+///
+/// What differs from the GL array, and why it is not a divergence.
+/// Three things:
+///
+///
+/// - Mip generation for BC formats is CPU-built. The GL array calls
+/// glGenerateMipmap on compressed arrays only to log that it skipped
+/// them; Vulkan cannot blit into a compressed image at all, which is exactly why
+/// V6b built . So a BC array's levels
+/// 1..N-1 are encoded here, deterministically, and uploaded like level 0.
+/// Uncompressed arrays use , which is
+/// the device's blit.
+/// - Filtering lives in the sampler, not the image. GL sets
+/// TexParameter on the texture object; Vulkan bakes it into an immutable
+/// sampler. Both address modes are registered up front because a shared atlas is
+/// sampled both ways by different batches — the same reason the GL array holds
+/// two resident bindless handles.
+/// - There is no anisotropy knob. The GL array reads
+/// graphicsDevice.MaxSupportedAnisotropy at construction. The RHI
+/// sampler takes , and the
+/// quality preset does not reach this class yet — the world arm that draws
+/// through these arrays is the next slice, and it is the one that can gate a
+/// filtering change visually. Until then this asks for the same trilinear
+/// filtering with anisotropy 1, and says so rather than guessing.
+///
+///
+internal sealed class RhiWorldTextureArray : IWorldTextureArray
+{
+ private readonly IGpuDevice _device;
+ private readonly IGpuTexture _texture;
+ private readonly GpuTextureFormat _format;
+ private readonly int _width;
+ private readonly int _height;
+ private readonly int _mipLevelCount;
+ private readonly List _pending = [];
+ private readonly Lock _gate = new();
+
+ private GpuTextureSlot _wrapSlot = GpuTextureSlot.Unassigned;
+ private GpuTextureSlot _clampSlot = GpuTextureSlot.Unassigned;
+ private bool _disposed;
+
+ private readonly record struct PendingLayer(int Layer, byte[] Data);
+
+ internal RhiWorldTextureArray(
+ IGpuDevice device,
+ TextureFormat format,
+ int width,
+ int height,
+ int layers)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ArgumentOutOfRangeException.ThrowIfLessThan(width, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(height, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(layers, 1);
+
+ _device = device;
+ SourceFormat = format;
+ _format = MapFormat(format);
+ _width = width;
+ _height = height;
+ Size = layers;
+ _mipLevelCount = MipLevelsFor(width, height);
+ // The same accounting TextureAtlasManager's eviction budget already
+ // meters on GL: whole mip chain, every layer.
+ TotalSizeInBytes = checked(
+ TextureAtlasManager.CalculateMipChainBytes(width, height, format) * layers);
+
+ IGpuTexture? texture = null;
+ try
+ {
+ texture = device.CreateTexture(new GpuTextureDescription(
+ $"world-atlas-{format}-{width}x{height}x{layers}",
+ GpuTextureKind.Texture2DArray,
+ _format,
+ width,
+ height,
+ layers,
+ _mipLevelCount));
+ _texture = texture;
+
+ // Both address modes up front: a shared atlas is sampled wrapped by
+ // one batch and clamped by the next, which is why the GL array holds
+ // two resident handles. Registering here rather than lazily keeps
+ // ResolveSlot a field read on the hot path.
+ _clampSlot = device.RegisterTexture(
+ texture,
+ device.CreateSampler(GpuSamplerDescription.WorldClamp));
+ _wrapSlot = device.RegisterTexture(
+ texture,
+ device.CreateSampler(GpuSamplerDescription.WorldRepeat));
+ }
+ catch
+ {
+ ReleaseSlotsQuietly();
+ texture?.Dispose();
+ throw;
+ }
+ }
+
+ public int Size { get; }
+
+ public long TotalSizeInBytes { get; }
+
+ public int PendingUpdateCount
+ {
+ get
+ {
+ lock (_gate)
+ return _pending.Count;
+ }
+ }
+
+ ///
+ /// The RHI's routes through the device's
+ /// retirement queue by contract, so ownership is durable the moment Dispose
+ /// returns — there is no publication step that can fail and need retrying,
+ /// which is the hazard the GL array's two-flag protocol exists for.
+ ///
+ public bool HasDurableDisposeOwnership => _disposed;
+
+ ///
+ public bool IsPhysicalRetirementComplete => _disposed;
+
+ public void UpdateLayer(int layer, byte[] data, PixelFormat? uploadPixelFormat, PixelType? uploadPixelType)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ ArgumentNullException.ThrowIfNull(data);
+ ArgumentOutOfRangeException.ThrowIfNegative(layer);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, Size);
+ // The GL array validates the payload against the format's expected byte
+ // count and rejects transfer overrides that contradict it. Reusing that
+ // validator rather than writing a second one keeps the two arms agreeing
+ // on what a well-formed layer is.
+ ManagedGLTextureArray.ValidateUploadPayload(
+ SourceFormat,
+ _width,
+ _height,
+ data.Length,
+ uploadPixelFormat,
+ uploadPixelType);
+
+ lock (_gate)
+ {
+ int existing = _pending.FindLastIndex(p => p.Layer == layer);
+ var update = new PendingLayer(layer, data);
+ if (existing >= 0)
+ _pending[existing] = update;
+ else
+ _pending.Add(update);
+ }
+ }
+
+ public long ProcessDirtyUpdates()
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ PendingLayer[] flush;
+ lock (_gate)
+ {
+ if (_pending.Count == 0)
+ return 0;
+ flush = [.. _pending];
+ }
+
+ long generated = 0;
+ bool compressed = BlockCompressionCodec.IsBlockCompressed(_format);
+ foreach (PendingLayer layer in flush)
+ {
+ _texture.Upload(0, layer.Layer, layer.Data);
+ if (_mipLevelCount <= 1)
+ continue;
+ if (!compressed)
+ continue;
+
+ // Vulkan cannot blit into a compressed image, so a BC chain is built
+ // and uploaded level by level. Deterministic integer arithmetic —
+ // see BlockCompressionMipChain — so two runs produce the same bytes.
+ foreach (BlockCompressionMipChain.Level level in
+ BlockCompressionMipChain.BuildCompressed(
+ _format,
+ layer.Data,
+ _width,
+ _height,
+ _mipLevelCount))
+ {
+ _texture.Upload(level.MipLevel, layer.Layer, level.Data);
+ generated = checked(generated + level.Data.Length);
+ }
+ }
+
+ if (!compressed && _mipLevelCount > 1)
+ {
+ _texture.GenerateMipChain();
+ generated = checked(generated + MipChainBytes());
+ }
+
+ lock (_gate)
+ {
+ // Only the entries this flush actually carried are cleared; a layer
+ // staged while the upload ran stays pending, exactly as the GL
+ // array's retain-on-failure protocol leaves it.
+ foreach (PendingLayer layer in flush)
+ {
+ int index = _pending.FindIndex(p => p.Layer == layer.Layer && ReferenceEquals(p.Data, layer.Data));
+ if (index >= 0)
+ _pending.RemoveAt(index);
+ }
+ }
+
+ return generated;
+ }
+
+ public GpuTextureSlot ResolveSlot(bool wrapping) => wrapping ? _wrapSlot : _clampSlot;
+
+ public void ReleaseTextureSlots() => ReleaseSlotsQuietly();
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ ReleaseSlotsQuietly();
+ _texture.Dispose();
+ lock (_gate)
+ _pending.Clear();
+ }
+
+ ///
+ /// The Chorizite format this array was created from, retained only so
+ /// can reuse the GL arm's payload validator.
+ ///
+ internal TextureFormat SourceFormat { get; }
+
+ private void ReleaseSlotsQuietly()
+ {
+ if (_wrapSlot.IsAssigned)
+ {
+ _device.ReleaseTextureSlot(_wrapSlot);
+ _wrapSlot = GpuTextureSlot.Unassigned;
+ }
+ if (_clampSlot.IsAssigned)
+ {
+ _device.ReleaseTextureSlot(_clampSlot);
+ _clampSlot = GpuTextureSlot.Unassigned;
+ }
+ }
+
+ private long MipChainBytes() =>
+ checked(TotalSizeInBytes
+ - (TextureAtlasManager.CalculateLevelBytes(_width, _height, SourceFormat) * Size));
+
+ internal static int MipLevelsFor(int width, int height) =>
+ (int)Math.Floor(Math.Log2(Math.Max(1, Math.Max(width, height)))) + 1;
+
+ ///
+ /// Chorizite's texture formats onto the pinned RHI list.
+ ///
+ /// RGB8, A8 and Rgba32f have no member of
+ /// . A8 is the interesting one: the GL
+ /// array serves it by swizzling R into A and forcing RGB to one, and the RHI
+ /// contract has no swizzle because Vulkan puts it in the image VIEW, which
+ /// the pinned does not describe. Naming
+ /// the gap is the honest answer — a silent substitution would render wrong
+ /// and look like a shader bug. The slice that draws world materials on
+ /// Vulkan either meets a real A8 atlas and extends the contract, or proves
+ /// none exists.
+ ///
+ private static GpuTextureFormat MapFormat(TextureFormat format) =>
+ format switch
+ {
+ TextureFormat.RGBA8 => GpuTextureFormat.Rgba8Unorm,
+ TextureFormat.DXT1 => GpuTextureFormat.Bc1Unorm,
+ TextureFormat.DXT3 => GpuTextureFormat.Bc2Unorm,
+ TextureFormat.DXT5 => GpuTextureFormat.Bc3Unorm,
+ _ => throw new NotSupportedException(
+ $"World texture format {format} has no GpuTextureFormat member. "
+ + "RGB8 and Rgba32f are not in the pinned RHI format list, and A8 needs the "
+ + "component swizzle the GL array applies, which lives in a Vulkan image view "
+ + "and is not part of GpuTextureDescription. Campaign V's world-draw slice owns "
+ + "extending the contract or proving no such atlas exists."),
+ };
+}
diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
index 956db65a..61329e58 100644
--- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
+++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs
@@ -244,6 +244,30 @@ public sealed class WorldRenderCompositionTests
BindlessSupport bindless) =>
lifetime.AcquireTerrainAtlas(() => Atlas);
+ ///
+ /// 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.
+ ///
+ public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
+ IGameRenderResourceLifetime lifetime,
+ IGpuDevice device,
+ IDatReaderWriter dats) =>
+ lifetime.AcquireTerrainAtlas(() => Atlas);
+
+ ///
+ /// Recorded rather than run: the exercise needs a real
+ /// to create images through. Its behaviour is
+ /// covered by RhiWorldTextureArrayTests and by the Vulkan
+ /// composition-host run — see plan §5.5.13.
+ ///
+ public void ExerciseBackendNeutralWorldTextures(
+ IGpuDevice device,
+ Action log) =>
+ WorldTextureExerciseCount++;
+
+ public int WorldTextureExerciseCount { get; private set; }
+
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
AnisotropicLevel = level;
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
index 064cd871..06b4441c 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/RecordingGpuDevice.cs
@@ -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(
+ ///
+ /// 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.
+ ///
+ public IReadOnlyList CreatedTextures => _createdTextures;
+
+ private readonly List _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)
{
diff --git a/tests/AcDream.App.Tests/Rendering/RhiCompositeTextureArrayBackendTests.cs b/tests/AcDream.App.Tests/Rendering/RhiCompositeTextureArrayBackendTests.cs
new file mode 100644
index 00000000..4746f4fc
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/RhiCompositeTextureArrayBackendTests.cs
@@ -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;
+
+///
+/// Campaign V slice V6i-2: the composite array pool's backend-neutral arm.
+///
+/// Plan §5.5.12 item 1 recorded that ICompositeTextureArrayBackend
+/// "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.
+///
+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(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(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(resource.Image).IsDisposed);
+
+ backend.Delete(resource);
+ Assert.True(Assert.IsType(resource.Image).IsDisposed);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(() => backend.Upload(foreign, 0, Rgba(32, 32)));
+ Assert.Throws(() => backend.Delete(foreign));
+ }
+
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void TheReportedLayerCeilingExceedsTheCachesOwnCap()
+ {
+ using var device = new RecordingGpuDevice();
+ var backend = new RhiCompositeTextureArrayBackend(device);
+
+ Assert.True(backend.MaximumArrayLayers >= CompositeTextureArrayCache.MaximumLayersPerArray);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs
new file mode 100644
index 00000000..1393b596
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Wb/RhiWorldTextureArrayTests.cs
@@ -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;
+
+///
+/// Campaign V slice V6i-2: the backend-neutral world texture array, driven
+/// against .
+///
+/// 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
+/// BlockCompressionMipChain while an RGBA8 array's come from the device's
+/// blit. Getting that backwards would compile, run, and render a texture with
+/// undefined mips.
+///
+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()
+ .Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal))
+ .Select(registration => registration.Sampler),
+ ];
+ Assert.Contains(GpuSamplerDescription.WorldClamp, samplers);
+ Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(() => array.UpdateLayer(0, new byte[16], null, null));
+ Assert.Equal(0, array.PendingUpdateCount);
+ }
+
+ ///
+ /// The formats with no member of GpuTextureFormat 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.
+ ///
+ [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(
+ () => arrays.CreateClampedArray(format, Extent, Extent, 1));
+ }
+
+ ///
+ /// Both arms meter the same bytes, because the eviction budget that reads
+ /// this number is shared policy above the seam.
+ ///
+ [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.");
+}