fuzzyworld

tower / files / src / world / frost.ts

frost.ts

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

e18dad04 2026-09-30 Pavel Safronov1/**
2 * The far cap's frost, and the order Tower clears it in. See design.md §1.9.6, D-090, D-091.
3 *
4 * The far cap is single-glazed, and cold glass frosts on its inner face. Tower clears one
5 * pane per turn of the world, in an order it shuffles afresh every loop. The order is drawn
6 * from the world's seed and the loop's number, so which panes are clear at any moment is a
7 * pure function of the clock — the same in both views, and in the book — with no history to
8 * keep.
9 *
10 * Panes are the triangles of the cap's lattice (D-078): side `PANE_KM`, a vertex on the
11 * axis, and one family of mullions parallel to x. A pane is named by its lattice cell (a, b)
12 * and which half of it it is: with basis (s, 0) and (s/2, h), every point is a·e1 + b·e2,
13 * and the cell splits along a + b = whole into a lower and an upper triangle.
14 */
15import { RADIUS_M, SPIN_PERIOD_S } from '../canon/constants.js'
16import { hash3, mulberry32 } from '../terrain/noise.js'
17
18const R_KM = RADIUS_M / 1000
19/** A pane's side, km: six to the cap's radius, as the mullions are drawn (cap-pattern.ts). */
20export const PANE_KM = R_KM / 6
21const H_KM = (PANE_KM * Math.sqrt(3)) / 2
22
23export interface Pane {
24 a: number
25 b: number
26 /** 0 for the lower triangle of the cell, 1 for the upper. */
27 up: 0 | 1
28}
29
30/** The pane a point on the cap is in, km from the axis. */
31export function paneAt(x: number, y: number): Pane {
32 const B = y / H_KM
33 const A = x / PANE_KM - B / 2
34 const a = Math.floor(A), b = Math.floor(B)
35 return { a, b, up: A - a + (B - b) >= 1 ? 1 : 0 }
36}
37
38/** A pane's three corners, km. */
39export function paneCorners(p: Pane): [number, number][] {
40 const at = (a: number, b: number): [number, number] => [a * PANE_KM + (b * PANE_KM) / 2, b * H_KM]
41 return p.up
42 ? [at(p.a + 1, p.b), at(p.a + 1, p.b + 1), at(p.a, p.b + 1)]
43 : [at(p.a, p.b), at(p.a + 1, p.b), at(p.a, p.b + 1)]
44}
45
46/** Nearest distance from the axis to a segment. */
47function segmentDistance([x0, y0]: [number, number], [x1, y1]: [number, number]): number {
48 const dx = x1 - x0, dy = y1 - y0
49 const t = Math.max(0, Math.min(1, -(x0 * dx + y0 * dy) / (dx * dx + dy * dy)))
50 return Math.hypot(x0 + dx * t, y0 + dy * t)
51}
52
53/**
54 * Every pane with glass in it: each triangle the cap's disc reaches into, whole or cut by
55 * the rim. In a fixed order, so a pane's index means the same pane everywhere.
56 */
57export function farCapPanes(): Pane[] {
58 const out: Pane[] = []
59 const reach = Math.ceil(R_KM / H_KM) + 2
60 for (let b = -reach; b <= reach; b++) {
61 for (let a = -2 * reach; a <= 2 * reach; a++) {
62 for (const up of [0, 1] as const) {
63 const c = paneCorners({ a, b, up })
64 // A triangle meets the disc where its nearest point to the axis is inside it. The
65 // axis itself is a corner of six of them, so the edges are enough.
66 const near = Math.min(segmentDistance(c[0]!, c[1]!), segmentDistance(c[1]!, c[2]!),
67 segmentDistance(c[2]!, c[0]!))
68 if (near < R_KM - 1e-9) out.push({ a, b, up })
69 }
70 }
71 }
72 return out
73}
74
75/** The panes, built once. */
76let panesCached: Pane[] | null = null
77export function panes(): Pane[] {
78 return (panesCached ??= farCapPanes())
79}
80
81/** One pane is cleared per turn of the world (D-090): 7 min 29 s. */
82export const TURN_S = SPIN_PERIOD_S
83
84/** A loop clears every pane once: 282 turns, 35.1 hours. */
85export const loopS = (): number => panes().length * TURN_S
86
87/**
88 * How fast frost closes a cleared pane: its cover is 1 - e^(-age / FROST_TAU_S). Ten hours
89 * leaves about one pane in ten nearly clear at any moment, which is §1.9.6's guess; the frost
90 * rate is rough to a factor of ten, and this is the knob that answers for it.
91 */
92export const FROST_TAU_S = 10 * 3600
93
94/** The order of loop `k`: pane indices in the order they are cleared, shuffled from the seed. */
95export function loopOrder(seed: number, k: number): number[] {
96 const n = panes().length
97 const order = Array.from({ length: n }, (_, i) => i)
98 const r = mulberry32(hash3(seed, k, 0xf405))
99 for (let i = n - 1; i > 0; i--) {
100 const j = Math.floor(r() * (i + 1))
101 const t = order[i]!
102 order[i] = order[j]!
103 order[j] = t
104 }
105 return order
106}
107
108/** Where each pane falls in loop `k`'s order: the inverse of `loopOrder`. */
109function slots(seed: number, k: number): number[] {
110 const order = loopOrder(seed, k)
111 const slot = new Array<number>(order.length)
112 order.forEach((pane, s) => { slot[pane] = s })
113 return slot
114}
115
116const cover = (ageS: number): number => 1 - Math.exp(-Math.max(0, ageS) / FROST_TAU_S)
117
118/** The two loops' slots last asked for: a frame at a time, the loop rarely changes. */
119let memo: { seed: number; k: number; now: number[]; before: number[] } | null = null
120
121/**
122 * The frost on every pane at `tS` seconds of world time, 0 (clear) to 1 (white), into
123 * `out`, in `panes()` order. The pane being cleared this turn thaws across it.
124 */
125export function frostAt(seed: number, tS: number, out: Float32Array): Float32Array {
126 const L = loopS()
127 const k = Math.floor(tS / L)
128 if (!memo || memo.seed !== seed || memo.k !== k) {
129 memo = { seed, k, now: slots(seed, k), before: slots(seed, k - 1) }
130 }
131 const start = k * L
132 const s = Math.floor((tS - start) / TURN_S)
133 const progress = (tS - start) / TURN_S - s
134 const n = panes().length
135 for (let i = 0; i < n; i++) {
136 const mine = memo.now[i]!
137 // When this pane last came clear: earlier in this loop, or in the loop before.
138 const clearedAt = mine < s ? start + (mine + 1) * TURN_S : start - L + (memo.before[i]! + 1) * TURN_S
139 if (mine === s) {
140 // Being cleared now: the frost it had built by the start of its turn, thawing.
141 out[i] = cover(start + s * TURN_S - clearedAt) * (1 - progress)
142 } else {
143 out[i] = cover(tS - clearedAt)
144 }
145 }
146 return out
147}