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."); +}