From 894c1b5c76e1b10ae130d79cdf2980461a6bc78b Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 20:28:39 -0700 Subject: [PATCH 1/2] Add a shared wind with gusts that roll across every outdoor scene --- src/api.ts | 1 + src/wind.ts | 160 ++++++++++++++++++++++++++++++++++++++ wallpapers/beach.ts | 6 +- wallpapers/city.ts | 46 +++++++++-- wallpapers/desert.ts | 38 +++++++-- wallpapers/farm.ts | 51 ++++++++++-- wallpapers/jungle.ts | 38 ++++++++- wallpapers/ocean.ts | 13 +++- wallpapers/prehistoric.ts | 32 +++++--- wallpapers/tundra.ts | 8 +- wallpapers/zen.ts | 10 ++- 11 files changed, 363 insertions(+), 40 deletions(-) create mode 100644 src/wind.ts diff --git a/src/api.ts b/src/api.ts index 1815efa..a590af8 100644 --- a/src/api.ts +++ b/src/api.ts @@ -22,3 +22,4 @@ export { type Weather, } from "./wallpaper" export { WeatherLayer } from "./weather" +export { SwayLayer, gust, sway } from "./wind" diff --git a/src/wind.ts b/src/wind.ts new file mode 100644 index 0000000..1dbdb2c --- /dev/null +++ b/src/wind.ts @@ -0,0 +1,160 @@ +import { hash, smoothstep } from "./math" + +// One wind for the whole scene, so everything that sways moves together. Now and then a gust rolls across the screen +// from left to right and dies away; between gusts the air is calm. x is in screen heights from the left edge and t in +// scene seconds, so a scene passes its own time and every scene shares the same weather. + +// Scene seconds between chances of a gust, how fast one crosses the screen (screen heights per scene second), and how +// long it takes to build and to die away at any one spot. +const SLOT = 14 +const SPEED = 0.45 +const RISE = 1.1 +const FALL = 3.2 +// Lines the gusts up so one crosses in the first few seconds of a scene and another about 45 seconds in, inside the +// frames dev/check.ts measures and the README's GIFs. +const PHASE = 15.5 + +// How hard a gust is blowing at x: 0 in calm air, up to 1 at its height. +export function gust(x: number, t: number) { + const s = t - x / SPEED + PHASE + const n = Math.floor(s / SLOT) + // About a third of the chances pass without a gust, so they don't come like clockwork. + const strength = hash(n * 7.31 + 2.1) + if (strength < 0.3) return 0 + const u = s - n * SLOT - hash(n * 3.17 + 0.5) * (SLOT - RISE - FALL) + if (u <= 0 || u >= RISE + FALL) return 0 + return (u < RISE ? smoothstep(0, RISE, u) : 1 - smoothstep(RISE, RISE + FALL, u)) * (0.6 + 0.4 * (strength - 0.3) / 0.7) +} + +// How far a plant leans, in units of its own sway: the same gentle rocking as ever in calm air, then a lean downwind +// (positive x) with livelier rocking as a gust passes. phase keeps neighbors out of step and rate is how fast it rocks. +export function sway(x: number, t: number, phase = 0, rate = 0.7) { + const g = gust(x, t) + return Math.sin(t * rate + phase) * (1 + 0.6 * g) + g * (2.2 + 0.5 * Math.sin(t * rate * 2.7 + phase * 1.7)) +} + +// Plants painted once into a static background that still bend in the gusts: the pixels they cover shift downwind +// row by row, more toward the tips, over what was painted behind them. Call begin() in layout() before painting them, +// end() once they're painted, seal() at the end of layout() if anything was painted over them since, and draw() right +// after copying the background each frame. +export class SwayLayer { + private before = new Float32Array(0) + private x0 = 0 + private y0 = 0 + private w = 0 + private h = 0 + private reach = 0 + private bare = new Float32Array(0) + private layer = new Float32Array(0) + // 1 where the plants are, 2 where something since painted over them hides them, 0 elsewhere. + private cover = new Uint8Array(0) + private weight = new Float32Array(0) + private shift = new Float32Array(0) + + // dir -1 sends the gusts the other way, right to left, for a scene whose breeze already blows that way. + constructor(private readonly dir = 1) {} + + begin(hdr: Float32Array) { + this.before = hdr.slice() + } + + // reach is how far the tips move in the strongest gust, in pixels; weight(x, y) in pixels is how much of that a pixel + // moves, from 0 at the roots to 1 at the tips. + end(hdr: Float32Array, W: number, H: number, reach: number, weight: (x: number, y: number) => number) { + const before = this.before + this.before = new Float32Array(0) + let x0 = W + let y0 = H + let x1 = -1 + let y1 = -1 + for (let y = 0; y < H; y++) + for (let x = 0; x < W; x++) { + const o = (y * W + x) * 3 + if (hdr[o] === before[o] && hdr[o + 1] === before[o + 1] && hdr[o + 2] === before[o + 2]) continue + if (weight(x, y) <= 0) continue + x0 = Math.min(x0, x) + x1 = Math.max(x1, x) + y0 = Math.min(y0, y) + y1 = Math.max(y1, y) + } + this.w = 0 + if (x1 < 0) return + const pad = Math.ceil(reach) + 1 + this.x0 = Math.max(0, x0 - pad) + this.y0 = y0 + this.w = Math.min(W - 1, x1 + pad) - this.x0 + 1 + this.h = y1 - y0 + 1 + this.reach = reach + const n = this.w * this.h + this.bare = new Float32Array(n * 3) + this.layer = new Float32Array(n * 3) + this.cover = new Uint8Array(n) + this.weight = new Float32Array(n) + this.shift = new Float32Array(this.w) + for (let y = 0; y < this.h; y++) + for (let x = 0; x < this.w; x++) { + const i = y * this.w + x + const o = ((this.y0 + y) * W + this.x0 + x) * 3 + for (let c = 0; c < 3; c++) { + this.bare[i * 3 + c] = before[o + c] + this.layer[i * 3 + c] = hdr[o + c] + } + this.cover[i] = hdr[o] !== before[o] || hdr[o + 1] !== before[o + 1] || hdr[o + 2] !== before[o + 2] ? 1 : 0 + this.weight[i] = Math.max(0, weight(this.x0 + x, this.y0 + y)) + } + } + + // Pixels painted since end() stay put over the plants. + seal(hdr: Float32Array, W: number) { + for (let y = 0; y < this.h; y++) + for (let x = 0; x < this.w; x++) { + const i = y * this.w + x + const o = ((this.y0 + y) * W + this.x0 + x) * 3 + if (hdr[o] !== this.layer[i * 3] || hdr[o + 1] !== this.layer[i * 3 + 1] || hdr[o + 2] !== this.layer[i * 3 + 2]) this.cover[i] = 2 + } + } + + // How far the pixel at (x, y) was carried downwind in the last draw(), for things hung on the plants. + offset(x: number, y: number) { + const i = Math.round(x) - this.x0 + const j = Math.round(y) - this.y0 + if (i < 0 || j < 0 || i >= this.w || j >= this.h) return 0 + return this.shift[i] * this.weight[j * this.w + i] + } + + draw(hdr: Float32Array, W: number, H: number, t: number) { + if (!this.w) return + let any = false + for (let x = 0; x < this.w; x++) { + const u = (this.dir > 0 ? this.x0 + x : W - this.x0 - x) / H + const g = gust(u, t) + // A little flutter on top of the lean, so the tips don't move as one stiff sheet. + this.shift[x] = this.dir * g * this.reach * (0.85 + 0.25 * Math.sin(t * 2.3 + u * 9)) + if (g > 0) any = true + } + if (any) shear(hdr, W, this.x0, this.y0, this.w, this.h, this.bare, this.layer, this.cover, this.weight, this.shift) + } +} + +// Redraws the layer's region with each pixel taken from `shift * weight` pixels upwind, over what lies behind it. +function shear(hdr: Float32Array, W: number, x0: number, y0: number, w: number, h: number, bare: Float32Array, layer: Float32Array, cover: Uint8Array, weight: Float32Array, shift: Float32Array) { + for (let y = 0; y < h; y++) { + const row = y * w + for (let x = 0; x < w; x++) { + const i = row + x + if (cover[i] === 2) continue + const d = shift[x] * weight[i] + if (d < 0.02 && d > -0.02) continue + const sx = x - d + const xa = Math.floor(sx) + const f = sx - xa + const a = xa >= 0 && cover[row + xa] ? 1 - f : 0 + const b = xa + 1 < w && xa + 1 >= 0 && cover[row + xa + 1] ? f : 0 + if (a + b === 0 && cover[i] === 0) continue + const o = ((y0 + y) * W + x0 + x) * 3 + const k = 1 - a - b + const ia = (row + xa) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] = bare[i * 3 + c] * k + (a ? layer[ia + c] * a : 0) + (b ? layer[ia + 3 + c] * b : 0) + } + } +} diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f977204..f6cc01b 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -5,6 +5,7 @@ import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -418,11 +419,12 @@ class Beach extends Canvas { const [cx, cy] = [p.crown[0] * H, p.crown[1] * H] for (let i = 0; i < 8; i++) { const base = -Math.PI / 2 + ((i / 7) * 2 - 1) * 1.6 + p.lean * 0.15 - const sway = Math.sin(this.time * 0.8 + i * 1.3 + p.lean) * 0.06 + // Turned whichever way carries the tip downwind. + const bend = sway(p.crown[0], this.time, i * 1.3 + p.lean, 0.8) * 0.06 * (Math.sin(base) > 0 ? -1 : 1) const length = (0.17 + hash(i * 3.1 + p.lean) * 0.05) * H const parts: Part[] = [] let [x, y] = [cx, cy] - let angle = base + sway + let angle = base + bend for (let s = 0; s < 8; s++) { const q = s / 8 // Fronds pointing sideways droop more. diff --git a/wallpapers/city.ts b/wallpapers/city.ts index c2fb6c8..39e7ca1 100644 --- a/wallpapers/city.ts +++ b/wallpapers/city.ts @@ -5,6 +5,7 @@ import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -148,6 +149,10 @@ const NEON: RGB[] = [ [1.4, 0.95, 0.2], ] const CONCRETE: RGB = [0.58, 0.56, 0.53] +const FLAGS: RGB[] = [ + [0.8, 0.12, 0.1], + [0.12, 0.32, 0.75], +] const STEEL: RGB = [0.62, 0.17, 0.11] const HEADLIGHT: RGB = [1.4, 0.95, 0.3] const TAILLIGHT: RGB = [1.4, 0.08, 0.06] @@ -304,6 +309,7 @@ class City extends Canvas { this.drawLights() if (this.activity !== "calm") this.drawTrain() if (this.activity === "teeming") for (const b of this.boats) this.drawBoat(b) + this.drawPromenade() for (const c of this.cars) if (c.lane === 0) this.drawCar(c) for (const c of this.cars) if (c.lane === 1) this.drawCar(c) // The barrier sits in front of the near lane, so it is copied back over the cars. @@ -628,13 +634,6 @@ class City extends Canvas { const rail: Part[] = [cap(-1, (QUAY - 0.012) * H, A * H + 1, (QUAY - 0.012) * H, 0.0012 * H, 0.0012 * H, edge)] for (let x = 0.01; x < A; x += 0.03) rail.push(cap(x * H, QUAY * H, x * H, (QUAY - 0.012) * H, 0.001 * H, 0.001 * H, edge)) this.shape(rail, this.lighting, 0.3) - // Trees along the promenade, between the highway lamps. - const leaves = this.paint([0.22, 0.42, 0.18]) - for (let x = 0.29; x < A + 0.05; x += 0.34) { - const base = QUAY + 0.014 - this.shape([cap(x * H, base * H, x * H, (base - 0.022) * H, 0.002 * H, 0.0015 * H, this.paint([0.35, 0.25, 0.16]))], this.lighting, 0.4) - this.shape([ell(x * H, (base - 0.036) * H, 0.014 * H, 0.013 * H, 0, leaves), ell((x - 0.011) * H, (base - 0.026) * H, 0.011 * H, 0.009 * H, 0, leaves), ell((x + 0.011) * H, (base - 0.027) * H, 0.011 * H, 0.009 * H, 0, leaves)], this.lighting, 0.7) - } // Shade under the deck, deepening toward it, then the street and its sidewalk. for (let y = UNDER; y < STREET; y += 0.005) this.polygon(this.rect(-0.01, y, A + 0.01, y + 0.0052), this.paint([0.12 + (y - UNDER) * 2, 0.12 + (y - UNDER) * 2, 0.15 + (y - UNDER) * 2])) this.polygon(this.rect(-0.01, STREET, A + 0.01, 0.965), this.paint([0.26, 0.26, 0.28])) @@ -746,6 +745,39 @@ class City extends Canvas { if (this.sign && look.glow > 0 && hash(Math.floor(this.time * 0.4) + 3.7) > 0.15) this.neon(this.sign, true, true) } + // Trees along the promenade between the highway lamps, nodding in the wind, and two flags that hang slack in calm + // air and stream out when a gust comes through. + private drawPromenade() { + const { A, H } = this + const base = QUAY + 0.014 + const leaves = this.paint([0.22, 0.42, 0.18]) + for (let i = 0, x = 0.29; x < A + 0.05; i++, x += 0.34) { + const lean = sway(x, this.time, i * 1.7, 0.9) * 0.0015 + this.shape([cap(x * H, base * H, (x + lean * 0.5) * H, (base - 0.022) * H, 0.002 * H, 0.0015 * H, this.paint([0.35, 0.25, 0.16]))], this.lighting, 0.4) + this.shape([ell((x + lean) * H, (base - 0.036) * H, 0.014 * H, 0.013 * H, 0, leaves), ell((x - 0.011 + lean * 0.7) * H, (base - 0.026) * H, 0.011 * H, 0.009 * H, 0, leaves), ell((x + 0.011 + lean * 0.7) * H, (base - 0.027) * H, 0.011 * H, 0.009 * H, 0, leaves)], this.lighting, 0.7) + } + const pole = this.paint([0.62, 0.62, 0.64]) + for (const [i, x] of [0.205, 0.205 + 0.34 * Math.floor((A - 0.4) / 0.34)].entries()) { + const top = base - 0.075 + this.shape([cap(x * H, base * H, x * H, (top - 0.004) * H, 0.0013 * H, 0.001 * H, pole)], this.lighting, 0.4) + const g = gust(x, this.time) + // From hanging nearly straight down to flying level, rippling faster the harder it blows. + const droop = 1.25 * (1 - (0.3 + 0.7 * g)) + const L = 0.03 + const upper: [number, number][] = [] + const lower: [number, number][] = [] + for (let k = 0; k <= 6; k++) { + const s = (k / 6) * L + const wave = Math.sin(k * 1.1 - this.time * (3 + 5 * g) + i) * 0.0025 * (k / 6) + const px = x + s * Math.cos(droop) + wave * Math.sin(droop) + const py = top + s * Math.sin(droop) + wave * Math.cos(droop) + upper.push([px * H, py * H]) + lower.push([px * H, (py + 0.016) * H]) + } + this.polygon([...upper, ...lower.reverse()], this.paint(FLAGS[i])) + } + } + private stepTraffic(dt: number) { for (const c of this.cars) c.x += LANES[c.lane].dir * LANES[c.lane].speed * dt this.cars = this.cars.filter((c) => c.x > -0.15 && c.x < this.A + 0.15) diff --git a/wallpapers/desert.ts b/wallpapers/desert.ts index 570ddf3..3184ead 100644 --- a/wallpapers/desert.ts +++ b/wallpapers/desert.ts @@ -5,6 +5,7 @@ import { TAU, clamp, fbm1, fbm2, hash, lerp, rand, smoothstep, type RGB } from " import { driftClouds, makeStorm, paintStorm } from "../src/sky" import type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -263,7 +264,7 @@ class Desert extends Canvas { private dust = Array.from({ length: 60 }, () => ({ x: Math.random() * 4, y: 0.3 + Math.random() * 0.7, s: Math.random() })) private clouds: Cloud[] private storm = makeStorm() - private tumbleweed = { x: -9, dir: 1, next: 14, spin: 0, hop: 0 } + private tumbleweed = { x: -9, next: 14, spin: 0, hop: 0 } private twigs = buildTwigs() private bigBird: Bird | undefined private flock: Bird[] = [] @@ -314,7 +315,7 @@ class Desert extends Canvas { dt = clamp(dt, 0, 0.1) * TIME_SCALE this.time += dt for (const d of this.dust) { - d.x += (0.01 + d.s * 0.012) * dt + d.x += (0.01 + d.s * 0.012 + gust(d.x, this.time) * 0.06) * dt d.y += Math.sin(this.time * 0.5 + d.s * 30) * 0.002 * dt if (d.x > this.A) d.x -= this.A } @@ -339,6 +340,8 @@ class Desert extends Canvas { render() { this.hdr.set(this.background) + // Heat haze over the sunlit floor, but not in winter or under cloud. + if (this.look.style === "front" && this.season !== "winter" && !this.weather.covered) shimmer(this.hdr, this.background, this.W, this.H, this.time) if (!this.weather.covered) this.drawStars() this.drawClouds() paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) @@ -562,18 +565,18 @@ class Desert extends Canvas { } } + // The tumbleweed rolls with the wind, ambling in calm air and bowling along when a gust catches it. private stepTumbleweed(dt: number) { const t = this.tumbleweed if (t.x < -5) { t.next -= dt if (t.next > 0) return - t.dir = Math.random() < 0.5 ? 1 : -1 - t.x = t.dir > 0 ? -0.1 : this.A + 0.1 + t.x = -0.1 return } - const move = 0.06 * dt - t.x += t.dir * move - t.spin += (t.dir * move) / 0.035 + const move = (0.045 + 0.1 * gust(t.x, this.time)) * dt + t.x += move + t.spin += move / 0.035 t.hop += move / 0.05 if (t.x < -0.2 || t.x > this.A + 0.2) { t.x = -9 @@ -978,6 +981,27 @@ class Desert extends Canvas { } } +// Heat haze: rows just above the hot floor at the horizon waver sideways by a fraction of a pixel, so the feet of the +// distant mesas swim. +function shimmer(hdr: Float32Array, background: Float32Array, W: number, H: number, t: number) { + for (let y = Math.floor((HORIZON - 0.05) * H); y < Math.ceil((HORIZON + 0.02) * H); y++) { + const v = y / H + const d = 0.0025 * H * Math.exp(-(((v - HORIZON + 0.008) / 0.02) ** 2)) * Math.sin(v * 520 + t * 5) * (0.6 + 0.4 * Math.sin(v * 90 - t * 1.7)) + if (d < 0.02 && d > -0.02) continue + const shift = Math.floor(d) + const f = d - shift + const row = y * W + for (let x = 0; x < W; x++) { + const a = (row + clamp(x - shift, 0, W - 1)) * 3 + const b = (row + clamp(x - shift - 1, 0, W - 1)) * 3 + const o = (row + x) * 3 + hdr[o] = background[a] * (1 - f) + background[b] * f + hdr[o + 1] = background[a + 1] * (1 - f) + background[b + 1] * f + hdr[o + 2] = background[a + 2] * (1 - f) + background[b + 2] * f + } + } +} + function mix(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } diff --git a/wallpapers/farm.ts b/wallpapers/farm.ts index 868c944..56cb6d7 100644 --- a/wallpapers/farm.ts +++ b/wallpapers/farm.ts @@ -4,6 +4,7 @@ import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -190,6 +191,11 @@ class Farm extends Canvas { private treeTops: [number, number][] = [] // Blossom petals in spring and leaves in autumn, drifting down on the breeze. private leaves: { x: number; y: number; phase: number; color: RGB }[] = [] + // The back corn rows are painted once but still bend as gusts pass, and the open pasture lightens under them. + private corn = new SwayLayer() + private pasture = new Int32Array(0) + private gusts = new Float32Array(0) + private fan = 0 constructor(settings: Settings) { super() @@ -223,9 +229,10 @@ class Farm extends Canvas { driftClouds(this.storm, this.A, dt) this.stepGloom(dt) this.weather.step(dt) + this.fan += dt * (0.8 + 1.6 * gust(0.3 * this.A, this.time)) for (const l of this.leaves) { l.y += (0.02 + Math.sin(this.time * 1.3 + l.phase) * 0.012) * dt - l.x += (0.025 + Math.sin(this.time * 0.7 + l.phase * 3) * 0.02) * dt + l.x += (0.025 + Math.sin(this.time * 0.7 + l.phase * 3) * 0.02 + gust(l.x, this.time) * 0.09) * dt if (l.y < 0.99 && l.x < this.A + 0.05) continue // Most come loose from one of the trees; some blow in from off to the left. const tree = this.treeTops[Math.floor(Math.random() * this.treeTops.length)] @@ -261,6 +268,9 @@ class Farm extends Canvas { render() { this.hdr.set(this.background) + for (let x = 0; x < this.W; x++) this.gusts[x] = gust(x / this.H, this.time) + lightenGrass(this.hdr, this.W, this.H, this.pasture, this.gusts) + this.corn.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) 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) @@ -315,6 +325,7 @@ class Farm extends Canvas { 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) }) + const field = this.hdr.slice() // A dirt road winding across the middle distance. for (let x = 0; x < W; x++) { const cy = this.roadY(x / H) * H @@ -349,8 +360,20 @@ class Farm extends Canvas { posts.push(cap(0.38 * A * H, fy - 0.014 * H, W + 5, fy - 0.014 * H, 0.0025 * H, 0.0025 * H, this.paint(WOOD))) this.shape(posts, this.lighting, 0.6) if (this.activity === "teeming") this.shape([ell(0.52 * A * H, 0.91 * H, 0.08 * H, 0.025 * H, 0, this.paint([0.32, 0.22, 0.12]))], this.lighting, 0.3) + this.corn.begin(this.hdr) 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++) + for (let y = Math.ceil(this.nearTop[x]) + 1; y < H; y++) { + const o = (y * W + x) * 3 + if (this.hdr[o] === field[o] && this.hdr[o + 1] === field[o + 1] && this.hdr[o + 2] === field[o + 2]) pasture.push(y * W + x) + } + this.pasture = Int32Array.from(pasture) + this.gusts = new Float32Array(W) } private paint(c: RGB): RGB { @@ -464,7 +487,7 @@ class Farm extends Canvas { const x = 0.3 * this.A * H const y = (0.65 - 0.21) * H const metal = this.paint([0.75, 0.75, 0.72]) - const turn = this.time * 0.8 + const turn = this.fan this.polygon([[x - 0.01 * H, y], [x - 0.075 * H, y - 0.018 * H], [x - 0.075 * H, y + 0.012 * H]], this.paint([0.6, 0.15, 0.1])) const parts: Part[] = [ell(x, y, 0.009 * H, 0.009 * H, 0, this.paint([0.35, 0.33, 0.3]))] for (let i = 0; i < 14; i++) { @@ -484,8 +507,8 @@ class Farm extends Canvas { 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 const h = height * (0.85 + hash(i * 7.3 + row) * 0.3) - const sway = row === 3 ? Math.sin(this.time * 0.7 + i * 0.9) * 0.05 : (hash(i + row * 5) - 0.5) * 0.06 - const top: [number, number] = [x + Math.sin(sway) * h, base - Math.cos(sway) * h] + const bend = row === 3 ? sway(x / H, this.time, i * 0.9) * 0.05 : (hash(i + row * 5) - 0.5) * 0.06 + const top: [number, number] = [x + Math.sin(bend) * h, base - Math.cos(bend) * h] const at = (t: number): [number, number] => [lerp(x, top[0], t), lerp(base, top[1], t)] const stalk = this.paint(corn.stalk) const parts: Part[] = [cap(x, base, ...top, 0.006 * H * (1 + row * 0.3), 0.003 * H, stalk)] @@ -494,10 +517,11 @@ class Farm extends Canvas { const side = l % 2 ? 1 : -1 const reach = h * 0.28 const mid: [number, number] = [lx + side * reach * 0.6, ly - reach * 0.25] - parts.push(cap(lx, ly, ...mid, 0.006 * H, 0.004 * H, stalk), cap(...mid, lx + side * reach, ly + reach * 0.15 + sway * reach, 0.004 * H, 0.0015 * H, stalk)) + // Leaves stream downwind as the stalk bends. + parts.push(cap(lx, ly, ...mid, 0.006 * H, 0.004 * H, stalk), cap(...mid, lx + side * reach + bend * reach * 1.5, ly + reach * 0.15 + bend * reach, 0.004 * H, 0.0015 * H, stalk)) } const [ex, ey] = at(0.55) - if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, 0.007 * H, 0.016 * H, 0.25 + sway, this.paint(corn.ear))) + if (corn.ear) parts.push(ell(ex + 0.008 * H, ey, 0.007 * H, 0.016 * H, 0.25 + bend, this.paint(corn.ear))) if (corn.tassel) parts.push(cap(...top, top[0] + 0.012 * H, top[1] - 0.012 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel)), cap(...top, top[0] - 0.01 * H, top[1] - 0.014 * H, 0.002 * H, 0.001 * H, this.paint(corn.tassel))) // Rim light on every thin leaf would turn the field into a wireframe, so the corn only catches a little of it. this.shape(parts, this.lighting, this.look.style === "rim" ? 0.2 : 0.6) @@ -881,6 +905,21 @@ function lerpRGB(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } +// A gust flattens the grass as it passes, turning up the paler side of the blades in soft streaks. +function lightenGrass(hdr: Float32Array, W: number, H: number, pasture: Int32Array, gusts: Float32Array) { + for (let k = 0; k < pasture.length; k++) { + const p = pasture[k] + const x = p % W + const g = gusts[x] + if (g < 0.01) continue + const y = (p - x) / W + const m = 1 + 0.1 * g * (0.4 + 0.6 * Math.sin(((x - y * 1.4) / H) * 55) ** 2) + hdr[p * 3] *= m + hdr[p * 3 + 1] *= m + hdr[p * 3 + 2] *= m + } +} + export const farm: Wallpaper = { id: "farm", name: "Farm", diff --git a/wallpapers/jungle.ts b/wallpapers/jungle.ts index 850f37c..845dcd4 100644 --- a/wallpapers/jungle.ts +++ b/wallpapers/jungle.ts @@ -3,6 +3,7 @@ import { eggWait } from "../src/egg" import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src/math" import type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" import { WeatherLayer } from "../src/weather" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -93,6 +94,8 @@ const DAYLIGHT = (() => { const RAYS_A = buildRays(3) const RAYS_B = buildRays(11) +const MIST_A = buildRays(23) +const MIST_B = buildRays(37) // A big foreground leaf rooted at (x, y), pointing along angle (radians, 0 = right, negative = up). interface Leaf { @@ -144,6 +147,9 @@ class Jungle extends Canvas { private liana = { x: 0, length: 0, phase: 0 } // The easter egg: a sloth lowers itself down the vine, smiles at you, and climbs back up. t is creature time. private sloth = { wait: eggWait() * TIME_SCALE, t: -1 } + // The hanging vines, painted once, swing out in the gusts; mist drifts along the floor on the same wind. + private vines = new SwayLayer() + private mist = 0 constructor(settings: Settings) { super() @@ -166,8 +172,9 @@ class Jungle extends Canvas { this.time += dt const cdt = dt * CREATURE_SPEED this.creatureTime += cdt + this.mist += (0.02 + 0.1 * gust(0.5 * this.A, this.time)) * dt for (const p of this.pollen) { - p.x += (0.004 + p.s * 0.006) * dt + p.x += (0.004 + p.s * 0.006 + gust(p.x, this.time) * 0.05) * dt p.y += Math.sin(this.time * 0.4 + p.s * 20) * 0.003 * dt if (p.x > this.A) p.x -= this.A } @@ -216,8 +223,11 @@ class Jungle extends Canvas { render() { this.hdr.set(this.background) + this.vines.draw(this.hdr, this.W, this.H, this.time) this.drawShafts() this.drawFalls() + const mist = this.look.mist[1] + driftMist(this.hdr, this.W, this.H, this.mist, mist[0], mist[1], mist[2], this.rainy ? 0.3 : 0.22) if (this.toucan.x > -5) this.drawToucan(this.toucan) if (this.sloth.t >= 0) this.drawSloth() if (this.activity !== "calm") this.drawMonkey() @@ -276,12 +286,15 @@ class Jungle extends Canvas { this.lighting, ) // Vines hanging from the canopy, clear of the waterfall. + this.vines.begin(this.hdr) for (let i = 0; i < 7; i++) { const vine = { x: (0.06 + (i / 6) * 0.88) * A, length: 0.25 + hash(i * 2.2) * 0.35, phase: hash(i * 4.4) * TAU } if (Math.abs(vine.x - fx) < 0.06) continue if (i === 0) this.liana = vine this.drawVine(vine.x * H, vine.length * H, vine.phase) } + // Hanging from the top, they swing more toward their tips. + this.vines.end(this.hdr, W, H, 0.014 * H, (_x, y) => clamp(y / (0.55 * H), 0, 1) ** 1.3) // Two big trunks with buttress roots, and the branches the animals use. this.drawTrunk(0.2 * A * H, 0.045 * H) this.drawTrunk(0.86 * A * H, 0.038 * H) @@ -307,6 +320,7 @@ class Jungle extends Canvas { ...[0.6, 0.95].map((angle, i) => ({ x: -0.02, y: -0.04, angle, length: [0.3, 0.26][i], phase: i * 1.3 + 2 })), ...[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() } @@ -513,7 +527,8 @@ class Jungle extends Canvas { const v = this.liana const s = lerp(0.2, 0.75, smoothstep(0, 10, t) * (1 - smoothstep(14, 24, t))) const at = (q: number): [number, number] => [(v.x + Math.sin(q * Math.PI * 0.8 + v.phase) * 0.02 * q) * H, q * v.length * H] - const [gx, gy] = at(s) + const [vx, gy] = at(s) + const gx = vx + this.vines.offset(vx, gy) const S = 0.17 * H const climbing = (t < 10 || t > 14) && t < 24 const reach = climbing ? Math.sin(t * 4) * 0.05 : 0 @@ -687,7 +702,8 @@ class Jungle extends Canvas { // A big glossy leaf on a short stalk, swaying gently, with a pale midrib. private drawLeaf(l: Leaf) { const H = this.H - const angle = l.angle + Math.sin(this.time * 0.5 + l.phase) * 0.035 + // Turned whichever way carries the tip downwind. + const angle = l.angle + sway(l.x, this.time, l.phase, 0.5) * 0.035 * (Math.sin(l.angle) > 0 ? -1 : 1) const dx = Math.cos(angle) const dy = Math.sin(angle) const x = l.x * H @@ -703,6 +719,22 @@ function mixRGB(a: RGB, b: RGB, t: number): RGB { return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)] } +// Low mist drifting between the trunks on the breeze, thickest just above the floor and thinner behind the text. +function driftMist(hdr: Float32Array, W: number, H: number, drift: number, r: number, g: number, b: number, strength: number) { + for (let y = Math.floor(0.5 * H); y < Math.min(H, Math.ceil(0.96 * H)); y++) { + const v = y / H + const band = Math.exp(-(((v - 0.8) / 0.12) ** 2)) * strength + for (let x = 0; x < W; x++) { + const u = x / H + const a = band * (0.45 + 0.55 * smoothstep(0.1, 0.3, Math.abs(x / W - 0.5))) * MIST_A[((u - drift) * 90 + v * 40) & (RAY_N - 1)] * (0.3 + 0.7 * MIST_B[((u - drift * 0.6) * 55 - v * 25 + 300) & (RAY_N - 1)]) + const o = (y * W + x) * 3 + hdr[o] += (r - hdr[o]) * a + hdr[o + 1] += (g - hdr[o + 1]) * a + hdr[o + 2] += (b - hdr[o + 2]) * a + } + } +} + // A wrapping strip of soft bands; two sliding past each other make the light shafts. function buildRays(seed: number) { const tex = new Float32Array(RAY_N) diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index ec5e136..9ea49ac 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -727,13 +727,18 @@ class Ocean extends Canvas { } } - // Marine snow: faint specks behind the whale's depth, a few brighter flakes in front. + // Marine snow: faint specks behind the whale's depth, a few brighter flakes in front, lit up like dust motes as they + // drift through a shaft of light. private drawSnow(back: boolean) { - const H = this.H + const { H, rayFade } = this + const t = this.time for (const s of this.snow) { if (back !== s.z > 0.35) continue - const tw = 0.6 + 0.4 * Math.sin(this.time * 2 + s.s * 40) - const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + const tw = 0.6 + 0.4 * Math.sin(t * 2 + s.s * 40) + const x = s.x * H + const y = clamp(Math.floor(s.y * H), 0, H - 1) + const ray = RAYS_A[((x + y * 0.42 + t * 7) | 0) & 1023] * (0.45 + 0.55 * RAYS_B[((x * 0.7 + y * 0.6 - t * 4.3) | 0) & 1023]) * rayFade[y] + const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + ray * 0.9 if (!back && s.s > 0.7) this.disc(s.x * H, s.y * H, 1.1, 0.6 + k, 0.75 + k, 0.8 + k, 0.35) else this.add(s.x * H, s.y * H, k * 0.8, k, k) } diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d37610a..f68ed19 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -5,6 +5,7 @@ import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { SwayLayer, gust, sway } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -175,6 +176,8 @@ class Prehistoric extends Canvas { // 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) } private trees: [number, number] = [0, 0] + // The far ferns and cycads, painted once, bend as the gusts pass. + private plants = new SwayLayer() private rex = { x: -9, dir: 1, next: 2, phase: 0 } private dust: { x: number; y: number; vx: number; age: number }[] = [] // p runs from browsing the left tree (0) to browsing the right one (1). @@ -235,6 +238,7 @@ class Prehistoric extends Canvas { render() { this.hdr.set(this.background) + this.plants.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) 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]) @@ -327,15 +331,22 @@ class Prehistoric extends Canvas { this.drawTree(this.trees[0], 0.665, 0.29) this.drawTree(this.trees[1], 0.662, 0.31) this.drawTree(0.93 * A, 0.64, 0.45) - for (const [i, [x, y, s]] of [ + this.plants.begin(this.hdr) + const ferns = [ [0.32, 0.78, 0.07], [0.88, 0.74, 0.08], [0.48, 0.9, 0.08], - ].entries()) - this.drawFern(x * A, y, s, i + 7, false) - this.drawCycad(0.04 * A, 0.86, 0.09) - this.drawCycad(0.47 * A, 0.835, 0.07) - this.drawCycad(0.9 * A, 0.84, 0.1) + ] + for (const [i, [x, y, s]] of ferns.entries()) this.drawFern(x * A, y, s, i + 7, false) + const cycads = [ + [0.04, 0.86, 0.09], + [0.47, 0.835, 0.07], + [0.9, 0.84, 0.1], + ] + for (const [x, base, size] of cycads) this.drawCycad(x * A, base, size) + // Each plant bends from its base, the cycads' wide crowns over a wider reach. + const reach = [...ferns.map(([x, y, s]) => [x * A, y, s, s * 1.2]), ...cycads.map(([x, y, s]) => [x * A, y, s, s * 2.2])] + this.plants.end(this.hdr, W, H, 0.01 * H, (px, py) => reach.reduce((w, [x, y, s, r]) => (Math.abs(px / H - x) < r ? Math.max(w, clamp((y - py / H) / (s * 1.2), 0, 1)) : w), 0)) this.background = this.hdr.slice() } @@ -500,12 +511,13 @@ class Prehistoric extends Canvas { } // A clump of fern fronds arching out and drooping at the tips; the front clumps sway in the breeze. - private drawFern(x: number, y: number, size: number, seed: number, sway: boolean) { + private drawFern(x: number, y: number, size: number, seed: number, swaying: boolean) { const H = this.H const fern = this.paint(FERN) for (let i = 0; i < 7; i++) { const base = -Math.PI / 2 + ((i / 6) * 2 - 1) * 1.15 + (hash(seed * 5 + i) - 0.5) * 0.2 - const bend = sway ? Math.sin(this.time * 0.7 + i * 1.3 + seed * 2) * 0.05 : 0 + // Turned whichever way carries the tip downwind. + const bend = swaying ? sway(x, this.time, i * 1.3 + seed * 2) * 0.05 * (Math.sin(base) > 0 ? -1 : 1) : 0 const len = size * (0.75 + hash(seed * 7 + i) * 0.35) * H const parts: Part[] = [] let [px, py] = [x * H, y * H] @@ -528,9 +540,11 @@ class Prehistoric extends Canvas { private drawSmoke() { const { A, H, look } = this const [hot, cool, alpha] = look.smoke + // A passing gust bends the plume further over. + const blown = 0.0025 * gust(VENT_X * A, this.time) for (const s of this.smoke) { const t = s.age - const x = (VENT_X * A + (hash(s.seed) - 0.5) * 0.02 + 0.002 * t + 0.0009 * t * t + 0.006 * Math.sin(t * 0.7 + s.seed)) * H + const x = (VENT_X * A + (hash(s.seed) - 0.5) * 0.02 + 0.002 * t + 0.0009 * t * t + 0.006 * Math.sin(t * 0.7 + s.seed) + blown * t) * H const y = (CRATER - 0.004 - 0.018 * t + 0.0004 * t * t) * H const r = (0.012 + 0.006 * t) * (0.8 + hash(s.seed + 1) * 0.4) * H const a = alpha * smoothstep(0, 1.5, t) * (1 - smoothstep(8, SMOKE_LIFE, t)) diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..1050cab 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -4,6 +4,7 @@ import { TAU, clamp, fbm1, hash, lerp, rand, smoothstep, type RGB } from "../src import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { SwayLayer, gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -147,6 +148,8 @@ class Tundra extends Canvas { // time since it began. private yeti = { wait: eggWait() * TIME_SCALE, t: -1, x: 0 } private readonly weather: WeatherLayer + // The big pines framing the edges, painted once, bend a little as gusts pass. + private pines = new SwayLayer() constructor(settings: Settings) { super() @@ -176,7 +179,7 @@ class Tundra extends Canvas { this.weather.step(dt) for (const f of this.flakes) { f.y += (0.02 + f.z * 0.035) * dt - f.x += (Math.sin(this.time * 0.6 + f.s * 20) * 0.006 + 0.004) * dt + f.x += (Math.sin(this.time * 0.6 + f.s * 20) * 0.006 + 0.004 + gust(f.x, this.time) * (0.06 + f.z * 0.06)) * dt if (f.y > 1) { f.y -= 1 f.x = Math.random() * this.A @@ -221,6 +224,7 @@ class Tundra extends Canvas { render() { this.hdr.set(this.background) + this.pines.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.5, this.look.stars > 50 ? 0.5 : 0.3) if (this.look.aurora > 0 && !this.weather.covered) this.drawAurora() const [top, bottom] = this.weather.scud ?? [this.look.clouds.top, this.look.clouds.bottom] @@ -266,7 +270,9 @@ class Tundra extends Canvas { this.drawIgloo(0.2 * A, 0.75, 0.075) if (this.activity === "teeming") this.drawSnowman(0.3 * A, 0.85, 0.75, [0.2, 0.35, 0.8], false) this.drawSnowman(0.62 * A, 0.87, 1, [0.8, 0.12, 0.1], true) + 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() } diff --git a/wallpapers/zen.ts b/wallpapers/zen.ts index c1f2340..7caa4ef 100644 --- a/wallpapers/zen.ts +++ b/wallpapers/zen.ts @@ -5,6 +5,7 @@ import { TAU, clamp, fbm1, hash, hash2, lerp, rand, smoothstep, type RGB } from import { driftClouds, makeClouds, makeStars, makeStorm, paintClouds, 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" +import { SwayLayer, gust } from "../src/wind" // The scene runs slower than real time, which keeps it calm behind text. const TIME_SCALE = 0.35 @@ -229,6 +230,9 @@ class Zen extends Canvas { private sparrows: Flier[] = [] // The easter egg: a tanuki with a leaf on its head. t counts scene seconds since it appeared. private tanuki = { wait: eggWait() * TIME_SCALE, t: -1 } + // The maple and cherry crowns, painted once, bend in the gusts, which here blow from the right like the breeze that + // carries the petals. + private trees = new SwayLayer(-1) constructor(settings: Settings) { super() @@ -295,6 +299,7 @@ class Zen extends Canvas { render() { this.hdr.set(this.background) + this.trees.draw(this.hdr, this.W, this.H, this.time) if (!this.weather.covered) paintStars(this.hdr, this.W, this.H, this.stars, this.time, 0.45, this.look.stars > 50 ? 0.5 : 0.3) 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) @@ -413,6 +418,7 @@ class Zen extends Canvas { this.front.set([black[i], black[i + 1], black[i + 2]], k * 3) this.keep[k] = white[i] - black[i] } + this.trees.seal(this.hdr, W) this.background = this.hdr.slice() } @@ -574,8 +580,10 @@ class Zen extends Canvas { const c = this.paint(ROCK) this.shape([ell(r.x * H, (r.y - r.r * 0.55) * H, r.r * H, r.r * 0.75 * H, -0.15, c), ell((r.x + r.r * 0.75) * H, (r.y - r.r * 0.25) * H, r.r * 0.55 * H, r.r * 0.45 * H, 0.2, this.paint([0.4, 0.38, 0.35]))], this.lighting, 0.8) } + this.trees.begin(this.hdr) this.drawMaple() this.drawCherry() + this.trees.end(this.hdr, this.W, H, 0.012 * H, (_x, y) => clamp((0.56 * H - y) / (0.3 * H), 0, 1)) } // A Japanese maple with red leaves in layered tiers, leaning in from the left. @@ -872,7 +880,7 @@ class Zen extends Canvas { if (p.age < 0) return if (p.state === 0) { p.spin += dt * 2 - p.x += (p.vx + 0.012 * Math.sin(this.time * 0.8 + p.spin * 0.3)) * dt + p.x += (p.vx + 0.012 * Math.sin(this.time * 0.8 + p.spin * 0.3) - 0.09 * gust(this.A - p.x, this.time)) * dt p.y += p.vy * dt if (p.x < -0.05) Object.assign(p, this.newPetal(false)) if (p.y < p.land) return From f49e62da35f339796c204b61ebeb14364a28489d Mon Sep 17 00:00:00 2001 From: vprdev Date: Fri, 9 Oct 2026 21:12:35 -0700 Subject: [PATCH 2/2] Light up ocean motes in the shafts, steady the gust flutter, and document the wind --- CONTRIBUTING.md | 9 +++++++++ src/wind.ts | 2 +- wallpapers/ocean.ts | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c067710..4f30795 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -169,6 +169,15 @@ make one `WeatherLayer` (from `src/weather.ts`) with the weather, time of day an Skip stars when `covered` is set, and color clouds with `scud` when it is. Passing `light` as the last argument gives a seasonal shower or flurry without the cloud deck, as the farm does in winter. `wallpapers/farm.ts` shows all of it. +## Wind + +Every outdoor scene shares one wind from `src/wind.ts`, so a gust rolls across the whole screen from left to right +every minute or so and then dies away. `gust(x, t)` is how hard it blows at x (screen heights) and scene time t, from 0 +to 1: add it to anything that drifts, such as petals, snow or dust. `sway(x, t, phase, rate)` replaces a plant's own +`Math.sin(t * rate + phase)`: the same rocking in calm air, plus a lean downwind as a gust passes. For plants painted +once in `layout()`, a `SwayLayer` bends them without redrawing: call `begin()` before painting them, `end()` after +with how far their tips move and a weight from roots to tips, and `draw()` right after copying the background. + ## Scrim Text gets a soft scrim that fades the scene toward a dark color around it. `scrim` gives that color (0 to 255) for diff --git a/src/wind.ts b/src/wind.ts index 1dbdb2c..156908b 100644 --- a/src/wind.ts +++ b/src/wind.ts @@ -30,7 +30,7 @@ export function gust(x: number, t: number) { // (positive x) with livelier rocking as a gust passes. phase keeps neighbors out of step and rate is how fast it rocks. export function sway(x: number, t: number, phase = 0, rate = 0.7) { const g = gust(x, t) - return Math.sin(t * rate + phase) * (1 + 0.6 * g) + g * (2.2 + 0.5 * Math.sin(t * rate * 2.7 + phase * 1.7)) + return Math.sin(t * rate + phase) * (1 + 0.4 * g) + g * (2.2 + 0.3 * Math.sin(t * rate * 2.7 + phase * 1.7)) } // Plants painted once into a static background that still bend in the gusts: the pixels they cover shift downwind diff --git a/wallpapers/ocean.ts b/wallpapers/ocean.ts index 9ea49ac..2342ff6 100644 --- a/wallpapers/ocean.ts +++ b/wallpapers/ocean.ts @@ -738,7 +738,7 @@ class Ocean extends Canvas { const x = s.x * H const y = clamp(Math.floor(s.y * H), 0, H - 1) const ray = RAYS_A[((x + y * 0.42 + t * 7) | 0) & 1023] * (0.45 + 0.55 * RAYS_B[((x * 0.7 + y * 0.6 - t * 4.3) | 0) & 1023]) * rayFade[y] - const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + ray * 0.9 + const k = (back ? 0.08 + (1 - s.z) * 0.1 : 0.25) * tw + ray * 1.5 if (!back && s.s > 0.7) this.disc(s.x * H, s.y * H, 1.1, 0.6 + k, 0.75 + k, 0.8 + k, 0.35) else this.add(s.x * H, s.y * H, k * 0.8, k, k) }