Commit 2 deleted the GL rendering backend's implementations; this step removes the package references and shader vocabulary they leave behind, so nothing in the App project still spells Silk.NET.OpenGL. Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are used directly and extensively across the Wb texture/mesh pipeline, independent of the deleted GL IUniformBuffer implementers the package comment used to cite. The stale comment is corrected in place. IMeshPipelineDevice.Gl is removed along with the GL? gl parameter threaded through WbMeshAdapter's four constructors, WorldRenderComposition's CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null implementation — nothing read any of them once the legacy per-mesh upload bodies were gone (confirmed by grep: the sole non-doc-comment hit was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed a real bug along the way: its teardown still pattern-matched the deleted GL GpuFrameFlightController to decide whether to wait for submitted work, which VulkanFrameFlightController replaced at slice V6a without this site being updated — so the wait had been silently dead on every Vulkan run since then. Retargeted to VulkanFrameFlightController, which carries the same WaitForSubmittedWork(). The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for upload validation is replaced by AcDream.Content's existing Silk.NET-free UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake tool GL-free); two new members (Rgb, Red, Float) extend that enum with their GL ABI constants to cover the full vocabulary WorldTextureArray needs, since MP1a's original set only covered what the extractor itself emits. ObjectMeshManager's App-boundary cast `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct pass-through now that both sides share the type. GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of the Vulkan texture table) is deleted and StorageBindingCount drops from 10 to 9; the descriptor-set-layout code that builds from that count (VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just allocates one fewer always-dummy-seeded, always-unused binding. Several fully dead GL-only classes came along for the ride, confirmed by zero construction sites: SilkFramebufferViewportTarget (NullFramebufferViewportTarget is the sole production IFramebufferViewportTarget), SilkRenderGlStateReader (NullRenderGlStateReader.Instance is the sole IRenderGlStateReader), RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a pass load-op instead), and GpuFrameTimer plus FrameProfiler's GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame bracket (RecordGpuSample is the only GPU-timing path any backend uses now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no longer applies, since WbDrawDispatcher's own diagnostic GPU sampling already moved to the device's Vulkan timer pool). GpuFrameFlightController itself stays (never constructed with a real fence API in production, but its retirement-ledger/serial-ring logic is backend-neutral and still covered by its own unit tests) — only its GL-specific parts (the public GL constructor overload, SilkGpuFenceApi) are deleted, since removing the whole class would mean restructuring the frozen Slice-8 composition shape's GpuFrameFlightController? threading, which is out of this commit's scope. TextureParameters.cs and BufferUsageExtensions.cs (zero callers each) are deleted outright. common.glsl is deleted: nothing in the actual Vulkan .spv build reads it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own complete self-contained preamble per file; common.glsl's textual concatenation was exclusively Shader.cs's GL-only mechanism, deleted at Commit 2. The five shader files that named it in comments (mesh_modern.vert, particle.vert, particle.frag, sky.frag, terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the mandatory modern path already made unreachable, with zero C# consumers and no compiled .spv — are deleted too. Regenerated via tools/compile-shaders.ps1: 9/9 remaining shader pairs compile (previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests doc comment's "nine of ten are not Vulkan-expressible" was already stale before this commit). Test fallout: dead-subject test methods/files are deleted rather than patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs, GpuResourceRetirementTransactionTests.cs's GL queue tests, one WorldRenderDiagnosticsTests source-order test, one RenderFrameResourceControllerTests clear-phase-order test); tests whose subject moved or was renamed are updated in place rather than deleted (GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests' pinned seven-member surface now reads six, ParticleBindlessInstanceTests' cross-dialect check now covers the one surviving dialect, WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was always the parameter that actually threw). Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors, with the Silk.NET.OpenGL/.Extensions.ARB package references physically removed from the csproj (not just unreferenced in code). Tests: full-solution `dotnet test` green across every project. Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
534 lines
22 KiB
C#
534 lines
22 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: the shared world texture array, expressed without
|
|
/// naming a backend.
|
|
///
|
|
/// <para>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
|
|
/// <c>ITextureArray</c> implementation over <see cref="IGpuTexture"/>, not a
|
|
/// codec." This is that interface. <c>ManagedGLTextureArray</c> used to be its
|
|
/// GL implementation, alongside <see cref="RhiWorldTextureArray"/>; which one
|
|
/// existed was decided once at composition by
|
|
/// <see cref="IWorldTextureArrayFactory"/>, never per call. Campaign V slice
|
|
/// V11 deleted <c>ManagedGLTextureArray</c> along with the rest of the raw-GL
|
|
/// arm, so <see cref="RhiWorldTextureArray"/> is now the sole implementation.</para>
|
|
///
|
|
/// <para><b>The slot, not the handle, is the seam.</b> Before V6i-2
|
|
/// <c>ObjectMeshManager</c> read <c>BindlessWrapHandle</c>/
|
|
/// <c>BindlessClampHandle</c> off the concrete GL array and interned them into
|
|
/// the device table itself. A 64-bit <c>ARB_bindless_texture</c> handle is
|
|
/// unspellable on Vulkan, so the array answers the question the caller was
|
|
/// really asking — <see cref="ResolveSlot"/> — instead: the RHI array
|
|
/// registered its two (texture, sampler) pairs at construction and returns a
|
|
/// field.</para>
|
|
/// </summary>
|
|
internal interface IWorldTextureArray : IDisposable
|
|
{
|
|
/// <summary>Array layers allocated. Immutable for the array's lifetime.</summary>
|
|
int Size { get; }
|
|
|
|
/// <summary>Bytes the whole array occupies including its mip chain.</summary>
|
|
long TotalSizeInBytes { get; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
int PendingUpdateCount { get; }
|
|
|
|
/// <summary>
|
|
/// True once disposal is durably owned by the backend's retirement path.
|
|
/// <see cref="TextureAtlasManager"/> refuses to commit its own logical
|
|
/// disposal until this is true, so a synchronous enqueue failure still
|
|
/// retries.
|
|
/// </summary>
|
|
bool HasDurableDisposeOwnership { get; }
|
|
|
|
/// <summary>
|
|
/// True only after every physical release stage has completed. Logical
|
|
/// disposal can become durable earlier, while a frame fence still owns the
|
|
/// image.
|
|
/// </summary>
|
|
bool IsPhysicalRetirementComplete { get; }
|
|
|
|
/// <summary>
|
|
/// Stages one layer's decoded payload. Both backends retain it until
|
|
/// <see cref="ProcessDirtyUpdates"/> so a burst of layer writes costs one
|
|
/// GPU submission rather than one per layer.
|
|
/// </summary>
|
|
void UpdateLayer(int layer, byte[] data, UploadPixelFormat? uploadPixelFormat, UploadPixelType? uploadPixelType);
|
|
|
|
/// <summary>
|
|
/// Flushes staged layers and refreshes the mip chain. Returns the bytes of
|
|
/// mip data generated, which is what the residency accounting meters.
|
|
/// </summary>
|
|
long ProcessDirtyUpdates();
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
GpuTextureSlot ResolveSlot(bool wrapping);
|
|
|
|
/// <summary>
|
|
/// Retires both table entries. Called when physical retirement completes,
|
|
/// never before: until then a submitted frame may still sample through the
|
|
/// slot. Idempotent.
|
|
/// </summary>
|
|
void ReleaseTextureSlots();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: construction-time backend selection for world texture
|
|
/// arrays.
|
|
///
|
|
/// <para>Plan §3.1 forbids a runtime fork in shared logic, and this is how the
|
|
/// texture stack obeys it: everything above — <see cref="TextureAtlasManager"/>'s
|
|
/// slot allocation, ref counting, layer retirement and eviction, and
|
|
/// <c>ObjectMeshManager</c>'s whole atlas policy — is written once against
|
|
/// <see cref="IWorldTextureArray"/>, and the only branch in the system is which
|
|
/// factory composition built.</para>
|
|
/// </summary>
|
|
internal interface IWorldTextureArrayFactory
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>The retirement queue array layers and images are released through.</summary>
|
|
IGpuResourceRetirementQueue Retirement { get; }
|
|
|
|
/// <summary>
|
|
/// Creates a clamped, mip-mapped 2-D array of <paramref name="layers"/>
|
|
/// layers. Clamping is the shared-atlas policy: a layer's neighbours are
|
|
/// unrelated textures, so wrapping across them would bleed.
|
|
/// </summary>
|
|
IWorldTextureArray CreateClampedArray(TextureFormat format, int width, int height, int layers);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The backend-neutral arm. Creates through <see cref="IGpuDevice.CreateTexture"/>
|
|
/// and registers both address modes into the device's one texture table, so an
|
|
/// array is usable from a shader the moment it exists.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6i-2: a shared world texture array owned as an
|
|
/// <see cref="IGpuTexture"/>.
|
|
///
|
|
/// <para><b>What differs from the GL array, and why it is not a divergence.</b>
|
|
/// Three things:</para>
|
|
///
|
|
/// <list type="number">
|
|
/// <item><b>Mip generation for BC formats is CPU-built.</b> The GL array calls
|
|
/// <c>glGenerateMipmap</c> 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 <see cref="BlockCompressionMipChain"/>. So a BC array's levels
|
|
/// 1..N-1 are encoded here, deterministically, and uploaded like level 0.
|
|
/// Uncompressed arrays use <see cref="IGpuTexture.GenerateMipChain"/>, which is
|
|
/// the device's blit.</item>
|
|
/// <item><b>Filtering lives in the sampler, not the image.</b> GL sets
|
|
/// <c>TexParameter</c> 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.</item>
|
|
/// <item><b>Anisotropy is asked for as a ceiling rather than read back.</b> See
|
|
/// <see cref="WorldArrayAnisotropy"/>.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
internal sealed class RhiWorldTextureArray : IWorldTextureArray
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para><b>What RETAIL does, which is the same thing.</b>
|
|
/// <c>RenderDeviceD3D::SetDefaultD3DStates</c> (<c>0x005a3800</c>) loops all
|
|
/// sixteen sampler stages and, at <c>0x005a4230</c>, issues
|
|
/// <c>SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy)</c> —
|
|
/// <c>0xA</c> is <c>D3DSAMP_MAXANISOTROPY</c>, 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.</para>
|
|
///
|
|
/// <para><b>What the GL arm does.</b> <c>ManagedGLTextureArray</c> sets
|
|
/// <c>GL_TEXTURE_MAX_ANISOTROPY</c> to <c>OpenGLGraphicsDevice
|
|
/// .MaxSupportedAnisotropy</c> — the driver's own
|
|
/// <c>GL_MAX_TEXTURE_MAX_ANISOTROPY</c>, read once at construction — and its
|
|
/// two resident bindless handles are built from sampler objects
|
|
/// (<c>WrapSampler</c>/<c>ClampSampler</c>) 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 <c>TerrainAtlas</c>.</para>
|
|
///
|
|
/// <para><b>Why a literal rather than a device read.</b> The pinned RHI
|
|
/// contract (plan §3.3) has no anisotropy limit on
|
|
/// <see cref="Gpu.GpuCapabilityRecord"/> and is frozen, but it does not need
|
|
/// one: <c>VulkanGpuSampler</c> already clamps
|
|
/// <see cref="GpuSamplerDescription.MaxAnisotropy"/> to
|
|
/// <c>VkPhysicalDeviceLimits.maxSamplerAnisotropy</c>, so requesting a
|
|
/// ceiling IS requesting the device maximum. Vulkan guarantees that limit is
|
|
/// at least 16 wherever the <c>samplerAnisotropy</c> 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.</para>
|
|
///
|
|
/// <para><b>Why it is not cosmetic.</b> 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.</para>
|
|
/// </summary>
|
|
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<PendingLayer> _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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The RHI's <see cref="IGpuTexture.Dispose"/> 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.
|
|
/// </summary>
|
|
public bool HasDurableDisposeOwnership => _disposed;
|
|
|
|
/// <inheritdoc/>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The Chorizite format this array was created from, retained only so
|
|
/// <see cref="UpdateLayer"/> can reuse the GL arm's payload validator.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Chorizite's texture formats onto the pinned RHI list.
|
|
///
|
|
/// <para><c>RGB8</c>, <c>A8</c> and <c>Rgba32f</c> have no member of
|
|
/// <see cref="GpuTextureFormat"/>. <c>A8</c> 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 <see cref="GpuTextureDescription"/> 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.</para>
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// The expected byte count for one uploaded layer of <paramref name="format"/>
|
|
/// at <paramref name="width"/>x<paramref name="height"/>.
|
|
/// </summary>
|
|
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}"),
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates an upload payload against the format's expected byte count and
|
|
/// rejects transfer overrides that contradict it.
|
|
/// </summary>
|
|
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}.");
|
|
}
|
|
}
|
|
}
|