diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c03760..0c946f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,13 +71,12 @@ exposure, 1.25 by default. ### layout() for static backgrounds `layout()` runs after every resize, once the buffers match the new size. Paint whatever never moves there (sky, -mountains, buildings, a tree), copy `hdr` into a field, and start each frame from that copy: +mountains, buildings, a tree); `Canvas` keeps the result in `this.background`, and each frame starts from it: ```ts protected override layout() { - paintSky(this.hdr, this.W, this.H, this.look.sky, this.look.orb) + paintSky(this.hdr, this.W, this.H, this.look.sky, this.look.orb, this.px) // …hills, a tree… - this.background = this.hdr.slice() } render() { @@ -92,6 +91,18 @@ also makes `finish()` cheaper: it blooms and tone-maps whatever `layout()` leave redoes the 4x4 blocks that differ from it and the bloom they change. So leave `hdr` holding the background at the end of `layout()`, and avoid full-screen per-frame passes where a static one will do. +The background is supersampled, so every static edge is anti-aliased: `layout()` first runs with `W`, `H` and `hdr` +twice the size, and that painting is box-filtered down into `background`; then it runs again at the real size, for +whatever `render()` reads (positions, ground heights, masks), and that pass's painting is dropped. (Big canvases, such +as snaps, skip the first pass.) Each run must start from scratch, as it already must for resizes. Everything in screen +heights just works. Anything in pixels takes `this.px`, the size of a real pixel in the current pass's pixels: rim +widths (`Math.exp(-depth / (1.5 * this.px))`), offsets (`+ 2 * this.px`), and per-pixel textures, which should stay +on real pixels (`hash2(Math.floor(x / px), Math.floor(y / px))`, `fbm1((x / px) * 0.3)`). `thick()` minimums are +already in real pixels. A per-pixel layer that `render()` draws over the scene (zen's front garden, space's near rings, +city's river) can be made in the first pass, box-filtered with `this.shrink(buffer, channels)`, and kept in a field for +the second pass to use. Scenes whose `layout()` paints nothing set `supersample = 1`. Smooth fills that cost a lot per +pixel (space's nebula) can be worked out once per real pixel. + ### Drawing `Canvas` methods, all in pixels, mixing over what's already there: diff --git a/screenshots/beach-sunset.png b/screenshots/beach-sunset.png index e4890bc..1be322c 100644 Binary files a/screenshots/beach-sunset.png and b/screenshots/beach-sunset.png differ diff --git a/screenshots/beach.png b/screenshots/beach.png index cd0d9e0..fe51452 100644 Binary files a/screenshots/beach.png and b/screenshots/beach.png differ diff --git a/screenshots/city.png b/screenshots/city.png index 767ba96..8c15e78 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 d32413c..664628f 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 5666ddc..3811e6b 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 bd3790d..5f5e17b 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 c978745..59b3e24 100644 Binary files a/screenshots/farm-night.png and b/screenshots/farm-night.png differ diff --git a/screenshots/farm-sunset.png b/screenshots/farm-sunset.png index fc14c04..317fdbb 100644 Binary files a/screenshots/farm-sunset.png and b/screenshots/farm-sunset.png differ diff --git a/screenshots/farm.png b/screenshots/farm.png index 8cdcb9b..89872ab 100644 Binary files a/screenshots/farm.png and b/screenshots/farm.png differ diff --git a/screenshots/prehistoric-night.png b/screenshots/prehistoric-night.png index d6796c3..f716d29 100644 Binary files a/screenshots/prehistoric-night.png and b/screenshots/prehistoric-night.png differ diff --git a/screenshots/prehistoric-sunset.png b/screenshots/prehistoric-sunset.png index 57afc57..797b59c 100644 Binary files a/screenshots/prehistoric-sunset.png and b/screenshots/prehistoric-sunset.png differ diff --git a/screenshots/prehistoric.png b/screenshots/prehistoric.png index 6e97316..03bdd3a 100644 Binary files a/screenshots/prehistoric.png and b/screenshots/prehistoric.png differ diff --git a/screenshots/space.png b/screenshots/space.png index 55d2b37..46e262e 100644 Binary files a/screenshots/space.png and b/screenshots/space.png differ diff --git a/screenshots/tundra-night.png b/screenshots/tundra-night.png index 7421128..4e7380a 100644 Binary files a/screenshots/tundra-night.png and b/screenshots/tundra-night.png differ diff --git a/screenshots/tundra.png b/screenshots/tundra.png index c7dc987..49dc161 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 9e19c59..88a16b3 100644 Binary files a/screenshots/zen-night.png and b/screenshots/zen-night.png differ diff --git a/screenshots/zen-sunset.png b/screenshots/zen-sunset.png index 4b135ef..933753f 100644 Binary files a/screenshots/zen-sunset.png and b/screenshots/zen-sunset.png differ diff --git a/screenshots/zen.png b/screenshots/zen.png index f83c8b7..4c45761 100644 Binary files a/screenshots/zen.png and b/screenshots/zen.png differ diff --git a/src/canvas.ts b/src/canvas.ts index 37c8b01..990ea8c 100644 --- a/src/canvas.ts +++ b/src/canvas.ts @@ -8,6 +8,11 @@ const BLOOM_THRESHOLD = 0.82 // Bloom changes smaller than this are left out when finish decides which blocks to tone-map again: under 0.06 of a // level at the steepest point of the tone curve. const BLOOM_EPSILON = 1e-4 +// layout() paints the static background at SUPERSAMPLE times the resolution while that stays within LAYOUT_PIXELS +// (about a 270x70-cell terminal), which keeps a resize under about 150 ms; larger canvases, such as real-pixel mode on +// a big terminal, paint it at their own size. +const SUPERSAMPLE = 2 +const LAYOUT_PIXELS = 600_000 export type Part = | { kind: "capsule"; ax: number; ay: number; bx: number; by: number; r0: number; r1: number; color: RGB; ribs: number } @@ -156,8 +161,17 @@ export abstract class Canvas { return best } - // Called after every size change, once the buffers match the new size. + // Called after every size change, once the buffers match the new size, to paint what never moves into hdr; Canvas + // keeps the result in `background` for render() to start from. It usually runs twice: first with W, H and hdr + // `supersample` times larger, painting a background that is then box-filtered down so every static edge is + // anti-aliased, then at the real size for everything else render() reads (positions, masks, per-pixel layers), whose + // painting is dropped. `px` is one real pixel in the current pass's pixels: scale pixel-sized widths, offsets and + // textures by it. protected layout() {} + protected background = new Float32Array(0) + protected px = 1 + // 1 for scenes whose layout() paints nothing. + protected supersample = SUPERSAMPLE resize(W: number, H: number, cells = false) { W = Math.max(64, Math.round(W)) @@ -213,16 +227,38 @@ export abstract class Canvas { this.colB[x] = Math.min(this.bw - 1, i + 1) * 3 this.colF[x] = clamp(bx - i, 0, 1) } + const S = W * H * this.supersample ** 2 <= LAYOUT_PIXELS ? this.supersample : 1 + let fine: Float32Array | undefined + if (S > 1) { + const hdr = this.hdr + this.W = W * S + this.H = H * S + this.px = S + this.hdr = new Float32Array(W * S * H * S * 3) + this.layout() + fine = this.hdr + this.W = W + this.H = H + this.px = 1 + this.hdr = hdr + } this.layout() keepShadows(this.hdr) - this.still = this.hdr.slice() + this.background = fine ? shrink(fine, W, H, S, 3) : this.hdr.slice() + this.still = this.background this.stillExposure = NaN } - // A stroke radius in screen heights, in pixels; on terminal cells never under min, since thinner strokes break up - // into noise there and shimmer as they move. + // Box-filters buf, a buffer of the size being painted in layout()'s supersampled pass with `channels` values per + // pixel, down to the real size; for per-pixel layers that render() reads, kept from that pass for the next. + protected shrink(buf: Float32Array, channels = 3) { + return shrink(buf, this.W / this.px, this.H / this.px, this.px, channels) + } + + // A stroke radius in screen heights, in pixels; on terminal cells never under min real pixels, since thinner strokes + // break up into noise there and shimmer as they move. protected thick(radius: number, min = 1) { - return this.cells ? Math.max(radius * this.H, min) : radius * this.H + return this.cells ? Math.max(radius * this.H, min * this.px) : radius * this.H } // Adds light to one pixel. @@ -263,7 +299,7 @@ export abstract class Canvas { // only the outline facing the light catches it; with front lighting each part is shaded as a rounded form. // light scales the effect (0 draws flat color). protected shape(parts: Part[], lighting: Lighting, light = 1) { - fillShape(this.hdr, this.W, this.H, parts, lighting, light) + fillShape(this.hdr, this.W, this.H, parts, lighting, light, this.px) } // Bloom, vignette and ACES tone mapping from hdr into pixels. @@ -315,6 +351,23 @@ function blurLines(src: Float32Array, dst: Float32Array, k0: number, k1: number, } } +// Averages each S x S block of src (W * S by H * S pixels of `channels` values) into one pixel. +function shrink(src: Float32Array, W: number, H: number, S: number, channels: number) { + const out = new Float32Array(W * H * channels) + const k = 1 / (S * S) + for (let y = 0; y < H; y++) + for (let sy = 0; sy < S; sy++) { + const row = (y * S + sy) * W * S + for (let x = 0; x < W; x++) + for (let sx = 0; sx < S; sx++) { + const i = (row + x * S + sx) * channels + const o = (y * W + x) * channels + for (let c = 0; c < channels; c++) out[o + c] += src[i + c] * k + } + } + return out +} + function fillDisc(hdr: Float32Array, W: number, H: number, cx: number, cy: number, rad: number, r: number, g: number, b: number, a: number) { const x0 = Math.max(0, Math.floor(cx - rad - 1)) const x1 = Math.min(W - 1, Math.ceil(cx + rad + 1)) @@ -354,7 +407,8 @@ function fillEllipse(hdr: Float32Array, W: number, H: number, cx: number, cy: nu } } -function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], lighting: Lighting, light: number) { +// unit is Canvas.px: the rim light reaches 2.2 real pixels in. +function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], lighting: Lighting, light: number, unit: number) { if (!parts.length) return // The shape's overall extent: the pixels it can cover, and the direction its rim light comes from. let cx0 = Infinity @@ -449,7 +503,7 @@ function fillShape(hdr: Float32Array, W: number, H: number, parts: Part[], light hdr[o + 2] += (c[2] * shade - hdr[o + 2]) * cov continue } - const lit = light * clamp((best + 2.2) / 2.2, 0, 1) * Math.max(0, nx * sx + ny * sy) * 0.8 + const lit = light * clamp((best + 2.2 * unit) / (2.2 * unit), 0, 1) * Math.max(0, nx * sx + ny * sy) * 0.8 hdr[o] += (c[0] * k + lighting.color[0] * lit - hdr[o]) * cov hdr[o + 1] += (c[1] * k + lighting.color[1] * lit - hdr[o + 1]) * cov hdr[o + 2] += (c[2] * k + lighting.color[2] * lit - hdr[o + 2]) * cov @@ -621,11 +675,13 @@ function fillPolygon(hdr: Float32Array, W: number, H: number, pts: readonly (rea const px = x + 0.5 const py = y + 0.5 let inside = false - let dmin = Infinity + let dmin = 0.5 for (let i = 0, j = n - 1; i < n; j = i++) { const [ax, ay] = pts[j] const [bx, by] = pts[i] if (ay > py !== by > py && px < ((bx - ax) * (py - ay)) / (by - ay) + ax) inside = !inside + // Only edges within half a pixel change the coverage. + if (px < Math.min(ax, bx) - 0.5 || px > Math.max(ax, bx) + 0.5 || py < Math.min(ay, by) - 0.5 || py > Math.max(ay, by) + 0.5) continue const dx = bx - ax const dy = by - ay const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy || 1e-6))) diff --git a/src/sky.ts b/src/sky.ts index 6771848..5fd6f76 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -30,8 +30,8 @@ export interface Star { } // Fills the whole canvas with a vertical gradient through stops ([height fraction, color]), adds a glow around the orb -// and draws the orb itself. -export function paintSky(hdr: Float32Array, W: number, H: number, stops: [number, RGB][], orb: Orb) { +// and draws the orb itself. px is Canvas.px, for the moon's texture. +export function paintSky(hdr: Float32Array, W: number, H: number, stops: [number, RGB][], orb: Orb, px = 1) { const ox = orb.x * W const oy = orb.y * H const R = orb.r * H @@ -52,7 +52,7 @@ export function paintSky(hdr: Float32Array, W: number, H: number, stops: [number const cov = Math.min(1, Math.max(0, R - r + 0.5)) if (cov <= 0) continue const limb = 0.85 + 0.15 * Math.sqrt(Math.max(0, 1 - (r / R) ** 2)) - const maria = orb.moon ? 1 - 0.18 * smoothstep(0.55, 0.75, fbm1((x - ox) * 0.25 + 3, 7) + fbm1((y - oy) * 0.25 + 1, 9) * 0.6) : 1 + const maria = orb.moon ? 1 - 0.18 * smoothstep(0.55, 0.75, fbm1(((x - ox) / px) * 0.25 + 3, 7) + fbm1(((y - oy) / px) * 0.25 + 1, 9) * 0.6) : 1 const m = limb * maria hdr[o] += (orb.core[0] * m - hdr[o]) * cov hdr[o + 1] += (orb.core[1] * m - hdr[o + 1]) * cov diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index 5c5c3b6..93626c0 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -182,7 +182,6 @@ class Beach extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] // Towering cumulus far out on the sea horizon, painted into the background, and shadows of the near clouds. @@ -322,7 +321,7 @@ class Beach extends Canvas { protected override layout() { const { W, H, A, look } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(this.hdr, W, H, look.sky, look.orb) + paintSky(this.hdr, W, H, look.sky, look.orb, this.px) this.weather.cover(this.hdr, W, H) const sky = this.hdr.slice() if (!this.weather.covered) paintClouds(this.hdr, W, H, this.towers, look.clouds.top, look.clouds.bottom, look.clouds.alpha, look.orb) @@ -336,9 +335,12 @@ class Beach extends Canvas { } // Dry sand everywhere below the horizon; the sea covers it down to the waterline each frame. const [dry] = look.sand + const px = this.px for (let y = Math.floor(HORIZON * H); y < H; y++) for (let x = 0; x < W; x++) { - const ripple = 0.92 + 0.08 * Math.sin(x * 0.4 + y * 1.1 + fbm1(x * 0.05 + y * 0.02, 3) * 5) + (hash2(x, y) - 0.5) * 0.06 + const u = x / px + const v = y / px + const ripple = 0.92 + 0.08 * Math.sin(u * 0.4 + v * 1.1 + fbm1(u * 0.05 + v * 0.02, 3) * 5) + (hash2(Math.floor(u), Math.floor(v)) - 0.5) * 0.06 const o = (y * W + x) * 3 this.hdr[o] = dry[0] * ripple this.hdr[o + 1] = dry[1] * ripple @@ -359,7 +361,6 @@ class Beach extends Canvas { { base: [0.12, 1.03], crown: [0.35, 0.33], lean: 1 }, { base: [A - 0.1, 1.0], crown: [A - 0.27, 0.39], lean: -1 }, ] - this.background = this.hdr.slice() // Each row of sea mirrors the sky about the horizon, stretched a little as rough water does, its ripples wobbling // wider toward the shore. const y0 = Math.floor(HORIZON * H) diff --git a/wallpapers/city.ts b/wallpapers/city.ts index e365800..a033410 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -236,9 +236,10 @@ class City extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) // The river with the skyline reflected in it, which each frame ripples sideways where `water` is set. private river = new Float32Array(0) + // The supersampled layout pass's river, box-filtered, for the real-size pass. + private fineRiver: Float32Array | undefined private water = new Uint8Array(0) private stars: Star[] private visibleStars: Star[] = [] @@ -357,7 +358,7 @@ class City extends Canvas { protected override layout() { const { W, H, A, look } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(this.hdr, W, H, look.sky, look.orb) + paintSky(this.hdr, W, H, look.sky, look.orb, this.px) this.weather.cover(this.hdr, W, H) const sky = this.hdr.slice() this.beacons = [] @@ -406,8 +407,12 @@ class City extends Canvas { this.water = new Uint8Array(W * H) for (let i = Math.floor(BANK * H) * W; i < Math.min(H, Math.ceil(QUAY * H)) * W; i++) this.water[i] = this.hdr[i * 3] === this.river[i * 3] && this.hdr[i * 3 + 2] === this.river[i * 3 + 2] ? 1 : 0 this.paintLamps() + if (this.px > 1) this.fineRiver = this.shrink(this.river) + else if (this.fineRiver) { + this.river = this.fineRiver + this.fineRiver = undefined + } if (this.wet) this.paintWetStreet() - this.background = this.hdr.slice() if (this.warm) return this.warm = true for (let i = 0; i < 400; i++) this.stepTraffic(0.25) @@ -457,7 +462,7 @@ class City extends Canvas { // A tower drawn pixel by pixel: a facade with a shaded side by day, rim-lit edges at sunset and night, and a grid, // bands or strips of windows, some of them lit. private paintTower(t: Tower) { - const { W, H, A, look } = this + const { W, H, A, look, px } = this const day = look.style === "front" const top = t.tiers[t.tiers.length - 1] const cx = (t.tiers[0].x0 + t.tiers[0].x1) / 2 @@ -488,11 +493,12 @@ class City extends Canvas { let g = body[1] * shade // The shaded side takes the blue of the sky. let b = body[2] * shade * (side < 1 ? 1.25 : 1) - const wu = u - 0.004 - const wv = v - 0.006 + // Windows are finer than the supersampled layout's pixels can show, so they keep to real pixels. + const wu = ((Math.floor(x / px) + 0.5) * px) / H - tier.x0 - 0.004 + const wv = ((Math.floor(y / px) + 0.5) * px) / H - tier.top - 0.006 const fu = wu / cw - Math.floor(wu / cw) const fv = wv / ch - Math.floor(wv / ch) - const inside = wu > 0 && wv > 0 && u < width - 0.003 && (t.windows === 0 ? fu < 0.55 && fv < 0.6 : t.windows === 1 ? fv < 0.5 : fu < 0.45) + const inside = wu > 0 && wv > 0 && wu + 0.004 < width - 0.003 && (t.windows === 0 ? fu < 0.55 && fv < 0.6 : t.windows === 1 ? fv < 0.5 : fu < 0.45) if (inside && (!t.far || !day)) { const col = Math.floor(wu / (t.windows === 1 ? cw * 2.5 : cw)) const row = Math.floor(wv / (t.windows === 2 ? ch * 1.5 : ch)) @@ -517,7 +523,7 @@ class City extends Canvas { } if (rimGlow > 0) { const edge = (sunLeft ? u : width - u) * H - const rim = (Math.exp(-edge / 1.5) + 0.6 * Math.exp(-v * H)) * rimGlow + const rim = (Math.exp(-edge / (1.5 * px)) + 0.6 * Math.exp((-v * H) / px)) * rimGlow r += look.light[0] * rim g += look.light[1] * rim b += look.light[2] * rim @@ -538,7 +544,7 @@ class City extends Canvas { this.beacons.push([mid / H, top.top - 0.07]) } if (t.crown === 3) { - this.polygon([[mid - w / 2, roof + 1], [mid, roof - 0.06 * H], [mid + w / 2, roof + 1]], dark) + this.polygon([[mid - w / 2, roof + this.px], [mid, roof - 0.06 * H], [mid + w / 2, roof + this.px]], dark) this.shape([cap(mid, roof - 0.05 * H, mid, roof - 0.1 * H, 0.002 * H, 0.001 * H, dark)], this.lighting, 0.5) this.beacons.push([mid / H, top.top - 0.1]) } @@ -581,7 +587,7 @@ class City extends Canvas { const on = lit && this.look.glow > 0 const k = on ? this.look.glow : 0.3 const tube: RGB = [s.color[0] * k, s.color[1] * k, s.color[2] * k] - const r = Math.max(0.55, 0.0013 * H) + const r = Math.max(0.55 * this.px, 0.0013 * H) const inset = 0.0025 const [x0, y0, x1, y1] = [(s.x0 + inset) * H, (s.y0 + inset) * H, (s.x1 - inset) * H, (s.y1 - inset) * H] const parts: Part[] = [cap(x0, y0, x1, y0, r, r, tube), cap(x1, y0, x1, y1, r, r, tube), cap(x1, y1, x0, y1, r, r, tube), cap(x0, y1, x0, y0, r, r, tube)] @@ -600,23 +606,23 @@ class City extends Canvas { } this.shape(parts, this.lighting, 0) if (!on) return - this.glow(this.hdr, ((s.x0 + s.x1) / 2) * H, ((s.y0 + s.y1) / 2) * H, (s.x1 - s.x0) * H * 1.2 + 4, (s.y1 - s.y0) * H * 0.9 + 4, s.color, 0.12 * k) + this.glow(this.hdr, ((s.x0 + s.x1) / 2) * H, ((s.y0 + s.y1) / 2) * H, (s.x1 - s.x0) * H * 1.2 + 4 * this.px, (s.y1 - s.y0) * H * 0.9 + 4 * this.px, s.color, 0.12 * k) } // The river darkens toward the near quay and mirrors the skyline, the reflection stretched downward into streaks and // broken by ripples. private paintRiver() { - const { W, H, hdr, look } = this + const { W, H, hdr, look, px } = this const r0 = Math.floor(BANK * H) const r1 = Math.min(H, Math.ceil(QUAY * H)) const [far, near] = look.river - const smear = Math.max(2, Math.round(0.006 * H)) + const smear = Math.max(2 * px, Math.round(0.006 * H)) for (let y = r0; y < r1; y++) { const depth = (y - r0) / (r1 - r0) - const sy = r0 - 1 - Math.floor((y - r0) * 0.7) + const sy = r0 - px - Math.floor((y - r0) * 0.7) const k = lerp(1, 0.6, depth) * look.reflect for (let x = 0; x < W; x++) { - const ripple = 0.88 + 0.12 * Math.sin(x * 0.12 + y * 0.9 + fbm1(x * 0.04 + y * 0.17, 3) * 5) + const ripple = 0.88 + 0.12 * Math.sin((x / px) * 0.12 + (y / px) * 0.9 + fbm1((x / px) * 0.04 + (y / px) * 0.17, 3) * 5) let r = 0 let g = 0 let b = 0 @@ -720,18 +726,18 @@ class City extends Canvas { // After rain the street mirrors the underside of the highway, its pillars, lamps and the sign hung on one. private paintWetStreet() { - const { W, H, hdr } = this + const { W, H, hdr, px } = this const s0 = Math.floor(STREET * H) for (let y = s0; y < H; y++) { - const sy = s0 - 1 - Math.floor((y - s0) * 0.8) + const sy = s0 - px - Math.floor((y - s0) * 0.8) for (let x = 0; x < W; x++) { - const puddle = 0.35 + 0.65 * smoothstep(0.4, 0.62, fbm1(x * 0.03 + Math.floor(y * 0.4) * 5.3, 2)) - const streak = 0.7 + 0.3 * Math.sin(x * 1.3 + y * 0.2) + const puddle = 0.35 + 0.65 * smoothstep(0.4, 0.62, fbm1((x / px) * 0.03 + Math.floor((y / px) * 0.4) * 5.3, 2)) + const streak = 0.7 + 0.3 * Math.sin((x / px) * 1.3 + (y / px) * 0.2) let r = 0 let g = 0 let b = 0 for (let j = 0; j < 3; j++) { - const o = (clamp(sy - j * 2, 0, H - 1) * W + x) * 3 + const o = (clamp(sy - j * 2 * px, 0, H - 1) * W + x) * 3 r += hdr[o] g += hdr[o + 1] b += hdr[o + 2] diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 5583a7e..2ced0a2 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -255,7 +255,6 @@ class Desert extends Canvas { private time = 0 private readonly activity: Activity private readonly look: Look - private background = new Float32Array(0) private frontTop = new Float32Array(0) private nearTop = new Float32Array(0) private orbX = 0 @@ -359,7 +358,7 @@ class Desert extends Canvas { // Everything that never moves is painted once per size into `background`, then copied in each frame. protected override layout() { - const { W, H, A, look } = this + const { W, H, A, look, px } = this this.orbX = look.orb.x * W this.orbY = look.orb.y * H this.drawSky() @@ -377,8 +376,8 @@ class Desert extends Canvas { } const horizon = look.sky[look.sky.length - 1][1] this.fillBelow(farTop, (x, y, d) => { - if (y > HORIZON * H + 1) return undefined - const haze = (0.35 + 0.4 * (1 - d / 40)) * (look.style === "front" ? 0.45 : 0.4) + if (y > HORIZON * H + px) return undefined + const haze = (0.35 + 0.4 * (1 - d / (40 * px))) * (look.style === "front" ? 0.45 : 0.4) return this.surface(mix(look.farMesa, horizon, haze), farTop, x, d, 1.5, 0.6, true) }) this.drawFloor() @@ -392,36 +391,35 @@ class Desert extends Canvas { backTop[x] = (0.7 + 0.035 * Math.sin(u * 3.1 + 1) + 0.02 * Math.sin(u * 7.3)) * H this.frontTop[x] = (0.83 + 0.045 * Math.sin(u * 2.2 + 2.5) + 0.015 * Math.sin(u * 5.7 + 1)) * H } - this.fillBelow(backTop, (x, _y, d) => this.surface(scale(look.backDune, 1 - d * 0.005), backTop, x, d, 2.2, 0.55, false)) + this.fillBelow(backTop, (x, _y, d) => this.surface(scale(look.backDune, 1 - (d / px) * 0.005), backTop, x, d, 2.2, 0.55, false)) this.fillBelow(this.frontTop, (x, _y, d) => { - const ripple = 0.9 + 0.1 * Math.sin(x * 0.5 + d * 0.9 + fbm1(x * 0.05, 3) * 4) + const ripple = 0.9 + 0.1 * Math.sin((x / px) * 0.5 + (d / px) * 0.9 + fbm1((x / px) * 0.05, 3) * 4) return this.surface(scale(look.frontDune, ripple), this.frontTop, x, d, 2.5, 0.5, false) }) if (day) mottle(this.hdr, W, H, HORIZON, 1, 0.6, [1.1, 0.98, 0.8], [0.72, 0.7, 0.86]) if (this.season === "spring") this.drawBloom() if (this.activity === "teeming") { for (const [x, h] of [[0.45, 0.14], [A - 0.14, 0.11]]) { - const gy = backTop[clamp(Math.round(x * H), 0, W - 1)] + 2 + const gy = backTop[clamp(Math.round(x * H), 0, W - 1)] + 2 * px this.shadow(x * H, gy, 0.25 * h * H, h * H) this.drawSaguaro(x * H, gy, h * H, 0.5) } } // The big saguaro and the skull keep to the sides, clear of the text in the middle. this.shadow(0.22 * H, this.ground(0.22 / A), 0.085 * H, 0.34 * H) - this.drawSaguaro(0.22 * H, this.ground(0.22 / A) + 3, 0.34 * H, 0) + this.drawSaguaro(0.22 * H, this.ground(0.22 / A) + 3 * px, 0.34 * H, 0) this.shadow((A - 0.32) * H, this.ground(1 - 0.32 / A), 0.07 * H, 0.1 * H, 2.5) this.drawSkull((A - 0.32) * H, this.ground(1 - 0.32 / A), 0.07 * H) if (this.activity === "teeming") { this.shadow(0.33 * A * H, this.ground(0.33), 0.09 * H, 0.16 * H, 1.1) - this.drawPricklyPear(0.33 * A * H, this.ground(0.33) + 2, H) + this.drawPricklyPear(0.33 * A * H, this.ground(0.33) + 2 * px, H) this.shadow(0.62 * A * H, this.ground(0.62), 0.06 * H, 0.07 * H, 0.8) - this.drawBarrel(0.62 * A * H, this.ground(0.62) + 1, H) + this.drawBarrel(0.62 * A * H, this.ground(0.62) + px, H) } // The coyote sits on the tallest near mesa toward either side, where text rarely covers it, but not behind the saguaro. let best = W - 1 - Math.round(W * 0.05) for (let x = 0; x < W; x++) if (Math.abs(x / W - 0.5) > 0.3 && Math.abs(x / W - 0.5) < 0.47 && Math.abs(x / H - 0.22) > 0.1 && this.nearTop[x] < this.nearTop[best]) best = x this.coyoteX = best - this.background = this.hdr.slice() } // Ground height in pixels at a fraction of the width. @@ -435,10 +433,10 @@ class Desert extends Canvas { } // Shades a mesa or dune pixel `depth` below its top edge. At sunset and night the edge glows with backlight; by day, - // slopes facing the sun are brighter, and cliffs show their rock layers. + // slopes facing the sun are brighter, and cliffs show their rock layers. rimWidth is in real pixels. private surface(base: RGB, top: Float32Array, x: number, depth: number, rimWidth: number, rimStrength: number, strata: boolean): RGB { if (strata && this.season === "winter") { - const snow = smoothstep(1, 0, depth - (0.006 + 0.03 * fbm1(x * 0.06, 4) ** 2) * this.H) + const snow = smoothstep(this.px, 0, depth - (0.006 + 0.03 * fbm1((x / this.px) * 0.06, 4) ** 2) * this.H) if (snow > 0) return mix(this.lit(base, top, x, depth, rimWidth, rimStrength, strata), this.dusting, snow * 0.85) } return this.lit(base, top, x, depth, rimWidth, rimStrength, strata) @@ -446,14 +444,15 @@ class Desert extends Canvas { private lit(base: RGB, top: Float32Array, x: number, depth: number, rimWidth: number, rimStrength: number, strata: boolean): RGB { const { light } = this.look + const px = this.px if (this.look.style === "rim") { - const k = Math.exp(-depth / rimWidth) * rimStrength * this.glowAt(x) * 0.6 + const k = Math.exp(-depth / (rimWidth * px)) * rimStrength * this.glowAt(x) * 0.6 return [base[0] + light[0] * k, base[1] + light[1] * k, base[2] + light[2] * k] } const slope = (top[Math.min(this.W - 1, x + 1)] - top[Math.max(0, x - 1)]) / 2 const face = strata ? (slope < -0.6 ? 0.6 : slope > 0.6 ? 1.1 : 0.85) : 0.85 + clamp(slope * 1.5, -0.3, 0.3) - const layers = strata ? 0.9 + 0.1 * Math.sin(depth * 0.7 + fbm1(x * 0.08, 2) * 3) : 1 - const edge = 1 + 0.35 * Math.exp(-depth / 1.2) + const layers = strata ? 0.9 + 0.1 * Math.sin((depth / px) * 0.7 + fbm1((x / px) * 0.08, 2) * 3) : 1 + const edge = 1 + 0.35 * Math.exp(-depth / (1.2 * px)) const k = face * layers * edge return [base[0] * k, base[1] * k, base[2] * k] } @@ -476,10 +475,10 @@ class Desert extends Canvas { hdr[o] = lerp(from[0], to[0], k) + glow * orb.glow[0] hdr[o + 1] = lerp(from[1], to[1], k) + glow * orb.glow[1] hdr[o + 2] = lerp(from[2], to[2], k) + glow * orb.glow[2] - if (look.milkyWay) { + const band = Math.abs(v - (0.45 - (x / W) * 0.35)) / 0.07 + if (look.milkyWay && band < 4) { // A faint, mottled band rising from lower left to upper right. - const band = Math.abs(v - (0.45 - (x / W) * 0.35)) / 0.07 - const m = Math.exp(-band * band) * (0.5 + 0.5 * fbm1(x * 0.05 + v * 9, 4)) * 0.05 + const m = Math.exp(-band * band) * (0.5 + 0.5 * fbm1((x / this.px) * 0.05 + v * 9, 4)) * 0.05 hdr[o] += m * 0.55 hdr[o + 1] += m * 0.4 hdr[o + 2] += m @@ -489,7 +488,7 @@ class Desert extends Canvas { if (cov <= 0) continue const limb = 0.85 + 0.15 * Math.sqrt(Math.max(0, 1 - (r / R) ** 2)) // The moon gets a few darker maria. - const maria = orb.moon ? 1 - 0.18 * smoothstep(0.55, 0.75, fbm1((x - this.orbX) * 0.25 + 3, 7) + fbm1((y - this.orbY) * 0.25 + 1, 9) * 0.6) : 1 + const maria = orb.moon ? 1 - 0.18 * smoothstep(0.55, 0.75, fbm1(((x - this.orbX) / this.px) * 0.25 + 3, 7) + fbm1(((y - this.orbY) / this.px) * 0.25 + 1, 9) * 0.6) : 1 this.blend(o, orb.core[0] * limb * maria, orb.core[1] * limb * maria, orb.core[2] * limb * maria, cov) } } diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 0f9f5bc..62b55de 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -185,7 +185,6 @@ class Farm extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] private shadows = makeShadows(3, HORIZON + 0.08, 0.95) @@ -326,7 +325,7 @@ class Farm extends Canvas { protected override layout() { const { W, H, A, look } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(this.hdr, W, H, look.sky, look.orb) + paintSky(this.hdr, W, H, look.sky, look.orb, this.px) this.weather.cover(this.hdr, W, H) // Far hills with a patchwork of fields, then the nearer pasture hill. Autumn turns most fields gold; snow evens // them out. @@ -361,7 +360,7 @@ class Farm extends Canvas { // The far fields fade into the sky, then the pasture in front of them stays crisp. if (day) haze(this.hdr, sky, W, H, 0.5, 0.62, 0.24, 0.08) this.fillBelow(this.nearTop, (x, y, d) => { - const blades = 1 - grass + grass * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + const blades = 1 - grass + grass * Math.sin((x / this.px) * 1.7 + (y / this.px) * 0.6 + fbm1((x / this.px) * 0.1, 2) * 6) const depth = 1 - clamp((y / H - 0.6) * 0.6, 0, 0.25) return this.rim([near[0] * blades * depth, near[1] * blades * depth, near[2] * blades * depth], x, d, 2) }) @@ -414,7 +413,6 @@ class Farm extends Canvas { for (let row = 0; row < 3; row++) this.drawCornRow(row) // Weighted by height on screen: the farther rows' tops sit higher, so each stalk bends from near its foot. this.corn.end(this.hdr, W, H, 0.012 * H, (_x, y) => clamp((0.96 * H - y) / (0.26 * H), 0, 1)) - this.background = this.hdr.slice() // Open grass on the pasture hill: the pixels nothing was painted over. const pasture: number[] = [] for (let x = 0; x < W; x++) @@ -464,11 +462,11 @@ class Farm extends Canvas { return 0.665 + 0.008 * Math.sin(u * 1.7) } - // At sunset and night, the top edge of a hill catches the light. + // At sunset and night, the top edge of a hill catches the light, over about width real pixels. private rim(base: RGB, x: number, depth: number, width: number): RGB { if (this.look.style === "front") return base const glow = 0.35 + 0.65 * Math.exp(-Math.abs(x - this.look.orb.x * this.W) / (0.45 * this.H)) - const k = Math.exp(-depth / width) * glow * 0.35 + const k = Math.exp(-depth / (width * this.px)) * glow * 0.35 return [base[0] + this.look.light[0] * k, base[1] + this.look.light[1] * k, base[2] + this.look.light[2] * k] } @@ -587,13 +585,14 @@ class Farm extends Canvas { const base = [0.79, 0.87, 0.96, 1.05][row] * H const height = (0.12 + row * 0.045) * H * corn.height // Stalks stand at least a few cells apart, so in the terminal the field reads as plants rather than stripes. - const spacing = this.cells ? Math.max(0.026 + row * 0.006, 9 / H) : 0.026 + row * 0.006 + const spacing = this.cells ? Math.max(0.026 + row * 0.006, (9 * this.px) / H) : 0.026 + row * 0.006 const stalk = this.paint(corn.stalk) // The row's shaded mass of leaves, so the field reads as rows rather than a tangle of thin strokes. const end = (0.36 * A + 0.03) * H - const mass: [number, number][] = [[-2, base + 2]] - for (let x = -2; x < end; x += 6) mass.push([x, base - height * (0.42 + 0.08 * Math.sin((x / H) * 37 + row * 2))]) - mass.push([end + 0.02 * H, base + 2]) + const px = this.px + const mass: [number, number][] = [[-2 * px, base + 2 * px]] + for (let x = -2 * px; x < end; x += 6 * px) mass.push([x, base - height * (0.42 + 0.08 * Math.sin((x / H) * 37 + row * 2))]) + mass.push([end + 0.02 * H, base + 2 * px]) this.polygon(mass, [stalk[0] * 0.6, stalk[1] * 0.6, stalk[2] * 0.6]) for (let i = 0; i * spacing < 0.36 * A; i++) { const x = (i * spacing + (row % 2) * spacing * 0.5 + hash(i * 3.1 + row) * 0.008) * H diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 68264f7..f5ea2d1 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -138,7 +138,6 @@ class Jungle extends Canvas { private readonly rainy: boolean private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private leaves: Leaf[] = [] private groundPx = new Float32Array(0) private falls = { x: 0, top: 0, bottom: 0, half: 0 } @@ -320,10 +319,11 @@ class Jungle extends Canvas { this.shape([cap(...this.highBranch(A - 0.15), ...this.highBranch(A - 0.46), 0.018 * H, 0.01 * H, this.paint(TRUNK))], this.lighting) // The forest floor and its ferns. this.groundPx = new Float32Array(W) - for (let x = 0; x < W; x++) this.groundPx[x] = (0.86 + 0.02 * Math.sin((x / H) * 3.3) + (fbm1(x * 0.02, 4) - 0.5) * 0.03) * H + const px = this.px + for (let x = 0; x < W; x++) this.groundPx[x] = (0.86 + 0.02 * Math.sin((x / H) * 3.3) + (fbm1((x / px) * 0.02, 4) - 0.5) * 0.03) * H for (let x = 0; x < W; x++) for (let y = Math.floor(this.groundPx[x]); y < H; y++) { - const k = 0.8 + 0.25 * fbm1(x * 0.3 + y * 0.7, 6) + const k = 0.8 + 0.25 * fbm1((x / px) * 0.3 + (y / px) * 0.7, 6) const c = this.paint(FLOOR) this.blend((y * W + x) * 3, c[0] * k, c[1] * k, c[2] * k, clamp(y + 1 - this.groundPx[x], 0, 1)) } @@ -332,8 +332,8 @@ class Jungle extends Canvas { for (const [x, r] of [[0.04, 0.045], [A - 0.12, 0.038]]) groundShadow(this.hdr, W, H, this.shade, x * H, this.groundPx[clamp(Math.round(x * H), 0, W - 1)] + 0.03 * H, r * 6 * H, H) for (let i = 0; i < Math.round(A * 6); i++) { const x = (hash(i * 3.9) * A) * H - groundShadow(this.hdr, W, H, this.shade, x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2, (0.1 + hash(i * 1.1) * 0.07) * H, 0.1 * H) - this.drawFern(x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2, (0.08 + hash(i * 1.1) * 0.06) * H, hash(i * 5.5) > 0.5 ? 1 : -1) + groundShadow(this.hdr, W, H, this.shade, x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2 * px, (0.1 + hash(i * 1.1) * 0.07) * H, 0.1 * H) + this.drawFern(x, this.groundPx[clamp(Math.round(x), 0, W - 1)] + 2 * px, (0.08 + hash(i * 1.1) * 0.06) * H, hash(i * 5.5) > 0.5 ? 1 : -1) } // Big leaves framing the corners, drawn each frame so they can sway. this.leaves = [ @@ -343,7 +343,6 @@ class Jungle extends Canvas { ...[2.5, 2.2].map((angle, i) => ({ x: A + 0.02, y: -0.04, angle, length: [0.3, 0.25][i], phase: i * 1.7 + 3 })), ] this.vines.seal(this.hdr, W) - this.background = this.hdr.slice() } private paint(c: RGB): RGB { diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index 58c7610..8402c43 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -114,6 +114,8 @@ interface Particle { } class Ocean extends Canvas { + // The whole scene sways, so layout() paints nothing. + protected override supersample = 1 private time = 0 private rowCol = new Float32Array(0) private rayFade = new Float32Array(0) diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index ce8e43b..269a5b0 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -183,7 +183,6 @@ class Prehistoric extends Canvas { private readonly night: boolean private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private allStars: Star[] private stars: Star[] = [] private clouds: Cloud[] @@ -322,9 +321,9 @@ class Prehistoric extends Canvas { // The sky, ridge, volcano, forest, ground, lake, trees, cycads and far ferns never move, so they are painted once // per size into `background`. protected override layout() { - const { W, H, A, look } = this + const { W, H, A, look, px } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(this.hdr, W, H, look.sky, look.orb) + paintSky(this.hdr, W, H, look.sky, look.orb, px) this.weather.cover(this.hdr, W, H) const sky = this.hdr.slice() const day = look === LOOKS.day @@ -344,16 +343,17 @@ class Prehistoric extends Canvas { groundTop[x] = (0.618 + 0.005 * Math.sin(u * 2.4)) * H } this.stars = this.allStars.filter((s) => s.y * H < Math.min(volcanoTop[clamp(Math.floor(s.x * W), 0, W - 1)], ridgeTop[clamp(Math.floor(s.x * W), 0, W - 1)]) - 2) - this.fillBelow(ridgeTop, (x, y, d) => { + // Each layer stops where the next one down covers it. + this.fillBelow(ridgeTop, forestTop, (x, y, d) => { const haze = clamp((y / H - 0.47) * 4, 0, 0.4) return this.rim([lerp(look.ridge[0], look.sky[look.sky.length - 1][1][0], haze), lerp(look.ridge[1], look.sky[look.sky.length - 1][1][1], haze), lerp(look.ridge[2], look.sky[look.sky.length - 1][1][2], haze)], x, d, 1.5) }) // The volcano: dark rock scored by gullies running down from the crater, greening toward its foot. - this.fillBelow(volcanoTop, (x, y, d) => { + this.fillBelow(volcanoTop, forestTop, (x, y, d) => { const u = x / H const v = y / H const g = (u - vx) / Math.max(0.02, v - CRATER + 0.02) - const gully = 0.82 + 0.18 * Math.sin(g * 22 + fbm1(g * 5, 3) * 5) + (hash2(x, y) - 0.5) * 0.06 + const gully = 0.82 + 0.18 * Math.sin(g * 22 + fbm1(g * 5, 3) * 5) + (hash2(Math.floor(x / px), Math.floor(y / px)) - 0.5) * 0.06 const side = look.style === "front" ? 1 + 0.2 * clamp(-g, -1, 1) : 1 const ash = smoothstep(0.32, 0.22, v) * 0.25 const green = smoothstep(0.5, 0.6, v) * (0.5 + 0.5 * fbm1(u * 12, 5)) @@ -366,15 +366,15 @@ class Prehistoric extends Canvas { // By day the ridge and volcano fade into the sky, then the forest and far plain, while the foreground stays crisp. if (day) haze(this.hdr, sky, W, H, 0.62, 0.63, 0.1) this.paintLava(vx) - this.fillBelow(forestTop, (x, y, d) => { - const k = 0.8 + 0.2 * fbm1(x * 0.3, 41) + (hash2(x, y) - 0.5) * 0.1 + this.fillBelow(forestTop, groundTop, (x, y, d) => { + const k = 0.8 + 0.2 * fbm1((x / px) * 0.3, 41) + (hash2(Math.floor(x / px), Math.floor(y / px)) - 0.5) * 0.1 return this.rim([look.forest[0] * k, look.forest[1] * k, look.forest[2] * k], x, d, 1.5) }) const [far, near] = look.ground - this.fillBelow(groundTop, (x, y, d) => { + this.fillBelow(groundTop, undefined, (x, y, d) => { const depth = clamp((y / H - 0.62) / 0.38, 0, 1) // Blades are a pixel-fine texture, which terminal cells would turn into diagonal hatching. - const blades = this.cells ? 0.95 : 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 + const blades = this.cells ? 0.95 : 0.9 + 0.1 * Math.sin((x / px) * 1.7 + (y / px) * 0.6 + fbm1((x / px) * 0.1, 2) * 6) + (hash2(Math.floor(x / px), Math.floor(y / px)) - 0.5) * 0.05 return this.rim([lerp(far[0], near[0], depth) * blades, lerp(far[1], near[1], depth) * blades, lerp(far[2], near[2], depth) * blades], x, d, 2) }) if (day) { @@ -413,7 +413,6 @@ class Prehistoric extends Canvas { const p = this.pool const o = p.y0 * W * 3 for (let i = 0; i < p.gloss.length; i++) if (this.hdr[o + i * 3] !== water[i * 3] || this.hdr[o + i * 3 + 1] !== water[i * 3 + 1] || this.hdr[o + i * 3 + 2] !== water[i * 3 + 2]) p.gloss[i] = 0 - this.background = this.hdr.slice() } // Shadows under everything that walks or drives, drawn before any of it so none falls across another animal. @@ -440,18 +439,19 @@ class Prehistoric extends Canvas { return [c[0] * t[0], c[1] * t[1], c[2] * t[2]] } - // At sunset and night, the top edge of a slope catches the light. + // At sunset and night, the top edge of a slope catches the light, over about width real pixels. private rim(base: RGB, x: number, depth: number, width: number): RGB { if (this.look.style === "front") return base const glow = 0.35 + 0.65 * Math.exp(-Math.abs(x - this.look.orb.x * this.W) / (0.45 * this.H)) - const k = Math.exp(-depth / width) * glow * 0.35 + const k = Math.exp(-depth / (width * this.px)) * glow * 0.35 return [base[0] + this.look.light[0] * k, base[1] + this.look.light[1] * k, base[2] + this.look.light[2] * k] } - private fillBelow(top: Float32Array, color: (x: number, y: number, depth: number) => RGB) { + // Fills each column from its top edge down to the bottom, or to where `until` starts. + private fillBelow(top: Float32Array, until: Float32Array | undefined, color: (x: number, y: number, depth: number) => RGB) { const { W, H } = this for (let x = 0; x < W; x++) - for (let y = Math.max(0, Math.floor(top[x])); y < H; y++) { + for (let y = Math.max(0, Math.floor(top[x])); y < (until ? Math.min(H, Math.ceil(until[x])) : H); y++) { const c = color(x, y, y + 0.5 - top[x]) this.blend((y * W + x) * 3, c[0], c[1], c[2], clamp(y + 1 - top[x], 0, 1)) } @@ -471,11 +471,11 @@ class Prehistoric extends Canvas { [0.02, 0.2, 0.43, 3], [0.038, 0.42, 0.54, 4], ]) { - for (let py = (CRATER + 0.004) * H; py < end * H; py += 0.5) { + for (let py = (CRATER + 0.004) * H; py < end * H; py += 0.5 * this.px) { const v = py / H const t = (v - CRATER) / (end - CRATER) const px = (vx + off + spread * (v - CRATER) + 0.03 * (fbm1(v * 10 + seed * 7, 3) - 0.5) * t) * H - const w = Math.max(0.7, lerp(0.006, 0.0028, t) * H) + const w = Math.max(0.7 * this.px, lerp(0.006, 0.0028, t) * H) const R = w * 4 for (let y = Math.max(0, Math.floor(py - R)); y <= Math.min(H - 1, Math.ceil(py + R)); y++) for (let x = Math.max(0, Math.floor(px - R)); x <= Math.min(W - 1, Math.ceil(px + R)); x++) { @@ -524,11 +524,12 @@ class Prehistoric extends Canvas { const [cx, cy, rx, ry] = [lx * H, ly * H, lrx * H, lry * H] const red = look.lava[0] const mud = this.paint([0.3, 0.24, 0.15]) - const y0 = Math.max(0, Math.floor(cy - ry - 2)) - const y1 = Math.min(H, Math.ceil(cy + ry + 3)) + const px = this.px + const y0 = Math.max(0, Math.floor(cy - ry - 2 * px)) + const y1 = Math.min(H, Math.ceil(cy + ry + 3 * px)) const gloss = new Float32Array((y1 - y0) * W) for (let y = y0; y < y1; y++) - for (let x = Math.max(0, Math.floor(cx - rx - 3)); x < Math.min(W, cx + rx + 3); x++) { + for (let x = Math.max(0, Math.floor(cx - rx - 3 * px)); x < Math.min(W, cx + rx + 3 * px); x++) { const q = Math.hypot((x + 0.5 - cx) / rx, (y + 0.5 - cy) / ry) const shore = clamp((1.12 - q) * 6, 0, 1) if (shore <= 0) continue @@ -539,7 +540,7 @@ class Prehistoric extends Canvas { this.blend(o, look.water[0], look.water[1], look.water[2], wet) gloss[(y - y0) * W + x] = wet * (0.72 + 0.06 * Math.sin(y * 2.1 + x * 0.05)) // The volcano's glow shimmers on the water below it. - const sheen = look.glow * 0.25 * Math.exp(-Math.abs(x / H - (A - VENT_X)) / 0.1) * wet * (0.7 + 0.3 * Math.sin(y * 2.7)) + const sheen = look.glow * 0.25 * Math.exp(-Math.abs(x / H - (A - VENT_X)) / 0.1) * wet * (0.7 + 0.3 * Math.sin((y / px) * 2.7)) this.hdr[o] += red[0] * sheen this.hdr[o + 1] += red[1] * sheen this.hdr[o + 2] += red[2] * sheen diff --git a/wallpapers/space.ts b/wallpapers/space.ts index a0c0d20..0664f26 100644 --- a/wallpapers/space.ts +++ b/wallpapers/space.ts @@ -122,8 +122,11 @@ class Space extends Canvas { private readonly activity: Activity private readonly look: Look private lighting: Lighting = { style: "front", dir: [0, 0, 1] } - private background = new Float32Array(0) + // The near half of the rings, premultiplied by their alpha, drawn over the planet each frame. fineRing is the + // supersampled layout pass's, box-filtered, for the real-size pass. private ringFront = new Float32Array(0) + private fineRing: Float32Array | undefined + private nebula: Float32Array | undefined private ringBox = [0, 0, 0, 0] private ringMask = new Uint8Array(0) private starPos: [number, number] = [0, 0] @@ -250,20 +253,18 @@ class Space extends Canvas { : look.lighting === "limb" ? { style: "rim", color: [1, 0.6, 0.3], x: this.starPos[0], y: this.starPos[1] } : { style: "rim", color: [0.3, 0.6, 1.5], x: cx, y: cy } - const [n1, n2] = look.nebula + // The nebula is smooth, so it is worked out once per real pixel, in the first layout pass, and spread over the + // pixels of each pass. + const px = this.px + const nebula = this.nebula ?? this.makeNebula(W / px, H / px) + this.nebula = px > 1 ? nebula : undefined for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { - const u = x / H - const v = y / H - const a = smoothstep(0.45, 0.85, fbm2(u * 2.2, v * 2.2, 5)) - const b = smoothstep(0.5, 0.9, fbm2(u * 3.1 + 7, v * 3.1 + 3, 5)) - const band = Math.abs(v - (0.15 + u * 0.25)) / 0.12 - const milky = Math.exp(-band * band) * (0.4 + 0.6 * fbm2(u * 9, v * 9, 4)) * 0.06 * look.milkyWay + const s = (Math.floor(y / px) * (W / px) + Math.floor(x / px)) * 3 const o = (y * W + x) * 3 - const [mr, mg] = look.starTints ? [0.55, 0.4] : [0.85, 0.85] - this.hdr[o] = 0.004 + n1[0] * a + n2[0] * b + milky * mr - this.hdr[o + 1] = 0.005 + n1[1] * a + n2[1] * b + milky * mg - this.hdr[o + 2] = 0.012 + n1[2] * a + n2[2] * b + milky + this.hdr[o] = nebula[s] + this.hdr[o + 1] = nebula[s + 1] + this.hdr[o + 2] = nebula[s + 2] } // Rings: bands of dust in a tilted, flattened annulus. The far half is painted now, behind the planet; the near // half is kept to draw over it each frame. @@ -300,12 +301,38 @@ class Space extends Canvas { if (a > 0.3) this.ringMask[o] = 1 continue } - this.ringFront[o * 4] = rr * k - this.ringFront[o * 4 + 1] = rg * k - this.ringFront[o * 4 + 2] = rb * k + this.ringFront[o * 4] = rr * k * a + this.ringFront[o * 4 + 1] = rg * k * a + this.ringFront[o * 4 + 2] = rb * k * a this.ringFront[o * 4 + 3] = a } - this.background = this.hdr.slice() + if (this.px > 1) this.fineRing = this.shrink(this.ringFront, 4) + else if (this.fineRing) { + this.ringFront = this.fineRing + this.fineRing = undefined + } + } + + // The nebulae and the Milky Way at w by h pixels. + private makeNebula(w: number, h: number) { + const look = this.look + const [n1, n2] = look.nebula + const out = new Float32Array(w * h * 3) + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const u = x / h + const v = y / h + const a = smoothstep(0.45, 0.85, fbm2(u * 2.2, v * 2.2, 5)) + const b = smoothstep(0.5, 0.9, fbm2(u * 3.1 + 7, v * 3.1 + 3, 5)) + const band = Math.abs(v - (0.15 + u * 0.25)) / 0.12 + const milky = Math.exp(-band * band) * (0.4 + 0.6 * fbm2(u * 9, v * 9, 4)) * 0.06 * look.milkyWay + const o = (y * w + x) * 3 + const [mr, mg] = look.starTints ? [0.55, 0.4] : [0.85, 0.85] + out[o] = 0.004 + n1[0] * a + n2[0] * b + milky * mr + out[o + 1] = 0.005 + n1[1] * a + n2[1] * b + milky * mg + out[o + 2] = 0.012 + n1[2] * a + n2[2] * b + milky + } + return out } private paint(c: RGB): RGB { @@ -400,7 +427,11 @@ class Space extends Canvas { for (let x = x0; x <= x1; x++) { const o = y * W + x const a = ringFront[o * 4 + 3] - if (a > 0) this.blend(o * 3, ringFront[o * 4], ringFront[o * 4 + 1], ringFront[o * 4 + 2], a) + if (a <= 0) continue + const i = o * 3 + this.hdr[i] = this.hdr[i] * (1 - a) + ringFront[o * 4] + this.hdr[i + 1] = this.hdr[i + 1] * (1 - a) + ringFront[o * 4 + 1] + this.hdr[i + 2] = this.hdr[i + 2] * (1 - a) + ringFront[o * 4 + 2] } } diff --git a/wallpapers/template.ts b/wallpapers/template.ts index 0b604af..e9fa94a 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -91,7 +91,6 @@ class Template extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] private balloon = { x: 0.3, phase: 0 } @@ -126,12 +125,13 @@ class Template extends Canvas { this.finish() } - // Runs after every resize. Whatever never moves is painted here once and copied in at the start of each frame. + // Runs after every resize. Whatever never moves is painted here once, kept as `background` and copied in at the start + // of each frame. protected override layout() { const { W, H, A, look, hdr } = this // Rim light comes from the sun or moon's position in pixels; front light from a direction. this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(hdr, W, H, look.sky, look.orb) + paintSky(hdr, W, H, look.sky, look.orb, this.px) // Two ridges of hills, the near one darker and lower. Scene units are fractions of the height: x runs 0..A. for (const [layer, color] of look.hills.entries()) for (let x = 0; x < W; x++) { @@ -152,7 +152,6 @@ class Template extends Canvas { this.shape([cap(tx, ty, tx, ty - 0.12 * H, 0.012 * H, 0.008 * H, this.paint([0.4, 0.28, 0.18]))], this.lighting) const leaves = this.paint([0.2, 0.42, 0.14]) this.shape([ell(tx, ty - 0.18 * H, 0.07 * H, 0.06 * H, 0, leaves), ell(tx - 0.05 * H, ty - 0.14 * H, 0.045 * H, 0.04 * H, 0, leaves), ell(tx + 0.05 * H, ty - 0.14 * H, 0.045 * H, 0.04 * H, 0, leaves)], this.lighting) - this.background = hdr.slice() } // A daytime color as it looks at this time of day. diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index e517390..00e64ad 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -142,7 +142,6 @@ class Tundra extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] private shadows = makeShadows(3, HORIZON + 0.08, 0.95) @@ -265,9 +264,9 @@ class Tundra extends Canvas { // Everything that never moves is painted once per size into `background`, then copied in each frame. protected override layout() { - const { W, H, A, look } = this + const { W, H, A, look, px } = this this.lighting = look.style === "front" ? { style: "front", dir: DAYLIGHT } : { style: "rim", color: look.light, x: look.orb.x * W, y: look.orb.y * H } - paintSky(this.hdr, W, H, look.sky, look.orb) + paintSky(this.hdr, W, H, look.sky, look.orb, px) this.weather.cover(this.hdr, W, H) const sky = this.hdr.slice() this.drawMountains() @@ -277,11 +276,11 @@ class Tundra extends Canvas { for (let x = 0; x < W; x++) for (let y = Math.max(0, Math.floor(drift[x])); y < H; y++) { const v = y / H - const waves = 0.5 + 0.5 * Math.sin((x / H) * 6 + v * 40 + fbm1(x * 0.02, 3) * 4) + const waves = 0.5 + 0.5 * Math.sin((x / H) * 6 + v * 40 + fbm1((x / px) * 0.02, 3) * 4) const k = smoothstep(0.3, 1, waves) * (look === LOOKS.day ? 0.2 : 0.35) * (1 - (v - 0.6) * 0.8) // By day the foreground lies in blue shadow, framing the sunlit middle distance. const c = lerpRGB(look.snow, look.shade, look === LOOKS.day ? Math.min(1, k + smoothstep(0.7, 1.1, v) * 0.45) : k) - const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - drift[x]) / 2) * 0.3 : 0 + const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - drift[x]) / (2 * px)) * 0.3 : 0 this.blend((y * W + x) * 3, c[0] + look.light[0] * edge, c[1] + look.light[1] * edge, c[2] + look.light[2] * edge, clamp(y + 1 - drift[x], 0, 1)) } // By day, broad blue cloud shadows drift over the snow, and the far field and peaks fade into the sky. @@ -304,7 +303,6 @@ class Tundra extends Canvas { this.pines.begin(this.hdr) for (const [fx, h] of [[0.04, 0.46], [0.11, 0.34], [0.955, 0.42], [0.995, 0.3]] as const) this.drawPine(fx * A, 1.02, h, 0) this.pines.end(this.hdr, W, H, 0.01 * H, (_x, y) => clamp((0.95 * H - y) / (0.4 * H), 0, 1) ** 1.5) - this.background = this.hdr.slice() } // Shadows under everything on the snow, drawn before any of it so none falls across another animal. The yeti has @@ -362,7 +360,7 @@ class Tundra extends Canvas { let c = y < snowLine ? look.peaks : look.rock // Sunlit faces warm, shaded ones a crisp blue. if (look.style === "front") c = mulRGB(c, slope > 0.3 ? [1.05, 1, 0.92] : slope < -0.3 ? [0.45, 0.6, 0.92] : [0.8, 0.82, 0.9]) - const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - top[x]) / 1.8) * 0.5 : 0 + const edge = look.style === "rim" ? Math.exp(-(y + 0.5 - top[x]) / (1.8 * this.px)) * 0.5 : 0 c = lerpRGB(c, look.sky[look.sky.length - 1][1], haze * 0.5) this.blend((y * W + x) * 3, c[0] + look.light[0] * edge, c[1] + look.light[1] * edge, c[2] + look.light[2] * edge, clamp(y + 1 - top[x], 0, 1)) } @@ -398,7 +396,7 @@ class Tundra extends Canvas { for (let s = 0; s < 4; s++) { const nx = x + (hash(i * 9 + s) - 0.4) * 0.04 const ny = y + (hash(i * 11 + s) - 0.5) * 0.012 - parts.push(cap(x * H, y * H, nx * H, ny * H, 0.0015 * H, 0.001 * H, crack)) + parts.push(cap(x * H, y * H, nx * H, ny * H, this.thick(0.0015, 0.55), this.thick(0.001, 0.45), crack)) ;[x, y] = [nx, ny] } this.shape(parts, this.lighting, 0) @@ -446,10 +444,10 @@ class Tundra extends Canvas { for (let row = 1; row < 4; row++) { const yy = base - (row / 4) * R * 0.85 const half = R * Math.sqrt(1 - (row / 4) ** 2) - lines.push(cap((cx - half) * H, yy * H, (cx + half) * H, yy * H, 0.0018 * H, 0.0018 * H, seam)) + lines.push(cap((cx - half) * H, yy * H, (cx + half) * H, yy * H, this.thick(0.0018, 0.6), this.thick(0.0018, 0.6), seam)) for (let k = 0; k < 4; k++) { const sx = cx + (((k + (row % 2) * 0.5) / 4) * 2 - 1) * half * 0.85 - lines.push(cap(sx * H, yy * H, sx * H, (yy + R * 0.21) * H, 0.001 * H, 0.001 * H, seam)) + lines.push(cap(sx * H, yy * H, sx * H, (yy + R * 0.21) * H, this.thick(0.001, 0.5), this.thick(0.001, 0.5), seam)) } } this.shape(lines, this.lighting, 0) diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index 59225fa..a39a09e 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -204,7 +204,6 @@ class Zen extends Canvas { private readonly look: Look private lighting: Lighting = { style: "front", dir: DAYLIGHT } private readonly shade: Shade - private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] private pond = { x: 1, y: 0.8, rx: 0.45, ry: 0.14 } @@ -219,6 +218,8 @@ class Zen extends Canvas { // The still water's color, reflections included, which the ripples shift sideways. private mirror = new Float32Array(0) private wave = new Float32Array(0) + // The supersampled layout pass's sky, bank and front garden on black and on white, for the real-size pass. + private layers: Float32Array[] | undefined private pads: { x: number; y: number; r: number }[] = [] private koi: Koi[] = [] private rings: Ring[] = [] @@ -357,7 +358,18 @@ class Zen extends Canvas { const rx = Math.min(0.25 * A, 0.56) this.pond = { x: Math.max(0.64 * A, 0.36 * A + rx + 0.06), y: 0.8, rx, ry: 0.14 } this.cherry = { x: A - 0.2, y: 0.27 } - paintSky(this.hdr, W, H, look.sky, look.orb) + // Lily pads, placed in pond coordinates (-1..1 across and down). + const p = this.pond + this.pads = [ + [-0.15, -0.5, 0.045], + [0.3, -0.6, 0.035], + [0.55, 0.35, 0.05], + [0.08, 0.5, 0.05], + [-0.62, -0.15, 0.04], + [0.35, 0.02, 0.03], + [-0.3, 0.2, 0.028], + ].map(([u, v, r]) => ({ x: p.x + u * p.rx, y: p.y + v * p.ry, r })) + paintSky(this.hdr, W, H, look.sky, look.orb, this.px) this.weather.cover(this.hdr, W, H) const clear = this.hdr.slice() this.drawHills() @@ -373,8 +385,23 @@ class Zen extends Canvas { for (const r of this.rocks()) ground(r.x + r.r * 0.2, r.y + r.r * 0.15, r.r * 2.2, r.r * 1.4, 0.8) for (const [i, l] of this.lanterns().entries()) ground(l.x, l.base, l.scale * (i === 0 ? 0.45 : 1), l.scale * (i === 0 ? 1 : 0.8), i === 0 ? 0.9 : 0.8) this.drawBackGarden() + const bank = this.hdr.slice() + // The garden in front of the water, painted on black and on white, gives its color and coverage there. Every + // primitive mixes linearly over what's under it, so the garden over the bank is the black layer plus the white + // one's coverage of the bank. + const black = this.frontGardenOn(0) + const white = this.frontGardenOn(1) + const hdr = this.hdr + for (let i = 0; i < hdr.length; i++) hdr[i] = black[i] + (white[i] - black[i]) * hdr[i] + this.trees.seal(this.hdr, W) + // The supersampled pass hands these layers, box-filtered, to the real-size pass, which builds the water from them. + if (this.px > 1) { + this.layers = [sky, bank, black, white].map((layer) => this.shrink(layer)) + return + } + const [skyLayer, bankLayer, onBlack, onWhite] = this.layers ?? [sky, bank, black, white] + this.layers = undefined // Find the water, note what lies under its edges, and work out its still color with the sky mirrored in it. - const p = this.pond const pool: number[] = [] this.wet = new Float32Array(W * H) for (let y = Math.floor(0.56 * H); y < H; y++) @@ -386,7 +413,7 @@ class Zen extends Canvas { } this.pool = Int32Array.from(pool) this.under = new Float32Array(pool.length * 3) - for (let k = 0; k < pool.length; k++) for (let c = 0; c < 3; c++) this.under[k * 3 + c] = this.hdr[pool[k] * 3 + c] + for (let k = 0; k < pool.length; k++) for (let c = 0; c < 3; c++) this.under[k * 3 + c] = bankLayer[pool[k] * 3 + c] this.mirror = new Float32Array(W * H * 3) this.wave = new Float32Array(W) const lamps = this.lanterns() @@ -397,9 +424,9 @@ class Zen extends Canvas { for (let x = 0; x < W; x++) { const o = (y * W + x) * 3 const s = (sy * W + x) * 3 - let r = look.water[0] * (1 - m) + sky[s] * m - let g = look.water[1] * (1 - m) + sky[s + 1] * m - let b = look.water[2] * (1 - m) + sky[s + 2] * m + let r = look.water[0] * (1 - m) + skyLayer[s] * m + let g = look.water[1] * (1 - m) + skyLayer[s + 1] * m + let b = look.water[2] * (1 - m) + skyLayer[s + 2] * m // Lantern light streaks down the water below each lantern. for (const l of lamps) { const k = Math.exp(-(((x - l.x * H) / (0.012 * H)) ** 2) - ((y - (2 * l.base - l.light) * H) / (0.035 * H)) ** 2) * look.lamps * 0.7 @@ -412,35 +439,23 @@ class Zen extends Canvas { this.mirror[o + 2] = b } } - // Lily pads, placed in pond coordinates (-1..1 across and down). - this.pads = [ - [-0.15, -0.5, 0.045], - [0.3, -0.6, 0.035], - [0.55, 0.35, 0.05], - [0.08, 0.5, 0.05], - [-0.62, -0.15, 0.04], - [0.35, 0.02, 0.03], - [-0.3, 0.2, 0.028], - ].map(([u, v, r]) => ({ x: p.x + u * p.rx, y: p.y + v * p.ry, r })) - // Paint the garden in front of the water on black and on white to find its color and coverage, then for real. - const real = this.hdr - this.hdr = new Float32Array(real.length) - this.drawFrontGarden() - const black = this.hdr - this.hdr = new Float32Array(real.length).fill(1) - this.drawFrontGarden() - const white = this.hdr - this.hdr = real - this.drawFrontGarden() this.front = new Float32Array(pool.length * 3) this.keep = new Float32Array(pool.length) for (let k = 0; k < pool.length; k++) { const i = pool[k] * 3 - this.front.set([black[i], black[i + 1], black[i + 2]], k * 3) - this.keep[k] = white[i] - black[i] + this.front.set([onBlack[i], onBlack[i + 1], onBlack[i + 2]], k * 3) + this.keep[k] = onWhite[i] - onBlack[i] } - this.trees.seal(this.hdr, W) - this.background = this.hdr.slice() + } + + // The garden in front of the water, painted alone over a plain `fill`. + private frontGardenOn(fill: number) { + const real = this.hdr + this.hdr = new Float32Array(real.length).fill(fill) + this.drawFrontGarden() + const out = this.hdr + this.hdr = real + return out } private paint(c: RGB): RGB { @@ -486,24 +501,26 @@ class Zen extends Canvas { const [far, near] = look.hills const peak = 0.42 * A const fuji = (u: number) => 0.255 + 0.24 * (1 - Math.exp(-Math.max(0, Math.abs(u - peak) - 0.012) / 0.17)) + // The hills stop where the garden's ground covers them. + const bottom = Math.min(H, Math.ceil((GROUND + 0.004) * H)) for (let x = 0; x < W; x++) { const u = x / H const range = 0.41 - 0.035 * fbm1(u * 1.3, 3) - 0.015 * Math.sin(u * 2.5 + 1) const top = Math.min(range, fuji(u)) * H const snowline = (0.3 + 0.012 * Math.sin(u * 140) * Math.sin(u * 37) - 0.01 * Math.abs(Math.sin(u * 61))) * H - for (let y = Math.max(0, Math.floor(top)); y < H; y++) { + for (let y = Math.max(0, Math.floor(top)); y < bottom; y++) { const c = this.rim(fuji(u) <= range && y < snowline ? look.snow : far, x, y + 0.5 - top, 1.5) this.blend((y * W + x) * 3, c[0], c[1], c[2], clamp(y + 1 - top, 0, 1)) } } for (let y = Math.floor(0.36 * H); y < Math.min(H, GROUND * H); y++) { const k = smoothstep(0.36, HORIZON, y / H) * 0.55 - for (let x = 0; x < W; x++) this.blend((y * W + x) * 3, look.mist[0], look.mist[1], look.mist[2], k * (0.8 + 0.2 * Math.sin((x / H) * 6 + y * 0.1))) + for (let x = 0; x < W; x++) this.blend((y * W + x) * 3, look.mist[0], look.mist[1], look.mist[2], k * (0.8 + 0.2 * Math.sin((x / H) * 6 + (y / this.px) * 0.1))) } for (let x = 0; x < W; x++) { const u = x / H const top = (0.485 - 0.012 * fbm1(u * 7, 5) - 0.01 * Math.sin(u * 2 + 2)) * H - for (let y = Math.max(0, Math.floor(top)); y < H; y++) { + for (let y = Math.max(0, Math.floor(top)); y < bottom; y++) { const c = this.rim(near, x, y + 0.5 - top, 2) this.blend((y * W + x) * 3, c[0], c[1], c[2], clamp(y + 1 - top, 0, 1)) } @@ -528,11 +545,11 @@ class Zen extends Canvas { this.polygon([P(-0.001, -0.09), P(0.001, -0.09), P(0.001, -0.115), P(-0.001, -0.115)], roof) } - // At sunset and night, the top edge of a hill catches the light. + // At sunset and night, the top edge of a hill catches the light, over about width real pixels. private rim(base: RGB, x: number, depth: number, width: number): RGB { if (this.look.style === "front") return base const glow = 0.35 + 0.65 * Math.exp(-Math.abs(x - this.look.orb.x * this.W) / (0.45 * this.H)) - const k = Math.exp(-depth / width) * glow * 0.35 + const k = Math.exp(-depth / (width * this.px)) * glow * 0.35 return [base[0] + this.look.light[0] * k, base[1] + this.look.light[1] * k, base[2] + this.look.light[2] * k] } @@ -545,14 +562,15 @@ class Zen extends Canvas { const k = (0.03 - s0) / (1 - GROUND) // Lines stay at least 8 pixels apart (two terminal cells), so they read as lines rather than moiré. Nearer than // where the perspective spacing passes that, they widen as before. - const least = this.cells ? Math.max(s0, 8 / H) : s0 + const px = this.px + const least = this.cells ? Math.max(s0, (8 * px) / H) : s0 const near = GROUND + (least - s0) / k for (let y = Math.floor(GROUND * H); y < H; y++) { const v = (y + 0.5) / H const perspective = s0 + k * (v - GROUND) const spacing = Math.max(least, perspective) // Rings are squashed to 0.42 of their width, so they sit farther apart to stay clear of each other down the screen. - const ring = this.cells ? Math.max(perspective, 6 / (0.42 * H)) : perspective + const ring = this.cells ? Math.max(perspective, (6 * px) / (0.42 * H)) : perspective const lines = v < near ? (v - GROUND) / least : (near - GROUND) / least + Math.log(spacing / least) / k for (let x = 0; x < W; x++) { const u = (x + 0.5) / H @@ -568,10 +586,10 @@ class Zen extends Canvas { if (d < r.r + 4.4 * perspective + ring) groove = Math.sin(((d - r.r) / ring) * TAU) moss += smoothstep(r.r * 1.12, r.r * 0.95, d) } - const grain = (hash2(x, y) - 0.5) * 0.08 + const grain = (hash2(Math.floor(x / px), Math.floor(y / px)) - 0.5) * 0.08 const g = 0.93 + 0.1 * groove + grain const m = clamp(moss, 0, 1) - const mt = 0.85 + 0.25 * hash2(x >> 1, y >> 1) + 0.1 * Math.sin(u * 40 + v * 30) + const mt = 0.85 + 0.25 * hash2(Math.floor(x / px) >> 1, Math.floor(y / px) >> 1) + 0.1 * Math.sin(u * 40 + v * 30) const o = (y * W + x) * 3 const cov = clamp((v - top) * H + 0.5, 0, 1) this.blend(o, lerp(look.gravel[0] * g, look.moss[0] * mt, m), lerp(look.gravel[1] * g, look.moss[1] * mt, m), lerp(look.gravel[2] * g, look.moss[2] * mt, m), cov) @@ -761,14 +779,14 @@ class Zen extends Canvas { const red = this.paint(VERMILION) const shadow = this.paint([0.4, 0.08, 0.04]) const posts: Part[] = [] - for (const s of [-0.45, 0.45]) posts.push(cap((bx + s * span) * H, deck(s) * H, (bx + s * span) * H, (by + 0.03) * H, 0.004 * H, 0.004 * H, this.paint([0.2, 0.1, 0.08]))) + for (const s of [-0.45, 0.45]) posts.push(cap((bx + s * span) * H, deck(s) * H, (bx + s * span) * H, (by + 0.03) * H, this.thick(0.004, 0.95), this.thick(0.004, 0.95), this.paint([0.2, 0.1, 0.08]))) this.shape(posts, this.lighting, 0.4) const S = Array.from({ length: 25 }, (_, i) => -1.05 + (i / 24) * 2.1) this.polygon([...S.map((s) => [(bx + s * span) * H, (deck(s) - 0.004) * H] as const), ...S.toReversed().map((s) => [(bx + s * span) * H, deck(s) * H] as const)], this.paint([0.42, 0.3, 0.2])) this.polygon([...S.map((s) => [(bx + s * span) * H, deck(s) * H] as const), ...S.toReversed().map((s) => [(bx + s * span) * H, (deck(s) + 0.012) * H] as const)], red) this.polygon([...S.map((s) => [(bx + s * span) * H, (deck(s) + 0.012) * H] as const), ...S.toReversed().map((s) => [(bx + s * span) * H, (deck(s) + 0.016) * H] as const)], shadow) const rail: Part[] = [] - const r = 0.0022 * H + const r = this.thick(0.0022, 0.55) for (let i = 0; i < 9; i++) { const s = -0.95 + (i / 8) * 1.9 rail.push(cap((bx + s * span) * H, deck(s) * H, (bx + s * span) * H, (deck(s) - 0.026) * H, r, r, red))