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
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ than a cell, turns into speckle and shimmers as it moves. `Canvas` sets `this.ce
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. Soft light needs none of this: in low-contrast cells the engine draws gradients as bands across the cell (halves,
quarters or a middle stripe), so glows, haze and sky gradients stay smooth. Judge it with `bun dev/preview.ts`, and
with `WALLPAPER_OCTANTS=0` for quadrant terminals.
quarters or a middle stripe), so glows, haze and sky gradients stay smooth. On octant terminals high-contrast edge
cells may instead take a diagonal wedge or eighth block (`src/smooth.ts`) when it fits better, so clean anti-aliased
edges look smoother; `WALLPAPER_SMOOTH=0` turns that off for comparison. Judge it with `bun dev/preview.ts`, and with
`WALLPAPER_OCTANTS=0` for quadrant terminals.

### step and render

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,16 @@ terminals that report focus, such as Ghostty, kitty, iTerm2 and tmux). Any input

## Terminals

Any truecolor terminal works. Ghostty and kitty get octant characters (2x4 sub-pixels per cell); other terminals get
quadrant blocks (2x2). These environment variables tune rendering:
Any truecolor terminal works. Ghostty and kitty get octant characters (2x4 sub-pixels per cell), plus diagonal wedges
and eighth blocks along edges, which they draw themselves; other terminals get quadrant blocks (2x2). These environment
variables tune rendering:

| Variable | Effect |
| --- | --- |
| `WALLPAPER_PIXELS=1` | In Ghostty or kitty, draw the scene as a real image behind the text instead of characters |
| `WALLPAPER_SUPERSAMPLE=2` | Smoother edges in character mode, at about 2.5x the CPU |
| `WALLPAPER_OCTANTS=0` or `1` | Force quadrant or octant characters |
| `WALLPAPER_SMOOTH=0` | Octants only, without the wedges and eighth blocks (if a font draws them badly) |
| `WALLPAPER_LOCATION` | A place or `latitude,longitude` for local weather, if none is set with `/wallpaper location` |

## How it works
Expand Down
13 changes: 8 additions & 5 deletions dev/cells.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Renders terminal cells (a WALLPAPER_DUMP file, or dev/preview.ts output) to a PNG at 8x16 pixels per cell:
// bun dev/cells.ts dump.json out.png
import { OCTANTS } from "../src/octants"
import { SHAPES } from "../src/smooth"
import { glyph } from "./font"
import { encodePng } from "./png"

Expand All @@ -22,9 +23,13 @@ export function cellsToPng(cells: Cells) {
const ch = cells.char[i] >= 0x80000000 ? (rows?.[cy]?.[cx] ?? 0x3f) : cells.char[i]
const oct = ch === 0x20 ? -1 : OCTANTS.indexOf(ch)
const quad = QUADS.indexOf(ch)
const gl = oct < 0 && quad < 0 && ch > 32 ? glyph(ch) : undefined
const shape = oct < 0 ? SHAPES.get(ch) : undefined
const gl = oct < 0 && quad < 0 && !shape && ch > 32 ? glyph(ch) : undefined
for (let y = 0; y < CH; y++)
for (let x = 0; x < CW; x++) {
// Smooth glyphs are anti-aliased, as Ghostty and kitty draw them: mix by coverage of a 4x4 grid per pixel.
let cover = 0
if (shape) for (let s = 0; s < 16; s++) cover += shape((x + ((s & 3) + 0.5) / 4) / CW, (y + ((s >> 2) + 0.5) / 4) / CH) ? 1 / 16 : 0
const lit =
oct > 0
? !!(oct & (1 << (Math.floor((y * 4) / CH) * 2 + (x < CW / 2 ? 0 : 1))))
Expand All @@ -33,11 +38,9 @@ export function cellsToPng(cells: Cells) {
: gl
? x >= 1 && x < 6 && y >= 3 && y < 11 && !!(gl[x - 1] & (1 << (y - 3)))
: ch > 127 && x >= 2 && x <= 4 && y >= 5 && y <= 8
const src = lit ? cells.fg : cells.bg
const k = shape ? cover : lit ? 1 : 0
const o = ((cy * CH + y) * W + cx * CW + x) * 4
img[o] = src[i * 4] & 255
img[o + 1] = src[i * 4 + 1] & 255
img[o + 2] = src[i * 4 + 2] & 255
for (let c = 0; c < 3; c++) img[o + c] = (cells.bg[i * 4 + c] & 255) * (1 - k) + (cells.fg[i * 4 + c] & 255) * k
img[o + 3] = 255
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/canvas.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AgentEvent, TextMap } from "./wallpaper"
import { clamp, rand, smoothstep, type RGB } from "./math"
import { trackOrbs } from "./orbs"
import { clearShadows, keepShadows } from "./shadow"
import { addBox, trackTouched } from "./touched"

Expand Down Expand Up @@ -66,6 +67,8 @@ export abstract class Canvas {
readonly changed: number[] = []
// Whether this frame started with restore(); a scene that never calls it can have drawn anywhere.
private restored = false
// The sun and moon discs layout() painted, [cx, cy, r, ...] in pixels (see src/orbs.ts).
readonly orbs: number[] = []

private bw = 0
private bh = 0
Expand Down Expand Up @@ -195,6 +198,8 @@ export abstract class Canvas {
this.A = W / H
this.hdr = new Float32Array(W * H * 3)
trackTouched(this.hdr, this.touched)
this.orbs.length = 0
trackOrbs(this.hdr, this.orbs)
this.pixels = new Uint8Array(W * H * 4)
this.bw = Math.ceil(W / BLOOM)
this.bh = Math.ceil(H / BLOOM)
Expand Down
42 changes: 37 additions & 5 deletions src/engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { appendFileSync } from "node:fs"
import { watchEgg } from "./egg"
import { lerp, smoothstep, type RGB } from "./math"
import { OCTANTS } from "./octants"
import { FIRST, prepareEdges, refineEdge } from "./smooth"
import { AGENT_EVENTS, type AgentEvent, type Brightness, type Power, type Scene, type Scrim, type Settings, type TextMap, type Wallpaper } from "./wallpaper"

export {
Expand Down Expand Up @@ -210,6 +211,7 @@ function writeOctant(q: Float32Array, order: Uint8Array, previous: number, older
}
}
const band = bandSplit(q, 4, previous, flatErr, bestErr)
softFit = band >= 0
let chosen = band < 0 ? bestMask : band
if (band < 0 && previous >= 0 && previous !== bestMask) {
evalMask(q, previous)
Expand Down Expand Up @@ -237,6 +239,8 @@ function writeOctant(q: Float32Array, order: Uint8Array, previous: number, older
return chosen
}
const luma8 = new Float32Array(8)
// Whether writeOctant's last cell was soft light it drew as a gradient band.
let softFit = false
// Extra squared error (in 0-255 channel units) a cell tolerates before abandoning last frame's split.
const HYSTERESIS = 150
// Pixels around a touched box that still count as moving: anti-aliased edges and the trail PERSISTENCE leaves. Bloom
Expand Down Expand Up @@ -376,13 +380,27 @@ function markMoving(moving: Uint8Array, touched: readonly number[] | undefined,
}
}

// Marks the cells the sun or moon discs (see Scene.orbs) reach into.
function markOrbs(round: Uint8Array, orbs: readonly number[] | undefined, PW: number, PH: number, W: number, H: number) {
round.fill(0)
if (!orbs) return
for (let b = 0; b < orbs.length; b += 3) {
const r = orbs[b + 2] + 2
const x0 = Math.max(0, Math.floor(((orbs[b] - r) * W) / PW))
const x1 = Math.min(W - 1, Math.floor(((orbs[b] + r) * W) / PW))
const y0 = Math.max(0, Math.floor(((orbs[b + 1] - r) * H) / PH))
const y1 = Math.min(H - 1, Math.floor(((orbs[b + 1] + r) * H) / PH))
for (let y = y0; y <= y1; y++) if (x0 <= x1) round.fill(1, y * W + x0, y * W + x1 + 1)
}
}

// The most error a cell's previous split may have and still be kept, given the best split's. Where something moves,
// a step of one sub-pixel (not back to the split before) is taken as soon as it fits a little better, so slow motion
// glides a sub-pixel at a time instead of jumping several at once. Bigger changes, steps back, and every static cell
// keep the full margin, so noise and swaying stay steady.
function tolerance(bestErr: number, best: number, previous: number, older: number, moving: number, margin: number) {
const d = best ^ previous
return moving && (d & (d - 1)) === 0 && best !== older ? bestErr * 1.05 + margin * 0.3 : bestErr * 1.35 + margin
return moving && (d & (d - 1)) === 0 && best !== older ? bestErr * 1.1 + margin * 0.6 : bestErr * 1.35 + margin
}

// The terminal is sent every cell whose character or colors differ from the last frame, and slow light (aurora, twinkle,
Expand Down Expand Up @@ -724,13 +742,19 @@ export function createEngine(
const quad = new Float32Array(24)
const order = new Uint8Array(8)
const octants = process.env.WALLPAPER_OCTANTS !== "0" && (process.env.WALLPAPER_OCTANTS === "1" || OCTANT_TERMINALS.test(`${process.env.TERM_PROGRAM ?? ""} ${process.env.TERM ?? ""}`))
// Octant terminals also draw the smooth mosaics and eighth blocks themselves, for smoother edges.
const smooth = octants && process.env.WALLPAPER_SMOOTH !== "0"
let scratch = new Float32Array(0)
let surface = new Uint8Array(0)
let cellTint = new Int32Array(0)
let previousMask = new Int16Array(0)
// The split each cell showed before its current one, and the cells where something moves this frame (see tolerance).
let olderMask = new Int16Array(0)
let moving = new Uint8Array(0)
// Each cell's sub-pixels when src/smooth.ts last chose its glyph, and the cells over the sun or moon, which keep
// their octants: wedges would turn the disc into a polygon.
let edgeSeen = new Float32Array(0)
let round = new Uint8Array(0)
let history = new Float32Array(0)
// What each cell showed last frame, and the colors OpenCode drew this frame before the scene went in, for quieten.
let shownChar = new Uint32Array(0)
Expand Down Expand Up @@ -987,7 +1011,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, from ? undefined : scene.changed, from ? undefined : scene.touched)
paint(buf, from ? mixed : scene.pixels, scene.W, scene.H, advanced, pixels, desaturate, shade, persistence, scrim, from ? undefined : scene.changed, from ? undefined : scene.touched, scene.orbs)
if (mapFresh) {
mapFresh = false
scene.textMap?.(textMap)
Expand All @@ -1013,7 +1037,7 @@ 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, changed: readonly number[] | undefined, touched: readonly number[] | undefined) => {
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, touched: readonly number[] | undefined, orbs: readonly number[] | undefined) => {
const W = buf.width
const H = buf.height
const GW = W * 2
Expand Down Expand Up @@ -1044,9 +1068,12 @@ export function createEngine(
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])
moving = new Uint8Array(cells)
round = new Uint8Array(cells)
edgeSeen = new Float32Array(cells * 24).fill(-1)
}
if (advanced) {
markMoving(moving, touched, PW, PH, W, H)
markOrbs(round, orbs, PW, PH, W, H)
// 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
Expand Down Expand Up @@ -1153,6 +1180,7 @@ export function createEngine(
inputFg.set(fg)
inputBg.set(bg)
const q = quad
if (smooth) prepareEdges(grid, GW, GH, curve, edgeSeen)
const cover = look.scrim
const reach = look.reach
for (let y = 0; y < H; y++) {
Expand Down Expand Up @@ -1210,8 +1238,12 @@ export function createEngine(
}
if (fits) {
const was = previousMask[i]
if (octants) previousMask[i] = writeOctant(q, order, was, olderMask[i], moving[i], char, fg, bg, i, o)
else previousMask[i] = writeQuadrant(q, was, olderMask[i], moving[i], char, fg, bg, i, o)
if (octants) {
const octant = writeOctant(q, order, was < FIRST ? was : -1, olderMask[i], moving[i], char, fg, bg, i, o)
// Soft cells keep their gradient band; still edges may take a smooth glyph instead of the octant, but not
// where something moves (it would flip between wedge and octant as it glides) or over the sun and moon.
previousMask[i] = smooth && !softFit && !round[i] && !moving[i] ? refineEdge(q, was, octant, char, fg, bg, i, o, x, y, deepR, deepG, deepB, st, sb) : octant
} else previousMask[i] = writeQuadrant(q, was, olderMask[i], moving[i], char, fg, bg, i, o)
if (previousMask[i] !== was) olderMask[i] = was
rememberFit(i, o, char, fg, bg, fitChar, fitFg, fitBg)
fitTop[i] = st
Expand Down
11 changes: 11 additions & 0 deletions src/orbs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// The sun and moon discs a scene's layout() paints, [cx, cy, r, ...] in pixels, per scene buffer (see Canvas.orbs):
// round shapes that the engine keeps smooth wedge glyphs off, since they would turn the disc into a polygon.
const lists = new WeakMap<Float32Array, number[]>()

export function trackOrbs(hdr: Float32Array, list: number[]) {
lists.set(hdr, list)
}

export function addOrb(hdr: Float32Array, cx: number, cy: number, r: number) {
lists.get(hdr)?.push(cx, cy, r)
}
2 changes: 2 additions & 0 deletions src/sky.ts
Original file line number Diff line number Diff line change
@@ -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 { addOrb } from "./orbs"
import { touch } from "./touched"

// The sun or moon: x as a fraction of the width, y and r as fractions of the height.
Expand Down Expand Up @@ -36,6 +37,7 @@ export function paintSky(hdr: Float32Array, W: number, H: number, stops: [number
const ox = orb.x * W
const oy = orb.y * H
const R = orb.r * H
addOrb(hdr, ox, oy, R)
for (let y = 0; y < H; y++) {
const v = y / H
const i = stops.findIndex(([at]) => at >= v)
Expand Down
Loading
Loading