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:
parent
a6cd56663f
commit
e0dfecdf23
8 changed files with 610 additions and 170 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue