feat: threads

This commit is contained in:
Dan Finch 2026-08-05 09:24:13 +02:00
parent 6fb46573da
commit 46e7070b6b
18 changed files with 878 additions and 125 deletions

View file

@ -40,13 +40,13 @@ export namespace Framebuffer {
* 15-bit output. Skipped entirely when it would be a no-op (full depth, no
* dither).
*/
export function quantize(fb: Framebuffer, config: RenderConfig): void {
export function quantize(fb: Framebuffer, config: RenderConfig, y0 = 0, y1 = fb.height): void {
const levels = (1 << config.colorDepth) - 1
if (levels >= 255 && config.dither === 0) {
return
}
const { width, height, color } = fb
for (let y = 0; y < height; y++) {
const { width, color } = fb
for (let y = y0; y < y1; y++) {
for (let x = 0; x < width; x++) {
// Per-pixel threshold from the tiled Bayer matrix, centered on 0 and
// scaled by strength, nudges each channel before it snaps to a level.

View file

@ -51,6 +51,8 @@ export namespace Rasterizer {
viewProj: Mat4,
config: RenderConfig,
cull = false,
clipY0 = 0,
clipY1 = 1 << 30,
): void {
const { verts, indices } = mesh
const flat = config.lighting === "flat"
@ -65,7 +67,7 @@ export namespace Rasterizer {
// Near-clipping can turn one triangle into a quad; fan it back to tris.
const n = clipNear(3)
for (let k = 1; k + 1 < n; k++) {
fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull)
fillTriangle(fb, 0, k, k + 1, shade, texture, config, cull, clipY0, clipY1)
}
}
}
@ -167,6 +169,8 @@ export namespace Rasterizer {
texture: Texture,
config: RenderConfig,
cull: boolean,
clipY0: number,
clipY1: number,
): void {
const oa = ia * CLIP
const ob = ib * CLIP
@ -210,8 +214,10 @@ export namespace Rasterizer {
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)))
// Clamp to the caller's Y-band (default full frame) so worker threads can
// each fill a disjoint slice of rows without ever touching the same pixel.
const minY = Math.max(0, clipY0, Math.floor(Math.min(syA, syB, syC)))
const maxY = Math.min(height - 1, clipY1 - 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

View file

@ -46,6 +46,11 @@ export type RenderConfig = {
* short draw distance and the shimmer of far geometry. It also colors pixels
* no triangle covers, so the frame's clear color should match `fog.color`. */
fog: Fog | null
/** Beyond this distance (world units) trees + boulders draw as cheap low-poly
* impostors instead of full geometry, cutting per-triangle work in dense
* views. Kept inside `fog.far` so far detail is already fog-dimmed at the
* switch; `Infinity` disables LOD. */
lodDistance: number
}
/** Ready-made looks. The demo binds keys 1/2/3 to these, sweeping resolution,
@ -61,6 +66,7 @@ export namespace RenderConfig {
textureFilter: "nearest",
lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
lodDistance: 60,
}
export const ps1: RenderConfig = {
@ -73,6 +79,7 @@ export namespace RenderConfig {
textureFilter: "nearest",
lighting: "flat",
fog: { color: Color.rgb(150, 170, 200), near: 12, far: 200 },
lodDistance: 60,
}
export const soft: RenderConfig = {
@ -85,6 +92,7 @@ export namespace RenderConfig {
textureFilter: "nearest",
lighting: "flat",
fog: { color: Color.rgb(170, 190, 215), near: 16, far: 240 },
lodDistance: 70,
}
export const clean: RenderConfig = {
@ -94,9 +102,9 @@ export namespace RenderConfig {
colorDepth: 8,
dither: 0,
vertexSnap: 0,
perspectiveCorrect: 1,
textureFilter: "linear",
lighting: "flat",
fog: null,
lodDistance: Infinity,
}
}

View file

@ -64,8 +64,9 @@ export namespace Sky {
* 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, step = 1): void {
export function render(fb: Framebuffer, camera: Camera, sky: SkyConfig, time: number, step = 1, y0 = 0, y1 = -1): void {
const { width, height, color, depth } = fb
const bottom = y1 < 0 ? height : y1
const forward = Camera.forward(camera)
const right = Vec3.normalize(Vec3.cross(forward, UP))
const up = Vec3.cross(right, forward)
@ -76,11 +77,13 @@ export namespace Sky {
const clouds = sky.clouds
const cloud: CloudSample = { cover: 0, shade: 1 }
const s = Math.max(1, step | 0)
for (let by = 0; by < height; by += s) {
// Band `y0`..`bottom` must be step-aligned (callers ensure it) so the block
// grid stays global and neighboring bands don't seam.
for (let by = y0; by < bottom; 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)
const yEnd = Math.min(bottom, 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 file

@ -22,10 +22,11 @@ export type Boulder = {
* field of boulders batches into a single draw call.
*/
export namespace Boulder {
export function build(boulder: Boulder, mesh: Mesh): void {
/** `lod` "impostor" bakes a coarser rock (fewer facets) for far chunks. */
export function build(boulder: Boulder, mesh: Mesh, lod: "full" | "impostor" = "full"): void {
const rand = rng(boulder.seed)
const seg = 5
const rings = 4
const seg = lod === "impostor" ? 4 : 5
const rings = lod === "impostor" ? 2 : 4
const r = boulder.radius
// Squat and slightly oval, so it reads as a rock, not a ball.
const sx = r * (0.8 + rand() * 0.5)

View file

@ -28,24 +28,32 @@ export type Tree = {
export namespace Tree {
/** Append one tree into the shared `trunk` (bark) mesh and the `foliage` mesh
* for its kind (oak leaf vs spruce needle). */
export function build(tree: Tree, trunk: Mesh, foliage: Mesh): void {
/** `lod` "impostor" bakes a much cheaper stand-in (few tris, same textures +
* faceted look, same height/position) for far chunks; "full" is up close. */
export function build(tree: Tree, trunk: Mesh, foliage: Mesh, lod: "full" | "impostor" = "full"): void {
const rand = rng(tree.seed)
if (tree.kind === "oak") {
oak(tree.position, tree.growth, rand, trunk, foliage)
oak(tree.position, tree.growth, rand, trunk, foliage, lod)
} else {
spruce(tree.position, tree.growth, rand, trunk, foliage)
spruce(tree.position, tree.growth, rand, trunk, foliage, lod)
}
}
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh): void {
function oak(base: Vec3, g: number, rand: () => number, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
const h = lerp(0.8, 7, g)
const rTrunk = lerp(0.04, 0.32, g)
const forkY = base.y + h * 0.5
const canopyY = base.y + h * 0.72
const blobR = h * 0.3
if (lod === "impostor") {
// One low-poly blob on a stubby trunk -- reads as an oak at distance.
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2)
return
}
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5)
const blobR = h * 0.3
const spread = h * 0.32
const canopyY = base.y + h * 0.72
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
const blobs = 1 + Math.round(g * 2)
for (let i = 0; i < blobs; i++) {
@ -72,13 +80,16 @@ export namespace Tree {
}
}
function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh): void {
function spruce(base: Vec3, g: number, rand: () => number, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
const h = lerp(0.6, 9, g)
const rTrunk = lerp(0.03, 0.2, g)
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, 5)
const impostor = lod === "impostor"
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5)
// Stacked cones: widest low, shrinking to a point up top -> conical tiers.
const tiers = 2 + Math.round(g * 3)
// The impostor keeps the first two tiers at low sides (same seed => aligned).
const tiers = impostor ? 2 : 2 + Math.round(g * 3)
const sides = impostor ? 4 : 6
const bottom = base.y + h * 0.1
const span = h * 0.9
for (let i = 0; i < tiers; i++) {
@ -86,7 +97,7 @@ export namespace Tree {
const y = bottom + t * span * 0.82
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
const coneH = (span / tiers) * 1.9
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, 6)
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides)
}
}
@ -127,9 +138,7 @@ export namespace Tree {
/** A lumpy low-poly sphere (one oak canopy blob). Per-ring radius wobble keeps
* it organic without cracking the longitude seam. */
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number): void {
const seg = 5
const rings = 3
function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void {
const start = mesh.verts.length / STRIDE
for (let r = 0; r <= rings; r++) {
const phi = (r / rings) * Math.PI