From 67cd54fe334135d5814d064c3aeab4e94d6ca9c6 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 19:12:09 +0200 Subject: [PATCH] perf: faster still --- AGENTS.md | 22 ++- app/level.ts | 31 ++-- engine/render/Rasterizer.ts | 314 +++++++++++++++++++++--------------- engine/scene/Boulder.ts | 19 ++- engine/scene/Mesh.ts | 33 ++-- engine/scene/Sprite.ts | 11 +- engine/scene/Terrain.ts | 12 +- engine/scene/Tree.ts | 41 +++-- 8 files changed, 280 insertions(+), 203 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8fa5b97..0584638 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,9 @@ rules live in `.agents/rules/*.md`. 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 + `Mesh` (indexed tris; verts stored flat: `STRIDE` floats x,y,z,u,v per vertex, + no per-vertex objects — cache-friendly + alloc-free to draw), `Sprite` + (Y-axis billboard), `Terrain` (procedural heightfield around the room: flat clearing in the center, rolling hills, tall 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; @@ -136,19 +138,25 @@ front-out — a culled mesh that renders inside-out has its index order flipped ## 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: +off-screen or fogged each frame, so several 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. +- **Flat geometry + zero-alloc raster** — `Mesh` is a flat float array and the + whole per-triangle path uses reused scratch, so a frame allocates ~0 bytes + (measured). This buys frame *consistency* (no GC-pause spikes; worst/mean ~1.3x) + and makes geometry shareable for Web-Worker rasterization later. Note it did + **not** raise mean fps — allocation was never the bottleneck (JSC collects the + churn ~free); the mean is the transform+fill **compute**. -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. +Frustum + backface + half-res sky give ~1.5–2x, growing with content since culled +chunks cost ~nothing. The remaining bottleneck is raw compute on visible tris, so +the mean-fps levers left are to **do less** (LOD / impostors for far trees — +`TREE_COUNT`/`BOULDER_COUNT` are the blunt content dials) or **use more cores** +(Web-Worker banded rasterization, now unblocked by the flat geometry). ## Clouds diff --git a/app/level.ts b/app/level.ts index 1db0823..5dd01c2 100644 --- a/app/level.ts +++ b/app/level.ts @@ -1,6 +1,6 @@ import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" -import type { Mesh } from "../engine/scene/Mesh" +import { STRIDE, type Mesh } from "../engine/scene/Mesh" import { Boulder } from "../engine/scene/Boulder" import { Terrain } from "../engine/scene/Terrain" import { Tree } from "../engine/scene/Tree" @@ -231,13 +231,17 @@ function bounds(meshes: Mesh[]): Pick number { } function mesh(): Mesh { - return { vertices: [], indices: [] } + return { verts: [], indices: [] } } /** A perimeter wall collider: blocks from the sides, and `standable` so you can @@ -324,13 +328,8 @@ function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb { * needed -- texturing is perspective-correct, so a single quad looks right at * any size. */ function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void { - const base = m.vertices.length - m.vertices.push( - { pos: { x: a[0], y: a[1], z: a[2] }, uv: { x: 0, y: 0 } }, - { pos: { x: b[0], y: b[1], z: b[2] }, uv: { x: us, y: 0 } }, - { pos: { x: c[0], y: c[1], z: c[2] }, uv: { x: us, y: vs } }, - { pos: { x: d[0], y: d[1], z: d[2] }, uv: { x: 0, y: vs } }, - ) + const base = m.verts.length / STRIDE + m.verts.push(a[0], a[1], a[2], 0, 0, b[0], b[1], b[2], us, 0, c[0], c[1], c[2], us, vs, d[0], d[1], d[2], 0, vs) m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) } diff --git a/engine/render/Rasterizer.ts b/engine/render/Rasterizer.ts index 4f4d1f6..b907e52 100644 --- a/engine/render/Rasterizer.ts +++ b/engine/render/Rasterizer.ts @@ -1,27 +1,29 @@ import { Color } from "./Color" import type { Framebuffer } from "./Framebuffer" -import type { RenderConfig } from "./RenderConfig" +import type { Fog, RenderConfig } from "./RenderConfig" import { Texture } from "./Texture" -import { Mat4 } from "../math/Mat4" -import { Vec3 } from "../math/Vec3" -import type { Mesh, Vertex } from "../scene/Mesh" - -/** Vertex in clip space, carrying the texture coords that must survive - * near-plane clipping (which splits triangles and creates new vertices). */ -type ClipVertex = { x: number; y: number; w: number; u: number; v: number } - -/** Vertex after the perspective divide, in framebuffer pixels. `invW` (= 1/w) - * is kept per vertex because it drives both the depth test and perspective- - * correct texturing. */ -type ScreenVertex = { sx: number; sy: number; invW: number; u: number; v: number } +import type { Mat4 } from "../math/Mat4" +import { STRIDE, type Mesh } from "../scene/Mesh" /** Anything with w below this is treated as behind the camera and clipped. */ const NEAR_W = 0.01 -/** Fixed world-space directional light. Normalized once at module load. */ -const LIGHT = Vec3.normalize({ x: 0.4, y: 1, z: 0.35 }) +/** Fixed world-space directional light (normalized components). */ +const LIGHT_LEN = Math.hypot(0.4, 1, 0.35) +const LIGHT_X = 0.4 / LIGHT_LEN +const LIGHT_Y = 1 / LIGHT_LEN +const LIGHT_Z = 0.35 / LIGHT_LEN const AMBIENT = 0.4 const DIFFUSE = 0.6 +/** Floats per clip-space vertex in the scratch buffers: x, y, w, u, v (clip z is + * unused, so it is dropped). */ +const CLIP = 5 +/** Reused per-triangle scratch: the 3 projected verts (`src`) and the near-clip + * result (`dst`, up to 4 verts). Module-level so the hot path never allocates. + * Safe because a triangle is fully processed before the next one starts. */ +const src = new Float64Array(3 * CLIP) +const dst = new Float64Array(4 * CLIP) + /** * Software triangle rasterizer — the heart of the PS1 look. * @@ -31,15 +33,17 @@ const DIFFUSE = 0.6 * interpolates depth as 1/w, perspective-correct texture coords, and applies * flat shading plus distance fog. * + * Meshes are stored flat (see `Mesh`) and the whole per-triangle path works in + * reused scratch buffers, so drawing allocates nothing — no GC churn, no frame + * spikes. `cull` enables backface culling for solid, consistently-wound meshes. + * * The period-accurate rough edges are deliberate, not unfinished: no mipmaps * (so distant textures shimmer/moire) and no antialiasing (jagged silhouettes). - * Depth is a plain 1/w z-buffer and triangles are drawn double-sided (no - * backface culling), so mesh winding can never cause surfaces to drop out. */ export namespace Rasterizer { - /** Draw an indexed mesh into the framebuffer through a view-projection - * matrix. Shading is flat (one normal per face), so it is computed once per - * triangle here and shared by every pixel the triangle covers. */ + /** Draw an indexed mesh into the framebuffer through a view-projection matrix. + * Shading is flat (one normal per face), computed once per triangle. `cull` + * drops back-facing triangles (default off = double-sided). */ export function draw( fb: Framebuffer, mesh: Mesh, @@ -48,165 +52,221 @@ export namespace Rasterizer { config: RenderConfig, cull = false, ): void { - const { vertices, indices } = mesh + const { verts, indices } = mesh + const flat = config.lighting === "flat" for (let t = 0; t + 2 < indices.length; t += 3) { - const a = vertices[indices[t]] - const b = vertices[indices[t + 1]] - const c = vertices[indices[t + 2]] - const shade = config.lighting === "flat" ? flatShade(a, b, c) : 1 + const o0 = indices[t] * STRIDE + const o1 = indices[t + 1] * STRIDE + const o2 = indices[t + 2] * STRIDE + const shade = flat ? flatShade(verts, o0, o1, o2) : 1 + project(viewProj, verts, o0, 0) + project(viewProj, verts, o1, CLIP) + project(viewProj, verts, o2, CLIP * 2) // 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, cull) + const n = clipNear(3) + for (let k = 1; k + 1 < n; k++) { + fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull) } } } - function project(m: Mat4, vertex: Vertex): ClipVertex { - const p = Mat4.transform(m, vertex.pos) - return { x: p.x, y: p.y, w: p.w, u: vertex.uv.x, v: vertex.uv.y } + /** Transform vertex `o` of `verts` by `m` into clip space, written to `src` at + * `out`. Only x, y, w are needed (z is unused); the matrix multiply is inlined + * to avoid allocating a result object. */ + function project(m: Mat4, verts: number[], o: number, out: number): void { + const x = verts[o] + const y = verts[o + 1] + const z = verts[o + 2] + src[out] = m[0] * x + m[4] * y + m[8] * z + m[12] + src[out + 1] = m[1] * x + m[5] * y + m[9] * z + m[13] + src[out + 2] = m[3] * x + m[7] * y + m[11] * z + m[15] + src[out + 3] = verts[o + 3] + src[out + 4] = verts[o + 4] } /** Flat (per-face) directional shade in 0..1: ambient plus diffuse from the - * face normal. `abs()` makes it two-sided so back-facing tris still light. */ - function flatShade(a: Vertex, b: Vertex, c: Vertex): number { - const normal = Vec3.normalize(Vec3.cross(Vec3.sub(b.pos, a.pos), Vec3.sub(c.pos, a.pos))) - return Math.min(1, AMBIENT + DIFFUSE * Math.abs(Vec3.dot(normal, LIGHT))) + * face normal (cross of two edges). `abs()` makes it two-sided so back-facing + * tris still light. Reads positions straight from the flat vertex array. */ + function flatShade(verts: number[], o0: number, o1: number, o2: number): number { + const ax = verts[o0] + const ay = verts[o0 + 1] + const az = verts[o0 + 2] + const e1x = verts[o1] - ax + const e1y = verts[o1 + 1] - ay + const e1z = verts[o1 + 2] - az + const e2x = verts[o2] - ax + const e2y = verts[o2 + 1] - ay + const e2z = verts[o2 + 2] - az + const nx = e1y * e2z - e1z * e2y + const ny = e1z * e2x - e1x * e2z + const nz = e1x * e2y - e1y * e2x + const len = Math.hypot(nx, ny, nz) + if (len === 0) { + return AMBIENT + } + const d = Math.abs((nx * LIGHT_X + ny * LIGHT_Y + nz * LIGHT_Z) / len) + return Math.min(1, AMBIENT + DIFFUSE * d) } /** - * Clip a polygon against the camera plane (w = NEAR_W) with a single - * Sutherland-Hodgman pass, returning its vertices as a fan (0, 3, or 4). + * Clip the `count`-vertex polygon in `src` against the camera plane (w = + * NEAR_W) with a single Sutherland-Hodgman pass, writing the result (0, 3, or + * 4 verts) to `dst` and returning its vertex count. * * This matters even when standing inside the room: a wall to your side has * vertices both in front of and behind the eye. Without clipping, the behind * vertices have w <= 0 and invert under the perspective divide, smearing the - * triangle across the whole screen (and risking divide-by-zero). Clipping - * trims the triangle to just the visible part instead of dropping it. + * triangle across the whole screen (and risking divide-by-zero). */ - function clipNear(poly: ClipVertex[]): ClipVertex[] { - const out: ClipVertex[] = [] - for (let i = 0; i < poly.length; i++) { - const cur = poly[i] - const prev = poly[(i + poly.length - 1) % poly.length] - const curIn = cur.w >= NEAR_W - const prevIn = prev.w >= NEAR_W + function clipNear(count: number): number { + let out = 0 + for (let i = 0; i < count; i++) { + const ci = i * CLIP + const pi = ((i + count - 1) % count) * CLIP + const curW = src[ci + 2] + const prevW = src[pi + 2] + const curIn = curW >= NEAR_W + const prevIn = prevW >= NEAR_W // Crossing the plane emits the intersection point before the inside one. if (curIn !== prevIn) { - out.push(intersectNear(prev, cur)) + const t = (NEAR_W - prevW) / (curW - prevW) + const o = out * CLIP + dst[o] = src[pi] + (src[ci] - src[pi]) * t + dst[o + 1] = src[pi + 1] + (src[ci + 1] - src[pi + 1]) * t + dst[o + 2] = prevW + (curW - prevW) * t + dst[o + 3] = src[pi + 3] + (src[ci + 3] - src[pi + 3]) * t + dst[o + 4] = src[pi + 4] + (src[ci + 4] - src[pi + 4]) * t + out++ } if (curIn) { - out.push(cur) + const o = out * CLIP + dst[o] = src[ci] + dst[o + 1] = src[ci + 1] + dst[o + 2] = curW + dst[o + 3] = src[ci + 3] + dst[o + 4] = src[ci + 4] + out++ } } return out } - /** Point where edge a->b crosses w = NEAR_W, with every attribute lerped. */ - function intersectNear(a: ClipVertex, b: ClipVertex): ClipVertex { - const t = (NEAR_W - a.w) / (b.w - a.w) - return { - x: a.x + (b.x - a.x) * t, - y: a.y + (b.y - a.y) * t, - w: a.w + (b.w - a.w) * t, - u: a.u + (b.u - a.u) * t, - v: a.v + (b.v - a.v) * t, - } - } - - /** Perspective-divide a clip vertex into framebuffer pixels. + /** + * Scan-convert one clip-space triangle (verts `ia`, `ib`, `ic` in `dst`). * - * Vertex snap: real PS1 hardware transformed vertices in low-precision fixed - * point, so screen positions landed on a coarse grid and visibly popped - * between pixels as the camera moved — the trademark "vertex wobble". We - * emulate it by snapping to a `snap`-pixel grid. 0 disables it (smooth). */ - function toScreen(fb: Framebuffer, c: ClipVertex, snap: number): ScreenVertex { - const invW = 1 / c.w - let sx = (c.x * invW * 0.5 + 0.5) * fb.width - let sy = (1 - (c.y * invW * 0.5 + 0.5)) * fb.height - if (snap > 0) { - sx = Math.round(sx / snap) * snap - sy = Math.round(sy / snap) * snap - } - return { sx, sy, invW, u: c.u, v: c.v } - } - - /** Signed area of the triangle (a, b, point) times two. Its sign tells which - * side of edge a->b the point is on; the three edge values are the - * (unnormalized) barycentric weights. */ - function edge(a: ScreenVertex, b: ScreenVertex, px: number, py: number): number { - return (b.sx - a.sx) * (py - a.sy) - (b.sy - a.sy) * (px - a.sx) - } - - /** Scan-convert one clip-space triangle into the framebuffer. */ + * Vertex snap: real PS1 hardware transformed vertices in low-precision fixed + * point, so screen positions popped between pixels as the camera moved (the + * trademark "vertex wobble"). We emulate it by snapping to a `snap`-pixel grid. + */ function fillTriangle( fb: Framebuffer, - va: ClipVertex, - vb: ClipVertex, - vc: ClipVertex, + ia: number, + ib: number, + ic: number, shade: number, texture: Texture, config: RenderConfig, cull: boolean, ): void { - const a = toScreen(fb, va, config.vertexSnap) - const b = toScreen(fb, vb, config.vertexSnap) - const c = toScreen(fb, vc, config.vertexSnap) - const area = edge(a, b, c.sx, c.sy) + const oa = ia * CLIP + const ob = ib * CLIP + const oc = ic * CLIP + const width = fb.width + const height = fb.height + const snap = config.vertexSnap + const invWa = 1 / dst[oa + 2] + const invWb = 1 / dst[ob + 2] + const invWc = 1 / dst[oc + 2] + let sxA = (dst[oa] * invWa * 0.5 + 0.5) * width + let syA = (1 - (dst[oa + 1] * invWa * 0.5 + 0.5)) * height + let sxB = (dst[ob] * invWb * 0.5 + 0.5) * width + let syB = (1 - (dst[ob + 1] * invWb * 0.5 + 0.5)) * height + let sxC = (dst[oc] * invWc * 0.5 + 0.5) * width + let syC = (1 - (dst[oc + 1] * invWc * 0.5 + 0.5)) * height + if (snap > 0) { + sxA = Math.round(sxA / snap) * snap + syA = Math.round(syA / snap) * snap + sxB = Math.round(sxB / snap) * snap + syB = Math.round(syB / snap) * snap + sxC = Math.round(sxC / snap) * snap + syC = Math.round(syC / snap) * snap + } + // Signed area x2; its sign is the screen winding. + const area = (sxB - sxA) * (syC - syA) - (syB - syA) * (sxC - sxA) 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). + // Backface cull: a back-facing triangle has positive area here. Only for + // solid, consistently-wound meshes; sprites/room stay double-sided. 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))) - const maxY = Math.min(fb.height - 1, Math.ceil(Math.max(a.sy, b.sy, c.sy))) + const invArea = 1 / area + const uA = dst[oa + 3] + const vA = dst[oa + 4] + const uB = dst[ob + 3] + const vB = dst[ob + 4] + const uC = dst[oc + 3] + const vC = dst[oc + 4] + const minX = Math.max(0, Math.floor(Math.min(sxA, sxB, sxC))) + const maxX = Math.min(width - 1, Math.ceil(Math.max(sxA, sxB, sxC))) + const minY = Math.max(0, Math.floor(Math.min(syA, syB, syC))) + const maxY = Math.min(height - 1, Math.ceil(Math.max(syA, syB, syC))) + // Edge deltas for the three barycentric edge functions (b->c, c->a, a->b). + const dx0 = sxC - sxB + const dy0 = syC - syB + const dx1 = sxA - sxC + const dy1 = syA - syC + const dx2 = sxB - sxA + const dy2 = syB - syA const fog = config.fog + const filter = config.textureFilter + const color = fb.color + const depth = fb.depth for (let y = minY; y <= maxY; y++) { + const py = y + 0.5 + const rowStart = y * width for (let x = minX; x <= maxX; x++) { const px = x + 0.5 - const py = y + 0.5 - // Barycentric weights, normalized by area so they sum to 1. Dividing by - // a signed area accepts either winding, which is why culling is unneeded. - const w0 = edge(b, c, px, py) / area - const w1 = edge(c, a, px, py) / area - const w2 = edge(a, b, px, py) / area - if (w0 < 0 || w1 < 0 || w2 < 0) { + // Barycentric weights, normalized so they sum to 1. Dividing by a signed + // area accepts either winding. + const w0 = (dx0 * (py - syB) - dy0 * (px - sxB)) * invArea + if (w0 < 0) { continue } - // 1/w interpolates linearly in screen space, so this is exact. Larger = - // nearer; the z-buffer keeps the max seen per pixel. - const invW = w0 * a.invW + w1 * b.invW + w2 * c.invW - const idx = y * fb.width + x - if (invW <= fb.depth[idx]) { + const w1 = (dx1 * (py - syC) - dy1 * (px - sxC)) * invArea + if (w1 < 0) { continue } - // Perspective-correct texture coords: divide the interpolated u/w and - // v/w by the interpolated 1/w to undo foreshortening, so textures sit - // flat on receding surfaces with no affine "swim". - const u = (w0 * a.u * a.invW + w1 * b.u * b.invW + w2 * c.u * c.invW) / invW - const v = (w0 * a.v * a.invW + w1 * b.v * b.invW + w2 * c.v * c.invW) / invW - // Alpha cutout: discard transparent texels so sprites read as cutouts, - // not rectangles. Opaque world textures are alpha 255, so unaffected. - const texel = Texture.sample(texture, u, v, config.textureFilter) + const w2 = (dx2 * (py - syA) - dy2 * (px - sxA)) * invArea + if (w2 < 0) { + continue + } + // 1/w interpolates linearly in screen space. Larger = nearer. + const invW = w0 * invWa + w1 * invWb + w2 * invWc + const idx = rowStart + x + if (invW <= depth[idx]) { + continue + } + // Perspective-correct texture coords: divide interpolated u/w, v/w by 1/w + // to undo foreshortening, so textures sit flat on receding surfaces. + const u = (w0 * uA * invWa + w1 * uB * invWb + w2 * uC * invWc) / invW + const v = (w0 * vA * invWa + w1 * vB * invWb + w2 * vC * invWc) / invW + // Alpha cutout: discard transparent texels so sprites read as cutouts. + const texel = Texture.sample(texture, u, v, filter) if (Color.a(texel) < 128) { continue } - let color = Color.scale(texel, shade) - if (fog !== null) { - // dist == w (view-space depth); fade from full color to fog color. - const dist = 1 / invW - const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near))) - color = Color.lerp(fog.color, color, f) - } - fb.color[idx] = color - fb.depth[idx] = invW + color[idx] = fog === null ? Color.scale(texel, shade) : shadeFog(texel, shade, fog, invW) + depth[idx] = invW } } } + + /** Shade a texel then fade it toward the fog color by view-space distance. */ + function shadeFog(texel: Color, shade: number, fog: Fog, invW: number): Color { + const dist = 1 / invW + const f = Math.min(1, Math.max(0, (fog.far - dist) / (fog.far - fog.near))) + return Color.lerp(fog.color, Color.scale(texel, shade), f) + } } diff --git a/engine/scene/Boulder.ts b/engine/scene/Boulder.ts index b11d660..9e8b1e9 100644 --- a/engine/scene/Boulder.ts +++ b/engine/scene/Boulder.ts @@ -1,5 +1,5 @@ import type { Vec3 } from "../math/Vec3" -import type { Mesh } from "./Mesh" +import { STRIDE, type Mesh } from "./Mesh" const TAU = Math.PI * 2 @@ -37,7 +37,7 @@ export namespace Boulder { const cy = boulder.position.y + sy * 0.55 const jitter = jitterGrid(seg, rings, rand) - const start = mesh.vertices.length + const start = mesh.verts.length / STRIDE for (let ir = 0; ir <= rings; ir++) { const phi = (ir / rings) * Math.PI const cyv = Math.cos(phi) @@ -45,14 +45,13 @@ export namespace Boulder { for (let is = 0; is <= seg; is++) { const theta = (is / seg) * TAU const j = jitter[ir][is] - mesh.vertices.push({ - pos: { - x: cx + crv * Math.cos(theta) * sx * j, - y: cy + cyv * sy * j, - z: cz + crv * Math.sin(theta) * sz * j, - }, - uv: { x: (is / seg) * 1.5, y: (ir / rings) * 1.5 }, - }) + mesh.verts.push( + cx + crv * Math.cos(theta) * sx * j, + cy + cyv * sy * j, + cz + crv * Math.sin(theta) * sz * j, + (is / seg) * 1.5, + (ir / rings) * 1.5, + ) } } const row = seg + 1 diff --git a/engine/scene/Mesh.ts b/engine/scene/Mesh.ts index 6f2fea8..34aaa2a 100644 --- a/engine/scene/Mesh.ts +++ b/engine/scene/Mesh.ts @@ -1,11 +1,26 @@ -import type { Vec2 } from "../math/Vec2" -import type { Vec3 } from "../math/Vec3" +/** Floats per vertex in `Mesh.verts`: position x, y, z then texture u, v. */ +export const STRIDE = 5 -/** One mesh vertex: a world-space position and its texture coordinate. uv is in - * tile units, not 0..1, so values >1 repeat the texture (see Texture.sample). */ -export type Vertex = { pos: Vec3; uv: Vec2 } +/** + * Indexed triangle mesh, stored flat for speed. `verts` is a packed run of + * `STRIDE` floats per vertex (x, y, z, u, v) instead of an array of nested + * `{pos, uv}` objects, so the transform loop reads contiguous numbers with no + * pointer chasing or per-vertex allocation. `indices` holds three vertex indices + * per triangle (an index `i` addresses `verts[i * STRIDE ..]`); sharing vertices + * keeps seams welded and shrinks the data. uv is in tile units, not 0..1, so + * values >1 repeat the texture (see Texture.sample). Build with `Mesh.push`. + */ +export type Mesh = { verts: number[]; indices: number[] } -/** Indexed triangle mesh in world space. `indices` holds three entries per - * triangle, each indexing into `vertices`; sharing vertices between triangles - * keeps seams welded and shrinks the data. */ -export type Mesh = { vertices: Vertex[]; indices: number[] } +export namespace Mesh { + export function create(): Mesh { + return { verts: [], indices: [] } + } + + /** Append a vertex, returning its index (for wiring up `indices`). */ + export function push(mesh: Mesh, x: number, y: number, z: number, u: number, v: number): number { + const index = mesh.verts.length / STRIDE + mesh.verts.push(x, y, z, u, v) + return index + } +} diff --git a/engine/scene/Sprite.ts b/engine/scene/Sprite.ts index 61c120d..8b58778 100644 --- a/engine/scene/Sprite.ts +++ b/engine/scene/Sprite.ts @@ -34,12 +34,9 @@ export namespace Sprite { const lz = p.z - rz * hw const gx = p.x + rx * hw const gz = p.z + rz * hw - const vertices = [ - { pos: { x: lx, y: y0, z: lz }, uv: { x: 0, y: 1 } }, - { pos: { x: gx, y: y0, z: gz }, uv: { x: 1, y: 1 } }, - { pos: { x: gx, y: y1, z: gz }, uv: { x: 1, y: 0 } }, - { pos: { x: lx, y: y1, z: lz }, uv: { x: 0, y: 0 } }, - ] - return { vertices, indices: [0, 1, 2, 0, 2, 3] } + // Flat verts (x, y, z, u, v) per corner: bottom-left, bottom-right, top-right, + // top-left. + const verts = [lx, y0, lz, 0, 1, gx, y0, gz, 1, 1, gx, y1, gz, 1, 0, lx, y1, lz, 0, 0] + return { verts, indices: [0, 1, 2, 0, 2, 3] } } } diff --git a/engine/scene/Terrain.ts b/engine/scene/Terrain.ts index 3410883..d1de1ec 100644 --- a/engine/scene/Terrain.ts +++ b/engine/scene/Terrain.ts @@ -1,4 +1,4 @@ -import type { Mesh } from "./Mesh" +import { STRIDE, type Mesh } from "./Mesh" /** A procedural heightfield surrounding the room. It is the single source of * ground height: the outdoor mesh is built from it and the player stands on the @@ -59,15 +59,15 @@ export namespace Terrain { rows: number, uvScale: number, ): void { - const base = mesh.vertices.length + const base = mesh.verts.length / STRIDE const dx = (x1 - x0) / cols const dz = (z1 - z0) / rows - const stride = cols + 1 + const rowLen = 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 } }) + mesh.verts.push(x, height(t, x, z), z, x * uvScale, z * uvScale) } } for (let i = 0; i < rows; i++) { @@ -77,9 +77,9 @@ export namespace Terrain { if (Math.max(Math.abs(cx), Math.abs(cz)) < t.inner) { continue } - const p = base + i * stride + j + const p = base + i * rowLen + 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) + mesh.indices.push(p, p + rowLen + 1, p + 1, p, p + rowLen, p + rowLen + 1) } } } diff --git a/engine/scene/Tree.ts b/engine/scene/Tree.ts index 7aae1dc..cb0a700 100644 --- a/engine/scene/Tree.ts +++ b/engine/scene/Tree.ts @@ -1,5 +1,5 @@ import { Vec3 } from "../math/Vec3" -import type { Mesh } from "./Mesh" +import { STRIDE, type Mesh } from "./Mesh" const TAU = Math.PI * 2 @@ -95,13 +95,15 @@ export namespace Tree { const axis = Vec3.normalize(Vec3.sub(b, a)) const [u, v] = basis(axis) const len = Vec3.length(Vec3.sub(b, a)) - const start = mesh.vertices.length + const start = mesh.verts.length / STRIDE for (let i = 0; i <= sides; i++) { const angle = (i / sides) * TAU - const dir = Vec3.add(Vec3.scale(u, Math.cos(angle)), Vec3.scale(v, Math.sin(angle))) + const dx = u.x * Math.cos(angle) + v.x * Math.sin(angle) + const dy = u.y * Math.cos(angle) + v.y * Math.sin(angle) + const dz = u.z * Math.cos(angle) + v.z * Math.sin(angle) const s = i / sides - mesh.vertices.push({ pos: Vec3.add(a, Vec3.scale(dir, ra)), uv: { x: s * 1.5, y: 0 } }) - mesh.vertices.push({ pos: Vec3.add(b, Vec3.scale(dir, rb)), uv: { x: s * 1.5, y: len * 0.5 } }) + mesh.verts.push(a.x + dx * ra, a.y + dy * ra, a.z + dz * ra, s * 1.5, 0) + mesh.verts.push(b.x + dx * rb, b.y + dy * rb, b.z + dz * rb, s * 1.5, len * 0.5) } for (let i = 0; i < sides; i++) { const p = start + i * 2 @@ -111,17 +113,15 @@ export namespace Tree { /** A cone standing on a base ring, apex `height` above it (one spruce tier). */ function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void { - const start = mesh.vertices.length - mesh.vertices.push({ pos: { x: base.x, y: base.y + height, z: base.z }, uv: { x: 0.5, y: 0 } }) + const start = mesh.verts.length / STRIDE + mesh.verts.push(base.x, base.y + height, base.z, 0.5, 0) for (let i = 0; i <= sides; i++) { const angle = (i / sides) * TAU - mesh.vertices.push({ - pos: { x: base.x + Math.cos(angle) * radius, y: base.y, z: base.z + Math.sin(angle) * radius }, - uv: { x: (i / sides) * 2, y: 1 }, - }) + mesh.verts.push(base.x + Math.cos(angle) * radius, base.y, base.z + Math.sin(angle) * radius, (i / sides) * 2, 1) } for (let i = 0; i < sides; i++) { - mesh.indices.push(start, start + 1 + i, start + 2 + i) + // Wound so the outer surface faces out, matching the backface-cull sign. + mesh.indices.push(start, start + 2 + i, start + 1 + i) } } @@ -130,7 +130,7 @@ export namespace Tree { function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void { const seg = 5 const rings = 3 - const start = mesh.vertices.length + const start = mesh.verts.length / STRIDE for (let r = 0; r <= rings; r++) { const phi = (r / rings) * Math.PI const cy = Math.cos(phi) @@ -138,14 +138,13 @@ export namespace Tree { const scale = radius * (0.85 + rand() * 0.3) for (let s = 0; s <= seg; s++) { const theta = (s / seg) * TAU - mesh.vertices.push({ - pos: { - x: center.x + cr * Math.cos(theta) * scale, - y: center.y + cy * scale, - z: center.z + cr * Math.sin(theta) * scale, - }, - uv: { x: (s / seg) * 2, y: (r / rings) * 2 }, - }) + mesh.verts.push( + center.x + cr * Math.cos(theta) * scale, + center.y + cy * scale, + center.z + cr * Math.sin(theta) * scale, + (s / seg) * 2, + (r / rings) * 2, + ) } } const row = seg + 1