docs(phase-2): refine design after DatReaderWriter spike

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<GfxObjSubMesh> — 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-10 17:18:43 +02:00
parent 7e601073d4
commit b72851a3ee

View file

@ -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<MeshRef> 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<GfxObjSubMesh>`** — 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<QualifiedDataId<Surface>>` — **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<ushort, SWVertex>` — sparse indexing.
- `SWVertex.UVs` is a `List<Vec2Duv>` — a single position may have multiple UV sets.
- `Polygon.VertexIds` is a `List<short>` — position indices for this polygon's vertices in order.
- `Polygon.PosUVIndices` is a `List<byte>` — 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<GfxObjSubMesh> 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<WorldEntity> entities);
public void Dispose();
}
```
Internally holds a `Dictionary<uint, GfxObjGpu>` where `GfxObjGpu` bundles VAO + index count + surface id.
Internally holds a `Dictionary<uint, GfxObjGpu>` 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+.