Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
219 changes: 148 additions & 71 deletions src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -59,13 +61,25 @@ 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)
private rowF = new Float32Array(0)
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
Expand Down Expand Up @@ -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++)
Expand Down Expand Up @@ -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
Expand Down Expand 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) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
}
}
}
Expand Down
Loading