diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 470231b..741397a 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -9,24 +9,65 @@ using GLPixelFormat = Silk.NET.OpenGL.PixelFormat; namespace AcDream.App.Rendering; /// -/// Builds a GL_TEXTURE_2D_ARRAY from the set of terrain types seen in the loaded -/// landblocks, one layer per unique terrain type. LandblockMesh writes per-vertex -/// layer indices into Vertex.TerrainLayer; the terrain fragment shader samples -/// texture(uAtlas, vec3(uv, float(vLayer))). +/// 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 unsafe class TerrainAtlas : IDisposable { private readonly GL _gl; - public uint GlTexture { get; } + + // --- Terrain atlas (unchanged public API from Phase 2b) --- + public uint GlTexture { get; } // terrain atlas, kept as GlTexture for back-compat with TerrainRenderer public IReadOnlyDictionary TerrainTypeToLayer { get; } public int LayerCount { get; } - private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary map, int layerCount) + // --- Alpha atlas (new in Phase 3c.2) --- + public uint GlAlphaTexture { get; } + public int AlphaLayerCount { get; } + /// + /// Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries). + /// Matches WorldBuilder's convention that corner alpha indices start at 0. + /// + public IReadOnlyList CornerAlphaLayers { get; } + /// + /// Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries). + /// WorldBuilder convention: side indices start at 4. + /// + public IReadOnlyList SideAlphaLayers { get; } + /// + /// Layer indices in the alpha atlas for RoadMaps (variable count, typically ~10). + /// + public IReadOnlyList RoadAlphaLayers { get; } + + private TerrainAtlas( + GL gl, + uint glTexture, IReadOnlyDictionary map, int layerCount, + uint glAlphaTexture, int alphaLayerCount, + IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers) { _gl = gl; GlTexture = glTexture; TerrainTypeToLayer = map; LayerCount = layerCount; + GlAlphaTexture = glAlphaTexture; + AlphaLayerCount = alphaLayerCount; + CornerAlphaLayers = cornerLayers; + SideAlphaLayers = sideLayers; + RoadAlphaLayers = roadLayers; } /// @@ -39,17 +80,15 @@ public sealed unsafe class TerrainAtlas : IDisposable var region = dats.Get(0x13000000u) ?? throw new InvalidOperationException("Region dat id 0x13000000 missing"); - var terrainDesc = region.TerrainInfo?.LandSurfaces?.TexMerge?.TerrainDesc; + var texMerge = region.TerrainInfo?.LandSurfaces?.TexMerge; + var terrainDesc = texMerge?.TerrainDesc; if (terrainDesc is null || terrainDesc.Count == 0) { - // Fallback: upload a single 1x1 white layer as layer 0. Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer"); return BuildFallback(gl); } - // Walk TerrainDesc. Each TMTerrainDesc has a TerrainType (enum cast to uint) - // and a TerrainTex with a QualifiedDataId TextureId. Decode - // each referenced SurfaceTexture → RenderSurface → RGBA8 via SurfaceDecoder. + // ---- Terrain atlas (unchanged Phase 2b logic) ---- var decodedByType = new Dictionary(); int maxW = 1, maxH = 1; foreach (var tmtd in terrainDesc) @@ -84,9 +123,6 @@ public sealed unsafe class TerrainAtlas : IDisposable if (decoded.Height > maxH) maxH = decoded.Height; } - // Allocate the GL_TEXTURE_2D_ARRAY with the max dimensions seen. Textures - // smaller than (maxW, maxH) are scaled up naively by nearest-neighbor - // replication into a resized RGBA8 buffer. Phase 2b doesn't need mip chains. int layerCount = decodedByType.Count; uint tex = gl.GenTexture(); gl.BindTexture(TextureTarget.Texture2DArray, tex); @@ -116,11 +152,159 @@ public sealed unsafe class TerrainAtlas : IDisposable 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} layers at {maxW}x{maxH}"); - return new TerrainAtlas(gl, tex, map, layerCount); + 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 (glAlpha, alphaLayerCount, cornerLayers, sideLayers, roadLayers) = + BuildAlphaAtlas(gl, dats, texMerge!); + + return new TerrainAtlas( + gl, + tex, map, layerCount, + glAlpha, alphaLayerCount, + cornerLayers, sideLayers, roadLayers); + } + + /// + /// 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; + /// 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 + /// TerrainBlending.BuildSurface which layer to cite for each + /// corner/side/road alpha source. + /// + private static (uint gl, int layerCount, + IReadOnlyList corner, IReadOnlyList side, IReadOnlyList road) + BuildAlphaAtlas(GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge) + { + var decoded = new List(); + var cornerLayers = new List(); + var sideLayers = new List(); + var roadLayers = new List(); + + foreach (var entry in texMerge.CornerTerrainMaps) + { + if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) + { + cornerLayers.Add((byte)decoded.Count); + 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); + 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); + 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 (fallbackAlpha, 1, cornerLayers, sideLayers, roadLayers); + } + + // 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 (glAlpha, decoded.Count, cornerLayers, sideLayers, roadLayers); + } + + private static bool TryDecodeAlphaMap(DatCollection 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; SurfaceDecoder routes both + // through the same "replicate single byte to RGBA" path. Palette is + // not used. + var d = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); + if (ReferenceEquals(d, DecodedTexture.Magenta)) + return false; + + decoded = d; + return true; } private static byte[] ResizeRgba8Nearest(DecodedTexture src, int dstW, int dstH) @@ -157,8 +341,25 @@ public sealed unsafe class TerrainAtlas : IDisposable gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); gl.BindTexture(TextureTarget.Texture2DArray, 0); - return new TerrainAtlas(gl, tex, new Dictionary { [0] = 0u }, 1); + + // 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, + tex, new Dictionary { [0] = 0u }, 1, + alphaTex, 1, + Array.Empty(), Array.Empty(), Array.Empty()); } - public void Dispose() => _gl.DeleteTexture(GlTexture); + public void Dispose() + { + _gl.DeleteTexture(GlTexture); + _gl.DeleteTexture(GlAlphaTexture); + } } diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 8c7c9b5..a33c5b5 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -38,6 +38,7 @@ public static class SurfaceDecoder PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), + PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), _ => DecodedTexture.Magenta, }; @@ -104,6 +105,34 @@ public static class SurfaceDecoder Height: 1); } + /// + /// Decode single-byte-per-pixel alpha (PFID_A8 / PFID_CUSTOM_LSCAPE_ALPHA) + /// into RGBA8 by replicating each alpha byte into all four channels. AC's + /// terrain blending alpha masks are stored as PFID_CUSTOM_LSCAPE_ALPHA and + /// other generic 8-bit alpha surfaces use PFID_A8; the bit layout is + /// identical so one decoder handles both. Replicating into all four + /// channels lets the fragment shader pull "the blend amount" from either + /// .a or .r without special-casing. + /// + private static DecodedTexture DecodeA8(RenderSurface rs) + { + int expected = rs.Width * rs.Height; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected * 4]; + for (int i = 0; i < expected; i++) + { + byte a = rs.SourceData[i]; + int d = i * 4; + rgba[d + 0] = a; + rgba[d + 1] = a; + rgba[d + 2] = a; + rgba[d + 3] = a; + } + return new DecodedTexture(rgba, rs.Width, rs.Height); + } + private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) { int expected = rs.Width * rs.Height * 4; diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs index 92f7bd7..4fb9c78 100644 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -55,6 +55,81 @@ public class SurfaceDecoderTests Assert.Same(DecodedTexture.Magenta, decoded); } + [Fact] + public void Decode_A8_ExpandsSingleByteToRgbaWithAlphaInAllChannels() + { + // PFID_A8 is single-byte-per-pixel alpha. AC terrain blending alpha maps + // are stored this way. WorldBuilder's GetExpandedAlphaTexture replicates + // the byte into all four RGBA channels so fragment shaders can read the + // blend value from any channel (convention: the alpha channel). + var src = new byte[] { 0x00, 0x40, 0x80, 0xFF }; // 2x2 image + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = PixelFormat.PFID_A8, + SourceData = src, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Equal(2, decoded.Width); + Assert.Equal(2, decoded.Height); + Assert.Equal(16, decoded.Rgba8.Length); + // Each input byte expands to (b, b, b, b) in RGBA output + Assert.Equal(new byte[] + { + 0x00, 0x00, 0x00, 0x00, + 0x40, 0x40, 0x40, 0x40, + 0x80, 0x80, 0x80, 0x80, + 0xFF, 0xFF, 0xFF, 0xFF, + }, decoded.Rgba8); + } + + [Fact] + public void Decode_CustomLscapeAlpha_TreatedIdenticallyToA8() + { + // PFID_CUSTOM_LSCAPE_ALPHA (0xF4) is AC's custom format for terrain + // blending alpha maps. Pixel layout is identical to PFID_A8 — one + // byte of alpha per pixel — so the decoder routes both through the + // same DecodeA8 implementation. + var src = new byte[] { 0x10, 0x20, 0x30, 0x40 }; // 2x2 + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA, + SourceData = src, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Equal(16, decoded.Rgba8.Length); + Assert.Equal(new byte[] + { + 0x10, 0x10, 0x10, 0x10, + 0x20, 0x20, 0x20, 0x20, + 0x30, 0x30, 0x30, 0x30, + 0x40, 0x40, 0x40, 0x40, + }, decoded.Rgba8); + } + + [Fact] + public void Decode_A8_WithShortSourceData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 4, + Height = 4, + Format = PixelFormat.PFID_A8, + SourceData = new byte[8], // expects 16 + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + [Fact] public void Decode_NullSourceData_ReturnsMagenta() {