feat: bees and frogs

This commit is contained in:
Dan Finch 2026-08-07 12:49:13 +02:00
parent 46e7070b6b
commit 16f205babe
13 changed files with 540 additions and 31 deletions

View file

@ -77,28 +77,44 @@ rules live in `.agents/rules/*.md`.
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
samples a 2x2 color-atlas texture, drawn double-sided).
samples a 2x2 color-atlas texture, drawn double-sided), `Mob` (a **roaming**
creature — `frog` hops the ground, `bee` hovers/darts — the engine's only
moving geometry. Unlike the baked props, a mob's low-poly mesh is built once
per kind in **local space**; `Mob.build` bakes the two canonical meshes,
`Mob.update` steps the wander AI (leashed to a home anchor, deterministic via
an evolving per-mob seed) each frame, and the live `position`/`heading`/`scale`
become a per-frame model matrix at draw time).
- `app/` — browser glue.
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS
upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync
interval, visible chunks / LOD-aware tri count — the real profiler in play).
Also owns the **mob sim**: each frame it steps `Mob.update` for every mob,
rebuilds the near-player mob colliders into `level.colliders`, and culls the
mobs (`visibleMobs`) so only the visible transforms get dispatched.
- `renderer.ts` — the render driver. When the page is cross-origin-isolated it
runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a
`SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a
lock-free `Atomics` barrier; otherwise it renders inline. `dispatch`/`done`
are non-blocking so the caller paces on rAF. `?bench=st|mt` A/Bs the paths.
- `renderScene.ts``renderBand(fb, scene, …, y0, y1)`: the single source of
render truth (sky + room + culled chunks + sprite + quantize for a row band).
Used full-height by the inline path, per-band by each worker. `Scene` bundles
the meshes/textures so it clones to a worker whole.
are non-blocking so the caller paces on rAF. Per-frame inputs ride shared
arrays: camera/matrix/visible-chunk list, plus the visible **mob transforms**
(`mobState`, count in the `MOBVIS` control slot). `?bench=st|mt` A/Bs the paths.
- `renderScene.ts``renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
source of render truth (sky + room + culled chunks + sprite + roaming mobs +
quantize for a row band). Used full-height by the inline path, per-band by each
worker. `Scene` bundles the static meshes/textures (incl. the two canonical mob
meshes) so it clones to a worker whole; each mob is drawn double-sided through
its own `viewProj × Mat4.compose(...)` model matrix, and `visibleMobs`
frustum-culls the moving mobs per frame.
- `assets.ts` — load `/assets/*.png``Texture` (zero-copy; ImageData bytes
are already the `Color` layout).
- `level.ts` — builds the playground: a flat stone-floored room (three thick
walls via `slab`, north side open) always drawn, in the center of a big grassy
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
`TREE_/BOULDER_/BUSH_/FLOWER_COUNT`/`_SEED`/`_REACH`), then `buildChunks` bakes
`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/leaf/needle/rock/flowers + a tight AABB) that
`main` frustum-culls; bushes fold into the leaf mesh, flowers get their own
@ -118,16 +134,19 @@ rules live in `.agents/rules/*.md`.
`#fps` meter div (styled inline).
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc`
- `assets/` — generated `floor/grass/bark/leaf/needle/rock/flower/wall/crate/npc/frog/bee`
PNGs (`floor` = room stone, `grass` = outdoor ground, `bark`/`leaf`/`needle` =
tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas).
tree trunk/oak/spruce, `rock` = boulders, `flower` = 2x2 bloom-color atlas,
`frog`/`bee` = mob skin atlases: frog green + eye tone; bee stripe bands +
head-dark + wing-pale regions).
Swap for real art anytime;
filenames are the contract.
- `server/` — Bun server stub. `shared/` — isomorphic slot.
## Frame pipeline (`app/main.ts` `tick`)
`Player.update` → build `Camera``Camera.viewProjection``visibleChunks`
`Mob.update` (all mobs) + rebuild near-player mob colliders → `Player.update`
build `Camera``Camera.viewProjection``visibleChunks` + `visibleMobs`
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
while N+1 is dispatched; the pump never blocks or async-awaits, so it can't
@ -148,8 +167,9 @@ 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)`
`Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
All backface-culled except double-sided flowers → `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.
Multi-threaded: N workers each run `renderBand` over
their band of the shared framebuffer in parallel; single-threaded: one call over
@ -306,6 +326,7 @@ job tmp dir, not the repo.
## Roadmap / not yet built
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
lighting; more cloud types; more props / a weapon / moving enemies. `shared/`
lighting; more cloud types; more props / a weapon; more mob kinds + smarter mob
behavior (they wander + block/stand-on today, but don't yet react to the player). `shared/`
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
DOM-built UI yet (deliberate).

View file

@ -2,3 +2,6 @@
- knobs
- toggle clouds: off, basic, fancy
- all the knobs

View file

@ -1,8 +1,10 @@
import type { Texture } from "../engine/render/Texture"
import barkUrl from "../assets/bark.png"
import beeUrl from "../assets/bee.png"
import crateUrl from "../assets/crate.png"
import floorUrl from "../assets/floor.png"
import flowerUrl from "../assets/flower.png"
import frogUrl from "../assets/frog.png"
import grassUrl from "../assets/grass.png"
import leafUrl from "../assets/leaf.png"
import needleUrl from "../assets/needle.png"
@ -21,11 +23,13 @@ export type Textures = {
wall: Texture
crate: Texture
npc: Texture
frog: Texture
bee: Texture
}
/** Load every game texture up front. Call once before starting the loop. */
export async function loadTextures(): Promise<Textures> {
const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc] = await Promise.all([
const [floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee] = await Promise.all([
loadTexture(floorUrl),
loadTexture(grassUrl),
loadTexture(barkUrl),
@ -36,8 +40,10 @@ export async function loadTextures(): Promise<Textures> {
loadTexture(wallUrl),
loadTexture(crateUrl),
loadTexture(npcUrl),
loadTexture(frogUrl),
loadTexture(beeUrl),
])
return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc }
return { floor, grass, bark, leaf, needle, rock, flower, wall, crate, npc, frog, bee }
}
function loadTexture(url: string): Promise<Texture> {

View file

@ -4,6 +4,7 @@ import { STRIDE, type Mesh } from "../engine/scene/Mesh"
import { Boulder } from "../engine/scene/Boulder"
import { Bush } from "../engine/scene/Bush"
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"
@ -58,6 +59,9 @@ export type Level = {
chunks: Chunk[]
colliders: Aabb[]
npcPosition: { x: number; y: number; z: number }
/** Roaming mobs -- simulated on the main thread each frame (see main.ts), not
* baked into the static culled chunks. */
mobs: Mob[]
terrain: Terrain
sky: SkyConfig
}
@ -112,6 +116,14 @@ const FLOWER_SEED = 0xF10E
const FLOWER_REACH = 0.3
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
/** Roaming mobs: how many frogs/bees to scatter, their seed, and how far out they
* reach (fraction of the world). Kept modest -- roaming meshes are drawn every
* frame (frustum-culled), not baked into the static chunks. */
const FROG_COUNT = 40
const BEE_COUNT = 30
const MOB_SEED = 0x30B
const MOB_REACH = 0.5
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
* (less drawn off-screen) but cost more per-cell tests + bounds; this is the
@ -199,8 +211,9 @@ export function buildLevel(): Level {
const bushes = placeBushes()
const flowers = placeFlowers()
const chunks = buildChunks(trees, boulders, bushes, flowers)
const mobs = placeMobs()
return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky }
return { floor, walls, crate, chunks, colliders, npcPosition, mobs, terrain: TERRAIN, sky }
}
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
@ -383,6 +396,43 @@ function placeFlowers(): Flower[] {
return flowers
}
/** Scatter frogs + bees across the grass (like the boulders), each at its home
* anchor with a random heading and size. No colliders here -- mobs move, so their
* block/stand-on AABBs are rebuilt per frame in `main`. */
function placeMobs(): Mob[] {
const rand = mulberry(MOB_SEED)
const maxDist = TERRAIN.outer * MOB_REACH
const mobs: Mob[] = []
const total = FROG_COUNT + BEE_COUNT
for (let guard = 0; mobs.length < total && guard < total * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 3 + rand() * (maxDist - ARENA - 3)
const x = Math.cos(angle) * dist
const z = Math.sin(angle) * dist
if (Math.max(Math.abs(x), Math.abs(z)) < TERRAIN.inner + 2) {
continue
}
const kind: MobKind = mobs.length < FROG_COUNT ? "frog" : "bee"
const y = Terrain.height(TERRAIN, x, z)
const scale = kind === "frog" ? 0.5 + rand() * 0.35 : 0.5 + rand() * 0.3
mobs.push({
kind,
home: { x, y, z },
position: { x, y, z },
heading: rand() * Math.PI * 2,
scale,
seed: (rand() * 0xFFFFFFFF) | 0,
vx: 0,
vz: 0,
vy: 0,
timer: rand() * 1.5,
phase: rand() * 10,
grounded: true,
})
}
return mobs
}
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
function mulberry(seed: number): () => number {
let a = seed >>> 0

View file

@ -1,12 +1,19 @@
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 type { Vec3 } from "../engine/math/Vec3"
import { loadTextures } from "./assets"
import { buildLevel } from "./level"
import { buildLevel, type Level } from "./level"
import { EYE_HEIGHT, Player } from "./player"
import { createRenderer } from "./renderer"
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
const FOV = Math.PI / 3
/** How close (world units) a mob must be to the player to get a live collider.
* Mobs farther than this can't be touched this frame, so skip them -- keeps the
* per-frame collider list (and the player's collision loop) short. */
const MOB_COLLIDE_RANGE = 3
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
@ -40,12 +47,20 @@ function benchStats(a: number[]): { median: number; p95: number; max: number; me
async function main(): Promise<void> {
const textures = await loadTextures()
const level = buildLevel()
// 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: [] }
Mob.build("frog", frogMesh)
Mob.build("bee", beeMesh)
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 },
mobCount: level.mobs.length,
sky: level.sky,
textures,
}
@ -99,11 +114,13 @@ async function main(): Promise<void> {
globalThis.addEventListener("resize", layout)
if (benchMode) {
runBench(renderer, level.chunks, present, benchMode)
runBench(renderer, level, present, benchMode)
return
}
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
// Colliders past this index are the dynamic mob ones, rebuilt every frame.
const staticColliderCount = level.colliders.length
const keys = new Set<string>()
globalThis.addEventListener("keydown", (e) => {
keys.add(e.code)
@ -200,6 +217,10 @@ async function main(): Promise<void> {
presentMax = 0
vsyncMax = 0
}
for (const m of level.mobs) {
Mob.update(m, dt, level.terrain)
}
rebuildMobColliders(level, player.position, staticColliderCount)
Player.update(player, keys, dt, level)
const camera: Camera = {
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
@ -209,9 +230,10 @@ async function main(): Promise<void> {
}
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
lastVisible = visible
lastCamera = camera
renderer.dispatch(camera, viewProj, visible, now / 1000)
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
inFlight = true
if (renderer.done()) {
show()
@ -225,7 +247,7 @@ async function main(): Promise<void> {
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
function runBench(
renderer: ReturnType<typeof createRenderer>,
chunks: Scene["chunks"],
level: Level,
present: () => void,
mode: string,
): void {
@ -281,10 +303,14 @@ function runBench(
return
}
}
for (const m of level.mobs) {
Mob.update(m, 1 / 60, level.terrain)
}
const camera = benchCamera(i / 60)
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
const visible = visibleChunks(chunks, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60)
const visible = visibleChunks(level.chunks, viewProj)
const mobDraws = visibleMobs(level.mobs, viewProj)
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
inFlight = true
if (renderer.done() && record()) {
report()
@ -293,6 +319,31 @@ function runBench(
requestAnimationFrame(tick)
}
/** Rebuild the dynamic tail of `level.colliders`: keep the static prefix, then add
* a block/stand-on AABB for each mob near the player. Mobs move, so these can't be
* baked; frogs are `standable` (hop onto them), bees only block (no mid-air
* platform). Only mobs within `MOB_COLLIDE_RANGE` are added -- the rest can't be
* reached this frame anyway. */
function rebuildMobColliders(level: Level, playerPos: Vec3, staticCount: number): void {
level.colliders.length = staticCount
for (const m of level.mobs) {
const dx = m.position.x - playerPos.x
const dz = m.position.z - playerPos.z
if (dx * dx + dz * dz > MOB_COLLIDE_RANGE * MOB_COLLIDE_RANGE) {
continue
}
const half = Mob.boundingRadius(m.kind) * m.scale * 0.7
level.colliders.push({
minX: m.position.x - half,
maxX: m.position.x + half,
minZ: m.position.z - half,
maxZ: m.position.z + half,
top: m.position.y + Mob.bodyHeight(m.kind) * m.scale,
standable: m.kind === "frog",
})
}
}
main().catch((error) => {
console.error(error)
})

View file

@ -1,6 +1,6 @@
import type { Framebuffer } from "../engine/render/Framebuffer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { renderBand, type Scene } from "./renderScene"
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. */
@ -17,6 +17,7 @@ type Init = {
camSAB: SharedArrayBuffer
vpSAB: SharedArrayBuffer
visSAB: SharedArrayBuffer
mobSAB: SharedArrayBuffer
timesSAB: SharedArrayBuffer
index: number
}
@ -24,6 +25,7 @@ type Init = {
const FRAME = 0
const DONE = 1
const VIS = 2
const MOBVIS = 3
const ctx = globalThis as unknown as {
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
@ -41,6 +43,7 @@ ctx.addEventListener("message", (e) => {
const cam = new Float64Array(m.camSAB)
const vp = new Float32Array(m.vpSAB)
const vis = new Int32Array(m.visSAB)
const mob = new Float32Array(m.mobSAB)
const times = new Float64Array(m.timesSAB)
const { scene, band, config, skyStep, index } = m
@ -54,7 +57,13 @@ ctx.addEventListener("message", (e) => {
const camera = { position: { x: cam[0], y: cam[1], z: cam[2] }, yaw: cam[3], pitch: cam[4], fov: cam[5] }
const count = Atomics.load(ctrl, VIS)
const visible = [...vis.subarray(0, count)]
renderBand(fb, scene, camera, vp, visible, config, skyStep, cam[6], band[0], band[1])
const mobCount = Atomics.load(ctrl, MOBVIS)
const mobDraws: MobDraw[] = []
for (let i = 0; i < mobCount; i++) {
const o = i * MOB_FLOATS
mobDraws.push({ kind: mob[o] === 1 ? "bee" : "frog", x: mob[o + 1], y: mob[o + 2], z: mob[o + 3], heading: mob[o + 4], scale: mob[o + 5] })
}
renderBand(fb, scene, camera, vp, visible, mobDraws, config, skyStep, cam[6], band[0], band[1])
times[index] = performance.now() - t0
Atomics.add(ctrl, DONE, 1)
}

View file

@ -4,8 +4,9 @@ import { Rasterizer } from "../engine/render/Rasterizer"
import type { RenderConfig } from "../engine/render/RenderConfig"
import { Sky, type SkyConfig } from "../engine/render/Sky"
import type { Camera } from "../engine/scene/Camera"
import type { Mat4 } from "../engine/math/Mat4"
import { Mat4 } from "../engine/math/Mat4"
import type { Mesh } from "../engine/scene/Mesh"
import { Mob, type MobKind } from "../engine/scene/Mob"
import { Sprite } from "../engine/scene/Sprite"
import type { Vec2 } from "../engine/math/Vec2"
import type { Vec3 } from "../engine/math/Vec3"
@ -21,10 +22,22 @@ export type Scene = {
walls: Mesh
crate: Mesh
npc: { position: Vec3; size: Vec2 }
/** Canonical local-space mob meshes, one per kind, built once + shared by every
* instance (each instance differs only by its per-frame model matrix). */
mobMesh: { frog: Mesh; bee: Mesh }
/** How many mobs the sim has -- sizes the worker's shared transform buffer. */
mobCount: number
sky: SkyConfig
textures: Textures
}
/** One mob's live transform for a frame: which mesh + where/how to place it.
* Produced by `visibleMobs` on the main thread, then either passed straight to
* `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(0/1), 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). */
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
@ -39,6 +52,24 @@ export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
return out
}
/** The `MobDraw`s for mobs whose world AABB is inside the view frustum. Mobs move,
* so (unlike chunks) they can't be baked into the culled world -- they're culled
* here per frame instead. Computed once on the main thread; the visible set is
* what gets shipped to the workers. */
export function visibleMobs(mobs: Mob[], viewProj: Mat4): MobDraw[] {
const frustum = Frustum.fromViewProj(viewProj)
const out: MobDraw[] = []
for (const m of mobs) {
const r = Mob.boundingRadius(m.kind) * m.scale
const h = Mob.bodyHeight(m.kind) * m.scale
const p = m.position
if (Frustum.intersectsAabb(frustum, p.x - r, p.y - r, p.z - r, p.x + r, p.y + h + r, p.z + r)) {
out.push({ kind: m.kind, x: p.x, y: p.y, z: p.z, heading: m.heading, scale: m.scale })
}
}
return out
}
/**
* Render rows [y0, y1) of one frame into `fb`. This is the single source of
* render truth: the single-thread path calls it with the full height, and each
@ -51,6 +82,7 @@ export function renderBand(
camera: Camera,
viewProj: Mat4,
visible: number[],
mobDraws: MobDraw[],
config: RenderConfig,
skyStep: number,
time: number,
@ -84,6 +116,15 @@ export function renderBand(
}
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
// Roaming mobs: each is the shared local-space mesh for its kind, placed by its
// own model matrix (viewProj x model). Drawn double-sided (cull off) -- they're
// small and few, so the winding-correct backface cull isn't worth the fuss.
for (const m of mobDraws) {
const mesh = m.kind === "bee" ? scene.mobMesh.bee : scene.mobMesh.frog
const texture = m.kind === "bee" ? tx.bee : tx.frog
const mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale))
Rasterizer.draw(fb, mesh, texture, mvp, config, false, y0, y1)
}
Framebuffer.quantize(fb, config, y0, y1)
}

View file

@ -2,7 +2,7 @@ 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, type Scene } from "./renderScene"
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
@ -21,6 +21,7 @@ const MAX_WORKERS = 3
const FRAME = 0 // bumped by main to dispatch a frame
const DONE = 1 // workers add 1 when their band is finished
const VIS = 2 // number of visible chunk indices this frame
const MOBVIS = 3 // number of visible mobs this frame
/**
* Render driver. When the page is cross-origin-isolated it runs a pool of worker
@ -38,7 +39,7 @@ export type Renderer = {
readonly parallel: boolean
reconfigure: (config: RenderConfig) => void
/** Start rendering one frame (non-blocking in the worker path). */
dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number) => void
dispatch: (camera: Camera, viewProj: Mat4, visible: number[], time: number, mobDraws: MobDraw[]) => void
/** Has the dispatched frame finished? (always true single-threaded.) */
done: () => boolean
/** Critical-path render time of the last frame, ms (max band time / inline time). */
@ -49,6 +50,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
const hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
const maxVis = Math.max(1, scene.chunks.length)
const maxMobs = Math.max(1, scene.mobCount)
let config = initial
const want = forceWorkers ?? ENABLE_WORKERS
let parallel = want && canShare()
@ -59,6 +61,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
let cam: Float64Array<ArrayBufferLike> = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time
let vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
let mob: Float32Array<ArrayBufferLike> = new Float32Array(0) // visible mob transforms (MOB_FLOATS each)
let times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
let lastWork = 0
@ -77,6 +80,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
vp = new Float32Array(new SharedArrayBuffer(16 * 4))
vis = new Int32Array(new SharedArrayBuffer(maxVis * 4))
mob = new Float32Array(new SharedArrayBuffer(maxMobs * MOB_FLOATS * 4))
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
try {
bands.forEach((band, index) => {
@ -97,6 +101,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
camSAB: cam.buffer,
vpSAB: vp.buffer,
visSAB: vis.buffer,
mobSAB: mob.buffer,
timesSAB: times.buffer,
index,
})
@ -127,7 +132,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
config = next
setup()
},
dispatch(camera, viewProj, visible, time) {
dispatch(camera, viewProj, visible, time, mobDraws) {
if (parallel && workers.length > 0) {
cam[0] = camera.position.x
cam[1] = camera.position.y
@ -141,14 +146,26 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
for (let i = 0; i < count; i++) {
vis[i] = visible[i]
}
const mobCount = Math.min(mobDraws.length, maxMobs)
for (let i = 0; i < mobCount; i++) {
const d = mobDraws[i]
const o = i * MOB_FLOATS
mob[o] = d.kind === "bee" ? 1 : 0
mob[o + 1] = d.x
mob[o + 2] = d.y
mob[o + 3] = d.z
mob[o + 4] = d.heading
mob[o + 5] = d.scale
}
Atomics.store(ctrl, VIS, count)
Atomics.store(ctrl, MOBVIS, mobCount)
Atomics.store(ctrl, DONE, 0)
Atomics.add(ctrl, FRAME, 1)
Atomics.notify(ctrl, FRAME, workers.length)
return
}
const t0 = performance.now()
renderBand(fb, scene, camera, viewProj, visible, config, SKY_STEP, time, 0, fb.height)
renderBand(fb, scene, camera, viewProj, visible, mobDraws, config, SKY_STEP, time, 0, fb.height)
lastWork = performance.now() - t0
},
done() {

BIN
assets/bee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
assets/frog.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

View file

@ -20,6 +20,26 @@ export namespace Mat4 {
return out
}
/** Model transform T * Ry * S: uniform `scale`, then a yaw rotation about Y,
* then a translation. Built directly in column-major storage (no intermediate
* matmuls) since it runs per mob per frame. A vertex at local +Z ends up
* pointing along world (sin yaw, 0, cos yaw), i.e. the object faces `yaw`. */
export function compose(tx: number, ty: number, tz: number, yaw: number, scale: number): Mat4 {
const c = Math.cos(yaw)
const s = Math.sin(yaw)
const out = new Float32Array(16)
out[0] = scale * c
out[2] = scale * -s
out[5] = scale
out[8] = scale * s
out[10] = scale * c
out[12] = tx
out[13] = ty
out[14] = tz
out[15] = 1
return out
}
/** Right-handed perspective projection (camera looks down -Z). Maps the view
* frustum to clip space; the -1 in row 3 copies -z into w, so the later
* divide by w is what produces foreshortening. */

264
engine/scene/Mob.ts Normal file
View file

@ -0,0 +1,264 @@
import { Terrain } from "./Terrain"
import type { Vec3 } from "../math/Vec3"
import { STRIDE, type Mesh } from "./Mesh"
const TAU = Math.PI * 2
/** A roaming creature drawn as a moving low-poly mesh (unlike the static baked
* world). Two kinds, told apart by silhouette + motion:
* frog -- squat, ground-bound, sits then springs a ballistic hop.
* bee -- small, hovers and darts through the air, wings out.
*
* Unlike `Tree`/`Boulder` (baked once into world-space chunks), a mob's geometry
* is a **canonical local-space mesh** built once per kind (front = +Z, frog feet
* / bee body at the origin); the live `position`/`heading`/`scale` are turned
* into a per-frame model matrix by the renderer. All wander state lives here so
* `update` is a pure stepping function of the mob + dt (deterministic via the
* evolving `seed`), which keeps the sim on the main thread and cloneable-free. */
export type MobKind = "frog" | "bee"
export type Mob = {
kind: MobKind
/** Leash anchor (where it was scattered); wandering is pulled back toward it. */
home: Vec3
/** Live feet-center (frog) / body-center (bee), advanced each frame. */
position: Vec3
/** Facing yaw; the mesh's front is local +Z, so world dir = (sin h, 0, cos h). */
heading: number
/** Per-instance size multiplier. */
scale: number
/** Evolving RNG state (mutated by `update`) -- keeps the sim deterministic. */
seed: number
/** Horizontal velocity (frog: only mid-hop; bee: cruise). */
vx: number
vz: number
/** Vertical velocity (frog ballistic hop; bee stays 0, it uses a bob). */
vy: number
/** Countdown to the next decision (frog: next hop; bee: next heading change). */
timer: number
/** Accumulated time, for the bee's hover bob. */
phase: number
/** Frog only: resting on the ground vs airborne in a hop. */
grounded: boolean
}
// --- Behavior tuning ------------------------------------------------------
const FROG_LEASH = 5
const FROG_REST_MIN = 0.7
const FROG_REST_SPAN = 1.8
const FROG_HOP_SPEED = 1.6
const FROG_HOP_IMPULSE = 3.2
const FROG_GRAVITY = 14
const BEE_LEASH = 6
const BEE_SPEED = 1.7
const BEE_TURN_MIN = 0.4
const BEE_TURN_SPAN = 1
const BEE_HOVER = 1.1
const BEE_BOB_AMP = 0.18
const BEE_BOB_FREQ = 3
export namespace Mob {
/** Advance one mob by `dt` seconds, sampling `terrain` for ground height. */
export function update(mob: Mob, dt: number, terrain: Terrain): void {
if (mob.kind === "frog") {
frog(mob, dt, terrain)
} else {
bee(mob, dt, terrain)
}
}
/** Append the canonical local-space mesh for `kind` into `mesh` (called once
* per kind at load; every instance shares it, differing only by transform). */
export function build(kind: MobKind, mesh: Mesh): void {
if (kind === "frog") {
buildFrog(mesh)
} else {
buildBee(mesh)
}
}
/** Local bounding radius (pre-scale), for building the per-frame cull AABB. */
export function boundingRadius(kind: MobKind): number {
return kind === "frog" ? 0.7 : 0.5
}
/** Local body height (pre-scale), for the top of the stand-on collider. */
export function bodyHeight(kind: MobKind): number {
return kind === "frog" ? 0.6 : 0.5
}
// --- Simulation ---------------------------------------------------------
function frog(mob: Mob, dt: number, terrain: Terrain): void {
if (mob.grounded) {
mob.timer -= dt
mob.position.y = Terrain.height(terrain, mob.position.x, mob.position.z)
if (mob.timer > 0) {
return
}
// Launch a hop: pick a heading (pulled homeward past the leash), then
// convert it into a forward+upward ballistic velocity.
mob.heading = wanderHeading(mob, FROG_LEASH, 0.9)
mob.vx = Math.sin(mob.heading) * FROG_HOP_SPEED
mob.vz = Math.cos(mob.heading) * FROG_HOP_SPEED
mob.vy = FROG_HOP_IMPULSE
mob.grounded = false
return
}
mob.vy -= FROG_GRAVITY * dt
mob.position.x += mob.vx * dt
mob.position.y += mob.vy * dt
mob.position.z += mob.vz * dt
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
if (mob.position.y <= ground && mob.vy < 0) {
mob.position.y = ground
mob.vx = 0
mob.vy = 0
mob.vz = 0
mob.grounded = true
mob.timer = FROG_REST_MIN + nextRand(mob) * FROG_REST_SPAN
}
}
function bee(mob: Mob, dt: number, terrain: Terrain): void {
mob.phase += dt
mob.timer -= dt
if (mob.timer <= 0) {
mob.heading = wanderHeading(mob, BEE_LEASH, 1.4)
mob.timer = BEE_TURN_MIN + nextRand(mob) * BEE_TURN_SPAN
}
mob.position.x += Math.sin(mob.heading) * BEE_SPEED * dt
mob.position.z += Math.cos(mob.heading) * BEE_SPEED * dt
const ground = Terrain.height(terrain, mob.position.x, mob.position.z)
mob.position.y = ground + BEE_HOVER + Math.sin(mob.phase * BEE_BOB_FREQ) * BEE_BOB_AMP
}
/** A new heading: free wander when inside the leash, else biased back toward
* home so the mob never drifts off into the peaks (`jitter` = the random cone
* half-width in radians layered on top of the homeward bearing). */
function wanderHeading(mob: Mob, leash: number, jitter: number): number {
const dx = mob.home.x - mob.position.x
const dz = mob.home.z - mob.position.z
if (dx * dx + dz * dz > leash * leash) {
return Math.atan2(dx, dz) + (nextRand(mob) - 0.5) * jitter
}
return nextRand(mob) * TAU
}
/** mulberry32 step over the mob's own `seed` (mutated), so a mob's motion is
* deterministic and needs no external RNG object to clone. */
function nextRand(mob: Mob): number {
const a = (mob.seed + 0x6D2B79F5) | 0
mob.seed = a
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
// --- Geometry -----------------------------------------------------------
// Mobs are drawn double-sided (see renderScene), so winding is not load-bearing
// here -- these builders only need to place faceted, flat-shaded surfaces.
function buildFrog(mesh: Mesh): void {
// Wide squat body, two eye bumps on the top-front, two hind haunches. UVs:
// the frog texture is green skin on the left, a dark eye tone on the right.
ellipsoid(mesh, 0, 0.26, 0, 0.5, 0.28, 0.52, 6, 4, 0, 0.68, 0, 1)
ellipsoid(mesh, 0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1)
ellipsoid(mesh, -0.24, 0.5, 0.26, 0.13, 0.13, 0.13, 4, 3, 0.75, 0.98, 0, 1)
ellipsoid(mesh, 0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1)
ellipsoid(mesh, -0.3, 0.2, -0.26, 0.2, 0.2, 0.26, 4, 3, 0, 0.68, 0, 1)
}
function buildBee(mesh: Mesh): void {
// Fore-aft ovoid body striped along its length, a dark head at the front, two
// pale wings. UVs: bee texture is stripe bands (left), head-dark (mid), wing-
// pale (right); the body maps v along z so the stripes band across it.
ovoidZ(mesh, -0.4, 0.4, 0.24, 7, 5, 0, 0.54, 0, 1)
ellipsoid(mesh, 0, 0.02, 0.44, 0.16, 0.16, 0.16, 5, 4, 0.6, 0.79, 0, 1)
wing(mesh, 1, 0.83, 0.99, 0, 1)
wing(mesh, -1, 0.83, 0.99, 0, 1)
}
/** A UV-rected ellipsoid (pole on Y), faceted like the boulders. */
function ellipsoid(
mesh: Mesh,
cx: number,
cy: number,
cz: number,
rx: number,
ry: number,
rz: number,
seg: number,
rings: number,
u0: number,
u1: number,
v0: number,
v1: number,
): void {
const start = mesh.verts.length / STRIDE
for (let ir = 0; ir <= rings; ir++) {
const phi = (ir / rings) * Math.PI
const cyv = Math.cos(phi)
const crv = Math.sin(phi)
const v = v0 + (v1 - v0) * (ir / rings)
for (let is = 0; is <= seg; is++) {
const theta = (is / seg) * TAU
const u = u0 + (u1 - u0) * (is / seg)
mesh.verts.push(cx + crv * Math.cos(theta) * rx, cy + cyv * ry, cz + crv * Math.sin(theta) * rz, u, v)
}
}
quadGrid(mesh, start, seg, rings)
}
/** An ovoid whose pole axis is Z (rings step along z, tapering at both ends),
* so the mapped `v` runs down the body's length -- used for the bee's stripes. */
function ovoidZ(
mesh: Mesh,
z0: number,
z1: number,
r: number,
seg: number,
rings: number,
u0: number,
u1: number,
v0: number,
v1: number,
): void {
const start = mesh.verts.length / STRIDE
for (let ir = 0; ir <= rings; ir++) {
const t = ir / rings
const z = z0 + (z1 - z0) * t
const rr = r * (0.15 + 0.85 * Math.sin(t * Math.PI))
const v = v0 + (v1 - v0) * t
for (let is = 0; is <= seg; is++) {
const theta = (is / seg) * TAU
const u = u0 + (u1 - u0) * (is / seg)
mesh.verts.push(Math.cos(theta) * rr, Math.sin(theta) * rr, z, u, v)
}
}
quadGrid(mesh, start, seg, rings)
}
/** Index a (seg x rings) vertex grid (row = seg+1) into two tris per cell. */
function quadGrid(mesh: Mesh, start: number, seg: number, rings: number): void {
const row = seg + 1
for (let ir = 0; ir < rings; ir++) {
for (let is = 0; is < seg; is++) {
const p = start + ir * row + is
mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
}
}
}
/** One flat wing quad on `side` (+1 right / -1 left), swept up and out. */
function wing(mesh: Mesh, side: number, u0: number, u1: number, v0: number, v1: number): void {
const base = mesh.verts.length / STRIDE
mesh.verts.push(
side * 0.06, 0.12, 0.14, u0, v0,
side * 0.42, 0.24, 0.1, u1, v0,
side * 0.42, 0.24, -0.12, u1, v1,
side * 0.06, 0.12, -0.1, u0, v1,
)
mesh.indices.push(base, base + 1, base + 2, base, base + 2, base + 3)
}
}

View file

@ -194,6 +194,31 @@ const crate: Shade = (x, y) => {
return [140 + n, 96 + n, 46 + n, 255]
}
// Frog skin atlas: green mottled skin with a lighter belly on the left (u<0.71),
// a dark eye tone on the right (mapped by the eye/haunch bumps' UVs).
const frog: Shade = (x, y) => {
const n = noise(x, y) * 10
if (x < 34) {
const belly = (y / 48) * 28
return [66 + n, 120 + belly + n, 60 + n, 255]
}
return [26 + n, 42 + n, 30 + n, 255]
}
// Bee atlas: yellow/black stripe bands down the left (u<0.54, banded by y so the
// body stripes across its length), a dark head tone in the middle, pale wings on
// the right.
const bee: Shade = (x, y) => {
const n = noise(x, y) * 8
if (x < 26) {
return Math.floor(y / 6) % 2 === 0 ? [250 + n, 206 + n, 42 + n, 255] : [30 + n, 26 + n, 14 + n, 255]
}
if (x < 38) {
return [36 + n, 30 + n, 18 + n, 255]
}
return [228 + n, 238 + n, 248 + n, 255]
}
// 48x64, transparent background, a simple round-topped figure with eyes.
const npc: Shade = (x, y) => {
const dx = (x - 24) / 17
@ -231,6 +256,8 @@ const assets: Array<[string, number, number, Shade]> = [
["wall", 64, 64, wall],
["crate", 64, 64, crate],
["npc", 48, 64, npc],
["frog", 48, 48, frog],
["bee", 48, 48, bee],
]
for (const [name, w, h, shade] of assets) {