feat(core+app): per-cell terrain texture blending (Phase 3c.4)

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
<uint, SurfaceInfo> 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 14:02:15 +02:00
parent a6cd56663f
commit e0dfecdf23
8 changed files with 610 additions and 170 deletions

View file

@ -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<uint, byte>(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<uint, AcDream.Core.Terrain.SurfaceInfo>();
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);

View file

@ -1,23 +1,127 @@
#version 430 core
in vec2 vTex;
in flat uint vLayer;
// 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.
// 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);
}

View file

@ -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 = 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 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);
}

View file

@ -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; }
/// <summary>
/// Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).
/// Matches WorldBuilder's convention that corner alpha indices start at 0.
/// </summary>
/// <summary>Layer indices in the alpha atlas for CornerTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> CornerAlphaLayers { get; }
/// <summary>
/// Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).
/// WorldBuilder convention: side indices start at 4.
/// </summary>
/// <summary>Layer indices in the alpha atlas for SideTerrainMaps (typically 4 entries).</summary>
public IReadOnlyList<byte> SideAlphaLayers { get; }
/// <summary>
/// Layer indices in the alpha atlas for RoadMaps (variable count, typically ~10).
/// </summary>
/// <summary>Layer indices in the alpha atlas for RoadMaps (variable count).</summary>
public IReadOnlyList<byte> RoadAlphaLayers { get; }
// --- Parallel TCode/RCode arrays (added in Phase 3c.4 for BuildSurface) ---
/// <summary>TCode for each CornerTerrainMap, parallel to <see cref="CornerAlphaLayers"/>.</summary>
public IReadOnlyList<uint> CornerAlphaTCodes { get; }
/// <summary>TCode for each SideTerrainMap, parallel to <see cref="SideAlphaLayers"/>.</summary>
public IReadOnlyList<uint> SideAlphaTCodes { get; }
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private TerrainAtlas(
GL gl,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers)
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> 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;
}
/// <summary>
@ -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);
}
/// <summary>
@ -180,20 +184,28 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <c>TerrainBlending.BuildSurface</c> which layer to cite for each
/// corner/side/road alpha source.
/// </summary>
private static (uint gl, int layerCount,
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road)
BuildAlphaAtlas(GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
private readonly record struct AlphaAtlasBuildResult(
uint gl, int layerCount,
IReadOnlyList<byte> corner, IReadOnlyList<byte> side, IReadOnlyList<byte> road,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes);
private static AlphaAtlasBuildResult BuildAlphaAtlas(
GL gl, DatCollection dats, DatReaderWriter.Types.TexMerge texMerge)
{
var decoded = new List<DecodedTexture>();
var cornerLayers = new List<byte>();
var sideLayers = new List<byte>();
var roadLayers = new List<byte>();
var cornerTCodes = new List<uint>();
var sideTCodes = new List<uint>();
var roadRCodes = new List<uint>();
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<uint, uint> { [0] = 0u }, 1,
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>());
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
public void Dispose()

View file

@ -4,6 +4,19 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// 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)
/// </summary>
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)
{

View file

@ -3,87 +3,179 @@ using DatReaderWriter.DBObjs;
namespace AcDream.Core.Terrain;
public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices);
/// <summary>
/// Terrain mesh data for a single landblock: 64 cells × 6 vertices per cell =
/// 384 vertices. All 6 vertices of a cell share the same <c>Data0..Data3</c>
/// blend recipe; the vertex shader derives UVs and corner index from
/// <c>gl_VertexID % 6</c> plus the split-direction bit.
/// </summary>
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;
/// <summary>
/// Build a per-cell terrain mesh for one landblock. Each cell is looked
/// up in the shared <paramref name="surfaceCache"/> by palette code; only
/// palette codes not yet seen in this scene call
/// <see cref="TerrainBlending.BuildSurface"/>.
/// </summary>
/// <param name="block">Landblock dat record (heightmap + terrain info).</param>
/// <param name="landblockX">Landblock X coord (high byte of landblock id) for split-direction hashing.</param>
/// <param name="landblockY">Landblock Y coord (second byte of landblock id).</param>
/// <param name="heightTable">Region.LandDefs.LandHeightTable — 256 float heights.</param>
/// <param name="ctx">TerrainAtlas-derived blending inputs.</param>
/// <param name="surfaceCache">Shared SurfaceInfo cache keyed by palette code.</param>
public static LandblockMeshData Build(
LandBlock block,
uint landblockX,
uint landblockY,
float[] heightTable,
IReadOnlyDictionary<uint, uint> terrainTypeToLayer)
TerrainBlendingContext ctx,
Dictionary<uint, SurfaceInfo> 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 vi = y * VerticesPerSide + x;
int hi = x * VerticesPerSide + y;
float height = heights[x, y];
// 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;
// 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 xR = Math.Min(x + 1, HeightmapSide - 1);
int yD = Math.Max(y - 1, 0);
int yU = Math.Min(y + 1, VerticesPerSide - 1);
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);
var normal = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
normals[x, y] = Vector3.Normalize(new Vector3(-dx, -dy, 1f));
}
vertices[vi] = new Vertex(
Position: new Vector3(x * CellSize, y * CellSize, height),
Normal: normal,
TexCoord: new Vector2(x / TexCoordDivisor, y / TexCoordDivisor),
TerrainLayer: layer);
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++)
{
// 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)];
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;
// WorldBuilder's palCode convention: t1=BL, t2=BR, t3=TR, t4=TL.
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
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);
}
}

View file

@ -0,0 +1,28 @@
using System.Numerics;
namespace AcDream.Core.Terrain;
/// <summary>
/// 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 <c>Data0..3</c> (the cell's blend recipe from
/// <see cref="TerrainBlending.FillCellData"/>); the vertex shader derives
/// which of the 4 cell corners a given vertex represents from
/// <c>gl_VertexID % 6</c> 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 <c>dFdx</c>/<c>dFdy</c>
/// 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.
/// </summary>
public readonly record struct TerrainVertex(
Vector3 Position,
Vector3 Normal,
uint Data0,
uint Data1,
uint Data2,
uint Data3);

View file

@ -8,15 +8,21 @@ namespace AcDream.Core.Tests.Terrain;
public class LandblockMeshTests
{
/// <summary>
/// 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).
/// </summary>
private static readonly float[] IdentityHeightTable =
Enumerable.Range(0, 256).Select(i => i * 2f).ToArray();
private static readonly IReadOnlyDictionary<uint, uint> EmptyTerrainMap =
new Dictionary<uint, uint>();
private static TerrainBlendingContext MakeContext() => new(
TerrainTypeToLayer: new Dictionary<uint, byte> { [0u] = 0 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: Array.Empty<byte>(),
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: Array.Empty<uint>(),
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
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<uint, SurfaceInfo>();
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<uint, SurfaceInfo>();
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<uint, SurfaceInfo>();
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<uint, SurfaceInfo>();
var map = new Dictionary<uint, uint>
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<uint, SurfaceInfo>();
// 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<uint, SurfaceInfo>();
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<uint, byte> { [0u] = 0, [4u] = 1 },
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: new byte[] { 0, 1, 2, 3 },
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: new uint[] { 1, 2, 4, 8 },
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
var cache = new Dictionary<uint, SurfaceInfo>();
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<uint, SurfaceInfo>();
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);
}
}