Merge phase-2/static-meshes-and-textures: Phase 2a static meshes
Phase 2a MVP complete. 8 commits implementing Tasks 1-8 of the Phase 2 plan: - BCnEncoder.Net 2.2.1 added to AcDream.Core - GfxObjMesh.Build: multi-surface CPU mesh extractor from GfxObj vertex+polygon data, with position/UV dedupe and fan triangulation - SurfaceDecoder: BCnEncoder.Net-backed DXT decoder + A8R8G8B8 raw, with 1x1 magenta fallback - LandblockLoader: parses Stabs and BuildingInfo from LandBlockInfo into WorldEntity records, type-filtering to GfxObj + Setup only - SetupMesh.Flatten: single-level part hierarchy walker - WorldView: 3x3 neighbor landblock id computation with edge clamping - TextureCache: App-side GL texture handle cache, walks the Surface → SurfaceTexture → RenderSurface chain from the dats - StaticMeshRenderer + mesh.vert/mesh.frag + GameWindow wire-up 21 new xUnit tests (17 → 38), all green, build clean. Smoke verified against real dats: Holtburg landblock hydrated 126 static entities (Stabs + Buildings), process runs without exception, terrain + static meshes pipeline complete end-to-end. Phase 2a stops here. Tasks 9-18 (conditional texture debugging, terrain atlas, 3x3 neighbor rendering, dual cameras, plugin API growth) deferred to Phase 2b in a future session.
This commit is contained in:
commit
1d1e668a2f
21 changed files with 1322 additions and 0 deletions
|
|
@ -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<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||
|
||||
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<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
var entities = info is not null
|
||||
? AcDream.Core.World.LandblockLoader.BuildEntitiesFromInfo(info)
|
||||
: Array.Empty<AcDream.Core.World.WorldEntity>();
|
||||
|
||||
// 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 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));
|
||||
}
|
||||
}
|
||||
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)
|
||||
{
|
||||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||||
foreach (var mr in flat)
|
||||
{
|
||||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(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();
|
||||
|
|
|
|||
9
src/AcDream.App/Rendering/Shaders/mesh.frag
Normal file
9
src/AcDream.App/Rendering/Shaders/mesh.frag
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#version 430 core
|
||||
in vec2 vTex;
|
||||
out vec4 fragColor;
|
||||
|
||||
uniform sampler2D uDiffuse;
|
||||
|
||||
void main() {
|
||||
fragColor = texture(uDiffuse, vTex);
|
||||
}
|
||||
15
src/AcDream.App/Rendering/Shaders/mesh.vert
Normal file
15
src/AcDream.App/Rendering/Shaders/mesh.vert
Normal file
|
|
@ -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);
|
||||
}
|
||||
134
src/AcDream.App/Rendering/StaticMeshRenderer.cs
Normal file
134
src/AcDream.App/Rendering/StaticMeshRenderer.cs
Normal file
|
|
@ -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<uint, List<SubMeshGpu>> _gpuByGfxObj = new();
|
||||
|
||||
public StaticMeshRenderer(GL gl, Shader shader, TextureCache textures)
|
||||
{
|
||||
_gl = gl;
|
||||
_shader = shader;
|
||||
_textures = textures;
|
||||
}
|
||||
|
||||
public void EnsureUploaded(uint gfxObjId, IReadOnlyList<GfxObjSubMesh> subMeshes)
|
||||
{
|
||||
if (_gpuByGfxObj.ContainsKey(gfxObjId))
|
||||
return;
|
||||
|
||||
var list = new List<SubMeshGpu>(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<WorldEntity> 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;
|
||||
}
|
||||
}
|
||||
92
src/AcDream.App/Rendering/TextureCache.cs
Normal file
92
src/AcDream.App/Rendering/TextureCache.cs
Normal file
|
|
@ -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<uint, uint> _handlesBySurfaceId = new();
|
||||
private uint _magentaHandle;
|
||||
|
||||
public TextureCache(GL gl, DatCollection dats)
|
||||
{
|
||||
_gl = gl;
|
||||
_dats = dats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<Surface>(surfaceId);
|
||||
if (surface is null)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var surfaceTexture = _dats.Get<SurfaceTexture>((uint)surface.OrigTextureId);
|
||||
if (surfaceTexture is null || surfaceTexture.Textures.Count == 0)
|
||||
return DecodedTexture.Magenta;
|
||||
|
||||
var rs = _dats.Get<RenderSurface>((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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCnEncoder.Net" Version="2.2.1" />
|
||||
<PackageReference Include="Chorizite.DatReaderWriter" Version="2.1.4" />
|
||||
<PackageReference Include="Serilog" Version="4.0.2" />
|
||||
</ItemGroup>
|
||||
|
|
|
|||
88
src/AcDream.Core/Meshing/GfxObjMesh.cs
Normal file
88
src/AcDream.Core/Meshing/GfxObjMesh.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Terrain;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
public static class GfxObjMesh
|
||||
{
|
||||
/// <summary>
|
||||
/// Walk a GfxObj's polygons and produce one <see cref="GfxObjSubMesh"/>
|
||||
/// per referenced Surface. Polygons are triangulated as fans.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<GfxObjSubMesh> Build(GfxObj gfxObj)
|
||||
{
|
||||
// Group output vertices and indices per surface index.
|
||||
var perSurface = new Dictionary<int, (List<Vertex> Vertices, List<uint> 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<Vertex>(), new List<uint>(), new Dictionary<(int, int), uint>());
|
||||
perSurface[surfaceIdx] = bucket;
|
||||
}
|
||||
|
||||
// Collect output vertex indices for this polygon.
|
||||
var polyOut = new List<uint>(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<GfxObjSubMesh>(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;
|
||||
}
|
||||
}
|
||||
12
src/AcDream.Core/Meshing/GfxObjSubMesh.cs
Normal file
12
src/AcDream.Core/Meshing/GfxObjSubMesh.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
using AcDream.Core.Terrain;
|
||||
|
||||
namespace AcDream.Core.Meshing;
|
||||
|
||||
/// <summary>
|
||||
/// One sub-mesh of a GfxObj: a vertex+index buffer that uses a single Surface.
|
||||
/// A GfxObj with multiple surfaces produces multiple sub-meshes.
|
||||
/// </summary>
|
||||
public sealed record GfxObjSubMesh(
|
||||
uint SurfaceId,
|
||||
Vertex[] Vertices,
|
||||
uint[] Indices);
|
||||
45
src/AcDream.Core/Meshing/SetupMesh.cs
Normal file
45
src/AcDream.Core/Meshing/SetupMesh.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<MeshRef> Flatten(Setup setup)
|
||||
{
|
||||
AnimationFrame? defaultAnim = null;
|
||||
if (setup.PlacementFrames.TryGetValue(Placement.Default, out var af))
|
||||
defaultAnim = af;
|
||||
|
||||
var result = new List<MeshRef>(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;
|
||||
}
|
||||
}
|
||||
10
src/AcDream.Core/Textures/DecodedTexture.cs
Normal file
10
src/AcDream.Core/Textures/DecodedTexture.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace AcDream.Core.Textures;
|
||||
|
||||
public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height)
|
||||
{
|
||||
/// <summary>1x1 magenta fallback for missing/unsupported textures.</summary>
|
||||
public static readonly DecodedTexture Magenta = new(
|
||||
Rgba8: [0xFF, 0x00, 0xFF, 0xFF],
|
||||
Width: 1,
|
||||
Height: 1);
|
||||
}
|
||||
71
src/AcDream.Core/Textures/SurfaceDecoder.cs
Normal file
71
src/AcDream.Core/Textures/SurfaceDecoder.cs
Normal file
|
|
@ -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();
|
||||
|
||||
/// <summary>
|
||||
/// Decode a RenderSurface's pixel bytes into RGBA8. Returns <see cref="DecodedTexture.Magenta"/>
|
||||
/// for unsupported formats, null data, or corrupt sizing.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
77
src/AcDream.Core/World/LandblockLoader.cs
Normal file
77
src/AcDream.Core/World/LandblockLoader.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Load a single landblock (heightmap + static objects) from the dats.
|
||||
/// </summary>
|
||||
/// <returns>Null if the landblock is missing from the cell dat.</returns>
|
||||
public static LoadedLandblock? Load(DatCollection dats, uint landblockId)
|
||||
{
|
||||
var block = dats.Get<LandBlock>(landblockId);
|
||||
if (block is null)
|
||||
return null;
|
||||
|
||||
var info = dats.Get<LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||||
var entities = info is null
|
||||
? Array.Empty<WorldEntity>()
|
||||
: BuildEntitiesFromInfo(info);
|
||||
|
||||
return new LoadedLandblock(landblockId, block, entities);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info)
|
||||
{
|
||||
var result = new List<WorldEntity>(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<MeshRef>(),
|
||||
});
|
||||
}
|
||||
|
||||
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<MeshRef>(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsSupported(uint id)
|
||||
{
|
||||
var type = id & TypeMask;
|
||||
return type == GfxObjMask || type == SetupMask;
|
||||
}
|
||||
}
|
||||
8
src/AcDream.Core/World/LoadedLandblock.cs
Normal file
8
src/AcDream.Core/World/LoadedLandblock.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
public sealed record LoadedLandblock(
|
||||
uint LandblockId,
|
||||
LandBlock Heightmap,
|
||||
IReadOnlyList<WorldEntity> Entities);
|
||||
5
src/AcDream.Core/World/MeshRef.cs
Normal file
5
src/AcDream.Core/World/MeshRef.cs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.World;
|
||||
|
||||
public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform);
|
||||
12
src/AcDream.Core/World/WorldEntity.cs
Normal file
12
src/AcDream.Core/World/WorldEntity.cs
Normal file
|
|
@ -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<MeshRef> MeshRefs { get; init; }
|
||||
}
|
||||
55
src/AcDream.Core/World/WorldView.cs
Normal file
55
src/AcDream.Core/World/WorldView.cs
Normal file
|
|
@ -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<LoadedLandblock> Landblocks { get; }
|
||||
public IEnumerable<WorldEntity> AllEntities => Landblocks.SelectMany(lb => lb.Entities);
|
||||
|
||||
private WorldView(uint centerLandblockId, IReadOnlyList<LoadedLandblock> landblocks)
|
||||
{
|
||||
CenterLandblockId = centerLandblockId;
|
||||
Landblocks = landblocks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load the 3x3 grid of landblocks around <paramref name="centerLandblockId"/>.
|
||||
/// Missing neighbors (edges of the world or absent from the cell dat) are silently skipped.
|
||||
/// </summary>
|
||||
public static WorldView Load(DatCollection dats, uint centerLandblockId)
|
||||
{
|
||||
var loaded = new List<LoadedLandblock>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static IEnumerable<uint> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
250
tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs
Normal file
250
tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs
Normal file
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Build a minimal GfxObj fixture with a single triangle using surface index 0.
|
||||
/// Three unique positions, one UV slot each.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
110
tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs
Normal file
110
tests/AcDream.Core.Tests/Meshing/SetupMeshTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
89
tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
Normal file
89
tests/AcDream.Core.Tests/Textures/SurfaceDecoderTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
119
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs
Normal file
119
tests/AcDream.Core.Tests/World/LandblockLoaderTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
46
tests/AcDream.Core.Tests/World/WorldViewTests.cs
Normal file
46
tests/AcDream.Core.Tests/World/WorldViewTests.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue