feat: terrain

This commit is contained in:
Dan Finch 2026-08-04 12:51:07 +02:00
parent f86b4ada13
commit 81474b6bca
13 changed files with 251 additions and 279 deletions

View file

@ -12,9 +12,11 @@ rules live in `.agents/rules/*.md`.
- **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 *is* rasterizer artifacts
(affine texture swim, vertex snap, no mipmaps). Raytracing was considered and
cut (it removes the very artifacts we want).
- **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.
@ -50,21 +52,32 @@ rules live in `.agents/rules/*.md`.
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`,
`Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun +
procedural clouds).
- `scene/``Camera` (fps yaw/pitch), `Mesh` (indexed tris), `Sprite`
(Y-axis billboard).
- `scene/``Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
`Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural
heightfield around the room: flat clearing in the center, rolling hills, tall
edge peaks. `Terrain.ground` builds the outdoor mesh with a hole for the room;
`Terrain.height` is the shared ground-height sampler for the player).
- `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: per-texture meshes, `Aabb` colliders,
NPC position, sky/cloud config. Geometry density knob `DIVISIONS_PER_UNIT`.
- `level.ts` — builds the playground: a flat stone-floored room (three walls,
north side open) in the center of a big grassy `Terrain` world (~20x across).
Per-texture meshes incl. the outdoor grass `ground`, `Aabb` colliders, NPC
position, `Terrain` config + `GROUND_DIVISIONS`/`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; ground
triangle count is `GROUND_DIVISIONS` (fixed grid, so world size sets cell
chunkiness, not tri count).
- `player.ts` — feet-cylinder player: gravity/jump + circle-vs-AABB/-circle
collision.
collision; ground height comes 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/wall/crate/npc` PNGs. Swap for real art anytime;
- `assets/` — generated `floor/grass/wall/crate/npc` PNGs (`floor` = room stone,
`grass` = outdoor ground). Swap for real art anytime;
filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot.
@ -72,15 +85,15 @@ rules live in `.agents/rules/*.md`.
`Player.update` → build `Camera``Camera.viewProjection`
`Sky.render` (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` floor, walls, crate (one call per texture) →
`Rasterizer.draw` ground, floor, walls, crate (one call per texture) →
`Sprite.billboard(npc)` drawn via `Rasterizer.draw`
`Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`).
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
affine↔perspective-correct UV blend (`perspectiveCorrect`), screen-space
vertex snap, flat directional lighting, distance fog, **alpha cutout** (discard
texel alpha < 128, for sprites), **double-sided** (no backface culling).
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
**double-sided** (no backface culling).
## The look — where to tune
@ -88,13 +101,19 @@ texel alpha < 128, for sprites), **double-sided** (no backface culling).
`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`, `perspectiveCorrect` (0 = full affine swim, 1 = correct),
`textureFilter`, `lighting`, `fog`.
- **`app/level.ts` `DIVISIONS_PER_UNIT`** (1.3) — geometry triangle density per
world unit. Higher = smaller triangles = less affine swim; **below ~0.5 the
floor degenerates into a black wedge** (affine sampling collapses onto the
texture's dark grout lines). Works with `perspectiveCorrect` — both fight the
same affine error from different sides.
`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` `GROUND_DIVISIONS`** (56) — outdoor ground grid resolution and
the **main outdoor FPS lever**. The open vista is **transform-bound** on the
ground's triangles (no frustum culling — every tri is projected each frame), so
cost is ~linear in this: measured ~45 fps at 48, ~28 fps at 64, ~24 fps at 96
(headless, `standard`). Lower it for FPS, raise for finer terrain. Draw distance
(`fog.far` + `Camera` far plane, pushed out to ~200/260 for this scene) is
comparatively cheap since far ground is a thin horizon band. Cranking
`TERRAIN.peakHeight`/`outer` costs almost nothing (same tri count).
## Clouds
@ -112,7 +131,8 @@ branching in the cloud shader. Cost scales with sky resolution — fine at
## Controls
WASD move · mouse look (click canvas to pointer-lock) · **Space** jump ·
**1/2/3** switch look presets. FPS shown bottom-right.
**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)
@ -134,9 +154,9 @@ 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 diagnosed the affine black-wedge
(affine sampled grout, perspective-correct sampled stone) and tuned the clouds.
Put temp scripts in the job tmp dir, not the repo.
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