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:
parent
a8459eecbb
commit
a6cd56663f
3 changed files with 680 additions and 3 deletions
|
|
@ -110,4 +110,223 @@ public class TerrainBlendingTests
|
|||
Assert.True(swCount > 0, "Expected at least one SWtoNE cell in a landblock");
|
||||
Assert.True(seCount > 0, "Expected at least one SEtoNW cell in a landblock");
|
||||
}
|
||||
|
||||
// ---- Phase 3c.3 tests ----
|
||||
|
||||
[Fact]
|
||||
public void ExtractTerrainCodes_InverseOfGetPalCode()
|
||||
{
|
||||
uint palCode = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Dirt, Grass, Dirt);
|
||||
uint[] extracted = TerrainBlending.ExtractTerrainCodes(palCode);
|
||||
Assert.Equal(new uint[] { Grass, Dirt, Grass, Dirt }, extracted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotateTerrainCode_CyclesThroughSingleCornerPatterns()
|
||||
{
|
||||
Assert.Equal(2u, TerrainBlending.RotateTerrainCode(1));
|
||||
Assert.Equal(4u, TerrainBlending.RotateTerrainCode(2));
|
||||
Assert.Equal(8u, TerrainBlending.RotateTerrainCode(4));
|
||||
Assert.Equal(1u, TerrainBlending.RotateTerrainCode(8)); // 16 → -15 = 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RotateTerrainCode_HandlesMultiCornerPatterns()
|
||||
{
|
||||
// Pattern 3 (corners 0+1) * 2 = 6, still < 16 → 6
|
||||
Assert.Equal(6u, TerrainBlending.RotateTerrainCode(3));
|
||||
// Pattern 6 (corners 1+2) * 2 = 12, still < 16 → 12
|
||||
Assert.Equal(12u, TerrainBlending.RotateTerrainCode(6));
|
||||
// Pattern 12 (corners 2+3) * 2 = 24, >= 16 → 24 - 15 = 9
|
||||
Assert.Equal(9u, TerrainBlending.RotateTerrainCode(12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRoadCodes_NoRoad_IsAllZero()
|
||||
{
|
||||
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
|
||||
var (r0, r1, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
||||
Assert.Equal(0u, r0);
|
||||
Assert.Equal(0u, r1);
|
||||
Assert.False(allRoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRoadCodes_AllFourRoads_IsAllRoad()
|
||||
{
|
||||
uint pal = TerrainBlending.GetPalCode(1, 1, 1, 1, Grass, Grass, Grass, Grass);
|
||||
var (_, _, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
||||
Assert.True(allRoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetRoadCodes_SingleCornerRoad_MapsToMaskBit()
|
||||
{
|
||||
// r1 set → mask bit 1 → roadCodes[0] = 1 (single corner 0), roadCodes[1] = 0
|
||||
uint pal = TerrainBlending.GetPalCode(1, 0, 0, 0, Grass, Grass, Grass, Grass);
|
||||
var (r0, r1, allRoad) = TerrainBlending.GetRoadCodes(pal);
|
||||
Assert.Equal(1u, r0);
|
||||
Assert.Equal(0u, r1);
|
||||
Assert.False(allRoad);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PseudoRandomIndex_ReturnsValueInRange()
|
||||
{
|
||||
for (int seed = 1; seed < 20; seed++)
|
||||
{
|
||||
int v = TerrainBlending.PseudoRandomIndex((uint)seed * 1000u, 5);
|
||||
Assert.InRange(v, 0, 4);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PseudoRandomIndex_CountZero_ReturnsZero()
|
||||
{
|
||||
Assert.Equal(0, TerrainBlending.PseudoRandomIndex(0x12345678u, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PseudoRandomIndex_IsDeterministic()
|
||||
{
|
||||
uint pal = 0xDEADBEEFu;
|
||||
int a = TerrainBlending.PseudoRandomIndex(pal, 4);
|
||||
int b = TerrainBlending.PseudoRandomIndex(pal, 4);
|
||||
Assert.Equal(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSurface_AllGrassNoRoads_ReturnsBaseOnly()
|
||||
{
|
||||
var ctx = MakeContext(terrainLayers: new Dictionary<uint, byte>
|
||||
{
|
||||
[Grass] = 7,
|
||||
[Dirt] = 11,
|
||||
});
|
||||
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Grass, Grass);
|
||||
|
||||
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
||||
|
||||
Assert.Equal(7, surf.BaseLayer);
|
||||
Assert.Equal(SurfaceInfo.None, surf.Ovl0Layer);
|
||||
Assert.Equal(SurfaceInfo.None, surf.Ovl1Layer);
|
||||
Assert.Equal(SurfaceInfo.None, surf.Ovl2Layer);
|
||||
Assert.Equal(SurfaceInfo.None, surf.RoadLayer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSurface_AllRoadsCell_UsesRoadTextureAsBase()
|
||||
{
|
||||
var ctx = MakeContext(
|
||||
terrainLayers: new Dictionary<uint, byte> { [Grass] = 7 },
|
||||
roadLayer: 24);
|
||||
uint pal = TerrainBlending.GetPalCode(1, 1, 1, 1, Grass, Grass, Grass, Grass);
|
||||
|
||||
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
||||
|
||||
Assert.Equal(24, surf.BaseLayer);
|
||||
Assert.Equal(SurfaceInfo.None, surf.Ovl0Layer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildSurface_TwoGrassTwoDirt_EmitsBaseAndAtLeastOneOverlay()
|
||||
{
|
||||
var ctx = MakeContext(terrainLayers: new Dictionary<uint, byte>
|
||||
{
|
||||
[Grass] = 7,
|
||||
[Dirt] = 11,
|
||||
});
|
||||
uint pal = TerrainBlending.GetPalCode(0, 0, 0, 0, Grass, Grass, Dirt, Dirt);
|
||||
|
||||
var surf = TerrainBlending.BuildSurface(pal, ctx);
|
||||
|
||||
// Base must be the duplicate (grass, layer 7).
|
||||
Assert.Equal(7, surf.BaseLayer);
|
||||
// There must be at least one overlay with the other terrain (dirt, layer 11).
|
||||
Assert.Equal(11, surf.Ovl0Layer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillCellData_RoundTripBitLayout()
|
||||
{
|
||||
// Craft a SurfaceInfo with distinct, recognizable byte values in each slot.
|
||||
var s = new SurfaceInfo(
|
||||
BaseLayer: 0x01,
|
||||
Ovl0Layer: 0x02, Ovl0AlphaLayer: 0x03, Ovl0Rotation: 1,
|
||||
Ovl1Layer: 0x04, Ovl1AlphaLayer: 0x05, Ovl1Rotation: 2,
|
||||
Ovl2Layer: 0x06, Ovl2AlphaLayer: 0x07, Ovl2Rotation: 3,
|
||||
RoadLayer: 0x08, Road0AlphaLayer: 0x09, Road0Rotation: 1,
|
||||
Road1AlphaLayer: 0x0A, Road1Rotation: 2);
|
||||
|
||||
var (d0, d1, d2, d3) = TerrainBlending.FillCellData(s, CellSplitDirection.SEtoNW);
|
||||
|
||||
// data0 low byte = BaseLayer (0x01)
|
||||
Assert.Equal(0x01u, d0 & 0xFFu);
|
||||
// data0 byte 1 = baseAlpha = 255 (unused slot)
|
||||
Assert.Equal(0xFFu, (d0 >> 8) & 0xFFu);
|
||||
// data0 byte 2 = Ovl0Layer (0x02)
|
||||
Assert.Equal(0x02u, (d0 >> 16) & 0xFFu);
|
||||
// data0 byte 3 = Ovl0AlphaLayer (0x03)
|
||||
Assert.Equal(0x03u, (d0 >> 24) & 0xFFu);
|
||||
// data1: Ovl1Layer, Ovl1AlphaLayer, Ovl2Layer, Ovl2AlphaLayer
|
||||
Assert.Equal(0x04u, d1 & 0xFFu);
|
||||
Assert.Equal(0x05u, (d1 >> 8) & 0xFFu);
|
||||
Assert.Equal(0x06u, (d1 >> 16) & 0xFFu);
|
||||
Assert.Equal(0x07u, (d1 >> 24) & 0xFFu);
|
||||
// data2: Road0Layer, Road0AlphaLayer, Road1Layer (= RoadLayer because Road1AlphaLayer != None), Road1AlphaLayer
|
||||
Assert.Equal(0x08u, d2 & 0xFFu);
|
||||
Assert.Equal(0x09u, (d2 >> 8) & 0xFFu);
|
||||
Assert.Equal(0x08u, (d2 >> 16) & 0xFFu); // texRd1 = same road layer
|
||||
Assert.Equal(0x0Au, (d2 >> 24) & 0xFFu);
|
||||
// data3: rotBase=0 bits 0-1, rotOvl0=1 bits 2-3, rotOvl1=2 bits 4-5,
|
||||
// rotOvl2=3 bits 6-7, rotRd0=1 bits 8-9, rotRd1=2 bits 10-11, split=1 bit 12
|
||||
uint expectedD3 = (1u << 2) | (2u << 4) | (3u << 6) | (1u << 8) | (2u << 10) | (1u << 12);
|
||||
Assert.Equal(expectedD3, d3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillCellData_NoRoad1_LeavesRoad1SlotAs255()
|
||||
{
|
||||
var s = new SurfaceInfo(
|
||||
BaseLayer: 0x01,
|
||||
Ovl0Layer: SurfaceInfo.None, Ovl0AlphaLayer: SurfaceInfo.None, Ovl0Rotation: 0,
|
||||
Ovl1Layer: SurfaceInfo.None, Ovl1AlphaLayer: SurfaceInfo.None, Ovl1Rotation: 0,
|
||||
Ovl2Layer: SurfaceInfo.None, Ovl2AlphaLayer: SurfaceInfo.None, Ovl2Rotation: 0,
|
||||
RoadLayer: 0x08, Road0AlphaLayer: 0x09, Road0Rotation: 0,
|
||||
Road1AlphaLayer: SurfaceInfo.None, Road1Rotation: 0);
|
||||
|
||||
var (_, _, d2, _) = TerrainBlending.FillCellData(s, CellSplitDirection.SWtoNE);
|
||||
|
||||
// Road0 present, Road1 absent → texRd1 and alphaRd1 both 255.
|
||||
Assert.Equal(0x08u, d2 & 0xFFu);
|
||||
Assert.Equal(0x09u, (d2 >> 8) & 0xFFu);
|
||||
Assert.Equal(0xFFu, (d2 >> 16) & 0xFFu);
|
||||
Assert.Equal(0xFFu, (d2 >> 24) & 0xFFu);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static TerrainBlendingContext MakeContext(
|
||||
IReadOnlyDictionary<uint, byte>? terrainLayers = null,
|
||||
byte roadLayer = SurfaceInfo.None,
|
||||
IReadOnlyList<byte>? cornerAlphaLayers = null,
|
||||
IReadOnlyList<byte>? sideAlphaLayers = null,
|
||||
IReadOnlyList<byte>? roadAlphaLayers = null,
|
||||
IReadOnlyList<uint>? cornerTCodes = null,
|
||||
IReadOnlyList<uint>? sideTCodes = null,
|
||||
IReadOnlyList<uint>? roadRCodes = null)
|
||||
{
|
||||
// Defaults mirror the actual Holtburg region shape: 4 corners, 1 side,
|
||||
// 3 roads. TCodes are synthetic placeholders for the single-corner
|
||||
// patterns the FindTerrainAlpha rotation loop will match.
|
||||
return new TerrainBlendingContext(
|
||||
TerrainTypeToLayer: terrainLayers ?? new Dictionary<uint, byte>(),
|
||||
RoadLayer: roadLayer,
|
||||
CornerAlphaLayers: cornerAlphaLayers ?? new byte[] { 0, 1, 2, 3 },
|
||||
SideAlphaLayers: sideAlphaLayers ?? new byte[] { 4 },
|
||||
RoadAlphaLayers: roadAlphaLayers ?? new byte[] { 5, 6, 7 },
|
||||
CornerAlphaTCodes: cornerTCodes ?? new uint[] { 1, 2, 4, 8 },
|
||||
SideAlphaTCodes: sideTCodes ?? new uint[] { 3 },
|
||||
RoadAlphaRCodes: roadRCodes ?? new uint[] { 1, 2, 4 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue