From d91016d34a82eb736db366deca22bc6e167752e7 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 09:37:40 -0700 Subject: [PATCH 1/5] Hug the text scrim to the glyphs and frost OpenCode's panels --- src/engine.tsx | 155 ++++++++++++++++++++++++++++--------------------- 1 file changed, 90 insertions(+), 65 deletions(-) diff --git a/src/engine.tsx b/src/engine.tsx index 1e50f98..27f5929 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -42,17 +42,18 @@ const LOWER_HALF = 0x2584 const BLOCKS_START = 0x2580 const BLOCKS_END = 0x259f const SPACE = 32 -// Scrim reach in cells horizontally and half-cells vertically. -const SCRIM_X = 4 -const SCRIM_Y = 4 // Share of the distance to white that light-theme scrims keep, so dark text sits on a pale tint of the scene. const LIGHT_SCRIM = 0.15 -// A click pokes the scene only where it shows through clearly: a blank surface cell with little scrim (mask value). -const OPEN = 0.15 +// A click pokes the scene only where it shows through clearly: a blank surface cell with little scrim (strength). +const OPEN = 0.45 +// Share of the full scrim that panels OpenCode draws with their own surface (dialogs, the sidebar, the prompt) keep +// everywhere, so they read as panels rather than loose text over the scene. +const PANEL = 0.5 // Each brightness compresses the scene into a range that keeps text readable on top of it, after scaling it by gain, -// and sets how far scrims cover the scene (scrim) and how fast sparse text saturates them (reach): brighter scenes -// need firmer scrims. In a light theme the scene is instead lifted toward white, with black at wash. +// and sets how far scrims cover the scene under text (scrim) and how far their halo reaches past it (reach, in +// sub-pixels, which are square: half a cell wide, a quarter cell tall): brighter scenes need firmer, wider scrims. In +// a light theme the scene is instead lifted toward white, with black at wash. function level(gain: number, wash: number, scrim: number, reach: number) { const dark = new Uint8Array(256) const light = new Uint8Array(256) @@ -65,8 +66,8 @@ function level(gain: number, wash: number, scrim: number, reach: number) { return { dark, light, scrim, reach } } const LEVELS: Record> = { - subtle: level(0.6, 0.65, 0.9, 3), - normal: level(1, 0.5, 0.93, 3.5), + subtle: level(0.6, 0.65, 0.9, 3.25), + normal: level(1, 0.5, 0.93, 3.75), vivid: level(1.6, 0.35, 0.96, 4.5), } // Night scenes keep their color: it is what separates them from light text. @@ -79,29 +80,56 @@ function mix(a: Uint8Array, b: Uint8Array, out: Uint8Array, t: number) { for (let p = 0; p < out.length; p++) out[p] = a[p] + (b[p] - a[p]) * t } -function blurRows(src: Float32Array, dst: Float32Array, w: number, h: number, r: number) { - const inv = 1 / (r * 2 + 1) - for (let y = 0; y < h; y++) { - const row = y * w - let sum = 0 - for (let x = -r; x <= r; x++) sum += src[row + Math.min(w - 1, Math.max(0, x))] - for (let x = 0; x < w; x++) { - dst[row + x] = sum * inv - sum += src[row + Math.min(w - 1, x + r + 1)] - src[row + Math.max(0, x - r)] +// Writes the scrim strength for each octant sub-pixel from the cells' shapes (1 text, 2 panel): full cover under text, +// falling off smoothly to nothing `reach` sub-pixels away, and at least PANEL of it across panels. +function buildScrim(shapes: Uint8Array, out: Float32Array, W: number, H: number, cover: number, reach: number) { + const GW = W * 2 + const GH = H * 4 + for (let gy = 0; gy < GH; gy++) { + const row = (gy >> 2) * W + for (let gx = 0; gx < GW; gx++) out[gy * GW + gx] = shapes[row + (gx >> 1)] === 1 ? 0 : 1e9 + } + distanceField(out, GW, GH) + const span = reach - 0.5 + for (let gy = 0; gy < GH; gy++) { + const row = (gy >> 2) * W + for (let gx = 0; gx < GW; gx++) { + const g = gy * GW + gx + const t = Math.min(1, Math.max(0, (out[g] - 0.5) / span)) + const halo = 1 - t * t * (3 - 2 * t) + out[g] = cover * (shapes[row + (gx >> 1)] === 2 && halo < PANEL ? PANEL : halo) } } } -function blurColumns(src: Float32Array, dst: Float32Array, w: number, h: number, r: number) { - const inv = 1 / (r * 2 + 1) - for (let x = 0; x < w; x++) { - let sum = 0 - for (let y = -r; y <= r; y++) sum += src[Math.min(h - 1, Math.max(0, y)) * w + x] - for (let y = 0; y < h; y++) { - dst[y * w + x] = sum * inv - sum += src[Math.min(h - 1, y + r + 1) * w + x] - src[Math.max(0, y - r) * w + x] +// Turns a field of 0 (text) and large values into each sample's approximate distance to the nearest 0, in two +// chamfer passes (steps of 1 straight and √2 diagonally). +function distanceField(d: Float32Array, w: number, h: number) { + const D = Math.SQRT2 + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const i = y * w + x + let v = d[i] + if (x > 0 && d[i - 1] + 1 < v) v = d[i - 1] + 1 + if (y > 0) { + if (d[i - w] + 1 < v) v = d[i - w] + 1 + if (x > 0 && d[i - w - 1] + D < v) v = d[i - w - 1] + D + if (x < w - 1 && d[i - w + 1] + D < v) v = d[i - w + 1] + D + } + d[i] = v + } + for (let y = h - 1; y >= 0; y--) + for (let x = w - 1; x >= 0; x--) { + const i = y * w + x + let v = d[i] + if (x < w - 1 && d[i + 1] + 1 < v) v = d[i + 1] + 1 + if (y < h - 1) { + if (d[i + w] + 1 < v) v = d[i + w] + 1 + if (x < w - 1 && d[i + w + 1] + D < v) v = d[i + w + 1] + D + if (x > 0 && d[i + w - 1] + D < v) v = d[i + w - 1] + D + } + d[i] = v } - } } // Terminals that draw octant glyphs themselves; elsewhere fall back to quadrants, which every font has. @@ -460,7 +488,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 ?? ""}`)) - let scratch = new Float32Array(0) + let glyphs = new Uint8Array(0) + let scrimLook: (typeof LEVELS)[Brightness] | undefined let surface = new Uint8Array(0) let cellTint = new Int32Array(0) let previousMask = new Int16Array(0) @@ -571,30 +600,31 @@ export function createEngine( } // Builds the scene image with the text scrim and tints baked in, for the cells marked in imageCell. - const publishImage = (px: Uint8Array, PW: number, PH: number, W: number, H: number, MH: number, scrim: Scrim, look: (typeof LEVELS)[Brightness], curve: Uint8Array) => { + const publishImage = (px: Uint8Array, PW: number, PH: number, W: number, H: number, scrim: Scrim, curve: Uint8Array) => { if (imagePixels.length !== PW * PH * 4) imagePixels = new Uint8Array(PW * PH * 4) const img = imagePixels + const GW = W * 2 + const GH = H * 4 const cw = PW / W const chh = PH / H for (let iy = 0; iy < PH; iy++) { const row = Math.min(H - 1, Math.floor(iy / chh)) const depth = row / Math.max(1, H - 1) const [deepR, deepG, deepB] = scrimAt(scrim, depth, light) - const my = clamp01((iy + 0.5) / (chh / 2) - 0.5, MH - 1) + const my = clamp01((iy + 0.5) / (chh / 4) - 0.5, GH - 1) const my0 = Math.floor(my) - const my1 = Math.min(MH - 1, my0 + 1) + const my1 = Math.min(GH - 1, my0 + 1) const fy = my - my0 for (let ix = 0; ix < PW; ix++) { const col = Math.min(W - 1, Math.floor(ix / cw)) const ci = row * W + col - const mx = clamp01((ix + 0.5) / cw - 0.5, W - 1) + const mx = clamp01((ix + 0.5) / (cw / 2) - 0.5, GW - 1) const mx0 = Math.floor(mx) - const mx1 = Math.min(W - 1, mx0 + 1) + const mx1 = Math.min(GW - 1, mx0 + 1) const fx = mx - mx0 - const m = - (mask[my0 * W + mx0] * (1 - fx) + mask[my0 * W + mx1] * fx) * (1 - fy) + - (mask[my1 * W + mx0] * (1 - fx) + mask[my1 * W + mx1] * fx) * fy - const k = look.scrim * Math.min(1, m * look.reach) + const k = + (mask[my0 * GW + mx0] * (1 - fx) + mask[my0 * GW + mx1] * fx) * (1 - fy) + + (mask[my1 * GW + mx0] * (1 - fx) + mask[my1 * GW + mx1] * fx) * fy const p = (iy * PW + ix) * 4 img[p] = curve[px[p]] * (1 - k) + deepR * k img[p + 1] = curve[px[p + 1]] * (1 - k) + deepG * k @@ -795,35 +825,35 @@ export function createEngine( const look = LEVELS[settings!.brightness ?? "normal"] const curve = light ? look.light : look.dark - // Soft scrim: a blurred mask of where text is, at half-cell vertical resolution. The scene fades to - // near-black under and around text with no hard edge, so letters and the spaces between them match. - const MH = H * 2 - if (mask.length < W * MH) { - mask = new Float32Array(W * MH) - scratch = new Float32Array(W * MH) - surface = new Uint8Array(W * H) - cellTint = new Int32Array(W * H) + // Scrim that hugs the text: full strength under each text cell and a halo that falls off within a cell or two of + // it, per octant sub-pixel, so the scene still shows between lines, after short lines and around words. It is + // only rebuilt when the set of text cells changes. + if (surface.length !== cells) { + surface = new Uint8Array(cells) + cellTint = new Int32Array(cells) + glyphs = new Uint8Array(cells) + mask = new Float32Array(GW * GH) + scrimLook = undefined } if (previousMask.length !== cells) previousMask = new Int16Array(cells).fill(-1) - mask.fill(0, 0, W * MH) + let reshaped = scrimLook !== look for (let i = 0; i < cells; i++) { - const tint = tintOf(key(bg, i * 4)) + const k = key(bg, i * 4) + const tint = tintOf(k) const on = tint !== NO_TINT surface[i] = on ? 1 : 0 cellTint[i] = tint - if (!on || char[i] === SPACE || (isBlock(char[i]) && tintOf(fgKey(fg, i * 4)) !== NO_TINT)) continue - const x = i % W - const y = (i / W) | 0 - mask[y * 2 * W + x] = 1 - mask[(y * 2 + 1) * W + x] = 1 + // 1 for text, 2 for a blank cell of a panel (a surface other than the base, such as a dialog), else 0. + const shape = !on ? 0 : char[i] !== SPACE && !(isBlock(char[i]) && tintOf(fgKey(fg, i * 4)) !== NO_TINT) ? 1 : k !== base && tint === TINT_ZERO ? 2 : 0 + if (glyphs[i] !== shape) ((glyphs[i] = shape), (reshaped = true)) } - for (let pass = 0; pass < 2; pass++) { - blurRows(mask, scratch, W, MH, SCRIM_X) - blurColumns(scratch, mask, W, MH, SCRIM_Y) + if (reshaped) { + scrimLook = look + buildScrim(glyphs, mask, W, H, look.scrim, look.reach) } if (open.length !== cells) open = new Uint8Array(cells) openW = W - for (let i = 0; i < cells; i++) open[i] = surface[i] && char[i] === SPACE && mask[((i / W) | 0) * 2 * W + (i % W)] < OPEN ? 1 : 0 + for (let i = 0; i < cells; i++) open[i] = surface[i] && char[i] === SPACE && mask[((i / W) | 0) * 4 * GW + (i % W) * 2] < OPEN ? 1 : 0 const cursor = renderer.getCursorState() const cursorX = cursor.visible ? cursor.x - 1 : -1 @@ -844,11 +874,7 @@ export function createEngine( layerPlaced = false const q = quad - const cover = look.scrim - const reach = look.reach for (let y = 0; y < H; y++) { - const mTop = y * 2 * W - const mBottom = (y * 2 + 1) * W const [deepR, deepG, deepB] = scrimAt(scrim, y / Math.max(1, H - 1), light) for (let x = 0; x < W; x++) { const i = y * W + x @@ -858,12 +884,11 @@ export function createEngine( const edgeTint = isBlock(ch) ? tintOf(fgKey(fg, o)) : NO_TINT const edge = edgeTint !== NO_TINT if (!surface[i] && !edge) continue - const st = cover * Math.min(1, mask[mTop + x] * reach) - const sb = cover * Math.min(1, mask[mBottom + x] * reach) // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. for (let s = 0; s < 8; s++) { - const a = ((y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)) * 3 - const k = s < 4 ? st : sb + const g = (y * 4 + (s >> 1)) * GW + x * 2 + (s & 1) + const a = g * 3 + const k = mask[g] q[s * 3] = curve[grid[a] | 0] * (1 - k) + deepR * k q[s * 3 + 1] = curve[grid[a + 1] | 0] * (1 - k) + deepG * k q[s * 3 + 2] = curve[grid[a + 2] | 0] * (1 - k) + deepB * k @@ -902,7 +927,7 @@ export function createEngine( // Build next frame's image now; the back layer places it before OpenCode draws, so text lands on top. if (pixels && (advanced || !nextImage)) { - const image = publishImage(px, PW, PH, W, H, MH, scrim, look, curve) + const image = publishImage(px, PW, PH, W, H, scrim, curve) if (image) { nextImage?.dispose() nextImage = image From 7f3fc322b062bc467fa5bf07fa7c58c267164520 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 09:49:15 -0700 Subject: [PATCH 2/5] Document the text-hugging scrim --- CONTRIBUTING.md | 2 +- README.md | 5 +++-- src/engine.tsx | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37be422..a0311f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -176,7 +176,7 @@ seasonal shower or flurry without the cloud deck, as the farm does in winter. `w ## Scrim -Text gets a soft scrim that fades the scene toward a dark color around it. `scrim` gives that color (0 to 255) for +Text gets a scrim that fades the scene toward a dark color under and just around each character. `scrim` gives that color (0 to 255) for each time of day at the top, middle and bottom of the screen. Darker, more saturated versions of the scene's own colors at those depths read better than black: dark sky blue at the top and dark green over grass by day, deep navy at night. Check that text stays readable with `bun dev/preview.ts` at every time of day. diff --git a/README.md b/README.md index 8d3c228..bc6cacf 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,9 @@ quadrant blocks (2x2). These environment variables tune rendering: ## How it works The plugin post-processes each frame OpenCode draws. Cells painted with OpenCode's neutral surface colors are replaced -by the scene, while colored backgrounds such as diffs and selections are left alone. Text keeps a soft scrim that fades -the scene toward a dark tint around it. Scenes render in HDR with bloom and tone mapping, then are fitted to two colors +by the scene, while colored backgrounds such as diffs and selections are left alone. Text keeps a scrim that hugs it: +the scene fades toward a dark tint under each character and within a cell or two around it, so it still shows between +lines and after short ones, and OpenCode's panels and dialogs keep a lighter frost. Scenes render in HDR with bloom and tone mapping, then are fitted to two colors per character cell. While one look fades into another, both scenes render and blend, scrim included, so only transitions cost extra. diff --git a/src/engine.tsx b/src/engine.tsx index 27f5929..6f5331a 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -488,7 +488,7 @@ 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 ?? ""}`)) - let glyphs = new Uint8Array(0) + let shapes = new Uint8Array(0) let scrimLook: (typeof LEVELS)[Brightness] | undefined let surface = new Uint8Array(0) let cellTint = new Int32Array(0) @@ -831,7 +831,7 @@ export function createEngine( if (surface.length !== cells) { surface = new Uint8Array(cells) cellTint = new Int32Array(cells) - glyphs = new Uint8Array(cells) + shapes = new Uint8Array(cells) mask = new Float32Array(GW * GH) scrimLook = undefined } @@ -845,11 +845,11 @@ export function createEngine( cellTint[i] = tint // 1 for text, 2 for a blank cell of a panel (a surface other than the base, such as a dialog), else 0. const shape = !on ? 0 : char[i] !== SPACE && !(isBlock(char[i]) && tintOf(fgKey(fg, i * 4)) !== NO_TINT) ? 1 : k !== base && tint === TINT_ZERO ? 2 : 0 - if (glyphs[i] !== shape) ((glyphs[i] = shape), (reshaped = true)) + if (shapes[i] !== shape) ((shapes[i] = shape), (reshaped = true)) } if (reshaped) { scrimLook = look - buildScrim(glyphs, mask, W, H, look.scrim, look.reach) + buildScrim(shapes, mask, W, H, look.scrim, look.reach) } if (open.length !== cells) open = new Uint8Array(cells) openW = W From 859eaf8c4560e3b219aeccd622472053893ff144 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 13:37:05 -0700 Subject: [PATCH 3/5] Even out the scrim per cell and widen its halo, so it fades smoothly instead of outlining text Tuned with the owner in a live preview. This supersedes the planned halo widening (reach 6.5/7/8): use reach 11/12/14. --- src/engine.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/engine.tsx b/src/engine.tsx index 6f5331a..ddeae77 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -66,9 +66,9 @@ function level(gain: number, wash: number, scrim: number, reach: number) { return { dark, light, scrim, reach } } const LEVELS: Record> = { - subtle: level(0.6, 0.65, 0.9, 3.25), - normal: level(1, 0.5, 0.93, 3.75), - vivid: level(1.6, 0.35, 0.96, 4.5), + subtle: level(0.6, 0.65, 0.9, 11), + normal: level(1, 0.5, 0.93, 12), + vivid: level(1.6, 0.35, 0.96, 14), } // Night scenes keep their color: it is what separates them from light text. const DESATURATE = { day: 0.22, sunset: 0.22, night: 0.04 } @@ -884,11 +884,14 @@ export function createEngine( const edgeTint = isBlock(ch) ? tintOf(fgKey(fg, o)) : NO_TINT const edge = edgeTint !== NO_TINT if (!surface[i] && !edge) continue - // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. + // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. The scrim darkens the whole cell evenly + // (its sub-pixels' mean): varying it within a cell would split the cell into a dark and a light half, turning the + // halo's soft fade into a hard outline around the text. + let k = 0 + for (let s = 0; s < 8; s++) k += mask[(y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)] / 8 for (let s = 0; s < 8; s++) { const g = (y * 4 + (s >> 1)) * GW + x * 2 + (s & 1) const a = g * 3 - const k = mask[g] q[s * 3] = curve[grid[a] | 0] * (1 - k) + deepR * k q[s * 3 + 1] = curve[grid[a + 1] | 0] * (1 - k) + deepG * k q[s * 3 + 2] = curve[grid[a + 2] | 0] * (1 - k) + deepB * k From f8d32871d40b8ca7000a7f309d09a72095fc98c7 Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 11:51:23 -0700 Subject: [PATCH 4/5] Widen the text scrim's halo so a paragraph's lines share one band --- src/engine.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/engine.tsx b/src/engine.tsx index afe2432..86f480b 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -69,9 +69,9 @@ function level(gain: number, wash: number, scrim: number, reach: number) { return { dark, light, scrim, reach } } const LEVELS: Record> = { - subtle: level(0.6, 0.65, 0.9, 3.25), - normal: level(1, 0.5, 0.93, 3.75), - vivid: level(1.6, 0.35, 0.96, 4.5), + subtle: level(0.6, 0.65, 0.9, 6.5), + normal: level(1, 0.5, 0.93, 7), + vivid: level(1.6, 0.35, 0.96, 8), } // Night scenes keep their color: it is what separates them from light text. const DESATURATE = { day: 0.22, sunset: 0.22, night: 0.04 } From 5479895d36b0a144c552c1eab9fb8bf0fb75665b Mon Sep 17 00:00:00 2001 From: vprdev Date: Sat, 10 Oct 2026 19:25:43 -0700 Subject: [PATCH 5/5] Dim the whole scene evenly instead of backing text The owner preferred an even darkening to a backing around text: the scene now fades 60/65/70% of the way to the scrim color at subtle/normal/vivid. The distance field around text only decides where clicks land and the text map scenes get. --- CONTRIBUTING.md | 4 +- README.md | 13 +++--- src/engine.tsx | 112 ++++++++++++++++-------------------------------- 3 files changed, 44 insertions(+), 85 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6c0f5d..ab92d66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -262,8 +262,8 @@ with how far their tips move and a weight from roots to tips, and `draw()` right ## Scrim -Text gets a scrim that fades the scene toward a dark color under and just around each character. `scrim` gives that color (0 to 255) for -each time of day at the top, middle and bottom of the screen. Darker, more saturated versions of the scene's own colors +The whole scene fades part of the way toward a dark color, so text reads on top of it. `scrim` gives that color (0 to +255) for each time of day at the top, middle and bottom of the screen. Darker, more saturated versions of the scene's own colors at those depths read better than black: dark sky blue at the top and dark green over grass by day, deep navy at night. Check that text stays readable with `bun dev/preview.ts` at every time of day. diff --git a/README.md b/README.md index cacc3c7..19022e3 100644 --- a/README.md +++ b/README.md @@ -27,12 +27,12 @@ Type `/wallpaper` to open the wallpaper menu: Use ↑/↓ to pick a setting, and Enter or ←/→ to change it. The wallpaper crossfades to each choice as you go, and Escape closes the menu. Your choices are kept across restarts. -- **Brightness** is subtle, normal or vivid. Brighter scenes get a firmer scrim behind text, so it stays readable. +- **Brightness** is subtle, normal or vivid. Brighter scenes are dimmed more, so text stays readable. - **Shuffle** switches to a different wallpaper every hour, or each time OpenCode starts. - **Per project** remembers a wallpaper for each project directory. A project starts from the global choice until you pick one there. -With a light OpenCode theme the scene is washed out toward white and text gets a pale scrim instead of a dark one. +With a light OpenCode theme the scene is washed out toward white instead of dimmed. ## Wallpapers @@ -182,11 +182,10 @@ variables tune rendering: ## How it works The plugin post-processes each frame OpenCode draws. Cells painted with OpenCode's neutral surface colors are replaced -by the scene, while colored backgrounds such as diffs and selections are left alone. Text keeps a scrim that hugs it: -the scene fades toward a dark tint under each character and within a cell or two around it, so it still shows between -lines and after short ones, and OpenCode's panels and dialogs keep a lighter frost. Scenes render in HDR with bloom and tone mapping, then are fitted to two colors -per character cell. While one look fades into another, both scenes render and blend, scrim included, so only -transitions cost extra. Scenes keep their landmarks toward the edges, and the engine tells them where text sits, so +by the scene, while colored backgrounds such as diffs and selections are left alone. The whole scene is dimmed evenly +toward a dark tint, so text reads anywhere without a backing of its own. Scenes render in HDR with bloom and tone +mapping, then are fitted to two colors per character cell. While one look fades into another, both scenes render and +blend, scrim included, so only transitions cost extra. Scenes keep their landmarks toward the edges, and the engine tells them where text sits, so creatures and visitors stop where they can be seen. Outdoor scenes share one wind: now and then a gust rolls across the screen, bending plants and carrying petals, snow and dust. diff --git a/src/engine.tsx b/src/engine.tsx index 86f480b..a4521ff 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -47,17 +47,17 @@ const BLOCKS_END = 0x259f const SPACE = 32 // Share of the distance to white that light-theme scrims keep, so dark text sits on a pale tint of the scene. const LIGHT_SCRIM = 0.15 -// A click pokes the scene only where it shows through clearly: a blank surface cell with little scrim (strength). -const OPEN = 0.45 -// Share of the full scrim that panels OpenCode draws with their own surface (dialogs, the sidebar, the prompt) keep -// everywhere, so they read as panels rather than loose text over the scene. +// A click pokes the scene only where it shows through clearly: a blank surface cell away from text (see buildNearText). +const OPEN = 0.5 +// How near text the panels OpenCode draws with their own surface (dialogs, the sidebar, the prompt) count, at least. const PANEL = 0.5 +// How far past text, in sub-pixels (half a cell wide, a quarter cell tall), the scene counts as near it. +const REACH = 7 // Each brightness compresses the scene into a range that keeps text readable on top of it, after scaling it by gain, -// and sets how far scrims cover the scene under text (scrim) and how far their halo reaches past it (reach, in -// sub-pixels, which are square: half a cell wide, a quarter cell tall): brighter scenes need firmer, wider scrims. In -// a light theme the scene is instead lifted toward white, with black at wash. -function level(gain: number, wash: number, scrim: number, reach: number) { +// and sets how far the whole scene fades toward the scrim color (scrim): brighter scenes need a firmer scrim. In a +// light theme the scene is instead lifted toward white, with black at wash. +function level(gain: number, wash: number, scrim: number) { const dark = new Uint8Array(256) const light = new Uint8Array(256) for (let v = 0; v < 256; v++) { @@ -66,12 +66,12 @@ function level(gain: number, wash: number, scrim: number, reach: number) { dark[v] = Math.round(soft * 255) light[v] = Math.round((wash + (1 - wash) * Math.min(1, soft * 1.9)) * 255) } - return { dark, light, scrim, reach } + return { dark, light, scrim } } const LEVELS: Record> = { - subtle: level(0.6, 0.65, 0.9, 6.5), - normal: level(1, 0.5, 0.93, 7), - vivid: level(1.6, 0.35, 0.96, 8), + subtle: level(0.6, 0.65, 0.6), + normal: level(1, 0.5, 0.65), + vivid: level(1.6, 0.35, 0.7), } // Night scenes keep their color: it is what separates them from light text. const DESATURATE = { day: 0.22, sunset: 0.22, night: 0.04 } @@ -87,9 +87,9 @@ function mix(a: Uint8Array, b: Uint8Array, out: Uint8Array, t: number) { for (let p = 0; p < out.length; p++) out[p] = a[p] + (b[p] - a[p]) * t } -// Writes the scrim strength for each octant sub-pixel from the cells' shapes (1 text, 2 panel): full cover under text, -// falling off smoothly to nothing `reach` sub-pixels away, and at least PANEL of it across panels. -function buildScrim(shapes: Uint8Array, out: Float32Array, W: number, H: number, cover: number, reach: number) { +// Writes how near text each octant sub-pixel is from the cells' shapes (1 text, 2 panel): 1 under text, falling off +// smoothly to 0 REACH sub-pixels away, and at least PANEL across panels. Clicks and the text map use it. +function buildNearText(shapes: Uint8Array, out: Float32Array, W: number, H: number) { const GW = W * 2 const GH = H * 4 for (let gy = 0; gy < GH; gy++) { @@ -97,25 +97,18 @@ function buildScrim(shapes: Uint8Array, out: Float32Array, W: number, H: number, for (let gx = 0; gx < GW; gx++) out[gy * GW + gx] = shapes[row + (gx >> 1)] === 1 ? 0 : 1e9 } distanceField(out, GW, GH) - const span = reach - 0.5 + const span = REACH - 0.5 for (let gy = 0; gy < GH; gy++) { const row = (gy >> 2) * W for (let gx = 0; gx < GW; gx++) { const g = gy * GW + gx const t = Math.min(1, Math.max(0, (out[g] - 0.5) / span)) - const halo = 1 - t * t * (3 - 2 * t) - out[g] = cover * (shapes[row + (gx >> 1)] === 2 && halo < PANEL ? PANEL : halo) + const near = 1 - t * t * (3 - 2 * t) + out[g] = shapes[row + (gx >> 1)] === 2 && near < PANEL ? PANEL : near } } } -// Marks the cells any of whose eight sub-pixels' scrim differs between mask and last. -function markRescrimmed(changed: Uint8Array, mask: Float32Array, last: Float32Array, W: number, H: number) { - const GW = W * 2 - for (let gy = 0; gy < H * 4; gy++) - for (let gx = 0; gx < GW; gx++) if (mask[gy * GW + gx] !== last[gy * GW + gx]) changed[(gy >> 2) * W + (gx >> 1)] = 1 -} - // Turns a field of 0 (text) and large values into each sample's approximate distance to the nearest 0, in two // chamfer passes (steps of 1 straight and √2 diagonally). function distanceField(d: Float32Array, w: number, h: number) { @@ -546,10 +539,6 @@ function isBlock(char: number) { return char >= BLOCKS_START && char <= BLOCKS_END } -function clamp01(value: number, max: number) { - return value < 0 ? 0 : value > max ? max : value -} - function applyTint(colors: Uint16Array | Uint8Array, o: number, tint: number) { if (tint === NO_TINT || tint === TINT_ZERO) return const dr = ((tint >> 20) & 1023) - 256 @@ -741,7 +730,8 @@ export interface Engine { } // Post-processes OpenCode's final frame: cells painted with neutral OpenCode theme surfaces are replaced by the scene, drawn -// as octant or quadrant blocks (or, opt-in, a real kitty image behind the text), with a soft scrim around text. +// as octant or quadrant blocks (or, opt-in, a real kitty image behind the text), dimmed evenly toward a scrim color so +// text reads on top. export function createEngine( context: Context, options: { @@ -781,10 +771,6 @@ export function createEngine( // Octant terminals also draw the smooth mosaics and eighth blocks themselves, for smoother edges. const smooth = octants && process.env.WALLPAPER_SMOOTH !== "0" let shapes = new Uint8Array(0) - let scrimLook: (typeof LEVELS)[Brightness] | undefined - // Last frame's scrim, and the cells whose scrim changed this frame. - let lastMask = new Float32Array(0) - let rescrimmed = new Uint8Array(0) let surface = new Uint8Array(0) let cellTint = new Int32Array(0) let previousMask = new Int16Array(0) @@ -817,7 +803,7 @@ export function createEngine( let mapFresh = false // Refitting only what changed (see settle and regrid). blended is the history-blended scene the grid is built from; // per cell, stale marks pixels to blend again, moved the cells whose blended pixels changed and refit those whose grid - // did. fitted marks cells whose last fit (fitChar, fitFg, fitBg, unless its scrim changed) still holds. + // did. fitted marks cells whose last fit (fitChar, fitFg, fitBg) still holds. let dirty = true let blended = new Uint8Array(0) let stale = new Uint8Array(0) @@ -884,32 +870,18 @@ export function createEngine( return setting === "1" && renderer.capabilities?.kitty_graphics === true } - // Builds the scene image with the text scrim and tints baked in, for the cells marked in imageCell. - const publishImage = (px: Uint8Array, PW: number, PH: number, W: number, H: number, scrim: Scrim, curve: Uint8Array) => { + // Builds the scene image with the scrim (k of the way toward it) and tints baked in, for the cells marked in imageCell. + const publishImage = (px: Uint8Array, PW: number, PH: number, W: number, H: number, scrim: Scrim, curve: Uint8Array, k: number) => { if (imagePixels.length !== PW * PH * 4) imagePixels = new Uint8Array(PW * PH * 4) const img = imagePixels - const GW = W * 2 - const GH = H * 4 const cw = PW / W const chh = PH / H for (let iy = 0; iy < PH; iy++) { const row = Math.min(H - 1, Math.floor(iy / chh)) const depth = row / Math.max(1, H - 1) const [deepR, deepG, deepB] = scrimAt(scrim, depth, light) - const my = clamp01((iy + 0.5) / (chh / 4) - 0.5, GH - 1) - const my0 = Math.floor(my) - const my1 = Math.min(GH - 1, my0 + 1) - const fy = my - my0 for (let ix = 0; ix < PW; ix++) { - const col = Math.min(W - 1, Math.floor(ix / cw)) - const ci = row * W + col - const mx = clamp01((ix + 0.5) / (cw / 2) - 0.5, GW - 1) - const mx0 = Math.floor(mx) - const mx1 = Math.min(GW - 1, mx0 + 1) - const fx = mx - mx0 - const k = - (mask[my0 * GW + mx0] * (1 - fx) + mask[my0 * GW + mx1] * fx) * (1 - fy) + - (mask[my1 * GW + mx0] * (1 - fx) + mask[my1 * GW + mx1] * fx) * fy + const ci = row * W + Math.min(W - 1, Math.floor(ix / cw)) const p = (iy * PW + ix) * 4 img[p] = curve[px[p]] * (1 - k) + deepR * k img[p + 1] = curve[px[p + 1]] * (1 - k) + deepG * k @@ -1044,7 +1016,7 @@ export function createEngine( mix(from.scene.pixels, scene.pixels, mixed, smoothstep(0, 1, fade)) } } - // Mid-fade the text scrim and desaturation blend from the old look to the new one along with the scene. + // Mid-fade the scrim and desaturation blend from the old look to the new one along with the scene. const t = from ? smoothstep(0, 1, fade) : 1 const was = from ?? { wallpaper: wallpaper!, settings: settings! } const scrim = blendScrim(was.wallpaper.scrim[was.settings.time], wallpaper!.scrim[settings!.time], t) @@ -1151,19 +1123,16 @@ export function createEngine( lastScrim = scrim lastCurve = curve - // Scrim that hugs the text: full strength under each text cell and a halo that falls off within a cell or two of - // it, per octant sub-pixel, so the scene still shows between lines, after short lines and around words. It is - // only rebuilt when the set of text cells changes. - if (surface.length !== cells) { + // How near text each sub-pixel is, rebuilt only when the set of text cells changes. + let reshaped = surface.length !== cells + if (reshaped) { surface = new Uint8Array(cells) cellTint = new Int32Array(cells) shapes = new Uint8Array(cells) mask = new Float32Array(GW * GH) - scrimLook = undefined } if (previousMask.length !== cells) previousMask = new Int16Array(cells).fill(-1) if (olderMask.length !== cells) olderMask = new Int16Array(cells).fill(-1) - let reshaped = scrimLook !== look for (let i = 0; i < cells; i++) { const k = key(bg, i * 4) const tint = tintOf(k) @@ -1174,17 +1143,7 @@ export function createEngine( const shape = !on ? 0 : char[i] !== SPACE && !(isBlock(char[i]) && tintOf(fgKey(fg, i * 4)) !== NO_TINT) ? 1 : k !== base && tint === TINT_ZERO ? 2 : 0 if (shapes[i] !== shape) ((shapes[i] = shape), (reshaped = true)) } - if (rescrimmed.length !== cells) { - rescrimmed = new Uint8Array(cells) - lastMask = new Float32Array(GW * GH) - } - rescrimmed.fill(0) - if (reshaped) { - scrimLook = look - lastMask.set(mask) - buildScrim(shapes, mask, W, H, look.scrim, look.reach) - markRescrimmed(rescrimmed, mask, lastMask, W, H) - } + if (reshaped) buildNearText(shapes, mask, W, H) if (open.length !== cells) open = new Uint8Array(cells) openW = W for (let i = 0; i < cells; i++) open[i] = surface[i] && char[i] === SPACE && mask[((i / W) | 0) * 4 * GW + (i % W) * 2] < OPEN ? 1 : 0 @@ -1194,7 +1153,7 @@ export function createEngine( mapCount.fill(0) for (let i = 0; i < cells; i++) { const g = Math.floor((((i / W) | 0) * MAP_ROWS) / H) * MAP_COLS + Math.floor(((i % W) * MAP_COLS) / W) - cover[g] += surface[i] ? Math.min(1, mask[((i / W) | 0) * 4 * GW + (i % W) * 2] / look.scrim) : 1 + cover[g] += surface[i] ? mask[((i / W) | 0) * 4 * GW + (i % W) * 2] : 1 mapCount[g]++ } for (let g = 0; g < cover.length; g++) cover[g] /= Math.max(1, mapCount[g]) @@ -1245,19 +1204,20 @@ export function createEngine( const edge = edgeTint !== NO_TINT if (!surface[i] && !edge) continue const fits = !edge && ch === SPACE && !atCursor(x, y) - // Same grid and scrim as when this cell was last fitted, and fitting is idempotent under hysteresis: reuse it. - if (fits && reusable && rescrimmed[i] === 0) { + // Same grid as when this cell was last fitted, and fitting is idempotent under hysteresis: reuse it. + if (fits && reusable) { reuseFit(i, o, char, fg, bg, fitChar, fitFg, fitBg) fitted[i] = 1 applyTint(bg, o, cellTint[i]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue } - // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. + // Eight square sub-pixels per cell, 2 wide by 4 tall, in row-major order. The scrim darkens the whole scene + // evenly, so text needs no backing of its own. + const k = look.scrim for (let s = 0; s < 8; s++) { const g = (y * 4 + (s >> 1)) * GW + x * 2 + (s & 1) const a = g * 3 - const k = mask[g] q[s * 3] = curve[grid[a] | 0] * (1 - k) + deepR * k q[s * 3 + 1] = curve[grid[a + 1] | 0] * (1 - k) + deepG * k q[s * 3 + 2] = curve[grid[a + 2] | 0] * (1 - k) + deepB * k @@ -1315,7 +1275,7 @@ export function createEngine( // Build next frame's image now; the back layer places it before OpenCode draws, so text lands on top. if (pixels && (advanced || !nextImage)) { - const image = publishImage(blended, PW, PH, W, H, scrim, curve) + const image = publishImage(blended, PW, PH, W, H, scrim, curve, look.scrim) if (image) { nextImage?.dispose() nextImage = image