From 16f205babe92a98664782e64baf1e5202290804a Mon Sep 17 00:00:00 2001 From: Errilaz Date: Fri, 7 Aug 2026 12:49:13 +0200 Subject: [PATCH] feat: bees and frogs --- AGENTS.md | 47 +++++--- README.md | 5 +- app/assets.ts | 10 +- app/level.ts | 52 ++++++++- app/main.ts | 65 +++++++++-- app/render-worker.ts | 13 ++- app/renderScene.ts | 43 ++++++- app/renderer.ts | 25 +++- assets/bee.png | Bin 0 -> 2846 bytes assets/frog.png | Bin 0 -> 9013 bytes engine/math/Mat4.ts | 20 ++++ engine/scene/Mob.ts | 264 ++++++++++++++++++++++++++++++++++++++++++ scripts/gen-assets.ts | 27 +++++ 13 files changed, 540 insertions(+), 31 deletions(-) create mode 100644 assets/bee.png create mode 100644 assets/frog.png create mode 100644 engine/scene/Mob.ts diff --git a/AGENTS.md b/AGENTS.md index 65fbdd6..ed57f1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/README.md b/README.md index fa0f23b..d2c02ef 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # MEAT - knobs -- toggle clouds: off, basic, fancy + - toggle clouds: off, basic, fancy + - all the knobs + + diff --git a/app/assets.ts b/app/assets.ts index 3fc376a..cdd9f34 100644 --- a/app/assets.ts +++ b/app/assets.ts @@ -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 { - 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 { 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 { diff --git a/app/level.ts b/app/level.ts index da651a4..7d78658 100644 --- a/app/level.ts +++ b/app/level.ts @@ -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 diff --git a/app/main.ts b/app/main.ts index 0597c89..034bfda 100644 --- a/app/main.ts +++ b/app/main.ts @@ -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("#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 { 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 { 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() globalThis.addEventListener("keydown", (e) => { keys.add(e.code) @@ -200,6 +217,10 @@ async function main(): Promise { 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 { } 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 { * interval, then reports the distributions (exposed on `window.__BENCH__`). */ function runBench( renderer: ReturnType, - 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) }) diff --git a/app/render-worker.ts b/app/render-worker.ts index 8e7b824..a243729 100644 --- a/app/render-worker.ts +++ b/app/render-worker.ts @@ -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) } diff --git a/app/renderScene.ts b/app/renderScene.ts index d4cebdc..3aadfcd 100644 --- a/app/renderScene.ts +++ b/app/renderScene.ts @@ -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) } diff --git a/app/renderer.ts b/app/renderer.ts index 1f4e9a7..1c80f1a 100644 --- a/app/renderer.ts +++ b/app/renderer.ts @@ -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 = new Float64Array(0) // pos x/y/z, yaw, pitch, fov, time let vp: Float32Array = new Float32Array(0) // the view-projection matrix let vis: Int32Array = new Int32Array(0) // visible chunk indices + let mob: Float32Array = new Float32Array(0) // visible mob transforms (MOB_FLOATS each) let times: Float64Array = 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() { diff --git a/assets/bee.png b/assets/bee.png new file mode 100644 index 0000000000000000000000000000000000000000..b5d0a35b0c9ca821d1875b5979427ec363237dc6 GIT binary patch literal 2846 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 literal 0 HcmV?d00001 diff --git a/assets/frog.png b/assets/frog.png new file mode 100644 index 0000000000000000000000000000000000000000..29139ea3551d2be6578a82d28c89e62e1d7a3ee0 GIT binary patch literal 9013 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 D;@C|i literal 0 HcmV?d00001 diff --git a/engine/math/Mat4.ts b/engine/math/Mat4.ts index 4626eba..7848e3c 100644 --- a/engine/math/Mat4.ts +++ b/engine/math/Mat4.ts @@ -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. */ diff --git a/engine/scene/Mob.ts b/engine/scene/Mob.ts new file mode 100644 index 0000000..c76a378 --- /dev/null +++ b/engine/scene/Mob.ts @@ -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) + } +} diff --git a/scripts/gen-assets.ts b/scripts/gen-assets.ts index 3fd58ae..92f93c1 100644 --- a/scripts/gen-assets.ts +++ b/scripts/gen-assets.ts @@ -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) {