From e0dfecdf23d28facfa3204b32202f7572a02396f Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 14:02:15 +0200 Subject: [PATCH] feat(core+app): per-cell terrain texture blending (Phase 3c.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks: Holtburg's terrain now uses AC's real per-cell texture-merge blend (base + up to 3 terrain overlays + up to 2 road overlays, with alpha masks from the alpha atlas) instead of the flat per-vertex single-layer atlas lookup that preceded it. Geometry rewrite: - New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) + Data0..3 (4x uint32 packed blend recipe) - LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of the old 9x9 vertex grid, emits 6 vertices per cell (two triangles), 384 total vertices per landblock - For each cell: extract 4-corner terrain/road values → GetPalCode → BuildSurface (cached across landblocks via a shared surfaceCache) → FillCellData → split direction from CalculateSplitDirection → emit 6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex shader expects - Per-vertex normals preserved via Phase 3b central-difference precomputation on the 9x9 heightmap, interpolated smoothly across the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy flat-shade approach — Phase 3a/3b user-tuned lighting was worth keeping) Renderer rewrite: - TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte attributes for Data0..3. The uvec4-of-bytes read pattern matches Landscape.vert so the ported shader math stays byte-for-byte identical to WorldBuilder's. - Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas on unit 1 (uAlpha) Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed): - terrain.vert: unpacks the 4 data bytes + rotation bits, derives the cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local UV per overlay's rotation field, and computes world-space normal for the fragment shader - terrain.frag: maskBlend3 three-layer alpha-weighted composite for terrain overlays, inverted-alpha road combine, final composite base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase 3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25, DIFFUSE=0.75, in sync with mesh.frag). - Editor uniforms (grid, brush, unwalkable slopes) deliberately omitted — not applicable to a game client - Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder reads it from uTexTiling[36] uploaded from the dats); one tile per cell = 8 tiles per landblock-side, slightly coarser than the old ~2x-per-cell tiling. Tunable via the TILE constant if needed. TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes, SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be built without the mesh loader touching the dats directly. GameWindow builds a TerrainBlendingContext once, shares a Dictionary surfaceCache across all 9 landblocks. Output: "terrain: 137 unique palette codes across 9 landblocks" — avg ~15 unique per landblock, cache reuse healthy. LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green. Visual smoke run launches clean: no shader compile/link errors, no GL warnings, terrain renders to the screen. User visual verification is the final acceptance gate for Phase 3c. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 36 ++- .../Rendering/Shaders/terrain.frag | 126 ++++++++++- .../Rendering/Shaders/terrain.vert | 106 ++++++++- src/AcDream.App/Rendering/TerrainAtlas.cs | 65 ++++-- src/AcDream.App/Rendering/TerrainRenderer.cs | 40 +++- src/AcDream.Core/Terrain/LandblockMesh.cs | 210 +++++++++++++----- src/AcDream.Core/Terrain/TerrainVertex.cs | 28 +++ .../Terrain/LandblockMeshTests.cs | 169 +++++++++----- 8 files changed, 610 insertions(+), 170 deletions(-) create mode 100644 src/AcDream.Core/Terrain/TerrainVertex.cs diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index beb110f..282ef0e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -149,21 +149,47 @@ public sealed class GameWindow : IDisposable int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); + // Shared blending context + SurfaceInfo cache across all loaded + // landblocks. Palette codes are deterministic so two landblocks that + // happen to share a cell layout hit the cache instead of rebuilding. + var terrainTypeToLayerBytes = new Dictionary(terrainAtlas.TerrainTypeToLayer.Count); + foreach (var kvp in terrainAtlas.TerrainTypeToLayer) + terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value; + + const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType + byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl) + ? rl + : AcDream.Core.Terrain.SurfaceInfo.None; + + var blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext( + TerrainTypeToLayer: terrainTypeToLayerBytes, + RoadLayer: roadLayer, + CornerAlphaLayers: terrainAtlas.CornerAlphaLayers, + SideAlphaLayers: terrainAtlas.SideAlphaLayers, + RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, + CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes, + SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, + RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); + + var surfaceCache = new Dictionary(); + foreach (var lb in worldView.Landblocks) { + uint lbX = (lb.LandblockId >> 24) & 0xFFu; + uint lbY = (lb.LandblockId >> 16) & 0xFFu; + var meshData = AcDream.Core.Terrain.LandblockMesh.Build( - lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer); + lb.Heightmap, lbX, lbY, heightTable, blendCtx, surfaceCache); // Compute world origin for this landblock relative to the center. - int lbX = (int)((lb.LandblockId >> 24) & 0xFFu); - int lbY = (int)((lb.LandblockId >> 16) & 0xFFu); var origin = new System.Numerics.Vector3( - (lbX - centerX) * 192f, - (lbY - centerY) * 192f, + ((int)lbX - centerX) * 192f, + ((int)lbY - centerY) * 192f, 0f); _terrain.AddLandblock(meshData, origin); } + Console.WriteLine($"terrain: {surfaceCache.Count} unique palette codes across {worldView.Landblocks.Count} landblocks"); _textureCache = new TextureCache(_gl, _dats); _staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache); diff --git a/src/AcDream.App/Rendering/Shaders/terrain.frag b/src/AcDream.App/Rendering/Shaders/terrain.frag index 76a8db0..d8b3ff9 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.frag +++ b/src/AcDream.App/Rendering/Shaders/terrain.frag @@ -1,23 +1,127 @@ #version 430 core -in vec2 vTex; -in flat uint vLayer; -in vec3 vWorldNormal; +// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's +// Landscape.frag, trimmed of editor-specific features (grid, brush, +// walkable-slope highlighting) and with Phase 3a/3b directional lighting +// layered on at the end. + +in vec2 vBaseUV; +in vec3 vWorldNormal; +in vec4 vOverlay0; +in vec4 vOverlay1; +in vec4 vOverlay2; +in vec4 vRoad0; +in vec4 vRoad1; +flat in float vBaseTexIdx; + out vec4 fragColor; -uniform sampler2DArray uAtlas; +uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture +uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture -// Shared lighting model with mesh.frag — must stay in sync. Phase 3b gave -// terrain real per-vertex normals via central differences on the heightmap, -// so hills catch and shade the sun just like static meshes do. -const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); +// Phase 3a lighting (in sync with mesh.frag — update both together). +const vec3 SUN_DIR = normalize(vec3(0.5, 0.4, 0.6)); const float AMBIENT = 0.25; const float DIFFUSE = 0.75; -void main() { - vec4 sampled = texture(uAtlas, vec3(vTex, float(vLayer))); +// Per-texture tiling repeat count across a cell. WorldBuilder uses +// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per +// cell, 8 tiles across a landblock) until we wire the array. The previous +// Phase 2b/3 single-layer path tiled at ~2 per cell, so the world may read +// slightly coarser at 1.0 — tunable here if it looks wrong. +const float TILE = 1.0; +// Three-layer alpha-weighted composite. Each terrain overlay layer +// contributes based on its own alpha mask; missing layers (h == 0) collapse +// to transparent. Lifted verbatim from WorldBuilder's Landscape.frag. +vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) { + float a0 = h0 == 0.0 ? 1.0 : t0.a; + float a1 = h1 == 0.0 ? 1.0 : t1.a; + float a2 = h2 == 0.0 ? 1.0 : t2.a; + float aR = 1.0 - (a0 * a1 * a2); + // avoid divide-by-zero when all three overlays are absent + float aRsafe = max(aR, 1e-6); + a0 = 1.0 - a0; + a1 = 1.0 - a1; + a2 = 1.0 - a2; + vec3 r0 = (a0 * t0.rgb + (1.0 - a0) * a1 * t1.rgb + (1.0 - a1) * a2 * t2.rgb); + return vec4(r0 / aRsafe, aR); +} + +vec4 combineOverlays(vec2 baseUV, vec4 pOverlay0, vec4 pOverlay1, vec4 pOverlay2) { + float h0 = pOverlay0.z < 0.0 ? 0.0 : 1.0; + float h1 = pOverlay1.z < 0.0 ? 0.0 : 1.0; + float h2 = pOverlay2.z < 0.0 ? 0.0 : 1.0; + vec4 t0 = vec4(0.0), t1 = vec4(0.0), t2 = vec4(0.0); + + if (h0 > 0.0) { + t0 = texture(uTerrain, vec3(baseUV * TILE, pOverlay0.z)); + if (pOverlay0.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay0.xy, pOverlay0.w)); + t0.a = a.a; + } + } + if (h1 > 0.0) { + t1 = texture(uTerrain, vec3(baseUV * TILE, pOverlay1.z)); + if (pOverlay1.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay1.xy, pOverlay1.w)); + t1.a = a.a; + } + } + if (h2 > 0.0) { + t2 = texture(uTerrain, vec3(baseUV * TILE, pOverlay2.z)); + if (pOverlay2.w >= 0.0) { + vec4 a = texture(uAlpha, vec3(pOverlay2.xy, pOverlay2.w)); + t2.a = a.a; + } + } + return maskBlend3(t0, t1, t2, h0, h1, h2); +} + +vec4 combineRoad(vec2 baseUV, vec4 pRoad0, vec4 pRoad1) { + float h0 = pRoad0.z < 0.0 ? 0.0 : 1.0; + float h1 = pRoad1.z < 0.0 ? 0.0 : 1.0; + vec4 result = vec4(0.0); + if (h0 > 0.0) { + result = texture(uTerrain, vec3(baseUV * TILE, pRoad0.z)); + if (pRoad0.w >= 0.0) { + vec4 a0 = texture(uAlpha, vec3(pRoad0.xy, pRoad0.w)); + // Roads use inverted alpha (the mask stores NON-road coverage). + result.a = 1.0 - a0.a; + if (h1 > 0.0 && pRoad1.w >= 0.0) { + vec4 a1 = texture(uAlpha, vec3(pRoad1.xy, pRoad1.w)); + result.a = 1.0 - (a0.a * a1.a); + } + } + } + return result; +} + +void main() { + // Base color: if there's no base layer (sentinel -1) just render black + // (shouldn't happen in valid data). + vec4 baseColor = vec4(0.0); + if (vBaseTexIdx >= 0.0) { + baseColor = texture(uTerrain, vec3(vBaseUV * TILE, vBaseTexIdx)); + } + + vec4 overlays = vec4(0.0); + if (vOverlay0.z >= 0.0) + overlays = combineOverlays(vBaseUV, vOverlay0, vOverlay1, vOverlay2); + + vec4 roads = vec4(0.0); + if (vRoad0.z >= 0.0) + roads = combineRoad(vBaseUV, vRoad0, vRoad1); + + // Composite: base × (1 - ovlA) × (1 - rdA) + ovl × ovlA × (1 - rdA) + road × rdA + vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a)); + vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a)); + vec3 roadMasked = roads.rgb * roads.a; + vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0); + + // Phase 3a/3b directional lighting (in sync with mesh.frag constants). vec3 N = normalize(vWorldNormal); float ndotl = max(dot(N, SUN_DIR), 0.0); float lighting = AMBIENT + DIFFUSE * ndotl; - fragColor = vec4(sampled.rgb * lighting, sampled.a); + + fragColor = vec4(rgb * lighting, 1.0); } diff --git a/src/AcDream.App/Rendering/Shaders/terrain.vert b/src/AcDream.App/Rendering/Shaders/terrain.vert index c81d77c..de32ebd 100644 --- a/src/AcDream.App/Rendering/Shaders/terrain.vert +++ b/src/AcDream.App/Rendering/Shaders/terrain.vert @@ -1,23 +1,105 @@ #version 430 core -layout(location = 0) in vec3 aPos; -layout(location = 1) in vec3 aNormal; -layout(location = 2) in vec2 aTex; -layout(location = 3) in uint aTerrainLayer; +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in uvec4 aPacked0; // bytes: baseTex, baseAlpha(255), ovl0Tex, ovl0Alpha +layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha +layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha +layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below) uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; -out vec2 vTex; -out flat uint vLayer; -out vec3 vWorldNormal; +out vec2 vBaseUV; +out vec3 vWorldNormal; +// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w". +// Negative .z means "layer not present, skip it in the fragment shader." +out vec4 vOverlay0; +out vec4 vOverlay1; +out vec4 vOverlay2; +out vec4 vRoad0; +out vec4 vRoad1; +flat out float vBaseTexIdx; + +// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check +// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's +// 90° rotation count. +vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) { + float texIdx = float(texIdxU); + float alphaIdx = float(alphaIdxU); + if (texIdx >= 254.0) texIdx = -1.0; + if (alphaIdx >= 254.0) alphaIdx = -1.0; + + vec2 rotatedUV = baseUV; + if (rotIdx == 1u) rotatedUV = vec2(1.0 - baseUV.y, baseUV.x); + else if (rotIdx == 2u) rotatedUV = vec2(1.0 - baseUV.x, 1.0 - baseUV.y); + else if (rotIdx == 3u) rotatedUV = vec2( baseUV.y, 1.0 - baseUV.x); + + return vec4(rotatedUV.x, rotatedUV.y, texIdx, alphaIdx); +} void main() { - vTex = aTex; - vLayer = aTerrainLayer; - // uModel for terrain is a pure translation so mat3(uModel) is identity - // and vWorldNormal == aNormal. Computing it the uniform way anyway so - // later world-rotated landblocks (if any) still work. + // Unpack rotation fields from aPacked3. Bit layout (data3): + // .x (byte 0): bits 0-1 rotBase (unused), 2-3 rotOvl0, 4-5 rotOvl1, 6-7 rotOvl2 + // .y (byte 1): bits 0-1 rotRd0 (= data3 bit 8-9), + // bits 2-3 rotRd1 (= data3 bit 10-11), + // bit 4 splitDir (= data3 bit 12) + uint rotOvl0 = (aPacked3.x >> 2u) & 3u; + uint rotOvl1 = (aPacked3.x >> 4u) & 3u; + uint rotOvl2 = (aPacked3.x >> 6u) & 3u; + uint rotRd0 = aPacked3.y & 3u; + uint rotRd1 = (aPacked3.y >> 2u) & 3u; + uint splitDir= (aPacked3.y >> 4u) & 1u; + + // Derive which of the 4 cell corners this vertex represents from + // gl_VertexID % 6. The CPU-side LandblockMesh emits vertices in a + // specific order for each split direction; the table below must stay + // in lockstep with LandblockMesh.Build's SWtoNE/SEtoNW branches. + // + // Corner labels: 0=BL (low x/y), 1=BR (high x, low y), + // 2=TR (high x/y), 3=TL (low x, high y). + // WorldBuilder assigns cell-local UV per corner: + // 0 → (0, 1) 1 → (1, 1) 2 → (1, 0) 3 → (0, 0) + // (the v axis is flipped vs. geometric convention — harmless, just a + // texture-space choice). + int vIdx = gl_VertexID % 6; + int corner = 0; + if (splitDir == 0u) { + // SWtoNE order: BL, TL, BR, BR, TL, TR → corners 0, 3, 1, 1, 3, 2 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 3; + else if (vIdx == 2) corner = 1; + else if (vIdx == 3) corner = 1; + else if (vIdx == 4) corner = 3; + else corner = 2; + } else { + // SEtoNW order: BL, TR, BR, BL, TL, TR → corners 0, 2, 1, 0, 3, 2 + if (vIdx == 0) corner = 0; + else if (vIdx == 1) corner = 2; + else if (vIdx == 2) corner = 1; + else if (vIdx == 3) corner = 0; + else if (vIdx == 4) corner = 3; + else corner = 2; + } + + vec2 baseUV; + if (corner == 0) baseUV = vec2(0.0, 1.0); + else if (corner == 1) baseUV = vec2(1.0, 1.0); + else if (corner == 2) baseUV = vec2(1.0, 0.0); + else baseUV = vec2(0.0, 0.0); + + vBaseUV = baseUV; vWorldNormal = normalize(mat3(uModel) * aNormal); + + float baseTex = float(aPacked0.x); + if (baseTex >= 254.0) baseTex = -1.0; + vBaseTexIdx = baseTex; + + vOverlay0 = unpackOverlayLayer(aPacked0.z, aPacked0.w, rotOvl0, baseUV); + vOverlay1 = unpackOverlayLayer(aPacked1.x, aPacked1.y, rotOvl1, baseUV); + vOverlay2 = unpackOverlayLayer(aPacked1.z, aPacked1.w, rotOvl2, baseUV); + vRoad0 = unpackOverlayLayer(aPacked2.x, aPacked2.y, rotRd0, baseUV); + vRoad1 = unpackOverlayLayer(aPacked2.z, aPacked2.w, rotRd1, baseUV); + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); } diff --git a/src/AcDream.App/Rendering/TerrainAtlas.cs b/src/AcDream.App/Rendering/TerrainAtlas.cs index 741397a..6e8584a 100644 --- a/src/AcDream.App/Rendering/TerrainAtlas.cs +++ b/src/AcDream.App/Rendering/TerrainAtlas.cs @@ -38,26 +38,27 @@ public sealed unsafe class TerrainAtlas : IDisposable // --- 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. - /// + /// 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). - /// WorldBuilder convention: side indices start at 4. - /// + /// 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, typically ~10). - /// + /// 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; } + private TerrainAtlas( GL gl, uint glTexture, IReadOnlyDictionary map, int layerCount, uint glAlphaTexture, int alphaLayerCount, - IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers) + IReadOnlyList cornerLayers, IReadOnlyList sideLayers, IReadOnlyList roadLayers, + IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes) { _gl = gl; GlTexture = glTexture; @@ -68,6 +69,9 @@ public sealed unsafe class TerrainAtlas : IDisposable CornerAlphaLayers = cornerLayers; SideAlphaLayers = sideLayers; RoadAlphaLayers = roadLayers; + CornerAlphaTCodes = cornerTCodes; + SideAlphaTCodes = sideTCodes; + RoadAlphaRCodes = roadRCodes; } /// @@ -159,14 +163,14 @@ public sealed unsafe class TerrainAtlas : IDisposable // ---- 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!); + var alphaBuild = BuildAlphaAtlas(gl, dats, texMerge!); return new TerrainAtlas( gl, tex, map, layerCount, - glAlpha, alphaLayerCount, - cornerLayers, sideLayers, roadLayers); + alphaBuild.gl, alphaBuild.layerCount, + alphaBuild.corner, alphaBuild.side, alphaBuild.road, + alphaBuild.cornerTCodes, alphaBuild.sideTCodes, alphaBuild.roadRCodes); } /// @@ -180,20 +184,28 @@ public sealed unsafe class TerrainAtlas : IDisposable /// 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) + private readonly record struct AlphaAtlasBuildResult( + uint gl, int layerCount, + IReadOnlyList corner, IReadOnlyList side, IReadOnlyList road, + IReadOnlyList cornerTCodes, IReadOnlyList sideTCodes, IReadOnlyList roadRCodes); + + private static AlphaAtlasBuildResult 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(); + 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 @@ -206,6 +218,7 @@ public sealed unsafe class TerrainAtlas : IDisposable if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) { sideLayers.Add((byte)decoded.Count); + sideTCodes.Add(entry.TCode); decoded.Add(dtex); } else @@ -218,6 +231,7 @@ public sealed unsafe class TerrainAtlas : IDisposable if (TryDecodeAlphaMap(dats, (uint)entry.TextureId, out var dtex)) { roadLayers.Add((byte)decoded.Count); + roadRCodes.Add(entry.RCode); decoded.Add(dtex); } else @@ -238,7 +252,10 @@ public sealed unsafe class TerrainAtlas : IDisposable 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); + return new AlphaAtlasBuildResult( + fallbackAlpha, 1, + cornerLayers, sideLayers, roadLayers, + cornerTCodes, sideTCodes, roadRCodes); } // Alpha maps should all be uniform size (WorldBuilder asserts 512×512). @@ -280,7 +297,10 @@ public sealed unsafe class TerrainAtlas : IDisposable $"AlphaAtlas: {decoded.Count} layers at {aMaxW}x{aMaxH} " + $"(corners={cornerLayers.Count}, sides={sideLayers.Count}, roads={roadLayers.Count})"); - return (glAlpha, decoded.Count, cornerLayers, sideLayers, roadLayers); + return new AlphaAtlasBuildResult( + glAlpha, decoded.Count, + cornerLayers, sideLayers, roadLayers, + cornerTCodes, sideTCodes, roadRCodes); } private static bool TryDecodeAlphaMap(DatCollection dats, uint surfaceTextureId, out DecodedTexture decoded) @@ -354,7 +374,8 @@ public sealed unsafe class TerrainAtlas : IDisposable gl, tex, new Dictionary { [0] = 0u }, 1, alphaTex, 1, - Array.Empty(), Array.Empty(), Array.Empty()); + Array.Empty(), Array.Empty(), Array.Empty(), + Array.Empty(), Array.Empty(), Array.Empty()); } public void Dispose() diff --git a/src/AcDream.App/Rendering/TerrainRenderer.cs b/src/AcDream.App/Rendering/TerrainRenderer.cs index 11cd851..2c4b768 100644 --- a/src/AcDream.App/Rendering/TerrainRenderer.cs +++ b/src/AcDream.App/Rendering/TerrainRenderer.cs @@ -4,6 +4,19 @@ using Silk.NET.OpenGL; namespace AcDream.App.Rendering; +/// +/// Draws the Phase 3c per-cell terrain mesh. Each landblock owns its own +/// VBO+EBO+VAO (no chunking yet — deferred to a hypothetical Phase 3d) and +/// gets drawn with a single DrawElements call per landblock. +/// +/// Attribute layout (see TerrainVertex for the byte layout): +/// location 0: vec3 aPos (3 floats) +/// location 1: vec3 aNormal (3 floats) +/// location 2: uvec4 aPacked0 (4 bytes, Data0) +/// location 3: uvec4 aPacked1 (4 bytes, Data1) +/// location 4: uvec4 aPacked2 (4 bytes, Data2) +/// location 5: uvec4 aPacked3 (4 bytes, Data3) +/// public sealed unsafe class TerrainRenderer : IDisposable { private readonly GL _gl; @@ -33,7 +46,7 @@ public sealed unsafe class TerrainRenderer : IDisposable _gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo); fixed (void* p = meshData.Vertices) _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw); + (nuint)(meshData.Vertices.Length * sizeof(TerrainVertex)), p, BufferUsageARB.StaticDraw); gpu.Ebo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo); @@ -41,15 +54,26 @@ public sealed unsafe class TerrainRenderer : IDisposable _gl.BufferData(BufferTargetARB.ElementArrayBuffer, (nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); - uint stride = (uint)sizeof(Vertex); + uint stride = (uint)sizeof(TerrainVertex); + + // location 0: Position (12 bytes) _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + // location 1: Normal (12 bytes, offset 12) _gl.EnableVertexAttribArray(1); _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + + // location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each, + // offsets 24, 28, 32, 36). The shader reads .x/.y/.z/.w as 8-bit fields. + nint dataOffset = 6 * sizeof(float); // 24 bytes _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + _gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset); _gl.EnableVertexAttribArray(3); - _gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float))); + _gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4)); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8)); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12)); _gl.BindVertexArray(0); _landblocks.Add(gpu); @@ -61,8 +85,16 @@ public sealed unsafe class TerrainRenderer : IDisposable _shader.SetMatrix4("uView", camera.View); _shader.SetMatrix4("uProjection", camera.Projection); + // Bind terrain atlas on unit 0 and alpha atlas on unit 1. _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture); + _gl.ActiveTexture(TextureUnit.Texture1); + _gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture); + + int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain"); + if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0); + int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha"); + if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1); foreach (var lb in _landblocks) { diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs index 13e51d5..ab79fff 100644 --- a/src/AcDream.Core/Terrain/LandblockMesh.cs +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -3,87 +3,179 @@ using DatReaderWriter.DBObjs; namespace AcDream.Core.Terrain; -public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices); +/// +/// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell = +/// 384 vertices. All 6 vertices of a cell share the same Data0..Data3 +/// blend recipe; the vertex shader derives UVs and corner index from +/// gl_VertexID % 6 plus the split-direction bit. +/// +public sealed record LandblockMeshData(TerrainVertex[] Vertices, uint[] Indices); public static class LandblockMesh { - private const int VerticesPerSide = 9; - private const int CellsPerSide = VerticesPerSide - 1; - private const float CellSize = 24.0f; - // Phase 2b: tile terrain textures ~4x per landblock instead of stretching - // a single texture across the whole 192-unit patch. - private const float TexCoordDivisor = CellsPerSide / 4.0f; + public const int HeightmapSide = 9; // 9×9 heightmap samples + public const int CellsPerSide = 8; // 8×8 cells per landblock + public const int VerticesPerCell = 6; // two triangles + public const int VerticesPerLandblock = CellsPerSide * CellsPerSide * VerticesPerCell; // 384 + public const float CellSize = 24.0f; + public const float LandblockSize = CellsPerSide * CellSize; // 192 + // TerrainInfo bit layout: bits 0-1 Road, bits 2-6 Type (5-bit + // TerrainTextureType), bits 11-15 Scenery. Road flag is the 2-bit field + // at the LSB; AC's per-corner road value for GetPalCode takes the mask + // as an int 0..3. + private const int RoadMask = 0x3; + private const int TypeShift = 2; + private const int TypeMask = 0x1F; + + /// + /// Build a per-cell terrain mesh for one landblock. Each cell is looked + /// up in the shared by palette code; only + /// palette codes not yet seen in this scene call + /// . + /// + /// Landblock dat record (heightmap + terrain info). + /// Landblock X coord (high byte of landblock id) for split-direction hashing. + /// Landblock Y coord (second byte of landblock id). + /// Region.LandDefs.LandHeightTable — 256 float heights. + /// TerrainAtlas-derived blending inputs. + /// Shared SurfaceInfo cache keyed by palette code. public static LandblockMeshData Build( LandBlock block, + uint landblockX, + uint landblockY, float[] heightTable, - IReadOnlyDictionary terrainTypeToLayer) + TerrainBlendingContext ctx, + Dictionary surfaceCache) { + ArgumentNullException.ThrowIfNull(block); ArgumentNullException.ThrowIfNull(heightTable); - ArgumentNullException.ThrowIfNull(terrainTypeToLayer); + ArgumentNullException.ThrowIfNull(ctx); + ArgumentNullException.ThrowIfNull(surfaceCache); if (heightTable.Length < 256) throw new ArgumentException("heightTable must have 256 entries", nameof(heightTable)); - // Precompute all 81 heights in (x,y) grid order so we can do cheap - // neighbor lookups when computing per-vertex normals by central differences. - // Heights are packed x-major (Height[x*9+y]) matching the fix in cc55c3f. - var heights = new float[VerticesPerSide, VerticesPerSide]; - for (int x = 0; x < VerticesPerSide; x++) - for (int y = 0; y < VerticesPerSide; y++) - heights[x, y] = heightTable[block.Height[x * VerticesPerSide + y]]; + // Pre-sample all 81 heights into a 2D array (x-major indexing). This + // doubles as the source for per-vertex normals via central differences + // (Phase 3b lighting, preserved through the per-cell refactor). + var heights = new float[HeightmapSide, HeightmapSide]; + for (int x = 0; x < HeightmapSide; x++) + for (int y = 0; y < HeightmapSide; y++) + heights[x, y] = heightTable[block.Height[x * HeightmapSide + y]]; - var vertices = new Vertex[VerticesPerSide * VerticesPerSide]; - for (int y = 0; y < VerticesPerSide; y++) + // Pre-compute all 81 vertex normals so the inner cell loop is a pure + // lookup. Central differences on the heightmap → smooth normal field. + var normals = new Vector3[HeightmapSide, HeightmapSide]; + for (int x = 0; x < HeightmapSide; x++) + for (int y = 0; y < HeightmapSide; y++) { - for (int x = 0; x < VerticesPerSide; x++) + int xL = Math.Max(x - 1, 0); + int xR = Math.Min(x + 1, HeightmapSide - 1); + int yD = Math.Max(y - 1, 0); + int yU = Math.Min(y + 1, HeightmapSide - 1); + float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize); + float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize); + normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f)); + } + + var vertices = new TerrainVertex[VerticesPerLandblock]; + var indices = new uint[VerticesPerLandblock]; // 1 index per vertex (no deduplication) + + int vi = 0; + for (int cy = 0; cy < CellsPerSide; cy++) + { + for (int cx = 0; cx < CellsPerSide; cx++) { - int vi = y * VerticesPerSide + x; - int hi = x * VerticesPerSide + y; - float height = heights[x, y]; + // Four corner TerrainInfo samples (x-major block.Terrain[x*9+y]). + var tBL = block.Terrain[cx * HeightmapSide + cy]; + var tBR = block.Terrain[(cx + 1) * HeightmapSide + cy]; + var tTR = block.Terrain[(cx + 1) * HeightmapSide + (cy + 1)]; + var tTL = block.Terrain[cx * HeightmapSide + (cy + 1)]; - // TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type (5-bit - // TerrainTextureType enum), bits 11-15 Scenery. The atlas keys on - // Type only, matching Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc - // which lists SurfaceTexture ids per TerrainTextureType. - uint terrainType = (uint)block.Terrain[hi].Type; - if (!terrainTypeToLayer.TryGetValue(terrainType, out var layer)) - layer = 0; + int rBL = tBL.Road & RoadMask; + int rBR = tBR.Road & RoadMask; + int rTR = tTR.Road & RoadMask; + int rTL = tTL.Road & RoadMask; + int ttBL = (int)tBL.Type & TypeMask; + int ttBR = (int)tBR.Type & TypeMask; + int ttTR = (int)tTR.Type & TypeMask; + int ttTL = (int)tTL.Type & TypeMask; - // Per-vertex normal from central differences on the heightmap. - // Surface is z=h(x,y); tangents Sx=(1,0,dh/dx), Sy=(0,1,dh/dy); - // normal = Sx x Sy = (-dh/dx, -dh/dy, 1), normalized. - // Edge vertices use forward/backward difference instead of central. - int xL = Math.Max(x - 1, 0); - int xR = Math.Min(x + 1, VerticesPerSide - 1); - int yD = Math.Max(y - 1, 0); - int yU = Math.Min(y + 1, VerticesPerSide - 1); - float dx = (heights[xR, y] - heights[xL, y]) / ((xR - xL) * CellSize); - float dy = (heights[x, yU] - heights[x, yD]) / ((yU - yD) * CellSize); - var normal = Vector3.Normalize(new Vector3(-dx, -dy, 1f)); + // WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL. + uint palCode = TerrainBlending.GetPalCode( + rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL); - vertices[vi] = new Vertex( - Position: new Vector3(x * CellSize, y * CellSize, height), - Normal: normal, - TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor), - TerrainLayer: layer); + if (!surfaceCache.TryGetValue(palCode, out var surf)) + { + surf = TerrainBlending.BuildSurface(palCode, ctx); + surfaceCache[palCode] = surf; + } + + var split = TerrainBlending.CalculateSplitDirection( + landblockX, (uint)cx, landblockY, (uint)cy); + + var (d0, d1, d2, d3) = TerrainBlending.FillCellData(surf, split); + + // Corner positions in landblock-local space. + var posBL = new Vector3( cx * CellSize, cy * CellSize, heights[cx, cy ]); + var posBR = new Vector3((cx + 1) * CellSize, cy * CellSize, heights[cx + 1, cy ]); + var posTR = new Vector3((cx + 1) * CellSize, (cy + 1) * CellSize, heights[cx + 1, cy + 1]); + var posTL = new Vector3( cx * CellSize, (cy + 1) * CellSize, heights[cx, cy + 1]); + + var nBL = normals[cx, cy]; + var nBR = normals[cx + 1, cy]; + var nTR = normals[cx + 1, cy + 1]; + var nTL = normals[cx, cy + 1]; + + // Emit 6 vertices in the exact order WorldBuilder's Landscape.vert + // expects. The vertex shader maps gl_VertexID % 6 → corner index + // for UV lookup, so the CPU order must match. + // + // SWtoNE (splitDir=0): + // vIdx: 0 1 2 3 4 5 + // corner: 0 3 1 1 3 2 + // BL TL BR BR TL TR + // SEtoNW (splitDir=1): + // vIdx: 0 1 2 3 4 5 + // corner: 0 2 1 0 3 2 + // BL TR BR BL TL TR + if (split == CellSplitDirection.SWtoNE) + { + WriteCell(vertices, ref vi, d0, d1, d2, d3, + posBL, nBL, posTL, nTL, posBR, nBR, + posBR, nBR, posTL, nTL, posTR, nTR); + } + else + { + WriteCell(vertices, ref vi, d0, d1, d2, d3, + posBL, nBL, posTR, nTR, posBR, nBR, + posBL, nBL, posTL, nTL, posTR, nTR); + } } } - var indices = new uint[CellsPerSide * CellsPerSide * 6]; - int idx = 0; - for (int y = 0; y < CellsPerSide; y++) - { - for (int x = 0; x < CellsPerSide; x++) - { - uint a = (uint)(y * VerticesPerSide + x); - uint b = (uint)(y * VerticesPerSide + x + 1); - uint c = (uint)((y + 1) * VerticesPerSide + x); - uint d = (uint)((y + 1) * VerticesPerSide + x + 1); - indices[idx++] = a; indices[idx++] = b; indices[idx++] = d; - indices[idx++] = a; indices[idx++] = d; indices[idx++] = c; - } - } + // Indices are trivial 0..383 since we don't deduplicate verts. + for (uint i = 0; i < VerticesPerLandblock; i++) + indices[i] = i; return new LandblockMeshData(vertices, indices); } + + private static void WriteCell( + TerrainVertex[] verts, ref int vi, + uint d0, uint d1, uint d2, uint d3, + Vector3 p0, Vector3 n0, + Vector3 p1, Vector3 n1, + Vector3 p2, Vector3 n2, + Vector3 p3, Vector3 n3, + Vector3 p4, Vector3 n4, + Vector3 p5, Vector3 n5) + { + verts[vi++] = new TerrainVertex(p0, n0, d0, d1, d2, d3); + verts[vi++] = new TerrainVertex(p1, n1, d0, d1, d2, d3); + verts[vi++] = new TerrainVertex(p2, n2, d0, d1, d2, d3); + verts[vi++] = new TerrainVertex(p3, n3, d0, d1, d2, d3); + verts[vi++] = new TerrainVertex(p4, n4, d0, d1, d2, d3); + verts[vi++] = new TerrainVertex(p5, n5, d0, d1, d2, d3); + } } diff --git a/src/AcDream.Core/Terrain/TerrainVertex.cs b/src/AcDream.Core/Terrain/TerrainVertex.cs new file mode 100644 index 0000000..a031e83 --- /dev/null +++ b/src/AcDream.Core/Terrain/TerrainVertex.cs @@ -0,0 +1,28 @@ +using System.Numerics; + +namespace AcDream.Core.Terrain; + +/// +/// Per-vertex terrain geometry + blend data. Terrain uses a cell-based +/// layout: 64 cells per landblock, 6 vertices per cell (two triangles), +/// 384 vertices per landblock total. All 6 vertices in a cell carry +/// identical Data0..3 (the cell's blend recipe from +/// ); the vertex shader derives +/// which of the 4 cell corners a given vertex represents from +/// gl_VertexID % 6 plus the split direction bit. +/// +/// Normal is stored per vertex via Phase 3b's central-difference scheme on +/// the 9×9 heightmap — this lets the fragment shader interpolate a smooth +/// normal across triangles (softer than WorldBuilder's dFdx/dFdy +/// flat-shaded approach). UVs are derived from the corner index in the +/// vertex shader — not stored here. +/// +/// Size: 12 (position) + 12 (normal) + 4*4 (Data0..3) = 40 bytes. +/// +public readonly record struct TerrainVertex( + Vector3 Position, + Vector3 Normal, + uint Data0, + uint Data1, + uint Data2, + uint Data3); diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs index 0d91e6e..ee123ae 100644 --- a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -8,15 +8,21 @@ namespace AcDream.Core.Tests.Terrain; public class LandblockMeshTests { /// - /// Synthetic height table that mirrors Phase 1's simplified "* 2.0f" scale so - /// the existing tests continue to describe the same behavior. Real AC uses a - /// non-linear table from Region.LandDefs.LandHeightTable loaded at runtime. + /// Synthetic height table with a * 2.0f scale (mirrors Phase 1's ramp so + /// existing test intuition carries through the Phase 3c rewrite). /// private static readonly float[] IdentityHeightTable = Enumerable.Range(0, 256).Select(i => i * 2f).ToArray(); - private static readonly IReadOnlyDictionary EmptyTerrainMap = - new Dictionary(); + private static TerrainBlendingContext MakeContext() => new( + TerrainTypeToLayer: new Dictionary { [0u] = 0 }, + RoadLayer: SurfaceInfo.None, + CornerAlphaLayers: Array.Empty(), + SideAlphaLayers: Array.Empty(), + RoadAlphaLayers: Array.Empty(), + CornerAlphaTCodes: Array.Empty(), + SideAlphaTCodes: Array.Empty(), + RoadAlphaRCodes: Array.Empty()); private static LandBlock BuildFlatLandBlock(byte heightIndex = 0) { @@ -35,28 +41,31 @@ public class LandblockMeshTests } [Fact] - public void Build_FlatBlock_Produces81VerticesAnd128Triangles() + public void Build_FlatBlock_Produces384VerticesAnd128Triangles() { var block = BuildFlatLandBlock(); + var cache = new Dictionary(); - var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); - Assert.Equal(81, mesh.Vertices.Length); + // 64 cells × 6 vertices per cell = 384 + Assert.Equal(384, mesh.Vertices.Length); + // Each cell emits 2 triangles = 6 indices, 64 cells → 384 indices (= 128 triangles) Assert.Equal(128 * 3, mesh.Indices.Length); } [Fact] - public void Build_Vertices_Cover192x192WorldUnits() + public void Build_Vertices_CoverExactly192x192WorldUnits() { var block = BuildFlatLandBlock(); + var cache = new Dictionary(); - var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); var minX = mesh.Vertices.Min(v => v.Position.X); var maxX = mesh.Vertices.Max(v => v.Position.X); var minY = mesh.Vertices.Min(v => v.Position.Y); var maxY = mesh.Vertices.Max(v => v.Position.Y); - Assert.Equal(0.0f, minX); Assert.Equal(192.0f, maxX); Assert.Equal(0.0f, minY); @@ -67,73 +76,119 @@ public class LandblockMeshTests public void Build_FlatBlock_AllVerticesSameZ() { var block = BuildFlatLandBlock(heightIndex: 10); + var cache = new Dictionary(); - var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray(); Assert.Single(zs); + Assert.Equal(20.0f, zs[0]); // heightIndex 10 × IdentityHeightTable[10] = 20 } [Fact] - public void Build_HeightValues_ScaleByTwo() - { - var block = BuildFlatLandBlock(heightIndex: 5); - - var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); - - // AC's Land::LandHeightTable scales height byte index by 2.0f for the simple ramp case. - Assert.Equal(10.0f, mesh.Vertices[0].Position.Z); - } - - [Fact] - public void Build_PerVertexTerrainLayer_UsesMappedLayerIndex() + public void Build_FlatBlock_NormalsPointStraightUp() { var block = BuildFlatLandBlock(); - // TerrainInfo is bit-packed: bits 0-1 Road, bits 2-6 Type, bits 11-15 Scenery. - // Raw ushort 0x001C = binary 0011100 → Type field = 7 (bits 2-6). - // This is what a terrain sample with TerrainTextureType=7 looks like in the - // underlying byte stream. LandblockMesh uses TerrainInfo.Type (not raw) as - // the atlas lookup key. - block.Terrain[2 * 9 + 3] = (ushort)(7 << 2); // Type=7, Road=0, Scenery=0 + var cache = new Dictionary(); - var map = new Dictionary + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); + + foreach (var v in mesh.Vertices) { - [0] = 0u, // default type → atlas layer 0 - [7] = 4u, // TerrainTextureType 7 → atlas layer 4 - }; + Assert.Equal(new Vector3(0, 0, 1), v.Normal); + } + } - var mesh = LandblockMesh.Build(block, IdentityHeightTable, map); + [Fact] + public void Build_AllVerticesOfACellShareIdenticalData() + { + var block = BuildFlatLandBlock(); + var cache = new Dictionary(); - // Vertex buffer internal order is y*9+x, so vertex at world (x=2, y=3) is at - // index 3*9+2 = 29. - Assert.Equal(4u, mesh.Vertices[3 * 9 + 2].TerrainLayer); - // An untouched vertex still has Type 0, maps to layer 0. - Assert.Equal(0u, mesh.Vertices[0].TerrainLayer); + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); + + // Vertices are emitted in strides of 6 per cell. Within each stride, + // Data0..3 must be identical — the vertex shader relies on that when + // it propagates the cell's blend recipe to all 3 fragment-shader outputs. + for (int cellIdx = 0; cellIdx < 64; cellIdx++) + { + int baseIdx = cellIdx * 6; + var d0 = mesh.Vertices[baseIdx].Data0; + var d1 = mesh.Vertices[baseIdx].Data1; + var d2 = mesh.Vertices[baseIdx].Data2; + var d3 = mesh.Vertices[baseIdx].Data3; + for (int i = 1; i < 6; i++) + { + Assert.Equal(d0, mesh.Vertices[baseIdx + i].Data0); + Assert.Equal(d1, mesh.Vertices[baseIdx + i].Data1); + Assert.Equal(d2, mesh.Vertices[baseIdx + i].Data2); + Assert.Equal(d3, mesh.Vertices[baseIdx + i].Data3); + } + } + } + + [Fact] + public void Build_SurfaceCacheIsReusedAcrossIdenticalCells() + { + var block = BuildFlatLandBlock(); // every cell has identical all-zero corners + var cache = new Dictionary(); + + LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); + + // A uniform flat landblock produces exactly ONE palette code (all + // corners are type 0, no roads) → BuildSurface called once, cache + // contains a single entry even though 64 cells were processed. + Assert.Single(cache); + } + + [Fact] + public void Build_CellsWithDistinctTerrainTypes_ProducesDistinctPaletteCodes() + { + // Put a dirt cell (type 4) at the center of an otherwise grass landblock. + // Grass cells all share one palCode; the "dirt + grass border" cells + // around the center introduce additional palette codes. + var block = BuildFlatLandBlock(); + // Type is at bits 2-6, so type=4 → ushort = (4 << 2) = 0x10. + block.Terrain[4 * 9 + 4] = (ushort)(4 << 2); + + var ctx = new TerrainBlendingContext( + TerrainTypeToLayer: new Dictionary { [0u] = 0, [4u] = 1 }, + RoadLayer: SurfaceInfo.None, + CornerAlphaLayers: new byte[] { 0, 1, 2, 3 }, + SideAlphaLayers: Array.Empty(), + RoadAlphaLayers: Array.Empty(), + CornerAlphaTCodes: new uint[] { 1, 2, 4, 8 }, + SideAlphaTCodes: Array.Empty(), + RoadAlphaRCodes: Array.Empty()); + var cache = new Dictionary(); + + LandblockMesh.Build(block, 0, 0, IdentityHeightTable, ctx, cache); + + // Should have more than one palette code now — uniform-grass cells + // plus at least one boundary cell with a non-zero corner type. + Assert.True(cache.Count >= 2, $"Expected mix of palette codes, got {cache.Count}"); } [Fact] public void Build_HeightmapPackedAsXMajor_NotYMajor() { - // Regression: Phase 1 used block.Height[y*9+x] which transposes the terrain - // along its diagonal relative to AC's native x-major packing. Invisible on - // flat landblocks but catastrophically wrong on Holtburg where static-object - // positions reference the un-transposed ground truth, leaving buildings - // buried by ~10 world-Z units. - // - // Set up an asymmetric heightmap: value at x-major index (x=2, y=0) = 5 - // (scaled to Z=10), everything else 0. The vertex at world position - // (x=2*24=48, y=0) should have Z=10. The vertex at (x=0, y=2*24=48) should - // have Z=0. Y-major indexing would swap these. + // Regression from the Phase 1 → 2a transpose bug. The underlying + // heightmap is indexed x*9+y; testing this lives on even after the + // per-cell refactor because the corner lookup in the cell loop still + // reads block.Height[cx*9+cy] for the BL corner. var block = BuildFlatLandBlock(); - block.Height[2 * 9 + 0] = 5; // x=2, y=0 in x-major packing + block.Height[2 * 9 + 0] = 5; // x=2, y=0 → world (48, 0), Z should be 10 - var mesh = LandblockMesh.Build(block, IdentityHeightTable, EmptyTerrainMap); + var cache = new Dictionary(); + var mesh = LandblockMesh.Build(block, 0, 0, IdentityHeightTable, MakeContext(), cache); - // Find vertices by position. Vertex buffer uses y*9+x internally. - var vAt_x2_y0 = mesh.Vertices[0 * 9 + 2]; // world (48, 0) - var vAt_x0_y2 = mesh.Vertices[2 * 9 + 0]; // world (0, 48) + // Search the vertex buffer for a vertex at world position (48, 0). + var atX48Y0 = mesh.Vertices.FirstOrDefault(v => + Math.Abs(v.Position.X - 48f) < 0.01f && Math.Abs(v.Position.Y) < 0.01f); + var atX0Y48 = mesh.Vertices.FirstOrDefault(v => + Math.Abs(v.Position.X) < 0.01f && Math.Abs(v.Position.Y - 48f) < 0.01f); - Assert.Equal(new Vector3(48, 0, 10), vAt_x2_y0.Position); - Assert.Equal(new Vector3(0, 48, 0), vAt_x0_y2.Position); + Assert.Equal(10.0f, atX48Y0.Position.Z); + Assert.Equal(0.0f, atX0Y48.Position.Z); } }