tower / files / src / terrain / ice.ts
ice.ts
e18dad04 2026-09-30 Pavel Safronov | 1 | /** |
| 2 | * A sea's ice, and the dim Tower keeps over it. See design.md §1.9.6, D-090, D-091. | |
| 3 | * | |
| 4 | * The far cap's window is a cold wall, and Tower lets the Deepward Sea freeze against it in | |
| 5 | * bands: open water, then pack ice, then fast ice frozen to the foot of the cap. It keeps | |
| 6 | * that end of the world dim to match — it aims less and less of its light at the sea toward | |
| 7 | * the ice, and none at the fast ice, which gets only the rod's glow. | |
| 8 | * | |
| 9 | * Both are functions of z alone, because the bands are rings: the sea closes on itself | |
| 10 | * around the world, and nothing about the ice changes with theta but the floes. | |
| 11 | */ | |
| 12 | import { G0 } from '../canon/constants.js' | |
| 13 | import type { SeaIce } from '../world/schema.js' | |
| 14 | ||
| 15 | /** Which way the ice lies from the edge: +1 if the fast ice is deepward of it, else -1. */ | |
| 16 | export const iceSide = (ice: SeaIce): number => Math.sign(ice.fastM - ice.edgeM) | |
| 17 | ||
| 18 | /** How far into the ice a point is, in units of the pack's width: 0 at the edge, 1 at the fast ice. */ | |
| 19 | export function intoIce(ice: SeaIce, z: number): number { | |
| 20 | return (z - ice.edgeM) / (ice.fastM - ice.edgeM) | |
| 21 | } | |
| 22 | ||
| 23 | /** Pack ice at its edge covers this much of the sea, and packs to this at the fast ice. */ | |
| 24 | export const PACK_AT_EDGE = 0.3 | |
| 25 | export const PACK_AT_FAST = 0.95 | |
| 26 | ||
| 27 | /** | |
| 28 | * How much of the sea's surface is ice at a depth `t` into the ice (`intoIce`): none on | |
| 29 | * the open water, the pack closing up across its band, and 1 — solid — on the fast ice. | |
| 30 | */ | |
| 31 | export function iceCover(t: number): number { | |
| 32 | if (t < 0) return 0 | |
| 33 | if (t >= 1) return 1 | |
| 34 | return PACK_AT_EDGE + (PACK_AT_FAST - PACK_AT_EDGE) * t | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * Tower's aimed light at the far end, as a share of what it aims everywhere else | |
| 39 | * (D-091). It begins to fall at the open water's shore, is down to `AIMED_AT_EDGE` by the | |
| 40 | * ice edge and `AIMED_AT_FAST` by the fast ice, and is nothing over the fast ice itself. | |
| 41 | */ | |
| 42 | export const AIMED_AT_EDGE = 0.35 | |
| 43 | export const AIMED_AT_FAST = 0.1 | |
| 44 | ||
| 45 | /** | |
| 46 | * What still lights the fast ice by day when Tower aims nothing at it: the rod's own glow, | |
| 47 | * which cannot be aimed away (§1.3.2). Tens of W/m² against the ~1,000 of a noon under the | |
| 48 | * sun — about a twentieth, the permanent overcast dusk §1.9.6 describes. | |
| 49 | */ | |
| 50 | export const ROD_GLOW_BY_DAY = 0.05 | |
| 51 | ||
| 52 | /** | |
| 53 | * The share of Tower's aimed light at `z` (sun and moon both), given the ice and where its | |
| 54 | * open water begins — the far side of the open water from the ice, `openM`. | |
| 55 | */ | |
| 56 | export function aimedShare(ice: SeaIce, openM: number, z: number): number { | |
| 57 | const t = intoIce(ice, z) | |
| 58 | if (t >= 1) return 0 | |
| 59 | if (t >= 0) return AIMED_AT_EDGE + (AIMED_AT_FAST - AIMED_AT_EDGE) * t | |
| 60 | // On the open water, from the shore to the edge. | |
| 61 | const s = (z - openM) / (ice.edgeM - openM) | |
| 62 | if (s <= 0) return 1 | |
| 63 | const k = Math.min(1, s) | |
| 64 | return 1 + (AIMED_AT_EDGE - 1) * k * k * (3 - 2 * k) | |
| 65 | } | |
| 66 | ||
| 67 | /** The sky's light by where it comes from, W/m², as sky.ts `lightMix` splits it. */ | |
| 68 | export interface LightMix { sun: number; rod: number; moon: number } | |
| 69 | ||
| 70 | /** | |
| 71 | * The light on the ground at `z` as a share of what the sky gives everywhere else. The rod | |
| 72 | * is never aimed and is the same everywhere; the sun and the moon are, and fall with | |
| 73 | * `aimedShare` — the sun no lower than the rod's own glow by day. The shaders do the same | |
| 74 | * sum per pixel; this one sets the eye's exposure where the observer stands, so the far end | |
| 75 | * is dusk to someone standing on it rather than black. | |
| 76 | */ | |
| 77 | export function lightShare(ice: SeaIce, openM: number, z: number, mix: LightMix): number { | |
| 78 | const total = mix.sun + mix.rod + mix.moon | |
| 79 | if (total <= 0) return 1 | |
| 80 | const aimed = aimedShare(ice, openM, z) | |
| 81 | return (mix.sun * Math.max(aimed, ROD_GLOW_BY_DAY) + mix.rod + mix.moon * aimed) / total | |
| 82 | } | |
| 83 | ||
| 84 | /** | |
| 85 | * The ice mountains at the foot of the cap (D-093). Grounded ice cannot stand much higher | |
| 86 | * than a few hundred metres over a few kilometres — it spreads under its own weight until | |
| 87 | * the stress at its bed is what the bed will take — so ice that climbs kilometres up the | |
| 88 | * window has to be standing on something. Tower raised a range there for it, as it raised the | |
| 89 | * Uplands, and the snow off the window wind and the meltwater of every pane it clears keep it | |
| 90 | * buried. | |
| 91 | * | |
| 92 | * The rock rises from the sea bed at the range's start to its height at the cap, steepening | |
| 93 | * toward the window. Ice mantles it from where it leaves the water, thickening with the plastic | |
| 94 | * profile of an ice sheet, | |
| 95 | * | |
| 96 | * H² = H0² + (2 τ / ρ g) · x | |
| 97 | * | |
| 98 | * x in from the ice's margin, to `ICE_MANTLE_M` on the heights. τ is 150 kPa, the top of what | |
| 99 | * ice sheets show: cold ice frozen to its bed. The highest ridges of rock stand out of it. | |
| 100 | */ | |
| 101 | export const ICE_DENSITY = 917 | |
| 102 | export const ICE_YIELD_PA = 150_000 | |
| 103 | /** How thick the ice lies on the range once it is well up it, m. */ | |
| 104 | export const ICE_MANTLE_M = 350 | |
| 105 | /** The ice's edge where the rock leaves the water: a front of a few tens of metres. */ | |
| 106 | export const ICE_FRONT_M = 45 | |
| 107 | /** How the rock steepens toward the cap: its height goes as t^RANGE_RISE across the range. */ | |
| 108 | export const RANGE_RISE = 1.6 | |
| 109 | ||
| 110 | /** The range's rock at `t` of the way across it (0 at its start, 1 at the cap), smoothed. */ | |
| 111 | export function rangeRock(t: number, bedM: number, heightM: number): number { | |
| 112 | const k = Math.min(1, Math.max(0, t)) | |
| 113 | return bedM + (heightM - bedM) * k ** RANGE_RISE | |
| 114 | } | |
| 115 | ||
| 116 | /** How far across the range the rock stands `belowM` under the water: where the ice begins. */ | |
| 117 | export function iceMargin(levelM: number, bedM: number, heightM: number, belowM = 40): number { | |
| 118 | const f = (levelM - belowM - bedM) / (heightM - bedM) | |
| 119 | return f <= 0 ? 0 : f >= 1 ? 1 : f ** (1 / RANGE_RISE) | |
| 120 | } | |
| 121 | ||
| 122 | /** The ice's thickness `xM` metres in from its margin. */ | |
| 123 | export function iceMantle(xM: number): number { | |
| 124 | if (xM < 0) return 0 | |
| 125 | const k = (2 * ICE_YIELD_PA) / (ICE_DENSITY * G0) | |
| 126 | return Math.min(ICE_MANTLE_M, Math.sqrt(ICE_FRONT_M * ICE_FRONT_M + k * xM)) | |
| 127 | } |