fuzzyworld

nordic-skating / commit

feat(sky): shooting stars, a comet, and an aeroplane drawing its trail

ce371025b7ed4302fdc463a130d392be07dca4b3 · Pavel Safronov · 2026-10-10 08:36:57 -0700 · parents df03cd8b973b

Claude Code usage: this commit ~72.7K out / ~11.2M cached · cost unknown (no per-model trailer) · session 1 tally 1.0K in / 886.4K out / 125.6M cached · usage by commit and session

feat(sky): shooting stars, a comet, and an aeroplane drawing its trail

Shooting stars cross the night sky about four times a minute. One night
lake in three has a comet, ahead and low, with a tail. Two day or dusk
lakes in three have an aeroplane crossing the sky ahead in four or five
minutes, its trail widening and fading behind it, white by day and pink
at dusk. Which lakes have which is the lake's own, by its number.

The sky can also be clouded over, for the weather that follows.

Usage: 1K in / 886.4K out / 125.6M cached (session tally)
rtk:   1.7M saved, 25.3% over 2681 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 src/render/sky.ts | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 148 insertions(+), 6 deletions(-)

diff --git a/src/render/sky.ts b/src/render/sky.ts
index cf62348..fe28dd5 100644
--- a/src/render/sky.ts
+++ b/src/render/sky.ts
@@ -1,4 +1,5 @@
 import * as THREE from 'three'
+import { mulberry32 } from '../world/lake.js'
 
 /** The times the lake can be skated at. */
 export const TIME_NAMES = ['day', 'dusk', 'night', 'aurora'] as const
@@ -23,6 +24,13 @@ export interface Look {
   sunFrom: readonly [number, number, number]
   /** How milky thick ice shows, against 1 by day: it is seen by the light that is on it. */
   iceLight: number
+  /** Whether shooting stars cross this sky, and a comet may hang in it. */
+  meteors: boolean
+  /** Whether an aeroplane may cross it, and the colour the light gives its trail. */
+  planes: boolean
+  trail: number
+  /** The colour of falling snow in this light. */
+  snow: number
 }
 
 export const LOOKS: Record<TimeName, Look> = {
@@ -31,27 +39,74 @@ export const LOOKS: Record<TimeName, Look> = {
     top: 0x6f90b4, horizon: 0x9db4c8, stars: 0, milkyWay: 0, aurora: 0,
     skyLight: 0x9db4c8, groundLight: 0x0b1a24, skyStrength: 1.5,
     sun: 0xfff1d6, sunStrength: 3, sunFrom: [-40, 12, -100], iceLight: 1,
+    meteors: false, planes: true, trail: 0xffffff, snow: 0xffffff,
   },
   // The sun just down ahead: an orange horizon under a deepening blue, and the first stars.
   dusk: {
     top: 0x27365c, horizon: 0xd99a7c, stars: 0.3, milkyWay: 0, aurora: 0,
     skyLight: 0x8a86ad, groundLight: 0x0b1420, skyStrength: 0.95,
     sun: 0xff9a5c, sunStrength: 1.7, sunFrom: [-20, 3, -100], iceLight: 0.55,
+    meteors: false, planes: true, trail: 0xffc2a0, snow: 0xf0d0c4,
   },
   // A clear, moonless night, far from any town: the Milky Way from horizon to horizon.
   night: {
     top: 0x02050c, horizon: 0x0d1828, stars: 1, milkyWay: 1, aurora: 0,
     skyLight: 0x40537a, groundLight: 0x04070b, skyStrength: 0.6,
     sun: 0xa9c1ff, sunStrength: 0.35, sunFrom: [60, 40, -60], iceLight: 0.14,
+    meteors: true, planes: false, trail: 0, snow: 0x8a9cc0,
   },
   // The same night with the northern lights over the lake ahead, and their green on the snow.
   aurora: {
     top: 0x02060c, horizon: 0x0c1c24, stars: 1, milkyWay: 0.35, aurora: 1,
     skyLight: 0x40706a, groundLight: 0x04080a, skyStrength: 0.65,
     sun: 0x8fe6c0, sunStrength: 0.3, sunFrom: [0, 30, -100], iceLight: 0.17,
+    meteors: true, planes: false, trail: 0, snow: 0x9cc8b6,
   },
 }
 
