Two fundamental terrain fixes based on the AC2D + holtburger deep dive: 1. Terrain split formula: replaced WorldBuilder's physics-path formula (214614067/1813693831) with AC2D's render-path formula (0x0CCAC033, 0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits for some cells. Since the render mesh uses this formula, the physics Z sampler must match it to avoid misalignment on slopes. 2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface with per-triangle barycentric interpolation. Each cell is split into two triangles (using the same AC2D formula). SampleZ determines which triangle the query point falls in, then interpolates within that triangle. This produces Z values that exactly match the visual terrain mesh — no more slope clipping. Removes the multi-point Z sampling hack from PlayerMovementController (no longer needed with exact triangle Z). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
461 lines
18 KiB
C#
461 lines
18 KiB
C#
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, 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
|
||
/// always hash to the same code, which keys a cache of precomputed blend
|
||
/// overlays in later phases.
|
||
/// Ported verbatim from WorldBuilder LandSurfaceManager.GetPalCode.
|
||
/// Bit layout (from LSB):
|
||
/// bits 0- 4 t4 (5 bits)
|
||
/// bits 5- 9 t3
|
||
/// bits 10-14 t2
|
||
/// bits 15-19 t1
|
||
/// bits 20-21 r4 (2 bits)
|
||
/// bits 22-23 r3
|
||
/// bits 24-25 r2
|
||
/// bits 26-27 r1
|
||
/// bit 28 size marker (always set)
|
||
/// </summary>
|
||
public static uint GetPalCode(
|
||
int r1, int r2, int r3, int r4,
|
||
int t1, int t2, int t3, int t4)
|
||
{
|
||
uint terrainBits = (uint)((t1 << 15) | (t2 << 10) | (t3 << 5) | t4);
|
||
uint roadBits = (uint)((r1 << 26) | (r2 << 24) | (r3 << 22) | (r4 << 20));
|
||
uint sizeBits = 1u << 28;
|
||
return sizeBits | roadBits | terrainBits;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Picks which way a 24×24 terrain cell is split into triangles. The hash
|
||
/// is designed so that both the visible mesh and the server's collision
|
||
/// triangulation agree on the same split — diverging from it causes
|
||
/// visual/physics disagreement at cell boundaries.
|
||
///
|
||
/// Uses the RENDER-PATH formula from AC2D and ACViewer's
|
||
/// LandblockMesh.cs (constants 0x0CCAC033, 0x421BE3BD, 0x6C1AC587,
|
||
/// 0x519B8F25), NOT the physics-path formula from WorldBuilder
|
||
/// (214614067 / 1813693831). The two produce different splits for
|
||
/// some cells, and the render formula is what the real AC client
|
||
/// uses for the visible terrain mesh. See
|
||
/// docs/research/2026-04-12-movement-deep-dive.md Part 3.
|
||
/// </summary>
|
||
public static CellSplitDirection CalculateSplitDirection(
|
||
uint landblockX, uint cellX, uint landblockY, uint cellY)
|
||
{
|
||
uint x = landblockX * 8 + cellX;
|
||
uint y = landblockY * 8 + cellY;
|
||
uint dw = unchecked(x * y * 0x0CCAC033u - x * 0x421BE3BDu + y * 0x6C1AC587u - 0x519B8F25u);
|
||
// Bit 31 set = NE/SW diagonal (SWtoNE in our enum)
|
||
// Bit 31 clear = NW/SE diagonal (SEtoNW in our enum)
|
||
return (dw & 0x80000000u) != 0
|
||
? CellSplitDirection.SWtoNE
|
||
: CellSplitDirection.SEtoNW;
|
||
}
|
||
|
||
// --- 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);
|
||
}
|
||
}
|