nordic-skating / files / src / render / snow.ts
snow.ts
236f7d03 2026-10-10 Pavel Safronov | 1 | import * as THREE from 'three' |
| 2 | import { mulberry32 } from '../world/lake.js' | |
| 3 | ||
| 4 | /** The box of air round the skater that has snow in it: metres across, up, and along. */ | |
| 5 | const BOX = [44, 22, 44] as const | |
| 6 | /** Snowflakes in a blizzard; lighter snow shows a share of them. */ | |
| 7 | const FLAKES = 6000 | |
| 8 | /** How fast a snowflake falls, m/s. */ | |
| 9 | const FALL = 1.5 | |
| 10 | ||
| 11 | const vertex = /* glsl */ ` | |
| 12 | attribute float aOrder; | |
| 13 | uniform vec3 uBox; | |
| 14 | uniform vec3 uDrift; | |
| 15 | uniform float uAmount; | |
| 16 | uniform float uPixels; | |
| 17 | varying float vShow; | |
| 18 | void main() { | |
| 19 | // Every flake is somewhere in the box, and the box goes round: what leaves one side | |
| 20 | // of it comes in at the other, so the air is always full without a flake being made. | |
| 21 | vec3 at = fract(position + uDrift) * uBox - vec3(uBox.x * 0.5, 3.0, uBox.z * 0.5); | |
| 22 | vec4 seen = modelViewMatrix * vec4(at, 1.0); | |
| 23 | gl_Position = projectionMatrix * seen; | |
| 24 | float far = length(seen.xyz); | |
| 25 | gl_PointSize = uPixels * (0.05 + 0.06 * fract(aOrder * 7.3)) / far; | |
| 26 | // Lighter snow is fewer flakes; and they fade out towards the edge of the box. | |
| 27 | vShow = step(aOrder, uAmount) * smoothstep(21.0, 14.0, far) * smoothstep(0.3, 1.5, far); | |
| 28 | } | |
| 29 | ` | |
| 30 | ||
| 31 | const fragment = /* glsl */ ` | |
| 32 | uniform vec3 uColour; | |
| 33 | varying float vShow; | |
| 34 | void main() { | |
| 35 | float soft = smoothstep(0.5, 0.15, length(gl_PointCoord - 0.5)); | |
| 36 | if (vShow * soft < 0.01) discard; | |
| 37 | gl_FragColor = vec4(uColour, vShow * soft * 0.9); | |
| 38 | #include <colorspace_fragment> | |
| 39 | } | |
| 40 | ` | |
| 41 | ||
| 42 | /** | |
| 43 | * Falling snow: a box of flakes that stays round the skater, through which the flakes | |
| 44 | * fall, are blown by the wind, and stream past as the skater moves. | |
| 45 | */ | |
| 46 | export class Snow { | |
| 47 | readonly points: THREE.Points | |
| 48 | private readonly uniforms = { | |
| 49 | uBox: { value: new THREE.Vector3(...BOX) }, | |
| 50 | uDrift: { value: new THREE.Vector3() }, | |
| 51 | uAmount: { value: 0 }, | |
| 52 | uPixels: { value: 1 }, | |
| 53 | uColour: { value: new THREE.Color() }, | |
| 54 | } | |
| 55 | ||
| 56 | constructor() { | |
| 57 | const random = mulberry32(1947) | |
| 58 | const places = new Float32Array(FLAKES * 3) | |
| 59 | const order = new Float32Array(FLAKES) | |
| 60 | for (let i = 0; i < FLAKES; i++) { | |
| 61 | for (let c = 0; c < 3; c++) places[i * 3 + c] = random() | |
| 62 | order[i] = random() | |
| 63 | } | |
| 64 | const geometry = new THREE.BufferGeometry() | |
| 65 | geometry.setAttribute('position', new THREE.BufferAttribute(places, 3)) | |
| 66 | geometry.setAttribute('aOrder', new THREE.BufferAttribute(order, 1)) | |
| 67 | this.points = new THREE.Points(geometry, new THREE.ShaderMaterial({ | |
| 68 | uniforms: this.uniforms, vertexShader: vertex, fragmentShader: fragment, | |
| 69 | transparent: true, depthWrite: false, | |
| 70 | })) | |
| 71 | this.points.frustumCulled = false | |
| 72 | } | |
| 73 | ||
| 74 | /** How much snow: 0 for none, 1 for a blizzard; and its colour in this light. */ | |
| 75 | show(amount: number, colour: number): void { | |
| 76 | this.uniforms.uAmount.value = amount | |
| 77 | this.uniforms.uColour.value.set(colour) | |
| 78 | this.points.visible = amount > 0 | |
| 79 | } | |
| 80 | ||
| 81 | /** `height` is the height of the picture in pixels, which sets how big a flake is drawn. */ | |
| 82 | resize(height: number): void { | |
| 83 | this.uniforms.uPixels.value = height | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Move the snow on by `dt` seconds: falling, blown by a wind of `windX` m/s across the | |
| 88 | * lake and `windS` along it, and left behind by a skater moving at `moveX` and `moveS`. | |
| 89 | */ | |
| 90 | update(dt: number, windX: number, windS: number, moveX: number, moveS: number): void { | |
| 91 | const drift = this.uniforms.uDrift.value | |
| 92 | // What is drawn looks down -z, so along the lake is the other way round. | |
| 93 | drift.x = (drift.x + ((windX - moveX) * dt) / BOX[0]) % 1 | |
| 94 | drift.y = (drift.y - (FALL * dt) / BOX[1]) % 1 | |
| 95 | drift.z = (drift.z - ((windS - moveS) * dt) / BOX[2]) % 1 | |
| 96 | } | |
| 97 | } |