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

@ -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() {