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 @@ -135,6 +135,15 @@ details), and values in between soften it.
- `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`.

### Shadows

`src/shadow.ts` grounds things standing on the ground. Make one `Shade` with `sunShade(time, look.orb,
weather.covered)`, then call `groundShadow(hdr, W, H, shade, x, y, w, h, tip?, lift?)` with the ground point under a
thing, its width and height in pixels, before drawing it: static things in `layout()`, moving ones each frame. It draws a
soft contact shadow and a cast shadow away from the light: short by day, long and spreading from the low sun at sunset,
faint at night, contact only under cloud. `lift` (pixels off the ground) fades the contact shadow and slides the cast one
away, for hops and lifts. Shadows multiply the ground toward a sky-tinted color, so they never gray it.

`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.

Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +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 { fillShadow, groundShadow, sunShade, type Shade } from "./shadow"
export { STAR_TINTS, driftClouds, makeClouds, makeStars, makeStorm, paintClouds, paintSky, paintStars, paintStorm, type Cloud, type Orb, type Star } from "./sky"
export {
ACTIVITIES,
Expand Down
94 changes: 94 additions & 0 deletions src/shadow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Shadows on the ground: a soft contact shadow right under a thing, and the shadow it casts away from the light.
// Scenes make one Shade per layout with sunShade() and call groundShadow() under each thing that stands on the ground,
// before drawing it: static things in layout(), moving things each frame.
import { clamp, type RGB } from "./math"
import type { Time } from "./wallpaper"

// The ground is seen at a low angle, so a round footprint looks this much shallower than it is wide.
const SQUASH = 0.38

// How a scene's light throws shadows. Shadows multiply the ground toward tint, a dark color tinted by the sky that
// fills them, so they deepen the ground's own hue rather than graying it.
export interface Shade {
tint: RGB
// Strength of the soft patch right under a thing, and of the shadow it casts.
contact: number
cast: number
// Cast length per unit of height.
length: number
// A low sun throws shadows away from (x, y), in fractions of the width and height; a high sun or moon throws them
// all along (x, y).
x: number
y: number
radiate: boolean
}

// Short, soft shadows by day; long ones at sunset, spreading away from the low sun; at night faint contact shadows and
// a hint of moon shadow. Under cloud the light is diffuse, so only contact shadows remain. orb is the sun or moon, in
// fractions of the width and height.
export function sunShade(time: Time, orb: { x: number; y: number }, covered = false): Shade {
const away = orb.x < 0.5 ? 1 : -1
const looks: Record<Time, Shade> = {
day: { tint: [0.36, 0.44, 0.64], contact: 0.75, cast: 0.6, length: 0.6, x: away, y: 0.45, radiate: false },
sunset: { tint: [0.32, 0.22, 0.42], contact: 0.7, cast: 0.75, length: 2.4, x: orb.x, y: orb.y, radiate: true },
night: { tint: [0.35, 0.45, 0.85], contact: 0.45, cast: 0.2, length: 0.6, x: away, y: 0.45, radiate: false },
}
const shade = looks[time]
return { ...shade, contact: covered ? shade.contact * 0.8 : shade.contact, cast: covered ? 0 : shade.cast }
}

// The shadows of a thing w pixels wide and h tall standing at (x, y), the ground under its middle. tip is the width
// of the far end of its cast shadow relative to the near end: under 1 for things that narrow upward, over 1 for a tree
// with a wide crown. lift is how far it is off the ground in pixels: the contact shadow spreads and fades, and the cast
// shadow slides away.
export function groundShadow(hdr: Float32Array, W: number, H: number, shade: Shade, x: number, y: number, w: number, h: number, tip = 0.7, lift = 0) {
const [tr, tg, tb] = shade.tint
const up = clamp(lift / Math.max(1, h * 0.5), 0, 1)
// Centered a little in front of the feet: the half behind them is mostly hidden by the thing itself.
const r = w * 0.6 * (1 + up * 0.6)
const cy = y + r * SQUASH * 0.3
if (shade.contact > 0 && up < 1) fillShadow(hdr, W, H, x, cy, x, cy, r, r, tr, tg, tb, shade.contact * (1 - up) ** 2, 1, 0.6)
if (shade.cast <= 0) return
const dx = shade.radiate ? x - shade.x * W : shade.x
const dy = shade.radiate ? Math.max(0, y - shade.y * H) : shade.y
const d = Math.hypot(dx, dy) || 1
// Toward the viewer the ground is foreshortened, so the down-screen part of a shadow is shorter.
const ux = dx / d
const uy = (dy / d) * 0.5
const near = lift * shade.length
const far = near + h * shade.length
fillShadow(hdr, W, H, x + ux * near, y + uy * near, x + ux * far, y + uy * far, w * 0.4, w * 0.4 * tip, tr, tg, tb, shade.cast * (1 - up * 0.5), 0.3, 0.55)
}

// Darkens a soft tapered capsule lying on the ground toward (tr, tg, tb): from (ax, ay) radius r0 to (bx, by) radius
// r1, in pixels, measured with y stretched by 1/SQUASH. Strength a at the near end fades to a * fade at the far end.
// soft is the share of the radius that is penumbra at the near end (1 fades from the middle); it widens to 1 at the far
// end.
export function fillShadow(hdr: Float32Array, W: number, H: number, ax: number, ay: number, bx: number, by: number, r0: number, r1: number, tr: number, tg: number, tb: number, a: number, fade: number, soft: number) {
if (a <= 0) return
const R = Math.max(r0, r1)
const x0 = Math.max(0, Math.floor(Math.min(ax, bx) - R))
const x1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx) + R))
const y0 = Math.max(0, Math.floor(Math.min(ay, by) - R * SQUASH))
const y1 = Math.min(H - 1, Math.ceil(Math.max(ay, by) + R * SQUASH))
const dx = bx - ax
const dy = (by - ay) / SQUASH
const len2 = dx * dx + dy * dy
for (let y = y0; y <= y1; y++) {
const oy = (y + 0.5 - ay) / SQUASH
for (let x = x0; x <= x1; x++) {
const ox = x + 0.5 - ax
const t = len2 > 0 ? clamp((ox * dx + oy * dy) / len2, 0, 1) : 0
const r = r0 + (r1 - r0) * t
const e = Math.hypot(ox - dx * t, oy - dy * t) / r
if (e >= 1) continue
// The penumbra widens along the shadow, away from what casts it.
const k = clamp((1 - e) / (soft + (1 - soft) * t), 0, 1)
const s = a * k * k * (3 - 2 * k) * (1 + (fade - 1) * t)
const i = (y * W + x) * 3
hdr[i] *= 1 - s * (1 - tr)
hdr[i + 1] *= 1 - s * (1 - tg)
hdr[i + 2] *= 1 - s * (1 - tb)
}
}
}
15 changes: 15 additions & 0 deletions wallpapers/beach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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 { groundShadow, sunShade, type Shade } from "../src/shadow"
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"
Expand Down Expand Up @@ -163,6 +164,7 @@ class Beach extends Canvas {
private readonly activity: Activity
private readonly look: Look
private lighting: Lighting = { style: "front", dir: DAYLIGHT }
private readonly shade: Shade
private background = new Float32Array(0)
private stars: Star[]
private clouds: Cloud[]
Expand Down Expand Up @@ -190,6 +192,7 @@ class Beach extends Canvas {
const look = offSeason && settings.time === "day" ? { ...LOOKS.day, ...OFF_SEASON } : LOOKS[settings.time]
// With the sun or moon behind cloud, only a trace of the glitter path is left.
this.look = this.weather.covered ? { ...look, glitter: look.glitter * 0.15 } : look
this.shade = sunShade(settings.time, look.orb, this.weather.covered)
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)
Expand Down Expand Up @@ -312,6 +315,11 @@ class Beach extends Canvas {
this.background = this.hdr.slice()
}

private ground(x: number, y: number, w: number, h: number, tip?: number, lift?: number) {
const H = this.H
groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip, lift)
}

private paint(c: RGB): RGB {
const t = this.look.tint
return [c[0] * t[0], c[1] * t[1], c[2] * t[2]]
Expand Down Expand Up @@ -453,6 +461,8 @@ class Beach extends Canvas {
this.shape([cap(...P(-0.035, 0.06), ...P(0.045, 0.056), 0.009 * s * H, 0.006 * s * H, c), ell(...P(-0.048, 0.059), 0.01 * s * H, 0.009 * s * H, 0, c)], this.lighting, 0.6)
this.shape([cap(...P(-0.012, 0.06), ...P(0.012, 0.059), 0.0095 * s * H, 0.0095 * s * H, this.paint(stripe))], this.lighting, 0.4)
}
// The canopy shades whoever lies under it.
this.ground(ux, uy + 0.05 * s, 0.18 * s, 0.17 * s, 1.2)
this.shape([cap(...P(0, 0.05), ...P(0, -0.12), 0.003 * s * H, 0.003 * s * H, this.paint([0.85, 0.85, 0.82]))], this.lighting, 0.4)
for (let k = 0; k < 6; k++) {
const a0 = Math.PI + (k / 6) * Math.PI
Expand All @@ -466,6 +476,7 @@ class Beach extends Canvas {
private drawDriftwood(x: number, y: number) {
const H = this.H
const wood = this.paint([0.62, 0.55, 0.45])
this.ground(x, y + 0.006, 0.13, 0.02, 1)
this.shape([cap((x - 0.07) * H, y * H, (x + 0.06) * H, (y - 0.008) * H, 0.009 * H, 0.006 * H, wood, 2), cap((x + 0.02) * H, (y - 0.005) * H, (x + 0.05) * H, (y - 0.03) * H, 0.004 * H, 0.002 * H, wood)], this.lighting, 0.7)
}

Expand All @@ -485,6 +496,8 @@ class Beach extends Canvas {
if (this.season !== "summer") this.drawUmbrella(0.64 * A, 0.86, [0.85, 0.15, 0.12])
const [sx, sy] = [0.42 * A, 0.92]
const sand = this.paint([0.78, 0.66, 0.42])
this.ground(sx, sy, 0.1, 0.09, 0.5)
this.ground(0.53 * A, 0.95, 0.028, 0.032)
const box = (x0: number, y0: number, x1: number, y1: number) => [[x0 * H, y0 * H], [x1 * H, y0 * H], [x1 * H, y1 * H], [x0 * H, y1 * H]] as const
this.polygon(box(sx - 0.05, sy - 0.03, sx + 0.05, sy), sand)
for (const [dx, h] of [[-0.04, 0.06], [0, 0.08], [0.04, 0.06]] as const) {
Expand Down Expand Up @@ -626,6 +639,7 @@ class Beach extends Canvas {
const y = lerp(lerp(0.62, beached, come), 0.66, smoothstep(52, 62, t)) + Math.sin(this.time * 1.4) * 0.004 * clamp(afloat, 0, 1)
const tilt = 0.35 + Math.sin(this.time * 1.1) * 0.25 * clamp(afloat, 0, 1)
const S = 0.09 * H
if (afloat < 1) this.ground(x, y + 0.012, 0.07, 0.025, 1, clamp(afloat, 0, 1) * 0.03 * H)
const ca = Math.cos(tilt)
const sa = Math.sin(tilt)
const P = (u: number, v: number): [number, number] => [x * H + (u * ca - v * sa) * S, y * H + (u * sa + v * ca) * S]
Expand All @@ -651,6 +665,7 @@ class Beach extends Canvas {
const y = 0.97 * H
const red = this.paint([0.85, 0.25, 0.12])
const step = c.rest > 0 ? 0 : Math.sin(c.phase) * 0.06
this.ground(c.x, 0.97, 0.055, 0.035)
const parts: Part[] = [ell(x, y - 0.25 * S, 0.4 * S, 0.22 * S, 0, red)]
for (const side of [-1, 1])
for (let k = 0; k < 3; k++) {
Expand Down
12 changes: 12 additions & 0 deletions wallpapers/city.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +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 { groundShadow, sunShade, type Shade } from "../src/shadow"
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"
Expand Down Expand Up @@ -215,6 +216,7 @@ class City extends Canvas {
private readonly activity: Activity
private readonly look: Look
private lighting: Lighting = { style: "front", dir: DAYLIGHT }
private readonly shade: Shade
private background = new Float32Array(0)
// The river with the skyline reflected in it, which each frame ripples sideways where `water` is set.
private river = new Float32Array(0)
Expand Down Expand Up @@ -251,6 +253,7 @@ class City extends Canvas {
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.weather = new WeatherLayer(settings.weather ?? "clear", settings.time, BANK)
this.shade = sunShade(settings.time, this.look.orb, this.weather.covered)
this.wet = settings.weather ? settings.weather === "rain" || settings.weather === "snow" : this.look.night && settings.activity === "teeming"
if (settings.activity !== "teeming") return
const umbrellas: RGB[] = [[0.1, 0.55, 0.6], [0.6, 0.1, 0.4], [0.65, 0.35, 0.05], [0.3, 0.15, 0.6], [0.6, 0.1, 0.08]]
Expand Down Expand Up @@ -304,11 +307,13 @@ 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)
for (const c of this.cars) this.ground(c.x, LANES[c.lane].y, (c.bus ? 0.08 : 0.04) * LANES[c.lane].scale, (c.bus ? 0.023 : 0.015) * LANES[c.lane].scale, 1)
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.
const a = Math.floor((ROAD + 0.012) * H) * W * 3
this.hdr.set(this.background.subarray(a, Math.ceil((ROAD + 0.026) * H) * W * 3), a)
for (const w of this.walkers) this.ground(w.x, 0.996, 0.02, 0.1, 0.8)
for (const w of this.walkers) this.drawWalker(w)
for (const b of this.pigeons) this.shape(bird(b, H, 0.022 * H, this.paint([0.42, 0.44, 0.5])), this.lighting, 0.4)
this.drawRain()
Expand Down Expand Up @@ -378,6 +383,11 @@ class City extends Canvas {
for (let i = 0; i < 400; i++) this.stepTraffic(0.25)
}

private ground(x: number, y: number, w: number, h: number, tip?: number) {
const H = this.H
groundShadow(this.hdr, this.W, H, this.shade, x * H, y * H, w * H, h * H, tip)
}

private paint(c: RGB): RGB {
const t = this.look.tint
return [c[0] * t[0], c[1] * t[1], c[2] * t[2]]
Expand Down Expand Up @@ -632,6 +642,7 @@ class City extends Canvas {
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.ground(x, base, 0.03, 0.05, 1.3)
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)
}
Expand All @@ -643,6 +654,7 @@ class City extends Canvas {
this.polygon(this.rect(-0.01, 0.965, A + 0.01, 0.969), this.paint([0.62, 0.6, 0.56]))
for (let i = 0; this.pillarX(i) - 0.04 < A; i++) {
const x = this.pillarX(i)
this.ground(x, STREET, 0.045, STREET - UNDER, 1)
this.polygon([[(x - 0.028) * H, UNDER * H], [(x + 0.028) * H, UNDER * H], [(x + 0.02) * H, STREET * H], [(x - 0.02) * H, STREET * H]], this.paint(CONCRETE))
this.polygon([[(x + 0.012) * H, UNDER * H], [(x + 0.028) * H, UNDER * H], [(x + 0.02) * H, STREET * H], [(x + 0.008) * H, STREET * H]], this.paint([0.42, 0.41, 0.4]))
}
Expand Down
Loading
Loading