feat: actors stage 2

This commit is contained in:
Dan Finch 2026-08-07 19:11:43 +02:00
parent 4869db01e5
commit 581e5892b0
19 changed files with 752 additions and 594 deletions

View file

@ -55,6 +55,14 @@ type ChunkMaterials = {
flower: Material
}
/** A chunk-material key (also the tag props reference, e.g. a tree's `trunk`). */
type MatKey = keyof ChunkMaterials
/** The fixed order draw groups are emitted in (grass first, flowers -- double-sided
* -- last), so the per-chunk draw sequence is deterministic and matches the pre-
* registry order. Every material key must appear here. */
const MAT_ORDER: MatKey[] = ["grass", "rock", "bark", "birch", "leaf", "needle", "flower"]
/** The playground: a flat-floored room dropped into the center of a big open
* landscape. The room (floor/walls/crate) is small and always drawn; the
* outdoor world is split into `chunks` that are frustum-culled per frame. */
@ -249,74 +257,43 @@ function buildChunks(m: ChunkMaterials, trees: Tree[], boulders: Boulder[], bush
for (let cj = 0; cj < CHUNK_GRID; cj++) {
const z0 = -TERRAIN.outer + cj * cell
const z1 = z0 + cell
const grass = mesh()
const bark = mesh()
const birchBark = mesh()
const leaf = mesh()
const needle = mesh()
const rock = mesh()
const flowerMesh = mesh()
const barkFar = mesh()
const birchBarkFar = mesh()
const leafFar = mesh()
const needleFar = mesh()
const rockFar = mesh()
// Accumulate geometry into one mesh per material key, for the near (full) and
// far (impostor) LOD sets. Props declare which material(s) they write, so the
// baker never names a texture -- adding a species/material touches no code here.
const near = new Map<string, Mesh>()
const far = new Map<string, Mesh>()
const grass = matMesh(near, "grass")
far.set("grass", grass) // the ground is drawn in both LOD sets
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) {
// Birch trunks go to their own white-bark mesh; oak + birch share the oak
// leaf foliage, spruce keeps its needles.
const trunk = tree.kind === "birch" ? birchBark : bark
const trunkFar = tree.kind === "birch" ? birchBarkFar : barkFar
const foliage = tree.kind === "spruce" ? needle : leaf
const foliageFar = tree.kind === "spruce" ? needleFar : leafFar
Tree.build(tree, trunk, foliage)
Tree.build(tree, trunkFar, foliageFar, "impostor")
const s = Tree.species(tree.kind)
Tree.build(tree, matMesh(near, s.trunk), matMesh(near, s.foliage))
Tree.build(tree, matMesh(far, s.trunk), matMesh(far, s.foliage), "impostor")
}
}
for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, rock)
Boulder.build(boulder, rockFar, "impostor")
Boulder.build(boulder, matMesh(near, "rock"))
Boulder.build(boulder, matMesh(far, "rock"), "impostor")
}
}
// Bushes share the near leaf mesh; they just drop out past lodDistance.
// Bushes fold into the near leaf mesh; they just drop out past lodDistance.
for (const bush of bushes) {
if (inCell(bush.position, x0, z0, x1, z1)) {
Bush.build(bush, leaf)
Bush.build(bush, matMesh(near, "leaf"))
}
}
for (const flower of flowers) {
if (inCell(flower.position, x0, z0, x1, z1)) {
Flower.build(flower, flowerMesh)
Flower.build(flower, matMesh(near, "flower"))
}
}
const b = bounds([grass, bark, birchBark, leaf, needle, rock, flowerMesh])
const b = bounds([...near.values()])
if (b === null) {
continue
}
// 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 })
chunks.push({ ...b, near: toGroups(near, m), far: toGroups(far, m) })
}
}
return chunks
@ -326,13 +303,26 @@ 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[] {
/** Lazily get (creating on first use) the accumulation mesh for a material key in a
* chunk's near/far map. Props write into these by key, so the baker stays generic. */
function matMesh(map: Map<string, Mesh>, key: string): Mesh {
let m = map.get(key)
if (m === undefined) {
m = mesh()
map.set(key, m)
}
return m
}
/** Turn a chunk's per-material meshes into a draw-group list, in a fixed material
* order (so the draw sequence is deterministic across bakes) and dropping any that
* ended up empty (a cell rarely holds every prop kind). */
function toGroups(map: Map<string, Mesh>, materials: ChunkMaterials): DrawGroup[] {
const out: DrawGroup[] = []
for (const [m, material] of pairs) {
if (m.indices.length > 0) {
out.push({ mesh: m, material })
for (const key of MAT_ORDER) {
const m = map.get(key)
if (m !== undefined && m.indices.length > 0) {
out.push({ mesh: m, material: materials[key] })
}
}
return out

View file

@ -1,7 +1,7 @@
import { RenderConfig } from "../engine/render/RenderConfig"
import { Camera } from "../engine/scene/Camera"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob } from "../engine/scene/Mob"
import { Mob, MOB_KINDS, type MobKind } from "../engine/scene/Mob"
import type { Vec3 } from "../engine/math/Vec3"
import { loadTextures } from "./assets"
import { buildLevel, type Level } from "./level"
@ -48,21 +48,22 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
async function main(): Promise<void> {
const textures = await loadTextures()
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: [] }
const beeMesh: Mesh = { verts: [], indices: [] }
const robinMesh: Mesh = { verts: [], indices: [] }
Mob.build("frog", frogMesh)
Mob.build("bee", beeMesh)
Mob.build("robin", robinMesh)
// Build each kind's canonical mesh once, shared by every instance (the sim
// supplies each mob's per-frame transform). Registry-driven -- a new kind needs
// no change here.
const mobMesh = {} as Record<MobKind, Mesh>
for (const kind of MOB_KINDS) {
const m: Mesh = { verts: [], indices: [] }
Mob.build(kind, m)
mobMesh[kind] = m
}
const scene: Scene = {
chunks: level.chunks,
floor: level.floor,
walls: level.walls,
crate: level.crate,
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
mobMesh: { frog: frogMesh, bee: beeMesh, robin: robinMesh },
mobMesh,
mobCount: level.mobs.length,
sky: level.sky,
textures,

View file

@ -1,6 +1,7 @@
import type { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
import { MOB_KINDS } from "../engine/scene/Mob"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
/** One-time setup: shared framebuffer + control/param buffers, the (cloned)
* scene, this worker's row band, and its index into the per-worker times array. */

View file

@ -36,11 +36,7 @@ export type Scene = {
* `renderBand` (single-thread) or packed into the shared `mobState` buffer and
* rebuilt in each worker. `MOB_FLOATS` is that packed layout's stride. */
export type MobDraw = { kind: MobKind; x: number; y: number; z: number; heading: number; scale: number }
export const MOB_FLOATS = 6 // kind index, x, y, z, heading, scale
/** Canonical kind order -- the index packed into the shared `mobState` buffer
* (main packs `indexOf`, each worker reads it back). Keep frog/bee first so the
* existing indices don't shift. */
export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"]
export const MOB_FLOATS = 6 // kind index (into MOB_KINDS), x, y, z, heading, scale
/** Chunk indices whose bounding box is inside the view frustum. Computed once on
* the main thread and shared with every worker (so they don't each re-cull). */

View file

@ -2,7 +2,8 @@ import { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import type { Mat4 } from "../engine/math/Mat4"
import type { Camera } from "../engine/scene/Camera"
import { renderBand, MOB_FLOATS, MOB_KINDS, type MobDraw, type Scene } from "./renderScene"
import { MOB_KINDS } from "../engine/scene/Mob"
import { renderBand, MOB_FLOATS, type MobDraw, type Scene } from "./renderScene"
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
const SKY_STEP = 2