231 lines
13 KiB
Markdown
231 lines
13 KiB
Markdown
# meat
|
||
|
||
A from-scratch browser **first-person-shooter game engine** with a configurable
|
||
**PS1 aesthetic**. Everything is hand-written: a **software rasterizer** draws
|
||
textured triangles into a low-res CPU framebuffer, then Canvas2D blits it
|
||
upscaled. No WebGL/WebGPU, no game-engine dependencies.
|
||
|
||
This file is the durable project brief (checked in, auto-loaded). The per-topic
|
||
rules live in `.agents/rules/*.md`.
|
||
|
||
## Guiding decisions (the "why", not derivable from code)
|
||
|
||
- **PS1 look is a set of live knobs**, dial-able from full-PS1 to clean. See
|
||
`RenderConfig`. Nothing about the era is hardcoded into the renderer.
|
||
- **Software rasterizer on purpose** — the PS1 look leans on rasterizer traits
|
||
(vertex snap, low-res + dither/banding, no mipmaps). Raytracing was considered
|
||
and cut (it removes the very artifacts we want). Note: **affine texture "swim"
|
||
was dropped** — texturing is always perspective-correct now (it warped badly on
|
||
the big outdoor terrain); the other era knobs stay.
|
||
- **Minimal architecture.** Scene-graph-lite / plain data + functions.
|
||
Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest
|
||
clear structure; add knobs to experiment rather than abstractions.
|
||
- **Culling beats batching here.** A "draw call" is just a JS loop (no GPU state),
|
||
so merging the world into big meshes only defeats visibility skipping. Instead
|
||
the outdoor world is stored as spatial **chunks** that are frustum-culled per
|
||
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
|
||
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
|
||
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
|
||
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
|
||
all browser glue (canvas, input, image decode) lives in `app/`.
|
||
|
||
## Stack & tooling
|
||
|
||
- **Bun** runtime + `bun test`. **TypeScript 7** (native `tsc`), strict,
|
||
`moduleResolution: bundler`. **Vite 8** serves/builds the client.
|
||
- **oxc** toolchain: `oxlint` + `oxfmt` (no eslint/prettier).
|
||
- commitlint + husky (Conventional Commits), OpenSpec change workflow,
|
||
forge-sync (Forgejo). Templates generated by `regime` from a `sigitex:` source.
|
||
|
||
## Commands
|
||
|
||
- `bun start` — Vite dev server; open the printed URL. Edits hot-reload.
|
||
- `bun run build` — production build to `dist/`.
|
||
- `bun run assets` — regenerate the placeholder PNGs in `/assets`.
|
||
- `bunx tsc --build tsconfig.app.json` — **typecheck the app+engine graph. Use
|
||
this**, not `bun run check` (see Caveats).
|
||
- `bunx oxlint engine app` — lint.
|
||
- `bun test` — tests (none yet).
|
||
- `bun run serve` — Bun server (`server/server.ts`, a stub for now).
|
||
|
||
## Layout
|
||
|
||
- `engine/` — headless engine, consumed by `app/` via tsconfig project ref.
|
||
- `math/` — `Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
|
||
- `render/` — `Color` (packed RGBA, little-endian = canvas ImageData order),
|
||
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
|
||
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
|
||
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
|
||
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
|
||
mipmaps), `Sky` (gradient + sun + procedural clouds; renders at 1/`step` res).
|
||
- `scene/` — `Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
|
||
`Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex,
|
||
no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite`
|
||
(Y-axis billboard), `Terrain` (procedural
|
||
heightfield around the room: flat clearing in the center, rolling hills, tall
|
||
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
|
||
per chunk, aligned so patches weld crack-free, with a hole for the room;
|
||
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
|
||
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
|
||
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
|
||
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
||
`Boulder.build` appends into a shared mesh).
|
||
- `app/` — browser glue.
|
||
- `main.ts` — game loop, input, preset switching, canvas blit, FPS meter.
|
||
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
||
are already the `Color` layout).
|
||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
||
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
||
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
||
`placeBoulders` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/
|
||
`_REACH`), then `buildChunks` bakes terrain + props into a `CHUNK_GRID` x
|
||
`CHUNK_GRID` grid of `Chunk`s (each = per-texture meshes grass/bark/leaf/needle/
|
||
rock + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate,
|
||
grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
|
||
sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
|
||
stays clean over the terrain skirt that laps under the room edges. Room surfaces
|
||
are single flat quads -- no subdivision needed since texturing is
|
||
perspective-correct.
|
||
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run
|
||
(`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast
|
||
running can't tunnel walls; ground height from `Terrain.height` (plus
|
||
standable AABBs).
|
||
- `index.html` — Vite entry at repo root; holds the `#screen` canvas and the
|
||
`#fps` meter div (styled inline).
|
||
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
||
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
|
||
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/wall/crate/npc` PNGs
|
||
(`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` = tree
|
||
trunk/oak/spruce, `rock` = boulders). Swap for real art anytime;
|
||
filenames are the contract.
|
||
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
||
|
||
## Frame pipeline (`app/main.ts` `frame`)
|
||
|
||
`Player.update` → build `Camera` → `Camera.viewProjection` →
|
||
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
|
||
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
|
||
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
|
||
bark, leaf, needle (backface-culled) → `Sprite.billboard(npc)` (double-sided) →
|
||
`Framebuffer.quantize` → `present` (integer-scale, letterboxed blit;
|
||
`imageSmoothingEnabled` follows `upscaleFilter`).
|
||
|
||
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
|
||
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
|
||
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites).
|
||
**Backface culling is opt-in** (`draw(..., cull)`, default off = double-sided):
|
||
on for solid world chunks, off for sprites and the room. It relies on winding, so
|
||
generators feeding culled draws (terrain patch, tree/boulder builders) must wind
|
||
front-out — a culled mesh that renders inside-out has its index order flipped
|
||
(see `Terrain.patch`). The cull sign: back-facing == positive screen area here.
|
||
|
||
## The look — where to tune
|
||
|
||
- **`engine/render/RenderConfig.ts`** — per-frame render dials + presets:
|
||
`standard` (384×216, the startup default), `soft`, `clean`, plus an unbound
|
||
`ps1` (320×240). In-app keys **1/2/3** switch standard/soft/clean live.
|
||
Knobs: `internalWidth/Height`, `upscaleFilter`, `colorDepth`, `dither`,
|
||
`vertexSnap`, `textureFilter`, `lighting`, `fog`. (Texturing is always
|
||
perspective-correct — the affine-swim dial was removed.)
|
||
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
|
||
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
|
||
(there are no mipmaps); higher = finer but shimmerier.
|
||
- **`app/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
|
||
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
|
||
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
|
||
per-cell tests/bounds. This is the lever if a dense world still lags.
|
||
|
||
## Performance / where the frame goes
|
||
|
||
The world is dense (hundreds of trees + boulders, ~50k tris) but most of it is
|
||
off-screen or fogged each frame, so several things keep it cheap:
|
||
- **Frustum culling** (`Frustum` + per-`Chunk` AABB test in `main`) — skips whole
|
||
chunks that fall outside the view. Behind you + off to the sides = free.
|
||
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
|
||
(terrain, foliage, rock). See the Rasterizer note re winding.
|
||
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
|
||
pixel and dominated the frame; sampling once per 2×2 block quarters it.
|
||
- **Flat geometry + zero-alloc raster** — `Mesh` is a flat float array and the
|
||
whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes
|
||
(measured). This buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x)
|
||
and makes geometry shareable for Web-Worker rasterization later. Note it did
|
||
**not** raise mean fps — allocation was never the bottleneck (JSC collects the
|
||
churn ~free); the mean is the transform+fill **compute**.
|
||
|
||
Frustum + backface + half-res sky give ~1.5–2x, growing with content since culled
|
||
chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so
|
||
the mean-fps levers left are to **do less** (LOD / impostors for far trees —
|
||
`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores**
|
||
(Web-Worker banded rasterization, now unblocked by the flat geometry).
|
||
|
||
## Clouds
|
||
|
||
Procedural, moving, in the sky pass (`engine/render/Sky.ts`). Two styles picked
|
||
by a `CloudLayer` discriminated union `kind`:
|
||
- **`basicCumulus`** — flat hard-thresholded white puffs, 1 noise lookup/pixel.
|
||
- **`fancyCumulus`** — domain-warped + heightfield-shaded fake volume,
|
||
~5 lookups/pixel (pricier; watch the FPS meter).
|
||
|
||
Both are exported presets in `app/level.ts`; the active one is set in
|
||
`buildLevel`'s `sky.clouds`. Add new cloud types by extending the union and
|
||
branching in the cloud shader. Cost scales with sky resolution — fine at
|
||
`standard`, heavy at `clean` (mitigate: fewer fbm octaves or half-res sky).
|
||
|
||
## Trees (`engine/scene/Tree.ts`)
|
||
|
||
Procedural low-poly geometry, faceted flat-shaded like everything else. Two
|
||
`kind`s carry the species read purely by silhouette:
|
||
- **`oak`** — short tapered trunk, a couple of branches, a broad cluster of
|
||
lumpy canopy `blob`s (wider than tall, bushy).
|
||
- **`spruce`** — tall thin trunk under stacked narrowing `cone` tiers pointing
|
||
to a tip (taller than wide, conical).
|
||
|
||
`growth` (0..1) runs **sapling → full grown**: it scales height/girth and adds
|
||
canopy blobs (oak) / tiers (spruce); `seed` gives each tree its own wobble.
|
||
`Tree.build` appends into caller meshes so a forest batches into 3 draw calls
|
||
(one bark trunk mesh, oak-leaf and spruce-needle foliage meshes). `app/level.ts`
|
||
`scatterTrees` seeds the forest; add a species by extending the union + a builder.
|
||
|
||
**Boulders** (`engine/scene/Boulder.ts`) work the same way: `Boulder.build`
|
||
appends a squashed, per-vertex-jittered low-poly sphere (seam/pole-safe so it
|
||
never cracks) into one shared rock mesh, sunk partway into the ground.
|
||
`scatterBoulders` sizes them small→big (biased small) and drops colliders on the
|
||
big ones.
|
||
|
||
## Controls
|
||
|
||
WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse
|
||
look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look
|
||
presets. FPS shown bottom-right. The room's north wall is open — walk out onto
|
||
the terrain and toward the peaks.
|
||
|
||
## Code style (oxlint-enforced — match it)
|
||
|
||
No semicolons, double quotes, 2-space indent, trailing commas. **`type`, never
|
||
`interface`.** Function declarations (arrows allowed). Uppercase hex (`0xFF`).
|
||
Curly braces required on all `if`. No `void` operator. `T[]` not `Array<T>`.
|
||
Prefer `globalThis` over `window`. Domain behavior lives in a `type` +
|
||
matching `namespace` (see `Vec3`, `Color`, `Rasterizer`).
|
||
|
||
## Caveats
|
||
|
||
- No mipmaps, so distant textures shimmer (period-correct); far cloud/geometry
|
||
is hidden by fog.
|
||
|
||
## Debugging the renderer without a browser (key workflow)
|
||
|
||
The engine is pure, so you can **render headless to a PNG and inspect it**. A
|
||
throwaway `bun` script can: `buildLevel()`, decode `assets/*.png` (they're
|
||
filter-0 RGBA — trivial to inflate), set a `Camera` pose, run
|
||
`Sky.render` + `Rasterizer.draw` into a `Framebuffer`, encode the color buffer
|
||
to PNG (same hand-rolled encoder as `scripts/gen-assets.ts`), write it, and read
|
||
it back. Sampling individual pixels this way tuned the clouds, framed the terrain
|
||
peaks, and confirmed the ground textures flat (no swim). Put temp scripts in the
|
||
job tmp dir, not the repo.
|
||
|
||
## Roadmap / not yet built
|
||
|
||
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
|
||
lighting; more cloud types; more props / a weapon / moving enemies. `shared/`
|
||
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
|
||
DOM-built UI yet (deliberate).
|