fuzzyworld

nordic-skating / mistral-medium-35-littlejs / files / src / render / sky.ts

Branch mistral-medium-35-littlejs · default branch · all branches

sky.ts

Blame: the commit that last changed each line · file · history

a8fd3f13 2026-10-10 Pavel Safronov1import * as THREE from 'three'
ce371025 2026-10-10 Pavel Safronov2import { mulberry32 } from '../world/lake.js'
a8fd3f13 2026-10-10 Pavel Safronov3
4/** The times the lake can be skated at. */
5export const TIME_NAMES = ['day', 'dusk', 'night', 'aurora'] as const
6export type TimeName = (typeof TIME_NAMES)[number]
7
8/** Everything about how a time of day looks: its sky, and the light it puts on the lake. */
9export interface Look {
10 /** The sky overhead and at the horizon. The haze over the lake is the horizon's colour. */
11 top: number
12 horizon: number
13 /** How much of each there is in the sky, 0 to 1. */
14 stars: number
15 milkyWay: number
16 aurora: number
17 /** The light of the whole sky: its colour from above and from the ice, and its strength. */
18 skyLight: number
19 groundLight: number
20 skyStrength: number
21 /** The sun, or by night the moon: its colour, strength and where it stands. */
22 sun: number
23 sunStrength: number
24 sunFrom: readonly [number, number, number]
25 /** How milky thick ice shows, against 1 by day: it is seen by the light that is on it. */
26 iceLight: number
ce371025 2026-10-10 Pavel Safronov27 /** Whether shooting stars cross this sky, and a comet may hang in it. */
28 meteors: boolean
29 /** Whether an aeroplane may cross it, and the colour the light gives its trail. */
30 planes: boolean
31 trail: number
32 /** The colour of falling snow in this light. */
33 snow: number
a8fd3f13 2026-10-10 Pavel Safronov34}
35
36export const LOOKS: Record<TimeName, Look> = {
37 // A flat, bright winter noon under thin cloud.
38 day: {
39 top: 0x6f90b4, horizon: 0x9db4c8, stars: 0, milkyWay: 0, aurora: 0,
40 skyLight: 0x9db4c8, groundLight: 0x0b1a24, skyStrength: 1.5,
41 sun: 0xfff1d6, sunStrength: 3, sunFrom: [-40, 12, -100], iceLight: 1,
ce371025 2026-10-10 Pavel Safronov42 meteors: false, planes: true, trail: 0xffffff, snow: 0xffffff,
a8fd3f13 2026-10-10 Pavel Safronov43 },
44 // The sun just down ahead: an orange horizon under a deepening blue, and the first stars.
45 dusk: {
46 top: 0x27365c, horizon: 0xd99a7c, stars: 0.3, milkyWay: 0, aurora: 0,
47 skyLight: 0x8a86ad, groundLight: 0x0b1420, skyStrength: 0.95,
48 sun: 0xff9a5c, sunStrength: 1.7, sunFrom: [-20, 3, -100], iceLight: 0.55,
ce371025 2026-10-10 Pavel Safronov49 meteors: false, planes: true, trail: 0xffc2a0, snow: 0xf0d0c4,
a8fd3f13 2026-10-10 Pavel Safronov50 },
51 // A clear, moonless night, far from any town: the Milky Way from horizon to horizon.
52 night: {
53 top: 0x02050c, horizon: 0x0d1828, stars: 1, milkyWay: 1, aurora: 0,
54 skyLight: 0x40537a, groundLight: 0x04070b, skyStrength: 0.6,
55 sun: 0xa9c1ff, sunStrength: 0.35, sunFrom: [60, 40, -60], iceLight: 0.14,
ce371025 2026-10-10 Pavel Safronov56 meteors: true, planes: false, trail: 0, snow: 0x8a9cc0,
a8fd3f13 2026-10-10 Pavel Safronov57 },
58 // The same night with the northern lights over the lake ahead, and their green on the snow.
59 aurora: {
60 top: 0x02060c, horizon: 0x0c1c24, stars: 1, milkyWay: 0.35, aurora: 1,
61 skyLight: 0x40706a, groundLight: 0x04080a, skyStrength: 0.65,
62 sun: 0x8fe6c0, sunStrength: 0.3, sunFrom: [0, 30, -100], iceLight: 0.17,
ce371025 2026-10-10 Pavel Safronov63 meteors: true, planes: false, trail: 0, snow: 0x9cc8b6,
a8fd3f13 2026-10-10 Pavel Safronov64 },
65}
66
ce371025 2026-10-10 Pavel Safronov67type Direction = [number, number, number]
68
69/**
70 * What is in the sky over lake number `seed` besides its stars, should the time of day
71 * allow it. Directions are as the skater sees them from the start of the lake: -z is
72 * down the lake, x to the right, y up.
73 */
74export function skyOf(seed: number): {
75 hasComet: boolean
76 comet: Direction
77 cometTail: Direction
78 hasPlane: boolean
79 /** The aeroplane flies the great circle that is square to this. */
80 planePath: Direction
81 /** How far round that circle it is to begin with, radians from the horizon it comes up over, and how fast it goes. */
82 planeFrom: number
83 planeSpeed: number
84} {
85 const random = mulberry32(seed ^ 0x2545f491)
86 // The comet: ahead and to one side, low enough to be in view, its tail up and away
87 // from where the sun went down.
88 const round = (random() - 0.5) * 1.4
89 const high = 0.2 + 0.2 * random()
90 const comet = new THREE.Vector3(Math.sin(round) * Math.cos(high), Math.sin(high), -Math.cos(round) * Math.cos(high))
91 const lean = new THREE.Vector3(random() - 0.5, 1.2, random() - 0.5)
92 const tail = lean.addScaledVector(comet, -lean.dot(comet)).normalize()
93 const hasComet = random() < 0.33
94 // The aeroplane: across the sky ahead from one side to the other, far off and so not
95 // high, taking four or five minutes over it.
96 const tipped = 0.25 + 0.3 * random()
97 const turned = (random() - 0.5) * 1.2
98 const way = random() < 0.5 ? 1 : -1
99 return {
100 hasComet,
101 comet: comet.toArray(),
102 cometTail: tail.toArray(),
103 hasPlane: random() < 0.66,
104 planePath: [-Math.sin(tipped) * Math.sin(turned) * way, Math.cos(tipped) * way, Math.sin(tipped) * Math.cos(turned) * way],
105 planeFrom: random() * 2.5,
106 planeSpeed: 0.01 + 0.004 * random(),
107 }
108}
109
a8fd3f13 2026-10-10 Pavel Safronov110const vertex = /* glsl */ `
111 varying vec3 vDirection;
112 void main() {
113 vDirection = position;
114 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
115 }
116`
117
118const fragment = /* glsl */ `
119 varying vec3 vDirection;
120 uniform vec3 uTop;
121 uniform vec3 uHorizon;
122 uniform float uStars;
123 uniform float uMilkyWay;
124 uniform float uAurora;
125 uniform float uTime;
ce371025 2026-10-10 Pavel Safronov126 uniform float uMeteors;
127 uniform vec4 uComet;
128 uniform vec3 uCometTail;
129 uniform vec4 uPlane;
130 uniform float uPlaneAt;
131 uniform vec3 uTrail;
132 uniform float uOvercast;
a8fd3f13 2026-10-10 Pavel Safronov133
134 float hash(vec3 p) {
135 p = fract(p * 0.3183099 + 0.1);
136 p *= 17.0;
137 return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
138 }
139 float noise(vec3 x) {
140 vec3 i = floor(x);
141 vec3 f = fract(x);
142 f = f * f * (3.0 - 2.0 * f);
143 return mix(
144 mix(mix(hash(i), hash(i + vec3(1, 0, 0)), f.x), mix(hash(i + vec3(0, 1, 0)), hash(i + vec3(1, 1, 0)), f.x), f.y),
145 mix(mix(hash(i + vec3(0, 0, 1)), hash(i + vec3(1, 0, 1)), f.x), mix(hash(i + vec3(0, 1, 1)), hash(i + vec3(1, 1, 1)), f.x), f.y),
146 f.z);
147 }
148 float fbm(vec3 p) {
149 float sum = 0.0;
150 float gain = 0.5;
151 for (int i = 0; i < 4; i++) {
152 sum += gain * noise(p);
153 p *= 2.03;
154 gain *= 0.5;
155 }
156 return sum;
157 }
158
159 // Stars: the sky is cut into cells, and a cell in every so many has one star in it,
160 // somewhere off its middle, of a brightness of its own.
161 float stars(vec3 d, float cells, float share, float size) {
162 vec3 p = d * cells;
163 vec3 cell = floor(p);
164 float h = hash(cell);
165 vec3 off = vec3(hash(cell + 7.0), hash(cell + 13.0), hash(cell + 29.0)) - 0.5;
166 float twinkle = 0.8 + 0.2 * sin(uTime * (1.5 + 3.0 * h) + h * 40.0);
167 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;
168 }
169
ce371025 2026-10-10 Pavel Safronov170 // A shooting star. Time is cut into six-second turns, and in one turn in five a star
171 // falls: from somewhere high in the sky, mostly downwards, for under a second, as a
172 // short bright line that is gone behind its head.
173 float meteor(vec3 d, float which) {
174 float turn = uTime / 6.0 + which * 0.37;
175 float n = floor(turn);
176 float t = fract(turn) * 6.0;
177 if (hash(vec3(n, which, 7.0)) > 0.2 || t > 0.9) return 0.0;
178 float round = hash(vec3(n, which, 1.0)) * 6.2832;
179 float high = 0.35 + 0.9 * hash(vec3(n, which, 2.0));
180 vec3 from = vec3(cos(high) * sin(round), sin(high), cos(high) * cos(round));
181 vec3 any = normalize(vec3(hash(vec3(n, which, 3.0)) - 0.5, -0.6, hash(vec3(n, which, 4.0)) - 0.5));
182 vec3 way = normalize(any - from * dot(any, from));
183 float gone = t / 0.9;
184 vec3 head = normalize(from + way * 0.35 * gone);
185 vec3 tail = normalize(from + way * 0.35 * max(0.0, gone - 0.35));
186 vec3 line = head - tail;
187 float along = clamp(dot(d - tail, line) / dot(line, line), 0.0, 1.0);
188 return smoothstep(0.004, 0.0, length(d - (tail + line * along))) * along * sin(3.1416 * gone);
189 }
190
a8fd3f13 2026-10-10 Pavel Safronov191 void main() {
192 vec3 d = normalize(vDirection);
193 float up = d.y;
194 vec3 colour = mix(uHorizon, uTop, pow(clamp(up, 0.0, 1.0), 0.45));
195 // Nothing shows through the haze that lies along the horizon.
196 float clear = smoothstep(0.02, 0.3, up);
197
198 // The Milky Way: a band right round the sky, tilted, clouded, and thick with stars.
199 float band = exp(-pow(dot(d, normalize(vec3(0.75, 0.35, 0.56))), 2.0) / 0.022);
200 float cloud = fbm(d * 9.0) * (0.6 + 0.8 * fbm(d * 2.5 + 11.0));
201 colour += uMilkyWay * clear * band * cloud * cloud * 1.1 * vec3(0.32, 0.36, 0.5);
202 float share = 0.09 + 0.3 * band * uMilkyWay;
203 colour += uStars * clear * vec3(0.95, 0.97, 1.0)
204 * (stars(d, 60.0, share, 0.11) + 0.6 * stars(d, 110.0, share * 1.3, 0.14));
205
206 // The aurora: curtains hung from a ceiling far overhead, rippling slowly, each made
207 // of upright rays, green low down and violet where they reach higher.
208 if (uAurora > 0.0 && up > 0.02) {
209 vec2 p = d.xz / (up + 0.15) * 1.2;
210 float warp = fbm(vec3(p * 0.5, uTime * 0.03));
211 float curtain = pow(1.0 - abs(sin(p.y * 1.1 + p.x * 0.25 + warp * 5.0)), 7.0);
212 float rays = 0.45 + 0.55 * noise(vec3(p.x * 7.0 + warp * 3.0, p.y * 0.7, uTime * 0.25));
213 float where = smoothstep(0.03, 0.16, up) * smoothstep(0.85, 0.3, up);
214 vec3 hue = mix(vec3(0.15, 1.0, 0.45), vec3(0.55, 0.3, 1.0), smoothstep(0.25, 0.75, up));
215 colour += uAurora * where * (curtain * rays * 1.1 * hue + 0.025 * vec3(0.1, 0.8, 0.4));
216 }
217
ce371025 2026-10-10 Pavel Safronov218 // Shooting stars, two turns of them overlapping, and a comet: a bright head and a
219 // tail that widens and fades away from it.
220 if (uMeteors > 0.0) colour += uMeteors * clear * (meteor(d, 0.0) + meteor(d, 1.0)) * vec3(1.0, 0.97, 0.9);
221 if (uComet.w > 0.0) {
222 vec3 off = d - uComet.xyz;
223 float along = dot(off, uCometTail);
224 float across = length(off - uCometTail * along);
225 float width = 0.008 + 0.2 * max(along, 0.0);
226 float tail = step(0.0, along) * exp(-across * across / (width * width)) * exp(-along / 0.16);
227 colour += uComet.w * clear * (1.3 * exp(-dot(off, off) / 0.00005) + 0.5 * tail) * vec3(0.75, 0.86, 1.0);
228 }
229
230 // An aeroplane, too high to hear: a bright speck crossing the sky on a great circle,
231 // drawing a trail behind it that widens and thins as it ages.
232 if (uPlane.w > 0.0) {
233 vec3 level = normalize(cross(uPlane.xyz, vec3(0.0, 1.0, 0.0)));
234 vec3 over = cross(level, uPlane.xyz);
235 float behind = uPlaneAt - atan(dot(d, over), dot(d, level));
236 float age = behind / 1.1;
237 if (behind > 0.0 && age < 1.0) {
238 float width = 0.0015 + 0.012 * age;
239 float trail = smoothstep(width, width * 0.3, abs(dot(d, uPlane.xyz))) * (1.0 - age) * smoothstep(0.0, 0.02, behind);
240 colour = mix(colour, uTrail, uPlane.w * clear * trail * (0.55 + 0.25 * noise(d * 90.0)));
241 }
242 colour += uPlane.w * clear * smoothstep(0.0035, 0.0, length(d - (level * cos(uPlaneAt) + over * sin(uPlaneAt))));
243 }
244
245 // Under falling snow the sky is cloud: all of it the colour of its horizon, and
246 // whatever is up there shows through only faintly.
247 colour = mix(colour, uHorizon, uOvercast);
248
a8fd3f13 2026-10-10 Pavel Safronov249 gl_FragColor = vec4(colour, 1.0);
250 #include <colorspace_fragment>
251 }
252`
253
254/**
255 * The sky: a great dome round the skater, painted by a shader. It is drawn first and
256 * never hides anything, and the haze does not touch it, since its horizon is already the
257 * colour of the haze.
258 */
259export class Sky {
260 readonly mesh: THREE.Mesh
261 private readonly uniforms = {
262 uTop: { value: new THREE.Color() },
263 uHorizon: { value: new THREE.Color() },
264 uStars: { value: 0 },
265 uMilkyWay: { value: 0 },
266 uAurora: { value: 0 },
267 uTime: { value: 0 },
ce371025 2026-10-10 Pavel Safronov268 uMeteors: { value: 0 },
269 uComet: { value: new THREE.Vector4() },
270 uCometTail: { value: new THREE.Vector3(0, 1, 0) },
271 uPlane: { value: new THREE.Vector4() },
272 uPlaneAt: { value: 0 },
273 uTrail: { value: new THREE.Color() },
274 uOvercast: { value: 0 },
a8fd3f13 2026-10-10 Pavel Safronov275 }
ce371025 2026-10-10 Pavel Safronov276 /** How far round its path the aeroplane was when this sky was shown, and how fast it goes. */
277 private planeFrom = 0
278 private planeSpeed = 0
a8fd3f13 2026-10-10 Pavel Safronov279
280 constructor() {
281 this.mesh = new THREE.Mesh(
282 new THREE.SphereGeometry(1000, 32, 16),
283 new THREE.ShaderMaterial({
284 uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment,
285 side: THREE.BackSide, depthWrite: false, fog: false,
286 }),
287 )
288 this.mesh.renderOrder = -1
289 this.mesh.frustumCulled = false
290 }
291
ce371025 2026-10-10 Pavel Safronov292 /**
293 * Be the sky of a time of day, over lake number `seed`, under `overcast` cloud (0 for
294 * 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
295 * night lake in three has a comet, and two day lakes in three an aeroplane.
296 */
297 show(look: Look, seed: number, overcast: number, horizon: THREE.Color): void {
298 const u = this.uniforms
299 u.uTop.value.set(look.top)
300 u.uHorizon.value.copy(horizon)
301 u.uStars.value = look.stars
302 u.uMilkyWay.value = look.milkyWay
303 u.uAurora.value = look.aurora
304 u.uOvercast.value = overcast
305 u.uMeteors.value = look.meteors ? 1 : 0
306
307 const things = skyOf(seed)
308 u.uComet.value.set(...things.comet, look.meteors && things.hasComet ? 1 : 0)
309 u.uCometTail.value.set(...things.cometTail)
310 u.uPlane.value.set(...things.planePath, look.planes && things.hasPlane ? 1 : 0)
311 u.uTrail.value.set(look.trail)
312 this.planeFrom = things.planeFrom
313 this.planeSpeed = things.planeSpeed
a8fd3f13 2026-10-10 Pavel Safronov314 }
315
316 /** `seconds` is the time by the clock, not by the skater: the sky moves while the lake waits. */
317 update(seconds: number): void {
318 this.uniforms.uTime.value = seconds
ce371025 2026-10-10 Pavel Safronov319 // Over the far horizon, a pause, and then another comes up over the near one.
320 this.uniforms.uPlaneAt.value = ((this.planeFrom + seconds * this.planeSpeed) % (Math.PI + 2.6)) - 0.1
a8fd3f13 2026-10-10 Pavel Safronov321 }
322}