diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a781ca6..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 soft scrim that fades the scene toward a dark color around 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 +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 a45d4e3..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,10 +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 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 -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/dots.ts b/src/dots.ts index 69eae51..d4c2d5d 100644 --- a/src/dots.ts +++ b/src/dots.ts @@ -77,9 +77,14 @@ function addDot(size: Uint8Array, sub: Uint8Array, light: Float32Array, i: numbe // Writes cell i as a dot if it is flat enough and clear of the scrim's strongest part: the cell's mean color behind, // the point's color in front. q holds the cell's eight sub-pixels as displayed (curve and scrim applied), grid the -// scene before the curve, st and sb the scrim over the top and bottom halves. held says it showed a dot last frame. -export function placeDot(dots: Dots, i: number, x: number, y: number, q: Float32Array, grid: Float32Array, GW: number, st: number, sb: number, curve: Uint8Array, held: boolean, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, o: number) { - if (st > SCRIM || sb > SCRIM) return false +// scene before the curve, scrim the text scrim's strength per sub-pixel. held says it showed a dot last frame. +export function placeDot(dots: Dots, i: number, x: number, y: number, q: Float32Array, grid: Float32Array, scrim: Float32Array, GW: number, curve: Uint8Array, held: boolean, char: Uint32Array, fg: Uint16Array, bg: Uint16Array, o: number) { + let cover = 0 + for (let s = 0; s < 8; s++) { + const k = scrim[(y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)] + if (k > SCRIM) return false + cover += k / 8 + } let r = 0 let g = 0 let b = 0 @@ -94,7 +99,7 @@ export function placeDot(dots: Dots, i: number, x: number, y: number, q: Float32 let err = 0 for (let s = 0; s < 8; s++) err += (q[s * 3] - r) ** 2 + (q[s * 3 + 1] - g) ** 2 + (q[s * 3 + 2] - b) ** 2 if (err > (held ? FLAT * 2 : FLAT)) return false - const k = (1 - (st + sb) / 2) * GAINS[dots.size[i] - 1] + const k = (1 - cover) * GAINS[dots.size[i] - 1] char[i] = GLYPHS[dots.size[i] - 1] bg[o] = r bg[o + 1] = g @@ -117,10 +122,11 @@ export function placeDot(dots: Dots, i: number, x: number, y: number, q: Float32 const lift = new Float32Array(3) // Paints cell i's point into its sub-pixel q, as it would look drawn into the scene. -export function lightDot(dots: Dots, i: number, x: number, y: number, q: Float32Array, grid: Float32Array, GW: number, st: number, sb: number, curve: Uint8Array) { +export function lightDot(dots: Dots, i: number, x: number, y: number, q: Float32Array, grid: Float32Array, scrim: Float32Array, GW: number, curve: Uint8Array) { const s = dots.sub[i] - const a = ((y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)) * 3 - const k = 1 - (s < 4 ? st : sb) + const g = (y * 4 + (s >> 1)) * GW + x * 2 + (s & 1) + const a = g * 3 + const k = 1 - scrim[g] for (let c = 0; c < 3; c++) { const under = grid[a + c] q[s * 3 + c] += (curve[Math.min(255, under + dots.light[i * 3 + c] * SHARPENED) | 0] - curve[under | 0]) * k diff --git a/src/engine.tsx b/src/engine.tsx index bb4645a..a4521ff 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -45,18 +45,19 @@ 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 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 (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. -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++) { @@ -65,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, 3), - normal: level(1, 0.5, 0.93, 3.5), - vivid: level(1.6, 0.35, 0.96, 4.5), + 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 } @@ -86,29 +87,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 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++) { + 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 near = 1 - t * t * (3 - 2 * t) + out[g] = shapes[row + (gx >> 1)] === 2 && near < PANEL ? PANEL : near } } } -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. @@ -511,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 @@ -706,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: { @@ -745,7 +770,7 @@ export function createEngine( 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 shapes = new Uint8Array(0) let surface = new Uint8Array(0) let cellTint = new Int32Array(0) let previousMask = new Int16Array(0) @@ -778,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, under scrim fitTop and fitBottom) 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) @@ -788,8 +813,6 @@ export function createEngine( let fitChar = new Uint32Array(0) let fitFg = new Uint16Array(0) let fitBg = new Uint16Array(0) - let fitTop = new Float64Array(0) - let fitBottom = new Float64Array(0) let colStart = new Int32Array(0) let rowStart = new Int32Array(0) let cellX = new Int32Array(0) @@ -847,8 +870,8 @@ 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, MH: number, scrim: Scrim, look: (typeof LEVELS)[Brightness], 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 cw = PW / W @@ -857,21 +880,8 @@ export function createEngine( 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 my0 = Math.floor(my) - const my1 = Math.min(MH - 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 mx0 = Math.floor(mx) - const mx1 = Math.min(W - 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 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 @@ -1006,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) @@ -1065,8 +1075,6 @@ export function createEngine( fitChar = new Uint32Array(cells) fitFg = new Uint16Array(cells * 3) fitBg = new Uint16Array(cells * 3) - fitTop = new Float64Array(cells) - fitBottom = new Float64Array(cells) // Pixel columns and rows of each cell, and the cell of each pixel column and row. colStart = Int32Array.from({ length: W + 1 }, (_, x) => Math.floor((x * PW) / W)) rowStart = Int32Array.from({ length: H + 1 }, (_, y) => Math.floor((y * PH) / H)) @@ -1115,43 +1123,37 @@ export function createEngine( lastScrim = scrim lastCurve = curve - // 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) + // 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) } if (previousMask.length !== cells) previousMask = new Int16Array(cells).fill(-1) if (olderMask.length !== cells) olderMask = new Int16Array(cells).fill(-1) - mask.fill(0, 0, W * MH) 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 - } - for (let pass = 0; pass < 2; pass++) { - blurRows(mask, scratch, W, MH, SCRIM_X) - blurColumns(scratch, mask, W, MH, SCRIM_Y) + // 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 (shapes[i] !== shape) ((shapes[i] = shape), (reshaped = true)) } + 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) * 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 if (frames % MAP_EVERY === 1) { const cover = textMap.cover cover.fill(0) 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) * 2 * W + (i % W)] * look.reach) : 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]) @@ -1187,12 +1189,8 @@ 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 + if (smooth) prepareEdges(grid, GW, GH, curve, edgeSeen, mask) 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 @@ -1205,21 +1203,21 @@ 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) 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 && st === fitTop[i] && sb === fitBottom[i]) { + // 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 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 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 @@ -1247,23 +1245,21 @@ export function createEngine( const was = previousMask[i] // A dot glyph where a still point of light falls on an otherwise flat cell; a cell with a point is fitted // afresh every frame, and once more after, since the point isn't part of the grid. - if (dots.size[i] && placeDot(dots, i, x, y, q, grid, GW, st, sb, curve, was === DOT, char, fg, bg, o)) { + if (dots.size[i] && placeDot(dots, i, x, y, q, grid, mask, GW, curve, was === DOT, char, fg, bg, o)) { previousMask[i] = DOT applyTint(bg, o, cellTint[i]) applyTint(fg, o, cellTint[i]) continue } - if (dots.size[i]) lightDot(dots, i, x, y, q, grid, GW, st, sb, curve) + if (dots.size[i]) lightDot(dots, i, x, y, q, grid, mask, GW, curve) if (octants) { const octant = writeOctant(q, order, was >= 0 && 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 + previousMask[i] = smooth && !softFit && !round[i] && !moving[i] ? refineEdge(q, was, octant, char, fg, bg, i, o, x, y, deepR, deepG, deepB) : 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 - fitBottom[i] = sb // A fit made with a point lit in, or while something moved (see tolerance), isn't reused next frame. fitted[i] = dots.size[i] || moving[i] ? 0 : 1 applyTint(bg, o, cellTint[i]) @@ -1279,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, MH, scrim, look, curve) + const image = publishImage(blended, PW, PH, W, H, scrim, curve, look.scrim) if (image) { nextImage?.dispose() nextImage = image diff --git a/src/smooth.ts b/src/smooth.ts index 7eba721..c795039 100644 --- a/src/smooth.ts +++ b/src/smooth.ts @@ -109,24 +109,26 @@ let GW = 0 let GH = 0 let curve: Uint8Array = new Uint8Array(256) let seen: Float32Array = new Float32Array(0) +let scrim: 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) { +// The frame's sub-pixel grid (2x4 per cell, RGB), tone curve and text scrim (one strength per sub-pixel), 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, cover: Float32Array) { grid = frame GW = width GH = height curve = tone seen = record + scrim = cover } // 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 +// neighbors (toned and scrimmed like q), 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) { +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) { if (octant === 0 || octant === 255) return octant let range = 0 for (let c = 0; c < 3; c++) { @@ -146,9 +148,10 @@ export function refineEdge(q: Float32Array, previous: number, octant: number, ch 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 g = row + Math.min(GW - 1, Math.max(0, x * 2 - 1 + nx)) + const a = g * 3 + const k = scrim[g] 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