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

@ -20,6 +20,11 @@ rules live in `.agents/rules/*.md`.
- **Minimal architecture.** Scene-graph-lite / plain data + functions.
Deliberately **not** ECS or any "Big Game Architecture." Prefer the smallest
clear structure; add knobs to experiment rather than abstractions.
- **Culling beats batching here.** A "draw call" is just a JS loop (no GPU state),
so merging the world into big meshes only defeats visibility skipping. Instead
the outdoor world is stored as spatial **chunks** that are frustum-culled per
frame; on-screen solids also **backface-cull**. This is what keeps a dense world
(thousands of trees/rocks) affordable — off-screen content costs ~nothing.
- **2D assets only.** Sprites/billboards (PS1-style), **no 3D model loading**.
- **Engine is headless.** `engine/` has no DOM types and could run server-side;
all browser glue (canvas, input, image decode) lives in `app/`.
@ -49,13 +54,15 @@ rules live in `.agents/rules/*.md`.
- `math/``Vec2`, `Vec3`, `Mat4` (column-major, OpenGL-style; verified).
- `render/``Color` (packed RGBA, little-endian = canvas ImageData order),
`Framebuffer` (Uint32 color + Float32 1/w depth; `quantize` = color-depth +
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`,
`Texture` (nearest/bilinear, wrapping, no mipmaps), `Sky` (gradient + sun +
procedural clouds).
Bayer dither), `RenderConfig` (the look dials + presets), `Rasterizer`
(optional backface cull per draw), `Frustum` (6 planes from the viewProj +
AABB test, for chunk culling), `Texture` (nearest/bilinear, wrapping, no
mipmaps), `Sky` (gradient + sun + procedural clouds; renders at 1/`step` res).
- `scene/``Camera` (fps yaw/pitch; far plane reaches the outdoor peaks),
`Mesh` (indexed tris), `Sprite` (Y-axis billboard), `Terrain` (procedural
heightfield around the room: flat clearing in the center, rolling hills, tall
edge peaks. `Terrain.ground` builds the outdoor mesh with a hole for the room;
edge peaks. `Terrain.patch` builds one ground patch over a rectangle -- called
per chunk, aligned so patches weld crack-free, with a hole for the room;
`Terrain.height` is the shared ground-height sampler for the player), `Tree`
(procedural low-poly oak/spruce geometry, sapling..full via a `growth` knob;
`Tree.build` appends into shared trunk + foliage meshes), `Boulder`
@ -66,18 +73,17 @@ rules live in `.agents/rules/*.md`.
- `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) in the center of a big grassy `Terrain`
world (~20x across).
Per-texture meshes incl. the outdoor grass `ground`, a scattered forest
(`scatterTrees``trunks`/`oakFoliage`/`spruceFoliage`, `TREE_COUNT`/`_SEED`/
`_REACH`) and `boulders` (`scatterBoulders`, `BOULDER_COUNT`/`_SEED`/`_REACH`),
`Aabb` colliders (incl. grown-tree trunks + big boulders), NPC position,
`Terrain` config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config.
The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the
terrain skirt that laps under the room edges. Room surfaces are single flat
quads -- no subdivision needed since texturing is perspective-correct; ground
triangle count is `GROUND_DIVISIONS` (fixed grid, so world size sets cell
chunkiness, not tri count).
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` → instance lists + colliders; `TREE_/BOULDER_COUNT`/`_SEED`/
`_REACH`), 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 + a tight AABB) that `main` frustum-culls. `Aabb` colliders (walls, crate,
grown trunks, big boulders), NPC position, `TERRAIN`/`TERRAIN_SUBDIV`/`GROUND_UV`,
sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it
stays clean over the terrain skirt that laps under the room edges. Room surfaces
are single flat quads -- no subdivision needed since texturing is
perspective-correct.
- `player.ts` — feet-cylinder player: gravity/jump + Shift-run
(`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast
running can't tunnel walls; ground height from `Terrain.height` (plus
@ -95,17 +101,21 @@ rules live in `.agents/rules/*.md`.
## Frame pipeline (`app/main.ts` `frame`)
`Player.update` → build `Camera``Camera.viewProjection`
`Sky.render` (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` ground, floor, walls, crate, boulders, tree trunks, oak
foliage, spruce foliage (one call per texture) →
`Sprite.billboard(npc)` drawn via `Rasterizer.draw`
`Sky.render` at 1/`SKY_STEP` res (fills color + resets depth, replaces a clear) →
`Rasterizer.draw` floor, walls, crate (room, always) → `Frustum.fromViewProj`,
then for each `Chunk` that `Frustum.intersectsAabb` passes: draw its grass, rock,
bark, leaf, needle (backface-culled) → `Sprite.billboard(npc)` (double-sided)
`Framebuffer.quantize``present` (integer-scale, letterboxed blit;
`imageSmoothingEnabled` follows `upscaleFilter`).
Rasterizer specifics: near-plane clip (Sutherland-Hodgman), **1/w z-buffer**,
perspective-correct UVs, screen-space vertex snap, flat directional lighting,
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
**double-sided** (no backface culling).
distance fog, **alpha cutout** (discard texel alpha < 128, for sprites).
**Backface culling is opt-in** (`draw(..., cull)`, default off = double-sided):
on for solid world chunks, off for sprites and the room. It relies on winding, so
generators feeding culled draws (terrain patch, tree/boulder builders) must wind
front-out — a culled mesh that renders inside-out has its index order flipped
(see `Terrain.patch`). The cull sign: back-facing == positive screen area here.
## The look — where to tune
@ -118,14 +128,27 @@ distance fog, **alpha cutout** (discard texel alpha < 128, for sprites),
- **`app/level.ts` `GROUND_UV`** (0.25) — outdoor ground texture tiles per world
unit. Lower = the stone tiles bigger and less busy = less far-distance moire
(there are no mipmaps); higher = finer but shimmerier.
- **`app/level.ts` `GROUND_DIVISIONS`** (56) — outdoor ground grid resolution and
the **main outdoor FPS lever**. The open vista is **transform-bound** on the
ground's triangles (no frustum culling — every tri is projected each frame), so
cost is ~linear in this: measured ~45 fps at 48, ~28 fps at 64, ~24 fps at 96
(headless, `standard`). Lower it for FPS, raise for finer terrain. Draw distance
(`fog.far` + `Camera` far plane, pushed out to ~200/260 for this scene) is
comparatively cheap since far ground is a thin horizon band. Cranking
`TERRAIN.peakHeight`/`outer` costs almost nothing (same tri count).
- **`app/level.ts` `CHUNK_GRID`** (12) / `TERRAIN_SUBDIV` (5) — spatial-cull
granularity and terrain resolution. World terrain divisions = `CHUNK_GRID *
TERRAIN_SUBDIV`. Smaller cells cull tighter (draw less off-screen) but cost more
per-cell tests/bounds. This is the lever if a dense world still lags.
## Performance / where the frame goes
The world is dense (hundreds of trees + boulders, ~50k tris) but most of it is
off-screen or fogged each frame, so three things keep it cheap:
- **Frustum culling** (`Frustum` + per-`Chunk` AABB test in `main`) — skips whole
chunks that fall outside the view. Behind you + off to the sides = free.
- **Backface culling** (`draw(..., true)`) — ~halves fill on solid geometry
(terrain, foliage, rock). See the Rasterizer note re winding.
- **Half-res sky** (`SKY_STEP` in `main`, default 2) — the cloud fbm runs per
pixel and dominated the frame; sampling once per 2×2 block quarters it.
Together ~1.52x over drawing everything full-res every frame, and the win grows
with content since culled chunks cost ~nothing. Next levers if needed: LOD /
impostors for far trees, flat typed-array geometry (kill per-tri allocation),
Web-Worker banded rasterization. `TREE_COUNT`/`BOULDER_COUNT` are the blunt
content dials.
## Clouds

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()

66
engine/render/Frustum.ts Normal file
View file

@ -0,0 +1,66 @@
import type { Mat4 } from "../math/Mat4"
/** The six view-frustum planes packed as (a, b, c, d) each, normal pointing
* inward: a point is inside a plane when a*x + b*y + c*z + d >= 0. */
export type Frustum = Float32Array
export namespace Frustum {
/** Extract the planes from a view-projection matrix (Gribb-Hartmann). Our Mat4
* is column-major (`m[col*4 + row]`), so a clip-space row `i` gathers the
* `i`-th entry of every column. Left/right/bottom/top/near/far are the row
* sums/differences with the w-row. */
export function fromViewProj(m: Mat4): Frustum {
const rx = [m[0], m[1], m[2], m[3]]
const ry = [m[4], m[5], m[6], m[7]]
const rz = [m[8], m[9], m[10], m[11]]
const rw = [m[12], m[13], m[14], m[15]]
// Row i of the clip matrix = (rx[i], ry[i], rz[i], rw[i]).
const row = (i: number): [number, number, number, number] => [rx[i], ry[i], rz[i], rw[i]]
const [x0, y0, z0, w0] = row(0)
const [x1, y1, z1, w1] = row(1)
const [x2, y2, z2, w2] = row(2)
const [x3, y3, z3, w3] = row(3)
const f = new Float32Array(24)
plane(f, 0, x3 + x0, y3 + y0, z3 + z0, w3 + w0) // left
plane(f, 1, x3 - x0, y3 - y0, z3 - z0, w3 - w0) // right
plane(f, 2, x3 + x1, y3 + y1, z3 + z1, w3 + w1) // bottom
plane(f, 3, x3 - x1, y3 - y1, z3 - z1, w3 - w1) // top
plane(f, 4, x3 + x2, y3 + y2, z3 + z2, w3 + w2) // near
plane(f, 5, x3 - x2, y3 - y2, z3 - z2, w3 - w2) // far
return f
}
/** True if the axis-aligned box might be visible. Conservative: tests the box
* corner farthest along each plane normal; the box is culled only if that
* corner is still outside some plane, so nothing visible is ever dropped. */
export function intersectsAabb(
f: Frustum,
minX: number,
minY: number,
minZ: number,
maxX: number,
maxY: number,
maxZ: number,
): boolean {
for (let p = 0; p < 24; p += 4) {
const a = f[p]
const b = f[p + 1]
const c = f[p + 2]
const px = a >= 0 ? maxX : minX
const py = b >= 0 ? maxY : minY
const pz = c >= 0 ? maxZ : minZ
if (a * px + b * py + c * pz + f[p + 3] < 0) {
return false
}
}
return true
}
function plane(f: Frustum, i: number, a: number, b: number, c: number, d: number): void {
const inv = 1 / Math.hypot(a, b, c)
f[i * 4] = a * inv
f[i * 4 + 1] = b * inv
f[i * 4 + 2] = c * inv
f[i * 4 + 3] = d * inv
}
}

View file

@ -46,6 +46,7 @@ export namespace Rasterizer {
texture: Texture,
viewProj: Mat4,
config: RenderConfig,
cull = false,
): void {
const { vertices, indices } = mesh
for (let t = 0; t + 2 < indices.length; t += 3) {
@ -56,7 +57,7 @@ export namespace Rasterizer {
// Near-clipping can turn one triangle into a quad; fan it back to tris.
const poly = clipNear([project(viewProj, a), project(viewProj, b), project(viewProj, c)])
for (let k = 1; k + 1 < poly.length; k++) {
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config)
fillTriangle(fb, poly[0], poly[k], poly[k + 1], shade, texture, config, cull)
}
}
}
@ -146,6 +147,7 @@ export namespace Rasterizer {
shade: number,
texture: Texture,
config: RenderConfig,
cull: boolean,
): void {
const a = toScreen(fb, va, config.vertexSnap)
const b = toScreen(fb, vb, config.vertexSnap)
@ -154,6 +156,12 @@ export namespace Rasterizer {
if (area === 0) {
return
}
// Backface cull: a back-facing triangle has the opposite screen winding
// (positive area here). Only enabled for solid, consistently-wound meshes;
// sprites and the room stay double-sided (cull = false).
if (cull && area > 0) {
return
}
const minX = Math.max(0, Math.floor(Math.min(a.sx, b.sx, c.sx)))
const maxX = Math.min(fb.width - 1, Math.ceil(Math.max(a.sx, b.sx, c.sx)))
const minY = Math.max(0, Math.floor(Math.min(a.sy, b.sy, c.sy)))

View file

@ -58,8 +58,13 @@ export namespace Sky {
* Per pixel it reconstructs the view ray from the camera basis, shades a
* horizon->zenith gradient by the ray's elevation, brightens toward `sun` near
* `sunDir`, then lays crisp-edged cumulus over the top.
*
* `step` (>= 1) renders the sky at 1/step resolution: the expensive shading
* (the per-pixel cloud fbm dominates the frame) runs once per step x step
* block and is copied across it. The sky is low-frequency, so 2 is nearly free
* visually and quarters the cloud cost; 1 is full resolution.
*/
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number): void {
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1): void {
const { width, height, color, depth } = fb
const forward = Camera.forward(camera)
const right = Vec3.normalize(Vec3.cross(forward, UP))
@ -70,10 +75,15 @@ export namespace Sky {
const cosSun = Math.cos(sky.sunSize)
const clouds = sky.clouds
const cloud: CloudSample = { cover: 0, shade: 1 }
for (let y = 0; y < height; y++) {
const ndcY = 1 - ((y + 0.5) / height) * 2
for (let x = 0; x < width; x++) {
const ndcX = ((x + 0.5) / width) * 2 - 1
const s = Math.max(1, step | 0)
for (let by = 0; by < height; by += s) {
// Shade at the block center, then flood the whole block with that color.
const sampleY = Math.min(height - 1, by + (s >> 1))
const ndcY = 1 - ((sampleY + 0.5) / height) * 2
const yEnd = Math.min(height, by + s)
for (let bx = 0; bx < width; bx += s) {
const sampleX = Math.min(width - 1, bx + (s >> 1))
const ndcX = ((sampleX + 0.5) / width) * 2 - 1
// View ray = forward + right*ndcX*tanX + up*ndcY*tanY, then normalized.
let dx = forward.x + right.x * ndcX * tanX + up.x * ndcY * tanY
let dy = forward.y + right.y * ndcX * tanX + up.y * ndcY * tanY
@ -100,9 +110,14 @@ export namespace Sky {
c = Color.lerp(c, Color.scale(clouds.color, cloud.shade), cloud.cover)
}
}
const i = y * width + x
color[i] = c
depth[i] = 0
const xEnd = Math.min(width, bx + s)
for (let y = by; y < yEnd; y++) {
const o = y * width
for (let x = bx; x < xEnd; x++) {
color[o + x] = c
depth[o + x] = 0
}
}
}
}
}

View file

@ -42,34 +42,46 @@ export namespace Terrain {
return rise * (hills + peaks)
}
/** Build the outdoor ground as a `divisions`x`divisions` grid over the whole
* world, each vertex lifted onto the heightfield. Cells inside the clearing
* are skipped so the mesh has a hole where the flat room floor goes (no
* z-fighting). `uvScale` sets texture tiles per world unit. */
export function ground(t: Terrain, divisions: number, uvScale: number): Mesh {
const vertices: Mesh["vertices"] = []
const indices: number[] = []
const step = (t.outer * 2) / divisions
const row = divisions + 1
for (let i = 0; i <= divisions; i++) {
const z = -t.outer + i * step
for (let j = 0; j <= divisions; j++) {
const x = -t.outer + j * step
vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
/** Append one ground patch: a `cols`x`rows` heightfield grid over the rectangle
* [x0,x1] x [z0,z1], each vertex lifted onto the heightfield. Quads whose
* center is inside the clearing are skipped (the room floor's hole). UVs use
* world position * `uvScale`, so neighboring patches tile seamlessly. Callers
* keep the spacing uniform and cell edges aligned, so shared edges weld with
* no cracks. Used to build the terrain per spatial chunk. */
export function patch(
t: Terrain,
mesh: Mesh,
x0: number,
z0: number,
x1: number,
z1: number,
cols: number,
rows: number,
uvScale: number,
): void {
const base = mesh.vertices.length
const dx = (x1 - x0) / cols
const dz = (z1 - z0) / rows
const stride = cols + 1
for (let i = 0; i <= rows; i++) {
const z = z0 + i * dz
for (let j = 0; j <= cols; j++) {
const x = x0 + j * dx
mesh.vertices.push({ pos: { x, y: height(t, x, z), z }, uv: { x: x * uvScale, y: z * uvScale } })
}
}
for (let i = 0; i < divisions; i++) {
for (let j = 0; j < divisions; j++) {
const cx = -t.outer + (j + 0.5) * step
const cz = -t.outer + (i + 0.5) * step
for (let i = 0; i < rows; i++) {
for (let j = 0; j < cols; j++) {
const cx = x0 + (j + 0.5) * dx
const cz = z0 + (i + 0.5) * dz
if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) {
continue
}
const p = i * row + j
indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
const p = base + i * stride + j
// Wound so the surface faces up/out, matching the backface-cull sign.
mesh.indices.push(p, p + stride + 1, p + 1, p, p + stride, p + stride + 1)
}
}
return { vertices, indices }
}
/** Rolling hills in 0..1, always non-negative so the ground never dips below