nordic-skating / local-littlejs / files / src / render / ice.ts
Branch local-littlejs · default branch · all branches
ice.ts
| 1 | import * as THREE from 'three' |
| 2 | import { crackNetwork } from '../ice/cracks.js' |
| 3 | import { BREAKING_THICKNESS } from '../ice/strength.js' |
| 4 | import { type Lake, MAX_THICKNESS, MIN_THICKNESS, mulberry32 } from '../world/lake.js' |
| 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 | |
| 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 |
| 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. |
| 21 | */ |
| 22 | const MAP_ACROSS = 1536 |
| 23 | const MAP_ALONG = 768 |
| 24 | /** How much of it is behind a skater who looks down the lake; the rest is ahead. */ |
| 25 | const MAP_BEHIND = 96 |
| 26 | const MAP_STEP_ACROSS = 12 |
| 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. */ |
| 30 | const MILK = [0x7d, 0x93, 0xa6] as const |
| 31 | /** |
| 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. |
| 35 | */ |
| 36 | const GLOW = [0xff, 0x1a, 0xb8] as const |
| 37 | |
| 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 |
| 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. |
| 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' |
| 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 => { |
| 91 | g.beginPath() |
| 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))) |
| 93 | g.stroke() |
| 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 | } |
| 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 |
| 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 |
| 135 | private milkiness = 1 |
| 136 | |
| 137 | constructor(renderer: THREE.WebGLRenderer, private lake: Lake) { |
| 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() |
| 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( |
| 149 | new Uint8Array((MAP_ACROSS / MAP_STEP_ACROSS) * (MAP_ALONG / MAP_STEP) * 4), |
| 150 | MAP_ACROSS / MAP_STEP_ACROSS, |
| 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) |
| 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. |
| 159 | new THREE.MeshLambertMaterial({ map: this.texture, emissive: 0xffffff, emissiveMap: this.map }), |
| 160 | ) |
| 161 | this.mesh.rotation.x = -Math.PI / 2 |
| 162 | } |
| 163 | |
| 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 | |
| 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 | */ |
| 180 | set light(strength: number) { |
| 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() |
| 184 | } |
| 185 | |
| 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 { |
| 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) |
| 195 | |
| 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) |
| 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 | |
| 207 | /** Draw the map for a skater at `x` across, starting `from` metres along. */ |
| 208 | private drawMap(x: number, from: number): void { |
| 209 | // On whole steps, so that a redraw lands every value exactly where it was before. |
| 210 | this.mapS = Math.floor(from / MAP_STEP) * MAP_STEP |
| 211 | this.mapX = Math.round(x / MAP_STEP_ACROSS) * MAP_STEP_ACROSS |
| 212 | const data = this.map.image.data as Uint8Array |
| 213 | let p = 0 |
| 214 | for (let row = 0; row < MAP_ALONG / MAP_STEP; row++) { |
| 215 | for (let col = 0; col < MAP_ACROSS / MAP_STEP_ACROSS; col++) { |
| 216 | const h = this.lake.thickness( |
| 217 | this.mapX - MAP_ACROSS / 2 + (col + 0.5) * MAP_STEP_ACROSS, |
| 218 | this.mapS + (row + 0.5) * MAP_STEP, |
| 219 | ) |
| 220 | const milk = Math.max(0, (h - BREAKING_THICKNESS) / (MAX_THICKNESS - BREAKING_THICKNESS)) |
| 221 | const glow = Math.max(0, (BREAKING_THICKNESS - h) / (BREAKING_THICKNESS - MIN_THICKNESS)) |
| 222 | for (let c = 0; c < 3; c++) data[p++] = Math.min(255, Math.round(MILK[c]! * milk * this.milkiness + GLOW[c]! * glow)) |
| 223 | data[p++] = 255 |
| 224 | } |
| 225 | } |
| 226 | this.map.needsUpdate = true |
| 227 | } |
| 228 | } |