perf: faster still

This commit is contained in:
Dan Finch 2026-08-04 19:12:09 +02:00
parent ef5029da1d
commit 67cd54fe33
8 changed files with 280 additions and 203 deletions

View file

@ -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.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.
Frustum + backface + half-res sky give ~1.52x, 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

View file

@ -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<Chunk, "minX" | "minY" | "minZ" | "maxX" |
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)
const verts = m.verts
for (let i = 0; i < verts.length; i += STRIDE) {
const x = verts[i]
const y = verts[i + 1]
const z = verts[i + 2]
minX = Math.min(minX, x)
minY = Math.min(minY, y)
minZ = Math.min(minZ, z)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
maxZ = Math.max(maxZ, z)
}
}
return maxX < minX ? null : { minX, minY, minZ, maxX, maxY, maxZ }
@ -310,7 +314,7 @@ function mulberry(seed: number): () => 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)
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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] }
}
}

View file

@ -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)
}
}
}

View file

@ -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