acdream/src/AcDream.App/Rendering/TerrainAtlas.cs
Erik db0f010544 phase(N.5b) Task 1: TerrainAtlas bindless extension
Add optional BindlessSupport ctor parameter + GetBindlessHandles()
method that returns (terrainHandle, alphaHandle) ulongs with both
textures made resident. Two-phase Dispose mirroring TextureCache
(MakeNonResident before DeleteTexture per ARB_bindless_texture spec).

Existing callers pass `Build(gl, dats)` unchanged; bindless = null
default keeps them working until T6/T8 wires the renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:37:23 +02:00

429 lines
18 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 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; }
// --- 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;
// Cached bindless handles. Generated lazily on first GetBindlessHandles() call;
// reused for the lifetime of the atlas.
private ulong _terrainHandle;
private ulong _alphaHandle;
private bool _handlesGenerated;
/// <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.");
if (!_handlesGenerated)
{
_terrainHandle = _bindless.GetResidentHandle(GlTexture);
_alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture);
_handlesGenerated = true;
}
return (_terrainHandle, _alphaHandle);
}
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
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;
GlAlphaTexture = glAlphaTexture;
AlphaLayerCount = alphaLayerCount;
CornerAlphaLayers = cornerLayers;
SideAlphaLayers = sideLayers;
RoadAlphaLayers = roadLayers;
CornerAlphaTCodes = cornerTCodes;
SideAlphaTCodes = sideTCodes;
RoadAlphaRCodes = roadRCodes;
}
/// <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, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
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);
}
// ---- Terrain atlas (unchanged Phase 2b logic) ----
var decodedByType = new Dictionary<uint, DecodedTexture>();
int maxW = 1, maxH = 1;
foreach (var tmtd in terrainDesc)
{
uint typeKey = (uint)tmtd.TerrainType;
if (decodedByType.ContainsKey(typeKey))
continue;
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;
}
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;
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
gl.TexImage3D(
TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
(uint)maxW, (uint)maxH, (uint)layerCount,
0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
var map = new Dictionary<uint, uint>();
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++;
}
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.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}");
// ---- 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!);
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount,
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, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
{
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 = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, fallbackAlpha);
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 = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, glAlpha);
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(DatCollection 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 = null)
{
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
var white = new byte[] { 0xFF, 0xFF, 0xFF, 0xFF };
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 = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, alphaTex);
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,
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
public void Dispose()
{
// Phase 1: release bindless residency BEFORE deleting textures.
// ARB_bindless_texture requires this ordering; interleaving is UB.
if (_handlesGenerated && _bindless is not null)
{
_bindless.MakeNonResident(_terrainHandle);
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
// Phase 2: delete the underlying GL textures.
_gl.DeleteTexture(GlTexture);
_gl.DeleteTexture(GlAlphaTexture);
}
}