Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ export {
type Weather,
} from "./wallpaper"
export { WeatherLayer } from "./weather"
export { SwayLayer, gust, sway } from "./wind"
160 changes: 160 additions & 0 deletions src/wind.ts
Original file line number Diff line number Diff line change
@@ -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.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
// 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)
}
}
}
6 changes: 4 additions & 2 deletions wallpapers/beach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
46 changes: 39 additions & 7 deletions wallpapers/city.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]))
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 31 additions & 7 deletions wallpapers/desert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]
}
Expand Down
Loading
Loading