feat: bees and frogs
This commit is contained in:
parent
46e7070b6b
commit
16f205babe
13 changed files with 540 additions and 31 deletions
47
AGENTS.md
47
AGENTS.md
|
|
@ -77,28 +77,44 @@ rules live in `.agents/rules/*.md`.
|
||||||
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
(procedural low-poly rock: a squashed, jittered, part-buried sphere;
|
||||||
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
|
`Boulder.build` appends into a shared mesh), `Bush` (cluster of small leaf
|
||||||
blobs, shares the oak leaf texture/mesh), `Flower` (thin stem + colored bloom;
|
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.
|
- `app/` — browser glue.
|
||||||
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
- `main.ts` — game loop: input, sim, preset switching, per-frame culling, then
|
||||||
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS
|
the non-blocking pump (`renderer.dispatch`/`done`) + `present` (GPU/CSS
|
||||||
upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync
|
upscale) + a multi-line frame HUD (`work + present` critical-path ms, vsync
|
||||||
interval, visible chunks / LOD-aware tri count — the real profiler in play).
|
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
|
- `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
|
runs a pool of `render-worker.ts` threads (`MAX_WORKERS`) over a
|
||||||
`SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a
|
`SharedArrayBuffer` framebuffer, each owning a disjoint row band, synced by a
|
||||||
lock-free `Atomics` barrier; otherwise it renders inline. `dispatch`/`done`
|
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.
|
are non-blocking so the caller paces on rAF. Per-frame inputs ride shared
|
||||||
- `renderScene.ts` — `renderBand(fb, scene, …, y0, y1)`: the single source of
|
arrays: camera/matrix/visible-chunk list, plus the visible **mob transforms**
|
||||||
render truth (sky + room + culled chunks + sprite + quantize for a row band).
|
(`mobState`, count in the `MOBVIS` control slot). `?bench=st|mt` A/Bs the paths.
|
||||||
Used full-height by the inline path, per-band by each worker. `Scene` bundles
|
- `renderScene.ts` — `renderBand(fb, scene, …, mobDraws, …, y0, y1)`: the single
|
||||||
the meshes/textures so it clones to a worker whole.
|
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
|
- `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes
|
||||||
are already the `Color` layout).
|
are already the `Color` layout).
|
||||||
- `level.ts` — builds the playground: a flat stone-floored room (three thick
|
- `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
|
walls via `slab`, north side open) always drawn, in the center of a big grassy
|
||||||
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
`Terrain` world (~20x across). Props are placed first (`placeTrees` /
|
||||||
`placeBoulders` / `placeBushes` / `placeFlowers` → instance lists + colliders;
|
`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 =
|
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
|
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
|
`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).
|
`#fps` meter div (styled inline).
|
||||||
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
- `scripts/gen-assets.ts` — procedurally draws the placeholder textures and
|
||||||
writes PNGs (hand-rolled encoder via `node:zlib`). Run via `bun run assets`.
|
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` =
|
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;
|
Swap for real art anytime;
|
||||||
filenames are the contract.
|
filenames are the contract.
|
||||||
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
- `server/` — Bun server stub. `shared/` — isomorphic slot.
|
||||||
|
|
||||||
## Frame pipeline (`app/main.ts` `tick`)
|
## 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) →
|
(frustum-cull, once on the main thread) → `renderer.dispatch` (non-blocking) →
|
||||||
next rAF: `renderer.done()` ? `present` : skip this vsync. Frame N is presented
|
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
|
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
|
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
|
`lodDistance²`) — either the full rock/bark/leaf/needle + flowers, or the cheap
|
||||||
`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped).
|
`rockFar/barkFar/needleFar/leafFar` impostor meshes (foliage detail dropped).
|
||||||
All backface-culled except double-sided flowers → `Sprite.billboard(npc)`
|
All backface-culled except double-sided flowers → `Sprite.billboard(npc)` →
|
||||||
→ `Framebuffer.quantize`. `chunkFar` is pure (camera + baked bounds + config
|
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.
|
only), so every worker band picks the same LOD for a chunk → no horizontal seam.
|
||||||
Multi-threaded: N workers each run `renderBand` over
|
Multi-threaded: N workers each run `renderBand` over
|
||||||
their band of the shared framebuffer in parallel; single-threaded: one call 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
|
## Roadmap / not yet built
|
||||||
|
|
||||||
In-browser RenderConfig slider panel; mipmaps; `painter` depth mode; gouraud
|
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
|
is nearly empty. The FPS meter is static HTML + `textContent` writes only — no
|
||||||
DOM-built UI yet (deliberate).
|
DOM-built UI yet (deliberate).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
# MEAT
|
# MEAT
|
||||||
|
|
||||||
- knobs
|
- knobs
|
||||||
- toggle clouds: off, basic, fancy
|
- toggle clouds: off, basic, fancy
|
||||||
|
- all the knobs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { Texture } from "../engine/render/Texture"
|
import type { Texture } from "../engine/render/Texture"
|
||||||
import barkUrl from "../assets/bark.png"
|
import barkUrl from "../assets/bark.png"
|
||||||
|
import beeUrl from "../assets/bee.png"
|
||||||
import crateUrl from "../assets/crate.png"
|
import crateUrl from "../assets/crate.png"
|
||||||
import floorUrl from "../assets/floor.png"
|
import floorUrl from "../assets/floor.png"
|
||||||
import flowerUrl from "../assets/flower.png"
|
import flowerUrl from "../assets/flower.png"
|
||||||
|
import frogUrl from "../assets/frog.png"
|
||||||
import grassUrl from "../assets/grass.png"
|
import grassUrl from "../assets/grass.png"
|
||||||
import leafUrl from "../assets/leaf.png"
|
import leafUrl from "../assets/leaf.png"
|
||||||
import needleUrl from "../assets/needle.png"
|
import needleUrl from "../assets/needle.png"
|
||||||
|
|
@ -21,11 +23,13 @@ export type Textures = {
|
||||||
wall: Texture
|
wall: Texture
|
||||||
crate: Texture
|
crate: Texture
|
||||||
npc: Texture
|
npc: Texture
|
||||||
|
frog: Texture
|
||||||
|
bee: Texture
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load every game texture up front. Call once before starting the loop. */
|
/** Load every game texture up front. Call once before starting the loop. */
|
||||||
export async function loadTextures(): Promise<Textures> {
|
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(floorUrl),
|
||||||
loadTexture(grassUrl),
|
loadTexture(grassUrl),
|
||||||
loadTexture(barkUrl),
|
loadTexture(barkUrl),
|
||||||
|
|
@ -36,8 +40,10 @@ export async function loadTextures(): Promise<Textures> {
|
||||||
loadTexture(wallUrl),
|
loadTexture(wallUrl),
|
||||||
loadTexture(crateUrl),
|
loadTexture(crateUrl),
|
||||||
loadTexture(npcUrl),
|
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> {
|
function loadTexture(url: string): Promise<Texture> {
|
||||||
|
|
|
||||||
52
app/level.ts
52
app/level.ts
|
|
@ -4,6 +4,7 @@ import { STRIDE, type Mesh } from "../engine/scene/Mesh"
|
||||||
import { Boulder } from "../engine/scene/Boulder"
|
import { Boulder } from "../engine/scene/Boulder"
|
||||||
import { Bush } from "../engine/scene/Bush"
|
import { Bush } from "../engine/scene/Bush"
|
||||||
import { Flower, type FlowerColor } from "../engine/scene/Flower"
|
import { Flower, type FlowerColor } from "../engine/scene/Flower"
|
||||||
|
import type { Mob, MobKind } from "../engine/scene/Mob"
|
||||||
import { Terrain } from "../engine/scene/Terrain"
|
import { Terrain } from "../engine/scene/Terrain"
|
||||||
import { Tree } from "../engine/scene/Tree"
|
import { Tree } from "../engine/scene/Tree"
|
||||||
|
|
||||||
|
|
@ -58,6 +59,9 @@ export type Level = {
|
||||||
chunks: Chunk[]
|
chunks: Chunk[]
|
||||||
colliders: Aabb[]
|
colliders: Aabb[]
|
||||||
npcPosition: { x: number; y: number; z: number }
|
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
|
terrain: Terrain
|
||||||
sky: SkyConfig
|
sky: SkyConfig
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +116,14 @@ const FLOWER_SEED = 0xF10E
|
||||||
const FLOWER_REACH = 0.3
|
const FLOWER_REACH = 0.3
|
||||||
const FLOWER_COLORS: FlowerColor[] = ["white", "red", "yellow"]
|
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
|
/** Spatial partition of the world for frustum culling: `CHUNK_GRID` x
|
||||||
* `CHUNK_GRID` square cells over [-outer, outer]. Smaller cells cull tighter
|
* `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
|
* (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 bushes = placeBushes()
|
||||||
const flowers = placeFlowers()
|
const flowers = placeFlowers()
|
||||||
const chunks = buildChunks(trees, boulders, bushes, flowers)
|
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
|
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
|
||||||
|
|
@ -383,6 +396,43 @@ function placeFlowers(): Flower[] {
|
||||||
return flowers
|
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. */
|
/** Deterministic 0..1 generator (mulberry32) for tree placement. */
|
||||||
function mulberry(seed: number): () => number {
|
function mulberry(seed: number): () => number {
|
||||||
let a = seed >>> 0
|
let a = seed >>> 0
|
||||||
|
|
|
||||||
65
app/main.ts
65
app/main.ts
|
|
@ -1,12 +1,19 @@
|
||||||
import { RenderConfig } from "../engine/render/RenderConfig"
|
import { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Camera } from "../engine/scene/Camera"
|
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 { loadTextures } from "./assets"
|
||||||
import { buildLevel } from "./level"
|
import { buildLevel, type Level } from "./level"
|
||||||
import { EYE_HEIGHT, Player } from "./player"
|
import { EYE_HEIGHT, Player } from "./player"
|
||||||
import { createRenderer } from "./renderer"
|
import { createRenderer } from "./renderer"
|
||||||
import { chunkFar, visibleChunks, type Scene } from "./renderScene"
|
import { chunkFar, visibleChunks, visibleMobs, type Scene } from "./renderScene"
|
||||||
|
|
||||||
const FOV = Math.PI / 3
|
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 screen = document.querySelector<HTMLCanvasElement>("#screen")!
|
||||||
const ctx = screen.getContext("2d")!
|
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> {
|
async function main(): Promise<void> {
|
||||||
const textures = await loadTextures()
|
const textures = await loadTextures()
|
||||||
const level = buildLevel()
|
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 = {
|
const scene: Scene = {
|
||||||
chunks: level.chunks,
|
chunks: level.chunks,
|
||||||
floor: level.floor,
|
floor: level.floor,
|
||||||
walls: level.walls,
|
walls: level.walls,
|
||||||
crate: level.crate,
|
crate: level.crate,
|
||||||
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
|
npc: { position: level.npcPosition, size: { x: 1.1, y: 1.5 } },
|
||||||
|
mobMesh: { frog: frogMesh, bee: beeMesh },
|
||||||
|
mobCount: level.mobs.length,
|
||||||
sky: level.sky,
|
sky: level.sky,
|
||||||
textures,
|
textures,
|
||||||
}
|
}
|
||||||
|
|
@ -99,11 +114,13 @@ async function main(): Promise<void> {
|
||||||
globalThis.addEventListener("resize", layout)
|
globalThis.addEventListener("resize", layout)
|
||||||
|
|
||||||
if (benchMode) {
|
if (benchMode) {
|
||||||
runBench(renderer, level.chunks, present, benchMode)
|
runBench(renderer, level, present, benchMode)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const player: Player = { position: { x: 0, y: 0, z: 8 }, yaw: 0, pitch: 0, velocityY: 0, onGround: true }
|
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>()
|
const keys = new Set<string>()
|
||||||
globalThis.addEventListener("keydown", (e) => {
|
globalThis.addEventListener("keydown", (e) => {
|
||||||
keys.add(e.code)
|
keys.add(e.code)
|
||||||
|
|
@ -200,6 +217,10 @@ async function main(): Promise<void> {
|
||||||
presentMax = 0
|
presentMax = 0
|
||||||
vsyncMax = 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)
|
Player.update(player, keys, dt, level)
|
||||||
const camera: Camera = {
|
const camera: Camera = {
|
||||||
position: { x: player.position.x, y: player.position.y + EYE_HEIGHT, z: player.position.z },
|
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 viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||||
const visible = visibleChunks(level.chunks, viewProj)
|
const visible = visibleChunks(level.chunks, viewProj)
|
||||||
|
const mobDraws = visibleMobs(level.mobs, viewProj)
|
||||||
lastVisible = visible
|
lastVisible = visible
|
||||||
lastCamera = camera
|
lastCamera = camera
|
||||||
renderer.dispatch(camera, viewProj, visible, now / 1000)
|
renderer.dispatch(camera, viewProj, visible, now / 1000, mobDraws)
|
||||||
inFlight = true
|
inFlight = true
|
||||||
if (renderer.done()) {
|
if (renderer.done()) {
|
||||||
show()
|
show()
|
||||||
|
|
@ -225,7 +247,7 @@ async function main(): Promise<void> {
|
||||||
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
* interval, then reports the distributions (exposed on `window.__BENCH__`). */
|
||||||
function runBench(
|
function runBench(
|
||||||
renderer: ReturnType<typeof createRenderer>,
|
renderer: ReturnType<typeof createRenderer>,
|
||||||
chunks: Scene["chunks"],
|
level: Level,
|
||||||
present: () => void,
|
present: () => void,
|
||||||
mode: string,
|
mode: string,
|
||||||
): void {
|
): void {
|
||||||
|
|
@ -281,10 +303,14 @@ function runBench(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const m of level.mobs) {
|
||||||
|
Mob.update(m, 1 / 60, level.terrain)
|
||||||
|
}
|
||||||
const camera = benchCamera(i / 60)
|
const camera = benchCamera(i / 60)
|
||||||
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
const viewProj = Camera.viewProjection(camera, renderer.fb.width / renderer.fb.height)
|
||||||
const visible = visibleChunks(chunks, viewProj)
|
const visible = visibleChunks(level.chunks, viewProj)
|
||||||
renderer.dispatch(camera, viewProj, visible, i / 60)
|
const mobDraws = visibleMobs(level.mobs, viewProj)
|
||||||
|
renderer.dispatch(camera, viewProj, visible, i / 60, mobDraws)
|
||||||
inFlight = true
|
inFlight = true
|
||||||
if (renderer.done() && record()) {
|
if (renderer.done() && record()) {
|
||||||
report()
|
report()
|
||||||
|
|
@ -293,6 +319,31 @@ function runBench(
|
||||||
requestAnimationFrame(tick)
|
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) => {
|
main().catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import type { Framebuffer } from "../engine/render/Framebuffer"
|
import type { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
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)
|
/** 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. */
|
* scene, this worker's row band, and its index into the per-worker times array. */
|
||||||
|
|
@ -17,6 +17,7 @@ type Init = {
|
||||||
camSAB: SharedArrayBuffer
|
camSAB: SharedArrayBuffer
|
||||||
vpSAB: SharedArrayBuffer
|
vpSAB: SharedArrayBuffer
|
||||||
visSAB: SharedArrayBuffer
|
visSAB: SharedArrayBuffer
|
||||||
|
mobSAB: SharedArrayBuffer
|
||||||
timesSAB: SharedArrayBuffer
|
timesSAB: SharedArrayBuffer
|
||||||
index: number
|
index: number
|
||||||
}
|
}
|
||||||
|
|
@ -24,6 +25,7 @@ type Init = {
|
||||||
const FRAME = 0
|
const FRAME = 0
|
||||||
const DONE = 1
|
const DONE = 1
|
||||||
const VIS = 2
|
const VIS = 2
|
||||||
|
const MOBVIS = 3
|
||||||
|
|
||||||
const ctx = globalThis as unknown as {
|
const ctx = globalThis as unknown as {
|
||||||
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
|
addEventListener: (type: "message", handler: (e: { data: Init }) => void) => void
|
||||||
|
|
@ -41,6 +43,7 @@ ctx.addEventListener("message", (e) => {
|
||||||
const cam = new Float64Array(m.camSAB)
|
const cam = new Float64Array(m.camSAB)
|
||||||
const vp = new Float32Array(m.vpSAB)
|
const vp = new Float32Array(m.vpSAB)
|
||||||
const vis = new Int32Array(m.visSAB)
|
const vis = new Int32Array(m.visSAB)
|
||||||
|
const mob = new Float32Array(m.mobSAB)
|
||||||
const times = new Float64Array(m.timesSAB)
|
const times = new Float64Array(m.timesSAB)
|
||||||
const { scene, band, config, skyStep, index } = m
|
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 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 count = Atomics.load(ctrl, VIS)
|
||||||
const visible = [...vis.subarray(0, count)]
|
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
|
times[index] = performance.now() - t0
|
||||||
Atomics.add(ctrl, DONE, 1)
|
Atomics.add(ctrl, DONE, 1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import { Rasterizer } from "../engine/render/Rasterizer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import { Sky, type SkyConfig } from "../engine/render/Sky"
|
import { Sky, type SkyConfig } from "../engine/render/Sky"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
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 type { Mesh } from "../engine/scene/Mesh"
|
||||||
|
import { Mob, type MobKind } from "../engine/scene/Mob"
|
||||||
import { Sprite } from "../engine/scene/Sprite"
|
import { Sprite } from "../engine/scene/Sprite"
|
||||||
import type { Vec2 } from "../engine/math/Vec2"
|
import type { Vec2 } from "../engine/math/Vec2"
|
||||||
import type { Vec3 } from "../engine/math/Vec3"
|
import type { Vec3 } from "../engine/math/Vec3"
|
||||||
|
|
@ -21,10 +22,22 @@ export type Scene = {
|
||||||
walls: Mesh
|
walls: Mesh
|
||||||
crate: Mesh
|
crate: Mesh
|
||||||
npc: { position: Vec3; size: Vec2 }
|
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
|
sky: SkyConfig
|
||||||
textures: Textures
|
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
|
/** 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). */
|
* the main thread and shared with every worker (so they don't each re-cull). */
|
||||||
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
|
export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
|
||||||
|
|
@ -39,6 +52,24 @@ export function visibleChunks(chunks: Chunk[], viewProj: Mat4): number[] {
|
||||||
return out
|
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 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
|
* render truth: the single-thread path calls it with the full height, and each
|
||||||
|
|
@ -51,6 +82,7 @@ export function renderBand(
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
viewProj: Mat4,
|
viewProj: Mat4,
|
||||||
visible: number[],
|
visible: number[],
|
||||||
|
mobDraws: MobDraw[],
|
||||||
config: RenderConfig,
|
config: RenderConfig,
|
||||||
skyStep: number,
|
skyStep: number,
|
||||||
time: number,
|
time: number,
|
||||||
|
|
@ -84,6 +116,15 @@ export function renderBand(
|
||||||
}
|
}
|
||||||
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
|
const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc }
|
||||||
Rasterizer.draw(fb, Sprite.billboard(sprite, camera), tx.npc, viewProj, config, false, y0, y1)
|
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)
|
Framebuffer.quantize(fb, config, y0, y1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Framebuffer } from "../engine/render/Framebuffer"
|
||||||
import type { RenderConfig } from "../engine/render/RenderConfig"
|
import type { RenderConfig } from "../engine/render/RenderConfig"
|
||||||
import type { Mat4 } from "../engine/math/Mat4"
|
import type { Mat4 } from "../engine/math/Mat4"
|
||||||
import type { Camera } from "../engine/scene/Camera"
|
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. */
|
/** Sky is drawn at 1/SKY_STEP resolution; band splits align to it. */
|
||||||
const SKY_STEP = 2
|
const SKY_STEP = 2
|
||||||
|
|
@ -21,6 +21,7 @@ const MAX_WORKERS = 3
|
||||||
const FRAME = 0 // bumped by main to dispatch a frame
|
const FRAME = 0 // bumped by main to dispatch a frame
|
||||||
const DONE = 1 // workers add 1 when their band is finished
|
const DONE = 1 // workers add 1 when their band is finished
|
||||||
const VIS = 2 // number of visible chunk indices this frame
|
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
|
* 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
|
readonly parallel: boolean
|
||||||
reconfigure: (config: RenderConfig) => void
|
reconfigure: (config: RenderConfig) => void
|
||||||
/** Start rendering one frame (non-blocking in the worker path). */
|
/** 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.) */
|
/** Has the dispatched frame finished? (always true single-threaded.) */
|
||||||
done: () => boolean
|
done: () => boolean
|
||||||
/** Critical-path render time of the last frame, ms (max band time / inline time). */
|
/** 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 hw = (globalThis.navigator as Navigator | undefined)?.hardwareConcurrency ?? 4
|
||||||
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
|
const workerCount = Math.max(1, Math.min(MAX_WORKERS, hw - 1))
|
||||||
const maxVis = Math.max(1, scene.chunks.length)
|
const maxVis = Math.max(1, scene.chunks.length)
|
||||||
|
const maxMobs = Math.max(1, scene.mobCount)
|
||||||
let config = initial
|
let config = initial
|
||||||
const want = forceWorkers ?? ENABLE_WORKERS
|
const want = forceWorkers ?? ENABLE_WORKERS
|
||||||
let parallel = want && canShare()
|
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 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 vp: Float32Array<ArrayBufferLike> = new Float32Array(0) // the view-projection matrix
|
||||||
let vis: Int32Array<ArrayBufferLike> = new Int32Array(0) // visible chunk indices
|
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 times: Float64Array<ArrayBufferLike> = new Float64Array(0) // per-worker band render ms
|
||||||
let lastWork = 0
|
let lastWork = 0
|
||||||
|
|
||||||
|
|
@ -77,6 +80,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
|
||||||
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
|
cam = new Float64Array(new SharedArrayBuffer(7 * 8))
|
||||||
vp = new Float32Array(new SharedArrayBuffer(16 * 4))
|
vp = new Float32Array(new SharedArrayBuffer(16 * 4))
|
||||||
vis = new Int32Array(new SharedArrayBuffer(maxVis * 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))
|
times = new Float64Array(new SharedArrayBuffer(bands.length * 8))
|
||||||
try {
|
try {
|
||||||
bands.forEach((band, index) => {
|
bands.forEach((band, index) => {
|
||||||
|
|
@ -97,6 +101,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
|
||||||
camSAB: cam.buffer,
|
camSAB: cam.buffer,
|
||||||
vpSAB: vp.buffer,
|
vpSAB: vp.buffer,
|
||||||
visSAB: vis.buffer,
|
visSAB: vis.buffer,
|
||||||
|
mobSAB: mob.buffer,
|
||||||
timesSAB: times.buffer,
|
timesSAB: times.buffer,
|
||||||
index,
|
index,
|
||||||
})
|
})
|
||||||
|
|
@ -127,7 +132,7 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
|
||||||
config = next
|
config = next
|
||||||
setup()
|
setup()
|
||||||
},
|
},
|
||||||
dispatch(camera, viewProj, visible, time) {
|
dispatch(camera, viewProj, visible, time, mobDraws) {
|
||||||
if (parallel && workers.length > 0) {
|
if (parallel && workers.length > 0) {
|
||||||
cam[0] = camera.position.x
|
cam[0] = camera.position.x
|
||||||
cam[1] = camera.position.y
|
cam[1] = camera.position.y
|
||||||
|
|
@ -141,14 +146,26 @@ export function createRenderer(scene: Scene, initial: RenderConfig, forceWorkers
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
vis[i] = visible[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, VIS, count)
|
||||||
|
Atomics.store(ctrl, MOBVIS, mobCount)
|
||||||
Atomics.store(ctrl, DONE, 0)
|
Atomics.store(ctrl, DONE, 0)
|
||||||
Atomics.add(ctrl, FRAME, 1)
|
Atomics.add(ctrl, FRAME, 1)
|
||||||
Atomics.notify(ctrl, FRAME, workers.length)
|
Atomics.notify(ctrl, FRAME, workers.length)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const t0 = performance.now()
|
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
|
lastWork = performance.now() - t0
|
||||||
},
|
},
|
||||||
done() {
|
done() {
|
||||||
|
|
|
||||||
BIN
assets/bee.png
Normal file
BIN
assets/bee.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
assets/frog.png
Normal file
BIN
assets/frog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
|
|
@ -20,6 +20,26 @@ export namespace Mat4 {
|
||||||
return out
|
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
|
/** 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
|
* frustum to clip space; the -1 in row 3 copies -z into w, so the later
|
||||||
* divide by w is what produces foreshortening. */
|
* divide by w is what produces foreshortening. */
|
||||||
|
|
|
||||||
264
engine/scene/Mob.ts
Normal file
264
engine/scene/Mob.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -194,6 +194,31 @@ const crate: Shade = (x, y) => {
|
||||||
return [140 + n, 96 + n, 46 + n, 255]
|
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.
|
// 48x64, transparent background, a simple round-topped figure with eyes.
|
||||||
const npc: Shade = (x, y) => {
|
const npc: Shade = (x, y) => {
|
||||||
const dx = (x - 24) / 17
|
const dx = (x - 24) / 17
|
||||||
|
|
@ -231,6 +256,8 @@ const assets: Array<[string, number, number, Shade]> = [
|
||||||
["wall", 64, 64, wall],
|
["wall", 64, 64, wall],
|
||||||
["crate", 64, 64, crate],
|
["crate", 64, 64, crate],
|
||||||
["npc", 48, 64, npc],
|
["npc", 48, 64, npc],
|
||||||
|
["frog", 48, 48, frog],
|
||||||
|
["bee", 48, 48, bee],
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const [name, w, h, shade] of assets) {
|
for (const [name, w, h, shade] of assets) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue