diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 956a9ef..30a8407 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,7 +120,9 @@ in full every frame. | Method | Draws | | --- | --- | -| `add(x, y, r, g, b)` | Adds light to one pixel, for stars, sparks and glints | +| `add(x, y, r, g, b)` | Adds light to one pixel, for glints on things that move with a creature | +| `point(x, y, r, g, b, size?, glide?)` | A still point of light, such as a star or a distant lamp; on terminal cells a round dot glyph (see below) | +| `splat(x, y, r, g, b)` | Adds light shared between the four pixels around (x, y), for moving specks: snowflakes, sparks, motes | | `blend(i, r, g, b, a)` | Mixes a color into the pixel at `hdr` index `i` by `a`, inside your own loops | | `disc(cx, cy, radius, r, g, b, a)` | An anti-aliased circle | | `ellipse(cx, cy, rx, ry, r, g, b, a)` | An anti-aliased axis-aligned ellipse | @@ -132,6 +134,13 @@ For per-pixel effects such as water, sand or fog, loop over `hdr` directly, and other rows of the scene into water rows with drifting ripples, a tint and a per-pixel weight; call it each frame over only the water's rows (as the beach and the prehistoric lake do), or once in `layout()` for still water such as ice. +A lone bright pixel turns into a hard square block on terminal cells, and a speck drawn with `add` hops a whole +pixel at a time. So draw still points with `point()`: on cells the engine shows each as a round dot glyph (`·`, `∙` +or `•` by size) where its cell is otherwise flat, and as a plain pixel elsewhere. Draw them before anything that may +pass in front of them, since whatever is drawn over a point keeps it a plain pixel. Set `glide` for points that drift +sideways, such as space's stars, so their dot fades from cell to cell. Draw moving specks with `splat()`, so they glide +and a faint one tints its cell instead of flipping its pattern. + ### Shapes `shape()` takes a list of `Part`s: @@ -163,8 +172,8 @@ details), and values in between soften it. - `paintSky(hdr, W, H, stops, orb)` fills the canvas with a vertical gradient through `[fraction of height, color]` stops, a glow around the sun or moon, and the orb itself. An `Orb` has x (fraction of width), y and r (fractions of height), `core` and `glow` colors, `near` and `wide` glow strengths, and `moon: true` for a cratered moon. -- `makeStars(count, top)` and `paintStars(hdr, W, H, stars, time, top, brightness)` draw slowly twinkling stars that - fade toward `top`. Stars are tinted from `STAR_TINTS`, never white. +- `makeStars(count, top)` and `paintStars(hdr, W, H, stars, time, top, brightness, this.points)` draw slowly twinkling + stars that fade toward `top`, as points (see Drawing). Stars are tinted from `STAR_TINTS`, never white. - `makeClouds(count, kind, y0, y1)`, `driftClouds(clouds, A, dt)` and `paintClouds(hdr, W, H, clouds, top, bottom, alpha, orb)` give clouds of one kind: `cumulus`, `towering` (standing on y, for a horizon), `cirrus`, `wisp`, `stratus` or `ash`. They are lit from the orb: the brighter of `top` and `bottom` faces it, and clouds near it are diff --git a/dev/cells.ts b/dev/cells.ts index ac5b578..f649e82 100644 --- a/dev/cells.ts +++ b/dev/cells.ts @@ -2,7 +2,7 @@ // bun dev/cells.ts dump.json out.png import { OCTANTS } from "../src/octants" import { SHAPES } from "../src/smooth" -import { glyph } from "./font" +import { DOTS, glyph } from "./font" import { encodePng } from "./png" const QUADS = [0x20, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588] @@ -24,12 +24,15 @@ export function cellsToPng(cells: Cells) { const oct = ch === 0x20 ? -1 : OCTANTS.indexOf(ch) const quad = QUADS.indexOf(ch) const shape = oct < 0 ? SHAPES.get(ch) : undefined - const gl = oct < 0 && quad < 0 && !shape && ch > 32 ? glyph(ch) : undefined + const dot = DOTS.get(ch) + const gl = oct < 0 && quad < 0 && !shape && !dot && 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. + // Smooth glyphs are anti-aliased, as Ghostty and kitty draw them: mix by coverage of a 4x4 grid per pixel; + // dots likewise, as a font renders them. 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 + if (dot) cover = Math.min(1, Math.max(0, dot.r + 0.5 - Math.hypot(x + 0.5 - CW / 2, y + 0.5 - dot.y))) const lit = oct > 0 ? !!(oct & (1 << (Math.floor((y * 4) / CH) * 2 + (x < CW / 2 ? 0 : 1)))) @@ -38,7 +41,7 @@ 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 k = shape ? cover : lit ? 1 : 0 + const k = shape || dot ? cover : lit ? 1 : 0 const o = ((cy * CH + y) * W + cx * CW + x) * 4 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/dev/font.ts b/dev/font.ts index eaab22c..662aa72 100644 --- a/dev/font.ts +++ b/dev/font.ts @@ -34,3 +34,11 @@ export function glyph(code: number): Uint8Array | undefined { if (code < 32 || code > 126) return undefined return glyphs[code - 32] } + +// Round dot glyphs the engine draws stars with (· ∙ •): radius and center height in pixels of an 8x16 cell, roughly +// as a typical monospace font draws them. +export const DOTS = new Map([ + [0xb7, { r: 1.1, y: 8.5 }], + [0x2219, { r: 1.5, y: 8.5 }], + [0x2022, { r: 2.2, y: 8.5 }], +]) diff --git a/screenshots/city-night.png b/screenshots/city-night.png index 4d3368c..960a387 100644 Binary files a/screenshots/city-night.png and b/screenshots/city-night.png differ diff --git a/screenshots/city-sunset.png b/screenshots/city-sunset.png index 27109a4..119c471 100644 Binary files a/screenshots/city-sunset.png and b/screenshots/city-sunset.png differ diff --git a/screenshots/city.png b/screenshots/city.png index 8c15e78..f1d9402 100644 Binary files a/screenshots/city.png and b/screenshots/city.png differ diff --git a/screenshots/desert-night.png b/screenshots/desert-night.png index 664628f..65d933c 100644 Binary files a/screenshots/desert-night.png and b/screenshots/desert-night.png differ diff --git a/screenshots/desert-sunset.png b/screenshots/desert-sunset.png index 3811e6b..50cf5af 100644 Binary files a/screenshots/desert-sunset.png and b/screenshots/desert-sunset.png differ diff --git a/screenshots/desert.png b/screenshots/desert.png index 5f5e17b..87f2d9d 100644 Binary files a/screenshots/desert.png and b/screenshots/desert.png differ diff --git a/screenshots/farm-night.png b/screenshots/farm-night.png index 59b3e24..4c9f528 100644 Binary files a/screenshots/farm-night.png and b/screenshots/farm-night.png differ diff --git a/screenshots/jungle-night.png b/screenshots/jungle-night.png index 820df74..0deed8c 100644 Binary files a/screenshots/jungle-night.png and b/screenshots/jungle-night.png differ diff --git a/screenshots/jungle-sunset.png b/screenshots/jungle-sunset.png index 2804163..4de1f24 100644 Binary files a/screenshots/jungle-sunset.png and b/screenshots/jungle-sunset.png differ diff --git a/screenshots/jungle.png b/screenshots/jungle.png index eb4b152..91e23ac 100644 Binary files a/screenshots/jungle.png and b/screenshots/jungle.png differ diff --git a/screenshots/ocean-night.png b/screenshots/ocean-night.png index 9e16974..6b18716 100644 Binary files a/screenshots/ocean-night.png and b/screenshots/ocean-night.png differ diff --git a/screenshots/ocean-sunset.png b/screenshots/ocean-sunset.png index d489d2b..9a49934 100644 Binary files a/screenshots/ocean-sunset.png and b/screenshots/ocean-sunset.png differ diff --git a/screenshots/ocean.png b/screenshots/ocean.png index 33bb17d..4b77671 100644 Binary files a/screenshots/ocean.png and b/screenshots/ocean.png differ diff --git a/screenshots/prehistoric-night.png b/screenshots/prehistoric-night.png index f716d29..4cbd1df 100644 Binary files a/screenshots/prehistoric-night.png and b/screenshots/prehistoric-night.png differ diff --git a/screenshots/prehistoric.png b/screenshots/prehistoric.png index 03bdd3a..1e54b17 100644 Binary files a/screenshots/prehistoric.png and b/screenshots/prehistoric.png differ diff --git a/screenshots/tundra-night.png b/screenshots/tundra-night.png index 4e7380a..3296fe3 100644 Binary files a/screenshots/tundra-night.png and b/screenshots/tundra-night.png differ diff --git a/screenshots/tundra-sunset.png b/screenshots/tundra-sunset.png index 4352270..fe21dd4 100644 Binary files a/screenshots/tundra-sunset.png and b/screenshots/tundra-sunset.png differ diff --git a/screenshots/tundra.png b/screenshots/tundra.png index 49dc161..8d38869 100644 Binary files a/screenshots/tundra.png and b/screenshots/tundra.png differ diff --git a/screenshots/zen-night.png b/screenshots/zen-night.png index 88a16b3..ac1cd11 100644 Binary files a/screenshots/zen-night.png and b/screenshots/zen-night.png differ diff --git a/src/canvas.ts b/src/canvas.ts index fa70e8e..28efcc5 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 { Points, splat } from "./points" import { trackOrbs } from "./orbs" import { clearShadows, keepShadows } from "./shadow" import { addBox, trackTouched } from "./touched" @@ -67,6 +68,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 + // Still points of light registered this frame (see point()), for the engine to draw as dot glyphs on cells. + readonly points = new Points() // The sun and moon discs layout() painted, [cx, cy, r, ...] in pixels (see src/orbs.ts). readonly orbs: number[] = [] @@ -195,6 +198,7 @@ export abstract class Canvas { this.W = W this.H = H this.cells = cells + this.points.on = cells this.A = W / H this.hdr = new Float32Array(W * H * 3) trackTouched(this.hdr, this.touched) @@ -305,6 +309,19 @@ export abstract class Canvas { this.touch(xi, yi, xi, yi) } + // Adds a still point of light, such as a star or a distant lamp: one pixel, or on terminal cells a round dot glyph + // (size 1 small, 2 medium, 3 large) where its cell is otherwise flat. Set glide for points that drift sideways. Draw + // points before anything that may pass in front of them: whatever is drawn over one keeps it a plain pixel. + protected point(x: number, y: number, r: number, g: number, b: number, size = 1, glide = false) { + this.points.add(this.hdr, this.W, this.H, x, y, r, g, b, size, glide) + } + + // Adds light for a moving speck (a snowflake, a spark, a firefly's core), shared between the pixels around it so + // it glides rather than hopping a pixel at a time. + protected splat(x: number, y: number, r: number, g: number, b: number) { + splat(this.hdr, this.W, this.H, x, y, r, g, b) + } + // Mixes a color over the pixel at hdr index i by coverage a. protected blend(i: number, r: number, g: number, b: number, a: number) { const h = this.hdr @@ -357,8 +374,10 @@ export abstract class Canvas { } // Bloom, vignette and ACES tone mapping from hdr into pixels. Only the blocks that differ from the still frame, or - // whose bloom does, are worked out again; the rest keep the still frame's pixels. + // whose bloom does, are worked out again; the rest keep the still frame's pixels. On terminal cells, still points + // come back out of hdr first (see point()). protected finish(exposure = 1.25) { + this.points.resolve(this.hdr, this.W, this.vignette, exposure) clearShadows(this.hdr) const { W, H, bw, bh, retone } = this const fresh = exposure !== this.stillExposure diff --git a/src/dots.ts b/src/dots.ts new file mode 100644 index 0000000..69eae51 --- /dev/null +++ b/src/dots.ts @@ -0,0 +1,128 @@ +// Draws the still points of light scenes register (see src/points.ts) on terminal cells: as a round dot glyph in the +// point's color over the cell's own color where the cell is otherwise flat, and elsewhere (an edge, a creature, the +// text scrim) painted into the cell's sub-pixels before they are fitted, as a plain pixel would be. +import { POINT_STRIDE } from "./points" + +// · ∙ • by point size. +const GLYPHS = [0xb7, 0x2219, 0x2022] +// Squared error (0-255 channel units, summed over a cell's eight sub-pixels) under which a cell counts as flat. A cell +// already showing a dot keeps it up to twice that, so a point at the limit doesn't flip between dot and block. +const FLAT = 400 +// Scrim strength past which a point is painted rather than dotted, keeping dots out of the scrim's strongest part. +const SCRIM = 0.5 +// A gliding point fades into the next cell over the outer twelfth of its cell on either side: a quick crossfade +// instead of a hop, and seldom long enough to show as two dots. +const GLIDE = 0.08 +// A dot covers less of its cell than the block a lone sub-pixel turns into, so smaller dots are drawn brighter. +const GAINS = [2, 1.5, 1.1] +// The unsharp mask lifts a lone bright sub-pixel by about this much; painted points get the same lift. +const SHARPENED = 1.5 +// previousMask value for a cell showing a dot. +export const DOT = -2 + +export class Dots { + // Per cell: the dot's size (0 for none), the sub-pixel it lies in (0-7, row-major) and the light it adds (display + // units, after desaturation and shade). + size = new Uint8Array(0) + sub = new Uint8Array(0) + light = new Float32Array(0) + + clear(cells: number) { + if (this.size.length !== cells) { + this.size = new Uint8Array(cells) + this.sub = new Uint8Array(cells) + this.light = new Float32Array(cells * 3) + return + } + this.size.fill(0) + this.light.fill(0) + } + + // Adds a scene's points, faded by alpha (during a crossfade), for a W x H grid of cells over a PW x PH scene. + gather(points: { readonly count: number; readonly data: Float32Array } | undefined, alpha: number, PW: number, PH: number, W: number, H: number, desaturate: number, shade: number) { + if (!points?.count || alpha <= 0) return + gatherPoints(this.size, this.sub, this.light, points.data, points.count, alpha, W / PW, H / PH, W, H, desaturate, shade) + } +} + +function gatherPoints(size: Uint8Array, sub: Uint8Array, light: Float32Array, d: Float32Array, count: number, alpha: number, sx: number, sy: number, W: number, H: number, desaturate: number, shade: number) { + for (let p = 0; p < count * POINT_STRIDE; p += POINT_STRIDE) { + const gx = d[p] * sx + const gy = d[p + 1] * sy + const cx = Math.floor(gx) + const cy = Math.floor(gy) + if (cx < 0 || cy < 0 || cx >= W || cy >= H) continue + const s = Math.min(3, Math.floor((gy - cy) * 4)) * 2 + ((gx - cx) * 2 >= 1 ? 1 : 0) + const l = d[p + 4] * 0.2126 + d[p + 5] * 0.7152 + d[p + 6] * 0.0722 + const r = (d[p + 4] + (l - d[p + 4]) * desaturate) * shade * alpha + const g = (d[p + 5] + (l - d[p + 5]) * desaturate) * shade * alpha + const b = (d[p + 6] + (l - d[p + 6]) * desaturate) * shade * alpha + const u = gx - cx - 0.5 + const share = d[p + 3] && Math.abs(u) > 0.5 - GLIDE ? (Math.abs(u) - (0.5 - GLIDE)) / (GLIDE * 2) : 0 + const row = cy * W + addDot(size, sub, light, row + cx, d[p + 2], s, r * (1 - share), g * (1 - share), b * (1 - share)) + const nx = cx + (u > 0 ? 1 : -1) + // The neighbour's sub-pixel is the one on the shared edge. + if (share > 0 && nx >= 0 && nx < W) addDot(size, sub, light, row + nx, d[p + 2], (s & 6) | (u > 0 ? 0 : 1), r * share, g * share, b * share) + } +} + +function addDot(size: Uint8Array, sub: Uint8Array, light: Float32Array, i: number, s: number, at: number, r: number, g: number, b: number) { + if (!size[i] || r + g + b > light[i * 3] + light[i * 3 + 1] + light[i * 3 + 2]) sub[i] = at + size[i] = Math.max(size[i], Math.min(3, Math.max(1, Math.round(s)))) + light[i * 3] += r + light[i * 3 + 1] += g + light[i * 3 + 2] += b +} + +// 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 + let r = 0 + let g = 0 + let b = 0 + for (let s = 0; s < 8; s++) { + r += q[s * 3] + g += q[s * 3 + 1] + b += q[s * 3 + 2] + } + r /= 8 + g /= 8 + b /= 8 + 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] + char[i] = GLYPHS[dots.size[i] - 1] + bg[o] = r + bg[o + 1] = g + bg[o + 2] = b + bg[o + 3] = 255 + for (let c = 0; c < 3; c++) { + let under = 0 + for (let s = 0; s < 8; s++) under += grid[((y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)) * 3 + c] + under /= 8 + lift[c] = (curve[Math.min(255, under + dots.light[i * 3 + c]) | 0] - curve[under | 0]) * k + } + // Scaled back as a whole if a channel would clip, so the dot keeps its tint instead of washing toward white. + const fit = Math.min(1, (255 - r) / Math.max(1e-3, lift[0]), (255 - g) / Math.max(1e-3, lift[1]), (255 - b) / Math.max(1e-3, lift[2])) + fg[o] = r + lift[0] * fit + fg[o + 1] = g + lift[1] * fit + fg[o + 2] = b + lift[2] * fit + fg[o + 3] = 255 + return true +} +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) { + const s = dots.sub[i] + const a = ((y * 4 + (s >> 1)) * GW + x * 2 + (s & 1)) * 3 + const k = 1 - (s < 4 ? st : sb) + 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 dbbe9b1..bb4645a 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -1,6 +1,7 @@ import type { Context } from "@opencode/plugin/tui/context" import { MouseButton, NativeImagePool, Renderable, type KittyImageTransport, type MouseEvent, type OptimizedBuffer, type RenderContext } from "@opentui/core" import { appendFileSync } from "node:fs" +import { DOT, Dots, lightDot, placeDot } from "./dots" import { watchEgg } from "./egg" import { lerp, smoothstep, type RGB } from "./math" import { OCTANTS } from "./octants" @@ -798,6 +799,7 @@ export function createEngine( let lastBlend = "" let lastScrim: Scrim | undefined let lastCurve: Uint8Array | undefined + const dots = new Dots() let imagePixels = new Uint8Array(0) let pool: NativeImagePool | undefined @@ -1011,6 +1013,11 @@ 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 + if (advanced) { + dots.clear(W * H) + dots.gather(scene.points, t, scene.W, scene.H, W, H, desaturate, shade) + dots.gather(from?.scene.points, 1 - t, scene.W, scene.H, W, H, desaturate, shade) + } 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 @@ -1190,7 +1197,7 @@ export function createEngine( for (let x = 0; x < W; x++) { const i = y * W + x // Cells where something moves fit differently (see tolerance), so they are never reused. - const reusable = fitted[i] === 1 && !refitAll && refit[i] === 0 && moving[i] === 0 + const reusable = fitted[i] === 1 && !refitAll && refit[i] === 0 && moving[i] === 0 && dots.size[i] === 0 fitted[i] = 0 if (imageCell[i]) continue const o = i * 4 @@ -1238,8 +1245,17 @@ export function createEngine( } if (fits) { 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)) { + 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 (octants) { - const octant = writeOctant(q, order, was < FIRST ? was : -1, olderMask[i], moving[i], char, fg, bg, i, o) + 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 @@ -1248,8 +1264,8 @@ export function createEngine( rememberFit(i, o, char, fg, bg, fitChar, fitFg, fitBg) fitTop[i] = st fitBottom[i] = sb - // A fit made while something moved (see tolerance) isn't reused next frame. - fitted[i] = moving[i] ? 0 : 1 + // 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]) if (char[i] !== SPACE) applyTint(fg, o, cellTint[i]) continue diff --git a/src/orbs.ts b/src/orbs.ts index 9cbed04..2fb902a 100644 --- a/src/orbs.ts +++ b/src/orbs.ts @@ -1,5 +1,6 @@ // 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. +// round shapes that the engine keeps smooth wedge glyphs off, since they would turn the disc into a polygon, and that +// stars don't shine through. const lists = new WeakMap() export function trackOrbs(hdr: Float32Array, list: number[]) { @@ -9,3 +10,11 @@ export function trackOrbs(hdr: Float32Array, list: number[]) { export function addOrb(hdr: Float32Array, cx: number, cy: number, r: number) { lists.get(hdr)?.push(cx, cy, r) } + +// Whether (x, y) lies on an orb of hdr's canvas, or within margin pixels of one. +export function onOrb(hdr: Float32Array, x: number, y: number, margin = 1) { + const list = lists.get(hdr) + if (!list) return false + for (let i = 0; i < list.length; i += 3) if ((x - list[i]) ** 2 + (y - list[i + 1]) ** 2 <= (list[i + 2] + margin) ** 2) return true + return false +} diff --git a/src/points.ts b/src/points.ts new file mode 100644 index 0000000..37efba3 --- /dev/null +++ b/src/points.ts @@ -0,0 +1,109 @@ +// Small points of light. On terminal cells a lone bright pixel becomes a hard square block, so still points (stars, +// distant lamps) are also registered with the frame: unless something is drawn over one before finish(), finish() +// takes it back out of the frame and the engine draws it as a round dot glyph where its cell is otherwise flat. Moving +// specks use splat() instead, which shares their light between the pixels around them, so they glide rather than hop +// a whole pixel at a time. +import { touch } from "./touched" + +// Values per point: x and y in pixels, size (1 small, 2 medium, 3 large), glide (1 when it drifts sideways), its light +// (r, g, b), which resolve() turns into what it adds on screen in display units (0-255), and the pixel's value just +// after it was painted, to tell whether anything was drawn over it since. +export const POINT_STRIDE = 10 + +export class Points { + // Set on terminal cells; elsewhere points are only painted. + on = false + // The last finished frame's visible points. + count = 0 + data = new Float32Array(POINT_STRIDE * 64) + private next = new Float32Array(POINT_STRIDE * 64) + private pending = 0 + + // Adds a point of light at (x, y) in pixels. glide marks a point that drifts sideways, which the engine fades from + // cell to cell as it crosses them. + add(hdr: Float32Array, W: number, H: number, x: number, y: number, r: number, g: number, b: number, size = 1, glide = false) { + if (x < 0 || y < 0 || x >= W || y >= H) return + const o = ((y | 0) * W + (x | 0)) * 3 + hdr[o] += r + hdr[o + 1] += g + hdr[o + 2] += b + touch(hdr, x | 0, y | 0, x | 0, y | 0) + if (!this.on) return + if ((this.pending + 1) * POINT_STRIDE > this.next.length) { + const grown = new Float32Array(this.next.length * 2) + grown.set(this.next) + this.next = grown + } + const p = this.pending++ * POINT_STRIDE + const d = this.next + d[p] = x + d[p + 1] = y + d[p + 2] = size + d[p + 3] = glide ? 1 : 0 + d[p + 4] = r + d[p + 5] = g + d[p + 6] = b + d[p + 7] = hdr[o] + d[p + 8] = hdr[o + 1] + d[p + 9] = hdr[o + 2] + } + + // Publishes this frame's points, before bloom: each one nothing was drawn over is taken back out of hdr, and its light + // becomes the brightness it adds over the tone-mapped pixel beneath (bloom aside); the rest stay painted. scale is + // each pixel's exposure (the vignette) times exposure. + resolve(hdr: Float32Array, W: number, scale: Float32Array, exposure: number) { + const done = this.data + this.data = this.next + this.next = done + this.count = resolvePoints(this.data, this.pending, hdr, W, scale, exposure) + this.pending = 0 + } +} + +function resolvePoints(d: Float32Array, pending: number, hdr: Float32Array, W: number, scale: Float32Array, exposure: number) { + let count = 0 + for (let p = 0; p < pending * POINT_STRIDE; p += POINT_STRIDE) { + const i = (d[p + 1] | 0) * W + (d[p] | 0) + if (hdr[i * 3] !== d[p + 7] || hdr[i * 3 + 1] !== d[p + 8] || hdr[i * 3 + 2] !== d[p + 9]) continue + const k = scale[i] * exposure + const q = count++ * POINT_STRIDE + for (let c = 0; c < 4; c++) d[q + c] = d[p + c] + for (let c = 0; c < 3; c++) { + hdr[i * 3 + c] -= d[p + 4 + c] + const under = hdr[i * 3 + c] * k + d[q + 4 + c] = (aces(under + d[p + 4 + c] * k) - aces(under)) * 255 + } + } + return count +} + +// The ACES curve finish() tone-maps with. +function aces(v: number) { + const mapped = (v * (2.51 * v + 0.03)) / (v * (2.43 * v + 0.59) + 0.14) + return mapped < 0 ? 0 : mapped > 1 ? 1 : mapped +} + +// Adds light at (x, y) in pixels, shared between the four pixels around it by how close it is to each, so a moving +// speck glides smoothly and a faint one tints its cell instead of flipping its pattern. The total stays the same. +export function splat(hdr: Float32Array, W: number, H: number, x: number, y: number, r: number, g: number, b: number) { + const px = x - 0.5 + const py = y - 0.5 + const x0 = Math.floor(px) + const y0 = Math.floor(py) + const fx = px - x0 + const fy = py - y0 + touch(hdr, x0, y0, x0 + 1, y0 + 1) + for (let j = 0; j < 2; j++) { + const yi = y0 + j + if (yi < 0 || yi >= H) continue + for (let i = 0; i < 2; i++) { + const xi = x0 + i + if (xi < 0 || xi >= W) continue + const k = (i ? fx : 1 - fx) * (j ? fy : 1 - fy) + const o = (yi * W + xi) * 3 + hdr[o] += r * k + hdr[o + 1] += g * k + hdr[o + 2] += b * k + } + } +} diff --git a/src/sky.ts b/src/sky.ts index 8fbb53b..d438c34 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,6 +1,7 @@ // 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 { addOrb, onOrb } from "./orbs" +import type { Points } from "./points" import { touch } from "./touched" // The sun or moon: x as a fraction of the width, y and r as fractions of the height. @@ -68,13 +69,18 @@ export function makeStars(count: number, top: number): Star[] { return Array.from({ length: count }, () => ({ x: Math.random(), y: Math.random() * top, b: 0.4 + Math.random() * 0.6, phase: Math.random() * TAU, tint: STAR_TINTS[Math.floor(Math.random() * STAR_TINTS.length)] })) } -// Stars twinkling slowly, fading toward the horizon at `top`. -export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[], time: number, top: number, brightness: number) { +// Stars twinkling slowly, fading toward the horizon at `top`. Given a canvas's points, they go there, so terminal +// cells show them as round dots, the brightest a little larger. +export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[], time: number, top: number, brightness: number, points?: Points) { for (const s of stars) { const x = (s.x * W) | 0 const y = (s.y * H) | 0 - if (x < 0 || y < 0 || x >= W || y >= H) continue + if (x < 0 || y < 0 || x >= W || y >= H || onOrb(hdr, x + 0.5, y + 0.5, 1.5)) continue const k = s.b * Math.max(0, 1 - s.y / top) * (0.7 + 0.3 * Math.sin(time * 0.8 + s.phase)) * brightness + if (points) { + points.add(hdr, W, H, x, y, k * s.tint[0], k * s.tint[1], k * s.tint[2], s.b > 0.9 ? 3 : s.b > 0.65 ? 2 : 1) + continue + } const o = (y * W + x) * 3 hdr[o] += k * s.tint[0] hdr[o + 1] += k * s.tint[1] diff --git a/src/wallpaper.ts b/src/wallpaper.ts index ad7faa2..f31fc43 100644 --- a/src/wallpaper.ts +++ b/src/wallpaper.ts @@ -16,6 +16,9 @@ export interface Scene { 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[] + // Optional: still points of light the last render left out of pixels, for the engine to draw as dot glyphs (see + // src/points.ts; Canvas provides them). + readonly points?: { readonly count: number; readonly data: Float32Array } resize(W: number, H: number, cells?: boolean): void step(dt: number): void render(): void diff --git a/src/weather.ts b/src/weather.ts index f442161..bea9939 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -4,6 +4,7 @@ import type { Time, Weather } from "./wallpaper" import { clamp, fbm2, hash, smoothstep, type RGB } from "./math" import { touch } from "./touched" +import { splat } from "./points" interface Palette { deck: [top: RGB, bottom: RGB] @@ -286,24 +287,10 @@ function streak(hdr: Float32Array, W: number, H: number, x: number, y: number, d function splash(hdr: Float32Array, W: number, H: number, x: number, y: number, rx: number, t: number, r: number, g: number, b: number) { for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI * 2 - const px = (x + Math.cos(a) * rx) | 0 - const py = (y + Math.sin(a) * rx * 0.3) | 0 - if (px < 0 || py < 0 || px >= W || py >= H) continue - const o = (py * W + px) * 3 - hdr[o] += r - hdr[o + 1] += g - hdr[o + 2] += b + splat(hdr, W, H, x + Math.cos(a) * rx, y + Math.sin(a) * rx * 0.3, r, g, b) } const up = Math.sin(t * Math.PI) * rx * 0.9 - for (const side of [-1, 1]) { - const px = (x + side * rx * 0.7) | 0 - const py = (y - up) | 0 - if (px < 0 || py < 0 || px >= W || py >= H) continue - const o = (py * W + px) * 3 - hdr[o] += r * 2 - hdr[o + 1] += g * 2 - hdr[o + 2] += b * 2 - } + for (const side of [-1, 1]) splat(hdr, W, H, x + side * rx * 0.7, y - up, r * 2, g * 2, b * 2) } // A soft snowflake mixed over the four pixels around (x, y). diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f5e270c..1f9f52d 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -280,7 +280,7 @@ class Beach extends Canvas { render() { this.restore() - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3, this.points) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] const orb = this.weather.covered ? undefined : this.look.orb paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) diff --git a/wallpapers/city.ts b/wallpapers/city.ts index ff517ac..d703bb9 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -318,7 +318,7 @@ class City extends Canvas { render() { const { W, H, look } = this this.restore() - if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4) + if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4, this.points) const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, this.weather.covered ? undefined : look.orb) paintStorm(this.hdr, W, H, this.storm, look.clouds.top, look.clouds.bottom, this.gloom) @@ -1006,8 +1006,8 @@ class City extends Canvas { const foam: RGB = look.night ? [0.04, 0.12, 0.2] : look.glow > 0 ? [0.3, 0.14, 0.1] : [0.25, 0.3, 0.35] for (let i = 1; i < 14; i++) { const k = (1 - i / 14) * 0.6 - this.add(X(-L / 2 - i * 0.006), y + (i % 2 ? 0.5 : 1.5), foam[0] * k, foam[1] * k, foam[2] * k) - this.add(X(-L / 2 - i * 0.006), y - (i % 2 ? 0.5 : 0), foam[0] * k, foam[1] * k, foam[2] * k) + this.splat(X(-L / 2 - i * 0.006), y + (i % 2 ? 0.5 : 1.5), foam[0] * k, foam[1] * k, foam[2] * k) + this.splat(X(-L / 2 - i * 0.006), y - (i % 2 ? 0.5 : 0), foam[0] * k, foam[1] * k, foam[2] * k) } this.polygon([[X(-L / 2), y - 0.008 * H], [X(L / 2 + 0.006), y - 0.009 * H], [X(L / 2 - 0.006), y], [X(-L / 2 + 0.004), y]], this.paint(b.tour ? [0.85, 0.82, 0.75] : [0.75, 0.6, 0.15])) const cabin = this.paint(b.tour ? [0.75, 0.74, 0.72] : [0.85, 0.85, 0.82]) @@ -1076,7 +1076,7 @@ class City extends Canvas { const y = r.y * H for (let k = 0; k < n; k++) { const a = (1 - k / n) * 0.9 - this.add(x + k * 0.15, y - k, RAIN[0] * a, RAIN[1] * a, RAIN[2] * a) + this.splat(x + k * 0.15, y - k, RAIN[0] * a, RAIN[1] * a, RAIN[2] * a) } } } diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index e1b5f9f..623b476 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -6,7 +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 { addOrb, onOrb } 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" @@ -735,8 +735,9 @@ class Desert extends Canvas { const top = this.look.milkyWay ? 0.55 : 0.32 const bright = this.look.milkyWay ? 0.6 : 0.35 for (const s of this.stars) { + if (onOrb(this.hdr, s.x * this.W, s.y * this.H, 1.5)) continue const k = s.b * (1 - s.y / top) * (0.7 + 0.3 * Math.sin(this.time * 0.8 + s.phase)) * bright - this.add(s.x * this.W, s.y * this.H, k * s.tint[0], k * s.tint[1], k * s.tint[2]) + this.point(s.x * this.W, s.y * this.H, k * s.tint[0], k * s.tint[1], k * s.tint[2], s.b > 0.9 ? 3 : s.b > 0.65 ? 2 : 1) } } @@ -959,7 +960,7 @@ class Desert extends Canvas { const x = d.x * H const y = d.y * H const k = (0.4 + d.s * 0.6) * (1 + glint * Math.exp(-Math.hypot(x - this.orbX, y - this.orbY) / (0.15 * H))) - this.add(x, y, look.dust[0] * k, look.dust[1] * k, look.dust[2] * k) + this.splat(x, y, look.dust[0] * k, look.dust[1] * k, look.dust[2] * k) } } } diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 1196dc1..3f5319d 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -302,7 +302,7 @@ class Farm extends Canvas { lightenGrass(this.hdr, this.W, this.H, this.pasture, this.gusts) if (x1 >= 0) this.touch(x0, this.pastureTop, x1, this.H - 1) this.corn.draw(this.hdr, this.W, this.H, this.time) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.points) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] const orb = this.weather.covered ? undefined : this.look.orb paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) @@ -1042,7 +1042,7 @@ class Farm extends Canvas { for (let i = 0; i < 6; i++) { const on = Math.floor(this.time * 4) % 6 === i ? 2 : 0.4 const color = [[1, 0.2, 0.2], [0.2, 1, 0.3], [0.3, 0.5, 1]][i % 3] - this.add(x + Math.cos((i / 5) * Math.PI) * 0.075 * H, y + 0.008 * H, color[0] * on, color[1] * on, color[2] * on) + this.splat(x + Math.cos((i / 5) * Math.PI) * 0.075 * H, y + 0.008 * H, color[0] * on, color[1] * on, color[2] * on) } } @@ -1054,7 +1054,7 @@ class Farm extends Canvas { if (k < 0.02) continue fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 1.8 * k, 2.4 * k, 0.6 * k, 0.6) - this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) + this.splat(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } } } diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 9e2c1b9..39254ca 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -731,14 +731,14 @@ class Jungle extends Canvas { if (k < 0.02) continue fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 0.9 * k * 2, 1.2 * k * 2, 0.3 * k * 2, 0.6) - this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) + this.splat(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } return } const [lr, lg, lb] = this.look.light for (const p of this.pollen) { const k = (0.05 + p.s * 0.08) * (1 - p.y) - this.add(p.x * H, p.y * H, lr * k, lg * k, lb * k * 0.8) + this.splat(p.x * H, p.y * H, lr * k, lg * k, lb * k * 0.8) } } diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index a829ac5..d37c9c5 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -367,7 +367,7 @@ class Ocean extends Canvas { const k = (0.12 + p.s * 0.45) * pulse const green = p.s > 0.5 if (p.s > 0.75) this.disc(p.x * H, p.y * H, 1.2, 0.1 * k * 4, (green ? 0.9 : 0.6) * k * 4, (green ? 0.6 : 1) * k * 4, 0.5) - this.add(p.x * H, p.y * H, 0.1 * k, (green ? 0.9 : 0.6) * k, (green ? 0.6 : 1) * k) + this.splat(p.x * H, p.y * H, 0.1 * k, (green ? 0.9 : 0.6) * k, (green ? 0.6 : 1) * k) } if (this.diver) this.drawTorch(this.diver, 4, [1, 0.55, 0.15]) } @@ -620,13 +620,13 @@ class Ocean extends Canvas { if (p.kind === "spark") { const k = p.life / p.max const i = k * k * 2.2 - this.add(x, y, 0.8 * i, i, i) + this.splat(x, y, 0.8 * i, i, i) continue } const R = Math.max(0.8, p.size * H) const fade = Math.min(1, (p.max - p.life) * 4) if (R < 1.3) { - this.add(x, y, 0.25 * fade, 0.4 * fade, 0.45 * fade) + this.splat(x, y, 0.25 * fade, 0.4 * fade, 0.45 * fade) continue } for (let yy = Math.floor(y - R - 1); yy <= y + R + 1; yy++) @@ -779,7 +779,7 @@ class Ocean extends Canvas { const ray = RAYS_A[((x + y * 0.42 + t * 7) | 0) & 1023] * (0.45 + 0.55 * RAYS_B[((x * 0.7 + y * 0.6 - t * 4.3) | 0) & 1023]) * rayFade[y] const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + ray * 1.5 if (!back && s.s > 0.7) this.disc(s.x * H, s.y * H, 1.1, 0.6 + k, 0.75 + k, 0.8 + k, 0.35) - else this.add(s.x * H, s.y * H, k * 0.8, k, k) + else this.splat(s.x * H, s.y * H, k * 0.8, k, k) } } // Swaying kelp: a stem of overlapping discs with leaf blades on alternating sides, rooted below the screen. diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d745ce1..a204adf 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -272,7 +272,7 @@ class Prehistoric extends Canvas { render() { this.restore() this.plants.draw(this.hdr, this.W, this.H, this.time) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.points) const orange = this.look.lava[1] flowLava(this.hdr, this.lava.index, this.lava.k, this.lava.along, this.time, orange[0], orange[1], orange[2]) this.touch(this.lava.box[0], this.lava.box[1], this.lava.box[2], this.lava.box[3]) @@ -892,7 +892,7 @@ class Prehistoric extends Canvas { for (let i = 0; i < 64; i++) { const a = (i / 64) * TAU const r = (0.03 + f * 0.12) * H - this.add(cx * H + Math.cos(a) * r, (y - 0.03) * H + Math.sin(a) * r * 0.5, 0.2 * k, 0.55 * k, 1.4 * k) + this.splat(cx * H + Math.cos(a) * r, (y - 0.03) * H + Math.sin(a) * r * 0.5, 0.2 * k, 0.55 * k, 1.4 * k) } } const appear = clamp(d.t / (FLASH * 0.5), 0, 1) @@ -1043,7 +1043,7 @@ class Prehistoric extends Canvas { const a = (1 - r) * e * 0.35 for (let i = 0; i < 48; i++) { const t = (i / 48) * TAU - this.add(n.x * H + Math.cos(t) * rx, n.y * H + Math.sin(t) * rx * 0.2, look.ripple[0] * a, look.ripple[1] * a, look.ripple[2] * a) + this.splat(n.x * H + Math.cos(t) * rx, n.y * H + Math.sin(t) * rx * 0.2, look.ripple[0] * a, look.ripple[1] * a, look.ripple[2] * a) } } const skin = this.paint([0.3, 0.4, 0.36]) diff --git a/wallpapers/space.ts b/wallpapers/space.ts index e873596..67b9a97 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -350,7 +350,7 @@ class Space extends Canvas { if (x < 0 || x >= W || y >= H || this.ringMask[i]) continue const k = s.b * (0.4 + s.layer * 0.3) * look.starK * (s.b > 0.85 ? 0.75 + 0.25 * Math.sin(this.time * 1.5 + s.phase) : 1) * 0.5 const t = look.starTints ? s.tint : WHITE_STAR - this.add(x, y, k * t[0], k * t[1], k * t[2]) + this.point(x, y, k * t[0], k * t[1], k * t[2], s.b > 0.9 ? 3 : s.b > 0.65 ? 2 : 1, true) } } @@ -534,7 +534,7 @@ class Space extends Canvas { const t = i / 47 const d = t * 0.12 * H const a = (1 - t) * k - this.add(m.x * H - (m.vx / v) * d, m.y * H - (m.vy / v) * d, c[0] * a, c[1] * a, c[2] * a) + this.splat(m.x * H - (m.vx / v) * d, m.y * H - (m.vy / v) * d, c[0] * a, c[1] * a, c[2] * a) } this.disc(m.x * H, m.y * H, 1.1, c[0] * 1.3, c[1] * 1.3, c[2] * 1.3, k) } @@ -562,15 +562,15 @@ class Space extends Canvas { this.lighting, 0.8, ) - for (let i = 0; i < 10; i++) this.add(...P(-0.3 + i * 0.06, 0.008), 1.3, 0.65, 0.12) + for (let i = 0; i < 10; i++) this.splat(...P(-0.3 + i * 0.06, 0.008), 1.3, 0.65, 0.12) for (const v of [-0.04, 0.04]) { const [ex, ey] = P(-0.47, v) this.disc(ex, ey, 0.016 * S, 0.35, 1, 2.2, 0.9) - this.add(ex, ey, 0.3, 0.8, 1.6) + this.splat(ex, ey, 0.3, 0.8, 1.6) } const blink = Math.sin(this.time * 3) > 0.7 ? 1.5 : 0 - this.add(...P(-0.1, -0.075), blink, 0.1 * blink, 0.1 * blink) - this.add(...P(-0.1, 0.075), 0.1 * blink, blink, 0.2 * blink) + this.splat(...P(-0.1, -0.075), blink, 0.1 * blink, 0.1 * blink) + this.splat(...P(-0.1, 0.075), 0.1 * blink, blink, 0.2 * blink) } // Spacecraft cross the sky slowly, wait offscreen, then come back. @@ -608,8 +608,8 @@ class Space extends Canvas { for (const u of [-0.08, 0, 0.08]) this.disc(...P(u, 0), 0.018 * S, 1.6, 0.85, 0.2, 0.9) } const blink = Math.sin(this.time * 4) > 0.6 ? 1.5 : 0 - this.add(...P(0.5, 0), blink, 0.1 * blink, 0.1 * blink) - this.add(...P(-0.5, 0), 0.1 * blink, blink, 0.2 * blink) + this.splat(...P(0.5, 0), blink, 0.1 * blink, 0.1 * blink) + this.splat(...P(-0.5, 0), 0.1 * blink, blink, 0.2 * blink) } // A small satellite with a gold body, two ribbed panels and a blinking red light. @@ -623,7 +623,7 @@ class Space extends Canvas { const P = (u: number, v: number): [number, number] => [x + u * S, y + v * S] const panel = this.paint([0.18, 0.22, 0.5]) this.shape([cap(...P(-0.55, 0), ...P(-0.12, 0), 0.08 * S, 0.08 * S, panel, 3), cap(...P(0.12, 0), ...P(0.55, 0), 0.08 * S, 0.08 * S, panel, 3), cap(...P(-0.09, 0), ...P(0.09, 0), 0.08 * S, 0.08 * S, this.paint([0.8, 0.6, 0.2])), ell(...P(0, -0.13), 0.06 * S, 0.03 * S, 0, this.paint([0.75, 0.75, 0.78]))], this.lighting, 0.8) - if (Math.sin(this.time * 3 + 1) > 0.7) this.add(...P(0, 0.1), 1.5, 0.1, 0.1) + if (Math.sin(this.time * 3 + 1) > 0.7) this.splat(...P(0, 0.1), 1.5, 0.1, 0.1) } // A tumbling asteroid: a lumpy rock with a pitted surface, lit from the star's side. @@ -715,7 +715,7 @@ class Space extends Canvas { const on = Math.floor(this.time * 4) % 6 === i ? 2 : 0.4 const lx = x + Math.cos((i / 5) * Math.PI) * 0.075 * H const c = [[1, 0.2, 0.2], [0.2, 1, 0.3], [0.3, 0.5, 1]][i % 3] - this.add(lx, y + 0.008 * H, c[0] * on, c[1] * on, c[2] * on) + this.splat(lx, y + 0.008 * H, c[0] * on, c[1] * on, c[2] * on) } } } diff --git a/wallpapers/template.ts b/wallpapers/template.ts index a188a36..d166b52 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -117,7 +117,7 @@ class Template extends Canvas { // Draws one frame into hdr (linear light; values above about 0.8 bloom), then finish() tone-maps it into pixels. render() { this.restore() - paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5) + paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5, this.points) paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, this.look.orb) if (this.activity !== "calm") this.drawBalloon() if (this.activity === "teeming" && this.look.night) this.drawFireflies() diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index ea11871..0cc408f 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -236,8 +236,9 @@ class Tundra extends Canvas { render() { this.restore() this.pines.draw(this.hdr, this.W, this.H, this.time) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) + // Before the stars: anything drawn over a star afterwards keeps it a plain pixel rather than a dot on cells. if (this.look.aurora > 0 && !this.weather.covered) this.drawAurora() + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3, this.points) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] const orb = this.weather.covered ? undefined : this.look.orb paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, orb) @@ -521,7 +522,7 @@ class Tundra extends Canvas { const k = (0.12 + f.z * 0.25) * (0.6 + 0.4 * f.s) const [x, y] = [f.x * H, f.y * H] if (f.z > 0.75) this.disc(x, y, 1.1, look.flakes[0] * k * 2, look.flakes[1] * k * 2, look.flakes[2] * k * 2, 0.5) - else this.add(x, y, look.flakes[0] * k, look.flakes[1] * k, look.flakes[2] * k) + else this.splat(x, y, look.flakes[0] * k, look.flakes[1] * k, look.flakes[2] * k) } } diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index 55246a4..4cf1c35 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -311,7 +311,7 @@ class Zen extends Canvas { render() { this.restore() this.trees.draw(this.hdr, this.W, this.H, this.time) - if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) + if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3, this.points) const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.weather.covered ? undefined : this.look.orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) @@ -1293,7 +1293,7 @@ class Zen extends Canvas { if (k < 0.02) continue fireflyLight(this.hdr, this.W, H, f.x * H, f.y * H, k) this.disc(f.x * H, f.y * H, 1.3, 1.8 * k, 2.4 * k, 0.6 * k, 0.6) - this.add(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) + this.splat(f.x * H, f.y * H, 0.9 * k, 1.2 * k, 0.3 * k) } } }