fuzzyworld

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

ice.ts

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

8b9467ca 2026-10-09 Pavel Safronov1import * as THREE from 'three'
c123191e 2026-10-10 Pavel Safronov2import { crackNetwork } from '../ice/cracks.js'
646446cd 2026-10-09 Pavel Safronov3import { BREAKING_THICKNESS } from '../ice/strength.js'
a8fd3f13 2026-10-10 Pavel Safronov4import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js'
8b9467ca 2026-10-09 Pavel Safronov5
6// Black ice: clear enough that the dark water under it is what you see.
7const ICE = '#0b1a24'
8/** Side of the square of ice the texture covers before it repeats, metres. */
9const TILE = 32
10const PIXELS = 2048
11/** The marks are sized in metres; this is how many pixels a metre is. */
12const PER_METRE = PIXELS / TILE
13/** Side of the sheet of ice that is drawn, metres: well past where the fog ends. */
14const SIZE = 3200
15
646446cd 2026-10-09 Pavel Safronov16/**
17 * How thick the ice is, is painted on it from a small map of the lake around the skater:
18 * this many metres across and along, of which `MAP_BEHIND` are behind the skater, at one
1cb1fefc 2026-10-09 Pavel Safronov19 * value every `MAP_STEP_ACROSS` metres across and `MAP_STEP` along. It is redrawn each
20 * time the skater has gone `MAP_REDRAW` metres along, or twice that across.
646446cd 2026-10-09 Pavel Safronov21 */
1cb1fefc 2026-10-09 Pavel Safronov22const MAP_ACROSS = 1536
646446cd 2026-10-09 Pavel Safronov23const MAP_ALONG = 768
4f816cf0 2026-10-10 Pavel Safronov24/** How much of it is behind a skater who looks down the lake; the rest is ahead. */
646446cd 2026-10-09 Pavel Safronov25const MAP_BEHIND = 96
1cb1fefc 2026-10-09 Pavel Safronov26const MAP_STEP_ACROSS = 12
646446cd 2026-10-09 Pavel Safronov27const MAP_STEP = 4
28const MAP_REDRAW = 32
29/** Thick ice is milky with frost and trapped air; this is the colour that adds. */
a8fd3f13 2026-10-10 Pavel Safronov30const MILK = [0x7d, 0x93, 0xa6] as const
31/**
d94fe229 2026-10-10 Pavel Safronov32 * Ice too thin to be real (world/lake.ts) is lit from within in a colour nothing on a
33 * lake is, a hot pink: the thinner, the more. It is the one thing here that is not
34 * physics, and it should look it.
a8fd3f13 2026-10-10 Pavel Safronov35 */
d94fe229 2026-10-10 Pavel Safronov36const GLOW = [0xff, 0x1a, 0xb8] as const
646446cd 2026-10-09 Pavel Safronov37
8b9467ca 2026-10-09 Pavel Safronov38/** Draw `paint` nine times, shifted by a tile each way, so that it wraps at every edge. */
39function wrapped(g: CanvasRenderingContext2D, paint: () => void): void {
40 for (const dx of [-PIXELS, 0, PIXELS]) {
41 for (const dy of [-PIXELS, 0, PIXELS]) {
42 g.save()
43 g.translate(dx, dy)
44 paint()
45 g.restore()
46 }
47 }
48}
49
50/**
51 * The marks that show the ice is moving under you: without them a sheet of black ice is
c123191e 2026-10-10 Pavel Safronov52 * one flat colour and the skater appears to stand still. Frost in soft patches, the
53 * network of white cracks that black ice is laced with, and bubbles frozen into it.
8b9467ca 2026-10-09 Pavel Safronov54 */
55function paintIce(): HTMLCanvasElement {
56 const canvas = document.createElement('canvas')
57 canvas.width = canvas.height = PIXELS
58 const g = canvas.getContext('2d')!
59 const random = mulberry32(2018)
60 g.fillStyle = ICE
61 g.fillRect(0, 0, PIXELS, PIXELS)
62
63 for (let i = 0; i < 45; i++) {
64 const x = random() * PIXELS
65 const y = random() * PIXELS
66 const r = (1.2 + random() * 5.5) * PER_METRE
67 const alpha = 0.04 + random() * 0.1
68 wrapped(g, () => {
69 const frost = g.createRadialGradient(x, y, 0, x, y, r)
70 frost.addColorStop(0, `rgba(150, 180, 200, ${alpha})`)
71 frost.addColorStop(1, 'rgba(150, 180, 200, 0)')
72 g.fillStyle = frost
73 g.fillRect(x - r, y - r, 2 * r, 2 * r)
74 })
75 }
76
77 g.lineCap = 'round'
78 g.lineJoin = 'round'
c123191e 2026-10-10 Pavel Safronov79 // The cracks (ice/cracks.ts). A crack in clear ice is not a line on it but a thin
80 // white sheet standing in it, seen from above and a little to one side: a bright edge
81 // where it meets the surface, and its face going down into the ice beside that, fainter
82 // and wider. So each is drawn three times, widest and faintest first; and the long ones
83 // once more to one side, for the face.
84 const LOOK = [
85 { core: [1.4, 0.9], body: [3.6, 0.17], haze: [10, 0.05] },
86 { core: [1.0, 0.72], body: [2.2, 0.13], haze: [5.5, 0.04] },
87 { core: [0.8, 0.42], body: [1.6, 0.07], haze: [0, 0] },
88 ] as const
89 for (const { points, rank } of crackNetwork(random, TILE)) {
90 const trace = (dx: number, dy: number): void => {
8b9467ca 2026-10-09 Pavel Safronov91 g.beginPath()
c123191e 2026-10-10 Pavel Safronov92 points.forEach(([x, y], n) => (n === 0 ? g.moveTo(x * PER_METRE + dx, y * PER_METRE + dy) : g.lineTo(x * PER_METRE + dx, y * PER_METRE + dy)))
8b9467ca 2026-10-09 Pavel Safronov93 g.stroke()
c123191e 2026-10-10 Pavel Safronov94 }
95 // Some are older than others, and have healed: milkier and less sharp.
96 const fresh = 0.7 + 0.3 * random()
97 const look = LOOK[rank]
98 wrapped(g, () => {
99 for (const [width, alpha] of [look.haze, look.body, look.core]) {
100 if (width === 0) continue
101 g.strokeStyle = `rgba(214, 234, 245, ${alpha * fresh})`
102 g.lineWidth = width
103 trace(0, 0)
104 }
105 if (rank === 0) {
106 g.strokeStyle = `rgba(214, 234, 245, ${0.16 * fresh})`
107 g.lineWidth = 1.2
108 trace(3.2, 2.1)
109 }
8b9467ca 2026-10-09 Pavel Safronov110 })
111 }
112
113 g.fillStyle = 'rgba(200, 225, 240, 0.22)'
114 for (let i = 0; i < 900; i++) {
115 g.beginPath()
116 g.arc(random() * PIXELS, random() * PIXELS, 0.8 + random() * 2, 0, Math.PI * 2)
117 g.fill()
118 }
119 return canvas
120}
121
122/**
123 * The ice is one sheet that stays under the skater while its texture slides the other
124 * way. Moving the picture rather than the sheet is what lets the lake go on for ever:
125 * nothing drawn is ever far from the origin, so nothing loses precision however far the
126 * skater has gone.
127 */
128export class Ice {
129 readonly mesh: THREE.Mesh
130 private readonly texture: THREE.CanvasTexture
646446cd 2026-10-09 Pavel Safronov131 private readonly map: THREE.DataTexture
132 /** Where the map of thickness was last drawn from: its middle across, its start along. */
133 private mapX = 0
134 private mapS = NaN
d94fe229 2026-10-10 Pavel Safronov135 private milkiness = 1
8b9467ca 2026-10-09 Pavel Safronov136
a8fd3f13 2026-10-10 Pavel Safronov137 constructor(renderer: THREE.WebGLRenderer, private lake: Lake) {
8b9467ca 2026-10-09 Pavel Safronov138 this.texture = new THREE.CanvasTexture(paintIce())
139 this.texture.colorSpace = THREE.SRGBColorSpace
140 this.texture.wrapS = this.texture.wrapT = THREE.RepeatWrapping
141 this.texture.repeat.set(SIZE / TILE, SIZE / TILE)
142 // The ice is seen at a grazing angle all the way to the horizon; without this the
143 // cracks smear into grey a few metres ahead.
144 this.texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
646446cd 2026-10-09 Pavel Safronov145 // How thick the ice is, is how it looks. Black ice is black because it is thin and
146 // clear; thicker ice is milkier. So the map of thickness is laid on as light added to
147 // the dark sheet, none where the ice will not hold and more the thicker it gets.
148 this.map = new THREE.DataTexture(
1cb1fefc 2026-10-09 Pavel Safronov149 new Uint8Array((MAP_ACROSS / MAP_STEP_ACROSS) * (MAP_ALONG / MAP_STEP) * 4),
150 MAP_ACROSS / MAP_STEP_ACROSS,
646446cd 2026-10-09 Pavel Safronov151 MAP_ALONG / MAP_STEP,
152 )
153 this.map.colorSpace = THREE.SRGBColorSpace
154 this.map.magFilter = THREE.LinearFilter
155 this.map.repeat.set(SIZE / MAP_ACROSS, SIZE / MAP_ALONG)
8b9467ca 2026-10-09 Pavel Safronov156 this.mesh = new THREE.Mesh(
157 new THREE.PlaneGeometry(SIZE, SIZE),
158 // Matt on purpose: a glossy sheet mirrors the sun as one glaring streak down the lake.
a8fd3f13 2026-10-10 Pavel Safronov159 new THREE.MeshLambertMaterial({ map: this.texture, emissive: 0xffffff, emissiveMap: this.map }),
8b9467ca 2026-10-09 Pavel Safronov160 )
161 this.mesh.rotation.x = -Math.PI / 2
162 }
163
a8fd3f13 2026-10-10 Pavel Safronov164 /** Paint the thickness of the ice afresh: something about the lake has been changed. */
165 redraw(): void {
166 this.mapS = NaN
167 }
168
169 /** Be the ice of another lake. */
170 show(lake: Lake): void {
171 this.lake = lake
172 this.redraw()
173 }
174
d94fe229 2026-10-10 Pavel Safronov175 /**
176 * How strongly the milkiness of thick ice shows: 1 by day, less by the light of a
177 * night. Impossible ice is lit from within and takes no notice: it is as pink at
178 * midnight as at noon, which is the more unnatural of the two.
179 */
a8fd3f13 2026-10-10 Pavel Safronov180 set light(strength: number) {
d94fe229 2026-10-10 Pavel Safronov181 // As a share of a colour's value as it is stored, which is not in proportion to its light.
182 this.milkiness = strength ** (1 / 2.2)
183 this.redraw()
a8fd3f13 2026-10-10 Pavel Safronov184 }
185
4f816cf0 2026-10-10 Pavel Safronov186 /**
187 * Put the right patch of ice under a skater who is at `x` across and `s` along.
188 * `facing` is 1 for a skater looking down the lake, -1 for one looking back up it.
189 */
190 update(x: number, s: number, facing = 1): void {
8b9467ca 2026-10-09 Pavel Safronov191 // Only the fraction of a tile matters, and taking it here, in doubles, is what keeps
192 // the picture sharp at any distance.
193 // (The sheet is a whole, even number of tiles across, so its middle is on a tile edge.)
194 this.texture.offset.set((x / TILE) % 1, (s / TILE) % 1)
646446cd 2026-10-09 Pavel Safronov195
4f816cf0 2026-10-10 Pavel Safronov196 // The map lies ahead of the skater whichever way that is, and round them when they
197 // look across the lake. It is redrawn when where it should start has moved on.
198 const behind = facing > 0.5 ? MAP_BEHIND : facing < -0.5 ? MAP_ALONG - MAP_BEHIND : MAP_ALONG / 2
199 if (!(Math.abs(s - behind - this.mapS) < MAP_REDRAW) || Math.abs(x - this.mapX) > 2 * MAP_REDRAW) this.drawMap(x, s - behind)
646446cd 2026-10-09 Pavel Safronov200 // The map stays where it was drawn while the sheet moves on with the skater.
201 this.map.offset.set(
202 (x - this.mapX + MAP_ACROSS / 2 - SIZE / 2) / MAP_ACROSS,
203 (s - this.mapS - SIZE / 2) / MAP_ALONG,
204 )
205 }
206
4f816cf0 2026-10-10 Pavel Safronov207 /** Draw the map for a skater at `x` across, starting `from` metres along. */
208 private drawMap(x: number, from: number): void {
646446cd 2026-10-09 Pavel Safronov209 // On whole steps, so that a redraw lands every value exactly where it was before.
4f816cf0 2026-10-10 Pavel Safronov210 this.mapS = Math.floor(from / MAP_STEP) * MAP_STEP
1cb1fefc 2026-10-09 Pavel Safronov211 this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS
646446cd 2026-10-09 Pavel Safronov212 const data = this.map.image.data as Uint8Array
213 let p = 0
214 for (let row = 0; row < MAP_ALONG / MAP_STEP; row++) {
1cb1fefc 2026-10-09 Pavel Safronov215 for (let col = 0; col < MAP_ACROSS / MAP_STEP_ACROSS; col++) {
646446cd 2026-10-09 Pavel Safronov216 const h = this.lake.thickness(
1cb1fefc 2026-10-09 Pavel Safronov217 this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP_ACROSS,
646446cd 2026-10-09 Pavel Safronov218 this.mapS + (row + 0.5) * MAP_STEP,
219 )
220 const milk = Math.max(0, (h - BREAKING_THICKNESS) / (MAX_THICKNESS - BREAKING_THICKNESS))
a8fd3f13 2026-10-10 Pavel Safronov221 const glow = Math.max(0, (BREAKING_THICKNESS - h) / (BREAKING_THICKNESS - MIN_THICKNESS))
d94fe229 2026-10-10 Pavel Safronov222 for (let c = 0; c < 3; c++) data[p++] = Math.min(255, Math.round(MILK[c]! * milk * this.milkiness + GLOW[c]! * glow))
646446cd 2026-10-09 Pavel Safronov223 data[p++] = 255
224 }
225 }
226 this.map.needsUpdate = true
8b9467ca 2026-10-09 Pavel Safronov227 }
228}