nordic-skating / files / src / render / marks.ts
marks.ts
b8af453d 2026-10-10 Pavel Safronov | 1 | import * as THREE from 'three' |
c123191e 2026-10-10 Pavel Safronov | 2 | import { crackStar } from '../ice/cracks.js' |
b8af453d 2026-10-10 Pavel Safronov | 3 | |
c123191e 2026-10-10 Pavel Safronov | 4 | /** How many stretches of line the lake remembers; when there are more, the oldest are forgotten. */ |
| 5 | const STRETCHES = 9000 | |
b8af453d 2026-10-10 Pavel Safronov | 6 | /** The line of the skates is drawn on in stretches this long, metres. */ |
c123191e 2026-10-10 Pavel Safronov | 7 | const TRACK_STEP = 0.5 |
| 8 | /** How fast a crack runs out from where it starts, m/s: quick, and slow enough to see go. */ | |
| 9 | const CRACK_SPEED = 28 | |
| 10 | /** When the skater is this far from where the marks are measured from, metres, they are measured from nearer. */ | |
| 11 | const RECENTRE = 400 | |
| 12 | ||
| 13 | type Colour = readonly [number, number, number, number] | |
| 14 | /** What each kind of mark looks like: its colour, and how solid it is. */ | |
| 15 | const TRACK: Colour = [0.82, 0.9, 0.95, 0.5] | |
| 16 | const PUSH: Colour = [0.9, 0.95, 0.98, 0.75] | |
| 17 | const CRACK: Colour = [0.93, 0.97, 1, 0.95] | |
b8af453d 2026-10-10 Pavel Safronov | 18 | |
| 19 | /** | |
| 20 | * What the skater leaves on the ice: the lines of their skates, the scratch of each push, | |
| 21 | * and, on ice thin enough to crack, the cracks. | |
| 22 | * | |
c123191e 2026-10-10 Pavel Safronov | 23 | * Every mark is a hairline: a line with no width of its own, drawn a pixel wide however |
| 24 | * near it is, which is what a scratch or a crack in ice is. A crack runs out from where | |
| 25 | * it starts over a moment, not all at once. | |
| 26 | * | |
| 27 | * The marks stay where they were made, so a skater who turns back, or circles an islet, | |
| 28 | * comes on their own tracks, as in the film. The lake remembers the last few thousand | |
| 29 | * stretches of line and forgets the ones before. | |
b8af453d 2026-10-10 Pavel Safronov | 30 | */ |
| 31 | export class Marks { | |
c123191e 2026-10-10 Pavel Safronov | 32 | readonly mesh: THREE.LineSegments |
| 33 | private readonly places = new Float32Array(STRETCHES * 6) | |
| 34 | private readonly colours = new Float32Array(STRETCHES * 8) | |
| 35 | private readonly geometry = new THREE.BufferGeometry() | |
| 36 | /** How many stretches have ever been drawn; the next goes in the place this leaves over. */ | |
| 37 | private drawn = 0 | |
| 38 | /** Where on the lake the marks are measured from: across, and along. */ | |
| 39 | private fromX = 0 | |
| 40 | private fromS = 0 | |
b8af453d 2026-10-10 Pavel Safronov | 41 | /** Where the line of the skates was last drawn to; nowhere when the pen is up. */ |
| 42 | private last: [number, number] | null = null | |
c123191e 2026-10-10 Pavel Safronov | 43 | /** The stretches of cracks still to appear, soonest first, and the time by this clock. */ |
| 44 | private coming: { at: number; ax: number; as: number; bx: number; bs: number }[] = [] | |
| 45 | private clock = 0 | |
b8af453d 2026-10-10 Pavel Safronov | 46 | |
| 47 | constructor() { | |
c123191e 2026-10-10 Pavel Safronov | 48 | this.geometry.setAttribute('position', new THREE.BufferAttribute(this.places, 3)) |
| 49 | this.geometry.setAttribute('color', new THREE.BufferAttribute(this.colours, 4)) | |
| 50 | this.geometry.setDrawRange(0, 0) | |
| 51 | this.mesh = new THREE.LineSegments( | |
| 52 | this.geometry, | |
| 53 | new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, depthWrite: false }), | |
b8af453d 2026-10-10 Pavel Safronov | 54 | ) |
c123191e 2026-10-10 Pavel Safronov | 55 | // They are never far from the skater, and are moved with them; nothing is gained by asking. |
| 56 | this.mesh.frustumCulled = false | |
b8af453d 2026-10-10 Pavel Safronov | 57 | this.mesh.renderOrder = 1 |
| 58 | } | |
| 59 | ||
c123191e 2026-10-10 Pavel Safronov | 60 | /** How brightly the marks show: 1 by day, less by the light of a night. */ |
| 61 | set light(strength: number) { | |
| 62 | ;(this.mesh.material as THREE.LineBasicMaterial).color.setScalar(strength) | |
b8af453d 2026-10-10 Pavel Safronov | 63 | } |
| 64 | ||
| 65 | /** Another lake, or a skater set down somewhere else: nothing has been marked here. */ | |
| 66 | clear(x: number, s: number): void { | |
c123191e 2026-10-10 Pavel Safronov | 67 | this.drawn = 0 |
| 68 | this.fromX = x | |
| 69 | this.fromS = s | |
b8af453d 2026-10-10 Pavel Safronov | 70 | this.last = null |
c123191e 2026-10-10 Pavel Safronov | 71 | this.coming = [] |
| 72 | this.geometry.setDrawRange(0, 0) | |
b8af453d 2026-10-10 Pavel Safronov | 73 | } |
| 74 | ||
c123191e 2026-10-10 Pavel Safronov | 75 | /** One stretch of line, from one place on the lake to another. */ |
| 76 | private stretch(ax: number, as: number, bx: number, bs: number, colour: Colour): void { | |
| 77 | const n = this.drawn++ % STRETCHES | |
| 78 | // A hair above the ice, so that it is the line that shows and not the ice through it. | |
| 79 | this.places.set([ax - this.fromX, 0.02, -(as - this.fromS), bx - this.fromX, 0.02, -(bs - this.fromS)], n * 6) | |
| 80 | this.colours.set([...colour, ...colour], n * 8) | |
| 81 | this.geometry.getAttribute('position').needsUpdate = true | |
| 82 | this.geometry.getAttribute('color').needsUpdate = true | |
| 83 | this.geometry.setDrawRange(0, Math.min(this.drawn, STRETCHES) * 2) | |
| 84 | } | |
| 85 | ||
| 86 | /** | |
| 87 | * Keep the marks where they lie on the ice, for a skater at `x` across and `s` along, | |
| 88 | * and let the cracks that are opening run on by `dt` seconds. | |
| 89 | */ | |
| 90 | follow(x: number, s: number, dt: number): void { | |
| 91 | if (Math.abs(x - this.fromX) > RECENTRE || Math.abs(s - this.fromS) > RECENTRE) { | |
| 92 | // Measured from where the skater now is, so that the numbers stay small. | |
| 93 | for (let i = 0; i < this.places.length; i += 3) { | |
| 94 | this.places[i] = this.places[i]! + this.fromX - x | |
| 95 | this.places[i + 2] = this.places[i + 2]! - (this.fromS - s) | |
| 96 | } | |
| 97 | this.fromX = x | |
| 98 | this.fromS = s | |
| 99 | this.geometry.getAttribute('position').needsUpdate = true | |
| 100 | } | |
| 101 | this.mesh.position.set(this.fromX - x, 0, -(this.fromS - s)) | |
| 102 | this.clock += dt | |
| 103 | while (this.coming.length > 0 && this.coming[0]!.at <= this.clock) { | |
| 104 | const { ax, as, bx, bs } = this.coming.shift()! | |
| 105 | this.stretch(ax, as, bx, bs, CRACK) | |
b8af453d 2026-10-10 Pavel Safronov | 106 | } |
| 107 | } | |
| 108 | ||
| 109 | /** The line of the skates, on to where the skater now is. */ | |
| 110 | track(x: number, s: number): void { | |
| 111 | if (this.last && Math.hypot(x - this.last[0], s - this.last[1]) < TRACK_STEP) return | |
c123191e 2026-10-10 Pavel Safronov | 112 | if (this.last) this.stretch(this.last[0], this.last[1], x, s, TRACK) |
b8af453d 2026-10-10 Pavel Safronov | 113 | this.last = [x, s] |
| 114 | } | |
| 115 | ||
| 116 | /** | |
| 117 | * The scratch of a push: a short line off to one side of the way the skater is going, | |
| 118 | * `foot` being -1 for a push off the left skate and 1 for the right. | |
| 119 | */ | |
| 120 | push(x: number, s: number, heading: number, foot: number): void { | |
| 121 | // The skate is a little to that side of the skater, and is pushed out and back: a | |
| 122 | // hundred degrees round from the way they are going. | |
| 123 | const footX = x + foot * 0.25 * Math.cos(heading) | |
| 124 | const footS = s - foot * 0.25 * Math.sin(heading) | |
| 125 | const out = heading + foot * 1.75 | |
c123191e 2026-10-10 Pavel Safronov | 126 | this.stretch(footX, footS, footX + 1.1 * Math.sin(out), footS + 1.1 * Math.cos(out), PUSH) |
b8af453d 2026-10-10 Pavel Safronov | 127 | } |
| 128 | ||
| 129 | /** | |
c123191e 2026-10-10 Pavel Safronov | 130 | * A crack opening in the ice at `x`, `s`: a few arms running out from there, about |
| 131 | * `length` metres each, forking as they go (ice/cracks.ts), over the moment it takes | |
| 132 | * a crack to run that far. | |
b8af453d 2026-10-10 Pavel Safronov | 133 | */ |
| 134 | crack(x: number, s: number, length: number): void { | |
c123191e 2026-10-10 Pavel Safronov | 135 | for (const { from, to, along } of crackStar(Math.random, length)) { |
| 136 | this.coming.push({ at: this.clock + along / CRACK_SPEED, ax: x + from[0], as: s + from[1], bx: x + to[0], bs: s + to[1] }) | |
b8af453d 2026-10-10 Pavel Safronov | 137 | } |
c123191e 2026-10-10 Pavel Safronov | 138 | this.coming.sort((a, b) => a.at - b.at) |
b8af453d 2026-10-10 Pavel Safronov | 139 | } |
| 140 | } |