diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs
new file mode 100644
index 0000000..470231b
--- /dev/null
+++ b/src/AcDream.App/Rendering/TerrainAtlas.cs
@@ -0,0 +1,164 @@
+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;
+
+///
+/// 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))).
+///
+public sealed unsafe class TerrainAtlas : IDisposable
+{
+ private readonly GL _gl;
+ public uint GlTexture { get; }
+ public IReadOnlyDictionary TerrainTypeToLayer { get; }
+ public int LayerCount { get; }
+
+ private TerrainAtlas(GL gl, uint glTexture, IReadOnlyDictionary map, int layerCount)
+ {
+ _gl = gl;
+ GlTexture = glTexture;
+ TerrainTypeToLayer = map;
+ LayerCount = layerCount;
+ }
+
+ ///
+ /// 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.
+ ///
+ public static TerrainAtlas Build(GL gl, DatCollection dats)
+ {
+ var region = dats.Get(0x13000000u)
+ ?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
+
+ var terrainDesc = region.TerrainInfo?.LandSurfaces?.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.
+ var decodedByType = new Dictionary();
+ 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(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((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;
+ }
+
+ // 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);
+ gl.TexImage3D(
+ TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8,
+ (uint)maxW, (uint)maxH, (uint)layerCount,
+ 0, GLPixelFormat.Rgba, PixelType.UnsignedByte, null);
+
+ var map = new Dictionary();
+ 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++;
+ }
+
+ 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.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);
+ }
+
+ 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)
+ {
+ 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);
+ return new TerrainAtlas(gl, tex, new Dictionary { [0] = 0u }, 1);
+ }
+
+ public void Dispose() => _gl.DeleteTexture(GlTexture);
+}