diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 463eb5f..956a9ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 04f04d6..a45d4e3 100644 --- a/README.md +++ b/README.md @@ -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 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/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 b9163a3..dbbe9b1 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 TextMap, type Wallpaper } from "./wallpaper" export { @@ -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) @@ -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 @@ -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, @@ -724,6 +742,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) @@ -731,6 +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, 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) @@ -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) @@ -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 @@ -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 @@ -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++) { @@ -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 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 new file mode 100644 index 0000000..7eba721 --- /dev/null +++ b/src/smooth.ts @@ -0,0 +1,251 @@ +// 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.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 +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, worst: 0 } +const sampleErr = new Float32Array(32) +let grid: Float32Array = new Float32Array(0) +let GW = 0 +let GH = 0 +let curve: Uint8Array = new Uint8Array(256) +let seen: Float32Array = new Float32Array(0) + +// The frame's sub-pixel grid (2x4 per cell, RGB) and tone curve, which refineEdge reads around each cell, and the +// engine's record of each cell's sub-pixels when its glyph was last chosen here (24 values per cell, -1 to start): +// most edges hold still, and keep their glyph cheaply. +export function prepareEdges(frame: Float32Array, width: number, height: number, tone: Uint8Array, record: Float32Array) { + grid = frame + GW = width + GH = height + curve = tone + seen = record +} + +// 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) { + if (octant === 0 || octant === 255) return octant + 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) return octant + let same = previous >= 0 + 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++) { + 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 + 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 + 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 + const smoothWorst = fit.worst + if (previous >= FIRST) { + fitCover(fine, 32, FINE, (previous - FIRST) * 32) + 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 + // 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) +} + +// 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 + sampleErr.fill(0, 0, n) + 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++) 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)