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
|
|
@ -98,6 +98,27 @@ internal interface IWorldRenderCompositionFactory
|
|||
GL gl,
|
||||
IDatReaderWriter dats,
|
||||
BindlessSupport bindless);
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the terrain atlas built through
|
||||
/// <see cref="AcDream.App.Rendering.Gpu.IGpuDevice"/> rather than raw GL.
|
||||
/// Same DATs, same decode, same layer ordering — only the upload differs.
|
||||
/// </summary>
|
||||
TerrainAtlas AcquireBackendNeutralTerrainAtlas(
|
||||
IGameRenderResourceLifetime lifetime,
|
||||
AcDream.App.Rendering.Gpu.IGpuDevice device,
|
||||
IDatReaderWriter dats);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void ExerciseBackendNeutralWorldTextures(
|
||||
AcDream.App.Rendering.Gpu.IGpuDevice device,
|
||||
Action<string> 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<string> 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",
|
||||
|
|
|
|||
180
src/AcDream.App/Rendering/BackendNeutralWorldTextures.cs
Normal file
180
src/AcDream.App/Rendering/BackendNeutralWorldTextures.cs
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
using AcDream.App.Rendering.Gpu;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Chorizite.Core.Render.Enums;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
///
|
||||
/// <para><b>Why this exists.</b> The slice's whole claim is that world texture
|
||||
/// CREATION now reaches <see cref="IGpuTexture"/>. 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: <c>UniformSkyParams</c> and the terrain clip
|
||||
/// block were both wrong for months because no Vulkan pipeline had ever been
|
||||
/// built from them. The parked <c>TerrainAtlas</c> draft this slice reuses was
|
||||
/// itself reverted for the same reason — "built then reverted because nothing
|
||||
/// exercised it".</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
///
|
||||
/// <para><b>What the two arrays cover between them.</b> RGBA8 exercises
|
||||
/// <see cref="IGpuTexture.GenerateMipChain"/> — the device-side blit — and BC1
|
||||
/// exercises the CPU chain, because Vulkan cannot blit into a compressed image
|
||||
/// and <see cref="Gpu.Vk.BlockCompressionMipChain"/> is what stands in for that.
|
||||
/// Those are the two upload shapes the world's shared atlases actually take.</para>
|
||||
/// </summary>
|
||||
internal sealed class BackendNeutralWorldTextures : IDisposable
|
||||
{
|
||||
private readonly IWorldTextureArray _rgba;
|
||||
private readonly IWorldTextureArray _compressed;
|
||||
private readonly ICompositeTextureArrayBackend _compositeBackend;
|
||||
private readonly CompositeTextureArrayResource _composite;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const int ArrayExtent = 64;
|
||||
|
||||
/// <summary>Composite surfaces are the retail 32×32 item art size class.</summary>
|
||||
private const int CompositeExtent = 32;
|
||||
|
||||
private const int CompositeLayers = 8;
|
||||
|
||||
internal static BackendNeutralWorldTextures Create(IGpuDevice device, Action<string> log)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(device);
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
return new BackendNeutralWorldTextures(device, log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the bundle and releases it in the same statement.
|
||||
///
|
||||
/// <para>Nothing is retained on purpose. The terrain atlas — which the same
|
||||
/// slice moved onto <see cref="IGpuTexture"/> — 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.</para>
|
||||
/// </summary>
|
||||
internal static void Exercise(IGpuDevice device, Action<string> log)
|
||||
{
|
||||
using BackendNeutralWorldTextures textures = Create(device, log);
|
||||
}
|
||||
|
||||
private BackendNeutralWorldTextures(IGpuDevice device, Action<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -127,9 +127,20 @@ internal readonly record struct CompositeTextureKey(
|
|||
|
||||
internal sealed class CompositeTextureArrayResource
|
||||
{
|
||||
/// <summary>The GL texture name, or 0 on the backend-neutral arm.</summary>
|
||||
public required uint Name { get; init; }
|
||||
|
||||
/// <summary>The resident bindless handle, or 0 on the backend-neutral arm.</summary>
|
||||
public required ulong Handle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the RHI image, on the arm that owns one. Null on
|
||||
/// GL, where <see cref="Name"/> and <see cref="Handle"/> are the identity.
|
||||
/// The cache above touches neither — it only ever hands a resource back to
|
||||
/// the backend that made it.
|
||||
/// </summary>
|
||||
public Gpu.IGpuTexture? Image { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V4t: this array's entry in the device texture table.
|
||||
/// The backend that made <see cref="Handle"/> resident also interned it, so
|
||||
|
|
@ -338,6 +349,108 @@ internal sealed unsafe class GlCompositeTextureArrayBackend : ICompositeTextureA
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the backend-neutral composite array backend.
|
||||
///
|
||||
/// <para>Plan §5.5.12 item 1 noted that <see cref="ICompositeTextureArrayBackend"/>
|
||||
/// "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.</para>
|
||||
///
|
||||
/// <para><b>Clamped, matching what the GL backend's texture parameters say for
|
||||
/// the modes that matter.</b> The GL backend sets <c>Repeat</c>, 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.</para>
|
||||
/// </summary>
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The GL backend reads <c>GL_MAX_ARRAY_TEXTURE_LAYERS</c>. The pinned
|
||||
/// <see cref="Gpu.GpuCapabilityRecord"/> has no array-layer field and §3.3 is
|
||||
/// frozen, so this reports Vulkan's guaranteed <c>maxImageArrayLayers</c>
|
||||
/// minimum of 256. That is not a limitation in practice:
|
||||
/// <see cref="CompositeTextureArrayCache.MaximumLayersPerArray"/> caps every
|
||||
/// array at 64, so the true device limit is never the binding constraint.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pools per-entity material composites into dimension-compatible texture
|
||||
/// arrays. Retail releases the owning CSurface reference immediately. This
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ namespace AcDream.App.Rendering;
|
|||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the backend-neutral arm. When present, both
|
||||
/// arrays are <see cref="IGpuTexture"/>s the device created and both slots
|
||||
/// were registered at build time, so <see cref="TextureSlots"/> is a field
|
||||
/// read rather than a residency negotiation. <see cref="GlTexture"/> and
|
||||
/// <see cref="GlAlphaTexture"/> are 0 and no GL handle exists at all.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// True when this atlas owns <see cref="IGpuTexture"/> arrays rather than raw
|
||||
/// GL names. The two construction paths are mutually exclusive.
|
||||
/// </summary>
|
||||
internal bool IsBackendNeutral => _rhi is not null;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="SetAnisotropic"/>, which changes the sampler.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Retail's terrain arrays are trilinear-filtered with the highest anisotropy
|
||||
/// the quality preset allows; <see cref="SetAnisotropic"/> lowers it. The GL
|
||||
/// path sets <c>GL_TEXTURE_MAX_ANISOTROPY</c> to 16 at build time, so the
|
||||
/// backend-neutral path starts at the same value.
|
||||
/// </summary>
|
||||
private const float RetailMaxAnisotropy = 16f;
|
||||
|
||||
private TerrainAtlas(
|
||||
IGpuDevice device,
|
||||
IGpuTexture terrain,
|
||||
IGpuTexture alpha,
|
||||
IGpuSampler alphaSampler,
|
||||
IReadOnlyDictionary<uint, uint> map,
|
||||
int layerCount,
|
||||
IReadOnlyList<float> tilingByLayer,
|
||||
int alphaLayerCount,
|
||||
IReadOnlyList<byte> cornerLayers,
|
||||
IReadOnlyList<byte> sideLayers,
|
||||
IReadOnlyList<byte> roadLayers,
|
||||
IReadOnlyList<uint> cornerTCodes,
|
||||
IReadOnlyList<uint> sideTCodes,
|
||||
IReadOnlyList<uint> 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,
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private readonly record struct TerrainLayerDecode(
|
||||
Dictionary<uint, DecodedTexture> DecodedByType,
|
||||
Dictionary<uint, uint> TilingByType,
|
||||
int MaxWidth,
|
||||
int MaxHeight);
|
||||
|
||||
private static TerrainLayerDecode DecodeTerrainLayers(
|
||||
IDatReaderWriter dats,
|
||||
Wb.BindlessSupport? bindless,
|
||||
GlTextureConstructionTransaction textures)
|
||||
IReadOnlyList<DatReaderWriter.Types.TMTerrainDesc> terrainDesc)
|
||||
{
|
||||
var region = dats.Get<Region>(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<uint, DecodedTexture>();
|
||||
var tilingByType = new Dictionary<uint, uint>();
|
||||
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<Region>(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<uint, DecodedTexture> decodedByType = decode.DecodedByType;
|
||||
Dictionary<uint, uint> tilingByType = decode.TilingByType;
|
||||
int maxW = decode.MaxWidth, maxH = decode.MaxHeight;
|
||||
|
||||
int layerCount = decodedByType.Count;
|
||||
var map = new Dictionary<uint, uint>();
|
||||
uint tex = TrackedTextureConstruction.Create(
|
||||
|
|
@ -321,11 +442,24 @@ public sealed unsafe class TerrainAtlas : IDisposable
|
|||
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
|
||||
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
|
||||
|
||||
private static AlphaAtlasBuildResult BuildAlphaAtlas(
|
||||
GL gl,
|
||||
/// <summary>
|
||||
/// Slice V6i-2: the alpha-map decode, shared by both construction paths for
|
||||
/// the same reason <see cref="DecodeTerrainLayers"/> is.
|
||||
/// </summary>
|
||||
private sealed record AlphaLayerDecode(
|
||||
List<DecodedTexture> Decoded,
|
||||
List<byte> CornerLayers,
|
||||
List<byte> SideLayers,
|
||||
List<byte> RoadLayers,
|
||||
List<uint> CornerTCodes,
|
||||
List<uint> SideTCodes,
|
||||
List<uint> 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<DecodedTexture>();
|
||||
var cornerLayers = new List<byte>();
|
||||
|
|
@ -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<DecodedTexture> decoded = decode.Decoded;
|
||||
List<byte> cornerLayers = decode.CornerLayers;
|
||||
List<byte> sideLayers = decode.SideLayers;
|
||||
List<byte> roadLayers = decode.RoadLayers;
|
||||
List<uint> cornerTCodes = decode.CornerTCodes;
|
||||
List<uint> sideTCodes = decode.SideTCodes;
|
||||
List<uint> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: build both arrays through
|
||||
/// <see cref="IGpuDevice"/>.
|
||||
///
|
||||
/// <para>Same DAT reads, same decode, same layer ordering and the same
|
||||
/// resize-to-max policy as <see cref="Build"/> — only the upload differs,
|
||||
/// which is the whole point of splitting the decode out. The terrain array
|
||||
/// gets a full mip chain (<see cref="IGpuTexture.GenerateMipChain"/> 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.</para>
|
||||
/// </summary>
|
||||
internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(device);
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
var region = dats.Get<Region>(0x13000000u)
|
||||
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
|
||||
var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge;
|
||||
var terrainDesc = texMerge?.TerrainDesc;
|
||||
|
||||
Dictionary<uint, DecodedTexture> decodedByType;
|
||||
Dictionary<uint, uint> tilingByType;
|
||||
int maxW, maxH;
|
||||
if (terrainDesc is null || terrainDesc.Count == 0)
|
||||
{
|
||||
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
|
||||
decodedByType = new Dictionary<uint, DecodedTexture> { [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<DecodedTexture> 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<uint, uint>(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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
|||
/// </summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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}")),
|
||||
]));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the device whose one texture table this
|
||||
/// array's two resident handles are interned into. Before this slice
|
||||
/// <c>ObjectMeshManager</c> 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 <see cref="ResolveSlot"/> instead. Null only
|
||||
/// for the legacy <c>OpenGLGraphicsDevice.CreateTextureArrayInternal</c>
|
||||
/// entry points, which no shared atlas uses.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: this array's device-table slot for the
|
||||
/// requested address mode.
|
||||
///
|
||||
/// <para>The interning call is the one <c>ObjectMeshManager</c> made
|
||||
/// itself before this slice, moved one level down so the caller can be
|
||||
/// written against <see cref="IWorldTextureArray"/> instead of against a
|
||||
/// 64-bit <c>ARB_bindless_texture</c> 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.</para>
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retires both table entries. <see cref="Dispose"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,12 @@ namespace AcDream.App.Rendering.Wb
|
|||
|
||||
internal AcDream.App.Rendering.Gpu.Gl.GlGpuDevice WorldTextureTable => _worldTextureTable;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: how a shared atlas's physical array is made.
|
||||
/// Composed once; see <see cref="IWorldTextureArrayFactory"/>.
|
||||
/// </summary>
|
||||
private readonly IWorldTextureArrayFactory _atlasArrays;
|
||||
|
||||
/// <summary>
|
||||
/// 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<TextureAtlasManager> _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<TextureAtlasManager, (ulong Wrap, ulong Clamp)>
|
||||
_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;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ namespace AcDream.App.Rendering.Wb {
|
|||
/// </summary>
|
||||
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!;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="IWorldTextureArrayFactory"/> handed to
|
||||
/// the constructor — nothing in this class or in
|
||||
/// <c>ObjectMeshManager</c>'s atlas policy branches on it.
|
||||
/// </summary>
|
||||
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<TextureAtlasManager>? 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
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."),
|
||||
};
|
||||
}
|
||||
|
|
@ -244,6 +244,30 @@ public sealed class WorldRenderCompositionTests
|
|||
BindlessSupport bindless) =>
|
||||
lifetime.AcquireTerrainAtlas(() => Atlas);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public TerrainAtlas AcquireBackendNeutralTerrainAtlas(
|
||||
IGameRenderResourceLifetime lifetime,
|
||||
IGpuDevice device,
|
||||
IDatReaderWriter dats) =>
|
||||
lifetime.AcquireTerrainAtlas(() => Atlas);
|
||||
|
||||
/// <summary>
|
||||
/// Recorded rather than run: the exercise needs a real
|
||||
/// <see cref="IGpuDevice"/> to create images through. Its behaviour is
|
||||
/// covered by <c>RhiWorldTextureArrayTests</c> and by the Vulkan
|
||||
/// composition-host run — see plan §5.5.13.
|
||||
/// </summary>
|
||||
public void ExerciseBackendNeutralWorldTextures(
|
||||
IGpuDevice device,
|
||||
Action<string> log) =>
|
||||
WorldTextureExerciseCount++;
|
||||
|
||||
public int WorldTextureExerciseCount { get; private set; }
|
||||
|
||||
public void SetTerrainAnisotropic(TerrainAtlas atlas, int level) =>
|
||||
AnisotropicLevel = level;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RecordingGpuTexture> CreatedTextures => _createdTextures;
|
||||
|
||||
private readonly List<RecordingGpuTexture> _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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the composite array pool's backend-neutral arm.
|
||||
///
|
||||
/// <para>Plan §5.5.12 item 1 recorded that <c>ICompositeTextureArrayBackend</c>
|
||||
/// "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.</para>
|
||||
/// </summary>
|
||||
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<RecordingGpuTexture>(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<RecordingGpuTexture>(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<RecordingGpuTexture>(resource.Image).IsDisposed);
|
||||
|
||||
backend.Delete(resource);
|
||||
Assert.True(Assert.IsType<RecordingGpuTexture>(resource.Image).IsDisposed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<InvalidOperationException>(() => backend.Upload(foreign, 0, Rgba(32, 32)));
|
||||
Assert.Throws<InvalidOperationException>(() => backend.Delete(foreign));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TheReportedLayerCeilingExceedsTheCachesOwnCap()
|
||||
{
|
||||
using var device = new RecordingGpuDevice();
|
||||
var backend = new RhiCompositeTextureArrayBackend(device);
|
||||
|
||||
Assert.True(backend.MaximumArrayLayers >= CompositeTextureArrayCache.MaximumLayersPerArray);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6i-2: the backend-neutral world texture array, driven
|
||||
/// against <see cref="RecordingGpuDevice"/>.
|
||||
///
|
||||
/// <para>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
|
||||
/// <c>BlockCompressionMipChain</c> while an RGBA8 array's come from the device's
|
||||
/// blit. Getting that backwards would compile, run, and render a texture with
|
||||
/// undefined mips.</para>
|
||||
/// </summary>
|
||||
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<GpuRecordedTextureRegistration>()
|
||||
.Where(registration => registration.TextureName.StartsWith("world-atlas", StringComparison.Ordinal))
|
||||
.Select(registration => registration.Sampler),
|
||||
];
|
||||
Assert.Contains(GpuSamplerDescription.WorldClamp, samplers);
|
||||
Assert.Contains(GpuSamplerDescription.WorldRepeat, samplers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ArgumentException>(() => array.UpdateLayer(0, new byte[16], null, null));
|
||||
Assert.Equal(0, array.PendingUpdateCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The formats with no member of <c>GpuTextureFormat</c> 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.
|
||||
/// </summary>
|
||||
[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<NotSupportedException>(
|
||||
() => arrays.CreateClampedArray(format, Extent, Extent, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Both arms meter the same bytes, because the eviction budget that reads
|
||||
/// this number is shared policy above the seam.
|
||||
/// </summary>
|
||||
[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.");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue