From 48f23a203c399bfddb327c36ba9b402bf700698b Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 20:30:16 -0700 Subject: [PATCH 1/4] Draw bolder detail on terminal cells and steady quadrant cells --- dev/check.ts | 2 +- dev/preview.ts | 2 +- src/canvas.ts | 26 +++++++- src/engine.tsx | 126 ++++++++++++++++++++------------------ src/wallpaper.ts | 5 +- wallpapers/beach.ts | 18 ++++-- wallpapers/farm.ts | 46 +++++++++----- wallpapers/jungle.ts | 8 ++- wallpapers/ocean.ts | 10 ++- wallpapers/prehistoric.ts | 23 +++++-- wallpapers/zen.ts | 13 +++- 11 files changed, 176 insertions(+), 103 deletions(-) diff --git a/dev/check.ts b/dev/check.ts index 2e702d5..18e0272 100644 --- a/dev/check.ts +++ b/dev/check.ts @@ -12,7 +12,7 @@ mkdirSync("dev/out", { recursive: true }) const debug = `${process.cwd()}/dev/out/check.log` rmSync(debug, { force: true }) process.env.WALLPAPER_DEBUG = debug -process.env.WALLPAPER_OCTANTS = "1" +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 maxMs = Number(args.values["max-ms"] ?? Infinity) diff --git a/dev/preview.ts b/dev/preview.ts index 2835d1b..fbebe7c 100644 --- a/dev/preview.ts +++ b/dev/preview.ts @@ -14,7 +14,7 @@ const weather = WEATHERS.find((w) => rest.includes(w)) const seconds = rest.find((arg) => /^[\d.]+$/.test(arg)) ?? "20" const wallpaper = wallpapers.find((w) => w.id === id) if (!wallpaper) throw new Error(`usage: bun dev/preview.ts <${wallpapers.map((w) => w.id).join("|")}> [seconds]`) -process.env.WALLPAPER_OCTANTS = "1" +process.env.WALLPAPER_OCTANTS ??= "1" mkdirSync("dev/out", { recursive: true }) const screen = mockScreen() screen.engine.start(wallpaper, { activity, time, season, weather }) diff --git a/src/canvas.ts b/src/canvas.ts index bd4e824..db9df50 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -20,6 +20,17 @@ export function ell(cx: number, cy: number, rx: number, ry: number, angle: numbe return { kind: "ellipse", cx, cy, rx, ry, angle, color, ribs } } +// A leafy blade along spine (points in pixels), widths[i] wide either side at each point: overlapping ellipses whose +// scalloped edges suggest leaflets. Fronds draw this on terminal cells (see Canvas.cells) instead of single leaflets, +// which would turn into speckle there. +export function blade(spine: readonly (readonly [number, number])[], widths: readonly number[], color: RGB): Part[] { + return spine.slice(1).map(([bx, by], i) => { + const [ax, ay] = spine[i] + const half = Math.hypot(bx - ax, by - ay) * 0.75 + return ell((ax + bx) / 2, (ay + by) / 2, Math.max(half, 0.5), Math.max(0.5, (widths[i] + widths[i + 1]) / 2), Math.atan2(by - ay, bx - ax), color) + }) +} + // How shape() lights a shape. rim: backlit from a point (pixels), so only the outline facing it glows. front: lit from // a direction (x right, y down, z toward the viewer), shading each part as a rounded form. export type Lighting = { style: "rim"; color: RGB; x: number; y: number } | { style: "front"; dir: readonly [number, number, number] } @@ -31,6 +42,10 @@ export abstract class Canvas { W = 0 H = 0 A = 1 + // Set when each pixel is a sub-pixel of a terminal character cell (2 across, 4 down) and each cell shows only two + // colors. Strokes under about two pixels across and texture finer than a cell turn into noise there, so scenes draw + // bolder when it's set: thicker strokes (see thick()), solid blades for leaflets, coarser texture. + protected cells = false hdr = new Float32Array(0) pixels = new Uint8Array(0) @@ -75,12 +90,13 @@ export abstract class Canvas { // Called after every size change, once the buffers match the new size. protected layout() {} - resize(W: number, H: number) { + resize(W: number, H: number, cells = false) { W = Math.max(64, Math.round(W)) H = Math.max(36, Math.round(H)) - if (W === this.W && H === this.H) return + if (W === this.W && H === this.H && cells === this.cells) return this.W = W this.H = H + this.cells = cells this.A = W / H this.hdr = new Float32Array(W * H * 3) this.pixels = new Uint8Array(W * H * 4) @@ -125,6 +141,12 @@ export abstract class Canvas { this.layout() } + // A stroke radius in screen heights, in pixels; on terminal cells never under min, since thinner strokes break up + // into noise there and shimmer as they move. + protected thick(radius: number, min = 1) { + return this.cells ? Math.max(radius * this.H, min) : radius * this.H + } + // Adds light to one pixel. protected add(x: number, y: number, r: number, g: number, b: number) { const xi = x | 0 diff --git a/src/engine.tsx b/src/engine.tsx index 91a87a8..1e50f98 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -259,8 +259,9 @@ const QUADRANTS = [ 0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588, ] -// Picks the two-colour split of a cell's four sub-pixels with the least error and writes it as a quadrant block. -function writeQuadrant(q8: Float32Array, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { +// 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) { const q = quad4 for (let s = 0; s < 4; s++) { const top = ((s >> 1) * 4 + (s & 1)) * 3 @@ -268,68 +269,75 @@ function writeQuadrant(q8: Float32Array, char: Uint32Array, fg: Uint16Array, bg: } let bestMask = 0 let bestErr = Infinity - let fr = 0 - let fgr = 0 - let fb = 0 - let br = 0 - let bgg = 0 - let bb = 0 for (let m = 0; m < 8; m++) { - let ar = 0 - let ag = 0 - let ab = 0 - let an = 0 - let cr = 0 - let cg = 0 - let cb = 0 - let cn = 0 - for (let s = 0; s < 4; s++) { - if (m & (1 << s)) { - ar += q[s * 3] - ag += q[s * 3 + 1] - ab += q[s * 3 + 2] - an++ - } else { - cr += q[s * 3] - cg += q[s * 3 + 1] - cb += q[s * 3 + 2] - cn++ - } - } - if (an) ((ar /= an), (ag /= an), (ab /= an)) - if (cn) ((cr /= cn), (cg /= cn), (cb /= cn)) - let err = 0 - for (let s = 0; s < 4; s++) { - const on = m & (1 << s) - const dr = q[s * 3] - (on ? ar : cr) - const dg = q[s * 3 + 1] - (on ? ag : cg) - const db = q[s * 3 + 2] - (on ? ab : cb) - err += dr * dr + dg * dg + db * db - } - if (err < bestErr - 0.5) { - bestErr = err + evalQuad(q, m) + if (split.err < bestErr - 0.5) { + bestErr = split.err bestMask = m - fr = ar - fgr = ag - fb = ab - br = cr - bgg = cg - bb = cb } } - bg[o] = br - bg[o + 1] = bgg - bg[o + 2] = bb + let chosen = bestMask + if (previous >= 0 && previous !== bestMask) { + evalQuad(q, previous) + if (split.err <= bestErr * 1.35 + HYSTERESIS / 2) chosen = previous + } + evalQuad(q, chosen) + bg[o] = split.br + bg[o + 1] = split.bg + bg[o + 2] = split.bb bg[o + 3] = 255 - if (bestMask === 0) { + if (chosen === 0) { char[i] = 0x20 - return + return chosen } - char[i] = QUADRANTS[bestMask] - fg[o] = fr - fg[o + 1] = fgr - fg[o + 2] = fb + char[i] = QUADRANTS[chosen] + fg[o] = split.fr + fg[o + 1] = split.fg + fg[o + 2] = split.fb fg[o + 3] = 255 + return chosen +} + +// Like evalMask, for a cell's four quadrant sub-pixels. +function evalQuad(q: Float32Array, mask: number) { + let fr = 0 + let fg = 0 + let fb = 0 + let fn = 0 + let br = 0 + let bg = 0 + let bb = 0 + let bn = 0 + for (let s = 0; s < 4; s++) { + if (mask & (1 << s)) { + fr += q[s * 3] + fg += q[s * 3 + 1] + fb += q[s * 3 + 2] + fn++ + } else { + br += q[s * 3] + bg += q[s * 3 + 1] + bb += q[s * 3 + 2] + bn++ + } + } + if (fn) ((fr /= fn), (fg /= fn), (fb /= fn)) + if (bn) ((br /= bn), (bg /= bn), (bb /= bn)) + let err = 0 + for (let s = 0; s < 4; s++) { + const on = mask & (1 << s) + const dr = q[s * 3] - (on ? fr : br) + const dg = q[s * 3 + 1] - (on ? fg : bg) + const db = q[s * 3 + 2] - (on ? fb : bb) + err += dr * dr + dg * dg + db * db + } + split.fr = fr + split.fg = fg + split.fb = fb + split.br = br + split.bg = bg + split.bb = bb + split.err = err } function isBlock(char: number) { @@ -694,8 +702,8 @@ export function createEngine( const target = `${targetW}x${targetH}` const resized = target !== lastTarget lastTarget = target - scene.resize(targetW, targetH) - from?.scene.resize(targetW, targetH) + scene.resize(targetW, targetH, !pixels) + from?.scene.resize(targetW, targetH, !pixels) // The scene advances at most once per timer interval, and not at all while paused; redraws in between (typing, UI // updates) reuse the last frame. const now = performance.now() @@ -881,7 +889,7 @@ export function createEngine( } if (ch === SPACE && !atCursor(x, y)) { if (octants) previousMask[i] = writeOctant(q, order, previousMask[i], char, fg, bg, i, o) - else writeQuadrant(q, char, fg, bg, i, o) + else previousMask[i] = writeQuadrant(q, previousMask[i], char, fg, bg, i, o) applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue diff --git a/src/wallpaper.ts b/src/wallpaper.ts index f0fbdea..7807b74 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -2,12 +2,13 @@ import type { RGB } from "./math" // A wallpaper's animation. Each frame the engine resizes the scene to the sub-pixel grid it needs, steps it by the // elapsed seconds, renders it, and reads `pixels` (RGBA, W x H; only resize may replace the array). Extending Canvas -// provides everything but step and render. +// provides everything but step and render. resize's cells is set when each pixel becomes one sub-pixel of a terminal +// character cell, fitted to two colors per cell, so the scene can draw bolder. export interface Scene { readonly W: number readonly H: number readonly pixels: Uint8Array - resize(W: number, H: number): void + resize(W: number, H: number, cells?: boolean): void step(dt: number): void render(): void // Optional: what the OpenCode agent is doing. Scenes bring on a rare visitor when a task is done and turn overcast diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f977204..07a6d2f 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -1,4 +1,4 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" @@ -422,6 +422,8 @@ class Beach extends Canvas { const length = (0.17 + hash(i * 3.1 + p.lean) * 0.05) * H const parts: Part[] = [] let [x, y] = [cx, cy] + const spine: [number, number][] = [[x, y]] + const widths = [this.thick(0.005)] let angle = base + sway for (let s = 0; s < 8; s++) { const q = s / 8 @@ -429,14 +431,18 @@ class Beach extends Canvas { angle += (Math.cos(base) > 0 ? 1 : -1) * 0.06 * (0.3 + Math.abs(Math.cos(base))) + 0.02 const nx = x + Math.cos(angle) * (length / 8) const ny = y + Math.sin(angle) * (length / 8) + q * q * 0.006 * H - parts.push(cap(x, y, nx, ny, lerp(0.005, 0.0015, q) * H, lerp(0.005, 0.0015, q + 0.125) * H, frond)) + parts.push(cap(x, y, nx, ny, this.thick(lerp(0.005, 0.0015, q)), this.thick(lerp(0.005, 0.0015, q + 0.125), 0.7), frond)) const leaf = (1 - q * 0.6) * 0.03 * H - for (const side of [-1, 1]) { - const la = angle + side * 1.1 + 0.4 - parts.push(cap(nx, ny, nx + Math.cos(la) * leaf, ny + Math.sin(la) * leaf, 0.0028 * H, 0.0008 * H, frond)) - } + spine.push([nx, ny]) + widths.push(leaf * 0.5) + if (!this.cells) + for (const side of [-1, 1]) { + const la = angle + side * 1.1 + 0.4 + parts.push(cap(nx, ny, nx + Math.cos(la) * leaf, ny + Math.sin(la) * leaf, 0.0028 * H, 0.0008 * H, frond)) + } ;[x, y] = [nx, ny] } + if (this.cells) parts.push(...blade(spine, widths, frond)) this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.7) } } diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 868c944..77ec218 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -296,7 +296,8 @@ class Farm extends Canvas { const season = this.season const golden = season === "autumn" ? 0.45 : 0.85 const patchwork = season === "winter" ? 0.3 : 1 - const grass = season === "winter" ? 0.03 : 0.1 + // Blades are a pixel-fine texture, which terminal cells would turn into diagonal hatching. + const grass = this.cells ? 0 : season === "winter" ? 0.03 : 0.1 const farTop = new Float32Array(W) this.nearTop = new Float32Array(W) for (let x = 0; x < W; x++) { @@ -448,13 +449,15 @@ class Farm extends Canvas { const H = this.H const c = this.paint([0.5, 0.48, 0.45]) const parts: Part[] = [] - for (const s of [-1, 1]) parts.push(cap((x + s * 0.035) * H, base * H, (x + s * 0.007) * H, (base - 0.2) * H, 0.0028 * H, 0.0022 * H, c)) - for (let i = 0; i < 4; i++) { - const y0 = base - i * 0.05 - const y1 = base - (i + 1) * 0.05 - const w0 = 0.035 - i * 0.007 - const w1 = 0.035 - (i + 1) * 0.007 - parts.push(cap((x - w0) * H, y0 * H, (x + w1) * H, y1 * H, 0.0015 * H, 0.0015 * H, c), cap((x + w0) * H, y0 * H, (x - w1) * H, y1 * H, 0.0015 * H, 0.0015 * H, c)) + for (const s of [-1, 1]) parts.push(cap((x + s * 0.035) * H, base * H, (x + s * 0.007) * H, (base - 0.2) * H, this.thick(0.0028, 1.1), this.thick(0.0022, 1), c)) + // Fewer, sturdier braces where the terminal's cells would turn a fine lattice into a scribble. + const levels = this.cells ? 2 : 4 + for (let i = 0; i < levels; i++) { + const y0 = base - (i * 0.2) / levels + const y1 = base - ((i + 1) * 0.2) / levels + const w0 = 0.035 - (i * 0.028) / levels + const w1 = 0.035 - ((i + 1) * 0.028) / levels + parts.push(cap((x - w0) * H, y0 * H, (x + w1) * H, y1 * H, this.thick(0.0015, 0.7), this.thick(0.0015, 0.7), c), cap((x + w0) * H, y0 * H, (x - w1) * H, y1 * H, this.thick(0.0015, 0.7), this.thick(0.0015, 0.7), c)) } this.shape(parts, this.lighting, 0.5) } @@ -467,9 +470,11 @@ class Farm extends Canvas { const turn = this.time * 0.8 this.polygon([[x - 0.01 * H, y], [x - 0.075 * H, y - 0.018 * H], [x - 0.075 * H, y + 0.012 * H]], this.paint([0.6, 0.15, 0.1])) const parts: Part[] = [ell(x, y, 0.009 * H, 0.009 * H, 0, this.paint([0.35, 0.33, 0.3]))] - for (let i = 0; i < 14; i++) { - const a = turn + (i / 14) * TAU - parts.push(cap(x + Math.cos(a) * 0.012 * H, y + Math.sin(a) * 0.012 * H, x + Math.cos(a) * 0.06 * H, y + Math.sin(a) * 0.06 * H, 0.004 * H, 0.008 * H, metal)) + // Blade tips stay several pixels apart, so the turning wheel doesn't shimmer in the terminal's cells. + const blades = this.cells ? 8 : 14 + for (let i = 0; i < blades; i++) { + const a = turn + (i / blades) * TAU + parts.push(cap(x + Math.cos(a) * 0.012 * H, y + Math.sin(a) * 0.012 * H, x + Math.cos(a) * 0.06 * H, y + Math.sin(a) * 0.06 * H, this.thick(0.004, 1), this.thick(0.008, 2), metal)) } this.shape(parts, this.lighting, 0.6) } @@ -480,25 +485,32 @@ class Farm extends Canvas { const corn = CORN[this.season] const base = [0.79, 0.87, 0.96, 1.05][row] * H const height = (0.12 + row * 0.045) * H * corn.height - const spacing = 0.026 + row * 0.006 + // Stalks stand at least a few cells apart, so in the terminal the field reads as plants rather than stripes. + const spacing = this.cells ? Math.max(0.026 + row * 0.006, 9 / H) : 0.026 + row * 0.006 + const stalk = this.paint(corn.stalk) + // The row's shaded mass of leaves, so the field reads as rows rather than a tangle of thin strokes. + const end = (0.36 * A + 0.03) * H + const mass: [number, number][] = [[-2, base + 2]] + for (let x = -2; x < end; x += 6) mass.push([x, base - height * (0.42 + 0.08 * Math.sin((x / H) * 37 + row * 2))]) + mass.push([end + 0.02 * H, base + 2]) + this.polygon(mass, [stalk[0] * 0.6, stalk[1] * 0.6, stalk[2] * 0.6]) for (let i = 0; i * spacing < 0.36 * A; i++) { const x = (i * spacing + (row % 2) * spacing * 0.5 + hash(i * 3.1 + row) * 0.008) * H const h = height * (0.85 + hash(i * 7.3 + row) * 0.3) const sway = row === 3 ? Math.sin(this.time * 0.7 + i * 0.9) * 0.05 : (hash(i + row * 5) - 0.5) * 0.06 const top: [number, number] = [x + Math.sin(sway) * h, base - Math.cos(sway) * h] const at = (t: number): [number, number] => [lerp(x, top[0], t), lerp(base, top[1], t)] - const stalk = this.paint(corn.stalk) - const parts: Part[] = [cap(x, base, ...top, 0.006 * H * (1 + row * 0.3), 0.003 * H, stalk)] + const parts: Part[] = [cap(x, base, ...top, this.thick(0.006 * (1 + row * 0.3), 1.2), this.thick(0.003, 0.8), stalk)] for (let l = 0; l < 4; l++) { const [lx, ly] = at(0.3 + l * 0.15) const side = l % 2 ? 1 : -1 const reach = h * 0.28 const mid: [number, number] = [lx + side * reach * 0.6, ly - reach * 0.25] - parts.push(cap(lx, ly, ...mid, 0.006 * H, 0.004 * H, stalk), cap(...mid, lx + side * reach, ly + reach * 0.15 + sway * reach, 0.004 * H, 0.0015 * H, stalk)) + parts.push(cap(lx, ly, ...mid, this.thick(0.006, 1.3), this.thick(0.004, 1.1), stalk), cap(...mid, lx + side * reach, ly + reach * 0.15 + sway * reach, this.thick(0.004, 1.1), this.thick(0.0015, 0.6), stalk)) } const [ex, ey] = at(0.55) - if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, 0.007 * H, 0.016 * H, 0.25 + sway, this.paint(corn.ear))) - if (corn.tassel) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) + if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, this.thick(0.007, 1.6), this.thick(0.016, 3), 0.25 + sway, this.paint(corn.ear))) + if (corn.tassel && !this.cells) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it. this.shape(parts, this.lighting, this.look.style === "rim" ? 0.2 : 0.6) } diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 850f37c..9fd07ec 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -1,4 +1,4 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src/math" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" @@ -378,10 +378,12 @@ class Jungle extends Canvas { for (let i = 0; i < 10; i++) { const [ax, ay] = at(i / 10) const [bx, by] = at((i + 1) / 10) - parts.push(cap(ax, ay, bx, by, size * 0.025, size * 0.02, this.paint(LEAF))) + parts.push(cap(ax, ay, bx, by, this.thick((size * 0.025) / this.H), this.thick((size * 0.02) / this.H, 0.8), this.paint(LEAF))) const leaflet = size * 0.14 * (1 - i / 12) - for (const s of [-1, 1]) parts.push(ell(ax + s * leaflet * 0.4, ay - leaflet * 0.5, leaflet * 0.6, leaflet * 0.18, s * 0.9 - 0.5 * side, this.paint(LEAF))) + if (!this.cells) for (const s of [-1, 1]) parts.push(ell(ax + s * leaflet * 0.4, ay - leaflet * 0.5, leaflet * 0.6, leaflet * 0.18, s * 0.9 - 0.5 * side, this.paint(LEAF))) } + // On terminal cells the leaflets merge into one blade. + if (this.cells) parts.push(...blade(Array.from({ length: 11 }, (_, i) => at(i / 10)), Array.from({ length: 11 }, (_, i) => size * 0.1 * (1 - i / 12)), this.paint(LEAF))) this.shape(parts, this.lighting, 0.6) } diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index ec5e136..a6eea5f 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -618,6 +618,8 @@ class Ocean extends Canvas { const camFar = Math.round(Math.sin(t * 0.09) * H * 0.04) + this.pad const camMid = Math.round(Math.sin(t * 0.09) * H * 0.08) + this.pad const cs = 128 / (H * 0.55) + // The caustic net is squashed into the thin surface band; less so on terminal cells, which would break its lines up. + 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 for (let y = 0; y < H; y++) { @@ -630,7 +632,7 @@ class Ocean extends Canvas { const sl = surf + Math.sin(x * 0.045 + t * 1.3) * 1.4 + Math.sin(x * 0.13 - t * 2.1) * 0.7 if (y < sl) { const cu = ((x * 0.6 * cs + t * 9) | 0) & 127 - const cv = ((y * 4 * cs + x * 0.2 * cs - t * 6) | 0) & 127 + const cv = ((y * squash * cs + x * 0.2 * cs - t * 6) | 0) & 127 const c = CAUSTICS[cv * CAUS_N + cu] const win = Math.exp(-Math.pow((x / W - 0.5) * 2.2, 2)) * 0.6 const edge = smoothstep(sl - 3, sl, y) @@ -760,7 +762,9 @@ class Ocean extends Canvas { r = lerp(r, fog[0], 0.3) g = lerp(g, fog[1], 0.3) b = lerp(b, fog[2], 0.3) - const stem = Math.max(0.6, w * 0.55) + // On terminal cells the stem and blades stay at least a sub-pixel or so wide, so they sway without shimmering. + const least = this.cells ? 1.1 : 0.6 + const stem = Math.max(least, w * 0.55) const steps = Math.max(1, Math.ceil(Math.hypot(nx - px, ny - py) / Math.max(0.5, stem * 0.6))) for (let j = 0; j < steps; j++) { const u = j / steps @@ -779,7 +783,7 @@ class Ocean extends Canvas { const bend = dirA * (1 + q * 0.6) const lx = nx + Math.sin(bend) * bladeL * q const ly = ny - Math.cos(bend) * bladeL * q - const rad = Math.max(0.5, w * 0.95 * Math.sin(Math.PI * Math.min(0.98, q * 0.9 + 0.08))) + const rad = Math.max(least - 0.1, w * 0.95 * Math.sin(Math.PI * Math.min(0.98, q * 0.9 + 0.08))) this.disc(lx, ly, rad, lr * (0.85 + q * 0.3), lg * (0.85 + q * 0.3), lb * (0.85 + q * 0.3), 0.88) } } diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d37610a..ac73f5e 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -1,4 +1,4 @@ -import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" +import { Canvas, blade, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" @@ -319,7 +319,8 @@ class Prehistoric extends Canvas { const [far, near] = look.ground this.fillBelow(groundTop, (x, y, d) => { const depth = clamp((y / H - 0.62) / 0.38, 0, 1) - const blades = 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 + // Blades are a pixel-fine texture, which terminal cells would turn into diagonal hatching. + const blades = this.cells ? 0.95 : 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 return this.rim([lerp(far[0], near[0], depth) * blades, lerp(far[1], near[1], depth) * blades, lerp(far[2], near[2], depth) * blades], x, d, 2) }) if (this.activity === "teeming") this.paintLake() @@ -484,16 +485,21 @@ class Prehistoric extends Canvas { const len = size * (1.4 + hash(i * 3 + x) * 0.5) * H const parts: Part[] = [] let [px, py] = [x * H, top * H] + const spine: [number, number][] = [[px, py]] + const widths = [1] let angle = a for (let s = 0; s < 6; s++) { angle += Math.cos(a) > 0 ? 0.05 : -0.05 const nx = px + Math.cos(angle) * (len / 6) const ny = py + Math.sin(angle) * (len / 6) + s * 0.0015 * H - parts.push(cap(px, py, nx, ny, 0.004 * H, 0.003 * H, frond)) + parts.push(cap(px, py, nx, ny, this.thick(0.004), this.thick(0.003), frond)) const leaf = (1 - s / 8) * size * 0.4 * H - for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 0.8) * leaf, ny + Math.sin(angle + side * 0.8) * leaf, 0.004 * H, 0.0012 * H, frond)) + spine.push([nx, ny]) + widths.push(leaf * 0.45) + if (!this.cells) for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 0.8) * leaf, ny + Math.sin(angle + side * 0.8) * leaf, 0.004 * H, 0.0012 * H, frond)) ;[px, py] = [nx, ny] } + if (this.cells) parts.push(...blade(spine, widths, frond)) this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) } this.shape([ell(x * H, (top - 0.008) * H, size * 0.12 * H, size * 0.16 * H, 0, this.paint([0.7, 0.5, 0.2]), 3)], this.lighting, 0.6) @@ -509,17 +515,22 @@ class Prehistoric extends Canvas { const len = size * (0.75 + hash(seed * 7 + i) * 0.35) * H const parts: Part[] = [] let [px, py] = [x * H, y * H] + const spine: [number, number][] = [[px, py]] + const widths = [1] let angle = base + bend for (let s = 0; s < 7; s++) { const q = s / 7 angle += (Math.cos(base) >= 0 ? 1 : -1) * 0.1 * (0.4 + Math.abs(Math.cos(base))) + bend * 0.3 const nx = px + Math.cos(angle) * (len / 7) const ny = py + Math.sin(angle) * (len / 7) - parts.push(cap(px, py, nx, ny, lerp(0.004, 0.0012, q) * H, lerp(0.004, 0.0012, q + 1 / 7) * H, fern)) + parts.push(cap(px, py, nx, ny, this.thick(lerp(0.004, 0.0012, q)), this.thick(lerp(0.004, 0.0012, q + 1 / 7), 0.7), fern)) const leaf = Math.sin(Math.PI * (0.25 + q * 0.75)) * size * 0.22 * H - for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 1.15) * leaf, ny + Math.sin(angle + side * 1.15) * leaf, 0.0045 * H, 0.001 * H, fern)) + spine.push([nx, ny]) + widths.push(leaf * 0.55) + if (!this.cells) for (const side of [-1, 1]) parts.push(cap(nx, ny, nx + Math.cos(angle + side * 1.15) * leaf, ny + Math.sin(angle + side * 1.15) * leaf, 0.0045 * H, 0.001 * H, fern)) ;[px, py] = [nx, ny] } + if (this.cells) parts.push(...blade(spine, widths, fern)) this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) } } diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index c1f2340..34455f2 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -516,10 +516,17 @@ class Zen extends Canvas { const rocks = this.rocks() const s0 = 0.011 const k = (0.03 - s0) / (1 - GROUND) + // Lines stay at least 8 pixels apart (two terminal cells), so they read as lines rather than moiré. Nearer than + // where the perspective spacing passes that, they widen as before. + const least = this.cells ? Math.max(s0, 8 / H) : s0 + const near = GROUND + (least - s0) / k for (let y = Math.floor(GROUND * H); y < H; y++) { const v = (y + 0.5) / H - const spacing = s0 + k * (v - GROUND) - const lines = Math.log(spacing / s0) / k / s0 + const perspective = s0 + k * (v - GROUND) + const spacing = Math.max(least, perspective) + // Rings are squashed to 0.42 of their width, so they sit farther apart to stay clear of each other down the screen. + const ring = this.cells ? Math.max(perspective, 6 / (0.42 * H)) : perspective + const lines = v < near ? (v - GROUND) / least : (near - GROUND) / least + Math.log(spacing / least) / k for (let x = 0; x < W; x++) { const u = (x + 0.5) / H const top = GROUND + 0.004 * Math.sin(u * 9) @@ -531,7 +538,7 @@ class Zen extends Canvas { let moss = smoothstep(0.62, 0.6, v + 0.008 * Math.sin(u * 13)) + smoothstep(1.22, 1.15, pd + 0.04 * Math.sin(u * 23)) + smoothstep(0.04, 0.03, sd) for (const r of rocks) { const d = Math.hypot(u - r.x, (v - r.y) / 0.42) - if (d < r.r + 4.4 * spacing) groove = Math.sin(((d - r.r) / spacing) * TAU) + if (d < r.r + 4.4 * perspective + ring) groove = Math.sin(((d - r.r) / ring) * TAU) moss += smoothstep(r.r * 1.12, r.r * 0.95, d) } const grain = (hash2(x, y) - 0.5) * 0.08 From 5f30f1d49db96e72dc1d8a7cc46bba4e09aea841 Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 20:37:34 -0700 Subject: [PATCH 2/4] Keep fronds and corn near-silhouettes under rim light on terminal cells --- wallpapers/farm.ts | 5 +++-- wallpapers/prehistoric.ts | 6 ++++-- wallpapers/zen.ts | 3 ++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 77ec218..a60c32f 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -511,8 +511,9 @@ class Farm extends Canvas { const [ex, ey] = at(0.55) if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, this.thick(0.007, 1.6), this.thick(0.016, 3), 0.25 + sway, this.paint(corn.ear))) if (corn.tassel && !this.cells) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) - // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it. - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.2 : 0.6) + // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it, and + // on terminal cells less still. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.08 : 0.2) : 0.6) } } diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index ac73f5e..4af0e02 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -500,7 +500,8 @@ class Prehistoric extends Canvas { ;[px, py] = [nx, ny] } if (this.cells) parts.push(...blade(spine, widths, frond)) - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) + // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.12 : 0.3) : 0.6) } this.shape([ell(x * H, (top - 0.008) * H, size * 0.12 * H, size * 0.16 * H, 0, this.paint([0.7, 0.5, 0.2]), 3)], this.lighting, 0.6) } @@ -531,7 +532,8 @@ class Prehistoric extends Canvas { ;[px, py] = [nx, ny] } if (this.cells) parts.push(...blade(spine, widths, fern)) - this.shape(parts, this.lighting, this.look.style === "rim" ? 0.3 : 0.6) + // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.12 : 0.3) : 0.6) } } diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index 34455f2..8206902 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -612,7 +612,8 @@ class Zen extends Canvas { const across = (h - 0.5) * 2 parts.push(ell((cx + across * w) * H, (cy + (hash(h * 13) - 0.5) * 0.022 + across * across * 0.012 - shade * 0.007) * H, (0.02 - shade * 0.003) * H, (0.011 - shade * 0.002) * H, (hash(h * 7) - 0.5) * 0.6, this.paint(reds[shade]))) } - this.shape(parts, this.lighting, 0.7) + // Rim light on every small cluster would speckle the crown on terminal cells. + this.shape(parts, this.lighting, this.cells && this.look.style === "rim" ? 0.25 : 0.7) } } From 2a1ac6ca245f9695877bf11b2e8b52ee6eb3cf57 Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 21:19:35 -0700 Subject: [PATCH 3/4] Document drawing for terminal cells and export blade --- CONTRIBUTING.md | 11 ++++++++--- src/api.ts | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c067710..37be422 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,14 @@ when idle at 5 fps, never more than 0.1 s at a time), then `render()`, and reads `src/canvas.ts`) and you only write `step` and `render`. Brightness is applied by the engine, so scenes ignore it. The grid is small: a 160x45 terminal is 320x180 sub-pixels in character mode (2x4 per cell), and 480x270 in pixel mode. -`bun dev/snap.ts` renders at 720x400. Anything thinner than about 2 sub-pixels flickers or vanishes, so size things as -fractions of the height `H`. Most scenes use x from 0 to `A` (the aspect ratio, `W / H`) and y from 0 to 1, and multiply -by `H` to get pixels. +`bun dev/snap.ts` renders at 720x400. Size things as fractions of the height `H`. Most scenes use x from 0 to `A` (the +aspect ratio, `W / H`) and y from 0 to 1, and multiply by `H` to get pixels. + +In character mode each cell shows only two colors, so anything thinner than about 2 sub-pixels, and any texture finer +than a cell, turns into speckle and shimmers as it moves. `Canvas` sets `this.cells` then (not in snaps or pixel mode), +so a scene can draw bolder there: `thick(radius, min)` gives a stroke radius in pixels that never drops below `min` on +cells, `blade(spine, widths, color)` draws a frond's leaflets as one scalloped blade, and textures can coarsen or drop +out. Judge it with `bun dev/preview.ts`, and with `WALLPAPER_OCTANTS=0` for quadrant terminals. ### step and render diff --git a/src/api.ts b/src/api.ts index 1815efa..d1c2272 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,5 @@ // The public API for wallpapers, imported as "opencode-wallpapers/api". Nothing here depends on OpenCode or OpenTUI. -export { Canvas, cap, ell, type Lighting, type Part } from "./canvas" +export { Canvas, blade, cap, ell, type Lighting, type Part } from "./canvas" export { eggWait } from "./egg" export { bird, flyAway, startle, type Flier } from "./flock" export { TAU, clamp, fbm1, fbm2, hash, hash2, hashString, hsv, lerp, noise1, noise2, rand, smoothstep, type RGB } from "./math" From 38d0c6ebb4ec20d8a5f1aa88c40aca34c3af7983 Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 21:24:22 -0700 Subject: [PATCH 4/4] Let prehistoric fronds catch a little more rim light on cells --- wallpapers/prehistoric.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index 4af0e02..3fbd6b5 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -501,7 +501,7 @@ class Prehistoric extends Canvas { } if (this.cells) parts.push(...blade(spine, widths, frond)) // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. - this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.12 : 0.3) : 0.6) + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.18 : 0.3) : 0.6) } this.shape([ell(x * H, (top - 0.008) * H, size * 0.12 * H, size * 0.16 * H, 0, this.paint([0.7, 0.5, 0.2]), 3)], this.lighting, 0.6) } @@ -533,7 +533,7 @@ class Prehistoric extends Canvas { } if (this.cells) parts.push(...blade(spine, widths, fern)) // On terminal cells rim light would outline every frond in scribbles; they stay near-silhouettes there. - this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.12 : 0.3) : 0.6) + this.shape(parts, this.lighting, this.look.style === "rim" ? (this.cells ? 0.18 : 0.3) : 0.6) } }