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

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

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