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()
{