nordic-skating / files / src / render / ice.ts
ice.ts
8b9467ca 2026-10-09 Pavel Safronov | 1 | import * as THREE from 'three' |
c123191e 2026-10-10 Pavel Safronov | 2 | import { crackNetwork } from '../ice/cracks.js' |
646446cd 2026-10-09 Pavel Safronov | 3 | import { BREAKING_THICKNESS } from '../ice/strength.js' |
a8fd3f13 2026-10-10 Pavel Safronov | 4 | import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js' |
8b9467ca 2026-10-09 Pavel Safronov | 5 | |
| 6 | // Black ice: clear enough that the dark water under it is what you see. | |
| 7 | const ICE = '#0b1a24' | |
| 8 | /** Side of the square of ice the texture covers before it repeats, metres. */ | |
| 9 | const TILE = 32 | |
| 10 | const PIXELS = 2048 | |
| 11 | /** The marks are sized in metres; this is how many pixels a metre is. */ | |
| 12 | const PER_METRE = PIXELS / TILE | |
| 13 | /** Side of the sheet of ice that is drawn, metres: well past where the fog ends. */ | |
| 14 | const SIZE = 3200 | |
| 15 | ||
646446cd 2026-10-09 Pavel Safronov | 16 | /** |
| 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 Safronov | 19 | * 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 Safronov | 21 | */ |
1cb1fefc 2026-10-09 Pavel Safronov | 22 | const MAP_ACROSS = 1536 |
646446cd 2026-10-09 Pavel Safronov | 23 | const MAP_ALONG = 768 |
4f816cf0 2026-10-10 Pavel Safronov | 24 | /** How much of it is behind a skater who looks down the lake; the rest is ahead. */ |
646446cd 2026-10-09 Pavel Safronov | 25 | const MAP_BEHIND = 96 |
1cb1fefc 2026-10-09 Pavel Safronov | 26 | const MAP_STEP_ACROSS = 12 |
646446cd 2026-10-09 Pavel Safronov | 27 | const MAP_STEP = 4 |
| 28 | const MAP_REDRAW = 32 | |
| 29 | /** Thick ice is milky with frost and trapped air; this is the colour that adds. */ | |
a8fd3f13 2026-10-10 Pavel Safronov | 30 | const MILK = [0x7d, 0x93, 0xa6] as const |
| 31 | /** | |
d94fe229 2026-10-10 Pavel Safronov | 32 | * 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 Safronov | 35 | */ |
d94fe229 2026-10-10 Pavel Safronov | 36 | const GLOW = [0xff, 0x1a, 0xb8] as const |
646446cd 2026-10-09 Pavel Safronov | 37 | |
8b9467ca 2026-10-09 Pavel Safronov | 38 | /** Draw `paint` nine times, shifted by a tile each way, so that it wraps at every edge. */ |
| 39 | function 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 Safronov | 52 | * 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 Safronov | 54 | */ |
| 55 | function 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 Safronov | 79 | // 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 Safronov | 91 | g.beginPath() |
c123191e 2026-10-10 Pavel Safronov | 92 | 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 Safronov | 93 | g.stroke() |
c123191e 2026-10-10 Pavel Safronov | 94 | } |
| 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 Safronov | 110 | }) |
| 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 | */ | |
| 128 | export class Ice { | |
| 129 | readonly mesh: THREE.Mesh | |
| 130 | private readonly texture: THREE.CanvasTexture | |
646446cd 2026-10-09 Pavel Safronov | 131 | 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 Safronov | 135 | private milkiness = 1 |
8b9467ca 2026-10-09 Pavel Safronov | 136 | |
a8fd3f13 2026-10-10 Pavel Safronov | 137 | constructor(renderer: THREE.WebGLRenderer, private lake: Lake) { |
8b9467ca 2026-10-09 Pavel Safronov | 138 | 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 Safronov | 145 | // 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 Safronov | 149 | new Uint8Array((MAP_ACROSS / MAP_STEP_ACROSS) * (MAP_ALONG / MAP_STEP) * 4), |
| 150 | MAP_ACROSS / MAP_STEP_ACROSS, | |
646446cd 2026-10-09 Pavel Safronov | 151 | 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 Safronov | 156 | 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 Safronov | 159 | new THREE.MeshLambertMaterial({ map: this.texture, emissive: 0xffffff, emissiveMap: this.map }), |
8b9467ca 2026-10-09 Pavel Safronov | 160 | ) |
| 161 | this.mesh.rotation.x = -Math.PI / 2 | |
| 162 | } | |
| 163 | ||
a8fd3f13 2026-10-10 Pavel Safronov | 164 | /** 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 Safronov | 175 | /** |
| 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 Safronov | 180 | set light(strength: number) { |
d94fe229 2026-10-10 Pavel Safronov | 181 | // 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 Safronov | 184 | } |
| 185 | ||
4f816cf0 2026-10-10 Pavel Safronov | 186 | /** |
| 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 Safronov | 191 | // 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 Safronov | 195 | |
4f816cf0 2026-10-10 Pavel Safronov | 196 | // 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 Safronov | 200 | // 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 Safronov | 207 | /** 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 Safronov | 209 | // On whole steps, so that a redraw lands every value exactly where it was before. |
4f816cf0 2026-10-10 Pavel Safronov | 210 | this.mapS = Math.floor(from / MAP_STEP) * MAP_STEP |
1cb1fefc 2026-10-09 Pavel Safronov | 211 | this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS |
646446cd 2026-10-09 Pavel Safronov | 212 | 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 Safronov | 215 | for (let col = 0; col < MAP_ACROSS / MAP_STEP_ACROSS; col++) { |
646446cd 2026-10-09 Pavel Safronov | 216 | const h = this.lake.thickness( |
1cb1fefc 2026-10-09 Pavel Safronov | 217 | this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP_ACROSS, |
646446cd 2026-10-09 Pavel Safronov | 218 | 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 Safronov | 221 | const glow = Math.max(0, (BREAKING_THICKNESS - h) / (BREAKING_THICKNESS - MIN_THICKNESS)) |
d94fe229 2026-10-10 Pavel Safronov | 222 | 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 Safronov | 223 | data[p++] = 255 |
| 224 | } | |
| 225 | } | |
| 226 | this.map.needsUpdate = true | |
8b9467ca 2026-10-09 Pavel Safronov | 227 | } |
| 228 | } |