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 new file mode 100644 index 0000000..e3f466b --- /dev/null +++ b/docs/plans/2026-04-10-phase-2-static-meshes-design.md @@ -0,0 +1,441 @@ +# acdream Phase 2 design: static meshes, textures, neighbor landblocks + +**Date:** 2026-04-10 +**Status:** Approved — design locked, ready for implementation planning. +**Builds on:** Phase 1 (`docs/plans/2026-04-10-phase-1-terrain-and-plugin-scaffold.md`, merged as commit `2089bf3`). + +## Goal + +Turn the single flat-shaded Holtburg terrain patch from Phase 1 into a recognizable textured 3×3 landblock world with all static objects (buildings, trees, rocks) rendered from their GfxObj / Setup sources, explorable by both orbit and free-fly cameras, with a minimal plugin API exposing the world entity list. + +## Non-goals + +- Lighting (flat color + diffuse texture only). Deferred to later phase. +- Frustum culling. 9 landblocks is small enough that drawing everything every frame is fine. +- Skeletal animation. Static objects only. Monsters/NPCs/players are Phase 3+. +- Collision. Free-fly clips through walls. +- Level-of-detail (LOD) swaps. Draw the full-res mesh at all distances. +- Hot world changes (`IGameState.Entities` is a one-shot snapshot built at load time). +- Per-cell terrain texture blending. Hard edges between adjacent terrain types are acceptable. + +## Locked decisions (from brainstorming) + +1. **Full scope:** geometry + textures + 3×3 neighbor landblocks. +2. **Both cameras:** `OrbitCamera` remains default, `FlyCamera` added, `F` key toggles. `ICamera` interface unifies them. +3. **Minimal plugin API:** `IGameState.Entities` (snapshot) + `IEvents.EntitySpawned` (per-entity-at-load). No overlay yet. +4. **BCnEncoder.Net** for DXT/BCn texture decoding (matches WorldBuilder's choice). + +## Architecture overview + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ acdream client │ +│ │ +│ dat directory │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ AcDream.Core │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │ │ +│ │ │ Terrain │ │ World │ │ Textures │ │ │ +│ │ │ (P1) │ │ │ │ │ │ │ +│ │ │ │ │ LandblockLdr │ │ Surface │ │ │ +│ │ │ Landblock│ │ WorldView │ │ Decoder │ │ │ +│ │ │ Mesh │ │ (3x3 grid) │ │ (BCn) │ │ │ +│ │ └──────────┘ │ WorldEntity │ └────────────┘ │ │ +│ │ └──────────────┘ │ │ +│ │ ┌──────────────────────────────┐ │ │ +│ │ │ Meshing │ │ │ +│ │ │ │ │ │ +│ │ │ GfxObjMesh.Build │ │ │ +│ │ │ SetupMesh.Flatten │ │ │ +│ │ └──────────────────────────────┘ │ │ +│ └──────┬───────────────┬──────────────┬────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ AcDream.App / Rendering │ │ +│ │ │ │ +│ │ TerrainRenderer(updated) StaticMeshRenderer(new) │ │ +│ │ ↳ atlas sampling ↳ one VAO per GfxObj │ │ +│ │ │ │ +│ │ TextureCache(new) │ │ +│ │ ↳ Surface.id → GL handle │ │ +│ │ │ │ +│ │ Shader: terrain.vert/frag (updated, samples atlas) │ │ +│ │ Shader: mesh.vert/frag (new) │ │ +│ │ │ │ +│ │ ICamera(new) │ │ +│ │ ├── OrbitCamera (P1, refactored behind interface) │ │ +│ │ └── FlyCamera (new, WASD + mouse look) │ │ +│ │ CameraController (F key toggles) │ │ +│ └──────────────┬───────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ Plugin pipeline (P1) │ │ +│ │ │ │ +│ │ + IGameState.Entities │ (snapshot) │ +│ │ + IEvents.EntitySpawned │ (per-entity, at load) │ +│ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key principles:** + +1. Pure CPU work (`World/`, `Meshing/`, `Textures/`) lives under `AcDream.Core` and is TDD-tested. +2. GPU work (`StaticMeshRenderer`, `TextureCache`, shaders, cameras) lives under `AcDream.App` and is manual-smoke tested against real retail dats. +3. Meshes are **deduplicated by GfxObj id.** One VAO per unique mesh. Entities sharing a GfxObj share its VAO — the draw loop iterates entities and sets per-entity model matrices. +4. Textures are **deduplicated by Surface id.** `TextureCache` lazily uploads on miss. +5. The 3×3 neighbor grid is a concern of `WorldView`, not `LandblockLoader`. The loader loads one landblock; the view composes nine. +6. Plugins receive **immutable snapshots** (`WorldEntitySnapshot`), not live `WorldEntity` references. Protects core state. + +## Data flow + +``` +startup + │ + ▼ +Load center landblock ID (Holtburg 0xA9B4) + │ + ▼ +For each of 9 landblocks in 3x3 grid around center: + │ + ├─► LandBlock (heightmap) ──► LandblockMesh.Build (Phase 1) + │ + └─► LandBlockInfo (static objects) + │ + ▼ + For each "stab" (static object placement): + │ + ├─► Read object id + position + rotation + scale + │ + ├─► Resolve the id to a GfxObj or a Setup + │ │ + │ ├── GfxObj ──► extract one CPU mesh + │ │ + │ └── Setup ──► recursively flatten into N (GfxObjId, PartTransform) + │ tuples — one per part + │ + ├─► Create WorldEntity { Id, SourceId, Position, Rotation, Scale, MeshRefs[] } + │ + └─► Fire IEvents.EntitySpawned(snapshot) + │ + ▼ +For each unique GfxObj referenced by any entity: + │ + ├─► Decode its Surfaces through Surface → SurfaceTexture → RenderSurface + │ using BCnEncoder.Net for DXT, palette lookup for indexed textures + │ + ├─► Upload RGBA8 bytes as a GL texture (via TextureCache) + │ + └─► Build VAO/VBO/EBO for the mesh (via StaticMeshRenderer) + │ + ▼ +Main loop: + │ + ├─► camera (orbit or fly, via ICamera) + │ + ├─► TerrainRenderer.Draw(camera, LandblockMeshData[] + origins) + │ for each of 9 landblock meshes: + │ bind terrain shader + terrain atlas texture + │ set model matrix (translate by landblock origin) + │ draw + │ + └─► StaticMeshRenderer.Draw(camera, IEnumerable) + group entities by GfxObj id + for each group: + bind mesh shader + bind the GfxObj's diffuse texture + for each entity in group: + set model matrix (translate + rotate + scale) + draw +``` + +## Components + +### `AcDream.Core/World/` + +**`WorldEntity`** — sealed class holding per-spawn state: + +```csharp +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 Vector3 Scale { get; init; } + public required IReadOnlyList MeshRefs { get; init; } +} + +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. + +```csharp +public sealed record LoadedLandblock( + uint LandblockId, + LandBlock Heightmap, + IReadOnlyList Entities); +``` + +**`WorldView`** — the 3×3 aggregate: + +```csharp +public sealed class WorldView +{ + public uint CenterLandblockId { get; } + public IReadOnlyList Landblocks { get; } + public IEnumerable AllEntities => Landblocks.SelectMany(lb => lb.Entities); + + public static WorldView Load(DatCollection dats, uint centerLandblockId); +} +``` + +Computes neighbor ids from the center: the high 16 bits of a landblock id are an `0xXXYY` coordinate, so neighbors are `(x-1..x+1, y-1..y+1)`. Missing neighbors (edges of the world, or missing in the dat) are silently skipped. + +### `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. + +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). + +We reuse the `Vertex` struct from Phase 1 (`Position`, `Normal`, `TexCoord`) even though `Normal` will be flat `UnitZ` for now. + +**`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. + +### `AcDream.Core/Textures/` + +**`SurfaceDecoder.Decode(Surface, DatCollection) → DecodedTexture`** — resolves Surface → SurfaceTexture → RenderSurface, decodes compressed or palette-indexed pixel data. + +```csharp +public sealed record DecodedTexture(byte[] Rgba8, int Width, int Height); +``` + +Uses `BCnEncoder.Net` for BC1/BC2/BC3. Handles palette indexed via `Palette` lookup. Falls back to a 1×1 magenta texture on any failure. + +Testable: feed it a synthetic uncompressed `RenderSurface` and assert byte-exact output. + +### `AcDream.App/Rendering/` + +**`ICamera`** — interface: + +```csharp +public interface ICamera +{ + Matrix4x4 View { get; } + Matrix4x4 Projection { get; } + float Aspect { get; set; } +} +``` + +**`OrbitCamera`** — refactored to implement `ICamera` (existing behavior preserved). + +**`FlyCamera`** — new implementation: + +```csharp +public sealed class FlyCamera : ICamera +{ + public Vector3 Position { get; set; } = new(96, 96, 100); + public float Yaw { get; set; } + public float Pitch { get; set; } + public float MoveSpeed { get; set; } = 80f; // world units / sec + public float MouseSensitivity { get; set; } = 0.002f; + public float FovY { get; set; } = MathF.PI / 3f; + public float Aspect { get; set; } = 16f / 9f; + + public Matrix4x4 View { get; } + public Matrix4x4 Projection { get; } + + public void Update(double dt, bool w, bool a, bool s, bool d, bool up, bool down); + public void Look(float deltaX, float deltaY); +} +``` + +WASD moves in the horizontal plane relative to yaw. Space/Ctrl (or up/down) move vertically. Mouse captured with `CursorMode.Raw`. + +**`CameraController`** — owns both cameras and the active selection: + +```csharp +public sealed class CameraController +{ + public ICamera Active { get; private set; } + public OrbitCamera Orbit { get; } + public FlyCamera Fly { get; } + + public void Toggle(); // flips Active, also flips cursor capture +} +``` + +**`StaticMeshRenderer`** — draws all `WorldEntity` via their `MeshRefs`: + +```csharp +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); + + // Draw all entities grouped by GfxObj for batched state changes. + public void Draw(ICamera camera, IEnumerable entities); + + public void Dispose(); +} +``` + +Internally holds a `Dictionary` where `GfxObjGpu` bundles VAO + index count + surface id. + +**`TextureCache`** — App-side GL texture ownership: + +```csharp +public sealed class TextureCache : IDisposable +{ + public TextureCache(GL gl, DatCollection dats); + + // Get or upload the texture for a Surface id. Returns GL texture handle. + public uint GetOrUpload(uint surfaceId); + + public void Dispose(); // delete all handles +} +``` + +On cache miss, calls `SurfaceDecoder.Decode`, uploads the RGBA8 bytes as a `GL_RGBA8` texture, stores the handle. + +**Terrain atlas** — separate concern from per-GfxObj textures. At load time we gather the unique terrain-type texture ids from the loaded landblocks, decode each, and upload as a `GL_TEXTURE_2D_ARRAY` with each terrain type as a layer. The updated terrain shader samples the array with the per-vertex `aTerrainIndex` (a byte value taken from `TerrainInfo[i]`). + +**Shader updates:** + +`terrain.vert` — gains `in uint aTerrainIndex; out flat uint vTerrainIndex; uniform mat4 uModel;`. Position transformed by `uModel` before view/projection. + +`terrain.frag` — gains `uniform sampler2DArray uTerrainAtlas;`. Samples atlas at `(vTexCoord, vTerrainIndex)`. Falls back to the Phase 1 height ramp if texture is missing. + +`mesh.vert` / `mesh.frag` — new pair for static meshes. Position × model × view × projection. Single `sampler2D uDiffuse`. No lighting. + +### `AcDream.Plugin.Abstractions/` + +**`IGameState`** — new interface: + +```csharp +public interface IGameState +{ + IReadOnlyList Entities { get; } +} + +public readonly record struct WorldEntitySnapshot( + uint Id, + uint SourceId, + Vector3 Position, + Quaternion Rotation, + Vector3 Scale); +``` + +**`IEvents`** — new interface: + +```csharp +public interface IEvents +{ + event Action EntitySpawned; +} +``` + +**`IPluginHost`** — gains two properties: + +```csharp +public interface IPluginHost +{ + IPluginLogger Log { get; } + IGameState State { get; } // NEW + IEvents Events { get; } // NEW +} +``` + +Plugins get snapshots, not live `WorldEntity` references — this protects core state from plugin mutation and prevents coupling to rendering details like `MeshRef`. + +### `AcDream.App/Plugins/` + +**`WorldGameState`** — sealed class implementing `IGameState`. Built from a `WorldView`. `Entities` returns a cached snapshot list rebuilt lazily when the world changes (in Phase 2, only at initial load). + +**`WorldEvents`** — sealed class implementing `IEvents`. Holds the `EntitySpawned` event. Fired by `WorldGameState` during snapshot building. + +**`AppPluginHost`** extended to own `State` and `Events`, handed to all plugins via `Initialize`. + +## Testing strategy + +**TDD (pure CPU, xUnit):** +- `GfxObjMesh.Build` — synthetic GfxObj, vertex/index count assertions, UV forwarding, triangulation of N-gons +- `SetupMesh.Flatten` — synthetic Setup with 2-3 parts, assert flattened (GfxObjId, Matrix4x4) tuples +- `SurfaceDecoder.Decode` — synthetic uncompressed RenderSurface, RGBA8 byte-exact assertion; BC1 decode exercised with a known small blob +- `LandblockLoader.Load` — synthetic or real Holtburg, entity count assertion +- `WorldView` — neighbor id math, edge handling + +**Manual smoke (against retail dats):** +- `StaticMeshRenderer` — do buildings appear? +- `TextureCache` — do textures look right? +- `FlyCamera` — does WASD move, does mouse look work? +- `CameraController` — does F toggle? +- Plugin round-trip — `SmokePlugin` subscribes to `EntitySpawned` and logs entity count at `Enable` time + +**Expected test count:** ~30-35 (17 from Phase 1 + 13-18 new). + +## Error handling & failure modes + +**Dat-level (load):** +- Missing neighbor → silent skip +- `LandBlockInfo` missing → log warning, render terrain without objects +- GfxObj/Setup unresolvable → log warning, skip entity, continue +- Surface/RenderSurface missing/corrupt → 1×1 magenta fallback texture + +**GL-level (runtime):** +- Texture upload fails → log error, magenta fallback +- Shader compile fails → throw at load time (same as Phase 1) +- Degenerate mesh (zero triangles) → log warning, skip rendering + +**Plugin-level:** +- `EntitySpawned` handler throws → catch, log, continue (don't mark plugin faulted — too disruptive) +- `IGameState.Entities` accessed during load → returns current snapshot, may be mid-build + +**Primary risk:** `GfxObjMesh.Build` is the piece most likely to produce visually wrong output. The test suite catches gross errors (counts, triangulation); subtle bugs (flipped triangles, wrong UV mapping) only show visually. Mitigation: smoke-test each sub-step separately — wireframe first, then UVs, then textures. + +## Task sequence (rough, full bite-sized plan in a separate doc) + +1. Add `Meshing/`, `World/`, `Textures/` folder scaffolding under `AcDream.Core` +2. `GfxObjMesh.Build` (TDD, pure CPU — the hardest piece) +3. `SurfaceDecoder.Decode` (TDD, BCnEncoder.Net) +4. `LandblockLoader.Load` (TDD) +5. `SetupMesh.Flatten` (TDD) +6. `WorldView` 3×3 grid + neighbor id math (TDD) +7. `TextureCache` (App-side GL wrapper) +8. `StaticMeshRenderer` + new mesh shader (manual smoke) +9. Integrate `StaticMeshRenderer` into `GameWindow` — buildings flat white +10. Hook textures into `StaticMeshRenderer` — buildings now textured +11. Terrain atlas texture + updated terrain shader — ground textured +12. Render neighbor landblocks — horizon no longer cliffs +13. Extract `ICamera`, refactor `OrbitCamera` behind it +14. `FlyCamera` implementation +15. `CameraController` + F toggle +16. Add `IGameState` + `IEvents` to abstractions +17. `WorldGameState` + `WorldEvents` + `AppPluginHost` wire-up +18. Extend `SmokePlugin` to subscribe to `EntitySpawned` — end-to-end smoke + +**Ordering rationale:** +- Pure CPU (1-6) first so TDD works without GL +- Static meshes (7-10) before terrain textures (11) because they're the bigger visual reward +- Neighbors (12) after single-landblock works — isolates the neighbor logic +- Camera (13-15) late because independent and doesn't block visual work +- Plugin API (16-18) last — discipline requires it, touches less than the rest + +## 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. +- 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. +- Collision, animation, hot reload of plugins — all Phase 3+.