nordic-skating / mistral-medium-35-littlejs / files / src / render / drifts.ts
Branch mistral-medium-35-littlejs · default branch · all branches
drifts.ts
8bb08e4a 2026-10-10 Pavel Safronov | 1 | import * as THREE from 'three' |
| 2 | import { mulberry32 } from '../world/lake.js' | |
| 3 | ||
| 4 | /** Side of the square of lake the picture of the drifts covers before it repeats, metres, and its side in pixels. */ | |
| 5 | const TILE = 256 | |
| 6 | const PIXELS = 512 | |
| 7 | /** Side of the sheet they are drawn on, metres: past where the haze ends. */ | |
| 8 | const SIZE = 1600 | |
| 9 | ||
| 10 | /** | |
| 11 | * Snow lying on the ice in drifts: long streaks laid down by the wind, all running the | |
| 12 | * way it blows. Where one lies the ice is covered, and a skater on it is slowed and | |
| 13 | * hears snow under the blade, not ice (skater.ts, audio/iceSound.ts). | |
| 14 | * | |
| 15 | * The drifts are one picture, repeated across the lake and turned to the wind. The same | |
| 16 | * picture is what is asked where a drift lies, so what is seen and what is felt agree. | |
| 17 | */ | |
| 18 | export class Drifts { | |
| 19 | readonly mesh: THREE.Mesh | |
| 20 | private readonly canvas = document.createElement('canvas') | |
| 21 | private readonly texture: THREE.CanvasTexture | |
| 22 | private readonly corners: THREE.BufferAttribute | |
| 23 | /** How much snow lies at each pixel of the picture, 0 to 255; none of it when there are no drifts. */ | |
| 24 | private lying: Uint8ClampedArray | null = null | |
| 25 | /** The way the wind blows, as how far across the lake and how far along it for each metre. */ | |
| 26 | private windX = 0 | |
| 27 | private windS = 1 | |
| 28 | ||
| 29 | constructor() { | |
| 30 | this.canvas.width = this.canvas.height = PIXELS | |
| 31 | this.texture = new THREE.CanvasTexture(this.canvas) | |
| 32 | this.texture.colorSpace = THREE.SRGBColorSpace | |
| 33 | this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping | |
| 34 | // The picture's first row is where its coordinates begin, as it is when it is asked about. | |
| 35 | this.texture.flipY = false | |
| 36 | const geometry = new THREE.PlaneGeometry(SIZE, SIZE) | |
| 37 | this.corners = geometry.getAttribute('uv') as THREE.BufferAttribute | |
| 38 | this.mesh = new THREE.Mesh( | |
| 39 | geometry, | |
| 40 | // Lit like the snow on the banks: white by day, blue by night. | |
| 41 | new THREE.MeshLambertMaterial({ map: this.texture, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1 }), | |
| 42 | ) | |
| 43 | this.mesh.rotation.x = -Math.PI / 2 | |
| 44 | this.mesh.visible = false | |
| 45 | } | |
| 46 | ||
| 47 | /** | |
| 48 | * Lay the drifts of lake number `seed`: `amount` of them, from 0 for none to 1 for | |
| 49 | * those of a blizzard, by a wind blowing `windFrom` radians round from down the lake. | |
| 50 | */ | |
| 51 | show(seed: number, amount: number, windFrom: number): void { | |
| 52 | this.windX = Math.sin(windFrom) | |
| 53 | this.windS = Math.cos(windFrom) | |
| 54 | this.mesh.visible = amount > 0 | |
| 55 | if (amount === 0) { | |
| 56 | this.lying = null | |
| 57 | return | |
| 58 | } | |
| 59 | const g = this.canvas.getContext('2d', { willReadFrequently: true })! | |
| 60 | const random = mulberry32(seed ^ 0x9e3779b9) | |
| 61 | g.clearRect(0, 0, PIXELS, PIXELS) | |
| 62 | const perMetre = PIXELS / TILE | |
| 63 | for (let n = 0; n < 20 + 90 * amount; n++) { | |
| 64 | // A streak: tens of metres long down the wind, a few across, thick in its middle | |
| 65 | // and thinning to nothing at its edges. The wind is along the picture. | |
| 66 | const x = random() * PIXELS | |
| 67 | const y = random() * PIXELS | |
| 68 | const long = (25 + 80 * random()) * perMetre | |
| 69 | const wide = (2 + 6 * random() * (0.5 + amount)) * perMetre | |
| 70 | for (const dx of [-PIXELS, 0, PIXELS]) { | |
| 71 | for (const dy of [-PIXELS, 0, PIXELS]) { | |
| 72 | g.save() | |
| 73 | g.translate(x + dx, y + dy) | |
| 74 | g.scale(long, wide) | |
| 75 | const soft = g.createRadialGradient(0, 0, 0, 0, 0, 1) | |
| 76 | soft.addColorStop(0, 'rgba(236, 241, 246, 0.95)') | |
| 77 | soft.addColorStop(0.55, 'rgba(236, 241, 246, 0.8)') | |
| 78 | soft.addColorStop(1, 'rgba(236, 241, 246, 0)') | |
| 79 | g.fillStyle = soft | |
| 80 | g.fillRect(-1, -1, 2, 2) | |
| 81 | g.restore() | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
| 85 | const picture = g.getImageData(0, 0, PIXELS, PIXELS).data | |
| 86 | this.lying = new Uint8ClampedArray(PIXELS * PIXELS) | |
| 87 | for (let i = 0; i < this.lying.length; i++) this.lying[i] = picture[i * 4 + 3]! | |
| 88 | this.texture.needsUpdate = true | |
| 89 | } | |
| 90 | ||
| 91 | /** Where a place on the lake is in the picture, in tiles: down the wind, and across it. */ | |
| 92 | private place(x: number, s: number): [number, number] { | |
| 93 | return [(x * this.windX + s * this.windS) / TILE, (x * this.windS - s * this.windX) / TILE] | |
| 94 | } | |
| 95 | ||
| 96 | /** How much snow lies at `x` across the lake and `s` along it: 0 for bare ice to 1 for a drift's middle. */ | |
| 97 | cover(x: number, s: number): number { | |
| 98 | if (!this.lying) return 0 | |
| 99 | const [u, v] = this.place(x, s) | |
| 100 | const col = Math.floor((u - Math.floor(u)) * PIXELS) | |
| 101 | const row = Math.floor((v - Math.floor(v)) * PIXELS) | |
| 102 | return this.lying[row * PIXELS + col]! / 255 | |
| 103 | } | |
| 104 | ||
| 105 | /** Put the right drifts under a skater who is at `x` across and `s` along. */ | |
| 106 | update(x: number, s: number): void { | |
| 107 | if (!this.mesh.visible) return | |
| 108 | // The sheet stays under the skater, and each of its corners is told which part of | |
| 109 | // the picture it is over: worked out here, where the numbers are exact, and with the | |
| 110 | // whole tiles taken off so that they are small. | |
| 111 | const [u0, v0] = this.place(x, s) | |
| 112 | const half = SIZE / 2 | |
| 113 | // The corners as the sheet has them: across, and up the sheet, which is along the lake. | |
| 114 | const at = [[-half, half], [half, half], [-half, -half], [half, -half]] as const | |
| 115 | for (const [i, [dx, ds]] of at.entries()) { | |
| 116 | this.corners.setXY( | |
| 117 | i, | |
| 118 | u0 - Math.floor(u0) + (dx * this.windX + ds * this.windS) / TILE, | |
| 119 | v0 - Math.floor(v0) + (dx * this.windS - ds * this.windX) / TILE, | |
| 120 | ) | |
| 121 | } | |
| 122 | this.corners.needsUpdate = true | |
| 123 | } | |
| 124 | } |