feat: culling
This commit is contained in:
parent
680e08aadc
commit
ef5029da1d
7 changed files with 309 additions and 103 deletions
83
AGENTS.md
83
AGENTS.md
|
|
@ -20,6 +20,11 @@ rules live in `.agents/rules/*.md`.
|
|||
- **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/`.
|
||||
|
|
@ -49,13 +54,15 @@ rules live in `.agents/rules/*.md`.
|
|||
- `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`,
|
||||
`Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun +
|
||||
procedural clouds).
|
||||
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), `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;
|
||||
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`
|
||||
|
|
@ -66,18 +73,17 @@ rules live in `.agents/rules/*.md`.
|
|||
- `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) in the center of a big grassy `Terrain`
|
||||
world (~20x across).
|
||||
Per-texture meshes incl. the outdoor grass `ground`, a scattered forest
|
||||
(`scatterTrees` → `trunks`/`oakFoliage`/`spruceFoliage`, `TREE_COUNT`/`_SEED`/
|
||||
`_REACH`) and `boulders` (`scatterBoulders`, `BOULDER_COUNT`/`_SEED`/`_REACH`),
|
||||
`Aabb` colliders (incl. grown-tree trunks + big boulders), 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).
|
||||
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
|
||||
|
|
@ -95,17 +101,21 @@ rules live in `.agents/rules/*.md`.
|
|||
## Frame pipeline (`app/main.ts` `frame`)
|
||||
|
||||
`Player.update` → build `Camera` → `Camera.viewProjection` →
|
||||
`Sky.render` (fills color + resets depth, replaces a clear) →
|
||||
`Rasterizer.draw` ground, floor, walls, crate, boulders, tree trunks, oak
|
||||
foliage, spruce foliage (one call per texture) →
|
||||
`Sprite.billboard(npc)` drawn via `Rasterizer.draw` →
|
||||
`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),
|
||||
**double-sided** (no backface culling).
|
||||
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
|
||||
|
||||
|
|
@ -118,14 +128,27 @@ distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
|
|||
- **`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).
|
||||
- **`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 three 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.
|
||||
|
||||
Together ~1.5–2x over drawing everything full-res every frame, and the win grows
|
||||
with content since culled chunks cost ~nothing. Next levers if needed: LOD /
|
||||
impostors for far trees, flat typed-array geometry (kill per-tri allocation),
|
||||
Web-Worker banded rasterization. `TREE_COUNT`/`BOULDER_COUNT` are the blunt
|
||||
content dials.
|
||||
|
||||
## Clouds
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue