diff --git a/AGENTS.md b/AGENTS.md index 8bdcb16..8fa5b97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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.5–2x 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 diff --git a/app/level.ts b/app/level.ts index bb5b907..1db0823 100644 --- a/app/level.ts +++ b/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 | 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. */ diff --git a/app/main.ts b/app/main.ts index 968f3bf..d3becd6 100644 --- a/app/main.ts +++ b/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("#screen")! const ctx = screen.getContext("2d")! @@ -109,15 +112,23 @@ async function main(): Promise { } 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() diff --git a/engine/render/Frustum.ts b/engine/render/Frustum.ts new file mode 100644 index 0000000..833887a --- /dev/null +++ b/engine/render/Frustum.ts @@ -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 + } +} diff --git a/engine/render/Rasterizer.ts b/engine/render/Rasterizer.ts index 078f939..4f4d1f6 100644 --- a/engine/render/Rasterizer.ts +++ b/engine/render/Rasterizer.ts @@ -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))) diff --git a/engine/render/Sky.ts b/engine/render/Sky.ts index dd44715..7f442bc 100644 --- a/engine/render/Sky.ts +++ b/engine/render/Sky.ts @@ -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 + } + } } } } diff --git a/engine/scene/Terrain.ts b/engine/scene/Terrain.ts index d42754d..3410883 100644 --- a/engine/scene/Terrain.ts +++ b/engine/scene/Terrain.ts @@ -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