feat: culling
This commit is contained in:
parent
680e08aadc
commit
ef5029da1d
7 changed files with 309 additions and 103 deletions
145
app/level.ts
145
app/level.ts
|
|
@ -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. */
|
||||
|
|
|
|||
23
app/main.ts
23
app/main.ts
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue