From 4869db01e562651021c6c2650be600b1184cb4a8 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Fri, 7 Aug 2026 16:58:24 +0200 Subject: [PATCH] feat: actors stage 1 --- AGENTS.md | 27 +++++----- app/level.ts | 100 ++++++++++++++++++++++++++++---------- app/main.ts | 11 ++--- app/renderScene.ts | 22 +++------ engine/render/Material.ts | 12 +++++ 5 files changed, 112 insertions(+), 60 deletions(-) create mode 100644 engine/render/Material.ts diff --git a/AGENTS.md b/AGENTS.md index 9670e26..794409c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/level.ts b/app/level.ts index d0592a5..aa563f6 100644 --- a/app/level.ts +++ b/app/level.ts @@ -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 | null { let minX = Infinity diff --git a/app/main.ts b/app/main.ts index b83a0c3..985a6b9 100644 --- a/app/main.ts +++ b/app/main.ts @@ -47,7 +47,7 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me async function main(): Promise { 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 { 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 } diff --git a/app/renderScene.ts b/app/renderScene.ts index df92903..b4f9209 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -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 } diff --git a/engine/render/Material.ts b/engine/render/Material.ts new file mode 100644 index 0000000..96d7297 --- /dev/null +++ b/engine/render/Material.ts @@ -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 }