tower / files / src / terrain / ice.ts
ice.ts
| 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 | } |