feat: actors stage 1
This commit is contained in:
parent
bd88d3cbd6
commit
4869db01e5
5 changed files with 112 additions and 60 deletions
27
AGENTS.md
27
AGENTS.md
|
|
@ -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.
|
||||||
|
|
|
||||||
100
app/level.ts
100
app/level.ts
|
|
@ -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
|
||||||
|
|
|
||||||
11
app/main.ts
11
app/main.ts
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
12
engine/render/Material.ts
Normal 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 }
|
||||||
Loading…
Add table
Add a link
Reference in a new issue