From c3d356b8ec6d3d963f88b71469c0ca91ea8c4b3b Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 09:37:38 -0700 Subject: [PATCH 1/5] Draw edges with smooth mosaic wedges and eighth blocks on octant terminals --- dev/cells.ts | 13 +-- src/engine.tsx | 11 ++- src/smooth.ts | 228 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 src/smooth.ts diff --git a/dev/cells.ts b/dev/cells.ts index ab0f6c4..ac5b578 100644 --- a/dev/cells.ts +++ b/dev/cells.ts @@ -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" @@ -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)))) @@ -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 } } diff --git a/src/engine.tsx b/src/engine.tsx index 1e50f98..ffdb537 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -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 Wallpaper } from "./wallpaper" export { @@ -460,6 +461,8 @@ 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) @@ -844,6 +847,7 @@ export function createEngine( layerPlaced = false const q = quad + if (smooth) prepareEdges(grid, GW, GH, curve) const cover = look.scrim const reach = look.reach for (let y = 0; y < H; y++) { @@ -888,8 +892,11 @@ export function createEngine( if (!surface[i]) continue } if (ch === SPACE && !atCursor(x, y)) { - if (octants) previousMask[i] = writeOctant(q, order, previousMask[i], char, fg, bg, i, o) - else previousMask[i] = writeQuadrant(q, previousMask[i], char, fg, bg, i, o) + if (octants) { + const previous = previousMask[i] + const octant = writeOctant(q, order, previous < FIRST ? previous : -1, char, fg, bg, i, o) + previousMask[i] = smooth ? refineEdge(q, previous, octant, char, fg, bg, i, o, x, y, deepR, deepG, deepB, st, sb) : octant + } 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/smooth.ts b/src/smooth.ts new file mode 100644 index 0000000..fc9b235 --- /dev/null +++ b/src/smooth.ts @@ -0,0 +1,228 @@ +// Smooth glyphs for octant terminals, which draw Symbols for Legacy Computing themselves (Ghostty, kitty): diagonal +// wedges and eighth blocks follow slanted and nearly straight edges where octants can only draw a 2x4 staircase. + +// Whether a point of the cell (x right, y down, both 0 to 1) is foreground. +type Inside = (x: number, y: number) => boolean + +// Below the line through (ax, ay) and (bx, by), extended across the cell: how Ghostty and kitty draw the smooth mosaics. +const below = + (ax: number, ay: number, bx: number, by: number): Inside => + (x, y) => + y >= ay + ((by - ay) * (x - ax)) / (bx - ax) + +const T1 = 1 / 3 +const T2 = 2 / 3 + +// One glyph of each complementary pair: a two-color fit gets the other by swapping colors, so the upper mosaics +// (U+1FB52 to U+1FB67), upper and right eighths, and the inverted triangles are left out. Lower and left halves and +// quarters are octants already. +const GLYPHS: [number, Inside][] = [ + [0x1fb3c, below(0, T2, 0.5, 1)], + [0x1fb3d, below(0, T2, 1, 1)], + [0x1fb3e, below(0, T1, 0.5, 1)], + [0x1fb3f, below(0, T1, 1, 1)], + [0x1fb40, below(0, 0, 0.5, 1)], + [0x1fb41, below(0, T1, 0.5, 0)], + [0x1fb42, below(0, T1, 1, 0)], + [0x1fb43, below(0, T2, 0.5, 0)], + [0x1fb44, below(0, T2, 1, 0)], + [0x1fb45, below(0, 1, 0.5, 0)], + [0x1fb46, below(0, T2, 1, T1)], + [0x1fb47, below(0.5, 1, 1, T2)], + [0x1fb48, below(0, 1, 1, T2)], + [0x1fb49, below(0.5, 1, 1, T1)], + [0x1fb4a, below(0, 1, 1, T1)], + [0x1fb4b, below(0.5, 1, 1, 0)], + [0x1fb4c, below(0.5, 0, 1, T1)], + [0x1fb4d, below(0, 0, 1, T1)], + [0x1fb4e, below(0.5, 0, 1, T2)], + [0x1fb4f, below(0, 0, 1, T2)], + [0x1fb50, below(0.5, 0, 1, 1)], + [0x1fb51, below(0, T1, 1, T2)], + // โ—ฃ โ—ข: the corner-to-corner diagonals. + [0x25e3, below(0, 0, 1, 1)], + [0x25e2, below(0, 1, 1, 0)], + // ๐Ÿญฌ ๐Ÿญญ ๐Ÿญฎ ๐Ÿญฏ: triangles from one edge to the center. + [0x1fb6c, (x, y) => x <= 0.5 - Math.abs(y - 0.5)], + [0x1fb6d, (x, y) => y <= 0.5 - Math.abs(x - 0.5)], + [0x1fb6e, (x, y) => x >= 0.5 + Math.abs(y - 0.5)], + [0x1fb6f, (x, y) => y >= 0.5 + Math.abs(x - 0.5)], + // โ– โ–ƒ โ–… โ–‡ and โ– โ–Ž โ– โ–‹ โ–Š โ–‰: eighths that octants can't draw. + ...[1, 3, 5, 7].map((n): [number, Inside] => [0x2580 + n, (_x, y) => y >= 1 - n / 8]), + ...[1, 2, 3, 5, 6, 7].map((n): [number, Inside] => [0x2590 - n, (x) => x < n / 8]), +] + +// The shape of a smooth glyph, for drawing it (dev/cells.ts). +export const SHAPES = new Map(GLYPHS) + +// How much of each sub-pixel a shape covers, on a grid of cols x rows sub-pixels per cell (row-major). +function measure(inside: Inside, cols: number, rows: number) { + const steps = 16 + return Float32Array.from({ length: cols * rows }, (_, s) => { + let n = 0 + for (let a = 0; a < steps; a++) + for (let b = 0; b < steps; b++) if (inside(((s % cols) + (a + 0.5) / steps) / cols, (Math.floor(s / cols) + (b + 0.5) / steps) / rows)) n++ + return n / (steps * steps) + }) +} + +// Each glyph's coverage of the cell's eight 2x4 sub-pixels, for its colors, and of a finer 4x8 grid for choosing it: +// there, minus its mean, and one over its sum of squares. +const N = GLYPHS.length +const COVER = new Float32Array(N * 8) +const FINE = new Float32Array(N * 32) +const CENTERED = new Float32Array(N * 32) +const INVERSE = new Float32Array(N) +GLYPHS.forEach(([, inside], k) => { + COVER.set(measure(inside, 2, 4), k * 8) + const fine = measure(inside, 4, 8) + FINE.set(fine, k * 32) + const mean = fine.reduce((a, b) => a + b) / 32 + fine.forEach((v, s) => (CENTERED[k * 32 + s] = v - mean)) + INVERSE[k] = 1 / fine.reduce((a, v) => a + (v - mean) ** 2, 0) +}) + +// Glyph ids at or above this are smooth glyphs (FIRST + index); below it they are octant masks. +export const FIRST = 256 +// Cells whose sub-pixels span less than this in every channel keep their octant: only edges gain from smooth glyphs. +const EDGE = 24 +// A smooth glyph must leave less than this share of the cell's variance unexplained, so that cells holding more than +// a two-color edge (a thin line, a corner, texture) keep their octant. +const CLEAN = 0.2 +// Like writeOctant's hysteresis, on the finer grid: a cell keeps last frame's glyph while its error is within this of +// the best. +const RATIO = 1.35 +const HYSTERESIS = 600 + +const bits = new Float32Array(32) +const near = new Float32Array(24 * 3) +const fine = new Float32Array(32 * 3) +const along = new Float32Array(32) +const low = new Float32Array(3) +const high = new Float32Array(3) +const fit = { fr: 0, fg: 0, fb: 0, br: 0, bg: 0, bb: 0, err: 0 } +let grid: Float32Array = new Float32Array(0) +let GW = 0 +let GH = 0 +let curve: Uint8Array = new Uint8Array(256) + +// The frame's sub-pixel grid (2x4 per cell, RGB) and tone curve, which refineEdge reads around each cell. +export function prepareEdges(frame: Float32Array, width: number, height: number, tone: Uint8Array) { + grid = frame + GW = width + GH = height + curve = tone +} + +// Given the octant writeOctant chose for cell (x, y) from its sub-pixels q, looks for a smooth glyph that fits the +// cell better and writes it instead. Glyphs are compared on a 4x8 grid interpolated from the sub-pixels and their +// neighbors (toned and scrimmed like q, with the cell's scrim), where a slanted edge is a slanted line rather than +// octants' staircase. Keeps last frame's glyph unless the new one is clearly better. Returns the glyph id used; +// writeOctant must have been given previous only if it was an octant. +export function refineEdge(q: Float32Array, previous: number, octant: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number, x: number, y: number, deepR: number, deepG: number, deepB: number, st: number, sb: number) { + let range = 0 + for (let c = 0; c < 3; c++) { + low[c] = 255 + high[c] = 0 + for (let s = 0; s < 8; s++) { + const v = q[s * 3 + c] + if (v < low[c]) low[c] = v + if (v > high[c]) high[c] = v + } + range = Math.max(range, high[c] - low[c]) + } + if (range < EDGE || octant === 0 || octant === 255) return octant + for (let ny = 0; ny < 6; ny++) { + const row = Math.min(GH - 1, Math.max(0, y * 4 - 1 + ny)) * GW + const k = ny < 3 ? st : sb + for (let nx = 0; nx < 4; nx++) { + const a = (row + Math.min(GW - 1, Math.max(0, x * 2 - 1 + nx))) * 3 + const n = (ny * 4 + nx) * 3 + near[n] = curve[grid[a] | 0] * (1 - k) + deepR * k + near[n + 1] = curve[grid[a + 1] | 0] * (1 - k) + deepG * k + near[n + 2] = curve[grid[a + 2] | 0] * (1 - k) + deepB * k + } + } + // Bilinear: each fine sample mixes its sub-pixel with the nearer neighbors across, down and diagonally. + for (let f = 0; f < 32; f++) { + const sx = ((f & 3) >> 1) + 1 + const sy = (f >> 3) + 1 + const hx = sx + (f & 1 ? 1 : -1) + const vy = sy + ((f >> 2) & 1 ? 1 : -1) + for (let c = 0; c < 3; c++) + fine[f * 3 + c] = near[(sy * 4 + sx) * 3 + c] * 0.5625 + (near[(sy * 4 + hx) * 3 + c] + near[(vy * 4 + sx) * 3 + c]) * 0.1875 + near[(vy * 4 + hx) * 3 + c] * 0.0625 + bits[f] = (octant >> ((f >> 3) * 2 + ((f & 3) >> 1))) & 1 + } + fitCover(fine, 32, bits, 0) + const octantErr = fit.err + // Rank glyphs along the axis between the octant's two colors; for colors on that axis this is the least squares fit. + for (let f = 0; f < 32; f++) along[f] = fine[f * 3] * (fit.fr - fit.br) + fine[f * 3 + 1] * (fit.fg - fit.bg) + fine[f * 3 + 2] * (fit.fb - fit.bb) + let best = 0 + let score = -1 + for (let k = 0; k < N; k++) { + let dot = 0 + for (let f = 0; f < 32; f++) dot += CENTERED[k * 32 + f] * along[f] + const v = dot * dot * INVERSE[k] + if (v > score) ((score = v), (best = k)) + } + fitCover(fine, 32, FINE, best * 32) + const smoothErr = fit.err + if (previous >= FIRST) { + fitCover(fine, 32, FINE, (previous - FIRST) * 32) + if (fit.err <= Math.min(octantErr, smoothErr) * RATIO + HYSTERESIS) return write(q, previous, char, fg, bg, i, o) + } else if (previous === octant && octantErr <= smoothErr * RATIO + HYSTERESIS) return octant + if (smoothErr >= octantErr || smoothErr > total(fine) * CLEAN) return octant + return write(q, FIRST + best, char, fg, bg, i, o) +} + +// The squared error of drawing the 32 fine samples as their mean color. +function total(samples: Float32Array) { + let err = 0 + for (let c = 0; c < 3; c++) { + let avg = 0 + for (let s = 0; s < 32; s++) avg += samples[s * 3 + c] / 32 + for (let s = 0; s < 32; s++) err += (samples[s * 3 + c] - avg) ** 2 + } + return err +} + +// Writes a smooth glyph with the colors that best fit the cell's own sub-pixels. +function write(q: Float32Array, id: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number) { + fitCover(q, 8, COVER, (id - FIRST) * 8) + char[i] = GLYPHS[id - FIRST][0] + fg[o] = fit.fr + fg[o + 1] = fit.fg + fg[o + 2] = fit.fb + fg[o + 3] = 255 + bg[o] = fit.br + bg[o + 1] = fit.bg + bg[o + 2] = fit.bb + bg[o + 3] = 255 + return id +} + +// Least squares foreground and background for n samples covered cover[at..at+n] by the glyph, kept within the cell's +// own color range, and the squared error left. +function fitCover(samples: Float32Array, n: number, cover: Float32Array, at: number) { + let mean = 0 + for (let s = 0; s < n; s++) mean += cover[at + s] / n + let spread = 0 + for (let s = 0; s < n; s++) spread += (cover[at + s] - mean) ** 2 + let err = 0 + for (let c = 0; c < 3; c++) { + let avg = 0 + let dot = 0 + for (let s = 0; s < n; s++) { + avg += samples[s * 3 + c] / n + dot += (cover[at + s] - mean) * samples[s * 3 + c] + } + const slope = spread > 0 ? dot / spread : 0 + const b = Math.min(high[c], Math.max(low[c], avg - slope * mean)) + const f = Math.min(high[c], Math.max(low[c], avg - slope * mean + slope)) + for (let s = 0; s < n; s++) err += (samples[s * 3 + c] - b - cover[at + s] * (f - b)) ** 2 + if (c === 0) ((fit.fr = f), (fit.br = b)) + if (c === 1) ((fit.fg = f), (fit.bg = b)) + if (c === 2) ((fit.fb = f), (fit.bb = b)) + } + fit.err = err +} From ebcd1cb00f644b429b992bb2829e284d350910b9 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:05:21 -0700 Subject: [PATCH 2/5] Keep a still edge cell's glyph without refitting it --- src/smooth.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/smooth.ts b/src/smooth.ts index fc9b235..9975261 100644 --- a/src/smooth.ts +++ b/src/smooth.ts @@ -105,6 +105,8 @@ let grid: Float32Array = new Float32Array(0) let GW = 0 let GH = 0 let curve: Uint8Array = new Uint8Array(256) +// Each cell's sub-pixels when its glyph was last chosen here: most edges hold still, and keep their glyph cheaply. +let seen = new Float32Array(0) // The frame's sub-pixel grid (2x4 per cell, RGB) and tone curve, which refineEdge reads around each cell. export function prepareEdges(frame: Float32Array, width: number, height: number, tone: Uint8Array) { @@ -112,6 +114,7 @@ export function prepareEdges(frame: Float32Array, width: number, height: number, GW = width GH = height curve = tone + if (seen.length !== (width / 2) * (height / 4) * 24) seen = new Float32Array((width / 2) * (height / 4) * 24).fill(-1) } // Given the octant writeOctant chose for cell (x, y) from its sub-pixels q, looks for a smooth glyph that fits the @@ -132,6 +135,10 @@ export function refineEdge(q: Float32Array, previous: number, octant: number, ch range = Math.max(range, high[c] - low[c]) } if (range < EDGE || octant === 0 || octant === 255) return octant + let same = previous >= 0 + for (let s = 0; s < 24 && same; s++) same = Math.abs(q[s] - seen[i * 24 + s]) < 0.5 + if (same) return previous >= FIRST ? write(q, previous, char, fg, bg, i, o) : octant + seen.set(q, i * 24) for (let ny = 0; ny < 6; ny++) { const row = Math.min(GH - 1, Math.max(0, y * 4 - 1 + ny)) * GW const k = ny < 3 ? st : sb From f692fe109624ba3794ec4ba7b63db2ed6c52d3b2 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:11:28 -0700 Subject: [PATCH 3/5] Skip the edge search for flat cells and on small changes --- src/smooth.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/smooth.ts b/src/smooth.ts index 9975261..6240c5a 100644 --- a/src/smooth.ts +++ b/src/smooth.ts @@ -123,6 +123,7 @@ export function prepareEdges(frame: Float32Array, width: number, height: number, // octants' staircase. Keeps last frame's glyph unless the new one is clearly better. Returns the glyph id used; // writeOctant must have been given previous only if it was an octant. export function refineEdge(q: Float32Array, previous: number, octant: number, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, i: number, o: number, x: number, y: number, deepR: number, deepG: number, deepB: number, st: number, sb: number) { + if (octant === 0 || octant === 255) return octant let range = 0 for (let c = 0; c < 3; c++) { low[c] = 255 @@ -134,9 +135,9 @@ export function refineEdge(q: Float32Array, previous: number, octant: number, ch } range = Math.max(range, high[c] - low[c]) } - if (range < EDGE || octant === 0 || octant === 255) return octant + if (range < EDGE) return octant let same = previous >= 0 - for (let s = 0; s < 24 && same; s++) same = Math.abs(q[s] - seen[i * 24 + s]) < 0.5 + for (let s = 0; s < 24 && same; s++) same = Math.abs(q[s] - seen[i * 24 + s]) < 3 if (same) return previous >= FIRST ? write(q, previous, char, fg, bg, i, o) : octant seen.set(q, i * 24) for (let ny = 0; ny < 6; ny++) { From b3690330fcf42b5419086efc8791f96671811284 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 10:29:50 -0700 Subject: [PATCH 4/5] Document smooth edge glyphs and WALLPAPER_SMOOTH --- CONTRIBUTING.md | 4 +++- README.md | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37be422..652cbc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,9 @@ In character mode each cell shows only two colors, so anything thinner than abou 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. +out. Judge it with `bun dev/preview.ts`, and with `WALLPAPER_OCTANTS=0` for quadrant terminals. On octant terminals +`src/smooth.ts` also draws high-contrast edge cells as diagonal wedges or eighth blocks when they fit better, so clean +anti-aliased edges look smoother than thin lines and texture; `WALLPAPER_SMOOTH=0` turns that off for comparison. ### step and render diff --git a/README.md b/README.md index 8d3c228..edeb45e 100644 --- a/README.md +++ b/README.md @@ -165,14 +165,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 From 440997b713c7f44113ddbe18e176a18e69c43018 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 15:14:07 -0700 Subject: [PATCH 5/5] Keep smooth wedges off the sun and moon, thin spiky edges and moving cells --- src/canvas.ts | 5 +++++ src/engine.tsx | 31 +++++++++++++++++++++++++------ src/orbs.ts | 11 +++++++++++ src/sky.ts | 2 ++ src/smooth.ts | 24 +++++++++++++++++++----- src/wallpaper.ts | 2 ++ wallpapers/desert.ts | 2 ++ 7 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/orbs.ts diff --git a/src/canvas.ts b/src/canvas.ts index c0c9778..fa70e8e 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -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" @@ -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 @@ -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) diff --git a/src/engine.tsx b/src/engine.tsx index 52ce078..dbbe9b1 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -380,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, @@ -737,8 +751,10 @@ export function createEngine( // 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. + // 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) @@ -995,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) @@ -1021,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 @@ -1052,10 +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 @@ -1222,8 +1240,9 @@ export function createEngine( const was = previousMask[i] 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; edges may take a smooth glyph instead of the octant. - previousMask[i] = smooth && !softFit ? refineEdge(q, was, octant, char, fg, bg, i, o, x, y, deepR, deepG, deepB, st, sb) : octant + // 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) diff --git a/src/orbs.ts b/src/orbs.ts new file mode 100644 index 0000000..9cbed04 --- /dev/null +++ b/src/orbs.ts @@ -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() + +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) +} diff --git a/src/sky.ts b/src/sky.ts index a8759a9..8fbb53b 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 { 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. @@ -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) diff --git a/src/smooth.ts b/src/smooth.ts index eff7972..7eba721 100644 --- a/src/smooth.ts +++ b/src/smooth.ts @@ -88,7 +88,9 @@ export const FIRST = 256 const EDGE = 24 // A smooth glyph must leave less than this share of the cell's variance unexplained, so that cells holding more than // a two-color edge (a thin line, a corner, texture) keep their octant. -const CLEAN = 0.2 +const CLEAN = 0.15 +// How much of the octant's error a smooth glyph may leave. +const GAIN = 0.85 // Like writeOctant's hysteresis, on the finer grid: a cell keeps last frame's glyph while its error is within this of // the best. const RATIO = 1.35 @@ -100,7 +102,8 @@ const fine = new Float32Array(32 * 3) const along = new Float32Array(32) const low = new Float32Array(3) const high = new Float32Array(3) -const fit = { fr: 0, fg: 0, fb: 0, br: 0, bg: 0, bb: 0, err: 0 } +const fit = { fr: 0, fg: 0, fb: 0, br: 0, bg: 0, bb: 0, err: 0, worst: 0 } +const sampleErr = new Float32Array(32) let grid: Float32Array = new Float32Array(0) let GW = 0 let GH = 0 @@ -164,6 +167,7 @@ export function refineEdge(q: Float32Array, previous: number, octant: number, ch } fitCover(fine, 32, bits, 0) const octantErr = fit.err + const octantWorst = fit.worst // Rank glyphs along the axis between the octant's two colors; for colors on that axis this is the least squares fit. for (let f = 0; f < 32; f++) along[f] = fine[f * 3] * (fit.fr - fit.br) + fine[f * 3 + 1] * (fit.fg - fit.bg) + fine[f * 3 + 2] * (fit.fb - fit.bb) let best = 0 @@ -176,11 +180,14 @@ export function refineEdge(q: Float32Array, previous: number, octant: number, ch } fitCover(fine, 32, FINE, best * 32) const smoothErr = fit.err + const smoothWorst = fit.worst if (previous >= FIRST) { fitCover(fine, 32, FINE, (previous - FIRST) * 32) - if (fit.err <= Math.min(octantErr, smoothErr) * RATIO + HYSTERESIS) return write(q, previous, char, fg, bg, i, o) + if (fit.err <= Math.min(octantErr, smoothErr) * RATIO + HYSTERESIS && fit.worst <= octantWorst * RATIO + HYSTERESIS) return write(q, previous, char, fg, bg, i, o) } else if (previous === octant && octantErr <= smoothErr * RATIO + HYSTERESIS) return octant - if (smoothErr >= octantErr || smoothErr > total(fine) * CLEAN) return octant + // A wedge must fit the edge clearly better overall and nowhere worse than the octant: on thin or spiky shapes it + // would cut a notch through a sub-pixel the octant gets right. + if (smoothErr >= octantErr * GAIN || smoothErr > total(fine) * CLEAN || smoothWorst > octantWorst) return octant return write(q, FIRST + best, char, fg, bg, i, o) } @@ -218,6 +225,7 @@ function fitCover(samples: Float32Array, n: number, cover: Float32Array, at: num let spread = 0 for (let s = 0; s < n; s++) spread += (cover[at + s] - mean) ** 2 let err = 0 + sampleErr.fill(0, 0, n) for (let c = 0; c < 3; c++) { let avg = 0 let dot = 0 @@ -228,10 +236,16 @@ function fitCover(samples: Float32Array, n: number, cover: Float32Array, at: num const slope = spread > 0 ? dot / spread : 0 const b = Math.min(high[c], Math.max(low[c], avg - slope * mean)) const f = Math.min(high[c], Math.max(low[c], avg - slope * mean + slope)) - for (let s = 0; s < n; s++) err += (samples[s * 3 + c] - b - cover[at + s] * (f - b)) ** 2 + for (let s = 0; s < n; s++) sampleErr[s] += (samples[s * 3 + c] - b - cover[at + s] * (f - b)) ** 2 if (c === 0) ((fit.fr = f), (fit.br = b)) if (c === 1) ((fit.fg = f), (fit.bg = b)) if (c === 2) ((fit.fb = f), (fit.bb = b)) } + let worst = 0 + for (let s = 0; s < n; s++) { + err += sampleErr[s] + if (sampleErr[s] > worst) worst = sampleErr[s] + } fit.err = err + fit.worst = worst } diff --git a/src/wallpaper.ts b/src/wallpaper.ts index e83384c..ad7faa2 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -14,6 +14,8 @@ export interface Scene { // Optional: boxes like changed's around what this frame drew over the still background, so the engine can let cells // where something moves follow it more closely (Canvas provides it). readonly touched?: readonly number[] + // Optional: sun and moon discs [cx, cy, r, ...] in pixels, which the engine keeps smooth wedge glyphs off. + readonly orbs?: readonly number[] resize(W: number, H: number, cells?: boolean): void step(dt: number): void render(): void diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 021b0a4..e1b5f9f 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -6,6 +6,7 @@ import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, fbm2, hash, lerp, rand, smoothstep, type RGB } from "../src/math" import { groundShadow, sunShade, type Shade } from "../src/shadow" import { campfire, lightPool } from "../src/light" +import { addOrb } from "../src/orbs" import { driftClouds, makeClouds, makeStorm, paintClouds, paintStorm, type Cloud } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -465,6 +466,7 @@ class Desert extends Canvas { const { W, H, hdr, look } = this const { orb } = look const R = orb.r * H + addOrb(hdr, this.orbX, this.orbY, R) for (let y = 0; y < H; y++) { const v = y / H const i = look.sky.findIndex(([at]) => at >= v)