+type Direction = [number, number, number]
+
+/**
+ * What is in the sky over lake number `seed` besides its stars, should the time of day
+ * allow it. Directions are as the skater sees them from the start of the lake: -z is
+ * down the lake, x to the right, y up.
+ */
+export function skyOf(seed: number): {
+  hasComet: boolean
+  comet: Direction
+  cometTail: Direction
+  hasPlane: boolean
+  /** The aeroplane flies the great circle that is square to this. */
+  planePath: Direction
+  /** How far round that circle it is to begin with, radians from the horizon it comes up over, and how fast it goes. */
+  planeFrom: number
+  planeSpeed: number
+} {
+  const random = mulberry32(seed ^ 0x2545f491)
+  // The comet: ahead and to one side, low enough to be in view, its tail up and away
+  // from where the sun went down.
+  const round = (random() - 0.5) * 1.4
+  const high = 0.2 + 0.2 * random()
+  const comet = new THREE.Vector3(Math.sin(round) * Math.cos(high), Math.sin(high), -Math.cos(round) * Math.cos(high))
+  const lean = new THREE.Vector3(random() - 0.5, 1.2, random() - 0.5)
+  const tail = lean.addScaledVector(comet, -lean.dot(comet)).normalize()
+  const hasComet = random() < 0.33
+  // The aeroplane: across the sky ahead from one side to the other, far off and so not
+  // high, taking four or five minutes over it.
+  const tipped = 0.25 + 0.3 * random()
+  const turned = (random() - 0.5) * 1.2
+  const way = random() < 0.5 ? 1 : -1
+  return {
+    hasComet,
+    comet: comet.toArray(),
+    cometTail: tail.toArray(),
+    hasPlane: random() < 0.66,
+    planePath: [-Math.sin(tipped) * Math.sin(turned) * way, Math.cos(tipped) * way, Math.sin(tipped) * Math.cos(turned) * way],
+    planeFrom: random() * 2.5,
+    planeSpeed: 0.01 + 0.004 * random(),
+  }
+}
+
 const vertex = /* glsl */ `
   varying vec3 vDirection;
   void main() {
@@ -68,6 +123,13 @@ const fragment = /* glsl */ `
   uniform float uMilkyWay;
   uniform float uAurora;
   uniform float uTime;
+  uniform float uMeteors;
+  uniform vec4 uComet;
+  uniform vec3 uCometTail;
+  uniform vec4 uPlane;
+  uniform float uPlaneAt;
+  uniform vec3 uTrail;
+  uniform float uOvercast;
 
   float hash(vec3 p) {
     p = fract(p * 0.3183099 + 0.1);
@@ -105,6 +167,27 @@ const fragment = /* glsl */ `
     return step(1.0 - share, h) * smoothstep(size, 0.0, length(fract(p) - 0.5 - off * 0.6)) * (0.3 + 0.7 * fract(h * 91.7)) * twinkle;
   }
 
+  // A shooting star. Time is cut into six-second turns, and in one turn in five a star
+  // falls: from somewhere high in the sky, mostly downwards, for under a second, as a
+  // short bright line that is gone behind its head.
+  float meteor(vec3 d, float which) {
+    float turn = uTime / 6.0 + which * 0.37;
+    float n = floor(turn);
+    float t = fract(turn) * 6.0;
+    if (hash(vec3(n, which, 7.0)) > 0.2 || t > 0.9) return 0.0;
+    float round = hash(vec3(n, which, 1.0)) * 6.2832;
+    float high = 0.35 + 0.9 * hash(vec3(n, which, 2.0));
+    vec3 from = vec3(cos(high) * sin(round), sin(high), cos(high) * cos(round));
+    vec3 any = normalize(vec3(hash(vec3(n, which, 3.0)) - 0.5, -0.6, hash(vec3(n, which, 4.0)) - 0.5));
+    vec3 way = normalize(any - from * dot(any, from));
+    float gone = t / 0.9;
+    vec3 head = normalize(from + way * 0.35 * gone);
+    vec3 tail = normalize(from + way * 0.35 * max(0.0, gone - 0.35));
+    vec3 line = head - tail;
+    float along = clamp(dot(d - tail, line) / dot(line, line), 0.0, 1.0);
+    return smoothstep(0.004, 0.0, length(d - (tail + line * along))) * along * sin(3.1416 * gone);
+  }
+
   void main() {
     vec3 d = normalize(vDirection);
     float up = d.y;
@@ -132,6 +215,37 @@ const fragment = /* glsl */ `
       colour += uAurora * where * (curtain * rays * 1.1 * hue + 0.025 * vec3(0.1, 0.8, 0.4));
     }
 
+    // Shooting stars, two turns of them overlapping, and a comet: a bright head and a
+    // tail that widens and fades away from it.
+    if (uMeteors > 0.0) colour += uMeteors * clear * (meteor(d, 0.0) + meteor(d, 1.0)) * vec3(1.0, 0.97, 0.9);
+    if (uComet.w > 0.0) {
+      vec3 off = d - uComet.xyz;
+      float along = dot(off, uCometTail);
+      float across = length(off - uCometTail * along);
+      float width = 0.008 + 0.2 * max(along, 0.0);
+      float tail = step(0.0, along) * exp(-across * across / (width * width)) * exp(-along / 0.16);
+      colour += uComet.w * clear * (1.3 * exp(-dot(off, off) / 0.00005) + 0.5 * tail) * vec3(0.75, 0.86, 1.0);
+    }
+
+    // An aeroplane, too high to hear: a bright speck crossing the sky on a great circle,
+    // drawing a trail behind it that widens and thins as it ages.
+    if (uPlane.w > 0.0) {
+      vec3 level = normalize(cross(uPlane.xyz, vec3(0.0, 1.0, 0.0)));
+      vec3 over = cross(level, uPlane.xyz);
+      float behind = uPlaneAt - atan(dot(d, over), dot(d, level));
+      float age = behind / 1.1;
+      if (behind > 0.0 && age < 1.0) {
+        float width = 0.0015 + 0.012 * age;
+        float trail = smoothstep(width, width * 0.3, abs(dot(d, uPlane.xyz))) * (1.0 - age) * smoothstep(0.0, 0.02, behind);
+        colour = mix(colour, uTrail, uPlane.w * clear * trail * (0.55 + 0.25 * noise(d * 90.0)));
+      }
+      colour += uPlane.w * clear * smoothstep(0.0035, 0.0, length(d - (level * cos(uPlaneAt) + over * sin(uPlaneAt))));
+    }
+
+    // Under falling snow the sky is cloud: all of it the colour of its horizon, and
+    // whatever is up there shows through only faintly.
+    colour = mix(colour, uHorizon, uOvercast);
+
     gl_FragColor = vec4(colour, 1.0);
     #include <colorspace_fragment>
   }
@@ -151,7 +265,17 @@ export class Sky {
     uMilkyWay: { value: 0 },
     uAurora: { value: 0 },
     uTime: { value: 0 },
+    uMeteors: { value: 0 },
+    uComet: { value: new THREE.Vector4() },
+    uCometTail: { value: new THREE.Vector3(0, 1, 0) },
+    uPlane: { value: new THREE.Vector4() },
+    uPlaneAt: { value: 0 },
+    uTrail: { value: new THREE.Color() },
+    uOvercast: { value: 0 },
   }
+  /** How far round its path the aeroplane was when this sky was shown, and how fast it goes. */
+  private planeFrom = 0
+  private planeSpeed = 0
 
   constructor() {
     this.mesh = new THREE.Mesh(
@@ -165,16 +289,34 @@ export class Sky {
     this.mesh.frustumCulled = false
   }
 
-  show(look: Look): void {
-    this.uniforms.uTop.value.set(look.top)
-    this.uniforms.uHorizon.value.set(look.horizon)
-    this.uniforms.uStars.value = look.stars
-    this.uniforms.uMilkyWay.value = look.milkyWay
-    this.uniforms.uAurora.value = look.aurora
+  /**
+   * Be the sky of a time of day, over lake number `seed`, under `overcast` cloud (0 for
+   * none, 1 for nothing but), with a horizon of the colour `horizon`, which is the haze's. What is in a sky besides its stars is the lake's own: one
+   * night lake in three has a comet, and two day lakes in three an aeroplane.
+   */
+  show(look: Look, seed: number, overcast: number, horizon: THREE.Color): void {
+    const u = this.uniforms
+    u.uTop.value.set(look.top)
+    u.uHorizon.value.copy(horizon)
+    u.uStars.value = look.stars
+    u.uMilkyWay.value = look.milkyWay
+    u.uAurora.value = look.aurora
+    u.uOvercast.value = overcast
+    u.uMeteors.value = look.meteors ? 1 : 0
+
+    const things = skyOf(seed)
+    u.uComet.value.set(...things.comet, look.meteors && things.hasComet ? 1 : 0)
+    u.uCometTail.value.set(...things.cometTail)
+    u.uPlane.value.set(...things.planePath, look.planes && things.hasPlane ? 1 : 0)
+    u.uTrail.value.set(look.trail)
+    this.planeFrom = things.planeFrom
+    this.planeSpeed = things.planeSpeed
   }
 
   /** `seconds` is the time by the clock, not by the skater: the sky moves while the lake waits. */
   update(seconds: number): void {
     this.uniforms.uTime.value = seconds
+    // Over the far horizon, a pause, and then another comes up over the near one.
+    this.uniforms.uPlaneAt.value = ((this.planeFrom + seconds * this.planeSpeed) % (Math.PI + 2.6)) - 0.1
   }
 }