fuzzyworld

nordic-skating / files / src / render / drifts.ts

drifts.ts

5.4 KB · history · blame

1import * as THREE from 'three'
2import { 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. */
5const TILE = 256
6const PIXELS = 512
7/** Side of the sheet they are drawn on, metres: past where the haze ends. */
8const 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 */
18export 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}