nordic-skating / commit
feat(sky): shooting stars, a comet, and an aeroplane drawing its trail
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 } }