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
scattered (`placeMobs`; `FROG_/BEE_COUNT`, `MOB_SEED`, `MOB_REACH` — mobs move,
so they carry no baked colliders), then `buildChunks` bakes
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each =
per-texture meshes grass/bark/birchBark/leaf/needle/rock/flowers + a tight AABB) that
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
(drawn double-sided). Trees + boulders are baked **twice** — full geometry and
a low-poly impostor (`barkFar/leafFar/needleFar/rockFar`, via the builders'
`lod` arg) — so a far chunk can swap to the cheap set with no per-frame work
(see `chunkFar` / `RenderConfig.lodDistance`). `Aabb` colliders (walls, crate, grown trunks, big
terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` grid of `Chunk`s (each = a
tight AABB + two `DrawGroup[]` lists, `near`/`far`, where a `DrawGroup` is a baked
mesh + its `Material` = texture + cull; the renderer just loops them and knows no
content by name) that `main` frustum-culls; bushes fold into the leaf mesh,
flowers get their own (double-sided) group. Trees + boulders are baked **twice**
full geometry into `near` and a low-poly impostor into `far` (via the builders'
`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
stays clean over the terrain skirt that laps under the room edges. Room surfaces
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
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 —
per chunk via the pure `chunkFar` test (dist² from camera to the chunk AABB vs
`lodDistance²`) — either the full rock/bark/leaf/needle + flowers, or the cheap
`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped).
All backface-culled except double-sided flowers → `Sprite.billboard(npc)`
floor/walls/crate (room, always) → for each visible `Chunk`, loop its draw-groups —
`near` or `far` chosen by the pure `chunkFar` test (dist² from camera to the chunk
AABB vs `lodDistance²`): `near` is grass + full trees/rocks + flowers, `far` is grass
+ the cheap impostors (foliage/flowers dropped). Each group draws with its own
`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,
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.

View file

@ -1,4 +1,5 @@
import { Color } from "../engine/render/Color"
import type { DrawGroup, Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { STRIDE, type Mesh } from "../engine/scene/Mesh"
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 { Terrain } from "../engine/scene/Terrain"
import { Tree } from "../engine/scene/Tree"
import type { Textures } from "./assets"
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
* standing in it, split by texture, plus an axis-aligned bounding box (tight to
* the actual geometry, so overhanging canopies aren't clipped). The renderer
* frustum-tests the box and skips the whole cell when it is off-screen -- this
* is what keeps a big, dense world affordable. Empty cells are never created. */
* standing in it, baked into `DrawGroup`s (mesh + material), plus an axis-aligned
* bounding box (tight to the actual geometry, so overhanging canopies aren't
* clipped). The renderer frustum-tests the box and skips the whole cell when it
* 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 = {
minX: number
minY: number
@ -33,24 +36,23 @@ export type Chunk = {
maxX: number
maxY: number
maxZ: number
grass: Mesh
bark: Mesh
/** Birch trunks (white bark texture) -- separate mesh since a draw is one mesh +
* one texture, and silver birch bark can't share the brown oak/spruce bark. */
birchBark: Mesh
leaf: Mesh
needle: Mesh
rock: Mesh
/** Flowers (color-atlas texture); drawn double-sided, so kept separate. */
flowers: Mesh
/** Cheap low-poly impostors of the same trees/boulders, drawn instead of the
* full meshes once the chunk is past `config.lodDistance` (renderScene). Same
* textures as bark/birch/leaf/needle/rock. Bushes/flowers have no far version. */
barkFar: Mesh
birchBarkFar: Mesh
leafFar: Mesh
needleFar: Mesh
rockFar: Mesh
/** Full-detail draw groups (grass + full trees/boulders), used up close. */
near: DrawGroup[]
/** LOD draw groups (grass + cheap tree/boulder impostors, no bushes/flowers),
* used once the chunk is past `config.lodDistance` (see `chunkFar`). */
far: DrawGroup[]
}
/** The materials the chunk baker binds its meshes to -- one per ground/prop
* texture. Built once from the loaded `Textures`, shared across every chunk. */
type ChunkMaterials = {
grass: Material
bark: Material
birch: Material
leaf: Material
needle: Material
rock: Material
flower: Material
}
/** The playground: a flat-floored room dropped into the center of a big open
@ -162,7 +164,7 @@ export const fancyCumulus: CloudLayer = {
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
// 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
@ -209,13 +211,26 @@ export function buildLevel(): Level {
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
// frustum-cullable spatial chunks.
const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders)
const bushes = placeBushes()
const flowers = placeFlowers()
const chunks = buildChunks(trees, boulders, bushes, flowers)
const chunks = buildChunks(materials, trees, boulders, bushes, flowers)
const mobs = placeMobs()
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
* 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. */
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 chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) {
@ -280,7 +295,28 @@ function buildChunks(trees: Tree[], boulders: Boulder[], bushes: Bush[], flowers
if (b === null) {
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
@ -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
}
/** 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. */
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
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> {
const textures = await loadTextures()
const level = buildLevel()
const level = buildLevel(textures)
// Two canonical mob meshes, built once and shared by every instance (the sim
// supplies each mob's per-frame transform).
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
for (const i of visible) {
const c = level.chunks[i]
t += c.grass.indices.length + c.flowers.indices.length
const far = chunkFar(c, cam.position, config.lodDistance)
t += far
? 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
const groups = chunkFar(c, cam.position, config.lodDistance) ? c.far : c.near
for (const g of groups) {
t += g.mesh.indices.length
}
}
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)
for (const i of visible) {
const c = scene.chunks[i]
Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1)
// Past lodDistance, swap full tree/boulder geometry for cheap impostors.
// Past lodDistance, draw the cheap impostor group set instead of full detail.
// `chunkFar` is pure (camera + chunk bounds + config), so every worker band
// makes the identical choice -- no full/impostor seam across bands.
if (chunkFar(c, camera.position, config.lodDistance)) {
Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1)
Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, 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)
// makes the identical choice -- no full/impostor seam across bands. The loop
// is content-agnostic: each group carries its own mesh + material.
const groups = chunkFar(c, camera.position, config.lodDistance) ? c.far : c.near
for (const g of groups) {
Rasterizer.draw(fb, g.mesh, g.material.texture, viewProj, config, g.material.cull, y0, y1)
}
}
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 }