feat(render): Campaign V slice V6i-2 commit 2 — world texture creation crosses to IGpuTexture
Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "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." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.
IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.
Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.
Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.
TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.
The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.
Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
0ca802cd 3.20e-05 (18 px of 563,200, inside the documented 9–31 px control band);
GL connected tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the
desktop witness AND 3/3 on the client capture; one Vulkan composition-host run
with VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors,
zero warnings, no [shutdown] diagnostic, and a captured frame. That run built
terrain-atlas 512x512x33 with 10 mip levels, terrain-alpha-atlas 512x512x8, RGBA8
64x64x32 (slots 3/4, 174,720 mip bytes blitted), BC1 64x64x32 (slots 5/6, 696 mip
bytes encoded) and composite 32x32x8 (slot 7).
One whole-suite run failed Issue181WallPressEquilibriumTests once; it passed
alone and did not recur in five further runs. Seven test classes mutate the same
process-global CameraDiagnostics switches with no xUnit collection isolation, and
this diff touches no camera, visibility or physics code. A separate run of the
UNCHANGED parent tree failed a different zero-allocation test, which is `#250`'s
documented class. Both are filed rather than attributed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f7344758f8
commit
c8d0f70bbe
12 changed files with 1662 additions and 95 deletions
443
src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
Normal file
443
src/AcDream.App/Rendering/Wb/WorldTextureArray.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <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. <see cref="ManagedGLTextureArray"/> and
|
||||
/// <see cref="RhiWorldTextureArray"/> implement it, and which one exists is
|
||||
/// decided once at composition by <see cref="IWorldTextureArrayFactory"/> —
|
||||
/// never per call, so the GL path executes exactly the statements it executed
|
||||
/// before.</para>
|
||||
///
|
||||
/// <para><b>The slot, not the handle, is the seam.</b> Before this slice
|
||||
/// <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 now answers the question the caller was
|
||||
/// really asking — <see cref="ResolveSlot"/> — 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.</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, PixelFormat? uploadPixelFormat, PixelType? 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>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 GL arm. Delegates to the same <c>OpenGLGraphicsDevice</c> entry point
|
||||
/// <see cref="TextureAtlasManager"/> called directly before this slice, so the
|
||||
/// shipping backend's construction is textually unchanged.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <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>There is no anisotropy knob.</b> The GL array reads
|
||||
/// <c>graphicsDevice.MaxSupportedAnisotropy</c> at construction. The RHI
|
||||
/// sampler takes <see cref="GpuSamplerDescription.MaxAnisotropy"/>, 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.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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<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));
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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, 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();
|
||||
}
|
||||
|
||||
/// <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."),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue