diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c946f3..ac447ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ protected override layout() { } render() { - this.hdr.set(this.background) + this.restore() // …moving things… this.finish() } @@ -103,6 +103,13 @@ city's river) can be made in the first pass, box-filtered with `this.shrink(buff the second pass to use. Scenes whose `layout()` paints nothing set `supersample = 1`. Smooth fills that cost a lot per pixel (space's nebula) can be worked out once per real pixel. +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; the shared helpers +(sky, weather, shadows, light, water, wind) record their own. 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: @@ -116,7 +123,8 @@ pixel (space's nebula) can be worked out once per real pixel. | `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. `reflect()` in `src/water.ts` mirrors +For per-pixel effects such as water, sand or fog, loop over `hdr` directly, and `touch()` the area during `render()` +(see above). `reflect()` in `src/water.ts` mirrors other rows of the scene into water rows with drifting ripples, a tint and a per-pixel weight; call it each frame over only the water's rows (as the beach and the prehistoric lake do), or once in `layout()` for still water such as ice. @@ -291,7 +299,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()`. @@ -314,6 +323,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] [home|session] [WxH] [seconds] # terminal cells behind text, as a PNG bun dev/pixel-check.ts [id] [activity] [time] # real-pixel mode against a mock kitty terminal @@ -329,6 +339,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. `home` and `session` lay the text out like OpenCode's home screen or a busy session, and a size such as `240x60` or `100x30` replaces 160x45. diff --git a/dev/check.ts b/dev/check.ts index 18e0272..82284f8 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,107 @@ 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, 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 + 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" }) + if (f === 205) engine.start(wallpaper, { activity, time: time === "night" ? "day" : "night", 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 990ea8c..c0c9778 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -1,6 +1,7 @@ import type { AgentEvent, TextMap } from "./wallpaper" import { clamp, rand, smoothstep, type RGB } from "./math" import { clearShadows, keepShadows } from "./shadow" +import { addBox, trackTouched } from "./touched" // Bloom is computed at 1/BLOOM resolution, then again at half that for the wide halo. const BLOOM = 4 @@ -56,6 +57,15 @@ 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; the drawing methods and the shared helpers (sky, weather, shadows, light, water, wind) add 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, for the engine to refit only the cells + // under them. + readonly changed: number[] = [] + // Whether this frame started with restore(); a scene that never calls it can have drawn anywhere. + private restored = false private bw = 0 private bh = 0 @@ -84,6 +94,8 @@ export abstract class Canvas { private stillPixels = new Uint8Array(0) private stillExposure = NaN private retone = new Uint8Array(0) + // The blocks tone-mapped last frame; the rest of pixels holds the still frame's. + private toned = new Uint8Array(0) private region = new Int32Array(4) // How overcast the scene is: eases to 1 after the agent fails and back to 0 with any other event (the engine sends @@ -182,6 +194,7 @@ export abstract class Canvas { this.cells = cells this.A = W / H this.hdr = new Float32Array(W * H * 3) + trackTouched(this.hdr, this.touched) this.pixels = new Uint8Array(W * H * 4) this.bw = Math.ceil(W / BLOOM) this.bh = Math.ceil(H / BLOOM) @@ -197,6 +210,7 @@ export abstract class Canvas { this.stillBloom = new Float32Array(this.bw * this.bh * 3) this.stillPixels = new Uint8Array(W * H * 4) this.retone = new Uint8Array(this.bw * this.bh) + this.toned = new Uint8Array(this.bw * this.bh) const invR = 1 / Math.hypot(W / 2, H / 2) this.vignette = new Float32Array(W * H) for (let y = 0; y < H; y++) @@ -261,6 +275,19 @@ export abstract class Canvas { return this.cells ? Math.max(radius * this.H, min * this.px) : radius * this.H } + // Starts a frame: copies the background (or a buffer of the scene's own) into hdr and clears touched. A scene that + // draws every pixel each frame passes undefined. + protected restore(background: Float32Array | undefined = this.background) { + if (background) this.hdr.set(background) + this.touched.length = 0 + this.restored = true + } + + // Marks pixels a scene's own loop writes during render(), x0..x1 by y0..y1 inclusive. + 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 @@ -270,6 +297,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. @@ -278,21 +306,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 @@ -300,30 +344,79 @@ 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, this.px) + 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 the blocks that differ from the still frame, or + // whose bloom does, are worked out again; the rest keep the still frame's pixels. protected finish(exposure = 1.25) { clearShadows(this.hdr) - if (exposure !== this.stillExposure) { + const { W, H, bw, bh, retone } = this + const fresh = exposure !== this.stillExposure + if (fresh) { // Without a still frame to compare with, bloomSource marks every block for tone-mapping. - bloomSource(this.still, this.W, this.H, this.bw, this.bh, this.stillSource, undefined, this.stillSource, this.retone, this.region) + bloomSource(this.still, W, H, bw, bh, this.stillSource, undefined, this.stillSource, retone, this.region) this.bloomA.set(this.stillSource) this.stillBloom.fill(0) - bloom(this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.stillBloom, this.retone, 0, 0, this.bw - 1, this.bh - 1) - toneMap(this.still, this.stillPixels, this.W, this.H, this.bw, this.retone, this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) + bloom(bw, bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.stillBloom, retone, 0, 0, bw - 1, bh - 1) + toneMap(this.still, this.stillPixels, W, H, bw, retone, this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) this.stillExposure = exposure + this.pixels.set(this.stillPixels) + this.toned.fill(0) } - this.pixels.set(this.stillPixels) - bloomSource(this.hdr, this.W, this.H, this.bw, this.bh, this.bloomA, this.still, this.stillSource, this.retone, this.region) + // Only blocks under what this frame drew can differ from the still frame. + if (this.restored) markBlocks(retone, bw, bh, this.touched) + else retone.fill(1) + this.restored = false + bloomSource(this.hdr, W, H, bw, bh, this.bloomA, this.still, this.stillSource, retone, this.region) const region = this.region - if (region[2] < region[0]) { - toneMap(this.hdr, this.pixels, this.W, this.H, this.bw, this.retone, this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) - return + const spread = region[2] >= region[0] + if (spread) { + this.bloomUp.set(this.stillBloom) + bloom(bw, bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.bloomUp, retone, region[0], region[1], region[2], region[3]) + } + settleBlocks(this.pixels, this.stillPixels, W, H, bw, bh, this.toned, retone, this.changed, fresh) + toneMap(this.hdr, this.pixels, W, H, bw, retone, spread ? this.bloomUp : this.stillBloom, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) + } +} + +// Marks the blocks (BLOOM pixels square) that any box overlaps. +function markBlocks(blocks: Uint8Array, bw: number, bh: number, boxes: readonly number[]) { + blocks.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++) if (x0 <= x1) blocks.fill(1, y * bw + x0, y * bw + x1 + 1) + } +} + +// Puts the still frame's pixels back in blocks tone-mapped last frame but not this one, and lists in changed (as runs +// of blocks, in pixels) every block whose pixels may differ from last frame's: those tone-mapped in either frame. +function settleBlocks(pixels: Uint8Array, stillPixels: Uint8Array, W: number, H: number, bw: number, bh: number, toned: Uint8Array, retone: Uint8Array, changed: number[], fresh: boolean) { + changed.length = 0 + if (fresh) changed.push(0, 0, W - 1, H - 1) + for (let by = 0; by < bh; by++) { + let run = -1 + for (let bx = 0; bx <= bw; bx++) { + const b = by * bw + bx + const moved = bx < bw && (toned[b] === 1 || retone[b] === 1) + if (bx < bw && toned[b] && !retone[b]) + for (let y = by * BLOOM; y < Math.min(H, by * BLOOM + BLOOM); y++) { + const o = (y * W + bx * BLOOM) * 4 + pixels.set(stillPixels.subarray(o, (y * W + Math.min(W, bx * BLOOM + BLOOM)) * 4), o) + } + if (bx < bw) toned[b] = retone[b] + if (moved && run < 0) run = bx + if (moved || run < 0) continue + if (!fresh) changed.push(run * BLOOM, by * BLOOM, Math.min(W, bx * BLOOM) - 1, Math.min(H, by * BLOOM + BLOOM) - 1) + run = -1 } - this.bloomUp.set(this.stillBloom) - bloom(this.bw, this.bh, this.bloomA, this.bloomB, this.bw2, this.bh2, this.bloom2A, this.bloom2B, this.bloomUp, this.retone, region[0], region[1], region[2], region[3]) - toneMap(this.hdr, this.pixels, this.W, this.H, this.bw, this.retone, this.bloomUp, this.bloomRow, this.vignette, this.rowA, this.rowB, this.rowF, this.colA, this.colB, this.colF, exposure) } } @@ -542,8 +635,9 @@ function toneMap(hdr: Float32Array, pixels: Uint8Array, W: number, H: number, bw } } -// Each bloom block's average light above the threshold, into source. Given the still frame, only blocks whose pixels -// differ from it are worked out, as the change from stillSource, and marked in retone; the rest are zero. Sets +// Each bloom block's average light above the threshold, into source. Given the still frame, only blocks marked in +// retone whose pixels differ from it are worked out, as the change from stillSource, and stay marked; the rest are +// zero. Sets // the box of blocks whose source changed, as region [x0, y0, x1, y1], empty when x1 < x0. function bloomSource(hdr: Float32Array, W: number, H: number, bw: number, bh: number, source: Float32Array, still: Float32Array | undefined, stillSource: Float32Array, retone: Uint8Array, region: Int32Array) { region[0] = bw @@ -553,7 +647,7 @@ function bloomSource(hdr: Float32Array, W: number, H: number, bw: number, bh: nu for (let by = 0; by < bh; by++) for (let bx = 0; bx < bw; bx++) { const o = (by * bw + bx) * 3 - const same = still !== undefined && sameBlock(hdr, still, W, H, bx, by) + const same = still !== undefined && (!retone[by * bw + bx] || sameBlock(hdr, still, W, H, bx, by)) retone[by * bw + bx] = same ? 0 : 1 if (same) { source[o] = source[o + 1] = source[o + 2] = 0 diff --git a/src/engine.tsx b/src/engine.tsx index 61f9ed1..be913ea 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -358,15 +358,15 @@ function quieten(char: Uint32Array, fg: Uint16Array, bg: Uint16Array, inputFg: U continue } const o = i * 4 - settle(fg, inputFg, shownFg, o) - settle(bg, inputBg, shownBg, o) + holdColor(fg, inputFg, shownFg, o) + holdColor(bg, inputBg, shownBg, o) } shownFg.set(fg) shownBg.set(bg) } // Colors OpenCode drew (unchanged from its input) are left exactly as they are. -function settle(colors: Uint16Array, input: Uint16Array, shown: Uint16Array, o: number) { +function holdColor(colors: Uint16Array, input: Uint16Array, shown: Uint16Array, o: number) { if (colors[o] === input[o] && colors[o + 1] === input[o + 1] && colors[o + 2] === input[o + 2] && colors[o + 3] === input[o + 3]) return for (let c = o; c < o + 4; c++) if (Math.abs(colors[c] - shown[c]) >= QUIET) return for (let c = o; c < o + 4; c++) colors[c] = shown[c] @@ -420,6 +420,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( @@ -510,62 +626,36 @@ 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 const textMap: TextMap = { cols: MAP_COLS, rows: MAP_ROWS, cover: new Float32Array(MAP_COLS * MAP_ROWS) } const mapCount = new Float32Array(MAP_COLS * MAP_ROWS) let mapped = false let mapFresh = 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 @@ -730,6 +820,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)}`) } } @@ -776,7 +868,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 (mapFresh) { mapFresh = false scene.textMap?.(textMap) @@ -802,27 +894,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" @@ -841,6 +956,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. @@ -921,6 +1039,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] @@ -929,6 +1049,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 @@ -956,9 +1085,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 @@ -972,7 +1105,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 @@ -1049,6 +1182,7 @@ export function createEngine( history = new Float32Array(0) previousMask = new Int16Array(0) shownChar = new Uint32Array(0) + dirty = process.env.WALLPAPER_DIRTY !== "0" lastActivity = Date.now() focused = true schedule() diff --git a/src/light.ts b/src/light.ts index bf1c6b7..bfc554d 100644 --- a/src/light.ts +++ b/src/light.ts @@ -1,5 +1,6 @@ import { cap, type Part } from "./canvas" import type { RGB } from "./math" +import { touch } from "./touched" // A pool of light from a lantern, lava, a window or glowing plankton, falling off over an ellipse (pixels) around // (cx, cy). lift lights what is already there in the light's own color, in proportion to its brightest channel, so lit @@ -9,6 +10,7 @@ export function lightPool(hdr: Float32Array, W: number, H: number, cx: number, c const x1 = Math.min(W - 1, Math.ceil(cx + rx)) const y0 = Math.max(0, Math.floor(cy - ry)) const y1 = Math.min(H - 1, Math.ceil(cy + ry)) + touch(hdr, x0, y0, x1, y1) const lr = color[0] * lift const lg = color[1] * lift const lb = color[2] * lift diff --git a/src/shadow.ts b/src/shadow.ts index edfffd1..c41b7fe 100644 --- a/src/shadow.ts +++ b/src/shadow.ts @@ -2,6 +2,7 @@ // Scenes make one Shade per layout with sunShade() and call groundShadow() under each thing that stands on the ground, // before drawing it: static things in layout(), moving things each frame. import { clamp, type RGB } from "./math" +import { touch } from "./touched" import type { Time } from "./wallpaper" // The ground is seen at a low angle, so a round footprint looks this much shallower than it is wide. @@ -97,6 +98,7 @@ export function fillShadow(hdr: Float32Array, W: number, H: number, ax: number, const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + R)) const y0 = Math.max(0, Math.floor(Math.min(ay, by) - R * SQUASH)) const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + R * SQUASH)) + touch(hdr, x0, y0, x1, y1) const dx = bx - ax const dy = (by - ay) / SQUASH const len2 = dx * dx + dy * dy diff --git a/src/sky.ts b/src/sky.ts index 5fd6f76..a8759a9 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,5 +1,6 @@ // Sky pieces shared by outdoor wallpapers: a depth-graded sky with a sun or moon, stars and drifting clouds. import { TAU, clamp, fbm1, fbm2, lerp, rand, smoothstep, type RGB } from "./math" +import { touch } from "./touched" // The sun or moon: x as a fraction of the width, y and r as fractions of the height. export interface Orb { @@ -76,6 +77,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] + touch(hdr, x, y, x, y) } } @@ -361,6 +363,7 @@ function drawCloud(hdr: Float32Array, W: number, H: number, s: Sprite, x: number const f = px - X0 const Y0 = Math.round(y) + s.y0 const sw = s.w + touch(hdr, X0, Y0, X0 + sw, Y0 + s.h - 1) for (let j = Math.max(0, -Y0); j < Math.min(s.h, H - Y0); j++) for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { const k = j * sw + i @@ -386,6 +389,7 @@ function drawShadow(hdr: Float32Array, W: number, H: number, s: Sprite, x: numbe const Y0 = Math.round(y) + s.y0 const sw = s.w const fade = H * 0.03 + touch(hdr, X0, Math.max(Y0, Math.floor(ground)), X0 + sw, Y0 + s.h - 1) for (let j = Math.max(0, -Y0, Math.floor(ground) - Y0); j < Math.min(s.h, H - Y0); j++) { const row = clamp((Y0 + j - ground) / fade, 0, 1) * 0.38 for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { diff --git a/src/touched.ts b/src/touched.ts new file mode 100644 index 0000000..fb81cd4 --- /dev/null +++ b/src/touched.ts @@ -0,0 +1,27 @@ +// What a frame draws over its scene's cached background (see Canvas.touched): boxes [x0, y0, x1, y1, ...] in pixels, +// inclusive. Shared helpers that draw into a scene's hdr from outside the Canvas record their boxes through the buffer. +const lists = new WeakMap() + +export function trackTouched(hdr: Float32Array, list: number[]) { + lists.set(hdr, list) +} + +// Adds a box to the touched list of the canvas that owns hdr, if any. +export function touch(hdr: Float32Array, x0: number, y0: number, x1: number, y1: number) { + const list = lists.get(hdr) + if (list) addBox(list, x0, y0, x1, y1) +} + +// Adds a box to a list. A box within a pixel of the last one grows that one instead, so per-pixel loops (add, blend) +// leave one box rather than thousands. +export function addBox(list: number[], x0: number, y0: number, x1: number, y1: number) { + 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) +} diff --git a/src/wallpaper.ts b/src/wallpaper.ts index 0332090..b14b507 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/water.ts b/src/water.ts index 65b6770..b0692b5 100644 --- a/src/water.ts +++ b/src/water.ts @@ -1,4 +1,5 @@ import type { RGB } from "./math" +import { touch } from "./touched" let wave = new Float32Array(0) @@ -8,6 +9,7 @@ let wave = new Float32Array(0) // since are left alone, so whatever swims or stands in front of the water stays in front of its reflection. export function reflect(hdr: Float32Array, W: number, H: number, y0: number, y1: number, rows: Float32Array, amp: Float32Array, weight: Float32Array, tint: RGB, time: number, still?: Float32Array) { if (wave.length !== W) wave = new Float32Array(W) + touch(hdr, 0, y0, W - 1, y1 - 1) for (let x = 0; x < W; x++) wave[x] = 2.4 * Math.sin((x / H) * 9 + time * 0.35) + 1.2 * Math.sin((x / H) * 23 - time * 0.6) for (let y = Math.max(0, y0); y < Math.min(H, y1); y++) { const r = y - y0 diff --git a/src/weather.ts b/src/weather.ts index 66719b7..f442161 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -3,6 +3,7 @@ // end of render, just before finish(). import type { Time, Weather } from "./wallpaper" import { clamp, fbm2, hash, smoothstep, type RGB } from "./math" +import { touch } from "./touched" interface Palette { deck: [top: RGB, bottom: RGB] @@ -181,7 +182,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) + touch(hdr, 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 +193,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) + touch(hdr, 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 +201,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) + touch(hdr, 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 +213,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) + touch(hdr, s[i] * W - rx - 2, s[i + 1] * H - rx - 2, s[i] * W + rx + 2, s[i + 1] * H + rx * 0.3 + 2) } } } diff --git a/src/wind.ts b/src/wind.ts index a9501a7..1d1961c 100644 --- a/src/wind.ts +++ b/src/wind.ts @@ -1,4 +1,5 @@ import { hash, smoothstep } from "./math" +import { touch } from "./touched" // One wind for the whole scene, so everything that sways moves together. Now and then a gust rolls across the screen // from left to right and dies away; between gusts the air is calm. x is in screen heights from the left edge and t in @@ -50,6 +51,8 @@ export class SwayLayer { private cover = new Uint8Array(0) private weight = new Float32Array(0) private shift = new Float32Array(0) + private top = new Int32Array(0) + private bottom = new Int32Array(0) // dir -1 sends the gusts the other way, right to left, for a scene whose breeze already blows that way. constructor(private readonly dir = 1) {} @@ -132,12 +135,32 @@ export class SwayLayer { this.shift[x] = this.dir * g * this.reach * (0.64 + 0.19 * Math.sin(t * 2.3 + u * 9)) if (g > 0) any = true } - if (any) shear(hdr, W, this.x0, this.y0, this.w, this.h, this.bare, this.layer, this.cover, this.weight, this.shift) + if (!any) return + if (this.top.length !== this.w) { + this.top = new Int32Array(this.w) + this.bottom = new Int32Array(this.w) + } + shear(hdr, W, this.x0, this.y0, this.w, this.h, this.bare, this.layer, this.cover, this.weight, this.shift, this.top, this.bottom) + // One touched box per run of columns that moved, so plants at both edges don't mark the whole width. + let run = -1 + let y0 = 0 + let y1 = 0 + for (let x = 0; x <= this.w; x++) { + const moved = x < this.w && this.top[x] <= this.bottom[x] + if (moved && run < 0) ((run = x), (y0 = this.top[x]), (y1 = this.bottom[x])) + if (moved) ((y0 = Math.min(y0, this.top[x])), (y1 = Math.max(y1, this.bottom[x]))) + if (moved || run < 0) continue + touch(hdr, this.x0 + run, this.y0 + y0, this.x0 + x - 1, this.y0 + y1) + run = -1 + } } } -// Redraws the layer's region with each pixel taken from `shift * weight` pixels upwind, over what lies behind it. -function shear(hdr: Float32Array, W: number, x0: number, y0: number, w: number, h: number, bare: Float32Array, layer: Float32Array, cover: Uint8Array, weight: Float32Array, shift: Float32Array) { +// Redraws the layer's region with each pixel taken from `shift * weight` pixels upwind, over what lies behind it, and +// records the rows each column changed between, in top and bottom. +function shear(hdr: Float32Array, W: number, x0: number, y0: number, w: number, h: number, bare: Float32Array, layer: Float32Array, cover: Uint8Array, weight: Float32Array, shift: Float32Array, top: Int32Array, bottom: Int32Array) { + top.fill(h) + bottom.fill(-1) for (let y = 0; y < h; y++) { const row = y * w for (let x = 0; x < w; x++) { @@ -155,6 +178,8 @@ function shear(hdr: Float32Array, W: number, x0: number, y0: number, w: number, const k = 1 - a - b const ia = (row + xa) * 3 for (let c = 0; c < 3; c++) hdr[o + c] = bare[i * 3 + c] * k + (a ? layer[ia + c] * a : 0) + (b ? layer[ia + 3 + c] * b : 0) + if (y < top[x]) top[x] = y + bottom[x] = y } } } diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index 93626c0..f5e270c 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -279,7 +279,7 @@ class Beach extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() 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] const orb = this.weather.covered ? undefined : this.look.orb @@ -404,6 +404,7 @@ class Beach extends Canvas { const ox = look.orb.x * W const roll = (t * 0.08) % 1 const thin = H / 180 + let bottom = 0 sea.gloss.fill(0) for (let x = 0; x < W; x++) { const u = x / H @@ -412,6 +413,7 @@ class Beach extends Canvas { const wave = lerp(0.62, edge / H - 0.005, roll) * H const broken = smoothstep(0.42, 0.6, fbm1(u * 6 + t * 0.1, 4)) const bend = 1.4 * Math.sin(u * 2.7 + t * 0.05) + 0.7 * Math.sin(u * 7.3 - t * 0.08) + bottom = Math.max(bottom, reach + 2, look.night ? edge + 0.05 * H : 0) for (let y = sea.y0; y < Math.min(H, reach + 2); y++) { const o = (y * W + x) * 3 const gi = (y - sea.y0) * W + x @@ -473,6 +475,7 @@ class Beach extends Canvas { hdr[o + 2] += look.foam[2] * k } } + this.touch(0, sea.y0, W - 1, bottom) } // A bonfire on the sand at night, lighting the beach, the crab and the palm beside it. diff --git a/wallpapers/city.ts b/wallpapers/city.ts index a033410..ff517ac 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -317,7 +317,7 @@ class City extends Canvas { render() { const { W, H, look } = this - this.hdr.set(this.background) + this.restore() 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, this.weather.covered ? undefined : look.orb) @@ -446,6 +446,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 { @@ -756,6 +757,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 2ced0a2..021b0a4 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -332,9 +332,12 @@ class Desert extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() // Heat haze over the sunlit floor, but not in winter or under cloud. - if (this.look.style === "front" && this.season !== "winter" && !this.weather.covered) shimmer(this.hdr, this.background, this.W, this.H, this.time) + if (this.look.style === "front" && this.season !== "winter" && !this.weather.covered) { + shimmer(this.hdr, this.background, this.W, this.H, this.time) + this.touch(0, Math.floor((HORIZON - 0.05) * this.H), this.W - 1, Math.ceil((HORIZON + 0.02) * this.H)) + } if (!this.weather.covered) this.drawStars() 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.weather.covered ? undefined : this.look.orb) diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 62b55de..1196dc1 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -212,6 +212,7 @@ class Farm extends Canvas { // The back corn rows are painted once but still bend as gusts pass, and the open pasture lightens under them. private corn = new SwayLayer() private pasture = new Int32Array(0) + private pastureTop = 0 private gusts = new Float32Array(0) private fan = 0 @@ -289,9 +290,17 @@ class Farm extends Canvas { } render() { - this.hdr.set(this.background) - for (let x = 0; x < this.W; x++) this.gusts[x] = gust(x / this.H, this.time) + this.restore() + let x0 = this.W + let x1 = -1 + for (let x = 0; x < this.W; x++) { + this.gusts[x] = gust(x / this.H, this.time) + if (this.gusts[x] < 0.01) continue + x0 = Math.min(x0, x) + x1 = x + } lightenGrass(this.hdr, this.W, this.H, this.pasture, this.gusts) + if (x1 >= 0) this.touch(x0, this.pastureTop, x1, this.H - 1) this.corn.draw(this.hdr, this.W, this.H, this.time) 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] @@ -421,6 +430,7 @@ class Farm extends Canvas { if (this.hdr[o] === field[o] && this.hdr[o + 1] === field[o + 1] && this.hdr[o + 2] === field[o + 2]) pasture.push(y * W + x) } this.pasture = Int32Array.from(pasture) + this.pastureTop = pasture.reduce((top, p) => Math.min(top, Math.floor(p / W)), H) this.gusts = new Float32Array(W) } diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index f5ea2d1..9e2c1b9 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -236,12 +236,13 @@ class Jungle extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() this.vines.draw(this.hdr, this.W, this.H, this.time) this.drawShafts() this.drawFalls() const mist = this.look.mist[1] driftMist(this.hdr, this.W, this.H, this.mist, mist[0], mist[1], mist[2], this.rainy ? 0.3 : 0.22) + this.touch(0, Math.floor(0.5 * this.H), this.W - 1, Math.ceil(0.96 * this.H)) if (this.toucan.x > -5) this.drawToucan(this.toucan) if (this.sloth.t >= 0) this.drawSloth() if (this.activity !== "calm") this.drawMonkey() @@ -428,6 +429,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++) { @@ -459,6 +461,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 8402c43..a829ac5 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -298,6 +298,7 @@ class Ocean extends Canvas { render() { const night = this.timeOfDay === "night" + this.restore() this.drawWater() if (night) this.water.set(this.hdr) this.drawWhale() @@ -656,6 +657,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 269a5b0..d745ce1 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -191,7 +191,7 @@ class Prehistoric extends Canvas { private shadows = makeShadows(3, HORIZON + 0.06, 0.95) 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] // The far ferns and cycads, painted once, bend as the gusts pass. private plants = new SwayLayer() @@ -270,11 +270,12 @@ class Prehistoric extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() this.plants.draw(this.hdr, this.W, this.H, this.time) 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] 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] const orb = this.weather.covered ? undefined : this.look.orb paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) @@ -513,7 +514,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 lake in front of the volcano with a muddy shore. Its water mirrors the forest, the volcano and the sky above it, diff --git a/wallpapers/space.ts b/wallpapers/space.ts index 0664f26..e873596 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -215,7 +215,7 @@ class Space extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() this.drawStars() this.drawMoon() this.drawPlanet() @@ -368,6 +368,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 + 1, H - 1) 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 @@ -423,6 +424,7 @@ class Space extends Canvas { private drawRingFront() { const [x0, y0, x1, y1] = this.ringBox const { W, ringFront } = this + this.touch(x0, y0, x1, y1) for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) { const o = y * W + x @@ -443,6 +445,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, sy + reach) 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 diff --git a/wallpapers/template.ts b/wallpapers/template.ts index e9fa94a..a188a36 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -116,7 +116,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() 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, this.look.orb) if (this.activity !== "calm") this.drawBalloon() diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index 00e64ad..ea11871 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -234,7 +234,7 @@ class Tundra extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() this.pines.draw(this.hdr, this.W, this.H, this.time) 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() @@ -495,10 +495,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 @@ -510,6 +512,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 a39a09e..55246a4 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -211,6 +211,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) @@ -308,7 +309,7 @@ class Zen extends Canvas { } render() { - this.hdr.set(this.background) + this.restore() this.trees.draw(this.hdr, this.W, this.H, this.time) 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] @@ -412,6 +413,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] = bankLayer[pool[k] * 3 + c] this.mirror = new Float32Array(W * H * 3) @@ -857,6 +859,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 @@ -891,6 +894,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