From a7e5c35429531fdee0913e4e0cb8cc5b4a9bacd3 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 09:37:15 -0700 Subject: [PATCH 1/5] Track what each frame draws over the static background in Canvas.touched --- src/canvas.ts | 64 ++++++++++++++++++++++++++++++++++++--- src/sky.ts | 5 +++ src/wallpaper.ts | 3 ++ src/weather.ts | 14 +++++++-- wallpapers/beach.ts | 3 +- wallpapers/city.ts | 4 ++- wallpapers/desert.ts | 4 ++- wallpapers/farm.ts | 2 +- wallpapers/jungle.ts | 4 ++- wallpapers/prehistoric.ts | 9 ++++-- wallpapers/space.ts | 7 ++++- wallpapers/template.ts | 2 +- wallpapers/tundra.ts | 3 +- wallpapers/zen.ts | 6 +++- 14 files changed, 112 insertions(+), 18 deletions(-) diff --git a/src/canvas.ts b/src/canvas.ts index db9df50..7944cd3 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -5,6 +5,29 @@ import { clamp, type RGB } from "./math" const BLOOM = 4 const BLOOM_THRESHOLD = 0.82 const BLUR = [1, 6, 15, 20, 15, 6, 1] +// Pixels beyond a touched box where a change's bloom has faded under about 1% of its peak (the wide halo's blur). +export const BLOOM_REACH = BLOOM * 10 + +// The touched list of the canvas that owns hdr, for helpers that draw into a scene's buffer without the scene. +const owners = new WeakMap() +export function touchedBy(hdr: Float32Array) { + return owners.get(hdr) +} + +// Adds a box to a touched list. A box within a pixel of the last one grows that one instead, so per-pixel loops leave +// one box rather than thousands. +export function addBox(list: number[] | undefined, x0: number, y0: number, x1: number, y1: number) { + if (!list) return + const n = list.length + if (n && x0 >= list[n - 4] - 1 && y0 >= list[n - 3] - 1 && x1 <= list[n - 2] + 1 && y1 <= list[n - 1] + 1) { + list[n - 4] = Math.min(list[n - 4], x0) + list[n - 3] = Math.min(list[n - 3], y0) + list[n - 2] = Math.max(list[n - 2], x1) + list[n - 1] = Math.max(list[n - 1], y1) + return + } + list.push(x0, y0, x1, y1) +} export type Part = | { kind: "capsule"; ax: number; ay: number; bx: number; by: number; r0: number; r1: number; color: RGB; ribs: number } @@ -48,6 +71,12 @@ export abstract class Canvas { protected cells = false hdr = new Float32Array(0) pixels = new Uint8Array(0) + // Boxes [x0, y0, x1, y1, ...] in pixels around everything render drew this frame over the cached background, so the + // engine can tell moving cells from static ones. restore() clears it; the drawing methods, the sky's stars and + // clouds and the weather add to it; scenes call touch() around their own per-pixel loops. A frame that never called + // restore counts as touched everywhere. Bloom spreads light up to BLOOM_REACH pixels beyond a box. + readonly touched: number[] = [] + private restored = false private bw = 0 private bh = 0 @@ -99,6 +128,7 @@ export abstract class Canvas { this.cells = cells this.A = W / H this.hdr = new Float32Array(W * H * 3) + owners.set(this.hdr, this.touched) this.pixels = new Uint8Array(W * H * 4) this.bw = Math.ceil(W / BLOOM) this.bh = Math.ceil(H / BLOOM) @@ -147,11 +177,24 @@ export abstract class Canvas { return this.cells ? Math.max(radius * this.H, min) : radius * this.H } + // Starts a frame: copies the cached background into hdr and forgets last frame's touched boxes. + protected restore(background: Float32Array) { + this.hdr.set(background) + this.touched.length = 0 + this.restored = true + } + + // Marks a box (pixels, x1 and y1 exclusive) that render changed with its own per-pixel loop. + protected touch(x0: number, y0: number, x1: number, y1: number) { + addBox(this.touched, 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 const yi = y | 0 if (xi < 0 || yi < 0 || xi >= this.W || yi >= this.H) return + addBox(this.touched, xi, yi, xi + 1, yi + 1) const i = (yi * this.W + xi) * 3 this.hdr[i] += r this.hdr[i + 1] += g @@ -161,6 +204,10 @@ export abstract class Canvas { // Mixes a color over the pixel at hdr index i by coverage a. protected blend(i: number, r: number, g: number, b: number, a: number) { const h = this.hdr + const p = i / 3 + const x = p % this.W + const y = (p - x) / this.W + addBox(this.touched, x, y, x + 1, y + 1) h[i] += (r - h[i]) * a h[i + 1] += (g - h[i + 1]) * a h[i + 2] += (b - h[i + 2]) * a @@ -168,28 +215,35 @@ export abstract class Canvas { // 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) { + addBox(this.touched, cx - rad - 1, cy - rad - 1, cx + rad + 1, cy + rad + 1) fillDisc(this.hdr, this.W, this.H, cx, cy, rad, r, g, b, a) } // 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) { + addBox(this.touched, cx - rx - 1, cy - ry - 1, cx + rx + 1, cy + ry + 1) fillEllipse(this.hdr, this.W, this.H, cx, cy, rx, ry, r, g, b, a) } // 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) + fillPolygon(this.hdr, this.W, this.H, points, color[0], color[1], color[2], alpha, this.touched) } // Draws parts as one shape: each pixel takes the nearest part's color, so joints stay seamless. With rim lighting // only the outline facing the light catches it; with front lighting each part is shaded as a rounded form. // 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) + fillShape(this.hdr, this.W, this.H, parts, lighting, light, this.touched) } // Bloom, vignette and ACES tone mapping from hdr into pixels. protected finish(exposure = 1.25) { + if (!this.restored) { + this.touched.length = 0 + this.touched.push(0, 0, this.W, this.H) + } + this.restored = false 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) } @@ -270,7 +324,7 @@ function fillEllipse(hdr: Float32Array, W: number, H: number, cx: number, cy: nu } } -function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], lighting: Lighting, light: number) { +function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], lighting: Lighting, light: number, touched: number[]) { if (!parts.length) return // The shape's overall extent: the pixels it can cover, and the direction its rim light comes from. let cx0 = Infinity @@ -294,6 +348,7 @@ function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], light boxes[j * 4 + 2] = Math.max(ax, bx) + pad boxes[j * 4 + 3] = Math.max(ay, by) + pad } + addBox(touched, cx0, cy0, cx1 + 1, cy1 + 1) const lx0 = lighting.style === "rim" ? lighting.x : 0 const ly0 = lighting.style === "rim" ? lighting.y : 0 const sl = Math.hypot(lx0 - (cx0 + cx1) / 2, ly0 - (cy0 + cy1) / 2) || 1 @@ -469,7 +524,7 @@ function bloom(hdr: Float32Array, W: number, H: number, bw: number, bh: number, } } -function fillPolygon(hdr: Float32Array, W: number, H: number, pts: readonly (readonly [number, number])[], r: number, g: number, b: number, a: number) { +function fillPolygon(hdr: Float32Array, W: number, H: number, pts: readonly (readonly [number, number])[], r: number, g: number, b: number, a: number, touched: number[]) { let x0 = Infinity let y0 = Infinity let x1 = -Infinity @@ -480,6 +535,7 @@ function fillPolygon(hdr: Float32Array, W: number, H: number, pts: readonly (rea x1 = Math.max(x1, x) y1 = Math.max(y1, y) } + addBox(touched, x0 - 1, y0 - 1, x1 + 2, y1 + 2) const n = pts.length for (let y = Math.max(0, Math.floor(y0 - 1)); y <= Math.min(H - 1, Math.ceil(y1 + 1)); y++) for (let x = Math.max(0, Math.floor(x0 - 1)); x <= Math.min(W - 1, Math.ceil(x1 + 1)); x++) { diff --git a/src/sky.ts b/src/sky.ts index fa2185a..92a61ed 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,4 +1,5 @@ // Sky pieces shared by outdoor wallpapers: a depth-graded sky with a sun or moon, stars and drifting clouds. +import { addBox, touchedBy } from "./canvas" import { TAU, fbm1, lerp, smoothstep, type RGB } from "./math" // The sun or moon: x as a fraction of the width, y and r as fractions of the height. @@ -74,11 +75,13 @@ export function makeStars(count: number, top: number): Star[] { // 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) { + const touched = touchedBy(hdr) for (const s of stars) { const x = (s.x * W) | 0 const y = (s.y * H) | 0 if (x < 0 || y < 0 || x >= W || y >= H) continue const k = s.b * Math.max(0, 1 - s.y / top) * (0.7 + 0.3 * Math.sin(time * 0.8 + s.phase)) * brightness + addBox(touched, x, y, x + 1, y + 1) const o = (y * W + x) * 3 hdr[o] += k * s.tint[0] hdr[o + 1] += k * s.tint[1] @@ -118,12 +121,14 @@ export function paintStorm(hdr: Float32Array, W: number, H: number, clouds: Clou // 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) { + const touched = touchedBy(hdr) 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 + addBox(touched, cx - rx, cy - ry, 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..891a1a2 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 around what the last render drew over its static background + // (Canvas provides it). Without it every cell keeps the steady fit. + readonly touched?: 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..052c6f6 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -1,6 +1,7 @@ // Weather shared by outdoor wallpapers: an overcast cloud deck, rain streaks with splashes, snow and fog. A scene makes // one WeatherLayer, calls cover() in layout right after painting its sky, step() with its scaled dt, and draw() at the // end of render, just before finish(). +import { addBox, touchedBy } from "./canvas" import type { Time, Weather } from "./wallpaper" import { clamp, fbm2, hash, smoothstep, type RGB } from "./math" @@ -181,11 +182,17 @@ 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) + const touched = touchedBy(hdr) + // The fog drifts, so grading changes the whole frame. + if (k.dim < 1 || k.fog > 0) { + grade(hdr, W, H, k.dim, p.desaturate * k.gray, p.fog, this.fogRows, this.time) + addBox(touched, 0, 0, W, H) + } const f = this.flakes for (let i = 0; i < this.snowCount * 4; i += 4) { const z = f[i + 2] const b = (0.35 + z * 0.5) * (0.6 + 0.4 * f[i + 3]) + addBox(touched, f[i] * W - 1, f[i + 1] * H - 1, f[i] * W + 3, f[i + 1] * H + 3) 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) @@ -195,6 +202,7 @@ export class WeatherLayer { const z = d[i + 2] const len = (0.025 + z * 0.035) * H const b = 0.05 + z * 0.12 + addBox(touched, d[i] * W - len * 0.12 - 1, d[i + 1] * H - len, d[i] * W + 2, d[i + 1] * H + 1) 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) } const s = this.splashes @@ -207,7 +215,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) + addBox(touched, s[i] * W - rx - 1, s[i + 1] * H - rx - 1, s[i] * W + rx + 1, s[i + 1] * H + rx * 0.3 + 1) + 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) } } } diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index 07a6d2f..8e43fce 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -247,7 +247,7 @@ class Beach extends Canvas { } render() { - this.hdr.set(this.background) + 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) 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) @@ -331,6 +331,7 @@ class Beach extends Canvas { const [, wet] = look.sand const ox = look.orb.x * W const roll = (t * 0.08) % 1 + this.touch(0, Math.floor(HORIZON * H), W, Math.min(H, 0.79 * H)) for (let x = 0; x < W; x++) { const u = x / H const edge = this.shore(u) * H diff --git a/wallpapers/city.ts b/wallpapers/city.ts index c2fb6c8..8c484b2 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -293,7 +293,7 @@ class City extends Canvas { render() { const { W, H, look } = this - this.hdr.set(this.background) + this.restore(this.background) if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4) 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) @@ -391,6 +391,7 @@ class City extends Canvas { // Adds a soft elliptical glow to buf, which is hdr or the river. private glow(buf: Float32Array, cx: number, cy: number, rx: number, ry: number, c: RGB, k: number) { const { W, H } = this + if (buf === this.hdr) this.touch(cx - rx, cy - ry, cx + rx + 1, cy + ry + 1) for (let y = Math.max(0, Math.floor(cy - ry)); y < Math.min(H, Math.ceil(cy + ry)); y++) for (let x = Math.max(0, Math.floor(cx - rx)); x < Math.min(W, Math.ceil(cx + rx)); x++) { const d = Math.hypot((x + 0.5 - cx) / rx, (y + 0.5 - cy) / ry) @@ -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, r1) 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..ab681fb 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -338,7 +338,7 @@ 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) @@ -550,6 +550,7 @@ class Desert extends Canvas { const cx = x - length * 0.55 const rx = length * 0.7 const ry = Math.max(1.5, length * 0.12) + this.touch(cx - rx, gy - ry, cx + rx + 1, gy + ry + 1) for (let y = Math.max(0, Math.floor(gy - ry)); y <= Math.min(H - 1, Math.ceil(gy + ry)); y++) for (let px = Math.max(0, Math.floor(cx - rx)); px <= Math.min(W - 1, Math.ceil(cx + rx)); px++) { const q = ((px + 0.5 - cx) / rx) ** 2 + ((y + 0.5 - gy) / ry) ** 2 @@ -744,6 +745,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, cy - ry, 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..8dea508 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -260,7 +260,7 @@ class Farm extends Canvas { } render() { - this.hdr.set(this.background) + 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) 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) diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 9fd07ec..82925c0 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) @@ -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 + if (look.shafts > 0) this.touch(0, 0, W, Math.ceil(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, f.bottom - ry, 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/prehistoric.ts b/wallpapers/prehistoric.ts index 3fbd6b5..ca3e605 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, 0, 0] } 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,9 +234,10 @@ class Prehistoric extends Canvas { } render() { - this.hdr.set(this.background) + 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) const orange = this.look.lava[1] + this.touch(this.lava.box[0], this.lava.box[1], this.lava.box[2], this.lava.box[3]) flowLava(this.hdr, this.lava.index, this.lava.k, this.lava.along, this.time, orange[0], orange[1], orange[2]) 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) @@ -418,7 +419,9 @@ 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]) } + // The streams' bounds, which render touches as their pulses flow. + const box = index.reduce((b, i) => [Math.min(b[0], i % W), Math.min(b[1], Math.floor(i / W)), Math.max(b[2], (i % W) + 1), Math.max(b[3], Math.floor(i / W) + 1)], [W, H, 0, 0]) + 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..477f6d4 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() @@ -334,6 +334,7 @@ class Space extends Canvas { const pad = 0.1 * R const lsx = Lx / (Math.hypot(Lx, Ly) || 1) const lsy = Ly / (Math.hypot(Lx, Ly) || 1) + this.touch(cx - R - pad - 1, cy - R - pad - 1, cx + R + pad + 2, H) for (let y = Math.max(0, Math.floor(cy - R - pad)); y < H; y++) for (let x = Math.max(0, Math.floor(cx - R - pad)); x <= Math.min(W - 1, Math.ceil(cx + R + pad)); x++) { const dx = (x + 0.5 - cx) / R @@ -388,6 +389,7 @@ class Space extends Canvas { private drawRingFront() { const [x0, y0, x1, y1] = this.ringBox const { W, ringFront } = this + this.touch(x0, y0, x1 + 1, y1 + 1) for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { const o = y * W + x @@ -404,6 +406,7 @@ class Space extends Canvas { // After an error the star dims, as if behind a passing dust cloud. const strength = (look.lighting === "front" ? 1 : 1.4) * (1 - smoothstep(0, 1, this.gloom) * 0.6) const reach = 0.45 * H + this.touch(sx - reach - 1, sy - reach - 1, sx + reach + 1, sy + reach + 1) for (let y = Math.max(0, Math.floor(sy - reach)); y < Math.min(H, sy + reach); y++) for (let x = Math.max(0, Math.floor(sx - reach)); x < Math.min(W, sx + reach); x++) { const dx = x + 0.5 - sx @@ -425,6 +428,7 @@ class Space extends Canvas { const cx = (0.24 * this.A + 0.06 * Math.cos(a)) * H const cy = (0.36 + 0.025 * Math.sin(a)) * H const R = 0.035 * H + this.touch(cx - R - 1, cy - R - 1, cx + R + 2, cy + R + 2) for (let y = Math.max(0, Math.floor(cy - R - 1)); y <= Math.min(H - 1, cy + R + 1); y++) for (let x = Math.max(0, Math.floor(cx - R - 1)); x <= Math.min(W - 1, cx + R + 1); x++) { const dx = (x + 0.5 - cx) / R @@ -583,6 +587,7 @@ class Space extends Canvas { const cx = r.x * H const cy = r.y * H const R = r.r * H + this.touch(cx - R * 1.3, cy - R * 1.3, cx + R * 1.3 + 1, cy + R * 1.3 + 1) for (let y = Math.max(0, Math.floor(cy - R * 1.3)); y <= Math.min(H - 1, cy + R * 1.3); y++) for (let x = Math.max(0, Math.floor(cx - R * 1.3)); x <= Math.min(W - 1, cx + R * 1.3); x++) { const dx = (x + 0.5 - cx) / R diff --git a/wallpapers/template.ts b/wallpapers/template.ts index 9e3aa7e..1fd0f74 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -115,7 +115,7 @@ 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) + this.restore(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) if (this.activity !== "calm") this.drawBalloon() diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..754cf66 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -220,7 +220,7 @@ class Tundra extends Canvas { } render() { - this.hdr.set(this.background) + 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) if (this.look.aurora > 0 && !this.weather.covered) this.drawAurora() const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] @@ -413,6 +413,7 @@ class Tundra extends Canvas { private drawAurora() { const { W, H, hdr, look } = this const t = this.time + this.touch(0, 0, W, 0.3 * H) 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 diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index 8206902..d27b619 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, 0, 0] private wet = new Float32Array(0) private under = new Float32Array(0) private front = new Float32Array(0) @@ -294,7 +295,7 @@ class Zen extends Canvas { } render() { - this.hdr.set(this.background) + 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) 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) @@ -359,6 +360,7 @@ class Zen extends Canvas { pool.push(y * W + x) } this.pool = Int32Array.from(pool) + this.poolBox = pool.reduce((b, i) => [Math.min(b[0], i % W), Math.min(b[1], Math.floor(i / W)), Math.max(b[2], (i % W) + 1), Math.max(b[3], Math.floor(i / W) + 1)], [W, H, 0, 0]) 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 + 3, cy + R * 0.32 + 3) 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 97ba620e9293e8a2e5c3b988573a786e3d3b45c9 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:00:07 -0700 Subject: [PATCH 2/5] Let cells where something moves take one-sub-pixel steps sooner --- src/engine.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/src/engine.tsx b/src/engine.tsx index 1e50f98..f305823 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -156,9 +156,9 @@ function evalMask(q: Float32Array, mask: number) { } // Splits a cell's eight sub-pixels into two colours by brightness threshold, choosing the split with the least -// squared error. A cell keeps last frame's split unless the new one is clearly better, so slow motion doesn't -// make the glyph pattern flicker between near-equal choices. Returns the mask used. -function writeOctant(q: Float32Array, order: Uint8Array, previous: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { +// squared error. A cell keeps last frame's split unless the new one is clearly better (see tolerance), so slow +// motion doesn't make the glyph pattern flicker between near-equal choices. Returns the mask used. +function writeOctant(q: Float32Array, order: Uint8Array, previous: number, older: number, moving: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { for (let s = 0; s < 8; s++) { const l = q[s * 3] * 0.2126 + q[s * 3 + 1] * 0.7152 + q[s * 3 + 2] * 0.0722 let j = s @@ -205,7 +205,7 @@ function writeOctant(q: Float32Array, order: Uint8Array, previous: number, char: let chosen = bestMask if (previous >= 0 && previous !== bestMask) { evalMask(q, previous) - if (split.err <= bestErr * 1.35 + HYSTERESIS) chosen = previous + if (split.err <= tolerance(bestErr, bestMask, previous, older, moving, HYSTERESIS)) chosen = previous } evalMask(q, chosen) if (chosen === 0 || chosen === 255) { @@ -231,6 +231,14 @@ function writeOctant(q: Float32Array, order: Uint8Array, previous: number, char: const luma8 = new Float32Array(8) // Extra squared error (in 0-255 channel units) a cell tolerates before abandoning last frame's split. const HYSTERESIS = 150 +// Pixels around a touched box that still count as moving: anti-aliased edges and the trail PERSISTENCE leaves. Bloom +// reaches much further (BLOOM_REACH), but it only shifts colors smoothly, so those cells keep the steady fit. +const MOVING_REACH = 4 +// Touched boxes under this many pixels (stars, snowflakes, motes) are points rather than edges and keep the steady fit. +const MOVING_MIN = 30 +// Boxes over this share of the screen are layers redrawn every frame (water, an aurora, light shafts, fog) or a scene +// that repaints everything, not something moving across a still background, so they keep the steady fit too. +const MOVING_MAX = 0.1 // Share of the previous frame blended into each new one, which smooths sub-pixel motion. const PERSISTENCE = 0.45 // How often the scene advances and redraws; wallpapers move slowly, so this stays low to save CPU and output. @@ -261,7 +269,7 @@ const QUADRANTS = [ // Picks the two-colour split of a cell's four sub-pixels with the least error and writes it as a quadrant block. Like // writeOctant it keeps last frame's split unless the new one is clearly better. Returns the mask used. -function writeQuadrant(q8: Float32Array, previous: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { +function writeQuadrant(q8: Float32Array, previous: number, older: number, moving: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { const q = quad4 for (let s = 0; s < 4; s++) { const top = ((s >> 1) * 4 + (s & 1)) * 3 @@ -279,7 +287,7 @@ function writeQuadrant(q8: Float32Array, previous: number, char: Uint32Array, fg let chosen = bestMask if (previous >= 0 && previous !== bestMask) { evalQuad(q, previous) - if (split.err <= bestErr * 1.35 + HYSTERESIS / 2) chosen = previous + if (split.err <= tolerance(bestErr, bestMask, previous, older, moving, HYSTERESIS / 2)) chosen = previous } evalQuad(q, chosen) bg[o] = split.br @@ -340,6 +348,32 @@ function evalQuad(q: Float32Array, mask: number) { split.err = err } +// Marks the cells under the scene's touched boxes (see Canvas.touched), widened by MOVING_REACH pixels. +function markMoving(moving: Uint8Array, touched: readonly number[] | undefined, PW: number, PH: number, W: number, H: number) { + moving.fill(0) + if (!touched) return + const sx = W / PW + const sy = H / PH + for (let b = 0; b < touched.length; b += 4) { + const area = (touched[b + 2] - touched[b]) * (touched[b + 3] - touched[b + 1]) + if (area < MOVING_MIN || area > PW * PH * MOVING_MAX) continue + const x0 = Math.max(0, Math.floor((touched[b] - MOVING_REACH) * sx)) + const x1 = Math.min(W, Math.ceil((touched[b + 2] + MOVING_REACH) * sx)) + const y0 = Math.max(0, Math.floor((touched[b + 1] - MOVING_REACH) * sy)) + const y1 = Math.min(H, Math.ceil((touched[b + 3] + MOVING_REACH) * sy)) + for (let y = y0; y < y1; y++) moving.fill(1, y * W + x0, y * W + x1) + } +} + +// The most error a cell's previous split may have and still be kept, given the best split's. Where something moves, +// a step of one sub-pixel (not back to the split before) is taken as soon as it fits a little better, so slow motion +// glides a sub-pixel at a time instead of jumping several at once. Bigger changes, steps back, and every static cell +// keep the full margin, so noise and swaying stay steady. +function tolerance(bestErr: number, best: number, previous: number, older: number, moving: number, margin: number) { + const d = best ^ previous + return moving && (d & (d - 1)) === 0 && best !== older ? bestErr * 1.05 + margin * 0.3 : bestErr * 1.35 + margin +} + function isBlock(char: number) { return char >= BLOCKS_START && char <= BLOCKS_END } @@ -464,6 +498,8 @@ export function createEngine( let surface = new Uint8Array(0) let cellTint = new Int32Array(0) let previousMask = new Int16Array(0) + let olderMask = new Int16Array(0) + let moving = new Uint8Array(0) let history = new Float32Array(0) let lastStep = 0 let lastTarget = "" @@ -734,7 +770,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.touched) if (options.dump && !dumped && frames > 120) { dumped = true write(buf, options.dump) @@ -755,10 +791,12 @@ 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, touched: readonly number[] | undefined) => { const W = buf.width const H = buf.height + if (moving.length !== W * H) moving = new Uint8Array(W * H) if (advanced) { + markMoving(moving, touched, PW, PH, W, H) const fresh = history.length !== px.length if (fresh) history = new Float32Array(px.length) for (let p = 0; p < px.length; p += 4) { @@ -805,6 +843,7 @@ export function createEngine( cellTint = new Int32Array(W * H) } if (previousMask.length !== cells) previousMask = new Int16Array(cells).fill(-1) + if (olderMask.length !== cells) olderMask = new Int16Array(cells).fill(-1) mask.fill(0, 0, W * MH) for (let i = 0; i < cells; i++) { const tint = tintOf(key(bg, i * 4)) @@ -888,8 +927,10 @@ export function createEngine( if (!surface[i]) continue } if (ch === SPACE && !atCursor(x, y)) { - 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) + const was = previousMask[i] + if (octants) previousMask[i] = writeOctant(q, order, was, olderMask[i], moving[i], char, fg, bg, i, o) + else previousMask[i] = writeQuadrant(q, was, olderMask[i], moving[i], char, fg, bg, i, o) + if (previousMask[i] !== was) olderMask[i] = was applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue @@ -977,6 +1018,7 @@ export function createEngine( lastTarget = "" history = new Float32Array(0) previousMask = new Int16Array(0) + olderMask = new Int16Array(0) lastActivity = Date.now() focused = true schedule() From b40b43c53bce887ee809ffc3c62a830bd2742c76 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:22:46 -0700 Subject: [PATCH 3/5] Document restore() and touch() for scene authors --- CONTRIBUTING.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37be422..56f85fa 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,10 @@ render() { This is the biggest single saving: a full-screen gradient with noise costs more than every creature together. +`restore()` also clears `touched`, the boxes around what this frame drew over the background, which the engine uses +to let moving things glide across terminal cells while still scenery stays steady. The drawing methods below, the sky's +stars and clouds and the weather record their boxes themselves. + ### Drawing `Canvas` methods, all in pixels, mixing over what's already there: @@ -102,7 +106,8 @@ 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 call `touch(x0, y0, x1, y1)` with the +loop's bounds in render. ### Shapes From c59a5b1c01a56219e2daa606a9949015ed10759a Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 12:38:24 -0700 Subject: [PATCH 4/5] Refit a cell the frame after it moved, so reused fits always match a full refit --- src/engine.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/engine.tsx b/src/engine.tsx index 455d619..05a8ab9 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -1079,7 +1079,7 @@ 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 - // Cells where something moves fit with a looser hysteresis (see tolerance), so they are never reused. + // Cells where something moves fit differently (see tolerance), so they are never reused. const reusable = fitted[i] === 1 && !refitAll && refit[i] === 0 && moving[i] === 0 fitted[i] = 0 if (imageCell[i]) continue @@ -1134,7 +1134,8 @@ export function createEngine( rememberFit(i, o, char, fg, bg, fitChar, fitFg, fitBg) fitTop[i] = st fitBottom[i] = sb - fitted[i] = 1 + // A fit made while something moved (see tolerance) isn't reused next frame. + fitted[i] = moving[i] ? 0 : 1 applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue From 4d93150ba6e02a1a5bdc3942ad6f7f17fe7ae920 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 11:53:19 -0700 Subject: [PATCH 5/5] Check in --verify-dirty that scenes draw only inside their touched boxes --- dev/check.ts | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/dev/check.ts b/dev/check.ts index 82284f8..4108246 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -3,7 +3,7 @@ // 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. +// differs between the two, and on any pixel a scene draws outside its touched boxes. import { mkdirSync, rmSync } from "node:fs" import { parseArgs } from "node:util" import { ACTIVITIES, SEASONS, TIMES, WEATHERS, type Activity, type Engine, type Time, type Wallpaper } from "../src/engine" @@ -47,6 +47,7 @@ for (const { wallpaper, activity, time } of runs) { 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}`, + result.uncovered && `${result.uncovered} pixels drawn outside the touched boxes, first ${result.where}`, ].filter(Boolean) if (problems.length) failed++ if (args.values.quiet && !problems.length) continue @@ -67,7 +68,10 @@ function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: strin performance.now = () => (hold ? held : (held = tick())) screen.renderer.getCursorState = () => cursor seed(1) - screen.engine.start(wallpaper, { activity, time, season, weather }) + let uncovered = 0 + let where = "" + const watched = verify && dirty === "1" ? watch(wallpaper, (n, at) => ((uncovered += n), (where ||= at))) : wallpaper + screen.engine.start(watched, { activity, time, season, weather }) let previous = new Uint32Array(W * H) let changes = 0 let total = 0 @@ -75,7 +79,7 @@ function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: strin let first = "" const recording: Uint16Array[] = [] for (let f = 0; f < frames; f++) { - if (verify) script(f, screen.engine, wallpaper, activity, time) + if (verify) script(f, screen.engine, watched, activity, time) const start = Bun.nanoseconds() screen.frame(verify && f >= 100 && f < 160 ? (x, y) => text(x + 3, y) : text) hold = false @@ -106,7 +110,7 @@ function play(wallpaper: Wallpaper, activity: Activity, time: Time, dirty: strin } } screen.engine.stop() - return { ms: total / (frames - 60), flicker: changes / (frames - 60), mismatches, first, recording } + return { ms: total / (frames - 60), flicker: changes / (frames - 60), mismatches, first, recording, uncovered, where } } // Text edits, redraws between frames, a moving cursor, agent events, a brightness change and a crossfade, to exercise every reason @@ -121,3 +125,35 @@ function script(f: number, engine: Engine, wallpaper: Wallpaper, activity: Activ 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" }) } + +// A wallpaper whose scenes check, after every render, that each pixel that differs from the cached background lies in +// a touched box: finish() only works out what's under them, so anything drawn elsewhere would never show. +function watch(wallpaper: Wallpaper, report: (pixels: number, at: string) => void): Wallpaper { + return { + ...wallpaper, + create(settings) { + const scene = wallpaper.create(settings) + const canvas = scene as unknown as { hdr: Float32Array; background?: Float32Array; touched?: number[] } + const render = scene.render.bind(scene) + scene.render = () => { + render() + const bg = canvas.background + const t = canvas.touched + if (!bg || !t || bg.length !== canvas.hdr.length) return + const inside = new Uint8Array(scene.W * scene.H) + for (let b = 0; b < t.length; b += 4) + for (let y = Math.max(0, Math.floor(t[b + 1])); y <= Math.min(scene.H - 1, Math.floor(t[b + 3])); y++) + for (let x = Math.max(0, Math.floor(t[b])); x <= Math.min(scene.W - 1, Math.floor(t[b + 2])); x++) inside[y * scene.W + x] = 1 + let n = 0 + let at = "" + for (let i = 0; i < inside.length; i++) { + if (inside[i] || (canvas.hdr[i * 3] === bg[i * 3] && canvas.hdr[i * 3 + 1] === bg[i * 3 + 1] && canvas.hdr[i * 3 + 2] === bg[i * 3 + 2])) continue + n++ + at ||= `${i % scene.W},${(i / scene.W) | 0}` + } + if (n) report(n, `${settings.time} at ${at}`) + } + return scene + }, + } +}