feat: actors stage 1

This commit is contained in:
Dan Finch 2026-08-07 16:58:24 +02:00
parent bd88d3cbd6
commit 4869db01e5
5 changed files with 112 additions and 60 deletions

View file

@ -116,13 +116,15 @@ rules live in `.agents/rules/*.md`.
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs `TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`) and the roaming mobs
scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move, scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move,
so they carry no baked colliders), then `buildChunks` bakes so they carry no baked colliders), then `buildChunks` bakes
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a
per-texture meshes grass/bark/birchBark/leaf/needle/rock/flowers + a tight AABB) that tight AABB + two `DrawGroup[]` lists, `near`/`far`, where a `DrawGroup` is a baked
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own mesh + its `Material` = texture + cull; the renderer just loops them and knows no
(drawn double-sided). Trees + boulders are baked **twice** — full geometry and content by name) that `main` frustum-culls; bushes fold into the leaf mesh,
a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders' flowers get their own (double-sided) group. Trees + boulders are baked **twice**
`lod` arg) — so a far chunk can swap to the cheap set with no per-frame work full geometry into `near` and a low-poly impostor into `far` (via the builders'
(see `chunkFar` / `RenderConfig.lodDistance`). `Aabb` colliders (walls, crate, grown trunks, big `lod` arg) — so a far chunk swaps to the cheap group set with no per-frame work
(see `chunkFar` / `RenderConfig.lodDistance`). `buildLevel(textures)` binds the
ground/prop materials once and shares them across chunks. `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 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 stays clean over the terrain skirt that laps under the room edges. Room surfaces
are single flat quads -- no subdivision needed since texturing is are single flat quads -- no subdivision needed since texturing is
@ -164,11 +166,12 @@ whole window (cost grew with window size); present is now ~0.2ms.
`renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at `renderBand` runs `renderScene.renderBand` for rows [y0,y1): `Sky.render` at
1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw` 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) → `Rasterizer.draw`
floor/walls/crate (room, always) → for each visible `Chunk` draw grass, then — floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups —
per chunk via the pure `chunkFar` test (dist² from camera to the chunk AABB vs `near` or `far` chosen by the pure `chunkFar` test (dist² from camera to the chunk
`lodDistance²`) — either the full rock/bark/leaf/needle + flowers, or the cheap AABB vs `lodDistance²`): `near` is grass + full trees/rocks + flowers, `far` is grass
`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped). + the cheap impostors (foliage/flowers dropped). Each group draws with its own
All backface-culled except double-sided flowers → `Sprite.billboard(npc)` `Material` (cull per-material, so solids backface-cull and flowers stay double-sided)
`Sprite.billboard(npc)`
the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix, the roaming mobs (each: shared local mesh × its `Mat4.compose` model matrix,
double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config double-sided) → `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
only), so every worker band picks the same LOD for a chunk → no horizontal seam. only), so every worker band picks the same LOD for a chunk → no horizontal seam.

View file

@ -1,4 +1,5 @@
import { Color } from "../engine/render/Color" import { Color } from "../engine/render/Color"
import type { DrawGroup, Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh" import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { Boulder } from "../engine/scene/Boulder" import { Boulder } from "../engine/scene/Boulder"
@ -7,6 +8,7 @@ import { Flower, type FlowerColor } from "../engine/scene/Flower"
import type { Mob, MobKind } from "../engine/scene/Mob" import type { Mob, MobKind } from "../engine/scene/Mob"
import { Terrain } from "../engine/scene/Terrain" import { Terrain } from "../engine/scene/Terrain"
import { Tree } from "../engine/scene/Tree" import { Tree } from "../engine/scene/Tree"
import type { Textures } from "./assets"
type Corner = [number, number, number] type Corner = [number, number, number]
@ -22,10 +24,11 @@ export type Aabb = {
} }
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders /** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
* standing in it, split by texture, plus an axis-aligned bounding box (tight to * standing in it, baked into `DrawGroup`s (mesh + material), plus an axis-aligned
* the actual geometry, so overhanging canopies aren't clipped). The renderer * bounding box (tight to the actual geometry, so overhanging canopies aren't
* frustum-tests the box and skips the whole cell when it is off-screen -- this * clipped). The renderer frustum-tests the box and skips the whole cell when it
* is what keeps a big, dense world affordable. Empty cells are never created. */ * is off-screen -- this is what keeps a big, dense world affordable. Empty cells
* are never created; empty groups are pruned at bake time. */
export type Chunk = { export type Chunk = {
minX: number minX: number
minY: number minY: number
@ -33,24 +36,23 @@ export type Chunk = {
maxX: number maxX: number
maxY: number maxY: number
maxZ: number maxZ: number
grass: Mesh /** Full-detail draw groups (grass + full trees/boulders), used up close. */
bark: Mesh near: DrawGroup[]
/** Birch trunks (white bark texture) -- separate mesh since a draw is one mesh + /** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers),
* one texture, and silver birch bark can't share the brown oak/spruce bark. */ * used once the chunk is past `config.lodDistance` (see `chunkFar`). */
birchBark: Mesh far: DrawGroup[]
leaf: Mesh }
needle: Mesh
rock: Mesh /** The materials the chunk baker binds its meshes to -- one per ground/prop
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */ * texture. Built once from the loaded `Textures`, shared across every chunk. */
flowers: Mesh type ChunkMaterials = {
/** Cheap low-poly impostors of the same trees/boulders, drawn instead of the grass: Material
* full meshes once the chunk is past `config.lodDistance` (renderScene). Same bark: Material
* textures as bark/birch/leaf/needle/rock. Bushes/flowers have no far version. */ birch: Material
barkFar: Mesh leaf: Material
birchBarkFar: Mesh needle: Material
leafFar: Mesh rock: Material
needleFar: Mesh flower: Material
rockFar: Mesh
} }
/** The playground: a flat-floored room dropped into the center of a big open /** The playground: a flat-floored room dropped into the center of a big open
@ -162,7 +164,7 @@ export const fancyCumulus: CloudLayer = {
relief: 7, relief: 7,
} }
export function buildLevel(): Level { export function buildLevel(textures: Textures): Level {
// Flat room floor, lifted a hair above the terrain's clearing (y 0). The // Flat room floor, lifted a hair above the terrain's clearing (y 0). The
// outdoor grid's cells straddle the room boundary and lap under the floor's // outdoor grid's cells straddle the room boundary and lap under the floor's
// edges; this small z-bias keeps the flat stone floor winning the depth test // edges; this small z-bias keeps the flat stone floor winning the depth test
@ -209,13 +211,26 @@ export function buildLevel(): Level {
const npcPosition = { x: 2, y: 0, z: -1 } const npcPosition = { x: 2, y: 0, z: -1 }
// The ground/prop materials the chunk baker draws with (grass + trees + rocks +
// flowers). Solid surfaces backface-cull; flowers are double-sided. Shared by
// every chunk, so cloning to a worker dedups them.
const materials: ChunkMaterials = {
grass: { texture: textures.grass, cull: true },
bark: { texture: textures.bark, cull: true },
birch: { texture: textures.birch, cull: true },
leaf: { texture: textures.leaf, cull: true },
needle: { texture: textures.needle, cull: true },
rock: { texture: textures.rock, cull: true },
flower: { texture: textures.flower, cull: false },
}
// Place the props (also pushes their colliders), then bake everything into // Place the props (also pushes their colliders), then bake everything into
// frustum-cullable spatial chunks. // frustum-cullable spatial chunks.
const trees = placeTrees(colliders) const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders) const boulders = placeBoulders(colliders)
const bushes = placeBushes() const bushes = placeBushes()
const flowers = placeFlowers() const flowers = placeFlowers()
const chunks = buildChunks(trees, boulders, bushes, flowers) const chunks = buildChunks(materials, trees, boulders, bushes, flowers)
const mobs = placeMobs() const mobs = placeMobs()
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky } return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky }
@ -225,7 +240,7 @@ export function buildLevel(): Level {
* chunks. Each prop lands in the cell holding its base; the cell's bounds are * chunks. Each prop lands in the cell holding its base; the cell's bounds are
* grown to the real geometry so overhanging canopies never get culled early. * grown to the real geometry so overhanging canopies never get culled early.
* Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */ * Bushes share the oak leaf mesh; flowers get their own (double-sided) mesh. */
function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] { function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers: Flower[]): Chunk[] {
const cell = (TERRAIN.outer * 2) / CHUNK_GRID const cell = (TERRAIN.outer * 2) / CHUNK_GRID
const chunks: Chunk[] = [] const chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) { for (let ci = 0; ci < CHUNK_GRID; ci++) {
@ -280,7 +295,28 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
if (b === null) { if (b === null) {
continue continue
} }
chunks.push({ ...b, grass, bark, birchBark, leaf, needle, rock, flowers: flowerMesh, barkFar, birchBarkFar, leafFar, needleFar, rockFar }) // Same draws as before, just described as data. Order is preserved (it
// matches the old fixed sequence): grass, then the solid props, then the
// double-sided flowers. Empty meshes are pruned so a chunk only carries the
// groups it actually has.
const near = drawGroups([
[grass, m.grass],
[rock, m.rock],
[bark, m.bark],
[birchBark, m.birch],
[leaf, m.leaf],
[needle, m.needle],
[flowerMesh, m.flower],
])
const far = drawGroups([
[grass, m.grass],
[rockFar, m.rock],
[barkFar, m.bark],
[birchBarkFar, m.birch],
[leafFar, m.leaf],
[needleFar, m.needle],
])
chunks.push({ ...b, near, far })
} }
} }
return chunks return chunks
@ -290,6 +326,18 @@ function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number,
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1 return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
} }
/** Pair meshes with their materials into a draw-group list, dropping any mesh
* that ended up empty (a cell rarely holds every prop kind). */
function drawGroups(pairs: [Mesh, Material][]): DrawGroup[] {
const out: DrawGroup[] = []
for (const [m, material] of pairs) {
if (m.indices.length > 0) {
out.push({ mesh: m, material })
}
}
return out
}
/** Tight AABB over several meshes' vertices, or null if they are all empty. */ /** Tight AABB over several meshes' vertices, or null if they are all empty. */
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null { function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
let minX = Infinity let minX = Infinity

View file

@ -47,7 +47,7 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
async function main(): Promise<void> { async function main(): Promise<void> {
const textures = await loadTextures() const textures = await loadTextures()
const level = buildLevel() const level = buildLevel(textures)
// Two canonical mob meshes, built once and shared by every instance (the sim // Two canonical mob meshes, built once and shared by every instance (the sim
// supplies each mob's per-frame transform). // supplies each mob's per-frame transform).
const frogMesh: Mesh = { verts: [], indices: [] } const frogMesh: Mesh = { verts: [], indices: [] }
@ -161,11 +161,10 @@ async function main(): Promise<void> {
let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length let t = level.floor.indices.length + level.walls.indices.length + level.crate.indices.length
for (const i of visible) { for (const i of visible) {
const c = level.chunks[i] const c = level.chunks[i]
t += c.grass.indices.length + c.flowers.indices.length const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
const far = chunkFar(c, cam.position, config.lodDistance) for (const g of groups) {
t += far t += g.mesh.indices.length
? c.barkFar.indices.length + c.birchBarkFar.indices.length + c.leafFar.indices.length + c.needleFar.indices.length + c.rockFar.indices.length }
: c.bark.indices.length + c.birchBark.indices.length + c.leaf.indices.length + c.needle.indices.length + c.rock.indices.length
} }
return (t / 3) | 0 return (t / 3) | 0
} }

View file

@ -101,23 +101,13 @@ export function renderBand(
Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1) Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1)
for (const i of visible) { for (const i of visible) {
const c = scene.chunks[i] const c = scene.chunks[i]
Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1) // Past lodDistance, draw the cheap impostor group set instead of full detail.
// Past lodDistance, swap full tree/boulder geometry for cheap impostors.
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band // `chunkFar` is pure (camera + chunk bounds + config), so every worker band
// makes the identical choice -- no full/impostor seam across bands. // makes the identical choice -- no full/impostor seam across bands. The loop
if (chunkFar(c, camera.position, config.lodDistance)) { // is content-agnostic: each group carries its own mesh + material.
Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1) const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near
Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1) for (const g of groups) {
Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, y0, y1) Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1)
Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1)
} else {
Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.birchBark, tx.birch, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1)
} }
} }
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc } const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }

12
engine/render/Material.ts Normal file
View file

@ -0,0 +1,12 @@
import type { Texture } from "./Texture"
import type { Mesh } from "../scene/Mesh"
/** How a surface is drawn: which texture, and whether back-faces are culled.
* (Alpha-cutout is global in the `Rasterizer`.) A `Material` is shared by every
* draw that uses it, so the renderer no longer needs to know content by name. */
export type Material = { texture: Texture; cull: boolean }
/** A baked mesh paired with the material it draws with. A chunk (or any baked
* batch) is just a list of these, so adding a new surface/texture is data, not a
* new branch in the render loop. */
export type DrawGroup = { mesh: Mesh; material: Material }