import { Framebuffer } from "../engine/render/Framebuffer" import { Frustum } from "../engine/render/Frustum" 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 { 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" import type { Textures } from "./assets" import type { Chunk } from "./level" /** Everything needed to render the world: the room, the cullable chunks, the NPC * billboard source, sky, and textures. Bundled so it can be handed to a worker * whole (it is plain data + typed arrays, structured-clone friendly). */ export type Scene = { chunks: Chunk[] floor: Mesh 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: Record /** 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 index, x, y, z, heading, scale /** Canonical kind order -- the index packed into the shared `mobState` buffer * (main packs `indexOf`, each worker reads it back). Keep frog/bee first so the * existing indices don't shift. */ export const MOB_KINDS: MobKind[] = ["frog", "bee", "robin"] /** 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[] { const frustum = Frustum.fromViewProj(viewProj) const out: number[] = [] for (let i = 0; i < chunks.length; i++) { const c = chunks[i] if (Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) { out.push(i) } } 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 * worker calls it with its own disjoint band -- same output either way, and no * two bands touch the same pixel (so the shared framebuffer needs no locking). */ export function renderBand( fb: Framebuffer, scene: Scene, camera: Camera, viewProj: Mat4, visible: number[], mobDraws: MobDraw[], config: RenderConfig, skyStep: number, time: number, y0: number, y1: number, ): void { const tx = scene.textures Sky.render(fb, camera, scene.sky, time, skyStep, y0, y1) // Room: small and always near, drawn unconditionally (double-sided). Rasterizer.draw(fb, scene.floor, tx.floor, viewProj, config, false, y0, y1) Rasterizer.draw(fb, scene.walls, tx.wall, viewProj, config, false, y0, y1) Rasterizer.draw(fb, scene.crate, tx.crate, viewProj, config, false, y0, y1) for (const i of visible) { const c = scene.chunks[i] Rasterizer.draw(fb, c.grass, tx.grass, viewProj, config, true, y0, y1) // Past lodDistance, swap full tree/boulder geometry for cheap impostors. // `chunkFar` is pure (camera + chunk bounds + config), so every worker band // makes the identical choice -- no full/impostor seam across bands. if (chunkFar(c, camera.position, config.lodDistance)) { Rasterizer.draw(fb, c.rockFar, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.barkFar, tx.bark, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.birchBarkFar, tx.birch, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leafFar, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needleFar, tx.needle, viewProj, config, true, y0, y1) } else { Rasterizer.draw(fb, c.rock, tx.rock, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.bark, tx.bark, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.birchBark, tx.birch, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.leaf, tx.leaf, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.needle, tx.needle, viewProj, config, true, y0, y1) Rasterizer.draw(fb, c.flowers, tx.flower, viewProj, config, false, y0, y1) } } const sprite: Sprite = { position: scene.npc.position, size: scene.npc.size, texture: tx.npc } 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 mvp = Mat4.multiply(viewProj, Mat4.compose(m.x, m.y, m.z, m.heading, m.scale)) Rasterizer.draw(fb, scene.mobMesh[m.kind], tx[m.kind], mvp, config, false, y0, y1) } Framebuffer.quantize(fb, config, y0, y1) } /** Whether a chunk is far enough to draw its impostor meshes: squared distance * from the camera to the chunk's AABB vs `lodDistance²`. Pure -- depends only on * camera, the chunk's baked bounds, and the config constant, all of which every * worker already holds, so the choice is identical across bands. */ export function chunkFar(chunk: Chunk, eye: Vec3, lodDistance: number): boolean { if (!(lodDistance < Infinity)) { return false } const dx = eye.x - Math.max(chunk.minX, Math.min(chunk.maxX, eye.x)) const dy = eye.y - Math.max(chunk.minY, Math.min(chunk.maxY, eye.y)) const dz = eye.z - Math.max(chunk.minZ, Math.min(chunk.maxZ, eye.z)) return dx * dx + dy * dy + dz * dz > lodDistance * lodDistance }