From a0ff6b3faa8bbc325576183a3b84162f7bbfbd38 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 09:47:25 -0700 Subject: [PATCH 1/3] Refit only the cells that can change, tracking what each scene draws --- dev/check.ts | 102 +++++++++++--- src/canvas.ts | 137 ++++++++++++++++++- src/engine.tsx | 270 ++++++++++++++++++++++++++++---------- src/sky.ts | 15 ++- src/wallpaper.ts | 3 + src/weather.ts | 15 ++- wallpapers/beach.ts | 13 +- wallpapers/city.ts | 12 +- wallpapers/desert.ts | 7 +- wallpapers/farm.ts | 10 +- wallpapers/jungle.ts | 6 +- wallpapers/ocean.ts | 2 + wallpapers/prehistoric.ts | 16 ++- wallpapers/space.ts | 2 +- wallpapers/template.ts | 6 +- wallpapers/tundra.ts | 13 +- wallpapers/zen.ts | 14 +- 17 files changed, 497 insertions(+), 146 deletions(-) diff --git a/dev/check.ts b/dev/check.ts index 18e0272..0d4dee2 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -1,9 +1,12 @@ // Runs every wallpaper (or the named ones) at every activity level and time of day through the engine on a mock screen and reports errors, frame cost and // flicker (cells whose glyph changes per frame; lower is calmer). Exits non-zero on errors, on flicker above --max-flicker (default 150 of 7200 cells) -// and, only when given, on frame cost above --max-ms: bun dev/check.ts [--quiet] [--max-ms=N] [--max-flicker=N] [wallpaper...] [season] [weather] +// and, only when given, on frame cost above --max-ms: bun dev/check.ts [--quiet] [--max-ms=N] [--max-flicker=N] [--verify-dirty] [wallpaper...] [season] [weather] +// --verify-dirty plays each combination twice, refitting every cell every frame (WALLPAPER_DIRTY=0) and then only the +// cells that changed, with text edits, redraws between frames and agent events mixed in, and fails on any cell that +// differs between the two. import { mkdirSync, rmSync } from "node:fs" import { parseArgs } from "node:util" -import { ACTIVITIES, SEASONS, TIMES, WEATHERS } from "../src/engine" +import { ACTIVITIES, SEASONS, TIMES, WEATHERS, type Activity, type Engine, type Time, type Wallpaper } from "../src/engine" import { H, W, mockScreen, text } from "./mock" import { seed } from "./seed" import { wallpapers } from "./wallpapers" @@ -14,49 +17,106 @@ rmSync(debug, { force: true }) process.env.WALLPAPER_DEBUG = debug process.env.WALLPAPER_OCTANTS ??= "1" -const args = parseArgs({ allowPositionals: true, options: { quiet: { type: "boolean" }, "max-ms": { type: "string" }, "max-flicker": { type: "string", default: "150" } } }) +const args = parseArgs({ allowPositionals: true, options: { quiet: { type: "boolean" }, "max-ms": { type: "string" }, "max-flicker": { type: "string", default: "150" }, "verify-dirty": { type: "boolean" } } }) const maxMs = Number(args.values["max-ms"] ?? Infinity) const maxFlicker = Number(args.values["max-flicker"]) +const verify = args.values["verify-dirty"] const season = SEASONS.find((s) => args.positionals.includes(s)) const weather = WEATHERS.find((w) => args.positionals.includes(w)) const ids = args.positionals.filter((arg) => arg !== season && arg !== weather) const unknown = ids.filter((id) => !wallpapers.some((w) => w.id === id)) if (unknown.length) throw new Error(`unknown wallpaper ${unknown.join(", ")}; available: ${wallpapers.map((w) => w.id).join(", ")}`) -const screen = mockScreen() +// Holding the clock makes the next frame a redraw that doesn't advance the scene, as when OpenCode repaints for typing. +let hold = false +const cursor = { x: 10, y: 40, visible: true } let failed = 0 let slowest = { ms: -1, name: "" } let busiest = { flicker: -1, name: "" } +const frames = 240 const runs = (ids.length ? wallpapers.filter((w) => ids.includes(w.id)) : wallpapers).flatMap((w) => ACTIVITIES.flatMap((activity) => TIMES.map((time) => ({ wallpaper: w, activity, time })))) for (const { wallpaper, activity, time } of runs) { - // Same random stream per combination, so a run's numbers don't depend on which others ran before it. + const reference = verify ? play(wallpaper, activity, time, "0").recording : undefined + const result = play(wallpaper, activity, time, "1", reference) + const errors = (await Bun.file(debug).exists()) ? (await Bun.file(debug).text()).split("\n").filter((l) => l.startsWith("error:")) : [] + rmSync(debug, { force: true }) + const name = `${wallpaper.id} ${activity} ${time}` + if (result.ms > slowest.ms) slowest = { ms: result.ms, name } + if (result.flicker > busiest.flicker) busiest = { flicker: result.flicker, name } + const problems = [ + errors.length && `${errors.length} errors`, + result.flicker > maxFlicker && `flicker over ${maxFlicker}`, + result.ms > maxMs && `slower than ${maxMs} ms`, + result.mismatches && `${result.mismatches} cells differ from a full refit, first ${result.first}`, + ].filter(Boolean) + if (problems.length) failed++ + if (args.values.quiet && !problems.length) continue + console.log(`${name.padEnd(26)} ${problems.length ? "FAIL" : "ok "} ${result.ms.toFixed(1)} ms/frame ${result.flicker.toFixed(0)} of ${W * H} cells change per frame${problems.length ? ` (${problems.join(", ")})` : ""}`) + for (const e of errors.slice(0, 3)) console.log(` ${e}`) +} +console.log(`${runs.length} runs, ${failed} failed; slowest ${slowest.ms.toFixed(1)} ms/frame (${slowest.name}), most flicker ${busiest.flicker.toFixed(0)} cells (${busiest.name})`) +if (failed) process.exit(1) + +// Plays one combination, with WALLPAPER_DIRTY set to dirty. Under --verify-dirty it records every frame, and compares +// each against the reference recording when given. +function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: string, reference?: Uint16Array[]) { + process.env.WALLPAPER_DIRTY = dirty + // A fresh engine and the same random stream per combination, so a run doesn't depend on which others ran before it. + const screen = mockScreen() + const tick = performance.now + let held = 0 + performance.now = () => (hold ? held : (held = tick())) + screen.renderer.getCursorState = () => cursor seed(1) screen.engine.start(wallpaper, { activity, time, season, weather }) let previous = new Uint32Array(W * H) let changes = 0 let total = 0 - const frames = 240 + let mismatches = 0 + let first = "" + const recording: Uint16Array[] = [] for (let f = 0; f < frames; f++) { + if (verify) script(f, screen.engine, wallpaper, activity, time) const start = Bun.nanoseconds() - screen.frame(text) + screen.frame(verify && f >= 100 && f < 160 ? (x, y) => text(x + 3, y) : text) + hold = false if (f >= 60) { total += (Bun.nanoseconds() - start) / 1e6 for (let i = 0; i < W * H; i++) if (screen.buffers.char[i] !== previous[i]) changes++ } previous = screen.buffers.char.slice() + if (!verify) continue + const cells = new Uint16Array(W * H * 8) + for (let i = 0; i < W * H; i++) { + cells[i * 8] = screen.buffers.char[i] & 0xffff + cells[i * 8 + 1] = screen.buffers.char[i] >>> 16 + for (let c = 0; c < 3; c++) { + cells[i * 8 + 2 + c] = screen.buffers.fg[i * 4 + c] + cells[i * 8 + 5 + c] = screen.buffers.bg[i * 4 + c] + } + } + recording.push(cells) + const expected = reference?.[f] + if (!expected) continue + for (let i = 0; i < W * H; i++) { + let same = true + for (let k = 0; k < 8; k++) if (cells[i * 8 + k] !== expected[i * 8 + k]) same = false + if (same) continue + mismatches++ + first ||= `frame ${f} cell ${i % W},${(i / W) | 0}` + } } screen.engine.stop() - const errors = (await Bun.file(debug).exists()) ? (await Bun.file(debug).text()).split("\n").filter((l) => l.startsWith("error:")) : [] - rmSync(debug, { force: true }) - const name = `${wallpaper.id} ${activity} ${time}` - const ms = total / (frames - 60) - const flicker = changes / (frames - 60) - if (ms > slowest.ms) slowest = { ms, name } - if (flicker > busiest.flicker) busiest = { flicker, name } - const problems = [errors.length && `${errors.length} errors`, flicker > maxFlicker && `flicker over ${maxFlicker}`, ms > maxMs && `slower than ${maxMs} ms`].filter(Boolean) - if (problems.length) failed++ - if (args.values.quiet && !problems.length) continue - console.log(`${name.padEnd(26)} ${problems.length ? "FAIL" : "ok "} ${ms.toFixed(1)} ms/frame ${flicker.toFixed(0)} of ${W * H} cells change per frame${problems.length ? ` (${problems.join(", ")})` : ""}`) - for (const e of errors.slice(0, 3)) console.log(` ${e}`) + return { ms: total / (frames - 60), flicker: changes / (frames - 60), mismatches, first, recording } +} + +// Text edits, redraws between frames, a moving cursor, agent events and a brightness change, to exercise every reason +// a cell's fit can go stale. +function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activity, time: Time) { + hold = f % 9 === 4 + cursor.x = 10 + (f >> 4) + if (f === 70) engine.react("busy") + if (f === 130) engine.react("done") + if (f === 150) engine.react("error") + if (f === 200) engine.react("idle") + if (f === 180) engine.start(wallpaper, { activity, time, season, weather, brightness: "vivid" }) } -console.log(`${runs.length} runs, ${failed} failed; slowest ${slowest.ms.toFixed(1)} ms/frame (${slowest.name}), most flicker ${busiest.flicker.toFixed(0)} cells (${busiest.name})`) -if (failed) process.exit(1) diff --git a/src/canvas.ts b/src/canvas.ts index db9df50..1c6e9bb 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -5,6 +5,9 @@ import { clamp, type RGB } from "./math" const BLOOM = 4 const BLOOM_THRESHOLD = 0.82 const BLUR = [1, 6, 15, 20, 15, 6, 1] +// How far, in bloom cells, a change to bloom's input can reach: two blurs at each level, the half-resolution level +// counting double, plus the bilinear lookups between levels. +const BLOOM_REACH = 23 export type Part = | { kind: "capsule"; ax: number; ay: number; bx: number; by: number; r0: number; r1: number; color: RGB; ribs: number } @@ -48,6 +51,23 @@ export abstract class Canvas { protected cells = false hdr = new Float32Array(0) pixels = new Uint8Array(0) + // Boxes [x0, y0, x1, y1, ...] in pixels, inclusive, around what render() drew this frame over the cached background. + // restore() clears it and every drawing method adds to it; scenes touch() what their own pixel loops write. + readonly touched: number[] = [] + // Boxes around every pixel whose `pixels` value may differ from last frame's: what was drawn this frame or last, + // and wherever bloom changed. finish() tone-maps only these, and the engine refits only the cells under them. + readonly changed: number[] = [] + + // Last frame's touched boxes, the thresholded bloom input they were judged against, and which bloom cells to tone-map. + private drawn: number[] = [] + private bloomIn = new Float32Array(0) + private readonly bloomBox = new Int32Array(4) + private tiles = new Uint8Array(0) + // Whether this frame started with restore(); scenes that never call it are tone-mapped in full every frame. + private restored = false + private fresh = true + // WALLPAPER_DIRTY=0 tone-maps every pixel every frame, the reference dev/check.ts --verify-dirty compares against. + private readonly dirty = process.env.WALLPAPER_DIRTY !== "0" private bw = 0 private bh = 0 @@ -109,6 +129,9 @@ export abstract class Canvas { 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.bloomIn = new Float32Array(this.bw * this.bh * 3) + this.tiles = new Uint8Array(this.bw * this.bh) + this.fresh = true const invR = 1 / Math.hypot(W / 2, H / 2) this.vignette = new Float32Array(W * H) for (let y = 0; y < H; y++) @@ -147,6 +170,28 @@ export abstract class Canvas { return this.cells ? Math.max(radius * this.H, min) : radius * this.H } + // Starts a frame: copies the cached background (if given) into hdr and clears touched. + protected restore(background?: Float32Array) { + if (background) this.hdr.set(background) + this.touched.length = 0 + this.restored = true + } + + // Marks pixels a scene's own loop writes, x0..x1 by y0..y1 inclusive, so they are tone-mapped and refitted. + protected touch(x0: number, y0: number, x1: number, y1: number) { + const t = this.touched + const n = t.length + // Pixel-by-pixel loops (add, blend) grow the last box instead of adding one box per pixel. + if (n && x0 >= t[n - 4] - 1 && y0 >= t[n - 3] - 1 && x1 <= t[n - 2] + 1 && y1 <= t[n - 1] + 1) { + t[n - 4] = Math.min(t[n - 4], x0) + t[n - 3] = Math.min(t[n - 3], y0) + t[n - 2] = Math.max(t[n - 2], x1) + t[n - 1] = Math.max(t[n - 1], y1) + return + } + t.push(x0, y0, x1, y1) + } + // Adds light to one pixel. protected add(x: number, y: number, r: number, g: number, b: number) { const xi = x | 0 @@ -156,6 +201,7 @@ export abstract class Canvas { this.hdr[i] += r this.hdr[i + 1] += g this.hdr[i + 2] += b + this.touch(xi, yi, xi, yi) } // Mixes a color over the pixel at hdr index i by coverage a. @@ -164,21 +210,37 @@ export abstract class Canvas { h[i] += (r - h[i]) * a h[i + 1] += (g - h[i + 1]) * a h[i + 2] += (b - h[i + 2]) * a + const y = Math.floor(i / 3 / this.W) + const x = i / 3 - y * this.W + this.touch(x, y, x, y) } // An anti-aliased filled circle, mixed over the scene. protected disc(cx: number, cy: number, rad: number, r: number, g: number, b: number, a: number) { fillDisc(this.hdr, this.W, this.H, cx, cy, rad, r, g, b, a) + this.touch(cx - rad - 2, cy - rad - 2, cx + rad + 2, cy + rad + 2) } // An anti-aliased filled ellipse, mixed over the scene; handy for parts that narrow as a creature turns edge-on. protected ellipse(cx: number, cy: number, rx: number, ry: number, r: number, g: number, b: number, a: number) { fillEllipse(this.hdr, this.W, this.H, cx, cy, rx, ry, r, g, b, a) + this.touch(cx - rx - 2, cy - ry - 2, cx + rx + 2, cy + ry + 2) } // An anti-aliased filled polygon in a flat color, for straight-edged things like buildings and machines. protected polygon(points: readonly (readonly [number, number])[], color: RGB, alpha = 1) { fillPolygon(this.hdr, this.W, this.H, points, color[0], color[1], color[2], alpha) + let x0 = Infinity + let y0 = Infinity + let x1 = -Infinity + let y1 = -Infinity + for (const [x, y] of points) { + x0 = Math.min(x0, x) + y0 = Math.min(y0, y) + x1 = Math.max(x1, x) + y1 = Math.max(y1, y) + } + if (points.length) this.touch(x0 - 2, y0 - 2, x1 + 2, y1 + 2) } // Draws parts as one shape: each pixel takes the nearest part's color, so joints stay seamless. With rim lighting @@ -186,12 +248,48 @@ export abstract class Canvas { // light scales the effect (0 draws flat color). protected shape(parts: Part[], lighting: Lighting, light = 1) { fillShape(this.hdr, this.W, this.H, parts, lighting, light) + for (const p of parts) { + const r = (p.kind === "capsule" ? Math.max(p.r0, p.r1) : Math.max(p.rx, p.ry)) + 2 + if (p.kind === "capsule") this.touch(Math.min(p.ax, p.bx) - r, Math.min(p.ay, p.by) - r, Math.max(p.ax, p.bx) + r, Math.max(p.ay, p.by) + r) + else this.touch(p.cx - r, p.cy - r, p.cx + r, p.cy + r) + } } - // Bloom, vignette and ACES tone mapping from hdr into pixels. + // Bloom, vignette and ACES tone mapping from hdr into pixels. Only pixels that can have changed since last frame are + // tone-mapped again: those under this frame's or last frame's touched boxes, and those bloom reaches from wherever + // its input changed. 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) + const full = !this.dirty || this.fresh || !this.restored + // A scene that never calls restore() can have drawn anywhere. + if (!this.restored) this.touched.splice(0, this.touched.length, 0, 0, this.W - 1, this.H - 1) + this.restored = false + this.fresh = false + const box = this.bloomBox + 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, this.bloomIn, box, full) + const changed = this.changed + changed.length = 0 + if (full) changed.push(0, 0, this.W - 1, this.H - 1) + else { + for (const t of this.touched) changed.push(t) + for (const t of this.drawn) changed.push(t) + if (box[0] <= box[2]) changed.push((box[0] - BLOOM_REACH) * BLOOM, (box[1] - BLOOM_REACH) * BLOOM, (box[2] + BLOOM_REACH + 1) * BLOOM, (box[3] + BLOOM_REACH + 1) * BLOOM) + } + this.drawn.length = 0 + for (const t of this.touched) this.drawn.push(t) + markTiles(this.tiles, this.bw, this.bh, changed) + 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, this.tiles, this.bw) + } +} + +// Marks the bloom cells (BLOOM pixels square) that any box overlaps. +function markTiles(tiles: Uint8Array, bw: number, bh: number, boxes: number[]) { + tiles.fill(0) + for (let b = 0; b < boxes.length; b += 4) { + const x0 = Math.max(0, Math.floor(boxes[b] / BLOOM)) + const y0 = Math.max(0, Math.floor(boxes[b + 1] / BLOOM)) + const x1 = Math.min(bw - 1, Math.floor(boxes[b + 2] / BLOOM)) + const y1 = Math.min(bh - 1, Math.floor(boxes[b + 3] / BLOOM)) + for (let y = y0; y <= y1; y++) tiles.fill(1, y * bw + x0, y * bw + x1 + 1) } } @@ -372,12 +470,18 @@ 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 in marked tiles (BLOOM pixels square, bw across). +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, tiles: Uint8Array, bw: number) { for (let y = 0; y < H; y++) { const rA = rowA[y] const rB = rowB[y] const fy = rowF[y] + const row = Math.floor(y / BLOOM) * bw for (let x = 0; x < W; x++) { + if (!tiles[row + Math.floor(x / BLOOM)]) { + x += BLOOM - 1 - (x % BLOOM) + continue + } const i = y * W + x const fx = colF[x] const w00 = (1 - fx) * (1 - fy) @@ -400,7 +504,9 @@ 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) { +// Unless full is set, bloomUp is left as it was when the thresholded input matches last frame's (bloomIn); box gets +// the bloom cells where it differs (x0 > x1 when none). +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, bloomIn: Float32Array, box: Int32Array, full: boolean) { for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) { let r = 0 @@ -425,6 +531,7 @@ function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, bloomA[o + 1] = g * k bloomA[o + 2] = b * k } + if (!diffBloom(bloomA, bloomIn, bw, bh, box) && !full) return blur(bloomA, bloomB, bw, bh) blur(bloomA, bloomB, bw, bh) for (let y = 0; y < bh2; y++) @@ -469,6 +576,26 @@ function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, } } +// Copies bloom's thresholded input into last, recording the bounding box of the cells that differed; false if none did. +function diffBloom(next: Float32Array, last: Float32Array, bw: number, bh: number, box: Int32Array) { + box[0] = bw + box[1] = bh + box[2] = -1 + box[3] = -1 + for (let i = 0; i < next.length; i++) { + if (next[i] === last[i]) continue + last[i] = next[i] + const cell = (i / 3) | 0 + const x = cell % bw + const y = (cell / bw) | 0 + box[0] = Math.min(box[0], x) + box[1] = Math.min(box[1], y) + box[2] = Math.max(box[2], x) + box[3] = Math.max(box[3], y) + } + return box[2] >= 0 +} + function fillPolygon(hdr: Float32Array, W: number, H: number, pts: readonly (readonly [number, number])[], r: number, g: number, b: number, a: number) { let x0 = Infinity let y0 = Infinity diff --git a/src/engine.tsx b/src/engine.tsx index 1e50f98..aacd152 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -388,6 +388,122 @@ function isSurface(k: number, light: boolean) { return (light ? luma > 170 : luma < 70) && Math.max(r, g, b) - Math.min(r, g, b) < 24 } +// Marks the cells under any of the boxes (scene pixels, inclusive). +function markCells(stale: Uint8Array, boxes: readonly number[], cellX: Int32Array, cellY: Int32Array, W: number, PW: number, PH: number) { + for (let b = 0; b < boxes.length; b += 4) { + const x0 = Math.max(0, Math.floor(boxes[b])) + const y0 = Math.max(0, Math.floor(boxes[b + 1])) + const x1 = Math.min(PW - 1, Math.ceil(boxes[b + 2])) + const y1 = Math.min(PH - 1, Math.ceil(boxes[b + 3])) + if (x0 > x1 || y0 > y1) continue + for (let y = cellY[y0]; y <= cellY[y1]; y++) stale.fill(1, y * W + cellX[x0], y * W + cellX[x1] + 1) + } +} + +// Blends the scene into the history, desaturated and shaded, and writes the result to blended, for the cells marked in +// stale. A cell whose blended pixels changed is marked in moved; stale is left marking the cells whose history is still +// settling toward the scene, which need blending again next frame even if the scene doesn't change there. Cells not +// marked keep their history and blended pixels exactly, since blending them again would change nothing. +function settle(px: Uint8Array, blended: Uint8Array, history: Float32Array, PW: number, W: number, H: number, colStart: Int32Array, rowStart: Int32Array, stale: Uint8Array, moved: Uint8Array, desaturate: number, shade: number, persistence: number, fresh: boolean) { + for (let cy = 0; cy < H; cy++) + for (let cx = 0; cx < W; cx++) { + const cell = cy * W + cx + moved[cell] = 0 + if (!stale[cell]) continue + let settled = true + let same = true + for (let y = rowStart[cy]; y < rowStart[cy + 1]; y++) + for (let x = colStart[cx]; x < colStart[cx + 1]; x++) { + const p = (y * PW + x) * 4 + const l = px[p] * 0.2126 + px[p + 1] * 0.7152 + px[p + 2] * 0.0722 + for (let c = p; c < p + 3; c++) { + const v = (px[c] + (l - px[c]) * desaturate) * shade + const h = fresh ? v : history[c] * persistence + v * (1 - persistence) + const before = history[c] + history[c] = h + if (history[c] !== before) settled = false + const was = blended[c] + blended[c] = h + if (blended[c] !== was) same = false + } + } + stale[cell] = settled ? 0 : 1 + moved[cell] = same ? 0 : 1 + } +} + +// Box-filters the moved cells' pixels down to the 2x4-per-cell octant grid (raw), then applies a four-neighbor unsharp +// mask into grid for them and their neighbors, marking in refit the cells whose sharpened grid changed. +function regrid(px: Uint8Array, PW: number, PH: number, GW: number, GH: number, W: number, H: number, raw: Float32Array, grid: Float32Array, moved: Uint8Array, refit: Uint8Array) { + for (let cell = 0; cell < W * H; cell++) { + if (!moved[cell]) continue + const cx = cell % W + const cy = (cell / W) | 0 + for (let gy = cy * 4; gy < cy * 4 + 4; gy++) { + const y0 = Math.floor((gy * PH) / GH) + const y1 = Math.max(y0 + 1, Math.floor(((gy + 1) * PH) / GH)) + for (let gx = cx * 2; gx < cx * 2 + 2; gx++) { + const x0 = Math.floor((gx * PW) / GW) + const x1 = Math.max(x0 + 1, Math.floor(((gx + 1) * PW) / GW)) + let r = 0 + let g = 0 + let b = 0 + for (let yy = y0; yy < y1; yy++) + for (let xx = x0; xx < x1; xx++) { + const p = (yy * PW + xx) * 4 + r += px[p] + g += px[p + 1] + b += px[p + 2] + } + const n = (y1 - y0) * (x1 - x0) + const o = (gy * GW + gx) * 3 + raw[o] = r / n + raw[o + 1] = g / n + raw[o + 2] = b / n + } + } + } + const row = GW * 3 + for (let cell = 0; cell < W * H; cell++) { + refit[cell] = 0 + const cx = cell % W + const cy = (cell / W) | 0 + if (!moved[cell] && !(cx > 0 && moved[cell - 1]) && !(cx < W - 1 && moved[cell + 1]) && !(cy > 0 && moved[cell - W]) && !(cy < H - 1 && moved[cell + W])) continue + for (let gy = cy * 4; gy < cy * 4 + 4; gy++) { + const up = gy > 0 ? -row : 0 + const down = gy < GH - 1 ? row : 0 + for (let gx = cx * 2; gx < cx * 2 + 2; gx++) { + const left = gx > 0 ? -3 : 0 + const right = gx < GW - 1 ? 3 : 0 + const o = (gy * GW + gx) * 3 + for (let c = o; c < o + 3; c++) { + const v = raw[c] + const out = v + (v * 4 - raw[c + up] - raw[c + down] - raw[c + left] - raw[c + right]) * (SHARPEN / 4) + const before = grid[c] + grid[c] = out < 0 ? 0 : out > 255 ? 255 : out + if (grid[c] !== before) refit[cell] = 1 + } + } + } + } +} + +// Writes a cell's last fit back: its glyph, background, and foreground unless it is blank. +function reuseFit(i: number, o: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, fitChar: Uint32Array, fitFg: Uint16Array, fitBg: Uint16Array) { + char[i] = fitChar[i] + for (let c = 0; c < 3; c++) bg[o + c] = fitBg[i * 3 + c] + bg[o + 3] = 255 + if (char[i] === SPACE) return + for (let c = 0; c < 3; c++) fg[o + c] = fitFg[i * 3 + c] + fg[o + 3] = 255 +} + +function rememberFit(i: number, o: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, fitChar: Uint32Array, fitFg: Uint16Array, fitBg: Uint16Array) { + fitChar[i] = char[i] + for (let c = 0; c < 3; c++) fitBg[i * 3 + c] = bg[o + c] + if (char[i] !== SPACE) for (let c = 0; c < 3; c++) fitFg[i * 3 + c] = fg[o + c] +} + // A full-screen renderable at the very back of the tree, so its image placement is drawn before any UI. class SceneLayer extends Renderable { constructor( @@ -472,58 +588,32 @@ export function createEngine( let openW = 0 let pressed = -1 let grid = new Float32Array(0) - let gridBlur = new Float32Array(0) + let raw = new Float32Array(0) let light = false + // Refitting only what changed (see settle and regrid). blended is the history-blended scene the grid is built from; + // per cell, stale marks pixels to blend again, moved the cells whose blended pixels changed and refit those whose grid + // did. fitted marks cells whose last fit (fitChar, fitFg, fitBg, under scrim fitTop and fitBottom) still holds. + let dirty = true + let blended = new Uint8Array(0) + let stale = new Uint8Array(0) + let moved = new Uint8Array(0) + let refit = new Uint8Array(0) + let fitted = new Uint8Array(0) + let fitChar = new Uint32Array(0) + let fitFg = new Uint16Array(0) + let fitBg = new Uint16Array(0) + let fitTop = new Float64Array(0) + let fitBottom = new Float64Array(0) + let colStart = new Int32Array(0) + let rowStart = new Int32Array(0) + let cellX = new Int32Array(0) + let cellY = new Int32Array(0) + let lastSize = "" + let lastSource: Uint8Array | undefined + let lastBlend = "" + let lastScrim: Scrim | undefined + let lastCurve: Uint8Array | undefined - // Box-filters the scene down to the 2x4-per-cell octant grid, then applies an unsharp mask. - const buildGrid = (px: Uint8Array, PW: number, PH: number, GW: number, GH: number) => { - if (grid.length !== GW * GH * 3) { - grid = new Float32Array(GW * GH * 3) - gridBlur = new Float32Array(GW * GH * 3) - } - for (let gy = 0; gy < GH; gy++) { - const y0 = Math.floor((gy * PH) / GH) - const y1 = Math.max(y0 + 1, Math.floor(((gy + 1) * PH) / GH)) - for (let gx = 0; gx < GW; gx++) { - const x0 = Math.floor((gx * PW) / GW) - const x1 = Math.max(x0 + 1, Math.floor(((gx + 1) * PW) / GW)) - let r = 0 - let g = 0 - let b = 0 - for (let yy = y0; yy < y1; yy++) - for (let xx = x0; xx < x1; xx++) { - const p = (yy * PW + xx) * 4 - r += px[p] - g += px[p + 1] - b += px[p + 2] - } - const n = (y1 - y0) * (x1 - x0) - const o = (gy * GW + gx) * 3 - grid[o] = r / n - grid[o + 1] = g / n - grid[o + 2] = b / n - } - } - // Four-neighbor unsharp mask in one pass, written to gridBlur and swapped in. - const row = GW * 3 - for (let gy = 0; gy < GH; gy++) { - const up = gy > 0 ? -row : 0 - const down = gy < GH - 1 ? row : 0 - for (let gx = 0; gx < GW; gx++) { - const left = gx > 0 ? -3 : 0 - const right = gx < GW - 1 ? 3 : 0 - const o = (gy * GW + gx) * 3 - for (let c = o; c < o + 3; c++) { - const v = grid[c] - const out = v + (v * 4 - grid[c + up] - grid[c + down] - grid[c + left] - grid[c + right]) * (SHARPEN / 4) - gridBlur[c] = out < 0 ? 0 : out > 255 ? 255 : out - } - } - } - const swap = grid - grid = gridBlur - gridBlur = swap - } let imagePixels = new Uint8Array(0) let pool: NativeImagePool | undefined let savedTransport: KittyImageTransport | undefined @@ -734,7 +824,7 @@ export function createEngine( const desaturate = lerp(DESATURATE[was.settings.time], DESATURATE[settings!.time], t) // Blending in the previous frame fades at the same speed per second whatever the frame rate. const persistence = interval > 1000 / FPS ? PERSISTENCE ** (interval / (1000 / FPS)) : PERSISTENCE - paint(buf, from ? mixed : scene.pixels, scene.W, scene.H, advanced, pixels, desaturate, shade, persistence, scrim) + paint(buf, from ? mixed : scene.pixels, scene.W, scene.H, advanced, pixels, desaturate, shade, persistence, scrim, from ? undefined : scene.changed) if (options.dump && !dumped && frames > 120) { dumped = true write(buf, options.dump) @@ -755,27 +845,50 @@ export function createEngine( // Everything after the scene has drawn its frame. It never touches the scene object: each wallpaper's scene is a // different class, and the optimizer would otherwise fall back to slow code for this whole function after a switch. - const paint = (buf: OptimizedBuffer, px: Uint8Array, PW: number, PH: number, advanced: boolean, pixels: boolean, desaturate: number, shade: number, persistence: number, scrim: Scrim) => { + const paint = (buf: OptimizedBuffer, px: Uint8Array, PW: number, PH: number, advanced: boolean, pixels: boolean, desaturate: number, shade: number, persistence: number, scrim: Scrim, changed: readonly number[] | undefined) => { const W = buf.width const H = buf.height - if (advanced) { - const fresh = history.length !== px.length - if (fresh) history = new Float32Array(px.length) - for (let p = 0; p < px.length; p += 4) { - const l = px[p] * 0.2126 + px[p + 1] * 0.7152 + px[p + 2] * 0.0722 - for (let c = 0; c < 3; c++) { - const v = (px[p + c] + (l - px[p + c]) * desaturate) * shade - const h = fresh ? v : history[p + c] * persistence + v * (1 - persistence) - history[p + c] = h - px[p + c] = h - } - } - } const GW = W * 2 const GH = H * 4 - if (advanced || grid.length !== GW * GH * 3) buildGrid(px, PW, PH, GW, GH) - const { char, fg, bg } = buf.buffers const cells = W * H + const size = `${W}x${H} ${PW}x${PH}` + const reset = size !== lastSize || history.length !== px.length || grid.length !== GW * GH * 3 + if (reset) { + lastSize = size + history = new Float32Array(px.length) + blended = new Uint8Array(px.length) + grid = new Float32Array(GW * GH * 3) + raw = new Float32Array(GW * GH * 3) + stale = new Uint8Array(cells) + moved = new Uint8Array(cells) + refit = new Uint8Array(cells) + fitted = new Uint8Array(cells) + fitChar = new Uint32Array(cells) + fitFg = new Uint16Array(cells * 3) + fitBg = new Uint16Array(cells * 3) + fitTop = new Float64Array(cells) + fitBottom = new Float64Array(cells) + // Pixel columns and rows of each cell, and the cell of each pixel column and row. + colStart = Int32Array.from({ length: W + 1 }, (_, x) => Math.floor((x * PW) / W)) + rowStart = Int32Array.from({ length: H + 1 }, (_, y) => Math.floor((y * PH) / H)) + cellX = new Int32Array(PW) + cellY = new Int32Array(PH) + for (let x = 0; x < W; x++) cellX.fill(x, colStart[x], colStart[x + 1]) + for (let y = 0; y < H; y++) cellY.fill(y, rowStart[y], rowStart[y + 1]) + } + if (advanced) { + // Anything that changes every pixel's blend, or a frame without a list of what changed, blends every cell again. + const blend = `${desaturate} ${shade} ${persistence}` + const everything = reset || !dirty || !changed || px !== lastSource || blend !== lastBlend + lastSource = px + lastBlend = blend + if (everything) stale.fill(1) + else markCells(stale, changed, cellX, cellY, W, PW, PH) + settle(px, blended, history, PW, W, H, colStart, rowStart, stale, moved, desaturate, shade, persistence, reset) + if (reset || !dirty) moved.fill(1) + regrid(blended, PW, PH, GW, GH, W, H, raw, grid, moved, refit) + } else refit.fill(0) + const { char, fg, bg } = buf.buffers if (frames++ % 10 === 0) { light = context.themeMode === "light" @@ -794,6 +907,9 @@ export function createEngine( } const look = LEVELS[settings!.brightness ?? "normal"] const curve = light ? look.light : look.dark + const refitAll = reset || !dirty || scrim !== lastScrim || curve !== lastCurve + lastScrim = scrim + lastCurve = curve // Soft scrim: a blurred mask of where text is, at half-cell vertical resolution. The scene fades to // near-black under and around text with no hard edge, so letters and the spaces between them match. @@ -852,6 +968,8 @@ export function createEngine( const [deepR, deepG, deepB] = scrimAt(scrim, y / Math.max(1, H - 1), light) for (let x = 0; x < W; x++) { const i = y * W + x + const reusable = fitted[i] === 1 && !refitAll && refit[i] === 0 + fitted[i] = 0 if (imageCell[i]) continue const o = i * 4 const ch = char[i] @@ -860,6 +978,15 @@ export function createEngine( if (!surface[i] && !edge) continue const st = cover * Math.min(1, mask[mTop + x] * reach) const sb = cover * Math.min(1, mask[mBottom + x] * reach) + const fits = !edge && ch === SPACE && !atCursor(x, y) + // Same grid and scrim as when this cell was last fitted, and fitting is idempotent under hysteresis: reuse it. + if (fits && reusable && st === fitTop[i] && sb === fitBottom[i]) { + reuseFit(i, o, char, fg, bg, fitChar, fitFg, fitBg) + fitted[i] = 1 + applyTint(bg, o, cellTint[i]) + if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) + continue + } // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. for (let s = 0; s < 8; s++) { const a = ((y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)) * 3 @@ -887,9 +1014,13 @@ export function createEngine( applyTint(fg, o, edgeTint) if (!surface[i]) continue } - if (ch === SPACE && !atCursor(x, y)) { + if (fits) { if (octants) previousMask[i] = writeOctant(q, order, previousMask[i], char, fg, bg, i, o) else previousMask[i] = writeQuadrant(q, previousMask[i], char, fg, bg, i, o) + rememberFit(i, o, char, fg, bg, fitChar, fitFg, fitBg) + fitTop[i] = st + fitBottom[i] = sb + fitted[i] = 1 applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue @@ -902,7 +1033,7 @@ export function createEngine( // Build next frame's image now; the back layer places it before OpenCode draws, so text lands on top. if (pixels && (advanced || !nextImage)) { - const image = publishImage(px, PW, PH, W, H, MH, scrim, look, curve) + const image = publishImage(blended, PW, PH, W, H, MH, scrim, look, curve) if (image) { nextImage?.dispose() nextImage = image @@ -977,6 +1108,7 @@ export function createEngine( lastTarget = "" history = new Float32Array(0) previousMask = new Int16Array(0) + dirty = process.env.WALLPAPER_DIRTY !== "0" lastActivity = Date.now() focused = true schedule() diff --git a/src/sky.ts b/src/sky.ts index fa2185a..32a8478 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -72,8 +72,8 @@ export function makeStars(count: number, top: number): Star[] { return Array.from({ length: count }, () => ({ x: Math.random(), y: Math.random() * top, b: 0.4 + Math.random() * 0.6, phase: Math.random() * TAU, tint: STAR_TINTS[Math.floor(Math.random() * STAR_TINTS.length)] })) } -// Stars twinkling slowly, fading toward the horizon at `top`. -export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[], time: number, top: number, brightness: number) { +// Stars twinkling slowly, fading toward the horizon at `top`. Pass a Canvas's touched to record what changed. +export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[], time: number, top: number, brightness: number, touched?: number[]) { for (const s of stars) { const x = (s.x * W) | 0 const y = (s.y * H) | 0 @@ -83,6 +83,7 @@ export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[ hdr[o] += k * s.tint[0] hdr[o + 1] += k * s.tint[1] hdr[o + 2] += k * s.tint[2] + touched?.push(x, y, x, y) } } @@ -111,19 +112,21 @@ export function makeStorm(): Cloud[] { return makeClouds(8, true, 0.04, 0.26) } -export function paintStorm(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, gloom: number) { +export function paintStorm(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, gloom: number, touched?: number[]) { if (gloom <= 0) return - paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8, true) + paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8, true, touched) } -// Clouds shaded from top to bottom; puffy ones are nearly opaque, streaks are thin and soft. -export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, puffy: boolean) { +// Clouds shaded from top to bottom; puffy ones are nearly opaque, streaks are thin and soft. Pass a Canvas's touched to +// record what changed. +export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, puffy: boolean, touched?: number[]) { for (const c of clouds) for (const p of c.puffs) { const cx = (c.x + p.dx) * H const cy = (c.y + p.dy) * H const rx = p.rx * H const ry = p.ry * H + touched?.push(cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1) for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { const dx = (x + 0.5 - cx) / rx diff --git a/src/wallpaper.ts b/src/wallpaper.ts index 7807b74..df9af85 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -8,6 +8,9 @@ export interface Scene { readonly W: number readonly H: number readonly pixels: Uint8Array + // Optional: boxes [x0, y0, x1, y1, ...] in pixels, inclusive, outside which this frame's pixels match the last + // frame's (Canvas provides it). Without it the engine refits every cell every frame. + readonly changed?: readonly number[] resize(W: number, H: number, cells?: boolean): void step(dt: number): void render(): void diff --git a/src/weather.ts b/src/weather.ts index 66719b7..9c24dde 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -165,8 +165,8 @@ export class WeatherLayer { if (this.covered) paintDeck(hdr, W, H, this.horizon, this.kind.cover, this.palette.deck[0], this.palette.deck[1]) } - // Grading and fog over the whole frame, then snow, rain and splashes. - draw(hdr: Float32Array, W: number, H: number) { + // Grading and fog over the whole frame, then snow, rain and splashes. Pass a Canvas's touched to record what changed. + draw(hdr: Float32Array, W: number, H: number, touched?: number[]) { if (this.fogRows.length !== H) { this.aspect = W / H this.rainCount = Math.min(MAX_ASPECT * RAIN, Math.round(RAIN * this.kind.rain * this.aspect)) @@ -181,7 +181,10 @@ export class WeatherLayer { } const p = this.palette const k = this.kind - if (k.dim < 1 || k.fog > 0) grade(hdr, W, H, k.dim, p.desaturate * k.gray, p.fog, this.fogRows, this.time) + if (k.dim < 1 || k.fog > 0) { + grade(hdr, W, H, k.dim, p.desaturate * k.gray, p.fog, this.fogRows, this.time) + touched?.push(0, 0, W - 1, H - 1) + } const f = this.flakes for (let i = 0; i < this.snowCount * 4; i += 4) { const z = f[i + 2] @@ -189,6 +192,7 @@ export class WeatherLayer { flake(hdr, W, H, f[i] * W, f[i + 1] * H, p.snow[0], p.snow[1], p.snow[2], b) // Nearer flakes are bigger. if (z > 0.55) flake(hdr, W, H, f[i] * W + 0.8, f[i + 1] * H + 0.6, p.snow[0], p.snow[1], p.snow[2], b) + touched?.push(f[i] * W - 2, f[i + 1] * H - 2, f[i] * W + 2, f[i + 1] * H + 2) } const d = this.drops for (let i = 0; i < this.rainCount * 3; i += 3) { @@ -196,6 +200,7 @@ export class WeatherLayer { const len = (0.025 + z * 0.035) * H const b = 0.05 + z * 0.12 streak(hdr, W, H, d[i] * W, d[i + 1] * H, len * 0.12, len, p.rain[0] * b, p.rain[1] * b, p.rain[2] * b) + touched?.push(d[i] * W - len * 0.12 - 2, d[i + 1] * H - len - 1, d[i] * W + 1, d[i + 1] * H + 1) } const s = this.splashes for (let i = 0; i < s.length; i += 4) { @@ -207,7 +212,9 @@ export class WeatherLayer { } const t = age / SPLASH_LIFE const b = (1 - t) * (0.12 + s[i + 3] * 0.2) - splash(hdr, W, H, s[i] * W, s[i + 1] * H, (0.003 + s[i + 3] * 0.006) * H * (0.6 + t * 1.6), t, p.rain[0] * b, p.rain[1] * b, p.rain[2] * b) + const rx = (0.003 + s[i + 3] * 0.006) * H * (0.6 + t * 1.6) + splash(hdr, W, H, s[i] * W, s[i + 1] * H, rx, t, p.rain[0] * b, p.rain[1] * b, p.rain[2] * b) + touched?.push(s[i] * W - rx - 1, s[i + 1] * H - rx - 1, s[i] * W + rx + 1, s[i + 1] * H + 1) } } } diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index 07a6d2f..84167ab 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -247,11 +247,11 @@ class Beach extends Canvas { } render() { - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3, this.touched) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy, this.touched) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) this.drawSea() for (const s of this.swimmers) this.drawSwimmer(s) this.drawBoat() @@ -267,7 +267,7 @@ class Beach extends Canvas { this.drawTrunk(p) this.drawFronds(p) } - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -331,11 +331,13 @@ class Beach extends Canvas { const [, wet] = look.sand const ox = look.orb.x * W const roll = (t * 0.08) % 1 + let bottom = 0 for (let x = 0; x < W; x++) { const u = x / H const edge = this.shore(u) * H const reach = (0.74 + 0.01 * Math.sin(u * 2.3 + 1) + 0.014) * H const wave = lerp(0.62, edge / H - 0.005, roll) * H + bottom = Math.max(bottom, reach + 2, edge + 0.02 * H) for (let y = Math.floor(HORIZON * H); y < Math.min(H, reach + 2); y++) { const o = (y * W + x) * 3 if (y > edge) { @@ -386,6 +388,7 @@ class Beach extends Canvas { hdr[o + 2] += look.foam[2] * k } } + this.touch(0, Math.floor(HORIZON * H), W - 1, bottom) } // A palm trunk curving from its base to the crown, ringed with old leaf scars, with coconuts at the top. diff --git a/wallpapers/city.ts b/wallpapers/city.ts index c2fb6c8..90a791e 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -293,11 +293,11 @@ class City extends Canvas { render() { const { W, H, look } = this - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4, this.touched) const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] - paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, look.clouds.puffy) - paintStorm(this.hdr, W, H, this.storm, look.clouds.top, look.clouds.bottom, this.gloom) + paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, look.clouds.puffy, this.touched) + paintStorm(this.hdr, W, H, this.storm, look.clouds.top, look.clouds.bottom, this.gloom, this.touched) if (this.hero.t >= 0) this.drawHero() this.drawBlimp() this.drawRiver() @@ -312,7 +312,7 @@ class City extends Canvas { for (const w of this.walkers) this.drawWalker(w) for (const b of this.pigeons) this.shape(bird(b, H, 0.022 * H, this.paint([0.42, 0.44, 0.5])), this.lighting, 0.4) this.drawRain() - this.weather.draw(this.hdr, W, H) + this.weather.draw(this.hdr, W, H, this.touched) this.finish() } @@ -401,6 +401,7 @@ class City extends Canvas { buf[o + 1] += c[1] * a buf[o + 2] += c[2] * a } + if (buf === this.hdr) this.touch(cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1) } private tower(x: number, w: number, h: number, seed: number, far: boolean): Tower { @@ -713,6 +714,7 @@ class City extends Canvas { const { W, H, hdr, river, water } = this const r0 = Math.floor(BANK * H) const r1 = Math.min(H, Math.ceil(QUAY * H)) + this.touch(0, r0, W - 1, r1 - 1) for (let y = r0; y < r1; y++) { const amp = (0.25 + ((y - r0) / (r1 - r0)) * 0.9) * (H / 200) const s = Math.sin(y * 0.45 + this.time * 1.3) * amp + Math.sin(y * 0.17 - this.time * 0.7) * amp * 0.6 diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 570ddf3..dea03e5 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -338,10 +338,10 @@ class Desert extends Canvas { } render() { - this.hdr.set(this.background) + this.restore(this.background) if (!this.weather.covered) this.drawStars() this.drawClouds() - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) for (const b of this.flock) this.look.night ? this.drawBat(b) : this.drawBird(b, 0.3, "vulture") if (this.bigBird) this.drawBird(this.bigBird, 0.8, this.look.night ? "owl" : "eagle") if (this.coyote) this.drawCoyote(this.coyote) @@ -350,7 +350,7 @@ class Desert extends Canvas { for (const b of this.startled) this.silhouette(bird(b, this.H, 0.022 * this.H, this.look.flock), 0.4) this.drawTumbleweed() this.drawDust() - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -744,6 +744,7 @@ class Desert extends Canvas { const rx = p.rx * H const ry = p.ry * H const warm = puffy ? 1 : 0.6 + 0.6 * Math.exp(-Math.abs(cx - this.orbX) / (0.5 * H)) + this.touch(cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1) for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { const dx = (x + 0.5 - cx) / rx diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index a60c32f..eb4094f 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -260,11 +260,11 @@ class Farm extends Canvas { } render() { - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.touched) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy, this.touched) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) this.drawFan() this.drawTractor() for (const c of this.chickens) this.drawChicken(c) @@ -276,7 +276,7 @@ class Farm extends Canvas { this.drawCornRow(3) this.drawFireflies() this.drawLeaves() - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 9fd07ec..cba4c49 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -215,7 +215,7 @@ class Jungle extends Canvas { } render() { - this.hdr.set(this.background) + this.restore(this.background) this.drawShafts() this.drawFalls() if (this.toucan.x > -5) this.drawToucan(this.toucan) @@ -230,7 +230,7 @@ class Jungle extends Canvas { for (const b of this.butterflies) this.drawButterfly(b) this.drawMotes() for (const l of this.leaves) this.drawLeaf(l) - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -394,6 +394,7 @@ class Jungle extends Canvas { const [lr, lg, lb] = look.light // After an error the shafts fade, as if clouds had covered the sun. const overcast = 1 - smoothstep(0, 1, this.gloom) * 0.75 + this.touch(0, 0, W - 1, H * 0.85) for (let y = 0; y < H * 0.85; y++) { const fade = Math.pow(1 - y / (H * 0.85), 1.4) * look.shafts * overcast for (let x = 0; x < W; x++) { @@ -425,6 +426,7 @@ class Jungle extends Canvas { } const rx = f.half * 3 const ry = 0.03 * H + this.touch(f.x - rx - 1, f.bottom - ry - 1, f.x + rx + 1, f.bottom + ry + 1) for (let y = Math.max(0, Math.floor(f.bottom - ry)); y < Math.min(H, f.bottom + ry); y++) for (let x = Math.max(0, Math.floor(f.x - rx)); x < Math.min(W, f.x + rx); x++) { const q = ((x + 0.5 - f.x) / rx) ** 2 + ((y + 0.5 - f.bottom) / ry) ** 2 diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index a6eea5f..973f51a 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -289,6 +289,7 @@ class Ocean extends Canvas { } render() { + this.restore() this.drawWater() this.drawWhale() this.drawSub() @@ -622,6 +623,7 @@ class Ocean extends Canvas { const squash = this.cells ? 2 : 4 // After an error the rays fade, as if clouds had covered the sun. const overcast = 1 - smoothstep(0, 1, this.gloom) * 0.7 + this.touch(0, 0, W - 1, H - 1) for (let y = 0; y < H; y++) { const rf = rayFade[y] * overcast const ry1 = y * 0.42 + t * 7 diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index 3fbd6b5..bb8e88f 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -173,7 +173,7 @@ class Prehistoric extends Canvas { private clouds: Cloud[] private smoke = Array.from({ length: SMOKE_PUFFS }, (_, i) => ({ age: (i / SMOKE_PUFFS) * SMOKE_LIFE, seed: Math.random() * 100 })) // Lava pixels (hdr index, strength, height down the flow) that pulse each frame. - private lava = { index: new Int32Array(0), k: new Float32Array(0), along: new Float32Array(0) } + private lava = { index: new Int32Array(0), k: new Float32Array(0), along: new Float32Array(0), box: [0, 0, -1, -1] } private trees: [number, number] = [0, 0] private rex = { x: -9, dir: 1, next: 2, phase: 0 } private dust: { x: number; y: number; vx: number; age: number }[] = [] @@ -234,13 +234,14 @@ class Prehistoric extends Canvas { } render() { - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.touched) const orange = this.look.lava[1] flowLava(this.hdr, this.lava.index, this.lava.k, this.lava.along, this.time, orange[0], orange[1], orange[2]) + this.touch(this.lava.box[0], this.lava.box[1], this.lava.box[2], this.lava.box[3]) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy, this.touched) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) this.drawSmoke() if (this.activity !== "calm") { this.drawPterosaurs() @@ -257,7 +258,7 @@ class Prehistoric extends Canvas { if (this.activity === "teeming") for (const d of this.dragonflies) this.drawDragonfly(d) if (this.delorean.t >= 0) this.drawDelorean() for (const b of this.birds) this.shape(bird(b, this.H, 0.024 * this.H, this.paint([0.5, 0.3, 0.2])), this.lighting, 0.4) - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -418,7 +419,8 @@ class Prehistoric extends Canvas { this.blend(i * 3, lerp(red[0], orange[0], h), lerp(red[1], orange[1], h), lerp(red[2], orange[2], h), core[i]) index.push(i) } - this.lava = { index: Int32Array.from(index), k: Float32Array.from(index, (i) => core[i] * (0.4 + heat[i])), along: Float32Array.from(index, (i) => along[i]) } + const box = index.length ? [index.reduce((m, i) => Math.min(m, i % W), W), Math.floor(index[0] / W), index.reduce((m, i) => Math.max(m, i % W), 0), Math.floor(index[index.length - 1] / W)] : [0, 0, -1, -1] + this.lava = { index: Int32Array.from(index), k: Float32Array.from(index, (i) => core[i] * (0.4 + heat[i])), along: Float32Array.from(index, (i) => along[i]), box } } // A still lake in front of the volcano, mirroring it and the sky, with a muddy shore. diff --git a/wallpapers/space.ts b/wallpapers/space.ts index 6c39a47..86e2a64 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -209,7 +209,7 @@ class Space extends Canvas { } render() { - this.hdr.set(this.background) + this.restore(this.background) this.drawStars() this.drawMoon() this.drawPlanet() diff --git a/wallpapers/template.ts b/wallpapers/template.ts index 9e3aa7e..449b038 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -115,9 +115,9 @@ class Template extends Canvas { // Draws one frame into hdr (linear light; values above about 0.8 bloom), then finish() tone-maps it into pixels. render() { - this.hdr.set(this.background) - paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5) - paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, true) + this.restore(this.background) + paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5, this.touched) + paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, true, this.touched) if (this.activity !== "calm") this.drawBalloon() if (this.activity === "teeming" && this.look.night) this.drawFireflies() if (this.activity === "teeming" && !this.look.night) this.drawBirds() diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..736092c 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -220,12 +220,12 @@ class Tundra extends Canvas { } render() { - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.touched) if (this.look.aurora > 0 && !this.weather.covered) this.drawAurora() const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy, this.touched) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) if (this.owl.x > -5) this.drawOwl() if (this.yeti.t >= 0) this.drawYeti() if (this.bear) this.drawBear(this.bear) @@ -233,7 +233,7 @@ class Tundra extends Canvas { for (const p of [...this.penguins].sort((a, b) => a.y - b.y)) this.drawPenguin(p) if (this.fox) this.drawFox(this.fox) this.drawFlakes() - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -413,10 +413,12 @@ class Tundra extends Canvas { private drawAurora() { const { W, H, hdr, look } = this const t = this.time + let bottom = 0 for (let x = 0; x < W; x++) { const u = x / H const band = (0.17 + 0.05 * Math.sin(u * 1.7 + t * 0.07) + 0.03 * Math.sin(u * 4.1 - t * 0.05)) * H const fold = 0.35 + 0.65 * Math.pow(0.5 + 0.5 * Math.sin(u * 14 + Math.sin(u * 3 + t * 0.1) * 3 + t * 0.15), 2) + bottom = Math.max(bottom, band + 0.05 * H) for (let y = 0; y < Math.min(H, band + 0.05 * H); y++) { const above = band - y const k = (above > 0 ? Math.exp(-above / (0.12 * H)) : Math.exp(-((above / (0.015 * H)) ** 2))) * fold * 0.35 * look.aurora @@ -428,6 +430,7 @@ class Tundra extends Canvas { hdr[o + 2] += k * lerp(0.5, 0.9, violet) } } + this.touch(0, 0, W - 1, bottom) } private drawFlakes() { diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index 8206902..e535d52 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -204,6 +204,7 @@ class Zen extends Canvas { // Water pixels: their hdr index, how much of each is water, the bank under its edge, and the garden in front of it // (bridge, lanterns, stones, lily pads) as a premultiplied color plus how much water shows through. private pool = new Int32Array(0) + private poolBox = [0, 0, -1, -1] private wet = new Float32Array(0) private under = new Float32Array(0) private front = new Float32Array(0) @@ -294,11 +295,11 @@ class Zen extends Canvas { } render() { - this.hdr.set(this.background) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) + this.restore(this.background) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3, this.touched) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] - paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) - paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy, this.touched) + paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom, this.touched) this.drawWater() for (const r of this.rings) this.drawRing(r) for (const p of this.petals) if (p.state === 1) this.drawPetal(p) @@ -319,7 +320,7 @@ class Zen extends Canvas { for (const p of this.petals) if (p.state !== 1) this.drawPetal(p) for (const b of this.sparrows) this.shape(bird(b, this.H, 0.02 * this.H, this.paint([0.45, 0.33, 0.22])), this.lighting, 0.4) this.drawFireflies() - this.weather.draw(this.hdr, this.W, this.H) + this.weather.draw(this.hdr, this.W, this.H, this.touched) this.finish() } @@ -359,6 +360,7 @@ class Zen extends Canvas { pool.push(y * W + x) } this.pool = Int32Array.from(pool) + this.poolBox = pool.length ? [pool.reduce((m, i) => Math.min(m, i % W), W), Math.floor(pool[0] / W), pool.reduce((m, i) => Math.max(m, i % W), 0), Math.floor(pool[pool.length - 1] / W)] : [0, 0, -1, -1] this.under = new Float32Array(pool.length * 3) for (let k = 0; k < pool.length; k++) for (let c = 0; c < 3; c++) this.under[k * 3 + c] = this.hdr[pool[k] * 3 + c] this.mirror = new Float32Array(W * H * 3) @@ -810,6 +812,7 @@ class Zen extends Canvas { const amp = 0.004 * H const ox = look.orb.x * W const tick = Math.floor(t * 3) * 131 + this.touch(this.poolBox[0], this.poolBox[1], this.poolBox[2], this.poolBox[3]) for (let k = 0; k < pool.length; k++) { const i = pool[k] const y = (i / W) | 0 @@ -844,6 +847,7 @@ class Zen extends Canvas { const fade = (1 - ring.age / ring.life) ** 1.5 * ring.strength const cx = ring.x * H const cy = ring.y * H + this.touch(cx - R - 2, cy - R * 0.32 - 2, cx + R + 2, cy + R * 0.32 + 2) for (let y = Math.max(0, Math.floor(cy - R * 0.32 - 2)); y <= Math.min(H - 1, Math.ceil(cy + R * 0.32 + 2)); y++) for (let x = Math.max(0, Math.floor(cx - R - 2)); x <= Math.min(W - 1, Math.ceil(cx + R + 2)); x++) { const i = y * W + x From d098ef763d680dca92d2628536ba2996e412ca19 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:20:52 -0700 Subject: [PATCH 2/3] Crossfade in check --verify-dirty --- dev/check.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dev/check.ts b/dev/check.ts index 0d4dee2..82284f8 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -109,7 +109,7 @@ function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: strin return { ms: total / (frames - 60), flicker: changes / (frames - 60), mismatches, first, recording } } -// Text edits, redraws between frames, a moving cursor, agent events and a brightness change, to exercise every reason +// Text edits, redraws between frames, a moving cursor, agent events, a brightness change and a crossfade, to exercise every reason // a cell's fit can go stale. function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activity, time: Time) { hold = f % 9 === 4 @@ -119,4 +119,5 @@ function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activ if (f === 150) engine.react("error") if (f === 200) engine.react("idle") if (f === 180) engine.start(wallpaper, { activity, time, season, weather, brightness: "vivid" }) + if (f === 205) engine.start(wallpaper, { activity, time: time === "night" ? "day" : "night", season, weather, brightness: "vivid" }) } From 8e60677421997337dbd635635559af782c40bf8d Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:35:53 -0700 Subject: [PATCH 3/3] Document touch() and --verify-dirty; start over after a failed frame --- CONTRIBUTING.md | 26 +++++++++++++++++++------- src/canvas.ts | 3 ++- src/engine.tsx | 2 ++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37be422..31bdec2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ exposure, 1.25 by default. ### layout() for static backgrounds `layout()` runs after every resize, once the buffers match the new size. Paint whatever never moves there (sky, -mountains, buildings, a tree), copy `hdr` into a field, and start each frame from that copy: +mountains, buildings, a tree), copy `hdr` into a field, and start each frame from that copy with `restore()`: ```ts protected override layout() { @@ -81,7 +81,7 @@ protected override layout() { } render() { - this.hdr.set(this.background) + this.restore(this.background) // …moving things… this.finish() } @@ -89,6 +89,14 @@ render() { This is the biggest single saving: a full-screen gradient with noise costs more than every creature together. +The engine also refits only the terminal cells that can have changed since the last frame. `restore()` starts the +frame's `touched` list of boxes, and every drawing method below adds the box it drew in. A scene that writes `hdr` in +its own loop during `render()` must call `touch(x0, y0, x1, y1)` (pixels, inclusive) for that area, and pass +`this.touched` to `paintStars`, `paintClouds`, `paintStorm` and `WeatherLayer.draw`. A scene that draws everything +each frame calls `restore()` with no background. Anything left untouched keeps last frame's cells, so a missed loop +shows as a smear; `bun dev/check.ts --verify-dirty ` catches it. A scene that never calls `restore()` is refitted +in full every frame. + ### Drawing `Canvas` methods, all in pixels, mixing over what's already there: @@ -102,7 +110,7 @@ This is the biggest single saving: a full-screen gradient with noise costs more | `polygon(points, color, alpha?)` | An anti-aliased polygon in a flat color, for straight-edged things like buildings and boats | | `shape(parts, lighting, light?)` | Rounded parts joined into one lit form, for creatures, people and plants | -For per-pixel effects such as water, sand or fog, loop over `hdr` directly. +For per-pixel effects such as water, sand or fog, loop over `hdr` directly, and `touch()` the area (see above). ### Shapes @@ -135,10 +143,10 @@ details), and values in between soften it. - `paintSky(hdr, W, H, stops, orb)` fills the canvas with a vertical gradient through `[fraction of height, color]` stops, a glow around the sun or moon, and the orb itself. An `Orb` has x (fraction of width), y and r (fractions of height), `core` and `glow` colors, `near` and `wide` glow strengths, and `moon: true` for a cratered moon. -- `makeStars(count, top)` and `paintStars(hdr, W, H, stars, time, top, brightness)` draw slowly twinkling stars that +- `makeStars(count, top)` and `paintStars(hdr, W, H, stars, time, top, brightness, touched)` draw slowly twinkling stars that fade toward `top`. Stars are tinted from `STAR_TINTS`, never white. - `makeClouds(count, puffy, y0, y1)`, `driftClouds(clouds, A, dt)` and `paintClouds(hdr, W, H, clouds, top, bottom, - alpha, puffy)` give puffy cumulus or thin streaks, shaded from `top` to `bottom`. + alpha, puffy, touched)` give puffy cumulus or thin streaks, shaded from `top` to `bottom`. `src/math.ts` has `TAU`, `lerp`, `clamp`, `smoothstep`, `rand`, stable hashes (`hash`, `hash2`, `hashString`), smooth noise (`noise1`, `noise2`, `fbm1`, `fbm2`), `hsv` and the `RGB` type. @@ -170,7 +178,7 @@ really change (blossom, leaves, snow, crowds) and nothing for indoor or timeless `settings.weather` is clear, overcast, rain, snow or fog, or missing for the wallpaper's own weather. Outdoor scenes make one `WeatherLayer` (from `src/weather.ts`) with the weather, time of day and horizon, and call its `cover()` in -`layout()` right after painting the sky, `step(dt)` in `step`, and `draw()` at the end of `render()` before `finish()`. +`layout()` right after painting the sky, `step(dt)` in `step`, and `draw(hdr, W, H, touched)` at the end of `render()` before `finish()`. Skip stars when `covered` is set, and color clouds with `scud` when it is. Passing `light` as the last argument gives a seasonal shower or flurry without the cloud deck, as the farm does in winter. `wallpapers/farm.ts` shows all of it. @@ -216,7 +224,8 @@ All optional, and all on `Scene`: ## Performance -A typical wallpaper costs 8 to 12 ms per frame at 15 fps through the whole pipeline (`bun dev/check.ts`). Timings vary +A typical wallpaper costs 3 to 10 ms per frame at 15 fps through the whole pipeline (`bun dev/check.ts`), most of it +fitting the cells that changed: scenes that move everywhere (the ocean, the beach's sea) cost the most. Timings vary a lot with machine load, so compare against an existing wallpaper measured back to back. - Paint static things once in `layout()`. @@ -236,6 +245,7 @@ a lot with machine load, so compare against an existing wallpaper measured back ```sh bun run typecheck bun dev/check.ts [id...] [season] [weather] # every activity and time: errors, ms/frame, flicker +bun dev/check.ts --verify-dirty [id...] # also checks that refitting only changed cells matches a full refit bun dev/snap.ts [activity] [time] [season] [weather] [seconds...] # scene PNGs in dev/out/, and ms per render bun dev/preview.ts [activity] [time] [season] [weather] [seconds] # terminal cells behind text, as a PNG bun dev/pixel-check.ts [id] [activity] [time] # real-pixel mode against a mock kitty terminal @@ -251,6 +261,8 @@ bun dev/gif.ts all # re-renders the README's GIFs `--quiet` prints only failures, and it fails on flicker above `--max-flicker` (150) and, if given, on frames slower than `--max-ms`; CI runs `--quiet --max-ms=60`. Dev renders seed `Math.random`, so they are reproducible. Errors thrown in `step` or `render` show up here; inside OpenCode they are only logged to `WALLPAPER_DEBUG`. + `--verify-dirty` plays each combination twice, refitting every cell (`WALLPAPER_DIRTY=0`) and then only what + changed, with text edits, redraws, agent events and a crossfade mixed in, and fails on any cell that differs. - `snap.ts` renders the scene directly, before terminal conversion, for judging the art. - `preview.ts` shows what the terminal will show, scrim included. - `dev/pty.ts [seconds] [keys...]` runs a real OpenCode in a pseudo-terminal and types each key argument, pausing diff --git a/src/canvas.ts b/src/canvas.ts index 1c6e9bb..7b706de 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -58,7 +58,8 @@ export abstract class Canvas { // and wherever bloom changed. finish() tone-maps only these, and the engine refits only the cells under them. readonly changed: number[] = [] - // Last frame's touched boxes, the thresholded bloom input they were judged against, and which bloom cells to tone-map. + // Last frame's touched boxes and thresholded bloom input, the bloom cells where that input changed, and which bloom + // cells to tone-map. private drawn: number[] = [] private bloomIn = new Float32Array(0) private readonly bloomBox = new Int32Array(4) diff --git a/src/engine.tsx b/src/engine.tsx index aacd152..50a0af5 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -778,6 +778,8 @@ export function createEngine( try { render(buf) } catch (error) { + // A frame cut short leaves the cached fits out of step with the scene, so the next one starts over. + lastSize = "" log(`error: ${error instanceof Error ? error.stack : String(error)}`) } }