From b72851a3ee50763ad2104db56bdba1594cb75793 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 10 Apr 2026 17:18:43 +0200 Subject: [PATCH] docs(phase-2): refine design after DatReaderWriter spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified the real GfxObj data shape against DatReaderWriter's generated types and WorldBuilder's ObjectMeshManager. Three corrections: - GfxObj.Surfaces is a list and Polygon.PosSurface indexes into it; multiple surfaces per GfxObj are the norm. GfxObjMesh.Build now returns IReadOnlyList — one sub-mesh per referenced surface. StaticMeshRenderer draws entities by grouping entities by GfxObj and then by sub-mesh within each GfxObj. Matches the approach WorldBuilder takes. - LandBlockInfo has both Objects (Stabs — small decorations, rocks, trees) AND Buildings (BuildingInfo). LandblockLoader loads both lists and maps them uniformly to WorldEntity since both types share the Frame shape. - Stab.Frame and BuildingInfo.Frame carry position+orientation only, no scale component. WorldEntity.Scale dropped. WorldEntitySnapshot loses its Scale field too. Phase 3+ can add it back if needed. Closes the one open design question flagged in the original doc. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...2026-04-10-phase-2-static-meshes-design.md | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/plans/2026-04-10-phase-2-static-meshes-design.md b/docs/plans/2026-04-10-phase-2-static-meshes-design.md index e3f466b..1dcb7ce 100644 --- a/docs/plans/2026-04-10-phase-2-static-meshes-design.md +++ b/docs/plans/2026-04-10-phase-2-static-meshes-design.md @@ -166,7 +166,6 @@ public sealed class WorldEntity public required uint SourceGfxObjOrSetupId { get; init; } public required Vector3 Position { get; init; } public required Quaternion Rotation { get; init; } - public required Vector3 Scale { get; init; } public required IReadOnlyList MeshRefs { get; init; } } @@ -175,7 +174,9 @@ public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform); `Id` is a monotonic counter assigned at load time (no wire protocol yet). `SourceGfxObjOrSetupId` is the dat id that this entity was built from; used for deduplication in rendering. -**`LandblockLoader`** — static class with `Load(DatCollection dats, uint landblockId) → LoadedLandblock`. Returns the heightmap `LandBlock` + the flat list of `WorldEntity` from `LandBlockInfo`. No GPU state. Testable. +**No `Scale` field:** `Stab` and `BuildingInfo` both store a `Frame` with `Origin: Vector3` and `Orientation: Quaternion` — no scale component. Phase 2 renders at unit scale. If Phase 3+ needs scale (e.g., for loot drops or resized objects), it can be added then. + +**`LandblockLoader`** — static class with `Load(DatCollection dats, uint landblockId) → LoadedLandblock`. Returns the heightmap `LandBlock` + the flat list of `WorldEntity` from `LandBlockInfo`. Loads **both** `LandBlockInfo.Objects` (`Stab` list — small decorations, barrels, rocks, trees) **and** `LandBlockInfo.Buildings` (`BuildingInfo` list — actual buildings). Both types share the same `Frame` shape so they map to `WorldEntity` uniformly. No GPU state. Testable. ```csharp public sealed record LoadedLandblock( @@ -201,14 +202,31 @@ Computes neighbor ids from the center: the high 16 bits of a landblock id are an ### `AcDream.Core/Meshing/` -**`GfxObjMesh.Build(GfxObj) → LandblockMeshData`** — walks `GfxObj.VertexArray` + `GfxObj.Polygons` into a vertex+index mesh. This is the hardest piece of Phase 2. +**`GfxObjMesh.Build(GfxObj) → IReadOnlyList`** — walks `GfxObj.VertexArray` + `GfxObj.Polygons` into one sub-mesh **per referenced `Surface`**. This is the hardest piece of Phase 2. -Key challenges: -- AC's GfxObj uses per-polygon vertex indexing with separate UV indices per vertex per polygon. A single vertex position may appear multiple times with different UVs — we need to either duplicate vertices or build a `(posIdx, uvIdx)` → output-vertex-idx map. -- Polygons can have more than 3 vertices (4, 5, ...). Simple fan triangulation is fine for convex polygons. -- Polygons reference a `Surface` index; for Phase 2, we record that as vertex data so the shader knows which texture to sample. For now, we assume each GfxObj uses one surface — if multiple, we pick the first (Phase 3 handles multi-surface properly). +```csharp +public readonly record struct GfxObjSubMesh( + uint SurfaceId, // from GfxObj.Surfaces[Polygon.PosSurface] + Vertex[] Vertices, + uint[] Indices); +``` -We reuse the `Vertex` struct from Phase 1 (`Position`, `Normal`, `TexCoord`) even though `Normal` will be flat `UnitZ` for now. +Key facts (verified in DatReaderWriter and cross-checked against WorldBuilder's `ObjectMeshManager`): +- `GfxObj.Surfaces` is a `List>` — **multiple surfaces per GfxObj are the norm, not the exception.** +- `Polygon.PosSurface` is a `short` index into `GfxObj.Surfaces`. Different polygons in the same GfxObj can use different surfaces. +- `GfxObj.VertexArray.Vertices` is a `Dictionary` — sparse indexing. +- `SWVertex.UVs` is a `List` — a single position may have multiple UV sets. +- `Polygon.VertexIds` is a `List` — position indices for this polygon's vertices in order. +- `Polygon.PosUVIndices` is a `List` — for each vertex in this polygon, which of the `SWVertex.UVs[]` slots to use. + +So the real rule is: **for each polygon, group by `PosSurface`, emit output vertices `(swVertex.Origin, swVertex.Normal, swVertex.UVs[PosUVIndices[i]])` for each `VertexIds[i]`, deduplicate by `(posIdx, uvIdx)` tuple, fan-triangulate the polygon's output vertices into sub-mesh indices.** The result is one `GfxObjSubMesh` per unique surface. + +Other challenges: +- Polygons can have more than 3 vertices (4, 5, ...). Simple fan triangulation works for convex polygons (AC's polygons are convex). +- `Polygon.CullMode` could mean we need to render both sides or one — for Phase 2 we ignore it and render both (no backface culling). +- `Polygon.NegSurface` and `NegUVIndices` support double-sided polys with different textures per face — ignored for Phase 2, same reason. + +We reuse the `Vertex` struct from Phase 1 (`Position`, `Normal`, `TexCoord`) and take `Normal` straight from `SWVertex.Normal` (AC already bakes per-vertex normals). **`SetupMesh.Flatten(Setup, DatCollection) → IReadOnlyList<(uint GfxObjId, Matrix4x4 PartTransform)>`** — walks a `Setup`'s parts and placement frames, yields the flat list of (GfxObjId, transform) tuples for a single `WorldEntity` to reference. @@ -282,17 +300,18 @@ public sealed unsafe class StaticMeshRenderer : IDisposable { public StaticMeshRenderer(GL gl, Shader meshShader, TextureCache textures); - // Upload a mesh for a given GfxObj id (idempotent; cached). - public void EnsureUploaded(uint gfxObjId, LandblockMeshData meshData, uint surfaceId); + // Upload all sub-meshes for a given GfxObj id (idempotent; cached). + public void EnsureUploaded(uint gfxObjId, IReadOnlyList subMeshes); - // Draw all entities grouped by GfxObj for batched state changes. + // Draw all entities grouped by GfxObj, then within each GfxObj by sub-mesh + // (each sub-mesh has its own surface/texture). public void Draw(ICamera camera, IEnumerable entities); public void Dispose(); } ``` -Internally holds a `Dictionary` where `GfxObjGpu` bundles VAO + index count + surface id. +Internally holds a `Dictionary` where `GfxObjGpu` bundles a list of `SubMeshGpu` records — one VAO/VBO/EBO per sub-mesh, each with its own `SurfaceId`. The draw loop iterates entities, and for each entity iterates that entity's GfxObj's sub-meshes, switching texture between sub-meshes. **`TextureCache`** — App-side GL texture ownership: @@ -334,10 +353,11 @@ public readonly record struct WorldEntitySnapshot( uint Id, uint SourceId, Vector3 Position, - Quaternion Rotation, - Vector3 Scale); + Quaternion Rotation); ``` +(No `Scale` field — Phase 2 renders at unit scale because `Stab.Frame` and `BuildingInfo.Frame` don't carry a scale component.) + **`IEvents`** — new interface: ```csharp @@ -435,7 +455,7 @@ Plugins get snapshots, not live `WorldEntity` references — this protects core ## Open questions (deferred, not blocking) -- Does `GfxObj` always reference one `Surface`, or do we need per-vertex surface indexing? If the latter, Phase 2 might need per-polygon draw batching. Defer investigation to Task 2. +- ~~Does `GfxObj` always reference one `Surface`?~~ **Answered during design spike:** multiple surfaces per GfxObj are the norm. Mesh builder produces one sub-mesh per referenced surface. WorldBuilder's `ObjectMeshManager` confirms this is the canonical approach. - Terrain atlas size — 16 layers enough, or more? Deferred to Task 11 once we know the retail terrain type count. -- Should `WorldEntitySnapshot` be sent as a single list or as an `IEnumerable` (lazy)? Probably list — plugins want indexed access. Locked: list. +- Should `WorldEntitySnapshot` be sent as a single list or as an `IEnumerable` (lazy)? Plugins want indexed access. Locked: list. - Collision, animation, hot reload of plugins — all Phase 3+.