feat: culling

This commit is contained in:
Dan Finch 2026-08-04 15:05:02 +02:00
parent 680e08aadc
commit ef5029da1d
7 changed files with 309 additions and 103 deletions

View file

@ -18,22 +18,33 @@ export type Aabb = {
standable: boolean
}
/** One spatial cell of the outdoor world: its terrain patch + the trees/boulders
* standing in it, split by texture, plus an axis-aligned bounding box (tight to
* the actual geometry, so overhanging canopies aren't clipped). The renderer
* frustum-tests the box and skips the whole cell when it is off-screen -- this
* is what keeps a big, dense world affordable. Empty cells are never created. */
export type Chunk = {
minX: number
minY: number
minZ: number
maxX: number
maxY: number
maxZ: number
grass: Mesh
bark: Mesh
leaf: Mesh
needle: Mesh
rock: Mesh
}
/** The playground: a flat-floored room dropped into the center of a big open
* landscape. Geometry is split by texture, plus the collision solids, where the
* NPC stands, the heightfield the outdoor ground + player share, and the sky. */
* landscape. The room (floor/walls/crate) is small and always drawn; the
* outdoor world is split into `chunks` that are frustum-culled per frame. */
export type Level = {
floor: Mesh
walls: Mesh
crate: Mesh
ground: Mesh
/** All tree trunks + branches (bark texture). */
trunks: Mesh
/** Oak canopies (leaf texture) and spruce foliage (needle texture), split so
* each takes its own texture in one draw call. */
oakFoliage: Mesh
spruceFoliage: Mesh
/** Scattered boulders (rock texture). */
boulders: Mesh
chunks: Chunk[]
colliders: Aabb[]
npcPosition: { x: number; y: number; z: number }
terrain: Terrain
@ -80,10 +91,14 @@ const BOULDER_COUNT = 70
const BOULDER_SEED = 0xB0142
const BOULDER_REACH = 0.7
/** Outdoor ground mesh resolution. A fixed grid over the whole world, so cell
* size (and cost) is set here, not by the world's size: bigger `outer` gives
* chunkier terrain, not more triangles. `GROUND_UV` sets texture tiles/unit. */
const GROUND_DIVISIONS = 56
/** 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
* granularity knob. `TERRAIN_SUBDIV` is the terrain quads per cell edge, so the
* world's terrain resolution is `CHUNK_GRID * TERRAIN_SUBDIV`. `GROUND_UV` sets
* texture tiles/unit. */
const CHUNK_GRID = 12
const TERRAIN_SUBDIV = 5
const GROUND_UV = 0.25
/** The two cloud styles; swap which one the sky uses in `buildLevel`.
@ -118,9 +133,6 @@ export function buildLevel(): Level {
const fy = FLOOR_LIFT
quad(floor, [-ARENA, fy, -ARENA], [ARENA, fy, -ARENA], [ARENA, fy, ARENA], [-ARENA, fy, ARENA], 12, 12)
// The big surrounding landscape, with a hole where the room sits.
const ground = Terrain.ground(TERRAIN, GROUND_DIVISIONS, GROUND_UV)
const walls = mesh()
const h = WALL_HEIGHT
const t = WALL_THICKNESS
@ -159,27 +171,86 @@ export function buildLevel(): Level {
const npcPosition = { x: 2, y: 0, z: -1 }
// Scatter a forest on the grass; appends into the tree meshes + trunk colliders.
const trunks = mesh()
const oakFoliage = mesh()
const spruceFoliage = mesh()
scatterTrees(trunks, oakFoliage, spruceFoliage, colliders)
// Place the props (also pushes their colliders), then bake everything into
// frustum-cullable spatial chunks.
const trees = placeTrees(colliders)
const boulders = placeBoulders(colliders)
const chunks = buildChunks(trees, boulders)
// Scatter boulders across the terrain; big ones get colliders.
const boulders = mesh()
scatterBoulders(boulders, colliders)
return { floor, walls, crate, chunks, colliders, npcPosition, terrain: TERRAIN, sky }
}
return { floor, walls, crate, ground, trunks, oakFoliage, spruceFoliage, boulders, colliders, npcPosition, terrain: TERRAIN, sky }
/** Bake the terrain + props into a `CHUNK_GRID` x `CHUNK_GRID` set of spatial
* chunks. Each prop lands in the cell holding its base; the cell's bounds are
* grown to the real geometry so overhanging canopies never get culled early. */
function buildChunks(trees: Tree[], boulders: Boulder[]): Chunk[] {
const cell = (TERRAIN.outer * 2) / CHUNK_GRID
const chunks: Chunk[] = []
for (let ci = 0; ci < CHUNK_GRID; ci++) {
const x0 = -TERRAIN.outer + ci * cell
const x1 = x0 + cell
for (let cj = 0; cj < CHUNK_GRID; cj++) {
const z0 = -TERRAIN.outer + cj * cell
const z1 = z0 + cell
const grass = mesh()
const bark = mesh()
const leaf = mesh()
const needle = mesh()
const rock = mesh()
Terrain.patch(TERRAIN, grass, x0, z0, x1, z1, TERRAIN_SUBDIV, TERRAIN_SUBDIV, GROUND_UV)
for (const tree of trees) {
if (inCell(tree.position, x0, z0, x1, z1)) {
Tree.build(tree, bark, tree.kind === "oak" ? leaf : needle)
}
}
for (const boulder of boulders) {
if (inCell(boulder.position, x0, z0, x1, z1)) {
Boulder.build(boulder, rock)
}
}
const b = bounds([grass, bark, leaf, needle, rock])
if (b === null) {
continue
}
chunks.push({ ...b, grass, bark, leaf, needle, rock })
}
}
return chunks
}
function inCell(p: { x: number; z: number }, x0: number, z0: number, x1: number, z1: number): boolean {
return p.x >= x0 && p.x < x1 && p.z >= z0 && p.z < z1
}
/** Tight AABB over several meshes' vertices, or null if they are all empty. */
function bounds(meshes: Mesh[]): Pick<Chunk, "minX" | "minY" | "minZ" | "maxX" | "maxY" | "maxZ"> | null {
let minX = Infinity
let minY = Infinity
let minZ = Infinity
let maxX = -Infinity
let maxY = -Infinity
let maxZ = -Infinity
for (const m of meshes) {
for (const v of m.vertices) {
minX = Math.min(minX, v.pos.x)
minY = Math.min(minY, v.pos.y)
minZ = Math.min(minZ, v.pos.z)
maxX = Math.max(maxX, v.pos.x)
maxY = Math.max(maxY, v.pos.y)
maxZ = Math.max(maxZ, v.pos.z)
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
}
/** Place `TREE_COUNT` trees around the room on walkable grass: each sits on the
* terrain, rolls oak/spruce and a growth stage, and (once past sapling size)
* drops a trunk collider so you can't walk through it. */
function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colliders: Aabb[]): void {
function placeTrees(colliders: Aabb[]): Tree[] {
const rand = mulberry(TREE_SEED)
const maxDist = TERRAIN.outer * TREE_REACH
let placed = 0
for (let guard = 0; placed < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
const trees: Tree[] = []
for (let guard = 0; trees.length < TREE_COUNT && guard < TREE_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 5 + rand() * (maxDist - ARENA - 5)
const x = Math.cos(angle) * dist
@ -191,24 +262,24 @@ function scatterTrees(trunks: Mesh, oakFoliage: Mesh, spruceFoliage: Mesh, colli
const kind = rand() < 0.5 ? "oak" : "spruce"
const growth = 0.08 + rand() * 0.92
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
Tree.build({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 }, trunks, kind === "oak" ? oakFoliage : spruceFoliage)
trees.push({ kind, position, growth, seed: (rand() * 0xFFFFFFFF) | 0 })
// Saplings are passable; grown trunks block. Square footprint, non-standable.
if (growth > 0.35) {
const r = growth * (kind === "oak" ? 0.3 : 0.2) + 0.15
colliders.push({ minX: x - r, maxX: x + r, minZ: z - r, maxZ: z + r, top: position.y + 3, standable: false })
}
placed++
}
return trees
}
/** Scatter `BOULDER_COUNT` boulders across the terrain, sizes biased toward
* small. Each sits on the ground; big ones drop a blocking collider so you
* can't walk through them (little rocks stay passable). */
function scatterBoulders(boulders: Mesh, colliders: Aabb[]): void {
function placeBoulders(colliders: Aabb[]): Boulder[] {
const rand = mulberry(BOULDER_SEED)
const maxDist = TERRAIN.outer * BOULDER_REACH
let placed = 0
for (let guard = 0; placed < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
const boulders: Boulder[] = []
for (let guard = 0; boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20; guard++) {
const angle = rand() * Math.PI * 2
const dist = ARENA + 4 + rand() * (maxDist - ARENA - 4)
const x = Math.cos(angle) * dist
@ -219,12 +290,12 @@ function scatterBoulders(boulders: Mesh, colliders: Aabb[]): void {
// Square the roll so most rocks are small, a few are big.
const radius = 0.35 + rand() * rand() * 2.2
const position = { x, y: Terrain.height(TERRAIN, x, z), z }
Boulder.build({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 }, boulders)
boulders.push({ position, radius, seed: (rand() * 0xFFFFFFFF) | 0 })
if (radius > 0.7) {
colliders.push({ minX: x - radius, maxX: x + radius, minZ: z - radius, maxZ: z + radius, top: position.y + radius * 0.7, standable: false })
}
placed++
}
return boulders
}
/** Deterministic 0..1 generator (mulberry32) for tree placement. */

View file

@ -1,4 +1,5 @@
import { Framebuffer } from "../engine/render/Framebuffer"
import { Frustum } from "../engine/render/Frustum"
import { Rasterizer } from "../engine/render/Rasterizer"
import { RenderConfig } from "../engine/render/RenderConfig"
import { Sky } from "../engine/render/Sky"
@ -9,6 +10,8 @@ import { buildLevel } from "./level"
import { EYE_HEIGHT, Player } from "./player"
const FOV = Math.PI / 3
/** Sky is drawn at 1/SKY_STEP resolution (the cloud fbm is the costly part). */
const SKY_STEP = 2
const screen = document.querySelector<HTMLCanvasElement>("#screen")!
const ctx = screen.getContext("2d")!
@ -109,15 +112,23 @@ async function main(): Promise<void> {
}
const viewProj = Camera.viewProjection(camera, fb.width / fb.height)
Sky.render(fb, camera, level.sky, now / 1000)
Rasterizer.draw(fb, level.ground, textures.grass, viewProj, config)
Sky.render(fb, camera, level.sky, now / 1000, SKY_STEP)
// Room is small and always near where you play; draw it unconditionally.
Rasterizer.draw(fb, level.floor, textures.floor, viewProj, config)
Rasterizer.draw(fb, level.walls, textures.wall, viewProj, config)
Rasterizer.draw(fb, level.crate, textures.crate, viewProj, config)
Rasterizer.draw(fb, level.boulders, textures.rock, viewProj, config)
Rasterizer.draw(fb, level.trunks, textures.bark, viewProj, config)
Rasterizer.draw(fb, level.oakFoliage, textures.leaf, viewProj, config)
Rasterizer.draw(fb, level.spruceFoliage, textures.needle, viewProj, config)
// Outdoor world: skip whole chunks that fall outside the view frustum.
const frustum = Frustum.fromViewProj(viewProj)
for (const c of level.chunks) {
if (!Frustum.intersectsAabb(frustum, c.minX, c.minY, c.minZ, c.maxX, c.maxY, c.maxZ)) {
continue
}
Rasterizer.draw(fb, c.grass, textures.grass, viewProj, config, true)
Rasterizer.draw(fb, c.rock, textures.rock, viewProj, config, true)
Rasterizer.draw(fb, c.bark, textures.bark, viewProj, config, true)
Rasterizer.draw(fb, c.leaf, textures.leaf, viewProj, config, true)
Rasterizer.draw(fb, c.needle, textures.needle, viewProj, config, true)
}
Rasterizer.draw(fb, Sprite.billboard(npc, camera), npc.texture, viewProj, config)
Framebuffer.quantize(fb, config)
present()