From baf0db303db3d89a5f9b3eab5eb5eb212944c43b Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 16:37:52 +0200 Subject: [PATCH] feat(core): add LandblockMesh flat-terrain generator Pure CPU mesh generator: takes a DatReaderWriter LandBlock DBObj and produces 81 vertices + 128 triangles covering 192x192 world units. Vertices are a readonly record struct (position, normal, texcoord) so the upcoming GPU upload in Task 8 can sizeof() them directly. Height byte -> world z uses a simple 2x scale; the real AC height lookup table is a Phase 2+ concern. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core/Terrain/LandblockMesh.cs | 50 ++++++++++++ src/AcDream.Core/Terrain/Vertex.cs | 5 ++ .../Terrain/LandblockMeshTests.cs | 76 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/AcDream.Core/Terrain/LandblockMesh.cs create mode 100644 src/AcDream.Core/Terrain/Vertex.cs create mode 100644 tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs diff --git a/src/AcDream.Core/Terrain/LandblockMesh.cs b/src/AcDream.Core/Terrain/LandblockMesh.cs new file mode 100644 index 0000000..0cb4f7d --- /dev/null +++ b/src/AcDream.Core/Terrain/LandblockMesh.cs @@ -0,0 +1,50 @@ +using System.Numerics; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Terrain; + +public sealed record LandblockMeshData(Vertex[] Vertices, uint[] Indices); + +public static class LandblockMesh +{ + // AC landblock geometry constants + private const int VerticesPerSide = 9; // 9x9 heightmap grid + private const int CellsPerSide = VerticesPerSide - 1; // 8x8 cells + private const float CellSize = 24.0f; // world units per cell edge + private const float HeightScale = 2.0f; // byte height -> world z + + public static LandblockMeshData Build(LandBlock block) + { + var vertices = new Vertex[VerticesPerSide * VerticesPerSide]; + for (int y = 0; y < VerticesPerSide; y++) + { + for (int x = 0; x < VerticesPerSide; x++) + { + int i = y * VerticesPerSide + x; + float height = block.Height[i] * HeightScale; + vertices[i] = new Vertex( + Position: new Vector3(x * CellSize, y * CellSize, height), + Normal: Vector3.UnitZ, + TexCoord: new Vector2(x / (float)CellsPerSide, y / (float)CellsPerSide)); + } + } + + var indices = new uint[CellsPerSide * CellsPerSide * 6]; + int idx = 0; + for (int y = 0; y < CellsPerSide; y++) + { + for (int x = 0; x < CellsPerSide; x++) + { + uint a = (uint)(y * VerticesPerSide + x); + uint b = (uint)(y * VerticesPerSide + x + 1); + uint c = (uint)((y + 1) * VerticesPerSide + x); + uint d = (uint)((y + 1) * VerticesPerSide + x + 1); + // two triangles per cell, CCW + indices[idx++] = a; indices[idx++] = b; indices[idx++] = d; + indices[idx++] = a; indices[idx++] = d; indices[idx++] = c; + } + } + + return new LandblockMeshData(vertices, indices); + } +} diff --git a/src/AcDream.Core/Terrain/Vertex.cs b/src/AcDream.Core/Terrain/Vertex.cs new file mode 100644 index 0000000..b590ef2 --- /dev/null +++ b/src/AcDream.Core/Terrain/Vertex.cs @@ -0,0 +1,5 @@ +using System.Numerics; + +namespace AcDream.Core.Terrain; + +public readonly record struct Vertex(Vector3 Position, Vector3 Normal, Vector2 TexCoord); diff --git a/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs new file mode 100644 index 0000000..c4e63ad --- /dev/null +++ b/tests/AcDream.Core.Tests/Terrain/LandblockMeshTests.cs @@ -0,0 +1,76 @@ +using System.Numerics; +using AcDream.Core.Terrain; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Terrain; + +public class LandblockMeshTests +{ + private static LandBlock BuildFlatLandBlock(byte heightIndex = 0) + { + var block = new LandBlock + { + HasObjects = false, + Terrain = new TerrainInfo[81], + Height = new byte[81], + }; + for (int i = 0; i < 81; i++) + { + block.Terrain[i] = (ushort)0; + block.Height[i] = heightIndex; + } + return block; + } + + [Fact] + public void Build_FlatBlock_Produces81VerticesAnd128Triangles() + { + var block = BuildFlatLandBlock(); + + var mesh = LandblockMesh.Build(block); + + Assert.Equal(81, mesh.Vertices.Length); + Assert.Equal(128 * 3, mesh.Indices.Length); + } + + [Fact] + public void Build_Vertices_Cover192x192WorldUnits() + { + var block = BuildFlatLandBlock(); + + var mesh = LandblockMesh.Build(block); + + var minX = mesh.Vertices.Min(v => v.Position.X); + var maxX = mesh.Vertices.Max(v => v.Position.X); + var minY = mesh.Vertices.Min(v => v.Position.Y); + var maxY = mesh.Vertices.Max(v => v.Position.Y); + + Assert.Equal(0.0f, minX); + Assert.Equal(192.0f, maxX); + Assert.Equal(0.0f, minY); + Assert.Equal(192.0f, maxY); + } + + [Fact] + public void Build_FlatBlock_AllVerticesSameZ() + { + var block = BuildFlatLandBlock(heightIndex: 10); + + var mesh = LandblockMesh.Build(block); + + var zs = mesh.Vertices.Select(v => v.Position.Z).Distinct().ToArray(); + Assert.Single(zs); + } + + [Fact] + public void Build_HeightValues_ScaleByTwo() + { + var block = BuildFlatLandBlock(heightIndex: 5); + + var mesh = LandblockMesh.Build(block); + + // AC's Land::LandHeightTable scales height byte index by 2.0f for the simple ramp case. + Assert.Equal(10.0f, mesh.Vertices[0].Position.Z); + } +}