feat(core): terrain surface recipe + cell data packing (Phase 3c.3)

Ports WorldBuilder's full BuildTexture / FillCellData pipeline as
pure CPU functions in TerrainBlending.cs, along with the SurfaceInfo
recipe record and a TerrainBlendingContext input struct that carries
the atlas index lists the algorithm needs.

This is still pure algorithm work — no GL, no shaders, no mesh gen
changes. Visual Phase 3c.4 next commit wires it into LandblockMesh
and rewrites the terrain shaders to consume Data0..3.

Added (all ports of WorldBuilder LandSurfaceManager methods):
  - ExtractTerrainCodes: inverse of GetPalCode terrain bits
  - PseudoRandomIndex: deterministic hash over palette code for alpha
    variant selection; overflow-dependent int math matches WorldBuilder
    byte-for-byte
  - RotateTerrainCode: *2 with wrap (1→2→4→8→1, multi-corner patterns
    handled in tests)
  - GetRoadCodes: decodes the 8-bit road mask into up to two canonical
    road patterns + allRoad flag; magic 0xE/0xD/0xB/0x7 switch kept verbatim
  - FindTerrainAlpha: picks corner vs side alpha map, walks the 4
    rotations looking for a TCode match, returns (alphaLayer, rotation)
    or (255, 0) for "not found"
  - FindRoadAlpha: same idea for road maps, iterates all maps from a
    pseudo-random offset
  - BuildSurface: composes the above into a SurfaceInfo, handling the
    all-road, all-duplicate-terrain, and distinct-terrain cases via
    BuildOverlayLayers + BuildWithDuplicates (ports GetTerrainTextures +
    BuildTerrainCodesWithDuplicates)
  - FillCellData: packs a SurfaceInfo + CellSplitDirection into the 4
    uint32 vertex attributes Data0..Data3. Byte layout documented in
    XML comment and matches WorldBuilder's Landscape.vert uvec4 byte
    unpacking exactly.

SurfaceInfo record carries resolved atlas byte layers directly (base +
3 terrain overlays + 2 road overlays, each with optional alpha layer
and 0-3 rotation). Sentinel 255 = "slot unused".

Tests (14 new, 75/75 total):
  - ExtractTerrainCodes round-trip with GetPalCode
  - RotateTerrainCode single-corner cycle + multi-corner patterns
  - GetRoadCodes: no-road, all-road, single-corner road
  - PseudoRandomIndex: range, count=0 guard, determinism
  - BuildSurface: all-grass → base only; all-road → road as base;
    two-grass-two-dirt → base + overlay
  - FillCellData: full round-trip bit layout with recognizable
    byte values in every slot, plus a no-road1 case that verifies
    the texRd1 slot collapses to 255 when road1 alpha is absent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 13:53:32 +02:00
parent a8459eecbb
commit a6cd56663f
3 changed files with 680 additions and 3 deletions

View file

@ -2,13 +2,16 @@ namespace AcDream.Core.Terrain;
/// <summary>
/// Asheron's Call terrain texture-merge math: pure functions that turn raw
/// per-corner terrain/road data into the palette codes and split directions
/// that drive per-cell texture blending. All algorithms are ports of
/// WorldBuilder (Chorizite) — see references/WorldBuilder/...
/// per-corner terrain/road data into the palette codes, surface blend recipes,
/// and per-cell vertex data that drive per-cell texture blending. All
/// algorithms are ports of WorldBuilder (Chorizite) — see
/// references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs.
/// Pure CPU math, no GL, no dats — everything here is unit-testable in isolation.
/// </summary>
public static class TerrainBlending
{
// --- Phase 3c.1 algorithms ---
/// <summary>
/// Packs the 4 corner terrain types and 4 corner road flags of a cell into
/// a single 32-bit palette code. Two cells with identical corner layouts
@ -57,4 +60,397 @@ public static class TerrainBlending
? CellSplitDirection.SEtoNW
: CellSplitDirection.SWtoNE;
}
// --- Phase 3c.3 algorithms (surface recipe + vertex data packing) ---
/// <summary>
/// Extracts the 4 corner terrain types from a palette code, in the order
/// [t1, t2, t3, t4]. Inverse of <see cref="GetPalCode"/>'s terrain packing.
/// </summary>
public static uint[] ExtractTerrainCodes(uint palCode) => new uint[]
{
(palCode >> 15) & 0x1Fu,
(palCode >> 10) & 0x1Fu,
(palCode >> 5) & 0x1Fu,
palCode & 0x1Fu,
};
/// <summary>
/// Pseudo-random index in [0, count) derived from the palette code. Same
/// code always returns the same index, so neighbor cells that share a
/// palette code pick the same alpha variant. Ported from
/// WorldBuilder.GeneratePseudoRandomIndex.
/// </summary>
public static int PseudoRandomIndex(uint palCode, int count)
{
if (count <= 0) return 0;
// The computation uses int overflow and double multiplication
// exactly as in WorldBuilder — do NOT "clean up" the casts.
int pseudoRandom = (int)Math.Floor((1379576222 * palCode - 1372186442) * 2.3283064e-10 * count);
return pseudoRandom >= count ? 0 : pseudoRandom;
}
/// <summary>
/// Rotates a terrain-code bit pattern one step (1→2→4→8→1). Used by
/// <see cref="FindTerrainAlpha"/> and <see cref="FindRoadAlpha"/> to walk
/// the 4 rotations of an alpha map looking for one that matches the
/// target terrain/road code pattern.
/// </summary>
public static uint RotateTerrainCode(uint code)
{
code *= 2;
return code >= 16 ? code - 15 : code;
}
/// <summary>
/// Decodes the road-bits region of a palette code into up to two road
/// "codes" (corner patterns) and an <c>allRoad</c> flag. Ported verbatim
/// from WorldBuilder.GetRoadCodes. The magic constants describe which of
/// the 4 corners have roads (mask bit 0 = corner 1, bit 1 = corner 2,
/// etc.) and which canonical patterns the result maps to.
/// </summary>
public static (uint road0, uint road1, bool allRoad) GetRoadCodes(uint palCode)
{
uint mask = 0;
if ((palCode & 0x0C000000u) != 0) mask |= 1; // r1 in bits 26-27
if ((palCode & 0x03000000u) != 0) mask |= 2; // r2 in bits 24-25
if ((palCode & 0x00C00000u) != 0) mask |= 4; // r3 in bits 22-23
if ((palCode & 0x00300000u) != 0) mask |= 8; // r4 in bits 20-21
if (mask == 0xF)
return (0, 0, allRoad: true);
return mask switch
{
0x0 => (0, 0, false),
0xE => (6, 12, false),
0xD => (9, 12, false),
0xB => (9, 3, false),
0x7 => (3, 6, false),
_ => (mask, 0, false),
};
}
/// <summary>
/// Finds an alpha-map layer and rotation for a single terrain overlay.
/// <paramref name="terrainCode"/> is a 4-bit pattern indicating which
/// corners the overlay applies to (1,2,4,8 = single-corner patterns →
/// use corner maps; other patterns → use side maps). Returns
/// <see cref="SurfaceInfo.None"/> and rotation 0 if no alpha map
/// matches under any rotation, which is a valid "don't draw this
/// overlay" state.
/// Ported from WorldBuilder.FindTerrainAlpha.
/// </summary>
public static (byte alphaLayer, byte rotation) FindTerrainAlpha(
uint palCode, uint terrainCode, TerrainBlendingContext ctx)
{
bool isCornerTerrain = terrainCode is 1 or 2 or 4 or 8;
var maps = isCornerTerrain ? ctx.CornerAlphaLayers : ctx.SideAlphaLayers;
var tCodes = isCornerTerrain ? ctx.CornerAlphaTCodes : ctx.SideAlphaTCodes;
if (maps.Count == 0 || tCodes.Count == 0)
return (SurfaceInfo.None, 0);
int randomIndex = PseudoRandomIndex(palCode, maps.Count);
uint currentCode = tCodes[randomIndex];
int rotationCount = 0;
while (currentCode != terrainCode && rotationCount < 4)
{
currentCode = RotateTerrainCode(currentCode);
rotationCount++;
}
if (rotationCount >= 4)
return (SurfaceInfo.None, 0);
return (maps[randomIndex], (byte)rotationCount);
}
/// <summary>
/// Finds an alpha-map layer and rotation for a road overlay. Unlike
/// terrain alphas, roads iterate through all maps (starting at a random
/// offset) looking for one whose RCode matches the requested pattern
/// under some rotation.
/// Returns <see cref="SurfaceInfo.None"/> if no match exists.
/// Ported from WorldBuilder.FindRoadAlpha.
/// </summary>
public static (byte alphaLayer, byte rotation) FindRoadAlpha(
uint palCode, uint roadCode, TerrainBlendingContext ctx)
{
var maps = ctx.RoadAlphaLayers;
var rCodes = ctx.RoadAlphaRCodes;
if (maps.Count == 0 || rCodes.Count == 0)
return (SurfaceInfo.None, 0);
int randomIndex = PseudoRandomIndex(palCode, maps.Count);
for (int i = 0; i < maps.Count; i++)
{
int index = (i + randomIndex) % maps.Count;
uint currentCode = rCodes[index];
for (int rotationCount = 0; rotationCount < 4; rotationCount++)
{
if (currentCode == roadCode)
return (maps[index], (byte)rotationCount);
currentCode = RotateTerrainCode(currentCode);
}
}
return (SurfaceInfo.None, 0);
}
/// <summary>
/// Turns a palette code into a full <see cref="SurfaceInfo"/> blend
/// recipe. Handles three cases internally:
/// <list type="bullet">
/// <item>All-road cell → base = road texture, no overlays</item>
/// <item>All corners same terrain → base only, no overlays</item>
/// <item>2-4 distinct terrain types → base + up to 3 overlays per
/// WorldBuilder's duplicate-handling logic</item>
/// </list>
/// Road overlays (up to 2) are added on top if any corner has a road bit.
/// Ported from WorldBuilder.BuildTexture + ProcessTerrainOverlays +
/// ProcessRoadOverlays + GetTerrainTextures + BuildTerrainCodesWithDuplicates.
/// </summary>
public static SurfaceInfo BuildSurface(uint palCode, TerrainBlendingContext ctx)
{
var terrainCorners = ExtractTerrainCodes(palCode); // t1..t4
var (road0, road1, allRoad) = GetRoadCodes(palCode);
// All-road cell: base IS the road texture, no overlays at all.
if (allRoad)
{
return SurfaceInfo.BaseOnly(ctx.RoadLayer);
}
// Resolve base + up to 3 overlay terrain layers and the per-overlay
// "which corners" code. For 4 distinct corners this produces layers
// = [t1,t2,t3,t4] and codes = [2,4,8] (so overlays 0,1,2 cover corners
// 1,2,3 respectively while the base covers corner 0). For cells with
// duplicates the layout is rebuilt via BuildTerrainCodesWithDuplicates.
byte[] terrainLayers = new byte[4];
uint[] overlayCodes = new uint[3];
BuildOverlayLayers(terrainCorners, ctx, terrainLayers, overlayCodes);
// Base is always terrainLayers[0]. Overlays 0..2 correspond to
// terrainLayers[1..3] when present.
byte baseLayer = terrainLayers[0];
// Find alpha layer + rotation for each of up to 3 overlays.
// A zero terrain code means "no more overlays" (short-circuit loop).
byte ovl0Layer = SurfaceInfo.None, ovl0Alpha = SurfaceInfo.None, ovl0Rot = 0;
byte ovl1Layer = SurfaceInfo.None, ovl1Alpha = SurfaceInfo.None, ovl1Rot = 0;
byte ovl2Layer = SurfaceInfo.None, ovl2Alpha = SurfaceInfo.None, ovl2Rot = 0;
if (overlayCodes[0] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[0], ctx);
if (a != SurfaceInfo.None)
{
ovl0Layer = terrainLayers[1]; ovl0Alpha = a; ovl0Rot = r;
}
}
if (overlayCodes[1] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[1], ctx);
if (a != SurfaceInfo.None)
{
ovl1Layer = terrainLayers[2]; ovl1Alpha = a; ovl1Rot = r;
}
}
if (overlayCodes[2] != 0)
{
var (a, r) = FindTerrainAlpha(palCode, overlayCodes[2], ctx);
if (a != SurfaceInfo.None)
{
ovl2Layer = terrainLayers[3]; ovl2Alpha = a; ovl2Rot = r;
}
}
// Road overlays: share the road terrain layer, differ in alpha and rotation.
byte roadLayer = SurfaceInfo.None;
byte road0Alpha = SurfaceInfo.None, road0Rot = 0;
byte road1Alpha = SurfaceInfo.None, road1Rot = 0;
if (ctx.RoadLayer != SurfaceInfo.None && road0 != 0)
{
var (a0, r0) = FindRoadAlpha(palCode, road0, ctx);
if (a0 != SurfaceInfo.None)
{
roadLayer = ctx.RoadLayer;
road0Alpha = a0;
road0Rot = r0;
if (road1 != 0)
{
var (a1, r1) = FindRoadAlpha(palCode, road1, ctx);
if (a1 != SurfaceInfo.None)
{
road1Alpha = a1;
road1Rot = r1;
}
}
}
}
return new SurfaceInfo(
baseLayer,
ovl0Layer, ovl0Alpha, ovl0Rot,
ovl1Layer, ovl1Alpha, ovl1Rot,
ovl2Layer, ovl2Alpha, ovl2Rot,
roadLayer, road0Alpha, road0Rot,
road1Alpha, road1Rot);
}
/// <summary>
/// Fills terrainLayers[0..3] and overlayCodes[0..2] based on the 4 corner
/// terrain codes. If all 4 corners are distinct we use the straightforward
/// mapping; otherwise we delegate to the duplicate-handling path. Combined
/// port of WorldBuilder.GetTerrainTextures +
/// BuildTerrainCodesWithDuplicates.
/// </summary>
private static void BuildOverlayLayers(
uint[] terrainCorners,
TerrainBlendingContext ctx,
byte[] terrainLayers,
uint[] overlayCodes)
{
// Find first duplicate pair — if any exists, use the duplicate path.
for (int i = 0; i < 4; i++)
{
for (int j = i + 1; j < 4; j++)
{
if (terrainCorners[i] == terrainCorners[j])
{
BuildWithDuplicates(terrainCorners, ctx, terrainLayers, overlayCodes, i);
return;
}
}
}
// All 4 distinct: layers = [t1,t2,t3,t4], overlay codes = [2, 4, 8]
for (int i = 0; i < 4; i++)
terrainLayers[i] = LookupTerrainLayer(ctx, terrainCorners[i]);
overlayCodes[0] = 2;
overlayCodes[1] = 4;
overlayCodes[2] = 8;
}
private static void BuildWithDuplicates(
uint[] terrainCorners,
TerrainBlendingContext ctx,
byte[] terrainLayers,
uint[] overlayCodes,
int duplicateIndex)
{
uint primaryTerrain = terrainCorners[duplicateIndex];
uint secondaryTerrain = 0;
terrainLayers[0] = LookupTerrainLayer(ctx, primaryTerrain);
terrainLayers[1] = SurfaceInfo.None;
terrainLayers[2] = SurfaceInfo.None;
terrainLayers[3] = SurfaceInfo.None;
overlayCodes[0] = 0;
overlayCodes[1] = 0;
overlayCodes[2] = 0;
bool foundSecondary = false;
for (int k = 0; k < 4; k++)
{
if (primaryTerrain == terrainCorners[k]) continue;
if (!foundSecondary)
{
overlayCodes[0] = (uint)(1 << k);
secondaryTerrain = terrainCorners[k];
terrainLayers[1] = LookupTerrainLayer(ctx, secondaryTerrain);
foundSecondary = true;
}
else
{
// Another corner differs from primary.
if (secondaryTerrain == terrainCorners[k]
&& overlayCodes[0] == (1u << (k - 1)))
{
// Adjacent same-secondary corners merge into a 2-bit pattern.
overlayCodes[0] += (uint)(1 << k);
}
else
{
terrainLayers[2] = LookupTerrainLayer(ctx, terrainCorners[k]);
overlayCodes[1] = (uint)(1 << k);
}
break;
}
}
}
private static byte LookupTerrainLayer(TerrainBlendingContext ctx, uint terrainCode)
{
return ctx.TerrainTypeToLayer.TryGetValue(terrainCode, out var layer)
? layer
: SurfaceInfo.None;
}
/// <summary>
/// Packs a <see cref="SurfaceInfo"/> and split direction into the 4 uint32s
/// that appear as vertex attributes (<c>Data0..Data3</c>) on every vertex
/// in a cell. Bit layout matches WorldBuilder's Landscape.vert so the
/// shader port can be verbatim.
///
/// <para>
/// Byte layout (little-endian, consumed by the shader as <c>uvec4</c>
/// byte-attribute reads):
/// </para>
/// <code>
/// data0: [ baseTex, baseAlpha=255, ovl0Tex, ovl0Alpha ]
/// data1: [ ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha ]
/// data2: [ road0Tex, road0Alpha, road1Tex, road1Alpha ]
/// data3: bit 0-1 rotBase (unused, 0)
/// bit 2-3 rotOvl0
/// bit 4-5 rotOvl1
/// bit 6-7 rotOvl2
/// bit 8-9 rotRd0
/// bit 10-11 rotRd1
/// bit 12 splitDirection (0=SWtoNE, 1=SEtoNW)
/// </code>
/// </summary>
public static (uint d0, uint d1, uint d2, uint d3) FillCellData(
SurfaceInfo s, CellSplitDirection split)
{
uint texBase = s.BaseLayer;
uint alphaBase = SurfaceInfo.None; // unused, always 255
uint texOvl0 = s.Ovl0Layer;
uint alphaOvl0 = s.Ovl0AlphaLayer;
uint rotOvl0 = s.Ovl0Rotation;
uint texOvl1 = s.Ovl1Layer;
uint alphaOvl1 = s.Ovl1AlphaLayer;
uint rotOvl1 = s.Ovl1Rotation;
uint texOvl2 = s.Ovl2Layer;
uint alphaOvl2 = s.Ovl2AlphaLayer;
uint rotOvl2 = s.Ovl2Rotation;
// Road0 and Road1 share s.RoadLayer (same road texture, different alpha masks).
uint texRd0 = s.RoadLayer;
uint alphaRd0 = s.Road0AlphaLayer;
uint rotRd0 = s.Road0Rotation;
uint texRd1 = s.Road1AlphaLayer == SurfaceInfo.None ? SurfaceInfo.None : s.RoadLayer;
uint alphaRd1 = s.Road1AlphaLayer;
uint rotRd1 = s.Road1Rotation;
uint d0 = (texBase | (alphaBase << 8)) | ((texOvl0 | (alphaOvl0 << 8)) << 16);
uint d1 = (texOvl1 | (alphaOvl1 << 8)) | ((texOvl2 | (alphaOvl2 << 8)) << 16);
uint d2 = (texRd0 | (alphaRd0 << 8)) | ((texRd1 | (alphaRd1 << 8)) << 16);
uint d3 = 0u // rotBase in bits 0-1 (unused)
| (rotOvl0 << 2)
| (rotOvl1 << 4)
| (rotOvl2 << 6)
| (rotRd0 << 8)
| (rotRd1 << 10)
| ((((uint)split) & 1u) << 12);
return (d0, d1, d2, d3);
}
}