feat(app): render 3x3 neighbor landblocks with texture atlas

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-10 20:23:21 +02:00
parent 347a7e92ff
commit 560100e5b6
4 changed files with 109 additions and 79 deletions

View file

@ -1,6 +1,4 @@
using AcDream.Core.Terrain;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Silk.NET.Input;
using Silk.NET.Maths;
@ -98,75 +96,67 @@ public sealed class GameWindow : IDisposable
_dats = new DatCollection(_datDir, DatAccessType.Read);
// Find ANY landblock ending in 0xFFFF. Holtburg 0xA9B4FFFF is a
// good default; fall back to the first one we find. Using Get<T>
// (returns null on miss) rather than TryGet to sidestep
// [MaybeNullWhen(false)] nullable-generic analysis under
// TreatWarningsAsErrors.
uint landblockId = 0xA9B4FFFFu;
var block = _dats.Get<LandBlock>(landblockId);
if (block is null)
{
foreach (var file in _dats.Cell.Tree)
{
if ((file.Id & 0xFFFFu) == 0xFFFFu)
{
landblockId = file.Id;
block = _dats.Get<LandBlock>(landblockId);
break;
}
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
if (block is null)
throw new InvalidOperationException("no landblock found in cell dat");
Console.WriteLine($"loaded landblock 0x{landblockId:X8}");
// Load the non-linear LandHeightTable from the Region dat. AC encodes
// per-vertex heights as byte indices into this 256-entry float table,
// not as a simple * 2.0 ramp — building placements depend on the real
// table, so terrain rendered with the simplified scale would leave
// buildings floating or buried.
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
var meshData = LandblockMesh.Build(block, heightTable, new Dictionary<uint, uint>());
_terrain = new TerrainRenderer(_gl, meshData, _shader);
// Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
_terrain = new TerrainRenderer(_gl, _shader, terrainAtlas);
// Load the 3x3 neighbor grid.
var worldView = AcDream.Core.World.WorldView.Load(_dats, centerLandblockId);
Console.WriteLine($"loaded {worldView.Landblocks.Count} landblocks in 3x3 grid");
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
foreach (var lb in worldView.Landblocks)
{
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, heightTable, terrainAtlas.TerrainTypeToLayer);
// Compute world origin for this landblock relative to the center.
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
_terrain.AddLandblock(meshData, origin);
}
_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
// Load LandBlockInfo for Holtburg, hydrate entities.
var info = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is not null
? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info)
: Array.Empty<AcDream.Core.World.WorldEntity>();
// Hydrate entities from ALL loaded landblocks, not just the center.
var allEntities = worldView.AllEntities.ToList();
Console.WriteLine($"hydrating {allEntities.Count} entities across {worldView.Landblocks.Count} landblocks");
// Populate MeshRefs for each entity by resolving its source id to GfxObj or Setup
// and extracting sub-meshes. Store back onto the entity. Since WorldEntity is
// `required init`, we rebuild the entity here.
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(entities.Count);
foreach (var e in entities)
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
foreach (var e in allEntities)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
{
// GfxObj: one mesh ref with identity transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
{
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx);
_staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes);
meshRefs.Add(new AcDream.Core.World.MeshRef(e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
meshRefs.Add(new AcDream.Core.World.MeshRef(
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
}
}
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
// Setup: flatten into parts, upload each part's GfxObj.
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
@ -184,11 +174,21 @@ public sealed class GameWindow : IDisposable
if (meshRefs.Count > 0)
{
// Add the landblock origin to the entity's position so the static
// mesh renderer draws it at the correct world location.
var sourceLandblock = worldView.Landblocks.First(lb => lb.Entities.Contains(e));
int lbX = (int)((sourceLandblock.LandblockId >> 24) & 0xFFu);
int lbY = (int)((sourceLandblock.LandblockId >> 16) & 0xFFu);
var worldOffset = new System.Numerics.Vector3(
(lbX - centerX) * 192f,
(lbY - centerY) * 192f,
0f);
hydratedEntities.Add(new AcDream.Core.World.WorldEntity
{
Id = e.Id,
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
Position = e.Position,
Position = e.Position + worldOffset,
Rotation = e.Rotation,
MeshRefs = meshRefs,
});
@ -196,7 +196,7 @@ public sealed class GameWindow : IDisposable
}
_entities = hydratedEntities;
Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}");
Console.WriteLine($"hydrated {_entities.Count} entities");
}
private void OnRender(double deltaSeconds)

