diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c067710..18db32f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,8 +132,13 @@ details), and values in between soften it. 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. -- `makeClouds(count, puffy, y0, y1)`, `driftClouds(clouds, A, dt)` and `paintClouds(hdr, W, H, clouds, top, bottom, - alpha, puffy)` give puffy cumulus or thin streaks, shaded from `top` to `bottom`. +- `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 + backlit with glowing edges. Pass no orb under an overcast deck to light them evenly from above. Each cloud's shape + is built once per screen size, so drawing them each frame is cheap. +- `makeShadows(count, y0, y1)` and `paintShadows(hdr, W, H, shadows, ground, orb)` drift soft cloud shadows across + the ground below `ground` under a high sun; `paintHaze(hdr, W, H, y0, y1, color, alpha)` paints a static haze band. `src/math.ts` has `TAU`, `lerp`, `clamp`, `smoothstep`, `rand`, stable hashes (`hash`, `hash2`, `hashString`), smooth noise (`noise1`, `noise2`, `fbm1`, `fbm2`), `hsv` and the `RGB` type. diff --git a/src/api.ts b/src/api.ts index 1815efa..00948c6 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,7 +3,7 @@ export { Canvas, cap, ell, type Lighting, type Part } from "./canvas" export { eggWait } from "./egg" export { bird, flyAway, startle, type Flier } from "./flock" export { TAU, clamp, fbm1, fbm2, hash, hash2, hashString, hsv, lerp, noise1, noise2, rand, smoothstep, type RGB } from "./math" -export { STAR_TINTS, driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "./sky" +export { STAR_TINTS, driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintHaze, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type CloudKind, type Orb, type Star } from "./sky" export { ACTIVITIES, AGENT_EVENTS, diff --git a/src/sky.ts b/src/sky.ts index fa2185a..6771848 100644 --- a/src/sky.ts +++ b/src/sky.ts @@ -1,5 +1,5 @@ // Sky pieces shared by outdoor wallpapers: a depth-graded sky with a sun or moon, stars and drifting clouds. -import { TAU, fbm1, lerp, smoothstep, type RGB } from "./math" +import { TAU, clamp, fbm1, fbm2, lerp, rand, smoothstep, type RGB } from "./math" // The sun or moon: x as a fraction of the width, y and r as fractions of the height. export interface Orb { @@ -29,13 +29,6 @@ export interface Star { tint: RGB } -export interface Cloud { - x: number - y: number - speed: number - puffs: { dx: number; dy: number; rx: number; ry: number }[] -} - // 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) { @@ -86,56 +79,324 @@ export function paintStars(hdr: Float32Array, W: number, H: number, stars: Star[ } } -// Puffy cumulus (rounded, lit from above) or thin streaks, scattered between y0 and y1. -export function makeClouds(count: number, puffy: boolean, y0: number, y1: number): Cloud[] { - return Array.from({ length: count }, () => ({ - x: Math.random() * 2, - y: y0 + Math.random() * (y1 - y0), - speed: 0.003 + Math.random() * 0.004, - puffs: puffy - ? Array.from({ length: 6 }, (_, i) => ({ dx: (i / 5 - 0.5) * 0.16 + (Math.random() - 0.5) * 0.03, dy: -Math.sin((i / 5) * Math.PI) * 0.02 + (Math.random() - 0.5) * 0.01, rx: 0.03 + Math.random() * 0.025, ry: 0.022 + Math.random() * 0.014 })) - : Array.from({ length: 5 }, () => ({ dx: (Math.random() - 0.5) * 0.22, dy: (Math.random() - 0.5) * 0.015, rx: 0.06 + Math.random() * 0.08, ry: 0.008 + Math.random() * 0.01 })), - })) +// Puffs heaped into rounded cumulus with flat bases, taller towering cumulus that stand on y, high cirrus streaks with +// hooked ends, soft wisps, low stratus layers, and billowing volcanic ash. +export type CloudKind = "cumulus" | "towering" | "cirrus" | "wisp" | "stratus" | "ash" + +export interface Cloud { + x: number + y: number + speed: number + kind: CloudKind | "shadow" + // Half the width in screen heights, so the cloud wraps around only once it is off screen. + w: number + // Puffs (x, y, radius r) or strands (x, y, half-length r, half-thickness s, tilt t) relative to the cloud's position, + // in screen heights, and the flat base puffs are cut off at. + parts: { x: number; y: number; r: number; s: number; t: number }[] + base: number + seed: number + sprite?: Sprite +} + +// A cloud's shape, built once per screen size: opacity, surface normal, thickness (0 at the edge, 1 deep inside) and +// how much its edge glows when backlit, per pixel. (x0, y0) is its top-left pixel relative to the cloud's position and +// cy its middle in screen heights. +interface Sprite { + H: number + w: number + h: number + x0: number + y0: number + cy: number + thin: number + a: Float32Array + nx: Float32Array + ny: Float32Array + nz: Float32Array + d: Float32Array + e: Float32Array +} + +// Opacity ramps over edge (field values) up to alpha; thin clouds are lit nearly flat; size is the rough half-width. +const KINDS: Record = { + cumulus: { size: [0.08, 0.13], speed: [0.003, 0.007], edge: [0.02, 0.28], alpha: 1, thin: 0 }, + towering: { size: [0.09, 0.14], speed: [0, 0], edge: [0.02, 0.28], alpha: 0.85, thin: 0 }, + ash: { size: [0.1, 0.16], speed: [0.004, 0.007], edge: [0, 0.6], alpha: 0.8, thin: 0 }, + cirrus: { size: [0.14, 0.24], speed: [0.005, 0.009], edge: [0.05, 0.8], alpha: 0.9, thin: 0.7 }, + wisp: { size: [0.12, 0.2], speed: [0.003, 0.006], edge: [0.04, 0.85], alpha: 0.75, thin: 0.6 }, + stratus: { size: [0.25, 0.45], speed: [0.002, 0.004], edge: [0.12, 0.75], alpha: 0.75, thin: 0.5 }, + shadow: { size: [0.2, 0.45], speed: [0.004, 0.01], edge: [0, 0.8], alpha: 1, thin: 0 }, +} + +export function makeClouds(count: number, kind: CloudKind, y0: number, y1: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const size = rand(KINDS[kind].size[0], KINDS[kind].size[1]) + const [parts, base] = shapeParts(kind, size) + return { x: ((i + Math.random() * 0.8) / count) * 2, y: y0 + Math.random() * (y1 - y0), speed: rand(KINDS[kind].speed[0], KINDS[kind].speed[1]), kind, w: halfWidth(parts), parts, base, seed: Math.random() * 100 } + }) +} + +// Soft shadows of clouds drifting across the ground between y0 and y1, flatter toward the horizon and bigger and +// faster nearer. +export function makeShadows(count: number, y0: number, y1: number): Cloud[] { + return Array.from({ length: count }, (_, i) => { + const near = (i + Math.random()) / count + const w = lerp(KINDS.shadow.size[0], KINDS.shadow.size[1], near) + const parts = [{ x: 0, y: 0, r: w, s: w * (0.12 + 0.12 * near), t: 0 }] + return { x: Math.random() * 2, y: lerp(y0, y1, near), speed: lerp(KINDS.shadow.speed[0], KINDS.shadow.speed[1], near), kind: "shadow" as const, w, parts, base: Infinity, seed: Math.random() * 100 } + }) } export function driftClouds(clouds: Cloud[], A: number, dt: number) { for (const c of clouds) { c.x += c.speed * dt - if (c.x > A + 0.4) c.x = -0.4 + if (c.x > A + c.w) c.x = -c.w } } // Low, heavy clouds that gather after the agent fails: darker versions of the scene's own cloud colors, faded in by // gloom (0 to 1). They drift like the others, so the caller moves them with driftClouds. export function makeStorm(): Cloud[] { - return makeClouds(8, true, 0.04, 0.26) + return makeClouds(8, "cumulus", 0.04, 0.26) } export function paintStorm(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, gloom: number) { if (gloom <= 0) return - paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8, true) -} - -// Clouds shaded from top to bottom; puffy ones are nearly opaque, streaks are thin and soft. -export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, puffy: boolean) { - for (const c of clouds) - for (const p of c.puffs) { - const cx = (c.x + p.dx) * H - const cy = (c.y + p.dy) * H - const rx = p.rx * H - const ry = p.ry * H - for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) - for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { - const dx = (x + 0.5 - cx) / rx - const dy = (y + 0.5 - cy) / ry - const q = dx * dx + dy * dy - if (q >= 1) continue - const under = smoothstep(-1, 1, dy) - const o = (y * W + x) * 3 - const a = (puffy ? Math.min(1, (1 - q) * 3) : (1 - q) ** 1.5) * alpha - hdr[o] += (lerp(top[0], bottom[0], under) - hdr[o]) * a - hdr[o + 1] += (lerp(top[1], bottom[1], under) - hdr[o + 1]) * a - hdr[o + 2] += (lerp(top[2], bottom[2], under) - hdr[o + 2]) * a - } + paintClouds(hdr, W, H, clouds, [top[0] * 0.45, top[1] * 0.45, top[2] * 0.5], [bottom[0] * 0.4, bottom[1] * 0.4, bottom[2] * 0.45], smoothstep(0, 1, gloom) * 0.8) +} + +// Clouds lit by the sun or moon: the brighter of top and bottom is the side facing the light, the darker the side away +// from it. Near the orb they are backlit, with darker cores and glowing edges (gold near the moon, the clouds' own tint +// elsewhere at night). Without an orb, under an overcast deck, they are lit evenly from above. +export function paintClouds(hdr: Float32Array, W: number, H: number, clouds: Cloud[], top: RGB, bottom: RGB, alpha: number, orb?: Orb) { + const [lit, shade] = luma(top) >= luma(bottom) ? [top, bottom] : [bottom, top] + const low = !!orb && !orb.moon && orb.y > 0.3 + // How much light comes from the open sky above rather than from the orb, and how much every edge facing it glows. + const sky = !orb ? 1 : orb.moon ? 0.25 : low ? 0 : 0.35 + const edge = !orb ? 0 : orb.moon ? 0.6 : low ? 0.3 : 0.15 + const k = !orb ? 0 : orb.moon ? 1.6 : low ? 1.4 : 0.6 + for (const c of clouds) { + const s = spriteFor(c, H) + const dx = orb ? orb.x * (W / H) - c.x : 0 + const dy = orb ? orb.y - c.y - s.cy : -1 + const dist = Math.hypot(dx, dy) || 1 + const back = orb ? Math.exp(-dist / 0.3) : 0 + const glow = orb?.glow ?? lit + const m = orb?.moon ? back : 1 + const rim: RGB = [lerp(lit[0] * 2.5, glow[0] * k, m), lerp(lit[1] * 2.5, glow[1] * k, m), lerp(lit[2] * 2.5, glow[2] * k, m)] + drawCloud(hdr, W, H, s, c.x * H, c.y * H, lit, shade, rim, dx / dist, dy / dist, back, edge, sky, alpha) + } +} + +// Darkens the ground under each shadow, below ground (a fraction of the height), and only under a high sun: pass no orb +// under an overcast deck. +export function paintShadows(hdr: Float32Array, W: number, H: number, shadows: Cloud[], ground: number, orb?: Orb) { + if (!orb || orb.moon || orb.y > 0.3) return + for (const c of shadows) drawShadow(hdr, W, H, spriteFor(c, H), c.x * H, c.y * H, ground * H) +} + +// A static band of haze between y0 and y1, thickest low down, in long soft streaks; paint it into the background. +export function paintHaze(hdr: Float32Array, W: number, H: number, y0: number, y1: number, color: RGB, alpha: number) { + for (let y = Math.max(0, Math.floor(y0 * H)); y < Math.min(H, Math.ceil(y1 * H)); y++) { + const v = (y / H - y0) / (y1 - y0) + const band = smoothstep(0, 1, v) * (1 - smoothstep(0.92, 1, v) * 0.5) * alpha + for (let x = 0; x < W; x++) { + const a = Math.min(1, band * (0.55 + 0.9 * fbm2((x / H) * 2.5, (y / H) * 18, 3))) + const o = (y * W + x) * 3 + hdr[o] += (color[0] - hdr[o]) * a + hdr[o + 1] += (color[1] - hdr[o + 1]) * a + hdr[o + 2] += (color[2] - hdr[o + 2]) * a } + } +} + +function luma(c: RGB) { + return c[0] * 0.2126 + c[1] * 0.7152 + c[2] * 0.0722 +} + +function shapeParts(kind: CloudKind, size: number): [Cloud["parts"], number] { + const part = (x: number, y: number, r: number, s = 0, t = 0) => ({ x, y, r, s, t }) + if (kind === "cumulus") { + // A row of puffs, biggest in the middle, heaped higher on top and cut off flat along the base. + const h = size * rand(0.6, 0.8) + const n = 5 + Math.floor(Math.random() * 3) + const row = Array.from({ length: n }, (_, i) => { + const f = (i / (n - 1)) * 2 - 1 + const r = h * (0.6 - 0.3 * Math.abs(f)) * rand(0.7, 1.2) + return part(f * size * 0.7 + rand(-0.06, 0.06) * size, h * 0.35 - r * rand(0.5, 0.95), r) + }) + const heap = Array.from({ length: 2 + Math.floor(Math.random() * 2) }, () => part(rand(-0.4, 0.4) * size, -h * rand(0.35, 0.55), h * rand(0.3, 0.42))) + return [[...row, ...heap], h * 0.35] + } + if (kind === "towering") { + // A broad foot on the horizon under a leaning column of puffs that narrows as it climbs. + const h = size * rand(1.4, 1.9) + const lean = rand(-0.25, 0.25) * size + const foot = Array.from({ length: 5 }, (_, i) => part(((i / 4) * 2 - 1) * size * 0.72 + rand(-0.05, 0.05) * size, -size * rand(0.1, 0.2), size * rand(0.26, 0.36))) + const column = Array.from({ length: 9 }, (_, i) => { + const f = (i + 1) / 9 + const r = size * (0.5 - 0.22 * f) * rand(0.8, 1.1) + return part(lean * f + rand(-0.3, 0.3) * size * (1 - 0.5 * f), -h * f * 0.75, r) + }) + return [[...foot, ...column], 0] + } + // Ash spreads out downwind in a long, low smear of small billows. + if (kind === "ash") return [Array.from({ length: 11 }, () => part(rand(-0.8, 0.8) * size, rand(-0.12, 0.12) * size, size * rand(0.16, 0.3))), Infinity] + if (kind === "cirrus") return [Array.from({ length: 3 + Math.floor(Math.random() * 2) }, () => part(rand(-0.35, 0.35) * size, rand(-0.03, 0.03), size * rand(0.45, 0.7), rand(0.007, 0.012), rand(-0.1, 0.02))), Infinity] + if (kind === "wisp") return [Array.from({ length: 2 + Math.floor(Math.random() * 2) }, () => part(rand(-0.3, 0.3) * size, rand(-0.012, 0.012), size * rand(0.55, 0.85), rand(0.007, 0.012), rand(-0.05, 0.05))), Infinity] + return [Array.from({ length: 3 }, () => part(rand(-0.25, 0.25) * size, rand(-0.01, 0.01), size * rand(0.65, 0.9), rand(0.013, 0.022))), Infinity] +} + +function halfWidth(parts: Cloud["parts"]) { + return parts.reduce((m, p) => Math.max(m, Math.abs(p.x) + p.r * 1.12), 0) +} + +function spriteFor(c: Cloud, H: number) { + if (c.sprite?.H !== H) c.sprite = buildSprite(c, H) + return c.sprite +} + +function buildSprite(c: Cloud, H: number): Sprite { + const kind = KINDS[c.kind] + const puffy = c.kind === "cumulus" || c.kind === "towering" || c.kind === "ash" + // Noise can push a puff's edge out a little past its radius. + const grow = puffy ? 1.12 : 1 + const x0 = Math.floor((-c.w - 0.01) * H) + const top = c.parts.reduce((m, p) => Math.min(m, p.y - (puffy ? p.r * grow : p.s * 4 + Math.abs(p.t) * p.r + (c.kind === "cirrus" ? 0.02 : 0))), 0) + const bottom = c.parts.reduce((m, p) => Math.max(m, Math.min(c.base, p.y + (puffy ? p.r * grow : p.s * 4 + Math.abs(p.t) * p.r))), 0) + const y0 = Math.floor(top * H) - 1 + const w = Math.ceil((c.w + 0.01) * H) - x0 + const h = Math.ceil(bottom * H) + 1 - y0 + const n = w * h + const field = new Float32Array(n) + const a = new Float32Array(n) + const d = new Float32Array(n) + const [lo, hi] = kind.edge + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + const u = (x0 + i + 0.5) / H + const v = (y0 + j + 0.5) / H + const f = shapeAt(c, u, v, puffy) + const k = j * w + i + field[k] = f + a[k] = smoothstep(lo, hi, f) * kind.alpha + d[k] = smoothstep(lo, 1, f) + } + // Normals from the blurred field, so each puff reads as a dome. + const r = Math.max(1, Math.round(H * 0.01)) + const blurred = boxBlur(boxBlur(field, w, h, r, 1, 0), w, h, r, 0, 1) + const nx = new Float32Array(n) + const ny = new Float32Array(n) + const nz = new Float32Array(n) + const e = new Float32Array(n) + const scale = H * 0.02 + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + const k = j * w + i + const gx = -(blurred[j * w + Math.min(w - 1, i + 1)] - blurred[j * w + Math.max(0, i - 1)]) * scale + const gy = -(blurred[Math.min(h - 1, j + 1) * w + i] - blurred[Math.max(0, j - 1) * w + i]) * scale + const l = Math.hypot(gx, gy, 1) + nx[k] = gx / l + ny[k] = gy / l + nz[k] = 1 / l + e[k] = (1 - d[k]) ** 2 * Math.sqrt(a[k]) + } + return { H, w, h, x0, y0, cy: (y0 + h / 2) / H, thin: kind.thin, a, nx, ny, nz, d, e } +} + +// The cloud's density at (u, v) relative to its position: the strongest puff or strand, broken up by noise at about the +// size of a character cell so edges fray softly instead of tracing ellipses. +function shapeAt(c: Cloud, u: number, v: number, puffy: boolean) { + let f = -1 + for (const p of c.parts) { + if (puffy) { + f = Math.max(f, 1 - ((u - p.x) ** 2 + (v - p.y) ** 2) / (p.r * p.r)) + continue + } + const along = (u - p.x) / p.r + if (along <= -1 || along >= 1) continue + // Cirrus curl up at the downwind end into hooks and fan out toward the other; stratus swell and thin along their + // length, with ragged ends. + const cirrus = c.kind === "cirrus" + const dv = v - p.y - p.t * (u - p.x) + (cirrus ? 0.02 * Math.max(0, along) ** 3 : 0) + const s = p.s * (cirrus ? 1.3 - 0.6 * along : 0.5 + 1.1 * fbm1(u * 6 + p.x * 50, 3)) + const ends = 1 - along * along + (c.kind === "stratus" ? (fbm1(v * 60 + p.y * 90, 2) - 0.45) * 0.8 : 0) + f = Math.max(f, ends * Math.exp(-((dv / s) ** 2))) + } + const seed = c.seed + // Fraying only near the edge keeps the insides solid. + const fray = (fbm2(u * 28 + seed, v * 28 + seed * 0.7, 3) - 0.44) * 2.4 * (1 - clamp(f, 0, 1)) + const base = c.base + (fbm1(u * 40 + seed, 3) - 0.5) * 0.008 + if (c.kind === "cumulus") return (f + fray) * smoothstep(base + 0.004, base - 0.01, v) + if (c.kind === "towering") return (f + fray * 0.8) * smoothstep(0.002, -0.008, v) + if (c.kind === "ash") return f + fray * 1.2 + // Fibers run along cirrus and wisps. + if (c.kind === "cirrus") return f * clamp((fbm2(u * 5 + seed, v * 70, 3) - 0.25) * 2.6, 0, 1.3) + if (c.kind === "wisp") return f * (0.55 + 0.9 * fbm2(u * 5 + seed, v * 45, 3)) + if (c.kind === "stratus") return f + fray * smoothstep(0, 0.2, f) + return f * (0.6 + 0.8 * fbm2(u * 6 + seed, v * 18, 2)) +} + +// A box blur of radius r along (dx, dy): (1, 0) for rows, (0, 1) for columns. +function boxBlur(src: Float32Array, w: number, h: number, r: number, dx: number, dy: number) { + const out = new Float32Array(src.length) + for (let j = 0; j < h; j++) + for (let i = 0; i < w; i++) { + let sum = 0 + for (let t = -r; t <= r; t++) sum += src[clamp(j + t * dy, 0, h - 1) * w + clamp(i + t * dx, 0, w - 1)] + out[j * w + i] = sum / (2 * r + 1) + } + return out +} + +// One cloud's sprite at (x, y) in pixels, sliding smoothly between pixel columns. lx, ly point toward the orb; back is +// how far the cloud is backlit by it and edge how much every edge facing it glows anyway. +function drawCloud(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number, lit: RGB, shade: RGB, rim: RGB, lx: number, ly: number, back: number, edge: number, sky: number, alpha: number) { + const l = Math.hypot(lx, ly, 0.4) + const Lx = lx / l + const Ly = ly / l + const Lz = 0.4 / l + const px = x + s.x0 + const X0 = Math.floor(px) + const f = px - X0 + const Y0 = Math.round(y) + s.y0 + const sw = s.w + for (let j = Math.max(0, -Y0); j < Math.min(s.h, H - Y0); j++) + for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { + const k = j * sw + i + const a = ((i < sw ? s.a[k] : 0) * (1 - f) + (i > 0 ? s.a[k - 1] : 0) * f) * alpha + if (a < 0.003) continue + const q = i < sw ? k : k - 1 + const nx = s.nx[q] + const ny = s.ny[q] + const light = (Math.max(0, nx * Lx + ny * Ly + s.nz[q] * Lz) * (1 - sky) + (0.5 - 0.5 * ny) * sky) * (1 - 0.5 * back * s.d[q]) + const t = light + (1 - light) * s.thin + const g = (back + edge) * s.e[q] * Math.max(0, nx * lx + ny * ly) * alpha + const o = ((Y0 + j) * W + X0 + i) * 3 + hdr[o] += (shade[0] + (lit[0] - shade[0]) * t - hdr[o]) * a + rim[0] * g + hdr[o + 1] += (shade[1] + (lit[1] - shade[1]) * t - hdr[o + 1]) * a + rim[1] * g + hdr[o + 2] += (shade[2] + (lit[2] - shade[2]) * t - hdr[o + 2]) * a + rim[2] * g + } +} + +function drawShadow(hdr: Float32Array, W: number, H: number, s: Sprite, x: number, y: number, ground: number) { + const px = x + s.x0 + const X0 = Math.floor(px) + const f = px - X0 + const Y0 = Math.round(y) + s.y0 + const sw = s.w + const fade = H * 0.03 + for (let j = Math.max(0, -Y0, Math.floor(ground) - Y0); j < Math.min(s.h, H - Y0); j++) { + const row = clamp((Y0 + j - ground) / fade, 0, 1) * 0.38 + for (let i = Math.max(0, -X0); i <= Math.min(sw, W - 1 - X0); i++) { + const k = j * sw + i + const a = ((i < sw ? s.a[k] : 0) * (1 - f) + (i > 0 ? s.a[k - 1] : 0) * f) * row + if (a < 0.003) continue + // Shade is lit by the blue sky, so it darkens reds most. + const o = ((Y0 + j) * W + X0 + i) * 3 + hdr[o] *= 1 - a + hdr[o + 1] *= 1 - a * 0.88 + hdr[o + 2] *= 1 - a * 0.62 + } + } } diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f977204..bd9692d 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -2,7 +2,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -22,7 +22,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } sea: [far: RGB, near: RGB] sand: [dry: RGB, wet: RGB] foam: RGB @@ -42,7 +42,7 @@ const LOOKS: Record = { ], orb: { x: 0.18, y: 0.14, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.68, 0.8], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.68, 0.8], alpha: 0.9 }, sea: [ [0.12, 0.42, 0.62], [0.08, 0.6, 0.62], @@ -67,7 +67,7 @@ const LOOKS: Record = { ], orb: { x: 0.55, y: 0.475, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.8 }, sea: [ [0.55, 0.25, 0.18], [0.12, 0.07, 0.12], @@ -91,7 +91,7 @@ const LOOKS: Record = { ], orb: { x: 0.7, y: 0.16, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, sea: [ [0.03, 0.06, 0.15], [0.01, 0.03, 0.07], @@ -166,6 +166,9 @@ class Beach extends Canvas { 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. + private towers = makeClouds(3, "towering", HORIZON, HORIZON) + private shadows = makeShadows(3, HORIZON + 0.06, 0.95) private storm = makeStorm() private palms: Palm[] = [] private boat = { x: -9, dir: 1, next: 14 } @@ -192,7 +195,7 @@ class Beach extends Canvas { this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.15 } : look if (this.season === "summer" && !this.look.night) this.swimmers = Array.from({ length: { calm: 1, lively: 3, teeming: 5 }[settings.activity] }, (_, i) => ({ x: 0.25 + i * 0.13 + hash(i) * 0.05, y: 0.69 + hash(i * 3.3) * 0.03, phase: i * 1.9 })) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.08, 0.3) + this.clouds = makeClouds(4, "cumulus", 0.07, 0.24) if (settings.activity !== "calm") this.dolphins = [0, 1].map((i) => ({ t: -1, x: 0, y: 0, dir: 1, wait: 4 + i * 9 })) } @@ -202,6 +205,7 @@ class Beach extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) @@ -250,9 +254,11 @@ class Beach extends Canvas { this.hdr.set(this.background) 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) 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.look.clouds.puffy) + 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) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawSea() + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.01, orb) for (const s of this.swimmers) this.drawSwimmer(s) this.drawBoat() for (const d of this.dolphins) this.drawDolphin(d) @@ -282,6 +288,7 @@ class Beach extends Canvas { 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) this.weather.cover(this.hdr, W, H) + if (!this.weather.covered) paintClouds(this.hdr, W, H, this.towers, look.clouds.top, look.clouds.bottom, look.clouds.alpha, look.orb) // A small island on the horizon. const ix = 0.16 * A this.polygon(Array.from({ length: 21 }, (_, i) => [(ix + Math.cos((i / 20) * Math.PI) * 0.08) * H, (HORIZON - Math.sin((i / 20) * Math.PI) * 0.025) * H] as const), this.paint([0.2, 0.35, 0.22])) diff --git a/wallpapers/city.ts b/wallpapers/city.ts index c2fb6c8..536800e 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -2,7 +2,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintHaze, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -27,7 +27,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } // The far skyline's hazy color and the river from far to near. haze: RGB river: [far: RGB, near: RGB] @@ -54,7 +54,7 @@ const LOOKS: Record = { ], orb: { x: 0.14, y: 0.11, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, haze: [0.55, 0.65, 0.78], river: [ [0.2, 0.36, 0.48], @@ -78,7 +78,7 @@ const LOOKS: Record = { ], orb: { x: 0.7, y: 0.5, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 20, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, haze: [0.36, 0.14, 0.16], river: [ [0.4, 0.16, 0.14], @@ -101,7 +101,7 @@ const LOOKS: Record = { ], orb: { x: 0.82, y: 0.12, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 50, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.08, 0.05, 0.2], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.08, 0.05, 0.2], alpha: 0.4 }, haze: [0.035, 0.03, 0.08], river: [ [0.03, 0.025, 0.08], @@ -249,7 +249,7 @@ class City extends Canvas { this.activity = settings.activity this.look = LOOKS[settings.time] this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 4, this.look.clouds.puffy, 0.04, 0.1) + this.clouds = makeClouds(2, "stratus", 0.05, 0.12) this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, BANK) this.wet = settings.weather ? settings.weather === "rain" || settings.weather === "snow" : this.look.night && settings.activity === "teeming" if (settings.activity !== "teeming") return @@ -296,7 +296,7 @@ class City extends Canvas { this.hdr.set(this.background) if (!this.weather.covered) paintStars(this.hdr, W, H, this.visibleStars, this.time, BANK, 0.4) const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] - paintClouds(this.hdr, W, H, this.clouds, top, bottom, look.clouds.alpha, look.clouds.puffy) + 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) if (this.hero.t >= 0) this.drawHero() this.drawBlimp() @@ -341,6 +341,9 @@ class City extends Canvas { this.paintTower(this.tower(x, w, 0.06 + hash(i * 3.1 + 0.7) ** 1.5 * 0.17, i + 500, true)) x += w * (0.7 + hash(i * 5.3) * 0.4) } + // City haze glowing with the horizon's light over the far towers, which the downtown skyline stands in front of. + const horizon = look.sky[look.sky.length - 1][1] + paintHaze(this.hdr, W, H, BANK - 0.3, BANK, horizon, 0.7) const near: Tower[] = [] for (let x = -0.02, i = 0; x < A + 0.02; i++) { const w = 0.05 + hash(i * 2.3 + 0.1) * 0.055 @@ -353,6 +356,7 @@ class City extends Canvas { tallest[0].crown = 3 tallest[1].crown = 2 for (const t of near) this.paintTower(t) + paintHaze(this.hdr, W, H, BANK - 0.1, BANK, horizon, 0.25) this.visibleStars = this.stars.filter((s) => { const o = (Math.floor(s.y * H) * W + Math.floor(s.x * W)) * 3 return this.hdr[o] === sky[o] && this.hdr[o + 1] === sky[o + 1] && this.hdr[o + 2] === sky[o + 2] diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 570ddf3..6f80ad8 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -2,7 +2,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, fbm2, hash, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeStorm, paintStorm } from "../src/sky" +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" @@ -23,7 +23,7 @@ interface Look { orb: { x: number; y: number; r: number; core: RGB; glow: RGB; near: number; wide: number; moon: boolean } stars: number milkyWay: boolean - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } farMesa: RGB nearMesa: RGB floor: [RGB, RGB] @@ -62,7 +62,7 @@ const LOOKS: Record = { orb: { x: 0.78, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.15, moon: false }, stars: 0, milkyWay: false, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.55, 0.62, 0.75], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.55, 0.62, 0.75], alpha: 0.9 }, farMesa: [0.62, 0.36, 0.26], nearMesa: [0.62, 0.27, 0.13], floor: [ @@ -106,7 +106,7 @@ const LOOKS: Record = { orb: { x: 0.6, y: 0.53, r: 0.065, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25, moon: false }, stars: 45, milkyWay: false, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.75, 0.24, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.75, 0.24, 0.2], alpha: 0.55 }, farMesa: [0.07, 0.03, 0.06], nearMesa: [0.07, 0.025, 0.04], floor: [ @@ -149,7 +149,7 @@ const LOOKS: Record = { orb: { x: 0.3, y: 0.16, r: 0.035, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 160, milkyWay: true, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.5 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.5 }, farMesa: [0.03, 0.04, 0.08], nearMesa: [0.012, 0.016, 0.035], floor: [ @@ -214,13 +214,6 @@ interface Mesa { slope: number } -interface Cloud { - x: number - y: number - speed: number - puffs: { dx: number; dy: number; rx: number; ry: number }[] -} - interface Bird { angle: number speed: number @@ -290,14 +283,7 @@ class Desert extends Canvas { const look = this.look const top = look.milkyWay ? 0.5 : 0.3 this.stars = Array.from({ length: look.stars }, () => ({ 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)] })) - this.clouds = Array.from({ length: look.clouds.puffy ? 4 : 5 }, () => ({ - x: Math.random() * 2, - y: look.clouds.puffy ? 0.1 + Math.random() * 0.2 : 0.12 + Math.random() * 0.24, - speed: 0.003 + Math.random() * 0.004, - puffs: look.clouds.puffy - ? Array.from({ length: 6 }, (_, i) => ({ dx: (i / 5 - 0.5) * 0.16 + (Math.random() - 0.5) * 0.03, dy: -Math.sin((i / 5) * Math.PI) * 0.02 + (Math.random() - 0.5) * 0.01, rx: 0.03 + Math.random() * 0.025, ry: 0.022 + Math.random() * 0.014 })) - : Array.from({ length: 5 }, () => ({ dx: (Math.random() - 0.5) * 0.22, dy: (Math.random() - 0.5) * 0.015, rx: 0.06 + Math.random() * 0.08, ry: 0.008 + Math.random() * 0.01 })), - })) + this.clouds = makeClouds(5, "cirrus", 0.08, 0.32) if (settings.activity !== "calm") { this.bigBird = { angle: 0, speed: 0.5, cx: 0.55, cy: 0.2, rx: 0.3, ry: 0.05, phase: 0 } this.snake = { x: 0.5, y: 0.9, vx: 0, vy: 0, wx: 0.5, wy: 0.9, wanderT: 0, phase: 0, trail: [] } @@ -318,10 +304,7 @@ class Desert extends Canvas { d.y += Math.sin(this.time * 0.5 + d.s * 30) * 0.002 * dt if (d.x > this.A) d.x -= this.A } - for (const c of this.clouds) { - c.x += c.speed * dt - if (c.x > this.A + 0.4) c.x = -0.4 - } + driftClouds(this.clouds, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.stepTumbleweed(dt) @@ -340,7 +323,8 @@ class Desert extends Canvas { render() { this.hdr.set(this.background) if (!this.weather.covered) this.drawStars() - this.drawClouds() + 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) for (const b of this.flock) this.look.night ? this.drawBat(b) : this.drawBird(b, 0.3, "vulture") if (this.bigBird) this.drawBird(this.bigBird, 0.8, this.look.night ? "owl" : "eagle") @@ -732,34 +716,6 @@ class Desert extends Canvas { } } - // Clouds drifting slowly across: puffy and sunlit from above by day, thin streaks lit from below otherwise. - private drawClouds() { - const { H, W, hdr, look } = this - const { alpha, puffy } = look.clouds - const [top, bottom] = this.weather.scud ?? [look.clouds.top, look.clouds.bottom] - for (const c of this.clouds) - for (const p of c.puffs) { - const cx = (c.x + p.dx) * H - const cy = (c.y + p.dy) * H - const rx = p.rx * H - const ry = p.ry * H - const warm = puffy ? 1 : 0.6 + 0.6 * Math.exp(-Math.abs(cx - this.orbX) / (0.5 * H)) - for (let y = Math.max(0, Math.floor(cy - ry)); y <= Math.min(H - 1, Math.ceil(cy + ry)); y++) - for (let x = Math.max(0, Math.floor(cx - rx)); x <= Math.min(W - 1, Math.ceil(cx + rx)); x++) { - const dx = (x + 0.5 - cx) / rx - const dy = (y + 0.5 - cy) / ry - const q = dx * dx + dy * dy - if (q >= 1) continue - const under = smoothstep(-1, 1, dy) - const o = (y * W + x) * 3 - const a = (puffy ? Math.min(1, (1 - q) * 3) : (1 - q) ** 1.5) * alpha - hdr[o] += (lerp(top[0], bottom[0], under) * warm - hdr[o]) * a - hdr[o + 1] += (lerp(top[1], bottom[1], under) * warm - hdr[o + 1]) * a - hdr[o + 2] += (lerp(top[2], bottom[2], under) * warm - hdr[o + 2]) * a - } - } - } - // A soaring bird seen from the front, wings spread and banking as it circles. A bald eagle has a white head and // tail and fingered wingtips; a vulture is small and dark with lifted wingtips; an owl has broad rounded wings, a // round face with ear tufts, and glowing eyes. diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 868c944..122b025 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -1,7 +1,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -19,7 +19,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } hills: [far: RGB, near: RGB] tint: RGB windows: number @@ -37,7 +37,7 @@ const LOOKS: Record = { ], orb: { x: 0.82, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.9 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.9 }, hills: [ [0.42, 0.6, 0.38], [0.3, 0.55, 0.16], @@ -57,7 +57,7 @@ const LOOKS: Record = { ], orb: { x: 0.28, y: 0.5, r: 0.06, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 30, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.8 }, hills: [ [0.3, 0.12, 0.12], [0.08, 0.04, 0.03], @@ -76,7 +76,7 @@ const LOOKS: Record = { ], orb: { x: 0.74, y: 0.15, r: 0.032, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.45 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, hills: [ [0.02, 0.035, 0.07], [0.008, 0.016, 0.03], @@ -172,6 +172,7 @@ class Farm extends Canvas { private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] + private shadows = makeShadows(3, HORIZON + 0.08, 0.95) private storm = makeStorm() private nearTop = new Float32Array(0) private cows: Walker[] = [] @@ -203,7 +204,7 @@ class Farm extends Canvas { const colors: RGB[] = this.season === "spring" ? [[1, 0.78, 0.86], [0.95, 0.6, 0.72]] : [[0.9, 0.45, 0.1], [0.75, 0.2, 0.07], [0.95, 0.7, 0.18]] this.leaves = Array.from({ length: Math.round(drifting) }, (_, i) => ({ x: Math.random() * 2, y: 0.4 + Math.random() * 0.55, phase: Math.random() * TAU, color: colors[i % colors.length] })) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.08, 0.3) + this.clouds = makeClouds(5, "cumulus", 0.08, 0.3) const walker = (x: number, y: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, look: 0, moving: false }) if (settings.activity !== "calm") { this.cows = [walker(0.9, 0.8), walker(1.3, 0.84), ...(settings.activity === "teeming" ? [walker(1.1, 0.78)] : [])] @@ -220,6 +221,7 @@ class Farm extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) @@ -263,7 +265,9 @@ class Farm extends Canvas { this.hdr.set(this.background) 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) 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.look.clouds.puffy) + 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) + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.01, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawFan() this.drawTractor() diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d37610a..ea7f0a3 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -2,7 +2,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { bird, flyAway, startle, type Flier } from "../src/flock" import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -29,7 +29,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } ridge: RGB forest: RGB ground: [far: RGB, near: RGB] @@ -53,7 +53,7 @@ const LOOKS: Record = { ], orb: { x: 0.16, y: 0.13, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.05], bottom: [0.62, 0.66, 0.74], alpha: 0.85 }, + clouds: { top: [1.1, 1.1, 1.05], bottom: [0.62, 0.66, 0.74], alpha: 0.85 }, ridge: [0.42, 0.52, 0.6], forest: [0.14, 0.3, 0.13], ground: [ @@ -82,7 +82,7 @@ const LOOKS: Record = { ], orb: { x: 0.2, y: 0.48, r: 0.06, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.8 }, ridge: [0.32, 0.12, 0.14], forest: [0.07, 0.03, 0.04], ground: [ @@ -110,7 +110,7 @@ const LOOKS: Record = { ], orb: { x: 0.18, y: 0.15, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, ridge: [0.02, 0.03, 0.08], forest: [0.006, 0.012, 0.028], ground: [ @@ -171,6 +171,9 @@ class Prehistoric extends Canvas { private allStars: Star[] private stars: Star[] = [] private clouds: Cloud[] + // Ash clouds drift off downwind from the top of the plume. + private ash = makeClouds(3, "ash", 0.04, 0.11) + private shadows = makeShadows(3, HORIZON + 0.06, 0.95) private smoke = Array.from({ length: SMOKE_PUFFS }, (_, i) => ({ age: (i / SMOKE_PUFFS) * SMOKE_LIFE, seed: Math.random() * 100 })) // Lava pixels (hdr index, strength, height down the flow) that pulse each frame. private lava = { index: new Int32Array(0), k: new Float32Array(0), along: new Float32Array(0) } @@ -194,7 +197,7 @@ class Prehistoric extends Canvas { this.look = LOOKS[settings.time] this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) this.allStars = makeStars(this.look.stars, 0.5) - this.clouds = makeClouds(this.look.clouds.puffy ? 4 : 5, this.look.clouds.puffy, 0.06, 0.3) + this.clouds = makeClouds(3, "cumulus", 0.06, 0.28) if (settings.activity !== "teeming") return const walker = (x: number, y: number, size: number): Walker => ({ x, y, wx: x, wy: y, wanderT: 0, face: 1, phase: Math.random() * TAU, graze: 0, rest: 0, moving: false, size }) this.herd = [walker(0.3, 0.8, 1), walker(0.55, 0.84, 1.1), walker(0.42, 0.82, 0.65)] @@ -207,7 +210,12 @@ class Prehistoric extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) + for (const c of this.ash) { + c.x += c.speed * dt + if (c.x > this.A + c.w) c.x = this.plumeTop() + } this.stepGloom(dt) this.weather.step(dt) this.birds = flyAway(this.birds, dt, this.A) @@ -239,8 +247,14 @@ class Prehistoric extends Canvas { 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]) 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.look.clouds.puffy) + 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) + paintShadows(this.hdr, this.W, this.H, this.shadows, HORIZON + 0.02, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) + // Ash takes the plume's cooled color, warmed underneath by the lava, and fades in as it leaves the plume. + const [hot, cool, alpha] = this.look.smoke + const start = this.plumeTop() + for (const c of this.ash) paintClouds(this.hdr, this.W, this.H, [c], [cool[0] * 1.15, cool[1] * 1.15, cool[2] * 1.15], [lerp(cool[0], hot[0], 0.3) * 0.7, lerp(cool[1], hot[1], 0.3) * 0.7, lerp(cool[2], hot[2], 0.3) * 0.7], alpha * 1.3 * smoothstep(start, start + 0.25, c.x), orb) this.drawSmoke() if (this.activity !== "calm") { this.drawPterosaurs() @@ -277,6 +291,7 @@ class Prehistoric extends Canvas { 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) this.weather.cover(this.hdr, W, H) + for (const [i, c] of this.ash.entries()) c.x = lerp(this.plumeTop(), A + c.w, (i + 0.3) / this.ash.length) const vx = VENT_X * A const ridgeTop = new Float32Array(W) const volcanoTop = new Float32Array(W) @@ -524,6 +539,11 @@ class Prehistoric extends Canvas { } } + // Where the plume levels off and its smoke starts to drift away as ash, in screen heights. + private plumeTop() { + return VENT_X * this.A + 0.08 + } + // Smoke billows up from the crater and leans downwind, lit from below by the lava when it is fresh. private drawSmoke() { const { A, H, look } = this diff --git a/wallpapers/template.ts b/wallpapers/template.ts index 9e3aa7e..f1ed2fe 100644 --- a/wallpapers/template.ts +++ b/wallpapers/template.ts @@ -50,7 +50,7 @@ const LOOKS: Record = { ], orb: { x: 0.6, y: 0.58, r: 0.055, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 25, - clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.6 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.8 }, hills: [ [0.2, 0.08, 0.1], [0.07, 0.04, 0.05], @@ -68,7 +68,7 @@ const LOOKS: Record = { // A gold moon rather than a white one. orb: { x: 0.75, y: 0.16, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 140, - clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.7 }, hills: [ [0.03, 0.05, 0.13], [0.015, 0.03, 0.08], @@ -100,7 +100,7 @@ class Template extends Canvas { this.activity = settings.activity this.look = LOOKS[settings.time] this.stars = makeStars(this.look.stars, 0.55) - this.clouds = makeClouds(4, true, 0.08, 0.3) + this.clouds = makeClouds(4, "cumulus", 0.08, 0.3) } // Advances the scene by dt seconds. Clamp dt so a stalled frame doesn't make things jump. @@ -117,7 +117,7 @@ class Template extends Canvas { render() { this.hdr.set(this.background) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.55, 0.5) - paintClouds(this.hdr, this.W, this.H, this.clouds, this.look.clouds.top, this.look.clouds.bottom, this.look.clouds.alpha, true) + 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() if (this.activity === "teeming" && !this.look.night) this.drawBirds() diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..4855bfb 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -1,7 +1,7 @@ import { Canvas, cap, ell, type Lighting, type Part } from "../src/canvas" import { eggWait } from "../src/egg" import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src/math" -import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" +import { driftClouds, makeClouds, makeShadows, makeStars, makeStorm, paintClouds, paintShadows, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "../src/sky" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" @@ -22,7 +22,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } snow: RGB shade: RGB rock: RGB @@ -45,7 +45,7 @@ const LOOKS: Record = { ], orb: { x: 0.78, y: 0.22, r: 0.032, core: [4.5, 4.3, 3.8], glow: [1, 0.95, 0.85], near: 0.4, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.05, 1.08, 1.12], bottom: [0.62, 0.7, 0.82], alpha: 0.85 }, + clouds: { top: [1.05, 1.08, 1.12], bottom: [0.62, 0.7, 0.82], alpha: 0.85 }, snow: [0.8, 0.87, 0.95], shade: [0.55, 0.66, 0.85], rock: [0.35, 0.4, 0.5], @@ -67,7 +67,7 @@ const LOOKS: Record = { ], orb: { x: 0.62, y: 0.53, r: 0.055, core: [3, 1.6, 0.9], glow: [1, 0.45, 0.3], near: 0.5, wide: 0.22 }, stars: 25, - clouds: { puffy: false, top: [0.12, 0.05, 0.12], bottom: [0.85, 0.35, 0.3], alpha: 0.55 }, + clouds: { top: [0.12, 0.05, 0.12], bottom: [0.85, 0.35, 0.3], alpha: 0.55 }, snow: [0.72, 0.42, 0.48], shade: [0.3, 0.18, 0.38], rock: [0.25, 0.12, 0.2], @@ -88,7 +88,7 @@ const LOOKS: Record = { ], orb: { x: 0.2, y: 0.14, r: 0.03, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 120, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.35 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.35 }, snow: [0.05, 0.12, 0.3], shade: [0.02, 0.05, 0.14], rock: [0.02, 0.04, 0.09], @@ -136,6 +136,7 @@ class Tundra extends Canvas { private background = new Float32Array(0) private stars: Star[] private clouds: Cloud[] + private shadows = makeShadows(3, HORIZON + 0.08, 0.95) private storm = makeStorm() private flakes: { x: number; y: number; z: number; s: number }[] private owl = { x: -9, y: 0.25, dir: 1, next: 14, phase: 0 } @@ -153,7 +154,7 @@ class Tundra extends Canvas { this.activity = settings.activity this.look = LOOKS[settings.time] this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 4, this.look.clouds.puffy, 0.06, 0.28) + this.clouds = makeClouds(3, "stratus", 0.26, 0.4) // Its own gentle snowfall under a clear sky, unless weather is chosen. this.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, HORIZON) this.flakes = Array.from({ length: settings.weather ? 0 : { calm: 70, lively: 100, teeming: 130 }[settings.activity] }, () => ({ x: Math.random() * 4, y: Math.random(), z: Math.random(), s: Math.random() })) @@ -171,6 +172,7 @@ class Tundra extends Canvas { const cdt = dt * CREATURE_SPEED this.creatureTime += cdt driftClouds(this.clouds, this.A, dt) + driftClouds(this.shadows, this.A, dt) driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) @@ -224,7 +226,9 @@ class Tundra extends Canvas { 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.look.aurora > 0 && !this.weather.covered) this.drawAurora() 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.look.clouds.puffy) + 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) + paintShadows(this.hdr, this.W, this.H, this.shadows, 0.61, orb) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) if (this.owl.x > -5) this.drawOwl() if (this.yeti.t >= 0) this.drawYeti() diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index c1f2340..996d55b 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -24,7 +24,7 @@ interface Look { sky: [number, RGB][] orb: Orb stars: number - clouds: { puffy: boolean; top: RGB; bottom: RGB; alpha: number } + clouds: { top: RGB; bottom: RGB; alpha: number } hills: [far: RGB, near: RGB] snow: RGB mist: RGB @@ -51,7 +51,7 @@ const LOOKS: Record = { ], orb: { x: 0.4, y: 0.12, r: 0.035, core: [5, 4.6, 3.8], glow: [1, 0.95, 0.8], near: 0.45, wide: 0.12 }, stars: 0, - clouds: { puffy: true, top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, + clouds: { top: [1.1, 1.1, 1.1], bottom: [0.6, 0.66, 0.78], alpha: 0.85 }, hills: [ [0.42, 0.55, 0.72], [0.18, 0.36, 0.22], @@ -80,7 +80,7 @@ const LOOKS: Record = { ], orb: { x: 0.52, y: 0.375, r: 0.05, core: [3.2, 1.7, 0.6], glow: [1, 0.42, 0.14], near: 0.5, wide: 0.25 }, stars: 25, - clouds: { puffy: false, top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, + clouds: { top: [0.1, 0.035, 0.09], bottom: [0.8, 0.3, 0.2], alpha: 0.55 }, hills: [ [0.38, 0.13, 0.16], [0.1, 0.04, 0.05], @@ -108,7 +108,7 @@ const LOOKS: Record = { ], orb: { x: 0.6, y: 0.16, r: 0.032, core: [1, 0.72, 0.3], glow: [0.45, 0.3, 0.12], near: 0.2, wide: 0.08, moon: true }, stars: 130, - clouds: { puffy: false, top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, + clouds: { top: [0.015, 0.02, 0.05], bottom: [0.06, 0.1, 0.3], alpha: 0.4 }, hills: [ [0.025, 0.04, 0.1], [0.008, 0.016, 0.035], @@ -237,7 +237,7 @@ class Zen extends Canvas { this.season = settings.season ?? "spring" this.weather = new WeatherLayer(settings.weather ?? (this.season === "winter" ? "snow" : "clear"), settings.time, HORIZON, !settings.weather) this.stars = makeStars(this.look.stars, 0.45) - this.clouds = makeClouds(this.look.clouds.puffy ? 3 : 5, this.look.clouds.puffy, 0.06, 0.26) + this.clouds = makeClouds(4, "wisp", 0.06, 0.24) const count = { calm: 0, lively: 3, teeming: 6 }[settings.activity] this.koi = Array.from({ length: count }, (_, i) => ({ a: rand(0, TAU), r: 0.32 + (i % 3) * 0.16, dir: i === 4 ? -1 : 1, phase: rand(0, TAU), size: rand(0.85, 1.1), colors: KOI[i] })) const falling = this.season === "spring" || this.season === "autumn" @@ -297,7 +297,7 @@ class Zen extends Canvas { this.hdr.set(this.background) 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) 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.look.clouds.puffy) + 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) this.drawWater() for (const r of this.rings) this.drawRing(r)