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