add CLAUDE.md describing the WebGL renderer architecture

This commit is contained in:
evanpelle
2026-05-18 10:07:19 -07:00
parent 4936ae3d59
commit f7dabe6a98
+155
View File
@@ -0,0 +1,155 @@
# CLAUDE.md — `src/client/render/`
WebGL2 renderer for the game map. Everything that draws onto the map canvas
lives here. HUD components (Lit elements, DOM overlays) live in
`src/client/graphics/`, not here.
## Pipeline
```
simulation tick (worker)
GameView.update(gu) ← client-side mirror (../view/GameView.ts)
│ builds long-lived FrameData object
WebGLFrameBuilder.update ← syncs palette, local-player ID, spawn
│ overlay; then uploads FrameData
uploadFrameData(view, frame) ← frame/Upload.ts — dispatches to view.update*()
GameView.update*() methods ← gl/GameView.ts — public facade
GPURenderer (gl/Renderer.ts) ← owns all passes
▼ per-frame RAF (driven from ClientGameRunner.driveFrame)
each Pass.draw(cameraMatrix) ← writes to the screen / FBO chain
```
The simulation runs at ~10Hz on a worker thread. The renderer draws at 60fps.
FrameData is built once per tick and mutated in place; passes read from it
each frame (and animate from local time, e.g. the spawn-overlay breath).
## Directory map
| Path | Purpose |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GameConstants.ts` | Top-level constants shared across passes (`MS_PER_TICK`, nuke radii, etc.) |
| `types/` | Shared TS interfaces: `FrameData`, `UnitState`, `PlayerState`, `RendererConfig`, pass-input shapes (`GhostPreviewData`, `NukeTrajectoryData`, `SpawnCenter`, …) |
| `frame/` | Frame-data accumulators + per-tick derivations (CPU-side, no GL) |
| `frame/derive/` | Pure derivations that turn raw simulation state into renderer-ready shapes (attack rings, alliance clusters, relation matrix, player status, nuke telegraphs) |
| `frame/Upload.ts` | `uploadFrameData(view, frame)` — single dispatch point that calls every `view.update*()` based on what's in the frame |
| `frame/TrailManager.ts` | Mutates the per-tile trail texture; emits dirty row range |
| `frame/RailroadCache.ts` | Maintains the railroad tile state buffer |
| `gl/` | WebGL2 renderer internals |
| `gl/GameView.ts` | Public facade — what `WebGLFrameBuilder` and the client talk to |
| `gl/Renderer.ts` | Owns all passes, runs them in order each frame, manages FBOs |
| `gl/Camera.ts` | World↔screen math; mutated externally each frame via `setCameraState` |
| `gl/RenderSettings.ts` | Typed view of `render-settings.json` (tuning knobs) |
| `gl/render-settings.json` | All per-pass tuning constants (alpha, radii, colors, etc.) |
| `gl/passes/` | One file per pass — see "Pass conventions" below |
| `gl/utils/` | Cross-pass helpers: `GlUtils` (program/shader compile), `TileCodec` (`OWNER_MASK` etc.), `NukeTrajectory` (Bezier math), `Affiliation`, `HeatManager`, `GpuResources` |
| `gl/shaders/` | `.glsl` source files (`?raw` imported by passes) |
| `gl/debug/` | Tweakpane-style debug GUI (`createDebugGui`) — live render-settings editor |
## Pass conventions
Each pass is a class that owns:
- A compiled `WebGLProgram` (+ uniform locations cached at construct time)
- Any VAOs / instance buffers it draws from
- Its slice of `RenderSettings` (passed in at construct time)
A typical pass exposes:
- `update(...)` or `set*()` — called externally to push per-tick or per-event
state (e.g. `setHighlightOwner`, `updateGhostPreview`, `applyLiveDelta`)
- `draw(cameraMatrix, zoom?, …)` — called every frame from `GPURenderer.render`
- `dispose()` — clean up GL resources
Passes never read DOM events or game state directly — they're pure consumers
of data pushed in via setters. The renderer composes them; the **client**
decides what to push.
## How the client pushes data
The WebGL view is meant to be input-pushed, not state-pulled. All wiring lives
in two places:
- **`WebGLFrameBuilder.update(gameView)`** runs each simulation tick. It
syncs:
- Palette entries for any newly-seen players
- `view.setLocalPlayerID(smallID)` when myPlayer is resolved
- `view.updateSpawnOverlay(inSpawnPhase, centers)`
- then `uploadFrameData(view, frameData)` for everything else
- **Controllers in `../controllers/`** push view state in response to
EventBus events (mouse / keyboard). Examples:
- `BuildPreviewController``view.updateGhostPreview`,
`view.updateNukeTrajectory`
- `WarshipSelectionController``view.setSelectedUnits`
- `HoverHighlightController``view.setHighlightOwner`
If a renderer feature isn't appearing in game, the usual cause is "the pass
is wired but no one's pushing data to it" — check `WebGLFrameBuilder` first,
then the controllers, then `ClientGameRunner` (alt-view toggle,
day/night-mode wiring).
## Camera + input
- The renderer has its own `Camera` but does **not** own input. Camera state
is pushed in each frame from `TransformHandler` (in `src/client/`) via
`view.setCameraState(x, y, z)`.
- Input events all flow through `InputHandler` (binds to a transparent
`inputOverlay` div above the GL canvas) → EventBus → controllers / HUD.
The WebGL canvas itself has `pointer-events: none`.
## FrameData contract
`FrameData` (in `types/`) is a **single long-lived object** on `GameView`.
Most fields are mutable references to long-lived buffers (`tileState`,
`trailState`, `railroadState`); some (`changedTiles`, derived arrays) are
reused per tick. The `readonly` modifier in the type is API hygiene — it
doesn't prevent mutation through the reference.
Live mode upload semantics (in `frame/Upload.ts`):
- `changedTiles = null` → "no delta info, full upload" (first tick)
- `changedTiles.length > 0` → "only these tiles changed, sub-upload dirty rows"
- `changedTiles.length === 0` → "nothing changed, skip"
`tileState` is drip-applied per render frame (see `gameView.drainPendingTileUpdates`
in `view/GameView.ts`) so big territory changes don't teleport in one chunk
each tick — they spread across the ~6 render frames between ticks.
## Asset pipeline
Sprite atlases live in `resources/atlases/` and are loaded via `assetUrl()`
in each pass (set `img.crossOrigin = "anonymous"` before `img.src` so the
WebGL texture upload doesn't get blocked cross-origin). Atlas metadata
JSONs are imported as TS modules (`resources/atlases/foo-meta.json`) and
bundled.
## Render settings
`render-settings.json` is the single source of truth for all per-pass tuning
constants. Passes read their slice (`settings.spawnOverlay`, `settings.bar`,
etc.) at construct time and use it in `draw`. The debug GUI in `gl/debug/`
gives a live-editable view of the same object during development.
## Adding a new pass
1. Define any new types in `types/` if the pass needs new input shapes.
2. Add the pass class in `gl/passes/`. Follow the existing structure:
uniform-location caching in the constructor, an `update`/`set*` API, a
`draw(cameraMatrix, …)` method, and `dispose`.
3. Add its settings struct to `RenderSettings` in `gl/RenderSettings.ts` and
defaults to `render-settings.json`.
4. Instantiate it in `GPURenderer`'s constructor and call its `draw` from the
appropriate phase of `Renderer.render`.
5. Expose any needed setters on `GameView` (gl/GameView.ts).
6. Wire the data push from `WebGLFrameBuilder` or a controller — without
this step the pass is dead code.