diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c067710..4ffa167 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,9 @@ This is the biggest single saving: a full-screen gradient with noise costs more | `polygon(points, color, alpha?)` | An anti-aliased polygon in a flat color, for straight-edged things like buildings and boats | | `shape(parts, lighting, light?)` | Rounded parts joined into one lit form, for creatures, people and plants | -For per-pixel effects such as water, sand or fog, loop over `hdr` directly. +For per-pixel effects such as water, sand or fog, loop over `hdr` directly. `reflect()` in `src/water.ts` mirrors +other rows of the scene into water rows with drifting ripples, a tint and a per-pixel weight; call it each frame over +only the water's rows (as the beach and the prehistoric lake do), or once in `layout()` for still water such as ice. ### Shapes diff --git a/src/water.ts b/src/water.ts new file mode 100644 index 0000000..65b6770 --- /dev/null +++ b/src/water.ts @@ -0,0 +1,33 @@ +import type { RGB } from "./math" + +let wave = new Float32Array(0) + +// Mirrors the scene into water rows y0 to y1. Row y shows row rows[y - y0] of the same buffer (outside those rows), +// tinted, wobbled by ripples of up to amp[y - y0] pixels that drift with time, and mixed in by weight (one value per +// pixel of those rows; 0 leaves a pixel alone). With still (those rows as the water was drawn), pixels painted over +// since are left alone, so whatever swims or stands in front of the water stays in front of its reflection. +export function reflect(hdr: Float32Array, W: number, H: number, y0: number, y1: number, rows: Float32Array, amp: Float32Array, weight: Float32Array, tint: RGB, time: number, still?: Float32Array) { + if (wave.length !== W) wave = new Float32Array(W) + for (let x = 0; x < W; x++) wave[x] = 2.4 * Math.sin((x / H) * 9 + time * 0.35) + 1.2 * Math.sin((x / H) * 23 - time * 0.6) + for (let y = Math.max(0, y0); y < Math.min(H, y1); y++) { + const r = y - y0 + const a = amp[r] + const phase = (y / H) * 150 + time * 1.2 + for (let x = 0; x < W; x++) { + const w = weight[r * W + x] + if (w <= 0) continue + const o = (y * W + x) * 3 + const s = (r * W + x) * 3 + if (still && (hdr[o] !== still[s] || hdr[o + 1] !== still[s + 1] || hdr[o + 2] !== still[s + 2])) continue + const p = phase + wave[x] + const sx = Math.min(W - 1.001, Math.max(0, x + a * Math.sin(p))) + const sy = Math.min(H - 1, Math.max(0, Math.round(rows[r] + a * 0.6 * Math.cos(p)))) + const x0 = sx | 0 + const f = sx - x0 + const m = (sy * W + x0) * 3 + hdr[o] += ((hdr[m] + (hdr[m + 3] - hdr[m]) * f) * tint[0] - hdr[o]) * w + hdr[o + 1] += ((hdr[m + 1] + (hdr[m + 4] - hdr[m + 1]) * f) * tint[1] - hdr[o + 1]) * w + hdr[o + 2] += ((hdr[m + 2] + (hdr[m + 5] - hdr[m + 2]) * f) * tint[2] - hdr[o + 2]) * w + } + } +} diff --git a/wallpapers/beach.ts b/wallpapers/beach.ts index f977204..6ae9d5b 100644 --- a/wallpapers/beach.ts +++ b/wallpapers/beach.ts @@ -1,9 +1,10 @@ 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 { TAU, clamp, fbm1, hash, hash2, lerp, noise1, 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 type { Activity, Season, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" // The scene runs slower than real time, which keeps it calm behind text. @@ -13,6 +14,9 @@ const CREATURE_SPEED = 0.25 const HORIZON = 0.5 // How long the message in a bottle stays, in scene seconds. const BOTTLE = 95 +// How long a ring on the water and a dolphin's glowing wake under it last, in scene seconds. +const RING = 4 +const WAKE = 7 // What changes with the time of day. Sea, sand and foam get their own colors; at night the foam glows with // bioluminescence instead of turning white. Objects and animals have one daytime color each, darkened by tint. @@ -27,6 +31,8 @@ interface Look { sand: [dry: RGB, wet: RGB] foam: RGB glitter: number + // The tint of what the sea mirrors. + mirror: RGB tint: RGB night: boolean } @@ -53,6 +59,7 @@ const LOOKS: Record = { ], foam: [0.92, 0.96, 1], glitter: 0.15, + mirror: [0.82, 0.9, 0.95], tint: [1, 1, 1], night: false, }, @@ -78,6 +85,7 @@ const LOOKS: Record = { ], foam: [1, 0.62, 0.42], glitter: 1, + mirror: [0.9, 0.78, 0.72], tint: [0.22, 0.13, 0.1], night: false, }, @@ -102,6 +110,7 @@ const LOOKS: Record = { ], foam: [0.1, 0.85, 1.1], glitter: 0.7, + mirror: [0.75, 0.9, 1], tint: [0.025, 0.04, 0.09], night: true, }, @@ -155,6 +164,8 @@ interface Dolphin { y: number dir: number wait: number + // Scene seconds since it dived back in, while it swims on under the surface; negative once out of sight. + under: number } class Beach extends Canvas { @@ -171,6 +182,11 @@ class Beach extends Canvas { private boat = { x: -9, dir: 1, next: 14 } private dolphins: Dolphin[] = [] private splashes: { x: number; y: number; vx: number; vy: number; age: number }[] = [] + // Rings spreading on the water where a dolphin broke the surface. + private rings: { x: number; y: number; age: number }[] = [] + // The sea's rows (from the horizon to the highest swash): which row each one mirrors, how far its ripples wobble the + // reflection, how much reflection each pixel takes this frame, and the rows as the sea was drawn. + private sea = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0), still: new Float32Array(0) } private crab = { x: 0.5, dir: 1, rest: 0, phase: 0 } private surfer = { x: -0.2, phase: 0 } private startled: Flier[] = [] @@ -193,7 +209,7 @@ class Beach extends Canvas { 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) - if (settings.activity !== "calm") this.dolphins = [0, 1].map((i) => ({ t: -1, x: 0, y: 0, dir: 1, wait: 4 + i * 9 })) + if (settings.activity !== "calm") this.dolphins = [0, 1].map((i) => ({ t: -1, x: 0, y: 0, dir: 1, wait: 4 + i * 9, under: -1 })) } step(dt: number) { @@ -214,6 +230,8 @@ class Beach extends Canvas { s.y += s.vy * dt } this.splashes = this.splashes.filter((s) => s.age < 1.2) + for (const r of this.rings) r.age += dt + this.rings = this.rings.filter((r) => r.age < RING) this.startled = flyAway(this.startled, dt, this.A) const b = this.bottle if (b.t >= 0) b.t += dt @@ -253,6 +271,7 @@ class Beach extends Canvas { paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawSea() + this.drawWakes() for (const s of this.swimmers) this.drawSwimmer(s) this.drawBoat() for (const d of this.dolphins) this.drawDolphin(d) @@ -267,6 +286,9 @@ class Beach extends Canvas { this.drawTrunk(p) this.drawFronds(p) } + // Last, once the palms, boat and birds are up: the sky and all of them mirrored in the open water. + const s = this.sea + reflect(this.hdr, this.W, this.H, s.y0, s.y1, s.rows, s.amp, s.gloss, this.look.mirror, this.time, s.still) this.weather.draw(this.hdr, this.W, this.H) this.finish() } @@ -310,6 +332,19 @@ class Beach extends Canvas { { base: [0.94 * A, 1.0], crown: [0.85 * A, 0.44], lean: -1 }, ] this.background = this.hdr.slice() + // Each row of sea mirrors the sky about the horizon, stretched a little as rough water does, its ripples wobbling + // wider toward the shore. + const y0 = Math.floor(HORIZON * H) + const y1 = Math.min(H, Math.ceil(0.77 * H) + 3) + const n = y1 - y0 + this.sea = { + y0, + y1, + rows: Float32Array.from({ length: n }, (_, r) => y0 - 1 - r * 0.85), + amp: Float32Array.from({ length: n }, (_, r) => (0.3 + (r / (0.24 * H)) * 2.6) * (H / 180)), + gloss: new Float32Array(n * W), + still: new Float32Array(n * W * 3), + } } private paint(c: RGB): RGB { @@ -322,59 +357,74 @@ class Beach extends Canvas { return 0.74 + 0.01 * Math.sin(u * 2.3 + 1) + 0.012 * Math.sin(this.time * 0.5) } - // The sea: deepening toward the shore, with drifting wave streaks, a glittering path under the sun or moon, a wave - // rolling in now and then, wet sand above the waterline, and foam along its edge. + // The sea: deepening toward the shore, with highlights on the wave crests drifting in (brightest along the path under + // the sun or moon), a wave rolling in now and then, wet sand above the waterline, and foam along its edge. It notes + // how much each pixel mirrors the sky, more toward the horizon as on real water; the reflection goes in at the end of + // render(), once everything it mirrors is drawn. private drawSea() { - const { W, H, hdr, look } = this + const { W, H, hdr, look, sea } = this const t = this.time const [far, near] = look.sea const [, wet] = look.sand const ox = look.orb.x * W const roll = (t * 0.08) % 1 + const thin = H / 180 + sea.gloss.fill(0) for (let x = 0; x < W; x++) { const u = x / H const edge = this.shore(u) * H const reach = (0.74 + 0.01 * Math.sin(u * 2.3 + 1) + 0.014) * H const wave = lerp(0.62, edge / H - 0.005, roll) * H - for (let y = Math.floor(HORIZON * H); y < Math.min(H, reach + 2); y++) { + const broken = smoothstep(0.42, 0.6, fbm1(u * 6 + t * 0.1, 4)) + const bend = 1.4 * Math.sin(u * 2.7 + t * 0.05) + 0.7 * Math.sin(u * 7.3 - t * 0.08) + for (let y = sea.y0; y < Math.min(H, reach + 2); y++) { const o = (y * W + x) * 3 + const gi = (y - sea.y0) * W + x if (y > edge) { - // Wet sand the swash has just left. + // Wet sand the swash has just left, faintly glossy. const k = clamp((reach - y) / (reach - edge + 1), 0, 1) * 0.8 hdr[o] += (wet[0] - hdr[o]) * k hdr[o + 1] += (wet[1] - hdr[o + 1]) * k hdr[o + 2] += (wet[2] - hdr[o + 2]) * k + sea.gloss[gi] = k * 0.3 continue } - const depth = clamp((y / H - HORIZON) / (edge / H - HORIZON), 0, 1) - let r = lerp(far[0], near[0], depth) - let g = lerp(far[1], near[1], depth) - let b = lerp(far[2], near[2], depth) - const streak = Math.sin(u * (30 - depth * 18) + y * 0.9 + t * 0.6 + Math.sin(y * 0.37) * 3) - const shine = streak > 0.9 ? (streak - 0.9) * 2 * (0.4 + depth) : 0 - r += look.light[0] * shine * 0.25 - g += look.light[1] * shine * 0.25 - b += look.light[2] * shine * 0.25 - // The glitter path widens toward the shore. - const spread = (0.02 + depth * 0.12) * H - const along = Math.abs(x - ox) / spread - if (along < 1 && hash2(x, Math.floor(y * 0.5) + Math.floor(t * 3) * 131) > 0.86 + along * 0.1) { - const k = (1 - along) * look.glitter * 1.2 - r += look.light[0] * k - g += look.light[1] * k - b += look.light[2] * k + const d = y / H - HORIZON + const depth = clamp(d / (edge / H - HORIZON), 0, 1) + // Rows of swell rolling in toward the shore, closer together toward the horizon, though never closer than a + // terminal cell. + const phase = 51 * Math.log(d + 0.15) - t * 0.6 + bend + const n = phase / TAU - Math.round(phase / TAU) + const swell = 0.94 + 0.06 * Math.cos(phase) + let r = lerp(far[0], near[0], depth) * swell + let g = lerp(far[1], near[1], depth) * swell + let b = lerp(far[2], near[2], depth) * swell + // Each crest catches the light in dashes along its length, long enough to read in a terminal cell. + const across = Math.abs(n) * ((TAU * (d + 0.15) * H) / 51) + const crest = Math.max(0, 1 - across / ((0.7 + depth * 1.2) * thin)) * smoothstep(0.008, 0.03, d) + let shine = 0 + if (crest > 0) { + // Under the sun or moon the dashes crowd together into a glittering path. + const path = Math.max(0, 1 - Math.abs(x - ox) / ((0.03 + depth * 0.14) * H)) ** 1.5 + const dash = smoothstep(0.45 - path * 0.25, 0.72 - path * 0.25, noise1((u * 2.4) / (d + 0.02) + Math.round(phase / TAU) * 17.3, 5)) + const sky = crest * dash * 0.14 * (1 - path) + const sun = crest * dash * path * look.glitter * 1.6 + shine = sky + sun + r += look.light[0] * sky + look.orb.glow[0] * sun + g += look.light[1] * sky + look.orb.glow[1] * sun + b += look.light[2] * sky + look.orb.glow[2] * sun } // Foam: the incoming wave's crest and the edge of the water. - const broken = smoothstep(0.42, 0.6, fbm1(u * 6 + t * 0.1, 4)) - const crest = Math.max(0, 1 - Math.abs(y - wave) / 1.2) * (0.5 + 0.5 * Math.sin(u * 40 + t)) * (1 - roll) * broken + const breaker = Math.max(0, 1 - Math.abs(y - wave) / 1.2) * (0.5 + 0.5 * Math.sin(u * 40 + t)) * (1 - roll) * broken const lap = Math.max(0, 1 - (edge - y) / 2.2) * (0.7 + 0.3 * Math.sin(u * 30 - t * 2)) - const foam = Math.max(crest, lap) + const foam = Math.max(breaker, lap) r += (look.foam[0] - r) * foam g += (look.foam[1] - g) * foam b += (look.foam[2] - b) * foam hdr[o] = r hdr[o + 1] = g hdr[o + 2] = b + sea.gloss[gi] = (0.1 + 0.7 * Math.exp(-d * 12)) * (1 - foam) * (1 - Math.min(1, shine * 1.5)) } // At night the glowing foam lights the wet sand just beyond it. if (look.night) @@ -386,6 +436,7 @@ class Beach extends Canvas { hdr[o + 2] += look.foam[2] * k } } + sea.still.set(hdr.subarray(sea.y0 * W * 3, sea.y1 * W * 3)) } // A palm trunk curving from its base to the crown, ringed with old leaf scars, with coconuts at the top. @@ -529,6 +580,7 @@ class Beach extends Canvas { // Each dolphin waits underwater, then arcs out of the sea and splashes back in. private stepDolphin(d: Dolphin, dt: number, cdt: number) { + if (d.under >= 0) d.under = d.under + dt > WAKE ? -1 : d.under + dt if (d.t < 0) { d.wait -= cdt if (d.wait > 0) return @@ -544,11 +596,70 @@ class Beach extends Canvas { this.splash(d.x + d.dir * 0.16, d.y) d.t = -1 d.wait = rand(10, 25) + d.under = 0 } } private splash(x: number, y: number) { for (let i = 0; i < 10; i++) this.splashes.push({ x, y, vx: (Math.random() - 0.5) * 0.06, vy: -(0.03 + Math.random() * 0.05), age: 0 }) + this.rings.push({ x, y, age: 0 }) + } + + // Wakes in the water: rings where dolphins broke the surface, the dolphin's path as it swims on under it, and the + // surfer's trail. At night they glow with the plankton the foam glows with; by day they are faint foam. + private drawWakes() { + const { H, look } = this + for (const r of this.rings) { + const f = r.age / RING + this.ring(r.x * H, r.y * H, (0.012 + f * 0.05) * H, (1 - f) ** 2 * (look.night ? 0.5 : 0.2)) + } + for (const d of this.dolphins) { + if (d.under < 0) continue + // The wake leads off from where it dived back in, growing as it swims on and fading as it goes deeper. + const x0 = d.x + d.dir * 0.16 + const head = x0 + d.dir * (0.02 + 0.03 * d.under) + this.trail(head * H, x0 * H, d.y * H, (1 - d.under / WAKE) ** 1.5 * (look.night ? 0.7 : 0.15)) + } + if (this.activity === "teeming") { + const s = this.surfer + this.trail((s.x - 0.04) * H, (s.x - 0.3) * H, (0.65 + Math.sin(s.phase * 1.5) * 0.004) * H, look.night ? 0.8 : 0.35) + } + } + + // A ring spreading on the water, flattened by perspective. + private ring(cx: number, cy: number, R: number, k: number) { + const { W, H, hdr, look } = this + const glow = look.night + for (let y = Math.max(0, Math.floor(cy - R * 0.25 - 2)); y <= Math.min(H - 1, Math.ceil(cy + R * 0.25 + 2)); y++) + for (let x = Math.max(0, Math.floor(cx - R - 2)); x <= Math.min(W - 1, Math.ceil(cx + R + 2)); x++) { + const d = Math.hypot(x + 0.5 - cx, (y + 0.5 - cy) / 0.25) + const a = Math.max(0, 1 - Math.abs(d - R) / (1.6 * (H / 180) + 0.6)) * k + if (a <= 0) continue + const o = (y * W + x) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] += glow ? look.foam[c] * a : (look.foam[c] - hdr[o + c]) * a + } + } + + // A wake from head back to tail along row y, widening and fading toward the tail, sparkling where the water churns. + private trail(head: number, tail: number, cy: number, k: number) { + const { W, H, hdr, look } = this + const glow = look.night + const len = Math.abs(head - tail) + const w = 0.02 * H + for (let x = Math.max(0, Math.floor(Math.min(head, tail))); x <= Math.min(W - 1, Math.ceil(Math.max(head, tail))); x++) { + const f = Math.abs(x - head) / len + if (f > 1) continue + const half = (0.15 + 0.85 * f) * w + const fade = (1 - f) ** 1.5 * k + for (let y = Math.max(0, Math.floor(cy - half - 1)); y <= Math.min(H - 1, Math.ceil(cy + half + 1)); y++) { + // Brightest along the two arms of the wake, spreading apart behind the head. + const v = Math.abs(y + 0.5 - cy) / half + const a = Math.max(0, 1 - Math.abs(v - 0.7) * 2.2) * fade * (0.6 + 0.4 * hash2(x >> 1, y)) + if (a <= 0) continue + const o = (y * W + x) * 3 + for (let c = 0; c < 3; c++) hdr[o + c] += glow ? look.foam[c] * a : (look.foam[c] - hdr[o + c]) * a + } + } } private drawSplashes() { diff --git a/wallpapers/prehistoric.ts b/wallpapers/prehistoric.ts index d37610a..31dfab2 100644 --- a/wallpapers/prehistoric.ts +++ b/wallpapers/prehistoric.ts @@ -4,6 +4,7 @@ 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 type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" // The scene runs slower than real time, which keeps it calm behind text. @@ -38,6 +39,8 @@ interface Look { glow: number smoke: [hot: RGB, cool: RGB, alpha: number] water: RGB + // The tint of what the lake mirrors. + mirror: RGB ripple: RGB tint: RGB } @@ -68,6 +71,7 @@ const LOOKS: Record = { glow: 0.05, smoke: [[0.3, 0.25, 0.22], [0.66, 0.64, 0.64], 0.5], water: [0.22, 0.44, 0.52], + mirror: [0.78, 0.88, 0.92], ripple: [0.55, 0.7, 0.75], tint: [1, 1, 1], }, @@ -97,6 +101,7 @@ const LOOKS: Record = { glow: 0.25, smoke: [[0.9, 0.28, 0.07], [0.16, 0.07, 0.1], 0.55], water: [0.5, 0.2, 0.12], + mirror: [0.9, 0.78, 0.72], ripple: [1, 0.45, 0.15], tint: [0.22, 0.13, 0.1], }, @@ -125,6 +130,7 @@ const LOOKS: Record = { glow: 0.5, smoke: [[0.8, 0.12, 0.03], [0.1, 0.025, 0.07], 0.6], water: [0.02, 0.04, 0.1], + mirror: [0.8, 0.88, 1], ripple: [0.1, 0.35, 0.9], tint: [0.025, 0.04, 0.09], }, @@ -182,6 +188,9 @@ class Prehistoric extends Canvas { private herd: Walker[] = [] private dragonflies: { x: number; y: number; tx: number; ty: number; rest: number; face: number; phase: number }[] = [] private nessie = { t: -1, x: 0, y: 0, dir: 1, wait: 1 } + // The lake's rows: which row each mirrors, how far its ripples wobble the reflection, and how much of it each pixel + // shows (none outside the water or under the plants in front of it). + private pool = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0) } private readonly weather: WeatherLayer private storm = makeStorm() private birds: Flier[] = [] @@ -242,6 +251,8 @@ class Prehistoric extends Canvas { paintClouds(this.hdr, this.W, this.H, this.clouds, top, bottom, this.look.clouds.alpha, this.look.clouds.puffy) paintStorm(this.hdr, this.W, this.H, this.storm, this.look.clouds.top, this.look.clouds.bottom, this.gloom) this.drawSmoke() + const p = this.pool + if (p.y1 > p.y0) reflect(this.hdr, this.W, this.H, p.y0, p.y1, p.rows, p.amp, p.gloss, this.look.mirror, this.time) if (this.activity !== "calm") { this.drawPterosaurs() this.drawSauropod() @@ -322,7 +333,9 @@ class Prehistoric extends Canvas { const blades = 0.9 + 0.1 * Math.sin(x * 1.7 + y * 0.6 + fbm1(x * 0.1, 2) * 6) + (hash2(x, y) - 0.5) * 0.05 return this.rim([lerp(far[0], near[0], depth) * blades, lerp(far[1], near[1], depth) * blades, lerp(far[2], near[2], depth) * blades], x, d, 2) }) + this.pool = { y0: 0, y1: 0, rows: new Float32Array(0), amp: new Float32Array(0), gloss: new Float32Array(0) } if (this.activity === "teeming") this.paintLake() + const water = this.hdr.slice(this.pool.y0 * W * 3, this.pool.y1 * W * 3) this.trees = [0.08 * A, Math.max(0.4 * A, 0.08 * A + 0.45)] this.drawTree(this.trees[0], 0.665, 0.29) this.drawTree(this.trees[1], 0.662, 0.31) @@ -336,6 +349,10 @@ class Prehistoric extends Canvas { 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) + // Plants painted over the water stay in front of its reflection. + const p = this.pool + const o = p.y0 * W * 3 + for (let i = 0; i < p.gloss.length; i++) if (this.hdr[o + i * 3] !== water[i * 3] || this.hdr[o + i * 3 + 1] !== water[i * 3 + 1] || this.hdr[o + i * 3 + 2] !== water[i * 3 + 2]) p.gloss[i] = 0 this.background = this.hdr.slice() } @@ -420,15 +437,18 @@ class Prehistoric extends Canvas { this.lava = { index: Int32Array.from(index), k: Float32Array.from(index, (i) => core[i] * (0.4 + heat[i])), along: Float32Array.from(index, (i) => along[i]) } } - // A still lake in front of the volcano, mirroring it and the sky, with a muddy shore. + // A lake in front of the volcano with a muddy shore. Its water mirrors the forest, the volcano and the sky above it, + // squeezed into the lake's depth, and render() puts the reflection in each frame so the lava and smoke move in it. private paintLake() { const { W, H, A, look } = this const [lx, ly, lrx, lry] = this.lake() const [cx, cy, rx, ry] = [lx * H, ly * H, lrx * H, lry * H] - const mirror = HORIZON * H const red = look.lava[0] const mud = this.paint([0.3, 0.24, 0.15]) - for (let y = Math.max(0, Math.floor(cy - ry - 2)); y < Math.min(H, cy + ry + 3); y++) + const y0 = Math.max(0, Math.floor(cy - ry - 2)) + const y1 = Math.min(H, Math.ceil(cy + ry + 3)) + const gloss = new Float32Array((y1 - y0) * W) + for (let y = y0; y < y1; y++) for (let x = Math.max(0, Math.floor(cx - rx - 3)); x < Math.min(W, cx + rx + 3); x++) { const q = Math.hypot((x + 0.5 - cx) / rx, (y + 0.5 - cy) / ry) const shore = clamp((1.12 - q) * 6, 0, 1) @@ -437,17 +457,25 @@ class Prehistoric extends Canvas { this.blend(o, mud[0], mud[1], mud[2], shore * 0.8) const wet = clamp((1 - q) * Math.min(rx, ry) * 0.5 + 0.5, 0, 1) if (wet <= 0) continue - const my = clamp(Math.round(2 * mirror - y), 0, H - 1) - const mx = clamp(Math.round(x + Math.sin(y * 1.3) * 0.8), 0, W - 1) - const m = (my * W + mx) * 3 - const k = 0.55 + 0.1 * Math.sin(y * 2.1 + x * 0.05) - this.blend(o, lerp(look.water[0], this.hdr[m], k), lerp(look.water[1], this.hdr[m + 1], k), lerp(look.water[2], this.hdr[m + 2], k), wet) + this.blend(o, look.water[0], look.water[1], look.water[2], wet) + gloss[(y - y0) * W + x] = wet * (0.72 + 0.06 * Math.sin(y * 2.1 + x * 0.05)) // The volcano's glow shimmers on the water below it. const sheen = look.glow * 0.25 * Math.exp(-Math.abs(x / H - VENT_X * A) / 0.1) * wet * (0.7 + 0.3 * Math.sin(y * 2.7)) this.hdr[o] += red[0] * sheen this.hdr[o + 1] += red[1] * sheen this.hdr[o + 2] += red[2] * sheen } + // The far shore mirrors the forest's edge, and the near one the volcano's crater, squeezed more toward it. + const far = cy - ry + const n = y1 - y0 + const below = (r: number) => Math.max(0, y0 + r - far) + this.pool = { + y0, + y1, + rows: Float32Array.from({ length: n }, (_, r) => (HORIZON - 0.004) * H - below(r) * (1.5 + (3.2 * below(r)) / (2 * ry))), + amp: new Float32Array(n).fill(0.8 * (H / 180)), + gloss, + } } // The lake's center and radii, in screen heights. diff --git a/wallpapers/tundra.ts b/wallpapers/tundra.ts index f7d1dd3..54fef30 100644 --- a/wallpapers/tundra.ts +++ b/wallpapers/tundra.ts @@ -3,6 +3,7 @@ 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 type { Activity, Settings, Time, Wallpaper } from "../src/wallpaper" +import { reflect } from "../src/water" import { WeatherLayer } from "../src/weather" // The scene runs slower than real time, which keeps it calm behind text. @@ -300,11 +301,26 @@ class Tundra extends Canvas { } } - // A frozen lake with a few cracks in the ice. + // A frozen lake with a few cracks in the ice, its glossy surface dimly mirroring the peaks and the sky. private drawLake(cx: number, cy: number, rx: number, ry: number) { - const H = this.H + const { W, H } = this const ice = this.look.ice this.shape([ell(cx * H, cy * H, rx * H, ry * H, 0, ice)], this.lighting, 0.3) + const y0 = Math.max(0, Math.floor((cy - ry) * H)) + const y1 = Math.min(H, Math.ceil((cy + ry) * H) + 1) + const gloss = new Float32Array((y1 - y0) * W) + for (let y = y0; y < y1; y++) { + const dy = (y + 0.5 - cy * H) / (ry * H) + for (let x = Math.max(0, Math.floor((cx - rx) * H)); x < Math.min(W, (cx + rx) * H + 1); x++) { + const cover = clamp((1 - Math.hypot((x + 0.5 - cx * H) / (rx * H), dy)) * ry * H + 0.5, 0, 1) + gloss[(y - y0) * W + x] = cover * (0.5 - 0.2 * (dy * 0.5 + 0.5)) + } + } + // The far shore mirrors the foot of the mountains, and the near one the sky above the peaks. + const below = (r: number) => Math.max(0, y0 + r - (cy - ry) * H) + const rows = Float32Array.from({ length: y1 - y0 }, (_, r) => 0.598 * H - below(r) * (1.6 + (2.2 * below(r)) / (2 * ry * H))) + const top = Math.max(...ice) + reflect(this.hdr, W, H, y0, y1, rows, new Float32Array(y1 - y0).fill(0.4 * (H / 180)), gloss, [(ice[0] / top) * 0.9, (ice[1] / top) * 0.9, (ice[2] / top) * 0.9], 0) const crack = scaleRGB(ice, 0.7) for (let i = 0; i < 5; i++) { const x0 = cx + (hash(i * 3.7) - 0.5) * rx * 1.4