acdream/src/AcDream.App/Rendering/TerrainAtlas.cs
Erik 28d2c6018e feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2)
GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality)
+ WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores
result in _resolvedQuality field. All six quality dimensions applied:

- NearRadius / FarRadius: replace old T16 env-var-only block; preset drives
  the radii, legacy ACDREAM_STREAM_RADIUS override still honoured.
- MsaaSamples: WindowOptions.Samples reads from startup quality resolution
  in Run() (pre-window-create read from SettingsStore). MSAA cannot change
  at runtime; ReapplyQualityPreset logs a restart-required warning if the
  new preset would change it.
- AnisotropicLevel: TerrainAtlas.SetAnisotropic() called after Build() and
  again in ReapplyQualityPreset. Temporarily removes bindless residency
  before the GL TexParameter call, re-makes resident after.
- AlphaToCoverage: WbDrawDispatcher.AlphaToCoverage property gates the
  glEnable/glDisable(SampleAlphaToCoverage) pair around the opaque pass.
- MaxCompletionsPerFrame: set on StreamingController after construction
  and after each mid-session restart.

ReapplyQualityPreset(QualityPreset) method handles mid-session changes
(Settings panel Quality dropdown Save): rebuilds streamer + controller for
radius changes, toggles A2C and aniso immediately, logs MSAA restart caveat.
onSaveDisplay callback updated to call ReapplyQualityPreset when Quality
field changes.

TerrainModernRenderer.Atlas property added to expose the atlas for
mid-session aniso updates.

991 tests passing, 8 pre-existing failures unchanged.

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

470 lines
20 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++;
}
// 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);
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!);
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>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.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)
{
// If bindless handles are live we must make them non-resident before
// mutating texture state, then re-resident after.
bool wasResident = _handlesGenerated && _bindless is not null;
if (wasResident)
{
_bindless!.MakeNonResident(_terrainHandle);
// Alpha texture is not affected by anisotropic but we must keep
// residency symmetric — re-generate both handles after.
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
_gl.BindTexture(TextureTarget.Texture2DArray, GlTexture);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
// Re-generate bindless handles if they were live before.
if (wasResident)
{
// GetBindlessHandles regenerates and makes resident.
_ = GetBindlessHandles();
}
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
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);
}
}