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
|
|
@ -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}")),
|
||||
]));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue