diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e9bf59..6c03760 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,10 @@ render() { } ``` -This is the biggest single saving: a full-screen gradient with noise costs more than every creature together. +This is the biggest single saving: a full-screen gradient with noise costs more than every creature together. It +also makes `finish()` cheaper: it blooms and tone-maps whatever `layout()` leaves in `hdr` once, then each frame only +redoes the 4x4 blocks that differ from it and the bloom they change. So leave `hdr` holding the background at the end +of `layout()`, and avoid full-screen per-frame passes where a static one will do. ### Drawing diff --git a/src/canvas.ts b/src/canvas.ts index 070bdf0..37c8b01 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -5,7 +5,9 @@ import { clearShadows, keepShadows } from "./shadow" // Bloom is computed at 1/BLOOM resolution, then again at half that for the wide halo. const BLOOM = 4 const BLOOM_THRESHOLD = 0.82 -const BLUR = [1, 6, 15, 20, 15, 6, 1] +// Bloom changes smaller than this are left out when finish decides which blocks to tone-map again: under 0.06 of a +// level at the steepest point of the tone curve. +const BLOOM_EPSILON = 1e-4 export type Part = | { kind: "capsule"; ax: number; ay: number; bx: number; by: number; r0: number; r1: number; color: RGB; ribs: number } @@ -59,6 +61,7 @@ export abstract class Canvas { private bloom2A = new Float32Array(0) private bloom2B = new Float32Array(0) private bloomUp = new Float32Array(0) + private bloomRow = new Float32Array(0) private vignette = new Float32Array(0) private rowA = new Int32Array(0) private rowB = new Int32Array(0) @@ -66,6 +69,17 @@ export abstract class Canvas { private colA = new Int32Array(0) private colB = new Int32Array(0) private colF = new Float32Array(0) + // The still frame is hdr as layout() leaves it, the background scenes copy back each frame. finish blooms and + // tone-maps it once; each frame then only works out the bloom blocks that differ from it and tone-maps again only + // the blocks their pixels or bloom change. Bloom is linear after its per-block threshold, so this matches a full + // bloom of the frame. + private still = new Float32Array(0) + private stillSource = new Float32Array(0) + private stillBloom = new Float32Array(0) + private stillPixels = new Uint8Array(0) + private stillExposure = NaN + private retone = new Uint8Array(0) + private region = new Int32Array(4) // How overcast the scene is: eases to 1 after the agent fails and back to 0 with any other event (the engine sends // idle once an error is a couple of minutes old). Scenes call stepGloom from step and darken their light or bring in @@ -160,10 +174,15 @@ export abstract class Canvas { this.bloomA = new Float32Array(this.bw * this.bh * 3) this.bloomB = new Float32Array(this.bw * this.bh * 3) this.bloomUp = new Float32Array(this.bw * this.bh * 3) + this.bloomRow = new Float32Array(this.bw * 3) this.bw2 = Math.ceil(this.bw / 2) this.bh2 = Math.ceil(this.bh / 2) this.bloom2A = new Float32Array(this.bw2 * this.bh2 * 3) this.bloom2B = new Float32Array(this.bw2 * this.bh2 * 3) + this.stillSource = new Float32Array(this.bw * this.bh * 3) + this.stillBloom = new Float32Array(this.bw * this.bh * 3) + this.stillPixels = new Uint8Array(W * H * 4) + this.retone = new Uint8Array(this.bw * this.bh) const invR = 1 / Math.hypot(W / 2, H / 2) this.vignette = new Float32Array(W * H) for (let y = 0; y < H; y++) @@ -196,6 +215,8 @@ export abstract class Canvas { } this.layout() keepShadows(this.hdr) + this.still = this.hdr.slice() + this.stillExposure = NaN } // A stroke radius in screen heights, in pixels; on terminal cells never under min, since thinner strokes break up @@ -247,46 +268,51 @@ export abstract class Canvas { // Bloom, vignette and ACES tone mapping from hdr into pixels. protected finish(exposure = 1.25) { - bloom(this.hdr, this.W, this.H, this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.bloomUp) - toneMap(this.hdr, this.pixels, this.W, this.H, this.bloomUp, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) clearShadows(this.hdr) + if (exposure !== this.stillExposure) { + // Without a still frame to compare with, bloomSource marks every block for tone-mapping. + bloomSource(this.still, this.W, this.H, this.bw, this.bh, this.stillSource, undefined, this.stillSource, this.retone, this.region) + this.bloomA.set(this.stillSource) + this.stillBloom.fill(0) + bloom(this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.stillBloom, this.retone, 0, 0, this.bw - 1, this.bh - 1) + toneMap(this.still, this.stillPixels, this.W, this.H, this.bw, this.retone, this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) + this.stillExposure = exposure + } + this.pixels.set(this.stillPixels) + bloomSource(this.hdr, this.W, this.H, this.bw, this.bh, this.bloomA, this.still, this.stillSource, this.retone, this.region) + const region = this.region + if (region[2] < region[0]) { + toneMap(this.hdr, this.pixels, this.W, this.H, this.bw, this.retone, this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) + return + } + this.bloomUp.set(this.stillBloom) + bloom(this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.bloomUp, this.retone, region[0], region[1], region[2], region[3]) + toneMap(this.hdr, this.pixels, this.W, this.H, this.bw, this.retone, this.bloomUp, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) } } -// Separable 7-tap binomial blur of a 3-channel buffer, in place, using tmp as scratch. -function blur(a: Float32Array, tmp: Float32Array, w: number, h: number) { - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) { - let r = 0 - let g = 0 - let b = 0 - for (let j = -3; j <= 3; j++) { - const i = (y * w + clamp(x + j, 0, w - 1)) * 3 - r += a[i] * BLUR[j + 3] - g += a[i + 1] * BLUR[j + 3] - b += a[i + 2] * BLUR[j + 3] - } - const o = (y * w + x) * 3 - tmp[o] = r / 64 - tmp[o + 1] = g / 64 - tmp[o + 2] = b / 64 - } - for (let y = 0; y < h; y++) - for (let x = 0; x < w; x++) { - let r = 0 - let g = 0 - let b = 0 - for (let j = -3; j <= 3; j++) { - const i = (clamp(y + j, 0, h - 1) * w + x) * 3 - r += tmp[i] * BLUR[j + 3] - g += tmp[i + 1] * BLUR[j + 3] - b += tmp[i + 2] * BLUR[j + 3] - } - const o = (y * w + x) * 3 - a[o] = r / 64 - a[o + 1] = g / 64 - a[o + 2] = b / 64 +// Separable 7-tap binomial blur of cells x0..x1, y0..y1 of a 3-channel buffer w cells wide, in place, using tmp as +// scratch. Edges repeat the border. +function blur(a: Float32Array, tmp: Float32Array, w: number, x0: number, y0: number, x1: number, y1: number) { + blurLines(a, tmp, x0, x1, 3, y0, y1, w * 3) + blurLines(tmp, a, y0, y1, w * 3, x0, x1, 3) +} + +// One pass of blur along lines l0..l1, lineStep apart, over samples k0..k1, step apart. +function blurLines(src: Float32Array, dst: Float32Array, k0: number, k1: number, step: number, l0: number, l1: number, lineStep: number) { + for (let l = l0; l <= l1; l++) { + const base = l * lineStep + for (let k = k0; k <= k1; k++) { + const m3 = base + Math.max(k - 3, k0) * step + const m2 = base + Math.max(k - 2, k0) * step + const m1 = base + Math.max(k - 1, k0) * step + const o = base + k * step + const p1 = base + Math.min(k + 1, k1) * step + const p2 = base + Math.min(k + 2, k1) * step + const p3 = base + Math.min(k + 3, k1) * step + for (let c = 0; c < 3; c++) dst[o + c] = (src[m3 + c] + src[p3 + c] + 6 * (src[m2 + c] + src[p2 + c]) + 15 * (src[m1 + c] + src[p1 + c]) + 20 * src[o + c]) / 64 } + } } function fillDisc(hdr: Float32Array, W: number, H: number, cx: number, cy: number, rad: number, r: number, g: number, b: number, a: number) { @@ -430,26 +456,30 @@ function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], light } } -function toneMap(hdr: Float32Array, pixels: Uint8Array, W: number, H: number, bloomUp: Float32Array, vignette: Float32Array, rowA: Int32Array, rowB: Int32Array, rowF: Float32Array, colA: Int32Array, colB: Int32Array, colF: Float32Array, exposure: number) { +// Tone-maps the pixels of the bloom blocks marked in retone. row is scratch for one row of bloom, blended between +// the two bloom rows above and below it. +function toneMap(hdr: Float32Array, pixels: Uint8Array, W: number, H: number, bw: number, retone: Uint8Array, bloomUp: Float32Array, row: Float32Array, vignette: Float32Array, rowA: Int32Array, rowB: Int32Array, rowF: Float32Array, colA: Int32Array, colB: Int32Array, colF: Float32Array, exposure: number) { for (let y = 0; y < H; y++) { - const rA = rowA[y] - const rB = rowB[y] - const fy = rowF[y] + const blocks = ((y / BLOOM) | 0) * bw + let blended = false for (let x = 0; x < W; x++) { + if (!retone[blocks + ((x / BLOOM) | 0)]) { + x += BLOOM - 1 + continue + } + if (!blended) { + const fy = rowF[y] + for (let j = 0; j < bw * 3; j++) row[j] = bloomUp[rowA[y] + j] * (1 - fy) + bloomUp[rowB[y] + j] * fy + blended = true + } const i = y * W + x const fx = colF[x] - const w00 = (1 - fx) * (1 - fy) - const w10 = fx * (1 - fy) - const w01 = (1 - fx) * fy - const w11 = fx * fy - const i00 = rA + colA[x] - const i10 = rA + colB[x] - const i01 = rB + colA[x] - const i11 = rB + colB[x] + const a = colA[x] + const b = colB[x] const k = vignette[i] * exposure const o = i * 4 for (let c = 0; c < 3; c++) { - const v = (hdr[i * 3 + c] + bloomUp[i00 + c] * w00 + bloomUp[i10 + c] * w10 + bloomUp[i01 + c] * w01 + bloomUp[i11 + c] * w11) * k + const v = (hdr[i * 3 + c] + row[a + c] * (1 - fx) + row[b + c] * fx) * k const mapped = ((v * (2.51 * v + 0.03)) / (v * (2.43 * v + 0.59) + 0.14)) * 255 pixels[o + c] = mapped < 0 ? 0 : mapped > 255 ? 255 : mapped } @@ -458,9 +488,23 @@ function toneMap(hdr: Float32Array, pixels: Uint8Array, W: number, H: number, bl } } -function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, bloomA: Float32Array, bloomB: Float32Array, bw2: number, bh2: number, bloom2A: Float32Array, bloom2B: Float32Array, bloomUp: Float32Array) { +// Each bloom block's average light above the threshold, into source. Given the still frame, only blocks whose pixels +// differ from it are worked out, as the change from stillSource, and marked in retone; the rest are zero. Sets +// the box of blocks whose source changed, as region [x0, y0, x1, y1], empty when x1 < x0. +function bloomSource(hdr: Float32Array, W: number, H: number, bw: number, bh: number, source: Float32Array, still: Float32Array | undefined, stillSource: Float32Array, retone: Uint8Array, region: Int32Array) { + region[0] = bw + region[1] = bh + region[2] = -1 + region[3] = -1 for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) { + const o = (by * bw + bx) * 3 + const same = still !== undefined && sameBlock(hdr, still, W, H, bx, by) + retone[by * bw + bx] = same ? 0 : 1 + if (same) { + source[o] = source[o + 1] = source[o + 2] = 0 + continue + } let r = 0 let g = 0 let b = 0 @@ -478,15 +522,42 @@ function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, b /= n const l = r * 0.2126 + g * 0.7152 + b * 0.0722 const k = l > 1e-4 ? Math.max(0, l - BLOOM_THRESHOLD) / l : 0 - const o = (by * bw + bx) * 3 - bloomA[o] = r * k - bloomA[o + 1] = g * k - bloomA[o + 2] = b * k + source[o] = r * k - (still ? stillSource[o] : 0) + source[o + 1] = g * k - (still ? stillSource[o + 1] : 0) + source[o + 2] = b * k - (still ? stillSource[o + 2] : 0) + if (!source[o] && !source[o + 1] && !source[o + 2]) continue + region[0] = Math.min(region[0], bx) + region[1] = Math.min(region[1], by) + region[2] = Math.max(region[2], bx) + region[3] = Math.max(region[3], by) } - blur(bloomA, bloomB, bw, bh) - blur(bloomA, bloomB, bw, bh) - for (let y = 0; y < bh2; y++) - for (let x = 0; x < bw2; x++) { +} + +function sameBlock(hdr: Float32Array, still: Float32Array, W: number, H: number, bx: number, by: number) { + for (let y = by * BLOOM; y < Math.min(H, by * BLOOM + BLOOM); y++) + for (let i = (y * W + bx * BLOOM) * 3; i < (y * W + Math.min(W, bx * BLOOM + BLOOM)) * 3; i++) if (hdr[i] !== still[i]) return false + return true +} + +// Spreads the bloom source in bloomA (overwritten) and adds it into out: two blurred levels folded into one. The +// source must be zero outside cells x0..x1, y0..y1, so only the cells its light can reach are worked out (blurs that +// stop where the light runs out match blurs over the whole grid). Marks in retone every block whose pixels read a +// cell that changes by more than BLOOM_EPSILON. +function bloom(bw: number, bh: number, bloomA: Float32Array, bloomB: Float32Array, bw2: number, bh2: number, bloom2A: Float32Array, bloom2B: Float32Array, out: Float32Array, retone: Uint8Array, x0: number, y0: number, x1: number, y1: number) { + // Each blur spreads light 3 cells. + const ax0 = Math.max(0, x0 - 6) + const ay0 = Math.max(0, y0 - 6) + const ax1 = Math.min(bw - 1, x1 + 6) + const ay1 = Math.min(bh - 1, y1 + 6) + blur(bloomA, bloomB, bw, ax0, ay0, ax1, ay1) + blur(bloomA, bloomB, bw, ax0, ay0, ax1, ay1) + const cx0 = Math.max(0, (ax0 >> 1) - 6) + const cy0 = Math.max(0, (ay0 >> 1) - 6) + const cx1 = Math.min(bw2 - 1, (ax1 >> 1) + 6) + const cy1 = Math.min(bh2 - 1, (ay1 >> 1) + 6) + bloom2A.fill(0) + for (let y = cy0; y <= cy1; y++) + for (let x = cx0; x <= cx1; x++) { let r = 0 let g = 0 let b = 0 @@ -504,25 +575,31 @@ function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, bloom2A[o + 1] = g / n bloom2A[o + 2] = b / n } - blur(bloom2A, bloom2B, bw2, bh2) - blur(bloom2A, bloom2B, bw2, bh2) + blur(bloom2A, bloom2B, bw2, cx0, cy0, cx1, cy1) + blur(bloom2A, bloom2B, bw2, cx0, cy0, cx1, cy1) // Fold the wide level into the narrow one so finish does a single bilinear fetch. - for (let y = 0; y < bh; y++) { + for (let y = Math.max(0, cy0 * 2 - 3); y <= Math.min(bh - 1, cy1 * 2 + 3); y++) { const sy = clamp((y + 0.5) * (bh2 / bh) - 0.5, 0, bh2 - 1) - const y0 = Math.floor(sy) - const y1 = Math.min(bh2 - 1, y0 + 1) - const fy = sy - y0 - for (let x = 0; x < bw; x++) { + const sy0 = Math.floor(sy) + const sy1 = Math.min(bh2 - 1, sy0 + 1) + const fy = sy - sy0 + for (let x = Math.max(0, cx0 * 2 - 3); x <= Math.min(bw - 1, cx1 * 2 + 3); x++) { const sx = clamp((x + 0.5) * (bw2 / bw) - 0.5, 0, bw2 - 1) - const x0 = Math.floor(sx) - const x1 = Math.min(bw2 - 1, x0 + 1) - const fx = sx - x0 + const sx0 = Math.floor(sx) + const sx1 = Math.min(bw2 - 1, sx0 + 1) + const fx = sx - sx0 const o = (y * bw + x) * 3 + let change = 0 for (let c = 0; c < 3; c++) { - const a = bloom2A[(y0 * bw2 + x0) * 3 + c] * (1 - fx) + bloom2A[(y0 * bw2 + x1) * 3 + c] * fx - const b = bloom2A[(y1 * bw2 + x0) * 3 + c] * (1 - fx) + bloom2A[(y1 * bw2 + x1) * 3 + c] * fx - bloomUp[o + c] = bloomA[o + c] * 1.1 + (a * (1 - fy) + b * fy) * 1.4 + const a = bloom2A[(sy0 * bw2 + sx0) * 3 + c] * (1 - fx) + bloom2A[(sy0 * bw2 + sx1) * 3 + c] * fx + const b = bloom2A[(sy1 * bw2 + sx0) * 3 + c] * (1 - fx) + bloom2A[(sy1 * bw2 + sx1) * 3 + c] * fx + const v = bloomA[o + c] * 1.1 + (a * (1 - fy) + b * fy) * 1.4 + out[o + c] += v + change += Math.abs(v) } + if (change < BLOOM_EPSILON) continue + for (let yy = Math.max(0, y - 1); yy <= Math.min(bh - 1, y + 1); yy++) + for (let xx = Math.max(0, x - 1); xx <= Math.min(bw - 1, x + 1); xx++) retone[yy * bw + xx] = 1 } } }