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; /// /// Holds both texture arrays the terrain renderer samples from: /// /// /// Terrain atlas — one GL_TEXTURE_2D_ARRAY layer per terrain type /// (grass, dirt, sand, forest...), sourced from /// Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc. /// /// /// Alpha atlas — 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. /// /// /// The alpha atlas is built but not yet sampled by any shader — that wiring /// lands in Phase 3c.4 along with the shader rewrite. /// public sealed class TerrainAtlas : IDisposable { public IReadOnlyDictionary TerrainTypeToLayer { get; } public int LayerCount { get; } /// /// UV repeat count for each terrain-array layer. Retail forwards /// TerrainTex::tex_tiling to both base and merged terrain textures /// (`TexMerge::CopyAndTile` 0x00503580, `TexMerge::Merge` 0x005038C0). /// public IReadOnlyList TilingByLayer { get; } // --- Alpha atlas (new in Phase 3c.2) --- public int AlphaLayerCount { get; } /// Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries). public IReadOnlyList CornerAlphaLayers { get; } /// Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries). public IReadOnlyList SideAlphaLayers { get; } /// Layer indices in the alpha atlas for RoadMaps (variable count). public IReadOnlyList RoadAlphaLayers { get; } // --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) --- /// TCode for each CornerTerrainMap, parallel to . public IReadOnlyList CornerAlphaTCodes { get; } /// TCode for each SideTerrainMap, parallel to . public IReadOnlyList SideAlphaTCodes { get; } /// RCode for each RoadMap, parallel to . public IReadOnlyList RoadAlphaRCodes { get; } /// /// Campaign V slice V6i-2: both arrays are s the /// device created, and both slots were registered at build time, so /// 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. /// 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; /// /// The device-table slots for the terrain and alpha arrays. Registered once /// at build time and re-registered only by , /// which changes the sampler. /// internal (GpuTextureSlot Terrain, GpuTextureSlot Alpha) TextureSlots => (_rhi.TerrainSlot, _rhi.AlphaSlot); /// /// Retail's terrain arrays are trilinear-filtered with the highest anisotropy /// the quality preset allows; lowers it. /// private const float RetailMaxAnisotropy = 16f; private TerrainAtlas( IGpuDevice device, IGpuTexture terrain, IGpuTexture alpha, IGpuSampler alphaSampler, IReadOnlyDictionary map, int layerCount, IReadOnlyList tilingByLayer, int alphaLayerCount, IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList 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); } /// /// 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. /// private readonly record struct TerrainLayerDecode( Dictionary DecodedByType, Dictionary TilingByType, int MaxWidth, int MaxHeight); private static TerrainLayerDecode DecodeTerrainLayers( IDatReaderWriter dats, IReadOnlyList terrainDesc) { var decodedByType = new Dictionary(); var tilingByType = new Dictionary(); 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(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((uint)st.Textures[0]); if (rs is null) { decodedByType[typeKey] = DecodedTexture.Magenta; continue; } Palette? palette = rs.DefaultPaletteId != 0 ? dats.Get(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); } /// /// Slice V6i-2: the alpha-map decode, shared by both construction paths for /// the same reason is. /// private sealed record AlphaLayerDecode( List Decoded, List CornerLayers, List SideLayers, List RoadLayers, List CornerTCodes, List SideTCodes, List RoadRCodes, int MaxWidth, int MaxHeight); private static AlphaLayerDecode DecodeAlphaLayers( IDatReaderWriter dats, DatReaderWriter.Types.TexMerge texMerge) { var decoded = new List(); var cornerLayers = new List(); var sideLayers = new List(); var roadLayers = new List(); var cornerTCodes = new List(); var sideTCodes = new List(); var roadRCodes = new List(); 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); } /// /// Builds both arrays through . The terrain array /// gets a full mip chain ( 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. /// internal static TerrainAtlas BuildBackendNeutral(IGpuDevice device, IDatReaderWriter dats) { ArgumentNullException.ThrowIfNull(device); ArgumentNullException.ThrowIfNull(dats); var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge; var terrainDesc = texMerge?.TerrainDesc; Dictionary decodedByType; Dictionary tilingByType; int maxW, maxH; if (terrainDesc is null || terrainDesc.Count == 0) { Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer"); decodedByType = new Dictionary { [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 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(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); /// /// 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. /// 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(surfaceTextureId); if (st is null || st.Textures.Count == 0) return false; var rs = dats.Get((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; } /// /// Update terrain-array anisotropy at runtime (called by /// 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. /// 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(); } }