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

Filter by extension

Filter by extension

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

Expand Down
2 changes: 1 addition & 1 deletion dev/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
26 changes: 24 additions & 2 deletions src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] }
Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
126 changes: 67 additions & 59 deletions src/engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,77 +259,85 @@ 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
for (let c = 0; c < 3; c++) q[s * 3 + c] = (q8[top + c] + q8[top + 6 + c]) / 2
}
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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/wallpaper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 12 additions & 6 deletions wallpapers/beach.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -422,21 +422,27 @@ 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
// Fronds pointing sideways droop more.
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)
}
}
Expand Down
Loading
Loading