acdream/src/AcDream.App/Rendering/TerrainAtlas.cs
Erik c87b15303d refactor(lifetime): own render resource prefixes
Give terrain, sky, retained UI, portal preparation, and the update/render frame pair explicit single owners. Make shader, texture, text, bindless, and GL construction prefixes checked and retryable so partial failure cannot lose or replay resource ownership.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 14:42:14 +02:00

586 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
using DatPixelFormat = DatReaderWriter.Enums.PixelFormat;
using GLPixelFormat = Silk.NET.OpenGL.PixelFormat;
namespace AcDream.App.Rendering;
/// <summary>
/// Holds both texture arrays the terrain renderer samples from:
/// <list type="bullet">
/// <item><description>
/// <b>Terrain atlas</b> — one GL_TEXTURE_2D_ARRAY layer per terrain type
/// (grass, dirt, sand, forest...), sourced from
/// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc.
/// </description></item>
/// <item><description>
/// <b>Alpha atlas</b> — one GL_TEXTURE_2D_ARRAY layer per blend mask,
/// sourced from CornerTerrainMaps / SideTerrainMaps / RoadMaps in the
/// same TexMerge. Used by the fragment shader to blend up to three
/// terrain overlays and two roads on top of a base cell texture.
/// </description></item>
/// </list>
/// The alpha atlas is built but not yet sampled by any shader — that wiring
/// lands in Phase 3c.4 along with the shader rewrite.
/// </summary>
public sealed unsafe class TerrainAtlas : IDisposable
{
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
public IReadOnlyDictionary<uint, uint> TerrainTypeToLayer { get; }
public int LayerCount { get; }
/// <summary>
/// UV repeat count for each terrain-array layer. Retail forwards
/// <c>TerrainTex::tex_tiling</c> to both base and merged terrain textures
/// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0).
/// </summary>
public IReadOnlyList<float> TilingByLayer { get; }
// --- Alpha atlas (new in Phase 3c.2) ---
public uint GlAlphaTexture { get; }
public int AlphaLayerCount { get; }
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> CornerAlphaLayers { get; }
/// <summary>Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> SideAlphaLayers { get; }
/// <summary>Layer indices in the alpha atlas for RoadMaps (variable count).</summary>
public IReadOnlyList<byte> RoadAlphaLayers { get; }
// --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) ---
/// <summary>TCode for each CornerTerrainMap, parallel to <see cref="CornerAlphaLayers"/>.</summary>
public IReadOnlyList<uint> CornerAlphaTCodes { get; }
/// <summary>TCode for each SideTerrainMap, parallel to <see cref="SideAlphaLayers"/>.</summary>
public IReadOnlyList<uint> SideAlphaTCodes { get; }
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private readonly Wb.BindlessSupport? _bindless;
private readonly BindlessTexturePair? _bindlessHandles;
private readonly BindlessTextureMutationGuard? _bindlessMutation;
private readonly RestoredTextureBindingMutation _anisotropyBindingMutation = new();
private ResourceShutdownTransaction? _shutdown;
/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
/// Throws <see cref="InvalidOperationException"/> if the atlas was constructed
/// without a <see cref="Wb.BindlessSupport"/> instance. Handles are generated
/// lazily on first call and cached for the atlas's lifetime; both textures
/// are made resident.
/// </summary>
public (ulong terrain, ulong alpha) GetBindlessHandles()
{
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
(ulong terrain, ulong alpha) = _bindlessHandles!.Acquire();
return (terrain, alpha);
}
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
IReadOnlyList<float> tilingByLayer,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
{
_gl = gl;
_bindless = bindless;
GlTexture = glTexture;
TerrainTypeToLayer = map;
LayerCount = layerCount;
TilingByLayer = tilingByLayer;
GlAlphaTexture = glAlphaTexture;
AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers;
SideAlphaLayers = sideLayers;
RoadAlphaLayers = roadLayers;
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
if (bindless is not null)
{
_bindlessHandles = new BindlessTexturePair(
glTexture,
glAlphaTexture,
bindless.GetResidentHandle,
bindless.MakeNonResident);
_bindlessMutation = new BindlessTextureMutationGuard(_bindlessHandles);
}
}
/// <summary>
/// Build the atlas by walking Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null)
{
var textures = new GlTextureConstructionTransaction(new GlTextureNameApi(gl));
try
{
TerrainAtlas atlas = BuildCore(gl, dats, bindless, textures);
textures.Commit();
return atlas;
}
catch (Exception constructionFailure)
{
try
{
textures.Rollback();
}
catch (Exception cleanupFailure)
{
throw new GlResourceConstructionException(
"TerrainAtlas construction failed and its allocated OpenGL texture names did not cleanly roll back.",
textures,
[constructionFailure, cleanupFailure]);
}
throw;
}
}
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) ----
var decodedByType = new Dictionary<uint, DecodedTexture>();
var tilingByType = new Dictionary<uint, uint>();
int maxW = 1, maxH = 1;
foreach (var tmtd in terrainDesc)
{
uint typeKey = (uint)tmtd.TerrainType;
if (decodedByType.ContainsKey(typeKey))
continue;
// Retail terrain composition repeats this surface exactly
// TerrainTex::tex_tiling times in each axis before applying the
// cell-scale alpha mask. Preserve the dat field alongside the
// decoded surface while layer assignment is still type-keyed.
tilingByType[typeKey] = tmtd.TerrainTex.TexTiling;
var surfaceTextureId = (uint)tmtd.TerrainTex.TextureId;
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
if (st is null || st.Textures.Count == 0)
{
Console.WriteLine($"WARN: TerrainType {tmtd.TerrainType} SurfaceTexture 0x{surfaceTextureId:X8} missing");
decodedByType[typeKey] = DecodedTexture.Magenta;
continue;
}
// Retail ImgTex::GetSurfaceDID (0x0053F0E0) returns source level
// zero unless Render::ShouldDropHighDetail explicitly selects
// level one. acdream currently has no low-detail quality mode, so
// index zero is the retail high-detail source.
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
if (rs is null)
{
decodedByType[typeKey] = DecodedTexture.Magenta;
continue;
}
Palette? palette = rs.DefaultPaletteId != 0
? dats.Get<Palette>(rs.DefaultPaletteId)
: null;
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette);
decodedByType[typeKey] = decoded;
if (decoded.Width > maxW) maxW = decoded.Width;
if (decoded.Height > maxH) maxH = decoded.Height;
}
int layerCount = decodedByType.Count;
var map = new Dictionary<uint, uint>();
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain atlas texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)maxW, (uint)maxH, (uint)layerCount,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
int layerIdx = 0;
foreach (var kvp in decodedByType)
{
byte[] buffer = ResizeRgba8Nearest(kvp.Value, maxW, maxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, layerIdx,
(uint)maxW, (uint)maxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
map[kvp.Key] = (uint)layerIdx;
layerIdx++;
}
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
var tilingByLayer = TerrainTextureTilingTable.Build(
map.Select(entry =>
(entry.Value, tilingByType.TryGetValue(entry.Key, out uint repeatCount)
? repeatCount
: 1u)));
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
// if texMerge?.TerrainDesc was null.
var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!, textures);
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount, tilingByLayer,
alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes);
}
/// <summary>
/// Load corner, side, and road alpha maps from the TexMerge into a second
/// GL_TEXTURE_2D_ARRAY. AC ships these as 512×512 PFID_A8 textures;
/// <see cref="SurfaceDecoder.DecodeRenderSurface"/> expands each alpha byte
/// into all four RGBA channels so the shader can sample from any channel.
///
/// Layers are appended in TexMerge insertion order: corners first, then
/// sides, then roads. The returned index lists tell
/// <c>TerrainBlending.BuildSurface</c> which layer to cite for each
/// corner/side/road alpha source.
/// </summary>
private readonly record struct AlphaAtlasBuildResult(
uint gl, int layerCount,
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl,
IDatReaderWriter dats,
DatReaderWriter.Types.TexMerge texMerge,
GlTextureConstructionTransaction textures)
{
var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>();
var sideLayers = new List<byte>();
var roadLayers = new List<byte>();
var cornerTCodes = new List<uint>();
var sideTCodes = new List<uint>();
var roadRCodes = new List<uint>();
foreach (var entry in texMerge.CornerTerrainMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
cornerLayers.Add((byte)decoded.Count);
cornerTCodes.Add(entry.TCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: CornerTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
foreach (var entry in texMerge.SideTerrainMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
sideLayers.Add((byte)decoded.Count);
sideTCodes.Add(entry.TCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: SideTerrainMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
foreach (var entry in texMerge.RoadMaps)
{
if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex))
{
roadLayers.Add((byte)decoded.Count);
roadRCodes.Add(entry.RCode);
decoded.Add(dtex);
}
else
{
Console.WriteLine($"WARN: RoadMap TextureId 0x{(uint)entry.TextureId:X8} failed to decode");
}
}
if (decoded.Count == 0)
{
Console.WriteLine("WARN: no alpha maps loaded; alpha atlas will be a 1x1 white fallback");
uint fallbackAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0,
GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new AlphaAtlasBuildResult(
fallbackAlpha, 1,
cornerLayers, sideLayers, roadLayers,
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;
}
uint glAlpha = TrackedTextureConstruction.Create(
textures,
gl,
"upload terrain alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)aMaxW, (uint)aMaxH, (uint)decoded.Count,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
for (int i = 0; i < decoded.Count; i++)
{
var buffer = ResizeRgba8Nearest(decoded[i], aMaxW, aMaxH);
fixed (byte* p = buffer)
{
gl.TexSubImage3D(
TextureTarget.Texture2DArray, 0,
0, 0, i,
(uint)aMaxW, (uint)aMaxH, 1,
GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
}
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
Console.WriteLine(
$"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} "
+ $"(corners={cornerLayers.Count}, sides={sideLayers.Count}, roads={roadLayers.Count})");
return new AlphaAtlasBuildResult(
glAlpha, decoded.Count,
cornerLayers, sideLayers, roadLayers,
cornerTCodes, sideTCodes, roadRCodes);
}
private static bool TryDecodeAlphaMap(IDatReaderWriter dats, uint surfaceTextureId, out DecodedTexture decoded)
{
decoded = DecodedTexture.Magenta;
var st = dats.Get<SurfaceTexture>(surfaceTextureId);
if (st is null || st.Textures.Count == 0)
return false;
var rs = dats.Get<RenderSurface>((uint)st.Textures[0]);
if (rs is null)
return false;
// Alpha maps ship as PFID_CUSTOM_LSCAPE_ALPHA (AC's landscape-alpha
// format) or the more generic PFID_A8; terrain blending alpha masks
// MUST use isAdditive=true so R=G=B=A=val — the terrain fragment shader
// reads .r for the blend weight. Palette is not used.
var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null, isClipMap: false, isAdditive: true);
if (ReferenceEquals(d, DecodedTexture.Magenta))
return false;
decoded = d;
return true;
}
private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH)
{
if (src.Width == dstW && src.Height == dstH)
return src.Rgba8;
var dst = new byte[dstW * dstH * 4];
for (int y = 0; y < dstH; y++)
{
int srcY = y * src.Height / dstH;
for (int x = 0; x < dstW; x++)
{
int srcX = x * src.Width / dstW;
int si = (srcY * src.Width + srcX) * 4;
int di = (y * dstW + x) * 4;
dst[di + 0] = src.Rgba8[si + 0];
dst[di + 1] = src.Rgba8[si + 1];
dst[di + 2] = src.Rgba8[si + 2];
dst[di + 3] = src.Rgba8[si + 3];
}
}
return dst;
}
private static TerrainAtlas BuildFallback(
GL gl,
Wb.BindlessSupport? bindless,
GlTextureConstructionTransaction textures)
{
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
uint tex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback terrain texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
// Fallback alpha atlas: 1x1 white, no layers tracked
uint alphaTex = TrackedTextureConstruction.Create(
textures,
gl,
"upload fallback alpha texture array",
texture =>
{
gl.BindTexture(TextureTarget.Texture2DArray, texture);
gl.TexImage3D(TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, 1, 1, 1, 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
fixed (byte* p = white)
gl.TexSubImage3D(TextureTarget.Texture2DArray, 0, 0, 0, 0, 1, 1, 1, GLPixelFormat.Rgba, PixelType.UnsignedByte, p);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
});
return new TerrainAtlas(
gl,
bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
TerrainTextureTilingTable.Build(Array.Empty<(uint Layer, uint RepeatCount)>()),
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters
/// are mutated; we temporarily make it non-resident if needed.
/// </summary>
public void SetAnisotropic(int level)
{
void Mutate()
{
_anisotropyBindingMutation.Execute(
() => unchecked((uint)GlResourceCommand.Execute(
_gl,
"read terrain-array binding before anisotropy mutation",
() => _gl.GetInteger(GetPName.TextureBinding2DArray))),
binding => GlResourceCommand.Execute(
_gl,
"set terrain-array binding for anisotropy mutation",
() => _gl.BindTexture(TextureTarget.Texture2DArray, binding)),
GlTexture,
() => GlResourceCommand.Execute(
_gl,
"set terrain atlas anisotropy",
() =>
{
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(
TextureTarget.Texture2DArray,
(TextureParameterName)0x84FE,
(float)level);
}));
}
if (_bindlessMutation is not null)
_bindlessMutation.Execute(Mutate);
else
Mutate();
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
public void Dispose()
{
_shutdown ??= new ResourceShutdownTransaction(
new ResourceShutdownStage(
"terrain atlas bindless residency",
[
new ResourceShutdownOperation(
"release terrain and alpha handles",
() => _bindlessHandles?.Release()),
]),
new ResourceShutdownStage(
"terrain atlas textures",
[
new ResourceShutdownOperation(
"delete terrain texture",
() => GlResourceCommand.DeleteTexture(
_gl,
GlTexture,
$"delete terrain atlas texture {GlTexture}")),
new ResourceShutdownOperation(
"delete alpha texture",
() => GlResourceCommand.DeleteTexture(
_gl,
GlAlphaTexture,
$"delete terrain alpha texture {GlAlphaTexture}")),
]));
_shutdown.CompleteOrThrow();
}
}