acdream/src/AcDream.App/Rendering/TerrainAtlas.cs
Erik 8a7a0837e1 feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1
already removed ImGui/Studio/DevTools. This step deletes the GL
rendering backend itself: every Gpu/Gl/** implementation, the Wb
ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/
BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache,
RenderBootstrap, and RenderFrameGlStateController.

GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/
OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses
its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and
RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone —
there is nothing left to select between. The five world-draw dual-arm
renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer,
ParticleRenderer, SkyRenderer) and the composition roots
(WorldRenderComposition, HostInputCameraComposition,
LivePresentationComposition, FrameRootComposition) collapse to their
RHI-only arm. GL-only diagnostic properties with a live external reader
(DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op
rather than disappearing, since the reader is out of this commit's
scope.

A few GL-flavored mechanisms turned out to be backend-neutral once
isolated: GlConstructionCleanupLedger is renamed
ResourceConstructionCleanupLedger (exception-chain walking has nothing
to do with GL), and GlfwNativePlatformProbe moved out of the otherwise
GL-only GraphicalCapabilityRecord.cs into
GraphicalWindowBackendSelection.cs before the rest of that file was
deleted.

Test files with no surviving subject are deleted outright
(GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests,
PortalDepthShaderParityTests, TextureCacheBindlessTests,
TextRendererFailureSafetyTests, ClipFrameUploadTests, every
Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests);
others get their dead GL-only members trimmed while their live
assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now
reads GpuBindingModel.StorageClipRegions, the same binding index under
its new backend-neutral name; GpuResourceRetirementTransactionTests
drops its OpenGLGraphicsDevice-subclassing test double and the two GL
queue tests it existed for). EnvCellRendererTests' construction helper
now builds a real ObjectMeshManager via VulkanMeshPipelineDevice
instead of passing null through a null-forgiving operator, since the
RHI constructor never tolerated a null mesh manager and the old GL
constructor (which did) is gone.

Deferred to the next two steps, deliberately not touched here: the
Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl
(WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale
csproj comment (the package itself is still load-bearing —
TextureFormat and friends are used well beyond the deleted
ManagedGLUniformBuffer), and the CI/gate scripts.

Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors.
Tests: full-solution `dotnet test` green across every project
(App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all
others 100%); the 2 App.Tests names that flake under full-suite
parallel execution (#250-family, documented pre-existing) pass in
isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 02:19:53 +02:00

508 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.App.Rendering.Gpu;
using AcDream.Core.Textures;
using DatReaderWriter;
using AcDream.Content;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatPixelFormat = DatReaderWriter.Enums.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 class TerrainAtlas : IDisposable
{
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 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; }
/// <summary>
/// Campaign V slice V6i-2: 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. The GL construction path this used to sit alongside is
/// deleted as of Campaign V slice V11.
/// </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>
/// The device-table slots for the terrain and alpha arrays. 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.TerrainSlot, _rhi.AlphaSlot);
/// <summary>
/// Retail's terrain arrays are trilinear-filtered with the highest anisotropy
/// the quality preset allows; <see cref="SetAnisotropic"/> lowers it.
/// </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)
{
_rhi = new RhiArrays(device, terrain, alpha, alphaSampler);
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);
}
/// <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,
IReadOnlyList<DatReaderWriter.Types.TMTerrainDesc> terrainDesc)
{
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;
}
return new TerrainLayerDecode(decodedByType, tilingByType, maxW, maxH);
}
/// <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)
{
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");
}
}
// 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);
}
/// <summary>
/// Builds both arrays through <see cref="IGpuDevice"/>. 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, matching what the
/// deleted GL path's texture parameters said.
/// </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;
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;
}
/// <summary>
/// Update terrain-array anisotropy 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.
/// </summary>
public void SetAnisotropic(int level)
{
ApplyAnisotropic(level);
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
public void Dispose()
{
// Slice V4t's teardown rule: 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();
}
}