diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 59a4bb2..a6e041c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -21,6 +21,10 @@ public sealed class GameWindow : IDisposable private DatCollection? _dats; private float _lastMouseX; private float _lastMouseY; + private StaticMeshRenderer? _staticMesh; + private Shader? _meshShader; + private TextureCache? _textureCache; + private IReadOnlyList _entities = Array.Empty(); public GameWindow(string datDir) => _datDir = datDir; @@ -83,6 +87,10 @@ public sealed class GameWindow : IDisposable Path.Combine(shadersDir, "terrain.vert"), Path.Combine(shadersDir, "terrain.frag")); + _meshShader = new Shader(_gl, + Path.Combine(shadersDir, "mesh.vert"), + Path.Combine(shadersDir, "mesh.frag")); + _camera = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y, @@ -117,16 +125,82 @@ public sealed class GameWindow : IDisposable var meshData = LandblockMesh.Build(block); _terrain = new TerrainRenderer(_gl, meshData, _shader); + + _textureCache = new TextureCache(_gl, _dats); + _staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache); + + // Load LandBlockInfo for Holtburg, hydrate entities. + var info = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); + var entities = info is not null + ? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info) + : Array.Empty(); + + // 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(entities.Count); + foreach (var e in entities) + { + var meshRefs = new List(); + + if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) + { + // GfxObj: one mesh ref with identity transform. + var gfx = _dats.Get(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)); + } + } + else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + // Setup: flatten into parts, upload each part's GfxObj. + var setup = _dats.Get(e.SourceGfxObjOrSetupId); + if (setup is not null) + { + var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); + foreach (var mr in flat) + { + var gfx = _dats.Get(mr.GfxObjId); + if (gfx is null) continue; + var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx); + _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); + meshRefs.Add(mr); + } + } + } + + if (meshRefs.Count > 0) + { + hydratedEntities.Add(new AcDream.Core.World.WorldEntity + { + Id = e.Id, + SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, + Position = e.Position, + Rotation = e.Rotation, + MeshRefs = meshRefs, + }); + } + } + + _entities = hydratedEntities; + Console.WriteLine($"hydrated {_entities.Count} entities on landblock 0x{landblockId:X8}"); } private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); _terrain?.Draw(_camera!); + _staticMesh?.Draw(_camera!, _entities); } private void OnClosing() { + _staticMesh?.Dispose(); + _textureCache?.Dispose(); + _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); _dats?.Dispose(); diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag new file mode 100644 index 0000000..dcd182c --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -0,0 +1,9 @@ +#version 430 core +in vec2 vTex; +out vec4 fragColor; + +uniform sampler2D uDiffuse; + +void main() { + fragColor = texture(uDiffuse, vTex); +} diff --git a/src/AcDream.App/Rendering/Shaders/mesh.vert b/src/AcDream.App/Rendering/Shaders/mesh.vert new file mode 100644 index 0000000..b288c04 --- /dev/null +++ b/src/AcDream.App/Rendering/Shaders/mesh.vert @@ -0,0 +1,15 @@ +#version 430 core +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aNormal; +layout(location = 2) in vec2 aTex; + +uniform mat4 uModel; +uniform mat4 uView; +uniform mat4 uProjection; + +out vec2 vTex; + +void main() { + vTex = aTex; + gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0); +} diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs new file mode 100644 index 0000000..621cc31 --- /dev/null +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -0,0 +1,134 @@ +// src/AcDream.App/Rendering/StaticMeshRenderer.cs +using System.Numerics; +using AcDream.Core.Meshing; +using AcDream.Core.Terrain; +using AcDream.Core.World; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed unsafe class StaticMeshRenderer : IDisposable +{ + private readonly GL _gl; + private readonly Shader _shader; + private readonly TextureCache _textures; + + // One GPU bundle per unique GfxObj id. Each GfxObj can have multiple sub-meshes. + private readonly Dictionary> _gpuByGfxObj = new(); + + public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures) + { + _gl = gl; + _shader = shader; + _textures = textures; + } + + public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes) + { + if (_gpuByGfxObj.ContainsKey(gfxObjId)) + return; + + var list = new List(subMeshes.Count); + foreach (var sm in subMeshes) + list.Add(UploadSubMesh(sm)); + _gpuByGfxObj[gfxObjId] = list; + } + + private SubMeshGpu UploadSubMesh(GfxObjSubMesh sm) + { + uint vao = _gl.GenVertexArray(); + _gl.BindVertexArray(vao); + + uint vbo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ArrayBuffer, vbo); + fixed (void* p = sm.Vertices) + _gl.BufferData(BufferTargetARB.ArrayBuffer, + (nuint)(sm.Vertices.Length * sizeof(Vertex)), p, BufferUsageARB.StaticDraw); + + uint ebo = _gl.GenBuffer(); + _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, ebo); + fixed (void* p = sm.Indices) + _gl.BufferData(BufferTargetARB.ElementArrayBuffer, + (nuint)(sm.Indices.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + + uint stride = (uint)sizeof(Vertex); + _gl.EnableVertexAttribArray(0); + _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0); + _gl.EnableVertexAttribArray(1); + _gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float))); + _gl.EnableVertexAttribArray(2); + _gl.VertexAttribPointer(2, 2, VertexAttribPointerType.Float, false, stride, (void*)(6 * sizeof(float))); + + _gl.BindVertexArray(0); + + return new SubMeshGpu + { + Vao = vao, + Vbo = vbo, + Ebo = ebo, + IndexCount = sm.Indices.Length, + SurfaceId = sm.SurfaceId, + }; + } + + public void Draw(OrbitCamera camera, IEnumerable entities) + { + _shader.Use(); + _shader.SetMatrix4("uView", camera.View); + _shader.SetMatrix4("uProjection", camera.Projection); + + foreach (var entity in entities) + { + if (entity.MeshRefs.Count == 0) + continue; + + foreach (var meshRef in entity.MeshRefs) + { + if (!_gpuByGfxObj.TryGetValue(meshRef.GfxObjId, out var subMeshes)) + continue; + + // model = entity root transform * per-part transform + var entityRoot = + Matrix4x4.CreateFromQuaternion(entity.Rotation) * + Matrix4x4.CreateTranslation(entity.Position); + var model = meshRef.PartTransform * entityRoot; + + _shader.SetMatrix4("uModel", model); + + foreach (var sub in subMeshes) + { + uint tex = _textures.GetOrUpload(sub.SurfaceId); + _gl.ActiveTexture(TextureUnit.Texture0); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + _gl.BindVertexArray(sub.Vao); + _gl.DrawElements(PrimitiveType.Triangles, (uint)sub.IndexCount, DrawElementsType.UnsignedInt, (void*)0); + } + } + } + _gl.BindVertexArray(0); + } + + public void Dispose() + { + foreach (var subs in _gpuByGfxObj.Values) + { + foreach (var sub in subs) + { + _gl.DeleteBuffer(sub.Vbo); + _gl.DeleteBuffer(sub.Ebo); + _gl.DeleteVertexArray(sub.Vao); + } + } + _gpuByGfxObj.Clear(); + } + + private sealed class SubMeshGpu + { + public uint Vao; + public uint Vbo; + public uint Ebo; + public int IndexCount; + public uint SurfaceId; + } +} diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs new file mode 100644 index 0000000..2794038 --- /dev/null +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -0,0 +1,92 @@ +// src/AcDream.App/Rendering/TextureCache.cs +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using Silk.NET.OpenGL; + +namespace AcDream.App.Rendering; + +public sealed unsafe class TextureCache : IDisposable +{ + private readonly GL _gl; + private readonly DatCollection _dats; + private readonly Dictionary _handlesBySurfaceId = new(); + private uint _magentaHandle; + + public TextureCache(GL gl, DatCollection dats) + { + _gl = gl; + _dats = dats; + } + + /// + /// Get or upload the GL texture handle for a Surface id. Returns a + /// 1x1 magenta fallback if the Surface or its RenderSurface chain is + /// missing or uses an unsupported format. + /// + public uint GetOrUpload(uint surfaceId) + { + if (_handlesBySurfaceId.TryGetValue(surfaceId, out var h)) + return h; + + var decoded = DecodeFromDats(surfaceId); + h = UploadRgba8(decoded); + _handlesBySurfaceId[surfaceId] = h; + return h; + } + + private DecodedTexture DecodeFromDats(uint surfaceId) + { + var surface = _dats.Get(surfaceId); + if (surface is null) + return DecodedTexture.Magenta; + + var surfaceTexture = _dats.Get((uint)surface.OrigTextureId); + if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) + return DecodedTexture.Magenta; + + var rs = _dats.Get((uint)surfaceTexture.Textures[0]); + if (rs is null) + return DecodedTexture.Magenta; + + return SurfaceDecoder.DecodeRenderSurface(rs); + } + + private uint UploadRgba8(DecodedTexture decoded) + { + uint tex = _gl.GenTexture(); + _gl.BindTexture(TextureTarget.Texture2D, tex); + + fixed (byte* p = decoded.Rgba8) + _gl.TexImage2D( + TextureTarget.Texture2D, + 0, + InternalFormat.Rgba8, + (uint)decoded.Width, + (uint)decoded.Height, + 0, + PixelFormat.Rgba, + PixelType.UnsignedByte, + p); + + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + + _gl.BindTexture(TextureTarget.Texture2D, 0); + return tex; + } + + public void Dispose() + { + foreach (var h in _handlesBySurfaceId.Values) + _gl.DeleteTexture(h); + _handlesBySurfaceId.Clear(); + if (_magentaHandle != 0) + { + _gl.DeleteTexture(_magentaHandle); + _magentaHandle = 0; + } + } +} diff --git a/src/AcDream.Core/AcDream.Core.csproj b/src/AcDream.Core/AcDream.Core.csproj index a919781..8813c3c 100644 --- a/src/AcDream.Core/AcDream.Core.csproj +++ b/src/AcDream.Core/AcDream.Core.csproj @@ -7,6 +7,7 @@ true + diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs new file mode 100644 index 0000000..31e6309 --- /dev/null +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -0,0 +1,88 @@ +using System.Numerics; +using AcDream.Core.Terrain; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.Meshing; + +public static class GfxObjMesh +{ + /// + /// Walk a GfxObj's polygons and produce one + /// per referenced Surface. Polygons are triangulated as fans. + /// + public static IReadOnlyList Build(GfxObj gfxObj) + { + // Group output vertices and indices per surface index. + var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); + + foreach (var kvp in gfxObj.Polygons) + { + var poly = kvp.Value; + + if (poly.VertexIds.Count < 3) + continue; // degenerate + + int surfaceIdx = poly.PosSurface; + if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) + continue; // out of range surface + + if (!perSurface.TryGetValue(surfaceIdx, out var bucket)) + { + bucket = (new List(), new List(), new Dictionary<(int, int), uint>()); + perSurface[surfaceIdx] = bucket; + } + + // Collect output vertex indices for this polygon. + var polyOut = new List(poly.VertexIds.Count); + bool skipPoly = false; + + for (int i = 0; i < poly.VertexIds.Count; i++) + { + int posIdx = poly.VertexIds[i]; + int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0; + + if (!gfxObj.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw)) + { + skipPoly = true; + break; + } + + var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count + ? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V) + : Vector2.Zero; + + var key = (posIdx, uvIdx); + if (!bucket.Dedupe.TryGetValue(key, out var outIdx)) + { + outIdx = (uint)bucket.Vertices.Count; + bucket.Vertices.Add(new Vertex(sw.Origin, sw.Normal, texcoord)); + bucket.Dedupe[key] = outIdx; + } + polyOut.Add(outIdx); + } + + if (skipPoly || polyOut.Count < 3) + continue; + + // Fan triangulation: (v0, v1, v2), (v0, v2, v3), ... + for (int i = 1; i < polyOut.Count - 1; i++) + { + bucket.Indices.Add(polyOut[0]); + bucket.Indices.Add(polyOut[i]); + bucket.Indices.Add(polyOut[i + 1]); + } + } + + // Emit one sub-mesh per surface. + var result = new List(perSurface.Count); + foreach (var kvp in perSurface) + { + var surfaceId = (uint)gfxObj.Surfaces[kvp.Key]; + result.Add(new GfxObjSubMesh( + SurfaceId: surfaceId, + Vertices: kvp.Value.Vertices.ToArray(), + Indices: kvp.Value.Indices.ToArray())); + } + return result; + } +} diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs new file mode 100644 index 0000000..6c399e5 --- /dev/null +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -0,0 +1,12 @@ +using AcDream.Core.Terrain; + +namespace AcDream.Core.Meshing; + +/// +/// One sub-mesh of a GfxObj: a vertex+index buffer that uses a single Surface. +/// A GfxObj with multiple surfaces produces multiple sub-meshes. +/// +public sealed record GfxObjSubMesh( + uint SurfaceId, + Vertex[] Vertices, + uint[] Indices); diff --git a/src/AcDream.Core/Meshing/SetupMesh.cs b/src/AcDream.Core/Meshing/SetupMesh.cs new file mode 100644 index 0000000..ac7a8f3 --- /dev/null +++ b/src/AcDream.Core/Meshing/SetupMesh.cs @@ -0,0 +1,45 @@ +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Meshing; + +public static class SetupMesh +{ + /// + /// Flatten a Setup into a list of (GfxObjId, PartTransform) refs. + /// Uses the default placement frame and DefaultScale per part. + /// Does NOT walk ParentIndex — each part's transform is local to the setup root. + /// This is simplification for Phase 2; complex hierarchical rigs are Phase 3. + /// + public static IReadOnlyList Flatten(Setup setup) + { + AnimationFrame? defaultAnim = null; + if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af)) + defaultAnim = af; + + var result = new List(setup.Parts.Count); + for (int i = 0; i < setup.Parts.Count; i++) + { + uint gfxObjId = (uint)setup.Parts[i]; + + Frame frame; + if (defaultAnim is not null && i < defaultAnim.Frames.Count) + frame = defaultAnim.Frames[i]; + else + frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + + Vector3 scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : Vector3.One; + + var transform = + Matrix4x4.CreateScale(scale) * + Matrix4x4.CreateFromQuaternion(frame.Orientation) * + Matrix4x4.CreateTranslation(frame.Origin); + + result.Add(new MeshRef(gfxObjId, transform)); + } + return result; + } +} diff --git a/src/AcDream.Core/Textures/DecodedTexture.cs b/src/AcDream.Core/Textures/DecodedTexture.cs new file mode 100644 index 0000000..130cecf --- /dev/null +++ b/src/AcDream.Core/Textures/DecodedTexture.cs @@ -0,0 +1,10 @@ +namespace AcDream.Core.Textures; + +public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height) +{ + /// 1x1 magenta fallback for missing/unsupported textures. + public static readonly DecodedTexture Magenta = new( + Rgba8: [0xFF, 0x00, 0xFF, 0xFF], + Width: 1, + Height: 1); +} diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs new file mode 100644 index 0000000..481b947 --- /dev/null +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -0,0 +1,71 @@ +using BCnEncoder.Decoder; +using BCnEncoder.Shared; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Textures; + +public static class SurfaceDecoder +{ + private static readonly BcDecoder BcDecoder = new(); + + /// + /// Decode a RenderSurface's pixel bytes into RGBA8. Returns + /// for unsupported formats, null data, or corrupt sizing. + /// + public static DecodedTexture DecodeRenderSurface(RenderSurface rs) + { + if (rs.SourceData is null || rs.Width <= 0 || rs.Height <= 0) + return DecodedTexture.Magenta; + + try + { + return rs.Format switch + { + PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), + PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), + PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), + PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), + _ => DecodedTexture.Magenta, + }; + } + catch + { + return DecodedTexture.Magenta; + } + } + + private static DecodedTexture DecodeA8R8G8B8(RenderSurface rs) + { + int expected = rs.Width * rs.Height * 4; + if (rs.SourceData.Length < expected) + return DecodedTexture.Magenta; + + var rgba = new byte[expected]; + // Source layout per pixel: B, G, R, A → swap to R, G, B, A + for (int i = 0; i < rs.Width * rs.Height; i++) + { + int s = i * 4; + rgba[s + 0] = rs.SourceData[s + 2]; // R <- R + rgba[s + 1] = rs.SourceData[s + 1]; // G <- G + rgba[s + 2] = rs.SourceData[s + 0]; // B <- B + rgba[s + 3] = rs.SourceData[s + 3]; // A <- A + } + return new DecodedTexture(rgba, rs.Width, rs.Height); + } + + private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format) + { + var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); + var rgba = new byte[rs.Width * rs.Height * 4]; + for (int i = 0; i < pixels.Length; i++) + { + int s = i * 4; + rgba[s + 0] = pixels[i].r; + rgba[s + 1] = pixels[i].g; + rgba[s + 2] = pixels[i].b; + rgba[s + 3] = pixels[i].a; + } + return new DecodedTexture(rgba, rs.Width, rs.Height); + } +} diff --git a/src/AcDream.Core/World/LandblockLoader.cs b/src/AcDream.Core/World/LandblockLoader.cs new file mode 100644 index 0000000..4234c11 --- /dev/null +++ b/src/AcDream.Core/World/LandblockLoader.cs @@ -0,0 +1,77 @@ +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.World; + +public static class LandblockLoader +{ + private const uint GfxObjMask = 0x01000000u; + private const uint SetupMask = 0x02000000u; + private const uint TypeMask = 0xFF000000u; + + /// + /// Load a single landblock (heightmap + static objects) from the dats. + /// + /// Null if the landblock is missing from the cell dat. + public static LoadedLandblock? Load(DatCollection dats, uint landblockId) + { + var block = dats.Get(landblockId); + if (block is null) + return null; + + var info = dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); + var entities = info is null + ? Array.Empty() + : BuildEntitiesFromInfo(info); + + return new LoadedLandblock(landblockId, block, entities); + } + + /// + /// Pure mapping from a parsed LandBlockInfo to a list of WorldEntity. + /// Each Stab and BuildingInfo becomes one entity. Unsupported id types + /// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped. + /// MeshRefs is left empty at this stage — Task 5 populates it. + /// + public static IReadOnlyList BuildEntitiesFromInfo(LandBlockInfo info) + { + var result = new List(info.Objects.Count + info.Buildings.Count); + uint nextId = 1; + + foreach (var stab in info.Objects) + { + if (!IsSupported(stab.Id)) + continue; + result.Add(new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = stab.Id, + Position = stab.Frame.Origin, + Rotation = stab.Frame.Orientation, + MeshRefs = Array.Empty(), + }); + } + + foreach (var building in info.Buildings) + { + if (!IsSupported(building.ModelId)) + continue; + result.Add(new WorldEntity + { + Id = nextId++, + SourceGfxObjOrSetupId = building.ModelId, + Position = building.Frame.Origin, + Rotation = building.Frame.Orientation, + MeshRefs = Array.Empty(), + }); + } + + return result; + } + + private static bool IsSupported(uint id) + { + var type = id & TypeMask; + return type == GfxObjMask || type == SetupMask; + } +} diff --git a/src/AcDream.Core/World/LoadedLandblock.cs b/src/AcDream.Core/World/LoadedLandblock.cs new file mode 100644 index 0000000..492b1f3 --- /dev/null +++ b/src/AcDream.Core/World/LoadedLandblock.cs @@ -0,0 +1,8 @@ +using DatReaderWriter.DBObjs; + +namespace AcDream.Core.World; + +public sealed record LoadedLandblock( + uint LandblockId, + LandBlock Heightmap, + IReadOnlyList Entities); diff --git a/src/AcDream.Core/World/MeshRef.cs b/src/AcDream.Core/World/MeshRef.cs new file mode 100644 index 0000000..09c3d26 --- /dev/null +++ b/src/AcDream.Core/World/MeshRef.cs @@ -0,0 +1,5 @@ +using System.Numerics; + +namespace AcDream.Core.World; + +public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform); diff --git a/src/AcDream.Core/World/WorldEntity.cs b/src/AcDream.Core/World/WorldEntity.cs new file mode 100644 index 0000000..a02e5bf --- /dev/null +++ b/src/AcDream.Core/World/WorldEntity.cs @@ -0,0 +1,12 @@ +using System.Numerics; + +namespace AcDream.Core.World; + +public sealed class WorldEntity +{ + public required uint Id { get; init; } + public required uint SourceGfxObjOrSetupId { get; init; } + public required Vector3 Position { get; init; } + public required Quaternion Rotation { get; init; } + public required IReadOnlyList MeshRefs { get; init; } +} diff --git a/src/AcDream.Core/World/WorldView.cs b/src/AcDream.Core/World/WorldView.cs new file mode 100644 index 0000000..50968dc --- /dev/null +++ b/src/AcDream.Core/World/WorldView.cs @@ -0,0 +1,55 @@ +// src/AcDream.Core/World/WorldView.cs +using DatReaderWriter; + +namespace AcDream.Core.World; + +public sealed class WorldView +{ + public uint CenterLandblockId { get; } + public IReadOnlyList Landblocks { get; } + public IEnumerable AllEntities => Landblocks.SelectMany(lb => lb.Entities); + + private WorldView(uint centerLandblockId, IReadOnlyList landblocks) + { + CenterLandblockId = centerLandblockId; + Landblocks = landblocks; + } + + /// + /// Load the 3x3 grid of landblocks around . + /// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped. + /// + public static WorldView Load(DatCollection dats, uint centerLandblockId) + { + var loaded = new List(); + foreach (var id in NeighborLandblockIds(centerLandblockId)) + { + var lb = LandblockLoader.Load(dats, id); + if (lb is not null) + loaded.Add(lb); + } + return new WorldView(centerLandblockId, loaded); + } + + /// + /// Enumerate the 3x3 neighbor landblock ids around a center. Clamps at the world edges + /// (skipping neighbors that would underflow or overflow the 8-bit coordinate range). + /// + public static IEnumerable NeighborLandblockIds(uint centerLandblockId) + { + int cx = (int)((centerLandblockId >> 24) & 0xFFu); + int cy = (int)((centerLandblockId >> 16) & 0xFFu); + + for (int dy = -1; dy <= 1; dy++) + { + for (int dx = -1; dx <= 1; dx++) + { + int nx = cx + dx; + int ny = cy + dy; + if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) + continue; + yield return (uint)((nx << 24) | (ny << 16) | 0xFFFFu); + } + } + } +} diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs new file mode 100644 index 0000000..c80bbad --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs @@ -0,0 +1,250 @@ +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class GfxObjMeshTests +{ + /// + /// Build a minimal GfxObj fixture with a single triangle using surface index 0. + /// Three unique positions, one UV slot each. + /// + private static GfxObj BuildSingleTriangle() + { + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, // synthetic surface id + VertexArray = new VertexArray + { + VertexType = VertexType.CSWVertexType, + Vertices = + { + [0] = new SWVertex + { + Origin = new Vector3(0, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 0 } }, + }, + [1] = new SWVertex + { + Origin = new Vector3(1, 0, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 1, V = 0 } }, + }, + [2] = new SWVertex + { + Origin = new Vector3(0, 1, 0), + Normal = new Vector3(0, 0, 1), + UVs = { new Vec2Duv { U = 0, V = 1 } }, + }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + NegSurface = -1, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + }, + }; + return gfx; + } + + [Fact] + public void Build_SingleTriangle_ProducesOneSubMeshOneTriangle() + { + var gfx = BuildSingleTriangle(); + + var subs = GfxObjMesh.Build(gfx); + + var sub = Assert.Single(subs); + Assert.Equal(0x08000000u, sub.SurfaceId); + Assert.Equal(3, sub.Vertices.Length); + Assert.Equal(3, sub.Indices.Length); // one triangle, 3 indices + } + + [Fact] + public void Build_SingleTriangle_CopiesPositionsNormalsAndUVs() + { + var gfx = BuildSingleTriangle(); + + var sub = GfxObjMesh.Build(gfx).Single(); + + // Indices point at unique vertices; collect them in order. + var vAtIdx0 = sub.Vertices[sub.Indices[0]]; + var vAtIdx1 = sub.Vertices[sub.Indices[1]]; + var vAtIdx2 = sub.Vertices[sub.Indices[2]]; + + Assert.Equal(new Vector3(0, 0, 0), vAtIdx0.Position); + Assert.Equal(new Vector3(1, 0, 0), vAtIdx1.Position); + Assert.Equal(new Vector3(0, 1, 0), vAtIdx2.Position); + + Assert.Equal(new Vector3(0, 0, 1), vAtIdx0.Normal); + Assert.Equal(new Vector2(0, 0), vAtIdx0.TexCoord); + Assert.Equal(new Vector2(1, 0), vAtIdx1.TexCoord); + Assert.Equal(new Vector2(0, 1), vAtIdx2.TexCoord); + } + + [Fact] + public void Build_Quad_IsTriangulatedAsFan() + { + // Single quad polygon with 4 vertices -> 2 triangles, 6 indices. + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } }, + [3] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2, 3 }, + PosUVIndices = { 0, 0, 0, 0 }, + }, + }, + }; + + var sub = GfxObjMesh.Build(gfx).Single(); + + Assert.Equal(4, sub.Vertices.Length); + Assert.Equal(6, sub.Indices.Length); // 2 triangles + } + + [Fact] + public void Build_SamePositionDifferentUVs_DuplicatesOutputVertices() + { + // One vertex has two different UV slots. Each (posIdx, uvIdx) combo + // becomes a distinct output vertex. + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex + { + Origin = new(0, 0, 0), + UVs = + { + new Vec2Duv { U = 0, V = 0 }, + new Vec2Duv { U = 1, V = 1 }, + }, + }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + [1] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 1, 0, 0 }, // same positions, different UV on vert 0 + }, + }, + }; + + var sub = GfxObjMesh.Build(gfx).Single(); + + // vert 0 has two different UV slots → 2 output vertices for pos 0 + // vert 1 + 2 unique → 2 more output vertices + // total: 4 output vertices + Assert.Equal(4, sub.Vertices.Length); + Assert.Equal(6, sub.Indices.Length); // 2 triangles + } + + [Fact] + public void Build_MultipleSurfaces_ProducesMultipleSubMeshes() + { + // 2 polygons, 2 surfaces → 2 sub-meshes. + var gfx = new GfxObj + { + Surfaces = { 0x08000001u, 0x08000002u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + [2] = new SWVertex { Origin = new(0, 1, 0), UVs = { new Vec2Duv() } }, + [3] = new SWVertex { Origin = new(1, 1, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + [1] = new Polygon + { + PosSurface = 1, + VertexIds = { 1, 3, 2 }, + PosUVIndices = { 0, 0, 0 }, + }, + }, + }; + + var subs = GfxObjMesh.Build(gfx); + + Assert.Equal(2, subs.Count); + Assert.Contains(subs, s => s.SurfaceId == 0x08000001u); + Assert.Contains(subs, s => s.SurfaceId == 0x08000002u); + } + + [Fact] + public void Build_DegeneratePolygonWithTwoVertices_Skipped() + { + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + [0] = new SWVertex { Origin = new(0, 0, 0), UVs = { new Vec2Duv() } }, + [1] = new SWVertex { Origin = new(1, 0, 0), UVs = { new Vec2Duv() } }, + }, + }, + Polygons = + { + [0] = new Polygon + { + PosSurface = 0, + VertexIds = { 0, 1 }, + PosUVIndices = { 0, 0 }, + }, + }, + }; + + var subs = GfxObjMesh.Build(gfx); + + Assert.Empty(subs); // no valid polygons → no sub-meshes + } +} diff --git a/tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs b/tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs new file mode 100644 index 0000000..a9ef509 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs @@ -0,0 +1,110 @@ +using System.Numerics; +using AcDream.Core.Meshing; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.Meshing; + +public class SetupMeshTests +{ + [Fact] + public void Flatten_SinglePartSetup_YieldsOneMeshRef() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(1) + { + Frames = + { + new Frame + { + Origin = new Vector3(0, 0, 0), + Orientation = Quaternion.Identity, + }, + }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + var single = Assert.Single(refs); + Assert.Equal(0x01000100u, single.GfxObjId); + // Identity-ish transform + Assert.Equal(Matrix4x4.Identity, single.PartTransform); + } + + [Fact] + public void Flatten_TwoPartSetup_YieldsTwoMeshRefs() + { + var setup = new Setup + { + Parts = { 0x01000100u, 0x01000200u }, + DefaultScale = { Vector3.One, Vector3.One }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(2) + { + Frames = + { + new Frame { Origin = new(0, 0, 0), Orientation = Quaternion.Identity }, + new Frame { Origin = new(10, 0, 0), Orientation = Quaternion.Identity }, + }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Equal(2, refs.Count); + Assert.Equal(0x01000100u, refs[0].GfxObjId); + Assert.Equal(0x01000200u, refs[1].GfxObjId); + // Second part is translated by 10 on X. + Assert.Equal(10f, refs[1].PartTransform.Translation.X); + } + + [Fact] + public void Flatten_PartScale_IsAppliedToTransform() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { new Vector3(2, 3, 4) }, + PlacementFrames = + { + [Placement.Default] = new AnimationFrame(1) + { + Frames = { new Frame { Orientation = Quaternion.Identity } }, + }, + }, + }; + + var refs = SetupMesh.Flatten(setup); + + // The transform's M11 = 2 (scale X), M22 = 3, M33 = 4 + Assert.Equal(2f, refs[0].PartTransform.M11); + Assert.Equal(3f, refs[0].PartTransform.M22); + Assert.Equal(4f, refs[0].PartTransform.M33); + } + + [Fact] + public void Flatten_MissingPlacementFrame_UsesIdentity() + { + var setup = new Setup + { + Parts = { 0x01000100u }, + DefaultScale = { Vector3.One }, + // PlacementFrames deliberately empty + }; + + var refs = SetupMesh.Flatten(setup); + + Assert.Single(refs); + Assert.Equal(Matrix4x4.Identity, refs[0].PartTransform); + } +} diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs new file mode 100644 index 0000000..dadcb97 --- /dev/null +++ b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs @@ -0,0 +1,89 @@ +using AcDream.Core.Textures; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Tests.Textures; + +public class SurfaceDecoderTests +{ + [Fact] + public void Decode_A8R8G8B8_ConvertsToRgba8() + { + // Source format is B, G, R, A in memory (little-endian ARGB). + // One 2x2 image: red, green, blue, white pixels. + var src = new byte[] + { + 0x00, 0x00, 0xFF, 0xFF, // red (B=0, G=0, R=255, A=255) + 0x00, 0xFF, 0x00, 0xFF, // green + 0xFF, 0x00, 0x00, 0xFF, // blue + 0xFF, 0xFF, 0xFF, 0xFF, // white + }; + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = src, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Equal(2, decoded.Width); + Assert.Equal(2, decoded.Height); + Assert.Equal(16, decoded.Rgba8.Length); // 2*2*4 + // red pixel, in RGBA: 255, 0, 0, 255 + Assert.Equal(0xFF, decoded.Rgba8[0]); + Assert.Equal(0x00, decoded.Rgba8[1]); + Assert.Equal(0x00, decoded.Rgba8[2]); + Assert.Equal(0xFF, decoded.Rgba8[3]); + } + + [Fact] + public void Decode_UnsupportedFormat_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 4, + Height = 4, + Format = PixelFormat.PFID_INDEX16, // not implemented path + SourceData = new byte[32], + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_NullSourceData_ReturnsMagenta() + { + var rs = new RenderSurface + { + Width = 4, + Height = 4, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = null!, + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } + + [Fact] + public void Decode_TruncatedA8R8G8B8_ReturnsMagenta() + { + // Buffer too small for width*height*4. + var rs = new RenderSurface + { + Width = 2, + Height = 2, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[8], // should be 16 + }; + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs); + + Assert.Same(DecodedTexture.Magenta, decoded); + } +} diff --git a/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs new file mode 100644 index 0000000..af68b01 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.Core.Tests.World; + +public class LandblockLoaderTests +{ + private static LandBlock BuildFlatLandBlock() + { + var block = new LandBlock + { + HasObjects = true, + Terrain = new TerrainInfo[81], + Height = new byte[81], + }; + for (int i = 0; i < 81; i++) + { + block.Terrain[i] = (ushort)0; + block.Height[i] = 0; + } + return block; + } + + [Fact] + public void BuildEntitiesFromInfo_StabsAndBuildings_AreMappedToEntities() + { + var info = new LandBlockInfo + { + Objects = + { + new Stab + { + Id = 0x01000042u, // GfxObj id + Frame = new Frame + { + Origin = new Vector3(10, 20, 5), + Orientation = Quaternion.Identity, + }, + }, + new Stab + { + Id = 0x02000099u, // Setup id + Frame = new Frame + { + Origin = new Vector3(30, 40, 10), + Orientation = Quaternion.Identity, + }, + }, + }, + Buildings = + { + new BuildingInfo + { + ModelId = 0x020000AAu, // Setup for a building + Frame = new Frame + { + Origin = new Vector3(50, 60, 0), + Orientation = Quaternion.Identity, + }, + }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Equal(3, entities.Count); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x01000042u && e.Position == new Vector3(10, 20, 5)); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x02000099u && e.Position == new Vector3(30, 40, 10)); + Assert.Contains(entities, e => e.SourceGfxObjOrSetupId == 0x020000AAu && e.Position == new Vector3(50, 60, 0)); + } + + [Fact] + public void BuildEntitiesFromInfo_AssignsMonotonicIds() + { + var info = new LandBlockInfo + { + Objects = + { + new Stab { Id = 0x01000001u, Frame = new Frame() }, + new Stab { Id = 0x01000002u, Frame = new Frame() }, + new Stab { Id = 0x01000003u, Frame = new Frame() }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + var ids = entities.Select(e => e.Id).OrderBy(i => i).ToArray(); + Assert.Equal(3, ids.Distinct().Count()); // all unique + } + + [Fact] + public void BuildEntitiesFromInfo_UnsupportedIdType_IsSkipped() + { + // 0x03xxxxxx is neither GfxObj (0x01) nor Setup (0x02). + var info = new LandBlockInfo + { + Objects = + { + new Stab { Id = 0x01000001u, Frame = new Frame() }, + new Stab { Id = 0x03000002u, Frame = new Frame() }, // skipped + new Stab { Id = 0x02000003u, Frame = new Frame() }, + }, + }; + + var entities = LandblockLoader.BuildEntitiesFromInfo(info); + + Assert.Equal(2, entities.Count); + Assert.DoesNotContain(entities, e => e.SourceGfxObjOrSetupId == 0x03000002u); + } + + [Fact] + public void BuildEntitiesFromInfo_Empty_ReturnsEmpty() + { + var entities = LandblockLoader.BuildEntitiesFromInfo(new LandBlockInfo()); + Assert.Empty(entities); + } +} diff --git a/tests/AcDream.Core.Tests/World/WorldViewTests.cs b/tests/AcDream.Core.Tests/World/WorldViewTests.cs new file mode 100644 index 0000000..7badf04 --- /dev/null +++ b/tests/AcDream.Core.Tests/World/WorldViewTests.cs @@ -0,0 +1,46 @@ +// tests/AcDream.Core.Tests/World/WorldViewTests.cs +using AcDream.Core.World; + +namespace AcDream.Core.Tests.World; + +public class WorldViewTests +{ + [Fact] + public void NeighborIds_Center_Returns9Ids() + { + var ids = WorldView.NeighborLandblockIds(0xA9B4FFFFu).ToList(); + + Assert.Equal(9, ids.Count); + Assert.Contains(0xA9B4FFFFu, ids); // center + Assert.Contains(0xA8B3FFFFu, ids); // NW + Assert.Contains(0xAAB5FFFFu, ids); // SE + } + + [Fact] + public void NeighborIds_LowerEdge_ClampsUnderflow() + { + // Landblock 0x0000FFFF — no west or south neighbors. + var ids = WorldView.NeighborLandblockIds(0x0000FFFFu).ToList(); + + // 4 neighbors should exist: center + E + N + NE + Assert.Equal(4, ids.Count); + Assert.Contains(0x0000FFFFu, ids); + Assert.Contains(0x0100FFFFu, ids); + Assert.Contains(0x0001FFFFu, ids); + Assert.Contains(0x0101FFFFu, ids); + } + + [Fact] + public void NeighborIds_UpperEdge_ClampsOverflow() + { + // Landblock 0xFFFFFFFF — no east or north neighbors. + var ids = WorldView.NeighborLandblockIds(0xFFFFFFFFu).ToList(); + + // 4 neighbors: center + W + S + SW + Assert.Equal(4, ids.Count); + Assert.Contains(0xFFFFFFFFu, ids); + Assert.Contains(0xFEFFFFFFu, ids); + Assert.Contains(0xFFFEFFFFu, ids); + Assert.Contains(0xFEFEFFFFu, ids); + } +}