View file

@ -1,14 +1,10 @@
#version 430 core
in float vHeight;
in vec2 vTex;
in flat uint vLayer;
out vec4 fragColor;
uniform sampler2DArray uAtlas;
void main() {
float t = clamp(vHeight / 200.0, 0.0, 1.0);
vec3 low = vec3(0.10, 0.35, 0.15); // green lowland
vec3 mid = vec3(0.55, 0.45, 0.25); // brown mid
vec3 high = vec3(0.90, 0.90, 0.95); // snowy peak
vec3 color = t < 0.5
? mix(low, mid, t * 2.0)
: mix(mid, high, (t - 0.5) * 2.0);
fragColor = vec4(color, 1.0);
fragColor = texture(uAtlas, vec3(vTex, float(vLayer)));
}

View file

@ -2,13 +2,17 @@
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in vec2 aTex;
layout(location = 3) in uint aTerrainLayer;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
out float vHeight;
out vec2 vTex;
out flat uint vLayer;
void main() {
vHeight = aPos.z;
gl_Position = uProjection * uView * vec4(aPos, 1.0);
vTex = aTex;
vLayer = aTerrainLayer;
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
}

View file

@ -1,3 +1,4 @@
using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
@ -7,33 +8,39 @@ public sealed unsafe class TerrainRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly uint _vao;
private readonly uint _vbo;
private readonly uint _ebo;
private readonly int _indexCount;
private readonly TerrainAtlas _atlas;
private readonly List<LandblockGpu> _landblocks = new();
public TerrainRenderer(GL gl, LandblockMeshData meshData, Shader shader)
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
{
_gl = gl;
_shader = shader;
_indexCount = meshData.Indices.Length;
_atlas = atlas;
}
_vao = _gl.GenVertexArray();
_gl.BindVertexArray(_vao);
public void AddLandblock(LandblockMeshData meshData, Vector3 worldOrigin)
{
var gpu = new LandblockGpu
{
Vao = _gl.GenVertexArray(),
WorldOrigin = worldOrigin,
IndexCount = meshData.Indices.Length,
};
_vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
_gl.BindVertexArray(gpu.Vao);
gpu.Vbo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, gpu.Vbo);
fixed (void* p = meshData.Vertices)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(meshData.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw);
_ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
gpu.Ebo = _gl.GenBuffer();
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, gpu.Ebo);
fixed (void* p = meshData.Indices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(meshData.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw);
// vertex layout: position(3f), normal(3f), texcoord(2f) = 8 floats stride
uint stride = (uint)sizeof(Vertex);
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
@ -45,22 +52,45 @@ public sealed unsafe class TerrainRenderer : IDisposable
_gl.VertexAttribIPointer(3, 1, VertexAttribIType.UnsignedInt, stride, (void*)(8 * sizeof(float)));
_gl.BindVertexArray(0);
_landblocks.Add(gpu);
}
public void Draw(OrbitCamera camera)
public void Draw(OrbitCamera camera) // ICamera in Task 5
{
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
_gl.BindVertexArray(_vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)_indexCount, DrawElementsType.UnsignedInt, (void*)0);
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
foreach (var lb in _landblocks)
{
var model = Matrix4x4.CreateTranslation(lb.WorldOrigin);
_shader.SetMatrix4("uModel", model);
_gl.BindVertexArray(lb.Vao);
_gl.DrawElements(PrimitiveType.Triangles, (uint)lb.IndexCount, DrawElementsType.UnsignedInt, (void*)0);
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteBuffer(_vbo);
_gl.DeleteBuffer(_ebo);
_gl.DeleteVertexArray(_vao);
foreach (var lb in _landblocks)
{
_gl.DeleteBuffer(lb.Vbo);
_gl.DeleteBuffer(lb.Ebo);
_gl.DeleteVertexArray(lb.Vao);
}
_landblocks.Clear();
}
private sealed class LandblockGpu
{
public uint Vao;
public uint Vbo;
public uint Ebo;
public int IndexCount;
public Vector3 WorldOrigin;
}
}