using AcDream.App.Rendering.Gpu; using AcDream.App.Rendering.Gpu.Vk; using AcDream.Content; using AcDream.Core.Rendering.Wb; using Chorizite.Core.Render.Enums; using Microsoft.Extensions.Logging; 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. ManagedGLTextureArray used to be its /// GL implementation, alongside ; which one /// existed was decided once at composition by /// , never per call. Campaign V slice /// V11 deleted ManagedGLTextureArray along with the rest of the raw-GL /// arm, so is now the sole implementation. /// /// The slot, not the handle, is the seam. Before V6i-2 /// 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 answers the question the caller was /// really asking — — instead: 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, UploadPixelFormat? uploadPixelFormat, UploadPixelType? 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 { /// /// Campaign V slice V6i-2: picks the arm from what the composed devices /// actually are. This is the ONE place the mesh pipeline's texture stack /// branches on a backend, which is what lets everything above it — capacity /// policy, slot allocation, ref counting, layer retirement, eviction — be /// written once. /// internal static IWorldTextureArrayFactory For( IMeshPipelineDevice graphicsDevice, IGpuDevice gpuDevice, ILogger logger) { ArgumentNullException.ThrowIfNull(graphicsDevice); ArgumentNullException.ThrowIfNull(gpuDevice); ArgumentNullException.ThrowIfNull(logger); // The GL arm this used to select between was deleted at Campaign V // slice V11; the RHI arm is the only one left. return new RhiWorldTextureArrayFactory(gpuDevice); } /// 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 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. /// Anisotropy is asked for as a ceiling rather than read back. See /// . /// /// internal sealed class RhiWorldTextureArray : IWorldTextureArray { /// /// Campaign V slice V7: the anisotropy the shared world atlases are sampled /// with, and the value that makes this arm's filtering the GL arm's. /// /// What RETAIL does, which is the same thing. /// RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all /// sixteen sampler stages and, at 0x005a4230, issues /// SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) — /// 0xA is D3DSAMP_MAXANISOTROPY, and the value is the device's /// own reported cap rather than a setting. So "as much anisotropy as this /// device has" is retail's rule, not a WorldBuilder habit acdream inherited, /// and asking for 1 here was a divergence from retail as well as from the /// shipping backend. /// /// What the GL arm does. ManagedGLTextureArray sets /// GL_TEXTURE_MAX_ANISOTROPY to OpenGLGraphicsDevice /// .MaxSupportedAnisotropy — the driver's own /// GL_MAX_TEXTURE_MAX_ANISOTROPY, read once at construction — and its /// two resident bindless handles are built from sampler objects /// (WrapSampler/ClampSampler) that set the same value. So the /// shipping backend asks for "as much anisotropy as this device has," /// unconditionally, and NOT for the quality preset's level; the preset /// reaches only TerrainAtlas. /// /// Why a literal rather than a device read. The pinned RHI /// contract (plan §3.3) has no anisotropy limit on /// and is frozen, but it does not need /// one: VulkanGpuSampler already clamps /// to /// VkPhysicalDeviceLimits.maxSamplerAnisotropy, so requesting a /// ceiling IS requesting the device maximum. Vulkan guarantees that limit is /// at least 16 wherever the samplerAnisotropy feature is supported — /// which this backend requires — and 16 is where every desktop driver caps, /// so the request and the GL arm's read land on the same number. /// /// Why it is not cosmetic. Measured at V7 on the differential's /// Holtburg stop: with this at 1, Vulkan's roof shingles, distant scenery and /// every grazing-angle surface sample a coarser mip than GL's, which is a /// visible blur and was the largest single population in the first /// GL-versus-Vulkan pair outside the animated sky. /// private const float WorldArrayAnisotropy = 16f; 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 with { MaxAnisotropy = WorldArrayAnisotropy, })); _wrapSlot = device.RegisterTexture( texture, device.CreateSampler(GpuSamplerDescription.WorldRepeat with { MaxAnisotropy = WorldArrayAnisotropy, })); } 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, UploadPixelFormat? uploadPixelFormat, UploadPixelType? uploadPixelType) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(data); ArgumentOutOfRangeException.ThrowIfNegative(layer); ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(layer, Size); 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."), }; private static bool IsCompressedFormat(TextureFormat format) => format is TextureFormat.DXT1 or TextureFormat.DXT3 or TextureFormat.DXT5; /// /// The expected byte count for one uploaded layer of /// at x. /// internal static int CalculateExpectedDataSize(TextureFormat format, int width, int height) { if (IsCompressedFormat(format)) return TextureHelpers.GetCompressedLayerSize(width, height, format); return format switch { TextureFormat.RGBA8 => checked(width * height * 4), TextureFormat.RGB8 => checked(width * height * 3), TextureFormat.A8 => checked(width * height), TextureFormat.Rgba32f => checked(width * height * 16), _ => throw new NotSupportedException($"Unsupported format {format}"), }; } /// /// Validates an upload payload against the format's expected byte count and /// rejects transfer overrides that contradict it. /// internal static void ValidateUploadPayload( TextureFormat format, int width, int height, int dataLength, UploadPixelFormat? uploadPixelFormat, UploadPixelType? uploadPixelType) { int expectedBytes = CalculateExpectedDataSize(format, width, height); if (dataLength != expectedBytes) { throw new ArgumentException( $"Texture-array layer payload has {dataLength} bytes; expected exactly {expectedBytes} " + $"for {format} {width}x{height}.", nameof(dataLength)); } if (IsCompressedFormat(format)) { if (uploadPixelFormat.HasValue || uploadPixelType.HasValue) throw new ArgumentException("Compressed texture uploads cannot specify pixel format/type overrides."); return; } UploadPixelFormat expectedFormat = format.ToPixelFormat(); UploadPixelType expectedType = format.ToPixelType(); if ((uploadPixelFormat ?? expectedFormat) != expectedFormat || (uploadPixelType ?? expectedType) != expectedType) { throw new ArgumentException( $"Upload descriptor {uploadPixelFormat}/{uploadPixelType} does not match " + $"the {expectedFormat}/{expectedType} transfer required by {format}."); } } }