fuzzyworld

tower / files / src / views / interior-model.ts

interior-model.ts

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

6bb061a6 2026-09-14 Pavel Safronov1/**
2 * Tower's inside, as meshes both 3D views draw: the displaced ground, the two glazed
3 * caps, the bore behind the far one and the beam — with the integrated haze in every
4 * one of their shaders. See design.md §1.8, §2.4.
5 *
6 * The interior view stands in it. The exterior view looks in at it through the
7 * windows (D-060), and that is why every shader here works in the **tower's frame**
8 * rather than the world's: the exterior turns the whole model with the hull, and
9 * haze has to be integrated where the air is, not where the scene graph put it.
10 * `uFrame` carries world into tower coordinates and `uCam` is the eye already in
11 * them. Inside, nothing turns, so both are the identity and the eye as it stands.
12 *
13 * Scene units are kilometres, so the numbers in the code are the numbers in canon.
14 */
80bcc97a 2026-09-17 Pavel Safronov15import type { Object3D, Texture } from 'three'
6bb061a6 2026-09-14 Pavel Safronov16import {
e18dad04 2026-09-30 Pavel Safronov17 AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry, DataTexture,
18 DoubleSide, FrontSide, Group, InstancedMesh, LinearFilter, Matrix3, Matrix4, Mesh, NearestFilter, RGBAFormat,
19 RepeatWrapping, RingGeometry,
20 ShaderMaterial, SphereGeometry,
59c12827 2026-09-28 Pavel Safronov21 Vector3, Vector4,
6bb061a6 2026-09-14 Pavel Safronov22} from 'three'
23import {
59c12827 2026-09-28 Pavel Safronov24 BEAM_RADIUS_M, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2,
25 COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M,
6bb061a6 2026-09-14 Pavel Safronov26 HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M,
42b4554e 2026-09-29 Pavel Safronov27 MOON_RADIUS_M, SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K,
6bb061a6 2026-09-14 Pavel Safronov28} from '../canon/constants.js'
abc0aad0 2026-10-01 Pavel Safronov29import { DIALS, type Sky, lightMix } from '../world/sky.js'
e18dad04 2026-09-30 Pavel Safronov30import { PANE_KM, frostAt, panes } from '../world/frost.js'
31import { ICE, PALETTE_GLSL } from '../terrain/palette.js'
abc0aad0 2026-10-01 Pavel Safronov32import { FARM_GLSL } from '../terrain/farms.js'
14d35b7d 2026-09-16 Pavel Safronov33import { LOWEST_GROUND_M } from '../terrain/tile.js'
e18dad04 2026-09-30 Pavel Safronov34import { AIMED_AT_EDGE, AIMED_AT_FAST, ROD_GLOW_BY_DAY, lightShare as lightShareAt } from '../terrain/ice.js'
80bcc97a 2026-09-17 Pavel Safronov35import { fallSheet } from '../terrain/falls.js'
00ceeedb 2026-09-27 Pavel Safronov36import { ForestView } from './forest-view.js'
a088e5d5 2026-09-17 Pavel Safronov37import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js'
38import { WAVE_AMP, WAVE_LEN, WAVE_PHASE, WAVE_TURN, boomAngle, shipsAt, waveAt } from '../terrain/ships.js'
376f2ce6 2026-09-16 Pavel Safronov39import { terrain } from '../terrain/service.js'
99a2821b 2026-09-29 Pavel Safronov40import type { RailLine } from '../terrain/rails.js'
6bb061a6 2026-09-14 Pavel Safronov41import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js'
376f2ce6 2026-09-16 Pavel Safronov42import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
e331ec2a 2026-09-28 Pavel Safronov43import { skyMatrix, starTexture } from './stars.js'
44import { STAR_FACE_CELLS, STAR_FACE_SIZE } from '../world/celestial.js'
6bb061a6 2026-09-14 Pavel Safronov45
46const KM = 1000
47const R = RADIUS_M / KM
59c12827 2026-09-28 Pavel Safronov48/** The sun's radius, and how far out its glow is drawn, km. */
49const SUN_R = SUN_RADIUS_M / KM
50const SUN_GLOW_R = 24
42b4554e 2026-09-29 Pavel Safronov51/** The moon's radius, and how far out its glow is drawn, km (D-088). */
52const MOON_R = MOON_RADIUS_M / KM
53const MOON_GLOW_R = 12
6bb061a6 2026-09-14 Pavel Safronov54const L = LENGTH_M / KM
55const BEAM_R = BEAM_RADIUS_M / KM
56/** Wall depth in km. The far cap's window is set this far into its own frame. */
57const HULL_D = HULL_DEPTH_M / KM
58/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */
59const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2
60const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM
61/** Width of the sunlit edge structure, in km (D-054). */
62const COLLECTOR_RIM = COLLECTOR_RIM_M / KM
63
64/**
65 * Rayleigh extinction per kilometre at floor density, per channel.
66 *
67 * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about
68 * twice as far as green and blue about half as far — which is the whole reason
69 * distant land in Tower goes blue rather than merely grey, and it costs nothing to
70 * carry three numbers instead of one.
71 */
72export const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM
73const K_RGB: [number, number, number] = [
74 K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4,
75]
76/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */
77const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2)
78
79/**
80 * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL.
81 *
82 * The obvious move is to make it sky blue, and it is wrong: blue already scatters
83 * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and
84 * every distant thing turns mauve regardless of what it is made of. With a neutral
85 * source the blue falls out of the physics, and the far side of the world stays
86 * recognisably forest and desert at the 78% canon says it should be.
87 */
88export const HAZE_DAY = 0x9aa3ad
89export const HAZE_DUSK = 0x8d8792
90
91
92// ---------------------------------------------------------------------- shaders
93
94/**
95 * Transmittance from `a` to `b` through Tower's air, per channel.
96 *
97 * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with
98 * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and
99 * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint
100 * samples land within a percent of the 512-sample version the instruments use, and
101 * the two agree on the canon table in design.md §1.8.
102 *
103 * **The segment is clipped to the air first.** From inside that changes nothing —
104 * the eye and everything it sees are both in the air already. From outside it is
105 * the whole difference between a view in through a window and a view through a
106 * thousand kilometres of make-believe atmosphere: only the part of the sightline
107 * that is actually inside the tube, between the sun cap and the far cap's glass,
108 * gets extinguished. The radius allows for the lake floors cut below the datum, and
109 * the density is held at floor value there rather than climbing past it.
110 */
111const HAZE_GLSL = /* glsl */`
112uniform vec3 uCam;
113uniform vec3 uK;
114uniform vec3 uHaze;
115uniform float uExposure;
116const float A = ${DENSITY_A.toExponential(8)};
117const float RR = ${(R * R).toFixed(4)};
14d35b7d 2026-09-16 Pavel Safronov118// The air reaches down past the deepest ground anything generates (tile.ts).
376f2ce6 2026-09-16 Pavel Safronov119const float RR_AIR = ${((R - LOWEST_GROUND_M / KM + 0.05) ** 2).toFixed(4)};
6bb061a6 2026-09-14 Pavel Safronov120const float Z_AIR0 = 0.0;
121const float Z_AIR1 = ${(L + HULL_D).toFixed(4)};
122
123vec3 towerTransmittance(vec3 from, vec3 to) {
124 vec3 d = to - from;
125 float t0 = 0.0;
126 float t1 = 1.0;
127 float a = dot(d.xy, d.xy);
128 float b = dot(from.xy, d.xy);
129 float c = dot(from.xy, from.xy) - RR_AIR;
130 if (a > 1e-8) {
131 float disc = b * b - a * c;
132 if (disc <= 0.0) return vec3(1.0);
133 float s = sqrt(disc);
134 t0 = max(t0, (-b - s) / a);
135 t1 = min(t1, (-b + s) / a);
136 } else if (c > 0.0) {
137 return vec3(1.0);
138 }
139 if (abs(d.z) > 1e-8) {
140 float za = (Z_AIR0 - from.z) / d.z;
141 float zb = (Z_AIR1 - from.z) / d.z;
142 t0 = max(t0, min(za, zb));
143 t1 = min(t1, max(za, zb));
144 } else if (from.z < Z_AIR0 || from.z > Z_AIR1) {
145 return vec3(1.0);
146 }
147 if (t1 <= t0) return vec3(1.0);
148
149 vec3 start = from + d * t0;
150 vec3 span = d * (t1 - t0);
151 float len = length(span);
152 const int N = 24;
153 float tau = 0.0;
154 for (int i = 0; i < N; i++) {
155 float t = (float(i) + 0.5) / float(N);
156 vec2 p = start.xy + span.xy * t;
157 tau += exp(-A * max(RR - dot(p, p), 0.0));
158 }
159 return exp(-uK * (tau * len / float(N)));
160}
161
162/**
163 * How a window is written out. Inside, \`face\` is what fills a pane — sky — and the
164 * glass is opaque to the renderer because nothing behind it is drawn. Outside,
165 * \`uSeeThrough\` is set and a pane is clear: it keeps only the haze that lies in
166 * front of it, and lets the scene behind show through by the rest. The straight
167 * alpha is taken on green, where canon quotes its extinction.
168 */
169uniform float uSeeThrough;
170vec4 windowColour(vec3 face, vec3 solid, float cover) {
171 vec3 T = towerTransmittance(uCam, vWorld);
172 vec3 air = uHaze * uExposure * (1.0 - T);
173 if (uSeeThrough < 0.5) return vec4(face * T + air, 1.0);
174 float haze = 1.0 - T.g;
175 float alpha = cover + (1.0 - cover) * haze;
176 vec3 col = cover * (solid * T + air) + (1.0 - cover) * air;
177 return vec4(col / max(alpha, 1e-4), alpha);
178}
179`
180
59c12827 2026-09-28 Pavel Safronov181/**
182 * Tower's light (design.md §1.3.4, D-074). By day it comes from the sun: a point on the
183 * axis `uSun.x` km from the sun cap, lit `uSun.y` of the way up. Tower aims the swarm
184 * so the sun is as bright seen from anywhere as from under it, so a surface is lit by how
185 * squarely it faces the sun — as under Earth's — and each place has its noon when the sun
186 * passes over it. By night the sun is out and the beam is banked, and what light there is
42b4554e 2026-09-29 Pavel Safronov187 * comes from the whole rod overhead, `uSun.z` of it — and from the moon (D-088), a point on
188 * the axis `uMoon.x` km from the sun cap throwing `uMoon.y` of the rod's light again.
59c12827 2026-09-28 Pavel Safronov189 *
190 * Every lit surface in both views goes through `direct`, which is what keeps the sun seen
191 * from the floor and the sun seen in through a window the same sun.
192 */
193const SUN_GLSL = /* glsl */`
194uniform vec3 uSun;
42b4554e 2026-09-29 Pavel Safronov195/** The moon: km from the sun cap, its light, how lit it is as it gathers, and its phase —
196 * the lit share of its disc, positive waxing and negative waning (D-088). */
197uniform vec4 uMoon;
f16f084e 2026-09-28 Pavel Safronov198/**
199 * How much of the beam runs on past the sun by day, to the far cap (D-079). The shoal takes
200 * the rest; this is what keeps the rod whole from cap to cap, and the far cap's mirror lit.
201 */
202const float BEAM_PAST_SUN = 0.7;
59c12827 2026-09-28 Pavel Safronov203vec3 sunPos() { return vec3(0.0, 0.0, uSun.x); }
204vec3 sunDir(vec3 p) { return normalize(sunPos() - p); }
42b4554e 2026-09-29 Pavel Safronov205vec3 moonPos() { return vec3(0.0, 0.0, uMoon.x); }
206vec3 moonDir(vec3 p) { return normalize(moonPos() - p); }
59c12827 2026-09-28 Pavel Safronov207vec3 upAt(vec3 p) { return -normalize(vec3(p.xy, 0.0)); }
208float direct(vec3 p, vec3 n) {
42b4554e 2026-09-29 Pavel Safronov209 return uSun.y * max(dot(n, sunDir(p)), 0.0) + uSun.z * max(dot(n, upAt(p)), 0.0)
210 + uMoon.y * max(dot(n, moonDir(p)), 0.0);
59c12827 2026-09-28 Pavel Safronov211}
e18dad04 2026-09-30 Pavel Safronov212
213/**
214 * The far end's dim (D-090, D-091). Tower aims less of its light at the sea toward the ice
215 * and none at the fast ice, which the rod's glow alone lights by day. \`uDim\` is where the
216 * open water begins, the ice edge and the fast ice's edge, km along the axis, and which way
217 * the ice lies from its edge (0: no iced sea). \`uLightMix\` is the sky's light split into
218 * sun, rod and moon, summing to 1 (sky.ts \`lightMix\`). The sums are ice.ts's, which sets
219 * the eye by them where the observer stands.
220 */
221uniform vec4 uDim;
222uniform vec3 uLightMix;
223float aimedShare(float zKm) {
224 if (uDim.w == 0.0) return 1.0;
225 float t = (zKm - uDim.y) / (uDim.z - uDim.y);
226 if (t >= 1.0) return 0.0;
227 if (t >= 0.0) return mix(${AIMED_AT_EDGE.toFixed(4)}, ${AIMED_AT_FAST.toFixed(4)}, t);
228 float s = clamp((zKm - uDim.x) / (uDim.y - uDim.x), 0.0, 1.0);
229 return mix(1.0, ${AIMED_AT_EDGE.toFixed(4)}, s * s * (3.0 - 2.0 * s));
230}
231/**
232 * How lit a place looks against the rest of the world, 0..1: multiplies everything lit
233 * there. The watts go through the same 0.35 power the exposure puts the dial's watts
234 * through (\`setSky\`), because an eye in the dim has adapted to it: the fast ice at a
235 * twentieth of the light looks like dusk, a third as bright, not like a hole.
236 */
237float lightShare(vec3 p) {
238 float a = aimedShare(p.z);
239 return pow(uLightMix.x * max(a, ${ROD_GLOW_BY_DAY.toFixed(4)}) + uLightMix.y + uLightMix.z * a, 0.35);
240}
59c12827 2026-09-28 Pavel Safronov241`
242
376f2ce6 2026-09-16 Pavel Safronov243/**
244 * A terrain tile (world-generation.md §6). Vertices arrive already on the cylinder,
245 * relative to the tile's centre, with normals in the tower's frame and material
246 * weights beside them — so this does nothing but place them and pass them on.
247 */
6bb061a6 2026-09-14 Pavel Safronov248const GROUND_VERT = /* glsl */`
249#include <common>
250#include <logdepthbuf_pars_vertex>
376f2ce6 2026-09-16 Pavel Safronov251uniform mat4 uFrame;
252attribute vec4 aMatA;
253attribute vec4 aMatB;
abc0aad0 2026-10-01 Pavel Safronov254attribute vec3 aExtra;
6bb061a6 2026-09-14 Pavel Safronov255varying vec3 vWorld;
256varying vec3 vNormal2;
376f2ce6 2026-09-16 Pavel Safronov257varying vec4 vMatA;
258varying vec4 vMatB;
abc0aad0 2026-10-01 Pavel Safronov259varying vec3 vExtra;
6bb061a6 2026-09-14 Pavel Safronov260
261void main() {
376f2ce6 2026-09-16 Pavel Safronov262 vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
263 vNormal2 = normal;
264 vMatA = aMatA;
265 vMatB = aMatB;
51c39d8b 2026-09-17 Pavel Safronov266 vExtra = aExtra;
376f2ce6 2026-09-16 Pavel Safronov267 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
6bb061a6 2026-09-14 Pavel Safronov268 #include <logdepthbuf_vertex>
269}
270`
271
272const GROUND_FRAG = /* glsl */`
273#include <common>
274#include <logdepthbuf_pars_fragment>
275varying vec3 vWorld;
276varying vec3 vNormal2;
376f2ce6 2026-09-16 Pavel Safronov277varying vec4 vMatA;
278varying vec4 vMatB;
abc0aad0 2026-10-01 Pavel Safronov279varying vec3 vExtra;
6bb061a6 2026-09-14 Pavel Safronov280${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov281${SUN_GLSL}
376f2ce6 2026-09-16 Pavel Safronov282${PALETTE_GLSL}
abc0aad0 2026-10-01 Pavel Safronov283${FARM_GLSL}
284/**
285 * The farms' light (D-095): x hours since sunrise, y the sun's day, z how much brighter
286 * Tower's noon is than the sky now (through the exposure's 0.35 power), w 0 if the beam is
287 * shut down and nothing can be lit.
288 */
289uniform vec4 uFarm;
6bb061a6 2026-09-14 Pavel Safronov290
376f2ce6 2026-09-16 Pavel Safronov291/** Cheap value noise, to keep the ground from being flat colour. */
6bb061a6 2026-09-14 Pavel Safronov292float hash(vec2 p) {
293 return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
294}
295float vnoise(vec2 p) {
296 vec2 i = floor(p), f = fract(p);
297 f = f * f * (3.0 - 2.0 * f);
298 return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x),
299 mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y);
300}
301
302void main() {
303 #include <logdepthbuf_fragment>
376f2ce6 2026-09-16 Pavel Safronov304 // Elevation from the radius, since the tile knows where it is and nothing else does.
305 float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0;
306 // The palette is sRGB; every uniform colour here is linear. Three inserts that
307 // decode into its own materials and never into a custom one.
abc0aad0 2026-10-01 Pavel Safronov308 vec3 base = pow(groundColour(vMatA, vMatB, vExtra.xy, elevM), vec3(2.2));
51c39d8b 2026-09-17 Pavel Safronov309 /**
310 * Strata. Rock is laid in beds, and a bed is a level in Tower — a constant radius — so
311 * the bands line up across separate mesas however far apart they stand, which is what
312 * makes a field of them read as one formation eroded out of one plateau (§4.5).
313 */
314 float bed = sin(elevM * 0.11) * 0.5 + 0.5;
315 float thin = sin(elevM * 0.53) * 0.5 + 0.5;
316 vec3 warm = vec3(1.05, 0.99, 0.93);
317 float rocky = vMatA.w + vExtra.y;
318 base *= mix(vec3(1.0), mix(vec3(0.93), warm, bed * 0.7 + thin * 0.3), rocky * 0.6);
80bcc97a 2026-09-17 Pavel Safronov319 // Under drawn water the ground is bed: sediment, darker, whatever the land around it.
320 base = mix(base, vec3(0.16, 0.14, 0.11), vMatB.w * 0.8);
376f2ce6 2026-09-16 Pavel Safronov321
322 // Unrolled surface coordinates, km. World xyz shears a pattern into radial streaks
323 // exactly where the ground is most grazing, which is most of the view.
324 vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)});
325 /**
326 * Mottling, as the map has it (D-052): a broad layer that dims and brightens, and a
327 * fine one that pushes the channels apart so ground reads as patchy cover over
328 * soil rather than one colour turned up and down. Water is left alone.
329 */
330 float land = 1.0 - vMatB.y;
331 float broad = vnoise(surf / 5.2);
332 float fine = vnoise(surf / 1.15 + 17.0);
333 float k = 0.9 + 0.2 * broad;
334 float mo = (fine - 0.5) * 0.13;
335 base *= mix(vec3(1.0), vec3(k - mo * 0.55, k + mo, k - mo * 0.25), land);
6bb061a6 2026-09-14 Pavel Safronov336
337 /**
376f2ce6 2026-09-16 Pavel Safronov338 * Grain near the observer. Tiles near the eye have metre spacing, but a metre is
339 * still flat colour at your feet. This is texture, not terrain: it breaks up the
340 * block without claiming there is anything there, and fades out past 4 km.
6bb061a6 2026-09-14 Pavel Safronov341 */
342 float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld));
376f2ce6 2026-09-16 Pavel Safronov343 // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads as
344 // a woven diamond lattice, which is worse than the flat colour it replaced.
6bb061a6 2026-09-14 Pavel Safronov345 vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86);
346 float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45;
376f2ce6 2026-09-16 Pavel Safronov347 base *= 1.0 + (grain - 0.5) * 0.12 * near * land;
6bb061a6 2026-09-14 Pavel Safronov348
abc0aad0 2026-10-01 Pavel Safronov349 /**
350 * Farmland (farms.ts, D-095): the field this is in, its crop in rows over bare soil, the
351 * flowers or fruit among the leaves, and tracks between the fields. Close in a row is a
352 * row; past a few pixels a row, the crop is the mix it averages to. Coordinates are taken
353 * in the field's block, from its centre, which keeps the noise small enough for float32.
354 * A farm's edge follows its fields' (D-097), and is sharpened here so the tile's vertices
355 * do not smear it across a neighbour.
356 */
357 float farmW = smoothstep(0.3, 0.7, vExtra.z);
358 int crop = 0;
359 if (farmW > 0.01) {
360 float zM = vWorld.z * 1000.0;
361 float aM = atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)};
362 if (aM < 0.0) aM += ${(2 * Math.PI * R * 1000).toFixed(1)};
363 vec4 fld = farmField(zM, aM);
364 crop = int(fld.x + 0.5);
365 // Along the rows and across them, metres from the block's centre: small, so float32 holds.
366 vec2 local = fld.wz;
367 float foot = max(length(fwidth(vec2(zM, aM))), 0.01);
368 float rowM = CROP_ROW[crop];
369 // Close in, a crop is plants: clumps and gaps at a hand's breadth, fading out by the
370 // time a pixel covers half a metre.
371 float closeIn = 1.0 - smoothstep(0.05, 0.5, foot);
372 float clump = vnoise(local / 0.12) * 0.5 + vnoise(local / 0.45 + 7.7) * 0.5;
373 float plant = 1.0 - 0.3 * smoothstep(0.62, 0.78, 1.0 - clump) * closeIn;
374 if (rowM > 0.0) {
375 float st = fract(local.y / rowM);
376 float band = smoothstep(0.12, 0.22, st) * (1.0 - smoothstep(0.78, 0.88, st));
377 plant = mix(band * plant, 0.66, smoothstep(rowM * 0.25, rowM * 0.8, foot));
378 }
379 float cover = CROP_COVER[crop];
380 float blossom = vnoise(local / 0.35) * 0.6 + vnoise(local / 1.7 + 3.1) * 0.4;
381 float bloom = smoothstep(1.0 - cover - 0.08, 1.0 - cover + 0.08, blossom);
382 bloom = mix(bloom, cover, smoothstep(0.1, 0.6, foot));
383 vec3 crops = mix(CROP_LEAF[crop], CROP_BLOOM[crop], bloom) * (1.0 + (clump - 0.5) * 0.5 * closeIn);
384 vec3 field = mix(FARM_SOIL, crops, plant);
385 field = mix(field, FARM_TRACK, 1.0 - smoothstep(-foot, foot, fld.y));
386 base = mix(base, field, farmW);
387 }
388
59c12827 2026-09-28 Pavel Safronov389 // Lit by the sun wherever on the axis it is (SUN_GLSL). The rest of the world is lit
390 // land, and lit land is what fills the shadows.
e18dad04 2026-09-30 Pavel Safronov391 vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure * lightShare(vWorld);
abc0aad0 2026-10-01 Pavel Safronov392 /**
393 * Each crop on its own day (farms.ts \`fieldLight\`): lit as at noon from sunrise for as
394 * many hours as the plant wants, on into the night if it wants more than the sun gives;
395 * taken down to the rod's glow by day if it wants fewer; dark by night.
396 */
397 if (farmW > 0.01 && uFarm.w > 0.5) {
398 vec3 litF = lit;
399 if (uFarm.x < CROP_DAY[crop]) litF = base * 1.14 * uExposure * uFarm.z;
400 else if (uFarm.x < uFarm.y) litF = lit * ${(ROD_GLOW_BY_DAY ** 0.35).toFixed(4)};
401 lit = mix(lit, litF, farmW);
402 }
6bb061a6 2026-09-14 Pavel Safronov403
404 vec3 T = towerTransmittance(uCam, vWorld);
405 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
406 #include <colorspace_fragment>
407}
408`
409
80bcc97a 2026-09-17 Pavel Safronov410/**
411 * Water (world-generation.md §4.2–§4.4). Every lake, sea and river is a surface at its own
412 * level, drawn over the ground with the depth test deciding the shoreline.
413 *
414 * Waves are a sum of four Gerstner-like components around the body's wind, sized from
415 * its wind and fetch in the worker (water.ts). They displace the surface only near the
416 * eye, where the vertices are close enough to carry them; everywhere they shade it, and
417 * each component fades out once it is smaller than a few pixels, so distant water is
418 * calm rather than a shimmer of aliasing. A river's flow scrolls ripples downstream.
419 *
420 * The surface coordinates are metres on the unrolled floor, taken from the tower-frame
421 * position, so the pattern is continuous across tiles and around theta.
422 */
423const WAVES_GLSL = /* glsl */`
424uniform float uTime;
425const float G = 9.80665;
a088e5d5 2026-09-17 Pavel Safronov426// Generated from ships.ts, which runs the same sum so a ship rides the waves drawn under it.
427const float WAVE_LEN[${WAVE_LEN.length}] = float[](${WAVE_LEN.map((v) => v.toFixed(3)).join(', ')});
428const float WAVE_AMP[${WAVE_LEN.length}] = float[](${WAVE_AMP.map((v) => v.toFixed(3)).join(', ')});
429const float WAVE_TURN[${WAVE_LEN.length}] = float[](${WAVE_TURN.map((v) => v.toFixed(3)).join(', ')});
430const float WAVE_PHASE[${WAVE_LEN.length}] = float[](${WAVE_PHASE.map((v) => v.toFixed(3)).join(', ')});
431
432/**
433 * Surface coordinates, metres, measured from a reference point near the eye.
434 *
435 * Around the ring the obvious coordinate is atan(y, x) × R, but in float32 that is good
436 * to only about 6 m at Tower's radius: noise and waves finer than that smear into radial
437 * streaks. So the angle is taken from a reference direction instead, where it is small
438 * and exact, and the reference moves with the eye in whole steps of SURF_SNAP_M — a
439 * multiple of every noise period used here — so patterns never jump (setCamera). Waves
440 * add back what the reference's own position contributes to their phase (uWaveOffset).
441 */
442uniform vec4 uSurfRef; // cos and sin of the reference angle, its z in metres, unused
443vec2 surfaceMetres(vec3 world) {
444 float da = atan(uSurfRef.x * world.y - uSurfRef.y * world.x, uSurfRef.x * world.x + uSurfRef.y * world.y);
445 return vec2(world.z * 1000.0 - uSurfRef.z, da * ${(R * 1000).toFixed(1)});
446}
447uniform vec4 uBodies[${MAX_BODIES}];
448uniform float uWaveOffset[${MAX_BODIES * WAVE_LEN.length}];
449float waveOffset(int body, int i) {
450 return body < 0 ? 0.0 : uWaveOffset[body * ${WAVE_LEN.length} + i];
451}
80bcc97a 2026-09-17 Pavel Safronov452
453// Height of the wave sum at surface position p (metres), and its gradient, fading each
454// component out below 'footprint' metres of wavelength.
a088e5d5 2026-09-17 Pavel Safronov455vec3 waves(vec2 p, float amp, float len, float dir, int body, float footprint) {
80bcc97a 2026-09-17 Pavel Safronov456 vec3 out3 = vec3(0.0);
457 if (amp <= 0.0 || len <= 0.0) return out3;
a088e5d5 2026-09-17 Pavel Safronov458 for (int i = 0; i < ${WAVE_LEN.length}; i++) {
80bcc97a 2026-09-17 Pavel Safronov459 float L = len * WAVE_LEN[i];
a088e5d5 2026-09-17 Pavel Safronov460 float fade = smoothstep(4.0, 16.0, L / max(footprint, 1e-3));
80bcc97a 2026-09-17 Pavel Safronov461 if (fade <= 0.0) continue;
462 float a = amp * WAVE_AMP[i] * fade;
463 float th = dir + WAVE_TURN[i];
464 vec2 d = vec2(cos(th), sin(th));
465 float k = 6.2831853 / L;
466 float w = sqrt(G * k);
467 // Time wraps every ten minutes: long enough that nobody sees it, short enough that
468 // float precision in the phase never degrades.
a088e5d5 2026-09-17 Pavel Safronov469 float ph = k * dot(d, p) - w * mod(uTime, 600.0) + WAVE_PHASE[i] + waveOffset(body, i);
80bcc97a 2026-09-17 Pavel Safronov470 out3.x += a * cos(ph);
471 out3.yz -= a * k * sin(ph) * d;
472 }
473 return out3;
474}
475
a088e5d5 2026-09-17 Pavel Safronov476/**
477 * A water vertex's waves — amplitude, wavelength, direction, and the fraction of the
478 * surface in whitecaps. A river carries its own chop in the attribute and never breaks; a
479 * lake or sea carries -(index + 1), and its sea state, which follows the weather, is
480 * looked up here (sea-state.ts).
481 */
482/**
483 * The wind's own short sea alone, -1 trough to 1 crest: where whitecaps break. Keyed to
484 * these rather than the whole sum, which a storm's 600 m swell dominates, so caps spread
485 * over the sea instead of lining up along the swell's crests.
486 */
487float chop(vec2 p, float amp, float len, float dir, int body) {
488 if (amp <= 0.0 || len <= 0.0) return 0.0;
489 float h = 0.0, total = 0.0;
490 for (int i = ${WAVE_LEN.length - 3}; i < ${WAVE_LEN.length}; i++) {
491 float L = len * WAVE_LEN[i];
492 float th = dir + WAVE_TURN[i];
493 float k = 6.2831853 / L;
494 float ph = k * dot(vec2(cos(th), sin(th)), p) - sqrt(G * k) * mod(uTime, 600.0) + WAVE_PHASE[i] + waveOffset(body, i);
495 h += WAVE_AMP[i] * cos(ph);
496 total += WAVE_AMP[i];
497 }
498 return h / total;
499}
500
501int bodyOf(vec4 water) { return water.y >= 0.0 ? -1 : int(-water.y - 0.5); }
502vec4 waveParams(vec4 water) {
503 if (water.y >= 0.0) return vec4(water.yzw, 0.0);
504 return uBodies[bodyOf(water)];
80bcc97a 2026-09-17 Pavel Safronov505}
506`
507
e18dad04 2026-09-30 Pavel Safronov508/**
509 * Sea ice (D-090): the pack's floes and the fast ice's cracks, on the Deepward Sea's rings.
510 *
511 * A floe is a cell of a jittered grid — a Voronoi cell, which is the shape ice breaks into:
512 * straight edges, meeting at odd angles. Each cell is ice or open water by its own hash
513 * against the cover the tile carries (field.ts \`seaIce\`), so the pack loosens toward its
514 * edge on its own. Where two ice cells meet there is a crack; where ice meets open water
515 * the floe stops short of the cell's edge, which opens the leads.
516 *
517 * Floes are 110 m and bigger, so unlike the waves they need no surface reference: absolute
518 * metres along the axis and around are good to centimetres in float32. The grid around is a
519 * whole number of cells, so the world closes on itself without a seam, and the hash is a
520 * permutation mod 289 — exact in float, so the same floe on every GPU.
521 */
522/** The frost table: a texel per lattice cell, with room for every pane's (a, b) (frost.ts). */
523const FROST_SIZE = 32
524const FROST_OFFSET = 16
525
526const FLOE_M = 110
527const ICE_LINEAR = `vec3(${ICE.map((c) => ((c / 255) ** 2.2).toFixed(4)).join(', ')})`
528const FLOES_AROUND = Math.round((2 * Math.PI * R * 1000) / FLOE_M)
529const FAST_CRACK_M = 660
530const FAST_AROUND = Math.round((2 * Math.PI * R * 1000) / FAST_CRACK_M)
531const ICE_GLSL = /* glsl */`
532float icePermute(float x) { return mod((34.0 * x + 1.0) * x, 289.0); }
533float iceHash(vec2 c, float salt) {
534 vec2 m = mod(c, 289.0);
535 return icePermute(icePermute(icePermute(m.x) + m.y) + salt) / 289.0;
536}
537/** Waves under ice: the pack damps the sea, and fast ice stills it. */
538float iceCalm(float cover) { return 1.0 - smoothstep(0.0, 0.6, cover); }
539
540/**
541 * The two nearest cell centres to a point, in cells: x, y the nearest cell, z the distance
542 * to the edge between it and the second nearest (in cells), w that second cell's index
543 * packed as offset (0..8).
544 */
545vec4 iceCells(vec2 x, float around, out vec2 second) {
546 vec2 n = floor(x), f = fract(x);
547 float d1 = 9.0, d2 = 9.0;
548 vec2 c1 = n, c2 = n, p1 = vec2(0.0), p2 = vec2(0.0);
549 for (int j = -1; j <= 1; j++) {
550 for (int i = -1; i <= 1; i++) {
551 vec2 g = vec2(float(i), float(j));
552 vec2 c = n + g;
553 c.y = mod(c.y, around);
554 vec2 pt = g + 0.1 + 0.8 * vec2(iceHash(c, 11.0), iceHash(c, 23.0));
555 float d = dot(pt - f, pt - f);
556 if (d < d1) { d2 = d1; c2 = c1; p2 = p1; d1 = d; c1 = c; p1 = pt; }
557 else if (d < d2) { d2 = d; c2 = c; p2 = pt; }
558 }
559 }
560 // Distance to the bisector between the two nearest centres: the edge between the cells.
561 vec2 mid = 0.5 * (p1 + p2);
562 float edge = dot(mid - f, normalize(p2 - p1));
563 second = c2;
564 return vec4(c1, edge, 0.0);
565}
566
567/**
568 * The ice at a point of the sea: x how much of the pixel is ice, y how dark with cracks,
569 * z the shade of the floe it is on. \`cover\` is the tile's, 1 on fast ice.
570 */
571vec3 seaIce(vec3 world, float cover, float footprint) {
572 vec2 m = vec2(world.z * 1000.0, atan(world.y, world.x) * ${(R * 1000).toFixed(1)});
573 if (m.y < 0.0) m.y += ${(2 * Math.PI * R * 1000).toFixed(1)};
574 float fast = step(0.999, cover);
575 vec3 out3 = vec3(0.0, 0.0, 1.0);
576
577 // The pack: floes over open leads.
578 vec2 x = vec2(m.x / ${FLOE_M.toFixed(1)}, m.y / ${(2 * Math.PI * R * 1000 / FLOES_AROUND).toFixed(4)});
579 vec2 c2;
580 vec4 v = iceCells(x, ${FLOES_AROUND.toFixed(1)}, c2);
581 bool ice1 = fast > 0.5 || iceHash(v.xy, 37.0) < cover;
582 bool ice2 = fast > 0.5 || iceHash(c2, 37.0) < cover;
583 float edgeM = v.z * ${FLOE_M.toFixed(1)};
584 float aa = max(footprint, 0.05);
585 // Leads open as the pack loosens: floes stop short of the cell edge by up to 12 m.
586 float lead = mix(12.0, 1.0, cover);
587 float onIce = ice1 ? (ice2 ? 1.0 : smoothstep(lead - aa, lead + aa, edgeM)) : 0.0;
588 // A crack where two floes meet: a metre and a half of darker, wetter ice.
589 float crack = (ice1 && ice2 && fast < 0.5) ? 1.0 - smoothstep(0.0, 1.5 + aa, edgeM) : 0.0;
590 // Each floe its own white: older floes whiter, younger greyer toward the edge of the pack.
591 float shade = 0.84 + 0.16 * iceHash(v.xy, 51.0);
592 shade *= mix(0.72, 1.0, smoothstep(0.3, 0.8, cover));
593
594 // Far off, a floe is under a pixel: the sea whitens by its share of ice instead.
595 float far = smoothstep(${(FLOE_M * 0.12).toFixed(1)}, ${(FLOE_M * 0.6).toFixed(1)}, footprint);
596 float share = fast > 0.5 ? 1.0 : cover * (1.0 - 0.02 * lead);
597 out3.x = mix(onIce, share, far);
598 out3.y = mix(crack, 0.0, far);
599 out3.z = mix(shade, 0.9 * mix(0.72, 1.0, smoothstep(0.3, 0.8, cover)), far);
600
601 // The fast ice is one sheet, cracked across in long lines and ridged where it pressed.
602 if (fast > 0.5) {
603 vec2 xb = vec2(m.x / ${FAST_CRACK_M.toFixed(1)}, m.y / ${(2 * Math.PI * R * 1000 / FAST_AROUND).toFixed(4)});
604 vec2 cb;
605 vec4 vb = iceCells(xb, ${FAST_AROUND.toFixed(1)}, cb);
606 float line = 1.0 - smoothstep(0.0, 2.0 + aa, vb.z * ${FAST_CRACK_M.toFixed(1)});
607 out3.y = max(out3.y, line * 0.6 * (1.0 - smoothstep(20.0, 120.0, footprint)));
608 out3.z = 0.9 + 0.1 * iceHash(vb.xy, 51.0);
609 }
610 return out3;
611}
612`
613
80bcc97a 2026-09-17 Pavel Safronov614const WATER_VERT = /* glsl */`
615#include <common>
616#include <logdepthbuf_pars_vertex>
617uniform mat4 uFrame;
618uniform vec3 uCam;
619attribute vec4 aWater;
620attribute vec4 aFlow;
621varying vec3 vWorld;
622varying vec4 vWater;
623varying vec4 vFlow;
624${WAVES_GLSL}
e18dad04 2026-09-30 Pavel Safronov625${ICE_GLSL}
80bcc97a 2026-09-17 Pavel Safronov626void main() {
627 vec3 world = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
628 // Displace only close in, where vertices are metres apart; beyond, waves are shading.
629 float near = 1.0 - smoothstep(0.3, 2.5, distance(uCam, world));
630 vec3 p = position;
a088e5d5 2026-09-17 Pavel Safronov631 vec4 wp = waveParams(aWater);
632 if (near > 0.0 && wp.x > 0.0) {
633 float h = waves(surfaceMetres(world), wp.x, wp.y, wp.z, bodyOf(aWater), 0.5).x;
e18dad04 2026-09-30 Pavel Safronov634 h *= iceCalm(aFlow.w);
80bcc97a 2026-09-17 Pavel Safronov635 // Up is toward the axis. The tile mesh sits in the tower's frame, so its model
636 // matrix is a translation and 'up' can be taken from the world position directly.
637 vec3 up = -normalize(vec3(world.xy, 0.0));
638 p += up * (h * near / 1000.0);
639 world += up * (h * near / 1000.0);
640 }
641 vWorld = world;
642 vWater = aWater;
643 vFlow = aFlow;
644 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
645 #include <logdepthbuf_vertex>
646}
647`
648
649const WATER_FRAG = /* glsl */`
650#include <common>
651#include <logdepthbuf_pars_fragment>
652varying vec3 vWorld;
653varying vec4 vWater;
654varying vec4 vFlow;
655${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov656${SUN_GLSL}
80bcc97a 2026-09-17 Pavel Safronov657${WAVES_GLSL}
e18dad04 2026-09-30 Pavel Safronov658${ICE_GLSL}
80bcc97a 2026-09-17 Pavel Safronov659
a088e5d5 2026-09-17 Pavel Safronov660// Periodic every 256 cells, which is what lets surfaceMetres' reference step without a seam.
661float wHash(vec2 p) { p = mod(p, 256.0); return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453); }
80bcc97a 2026-09-17 Pavel Safronov662float wNoise(vec2 p) {
663 vec2 i = floor(p), f = fract(p);
664 f = f * f * (3.0 - 2.0 * f);
665 return mix(mix(wHash(i), wHash(i + vec2(1, 0)), f.x), mix(wHash(i + vec2(0, 1)), wHash(i + vec2(1, 1)), f.x), f.y);
666}
667
668void main() {
669 #include <logdepthbuf_fragment>
670 vec2 sp = surfaceMetres(vWorld);
671 // Metres of floor under one pixel: what decides which waves can still be drawn.
672 float footprint = length(fwidth(sp));
673 vec3 radial = normalize(vec3(vWorld.xy, 0.0));
674 vec3 up = -radial;
675 vec3 spinward = vec3(-radial.y, radial.x, 0.0);
676 vec3 deepward = vec3(0.0, 0.0, 1.0);
677
a088e5d5 2026-09-17 Pavel Safronov678 vec4 wp = waveParams(vWater);
679 vec3 w = waves(sp, wp.x, wp.y, wp.z, bodyOf(vWater), footprint);
e18dad04 2026-09-30 Pavel Safronov680 vec2 grad = w.yz * iceCalm(vFlow.w);
80bcc97a 2026-09-17 Pavel Safronov681
682 /**
683 * Flow: ripples carried downstream. Two copies of a noise field advected by the flow
684 * and cross-faded on a short cycle, so the pattern moves without ever stretching out.
685 */
686 vec2 flow = vFlow.xy;
687 float speed = length(flow);
688 if (speed > 0.01) {
689 float cycle = 2.0;
690 float t = mod(uTime, cycle) / cycle;
691 vec2 q = sp / 1.6;
692 vec2 off = flow * cycle / 1.6;
693 float blend = abs(t * 2.0 - 1.0);
694 float scale = smoothstep(6.0, 1.0, footprint);
695 for (int k = 0; k < 2; k++) {
696 float tk = fract(t + float(k) * 0.5);
697 vec2 at = q - off * tk + float(k) * 7.3;
698 float e = 0.05;
699 float n0 = wNoise(at);
700 vec2 g = vec2(wNoise(at + vec2(e, 0.0)) - n0, wNoise(at + vec2(0.0, e)) - n0) / e;
701 float wk = k == 0 ? blend : 1.0 - blend;
702 grad += g * 0.06 * wk * scale * min(1.0, speed);
703 }
704 }
705
706 vec3 n = normalize(up - deepward * grad.x - spinward * grad.y);
707 vec3 view = normalize(vWorld - uCam);
708 float cosV = max(0.0, dot(-view, n));
709 // Schlick, with water's 2% at normal incidence.
710 float fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);
711
e18dad04 2026-09-30 Pavel Safronov712 float light = uExposure * (0.4 + 0.6 * direct(vWorld, up)) * lightShare(vWorld);
80bcc97a 2026-09-17 Pavel Safronov713
714 // Deep water is dark blue-green; what it reflects inside Tower is the far side of the
715 // world, which from here is haze-blue land.
716 vec3 deep = vec3(0.018, 0.045, 0.06);
717 vec3 body = deep * (0.5 + 0.5 * max(0.0, dot(n, up))) * light;
718 // What water mirrors in Tower is land, hazed — never an open sky — so it is not pure haze.
719 vec3 sky = mix(uHaze, vec3(0.30, 0.45, 0.56), 0.35) * light * 0.95;
720 vec3 col = mix(body, sky, fresnel);
721
59c12827 2026-09-28 Pavel Safronov722 // Water glints where it mirrors the sun back — and by night the banked rod, a line
723 // down the axis. The rod's is kept faint: at full strength a rippled river wore the
724 // glitter as a white snake.
80bcc97a 2026-09-17 Pavel Safronov725 vec3 refl = reflect(view, n);
726 float toAxis = max(0.0, dot(normalize(vec3(refl.xy, 0.0) + 1e-6), up));
59c12827 2026-09-28 Pavel Safronov727 col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light * uSun.z;
e18dad04 2026-09-30 Pavel Safronov728 col += vec3(1.0, 0.96, 0.88) * pow(max(dot(refl, sunDir(vWorld)), 0.0), 2000.0) * 2.5 * uSun.y * uExposure * aimedShare(vWorld.z);
42b4554e 2026-09-29 Pavel Safronov729 // And the moon, on a moonlit night: a path of light across the sea toward it.
e18dad04 2026-09-30 Pavel Safronov730 col += vec3(0.85, 0.9, 1.0) * pow(max(dot(refl, moonDir(vWorld)), 0.0), 1500.0) * 1.2 * uMoon.y * uExposure * aimedShare(vWorld.z);
80bcc97a 2026-09-17 Pavel Safronov731
732 // Foam: rapids and falls, and the edge where the water is only centimetres deep.
733 float depth = vWater.x;
734 float shore = 1.0 - smoothstep(0.0, 0.6, depth);
a088e5d5 2026-09-17 Pavel Safronov735 float churn = vFlow.z * (0.55 + 0.45 * wNoise(sp / 0.8 - flow * mod(uTime, 60.0) / 0.8));
736 float foam = clamp(max(churn, shore * 0.6 * step(0.001, wp.x)), 0.0, 1.0);
737 /**
738 * Whitecaps: the crests break over the share of the sea the wind says (sea-state.ts),
739 * broken up so they read as patches rather than stripes along every crest. Far off, where
740 * single caps are under a pixel, the sea just whitens by that share.
741 */
742 if (wp.w > 0.0) {
743 float crest = chop(sp, wp.x, wp.y, wp.z, bodyOf(vWater)) * 0.5 + 0.5;
744 float drift = mod(uTime, 600.0);
745 float patches = wNoise(sp / 9.6 + vec2(drift * 0.05, 0.0)) * 0.65 + wNoise(sp / 2.4 - drift * 0.2) * 0.35;
746 // The threshold is set so about wp.w of the surface clears it.
747 // Crest and patches together sit near 0.58 ± 0.18: 0.92 clears ~2% of it, 0.64 ~29%.
748 float t = 1.01 - 0.68 * sqrt(wp.w);
749 float cap = smoothstep(t, t + 0.06, crest * 0.7 + patches * 0.45);
750 // Foam is lace, not a sheet: broken up finely, and thinner toward the cap's edge.
51c39d8b 2026-09-17 Pavel Safronov751 /**
752 * Resolved only close in: beyond, its metre-scale cells drew as blocks, so it averages
753 * out. Two octaves, the second rotated, because one octave of value noise thresholded
754 * this hard reads as a honeycomb of its own lattice.
755 */
756 vec2 lp = vec2(sp.x * 0.83 - sp.y * 0.56, sp.x * 0.56 + sp.y * 0.83);
757 float fine = wNoise(sp / 0.8 + drift * 0.1) * 0.6 + wNoise(lp / 2.1 - drift * 0.05) * 0.4;
758 float lace = mix(smoothstep(0.3, 0.8, fine), 0.5, smoothstep(0.05, 0.4, footprint));
a088e5d5 2026-09-17 Pavel Safronov759 cap *= lace * 0.5 + 0.5 * cap;
760 float far = smoothstep(1.0, 8.0, footprint);
761 foam = max(foam, mix(cap, wp.w * 0.5, far) * 0.8);
762 }
80bcc97a 2026-09-17 Pavel Safronov763 col = mix(col, vec3(0.85, 0.88, 0.9) * light * (0.34 + 0.8 * dot(n, up)), foam);
764
e18dad04 2026-09-30 Pavel Safronov765 /**
766 * Sea ice (D-090): white over the water where the floes are, with the leads between them
767 * open and dark, and the fast ice one cracked sheet. Matte — snow on ice mirrors nothing —
768 * and lit like the foam, by the same light, which at the far end is a dusk.
769 */
770 float ice = 0.0;
771 if (vFlow.w > 0.001) {
772 vec3 floe = seaIce(vWorld, vFlow.w, footprint);
773 ice = floe.x;
774 vec3 white = ${ICE_LINEAR} * floe.z * (1.0 - 0.45 * floe.y);
775 // Snow on it, drifted: a mottle close in, where a metre is a few pixels.
776 float drift = wNoise(sp / 2.4) * 0.6 + wNoise(sp / 9.6) * 0.4;
777 white *= 1.0 + (drift - 0.5) * 0.12 * (1.0 - smoothstep(0.5, 4.0, footprint));
778 col = mix(col, white * light * 1.1, ice);
779 }
780
80bcc97a 2026-09-17 Pavel Safronov781 // How much ground shows through: shallow water is clear, a few metres is not.
782 float alpha = clamp(1.0 - exp(-depth / 3.5), 0.2, 0.97);
783 alpha = max(alpha, fresnel);
784 alpha = max(alpha, foam);
e18dad04 2026-09-30 Pavel Safronov785 alpha = max(alpha, ice);
80bcc97a 2026-09-17 Pavel Safronov786
787 vec3 T = towerTransmittance(uCam, vWorld);
788 gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
789 #include <colorspace_fragment>
790}
791`
792
00ceeedb 2026-09-27 Pavel Safronov793/**
794 * The forest canopy, seen from a distance (tile.ts `buildCanopy`, world-generation.md §4.6).
795 *
796 * Greatwood holds about three billion trees, so past a kilometre the *ground* under them is
797 * this: a skin over the tile, lifted by the canopy's height, with a top left rough by the
798 * tile. Clumps of trees stand in front of it out to nearly three kilometres
799 * (forest-view.ts) and it fills what they leave between them — a stand of cards seen from
800 * above is all gaps, and the wood has to be closed. Coming closer, the skin **sinks back
801 * onto the ground it was built from** and stops being drawn, where the trees themselves
802 * have taken over.
803 */
804const CANOPY_NEAR_M = 620
805const CANOPY_FAR_M = 950
806
807const CANOPY_VERT = /* glsl */`
808#include <common>
809#include <logdepthbuf_pars_vertex>
810uniform mat4 uFrame;
811uniform vec3 uCam;
812attribute vec3 aCanopy;
813varying vec3 vWorld;
814varying vec3 vCanopy;
815varying float vLift;
816void main() {
817 vec3 world = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
818 float lift = smoothstep(${CANOPY_NEAR_M.toFixed(1)}, ${CANOPY_FAR_M.toFixed(1)}, distance(uCam, world) * 1000.0);
819 vec3 up = -normalize(vec3(world.xy, 0.0));
820 vec3 p = position + up * (aCanopy.x * lift / 1000.0);
821 vWorld = world + up * (aCanopy.x * lift / 1000.0);
822 vCanopy = aCanopy;
823 vLift = lift;
824 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
825 #include <logdepthbuf_vertex>
826}
827`
828
829const CANOPY_FRAG = /* glsl */`
830#include <common>
831#include <logdepthbuf_pars_fragment>
832varying vec3 vWorld;
833varying vec3 vCanopy;
834varying float vLift;
835${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov836${SUN_GLSL}
00ceeedb 2026-09-27 Pavel Safronov837float canopyHash(vec2 p) {
838 return fract(sin(dot(mod(p, 512.0), vec2(37.1, 91.7))) * 24634.6345);
839}
840
841void main() {
842 #include <logdepthbuf_fragment>
843 if (vLift < 0.03) discard;
844 // The shape of the canopy is the shape of this surface: its normal comes from the
845 // surface itself, so the rough top the tile built reads as tree crowns rather than felt.
846 vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld)));
847 vec3 radial = normalize(vec3(vWorld.xy, 0.0));
848 if (dot(n, -radial) < 0.0) n = -n;
59c12827 2026-09-28 Pavel Safronov849 float lambert = direct(vWorld, n);
00ceeedb 2026-09-27 Pavel Safronov850
851 /**
852 * Seen from underneath — standing in the wood, looking out through it — a solid skin is
853 * a black ceiling over everything past the last drawn tree. A canopy is not solid: a
854 * third of it is gaps between crowns, and that is what lets you see any distance through
855 * a forest at all. So from below it is opened up, on a pattern fixed to the ground so it
856 * reads as gaps rather than as static on the screen.
857 */
858 vec3 toEye = normalize(uCam - vWorld);
859 bool under = dot(n, toEye) < 0.0;
860 if (under) {
861 vec2 sp = vec2(vWorld.z * 1000.0, atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)});
862 if (canopyHash(floor(sp / 9.0)) > 0.45) discard;
863 }
864
865 /**
866 * Closed canopy is the darkest ground in Tower — a crown swallows most of what lands on
867 * it, which is why a forest reads as a dark mass and its clearings as bright grass. So
868 * this has to come out darker than the forest floor the palette paints in the meadows,
869 * or the wood looks like mist lying over the grass, which is what it looked like first.
870 */
871 vec3 conifer = vec3(0.037, 0.067, 0.043);
872 vec3 broadleaf = vec3(0.070, 0.098, 0.040);
873 vec3 base = mix(broadleaf, conifer, clamp(vCanopy.z, 0.0, 1.0));
874 // Thinner stands let the floor show through, so they are lighter than closed canopy.
875 base *= 1.0 + 0.35 * (1.0 - clamp(vCanopy.y, 0.0, 1.0));
876 // Under the canopy the light is what has come through it: green, and a tenth as much.
e18dad04 2026-09-30 Pavel Safronov877 vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure * lightShare(vWorld);
00ceeedb 2026-09-27 Pavel Safronov878
879 vec3 T = towerTransmittance(uCam, vWorld);
880 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
881 #include <colorspace_fragment>
882}
883`
884
885/**
886 * Trees near the eye (forest-view.ts): instanced meshes of bark and foliage, lit from the
887 * axis like everything else. The kind of foliage is carried by the geometry, since all three
888 * tree meshes share this one material.
889 */
890const FOLIAGE_GLSL = /* glsl */`
891vec3 foliageColour(float part, float tint) {
892 vec3 bark = vec3(0.055, 0.037, 0.025);
893 vec3 conifer = vec3(0.030, 0.062, 0.038);
894 vec3 broadleaf = vec3(0.062, 0.098, 0.035);
895 vec3 giant = vec3(0.035, 0.058, 0.030);
896 vec3 c = part < 0.5 ? bark : part < 1.5 ? conifer : part < 2.5 ? broadleaf : giant;
897 // No two trees the same: a little lighter or darker, and a little yellower or bluer.
898 return c * (0.78 + 0.44 * tint) + vec3(0.006, 0.004, -0.004) * (tint - 0.5);
899}
900`
901
902const TREE_VERT = /* glsl */`
903#include <common>
904#include <logdepthbuf_pars_vertex>
905uniform mat4 uFrame;
906attribute float aPart;
907varying vec3 vWorld;
908varying vec3 vN;
909varying float vPart;
910varying float vTint;
911void main() {
912 mat4 m = modelMatrix * instanceMatrix;
913 vWorld = (uFrame * m * vec4(position, 1.0)).xyz;
914 vN = mat3(uFrame * m) * normal;
915 vPart = aPart;
916 // The instance's own position is as good a per-tree number as any.
917 vTint = fract(dot(instanceMatrix[3].xyz, vec3(17.3, 13.7, 29.1)));
918 gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
919 #include <logdepthbuf_vertex>
920}
921`
922
923const TREE_FRAG = /* glsl */`
924#include <common>
925#include <logdepthbuf_pars_fragment>
926varying vec3 vWorld;
927varying vec3 vN;
928varying float vPart;
929varying float vTint;
930${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov931${SUN_GLSL}
00ceeedb 2026-09-27 Pavel Safronov932${FOLIAGE_GLSL}
933void main() {
934 #include <logdepthbuf_fragment>
935 vec3 n = normalize(vN);
59c12827 2026-09-28 Pavel Safronov936 // A crown is lit on the side facing the sun, and its other side is its shadow.
937 float lambert = direct(vWorld, n);
00ceeedb 2026-09-27 Pavel Safronov938 // Ambient is high for foliage: under a crown the light is what the crowns around it have
939 // bounced, and a canopy with none of that reads as pitch black rather than as shade.
e18dad04 2026-09-30 Pavel Safronov940 vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure * lightShare(vWorld);
00ceeedb 2026-09-27 Pavel Safronov941 vec3 T = towerTransmittance(uCam, vWorld);
942 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
943 #include <colorspace_fragment>
944}
945`
946
947/**
948 * A tree as a card: one quad, turned to face the eye about the tree's own upright, with its
949 * shape cut out of it in the fragment — a cone for a conifer, a round crown for a broadleaf,
950 * a column and a high crown for a giant. No texture: at this distance a tree is a few pixels
951 * and its silhouette is all of it that survives.
952 */
953const CARD_VERT = /* glsl */`
954#include <common>
955#include <logdepthbuf_pars_vertex>
956uniform mat4 uFrame;
957uniform vec3 uCam;
958attribute vec2 aCard;
959varying vec3 vWorld;
960varying vec2 vUv;
961varying vec2 vCard;
962void main() {
963 vec3 base = (modelMatrix * vec4(instanceMatrix[3].xyz, 1.0)).xyz;
964 vec3 world = (uFrame * vec4(base, 1.0)).xyz;
965 // Up is toward the axis; the card turns about it to face the eye and never leans.
966 vec3 up = -normalize(vec3(world.xy, 0.0));
967 vec3 toEye = uCam - world;
968 vec3 right = normalize(cross(toEye, up));
969 // The tree stands on the ground, so the card grows along up from its foot.
970 vec3 p = instanceMatrix[3].xyz
971 + right * (position.x * instanceMatrix[0][0])
972 + up * (position.y * instanceMatrix[1][1]);
973 vWorld = (uFrame * modelMatrix * vec4(p, 1.0)).xyz;
974 vUv = uv;
975 vCard = aCard;
976 gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
977 #include <logdepthbuf_vertex>
978}
979`
980
981const CARD_FRAG = /* glsl */`
982#include <common>
983#include <logdepthbuf_pars_fragment>
984varying vec3 vWorld;
985varying vec2 vUv;
986varying vec2 vCard;
987${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov988${SUN_GLSL}
00ceeedb 2026-09-27 Pavel Safronov989${FOLIAGE_GLSL}
990void main() {
991 #include <logdepthbuf_fragment>
992 float u = vUv.x - 0.5, v = vUv.y;
993 float kind = vCard.x;
994 bool inside;
995 if (kind > 2.5) {
996 /**
997 * A clump: everything in twenty-odd metres of wood, drawn as one card. Three crowns
998 * over a solid band, so its outline is lumpy the way a stand of trees is, rather than
999 * the single cone a lone tree would make at this distance.
1000 */
1001 float bump = 0.0;
1002 bump = max(bump, 0.62 * (1.0 - pow(abs((u + 0.30) / 0.24), 1.7)));
1003 bump = max(bump, 0.95 * (1.0 - pow(abs((u - 0.02) / 0.26), 1.7)));
1004 bump = max(bump, 0.70 * (1.0 - pow(abs((u - 0.31) / 0.22), 1.7)));
1005 // A little noise on the tops, so a row of clumps is not a row of the same shape.
1006 bump *= 0.86 + 0.28 * fract(sin(vCard.y * 91.7 + u * 13.3) * 4371.3);
1007 inside = v < bump;
1008 } else if (kind > 1.5) {
1009 // A giant: a bare column for half its height, and a crown above.
1010 float crownHalf = 0.5 * (1.0 - smoothstep(0.55, 1.0, v));
1011 inside = v < 0.55 ? abs(u) < 0.1 : abs(u) < crownHalf;
1012 } else if (kind > 0.5) {
1013 // A conifer: a narrow cone on a stem.
1014 inside = v < 0.16 ? abs(u) < 0.08 : abs(u) < 0.5 * (1.0 - (v - 0.16) / 0.84);
1015 } else {
1016 // A broadleaf: a round crown over a short stem.
1017 float dy = (v - 0.68) / 0.34;
1018 inside = v < 0.4 ? abs(u) < 0.07 : (u * u) / 0.25 + dy * dy < 1.0;
1019 }
1020 if (!inside) discard;
1021 float part = kind > 2.5 ? 1.0 : kind > 1.5 ? 3.0 : kind > 0.5 ? 1.0 : 2.0;
1022 // A clump is all crown: no trunk of its own, since it is a stand and not a tree.
1023 if (kind < 2.5 && v < (kind > 1.5 ? 0.55 : kind > 0.5 ? 0.16 : 0.4)) part = 0.0;
1024 // Lit from overhead: the top of a crown is bright and its foot is in its own shade.
1025 float shade = 0.22 + 0.52 * smoothstep(0.0, 0.9, v);
59c12827 2026-09-28 Pavel Safronov1026 // As bright as a tree mesh's crown at the handover: the same sun, the same curve.
1027 float sun = (0.34 + 0.85 * direct(vWorld, upAt(vWorld))) / 1.19;
e18dad04 2026-09-30 Pavel Safronov1028 vec3 lit = foliageColour(part, vCard.y) * shade * sun * uExposure * lightShare(vWorld);
00ceeedb 2026-09-27 Pavel Safronov1029 vec3 T = towerTransmittance(uCam, vWorld);
1030 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
1031 #include <colorspace_fragment>
1032}
1033`
1034
a088e5d5 2026-09-17 Pavel Safronov1035/**
1036 * Ships (ships.ts): instanced hulls and sails, lit from the axis and hazed like everything
1037 * else. The instances are placed relative to the mesh, which sits at the eye, so their
1038 * matrices stay small numbers in float32.
1039 */
1040const SHIP_VERT = /* glsl */`
1041#include <common>
1042#include <logdepthbuf_pars_vertex>
1043uniform mat4 uFrame;
1044varying vec3 vWorld;
1045varying vec3 vN;
1046void main() {
1047 mat4 m = modelMatrix * instanceMatrix;
1048 vWorld = (uFrame * m * vec4(position, 1.0)).xyz;
1049 vN = mat3(uFrame * m) * normal;
1050 gl_Position = projectionMatrix * modelViewMatrix * instanceMatrix * vec4(position, 1.0);
1051 #include <logdepthbuf_vertex>
1052}
1053`
1054
1055const SHIP_FRAG = /* glsl */`
1056#include <common>
1057#include <logdepthbuf_pars_fragment>
1058uniform vec3 uColour;
1059varying vec3 vWorld;
1060varying vec3 vN;
1061${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov1062${SUN_GLSL}
a088e5d5 2026-09-17 Pavel Safronov1063void main() {
1064 #include <logdepthbuf_fragment>
59c12827 2026-09-28 Pavel Safronov1065 vec3 n = normalize(vN);
1066 // Both faces of a sail are lit, whichever way it has swung.
42b4554e 2026-09-29 Pavel Safronov1067 float lambert = uSun.y * abs(dot(n, sunDir(vWorld))) + uSun.z * abs(dot(n, upAt(vWorld)))
1068 + uMoon.y * abs(dot(n, moonDir(vWorld)));
a088e5d5 2026-09-17 Pavel Safronov1069 // Three.Color already converts its hex to linear.
e18dad04 2026-09-30 Pavel Safronov1070 vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure * lightShare(vWorld);
a088e5d5 2026-09-17 Pavel Safronov1071 vec3 T = towerTransmittance(uCam, vWorld);
1072 gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
1073 #include <colorspace_fragment>
1074}
1075`
1076
99a2821b 2026-09-29 Pavel Safronov1077/**
1078 * The ships' shader for things that are not instanced: the tour's craft (craft.ts) and the
1079 * railways. Lit from the sun and the rod, and hazed, like everything else in here.
1080 */
1081const PLAIN_VERT = /* glsl */`
1082#include <common>
1083#include <logdepthbuf_pars_vertex>
1084uniform mat4 uFrame;
1085varying vec3 vWorld;
1086varying vec3 vN;
1087void main() {
1088 vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
1089 vN = mat3(uFrame * modelMatrix) * normal;
1090 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1091 #include <logdepthbuf_vertex>
1092}
1093`
1094
1095/** Metres between a railway's piers, where its deck stands clear of the ground. */
1096const PIER_EVERY_M = 100
1097/** Samples of a railway per mesh: meshes with their own origins keep float32 precise. */
1098const RAIL_CHUNK = 100
1099
a088e5d5 2026-09-17 Pavel Safronov1100/**
1101 * A hull one unit long, bow toward +x, deck up +y: pointed at the bow, a transom at the
1102 * stern, sides tumbling in to a narrow keel, a sheer rising to the bow, and a deckhouse
1103 * aft. Scaled to each ship's length by its instance.
1104 */
1105function hullGeometry(): BufferGeometry {
1106 const STATIONS = 12
1107 const halfBeam = (t: number): number =>
1108 0.15 * Math.sqrt(Math.max(0, 1 - ((t - 0.4) / 0.6) ** 2)) * (t < 0.4 ? 0.78 + 0.22 * (t / 0.4) : 1)
1109 const deckY = (t: number): number => 0.08 + 0.05 * t ** 3
1110 const KEEL = -0.05
1111 const deck: number[][] = [], keel: number[][] = []
1112 for (let i = 0; i <= STATIONS; i++) {
1113 const t = i / STATIONS
1114 const x = t - 0.5
1115 const w = i === STATIONS ? 0 : halfBeam(t)
1116 deck.push([x, deckY(t), -w], [x, deckY(t), w])
1117 keel.push([x, KEEL, -w * 0.3], [x, KEEL, w * 0.3])
1118 }
1119 const tris: number[] = []
1120 const tri = (a: number[], b: number[], c: number[]) => tris.push(...a, ...b, ...c)
1121 const quad = (a: number[], b: number[], c: number[], d: number[]) => { tri(a, b, c); tri(a, c, d) }
1122 for (let i = 0; i < STATIONS; i++) {
1123 const [dl0, dr0, dl1, dr1] = [deck[2 * i]!, deck[2 * i + 1]!, deck[2 * i + 2]!, deck[2 * i + 3]!]
1124 const [kl0, kr0, kl1, kr1] = [keel[2 * i]!, keel[2 * i + 1]!, keel[2 * i + 2]!, keel[2 * i + 3]!]
1125 quad(dl0, dr0, dr1, dl1) // deck
1126 quad(dl0, dl1, kl1, kl0) // the -z side
1127 quad(dr0, kr0, kr1, dr1) // the +z side
1128 quad(kl0, kl1, kr1, kr0) // bottom
1129 }
1130 quad(deck[0]!, keel[0]!, keel[1]!, deck[1]!) // transom
1131 // A deckhouse aft: a box on the deck.
1132 const box = (x0: number, x1: number, w: number, y0: number, y1: number) => {
1133 const p = (x: number, y: number, z: number) => [x, y, z]
1134 const c = [p(x0, y0, -w), p(x1, y0, -w), p(x1, y0, w), p(x0, y0, w), p(x0, y1, -w), p(x1, y1, -w), p(x1, y1, w), p(x0, y1, w)]
1135 quad(c[4]!, c[7]!, c[6]!, c[5]!) // top
1136 quad(c[0]!, c[1]!, c[5]!, c[4]!)
1137 quad(c[1]!, c[2]!, c[6]!, c[5]!)
1138 quad(c[2]!, c[3]!, c[7]!, c[6]!)
1139 quad(c[3]!, c[0]!, c[4]!, c[7]!)
1140 }
1141 box(-0.38, -0.12, 0.07, deckY(0.2) - 0.01, deckY(0.2) + 0.07)
1142 const g = new BufferGeometry()
1143 g.setAttribute('position', new BufferAttribute(new Float32Array(tris), 3))
1144 g.computeVertexNormals()
1145 return g
1146}
1147
1148/** The jib, from the stemhead to high on the mast. Fixed: only the mainsail swings. */
1149function jibGeometry(): BufferGeometry {
1150 const g = new BufferGeometry()
1151 g.setAttribute('position', new BufferAttribute(new Float32Array([0.5, 0.13, 0, 0.12, 0.85, 0, 0.16, 0.1, 0]), 3))
1152 g.computeVertexNormals()
1153 return g
1154}
1155
1156/**
1157 * A mainsail on a unit hull: a triangle from the foot of the mast to its head and back
1158 * along the boom. The mast is at the origin, so an instance can swing the sail about it.
1159 */
1160function sailGeometry(): BufferGeometry {
1161 const positions = new Float32Array([
1162 0, 0.12, 0, 0, 0.95, 0, -0.44, 0.14, 0, // sail
1163 0.004, 0.08, 0, 0.004, 1.0, 0, -0.004, 1.0, 0, // a mast, thin
1164 0.004, 0.08, 0, -0.004, 1.0, 0, -0.004, 0.08, 0,
1165 ])
1166 const g = new BufferGeometry()
1167 g.setAttribute('position', new BufferAttribute(positions, 3))
1168 g.computeVertexNormals()
1169 return g
1170}
1171
1172/**
1173 * How far the water's surface reference steps: 256 cells of every noise scale the water
1174 * shader uses (0.8, 1.6, 2.4 and 9.6 m), so a step lands the noise exactly on itself.
1175 */
1176const SURF_SNAP_M = 2457.6
1177
1178/** Most ships drawn at once: two seas of two hundred each. */
1179const MAX_SHIPS = 800
1180/** Where the mast stands on a unit hull. */
1181const MAST_X = 0.12
1182
80bcc97a 2026-09-17 Pavel Safronov1183/**
1184 * A waterfall's sheet (falls.ts). Streaks run down it at free-fall speed, faster the
1185 * further down they are, over a sheet that thins to spray toward its foot.
1186 */
1187const FALL_VERT = /* glsl */`
1188#include <common>
1189#include <logdepthbuf_pars_vertex>
1190uniform mat4 uFrame;
1191varying vec3 vWorld;
1192varying vec2 vUv;
1193void main() {
1194 vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
1195 vUv = uv;
1196 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1197 #include <logdepthbuf_vertex>
1198}
1199`
1200
1201const FALL_FRAG = /* glsl */`
1202#include <common>
1203#include <logdepthbuf_pars_fragment>
1204uniform float uTime;
1205uniform float uHeightM;
1206varying vec3 vWorld;
1207varying vec2 vUv;
1208${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov1209${SUN_GLSL}
80bcc97a 2026-09-17 Pavel Safronov1210float fHash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
1211float fNoise(vec2 p) {
1212 vec2 i = floor(p), f = fract(p);
1213 f = f * f * (3.0 - 2.0 * f);
1214 return mix(mix(fHash(i), fHash(i + vec2(1, 0)), f.x), mix(fHash(i + vec2(0, 1)), fHash(i + vec2(1, 1)), f.x), f.y);
1215}
1216void main() {
1217 #include <logdepthbuf_fragment>
1218 // Seconds a streak has been falling to reach this far down: y = g t² / 2. Scrolling in
1219 // that rather than in distance makes streaks accelerate down the sheet as water does.
1220 float y = vUv.y * uHeightM;
1221 float s = sqrt(2.0 * y / 9.80665) - mod(uTime, 30.0);
1222 float streak = fNoise(vec2(vUv.x * 18.0, s * 3.0)) * 0.6 + fNoise(vec2(vUv.x * 55.0, s * 9.0)) * 0.4;
1223 float edge = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
1224 float alpha = (0.55 + 0.45 * streak) * edge * (1.0 - 0.6 * vUv.y);
59c12827 2026-09-28 Pavel Safronov1225 vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak)
e18dad04 2026-09-30 Pavel Safronov1226 * (0.45 + 0.6 * direct(vWorld, upAt(vWorld))) * lightShare(vWorld);
80bcc97a 2026-09-17 Pavel Safronov1227 vec3 T = towerTransmittance(uCam, vWorld);
1228 gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
1229 #include <colorspace_fragment>
1230}
1231`
1232
6bb061a6 2026-09-14 Pavel Safronov1233const FLAT_VERT = /* glsl */`
1234#include <common>
1235#include <logdepthbuf_pars_vertex>
1236uniform mat4 uFrame;
1237varying vec3 vWorld;
1238void main() {
1239 vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
1240 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1241 #include <logdepthbuf_vertex>
1242}
1243`
1244
1245/**
1246 * The sky through the windows (§1.4, D-045), and the exterior's sky (D-060).
1247 *
e331ec2a 2026-09-28 Pavel Safronov1248 * **The stars are Earth's** (D-075): the Yale Bright Star Catalogue, every star to
1249 * magnitude 6.5, where they really are. The direction is turned about z by the spin phase
1250 * — the pinwheel, one turn every 7 min 29 s — and then into the catalogue's frame by
1251 * `uSkyFrame`, which follows the date: Tower's axis points at the sun, so the far cap
1252 * looks at the antisolar point, and that walks round the ecliptic once a year
1253 * (celestial.ts). Two nested motions, as §1.6 says.
6bb061a6 2026-09-14 Pavel Safronov1254 *
e331ec2a 2026-09-28 Pavel Safronov1255 * A pixel cannot visit 9,000 stars, so they are baked into cells on the faces of a cube
1256 * (`bakeStars`): the pixel finds its face and cell and reads the nine cells round it.
1257 * Each face's grid carries a border of its neighbours' stars, so the nine never leave
1258 * the face and a star by an edge is seen from both sides of it and counted once.
6bb061a6 2026-09-14 Pavel Safronov1259 *
e331ec2a 2026-09-28 Pavel Safronov1260 * **Why a star is a gaussian a pixel wide.** It used to be a spike, (1 − d/0.09)^4, about
1261 * a pixel across on a retina panel. The sky is sampled once per pixel at the pixel's
1262 * centre, so what got drawn was that spike read at one arbitrary point on it — and half a
1263 * pixel of head movement moved the reading from the peak to a third of it. That is
1264 * flicker, and it is wrong twice over: vacuum has no air to twinkle through, and the
1265 * interior is deliberately still. A curve as wide as the pixel barely changes between
1266 * neighbouring samples, and it is what a point source looks like through any real
1267 * optic. The footprint is |dd/dpixel| on the unit direction, the pixel's angular size,
1268 * which has no seam anywhere on the sphere.
1269 *
1270 * **Brightness.** Flux is 1 at magnitude 6.5, so Sirius is 1,500. The eye does not see
1271 * that range and a screen cannot show it; a power of 0.8 does most of the squeezing —
1272 * steep enough that the constellations stand out of the faint field, as they do outdoors —
1273 * and the brightest also grow a little wider, which is how bright stars read in any
1274 * photograph and what tells Sirius from Rigel across a room.
6bb061a6 2026-09-14 Pavel Safronov1275 */
1276export const STAR_GLSL = /* glsl */`
1277uniform float uSpin;
1278uniform float uStarGain;
e331ec2a 2026-09-28 Pavel Safronov1279uniform mat3 uSkyFrame;
1280uniform highp sampler2D uStars;
6bb061a6 2026-09-14 Pavel Safronov1281
e331ec2a 2026-09-28 Pavel Safronov1282const float STAR_CELLS = ${STAR_FACE_CELLS.toFixed(1)};
1283const int STAR_SIZE = ${STAR_FACE_SIZE};
6bb061a6 2026-09-14 Pavel Safronov1284
e331ec2a 2026-09-28 Pavel Safronov1285/**
1286 * B−V to a naked-eye colour. The hot ones blue-white, the sun white, the cool ones
1287 * orange — and all of it pale, because starlight is too faint for the eye's colour
1288 * vision to make much of: a saturated sky reads as fairground lights.
1289 */
1290vec3 starTint(float bv) {
1291 vec3 c = mix(vec3(0.66, 0.76, 1.0), vec3(0.86, 0.9, 1.0), smoothstep(-0.3, 0.0, bv));
1292 c = mix(c, vec3(1.0, 0.97, 0.92), smoothstep(0.0, 0.6, bv));
1293 c = mix(c, vec3(1.0, 0.85, 0.66), smoothstep(0.6, 1.2, bv));
1294 c = mix(c, vec3(1.0, 0.74, 0.5), smoothstep(1.2, 1.9, bv));
1295 return c;
1296}
6bb061a6 2026-09-14 Pavel Safronov1297
1298vec3 starField(vec3 dir) {
1299 vec3 d0 = normalize(dir);
1300 float cs = cos(uSpin);
1301 float sn = sin(uSpin);
e331ec2a 2026-09-28 Pavel Safronov1302 vec3 d = uSkyFrame * vec3(cs * d0.x - sn * d0.y, sn * d0.x + cs * d0.y, d0.z);
1303
1304 // A pixel's angular size, and a star's width from it. Capped well inside a cell at a
1305 // face's corner (a seventh of a degree), or the nine cells would clip its skirt.
1306 float sg = clamp(length(fwidth(d)) * 0.85, 1e-5, 0.0008);
1307
1308 // The face, and the pixel's place on it: the same rule as celestial.ts's project().
1309 vec3 a = abs(d);
1310 int axis = a.x >= a.y && a.x >= a.z ? 0 : (a.y >= a.z ? 1 : 2);
1311 float n = axis == 0 ? d.x : (axis == 1 ? d.y : d.z);
1312 vec2 uv = axis == 0 ? d.yz : (axis == 1 ? d.zx : d.xy);
1313 uv /= abs(n);
1314 int face = 2 * axis + (n < 0.0 ? 1 : 0);
1315 ivec2 cell = ivec2(floor((uv + 1.0) * 0.5 * STAR_CELLS)) + 1;
1316 cell = clamp(cell, ivec2(1), ivec2(STAR_SIZE - 2));
1317 // Compared in the face's own frame — (u, v, 1) — where the angle is the same as on
1318 // the sky and nothing needs the face's axes in 3D.
1319 vec3 here = normalize(vec3(uv, 1.0));
1320
6bb061a6 2026-09-14 Pavel Safronov1321 vec3 sum = vec3(0.0);
e331ec2a 2026-09-28 Pavel Safronov1322 for (int j = -1; j <= 1; j++) {
1323 for (int i = -1; i <= 1; i++) {
1324 ivec2 c = cell + ivec2(i, j);
1325 vec4 s = texelFetch(uStars, ivec2(c.x, face * STAR_SIZE + c.y), 0);
1326 if (s.z <= 0.0) continue;
1327 vec3 at = normalize(vec3(s.xy, 1.0)) - here;
1328 float w = sg * (1.0 + 0.15 * log2(max(s.z, 1.0)));
1329 float peak = min(0.012 * pow(s.z, 0.8), 1.8);
1330 sum += starTint(s.w) * peak * exp(-dot(at, at) * 0.5 / (w * w));
6bb061a6 2026-09-14 Pavel Safronov1331 }
1332 }
1333 /**
e331ec2a 2026-09-28 Pavel Safronov1334 * The pinwheel's centre, marked. §1.4 promises one motionless star at the middle of
1335 * the turning sky, and it is the fixed point the whole phenomenon is read against —
1336 * but the real sky puts no star at the antisolar point, which walks a degree a day,
1337 * so this one is placed rather than catalogued. Not canon: on the backlog (design.md §5).
6bb061a6 2026-09-14 Pavel Safronov1338 */
e331ec2a 2026-09-28 Pavel Safronov1339 float th = acos(clamp(d0.z, -1.0, 1.0));
6bb061a6 2026-09-14 Pavel Safronov1340 float ps = sg * 1.6;
e331ec2a 2026-09-28 Pavel Safronov1341 sum += exp(-th * th * 0.5 / (ps * ps)) * vec3(1.0, 0.95, 0.86) * 1.6;
1342 return sum * uStarGain;
6bb061a6 2026-09-14 Pavel Safronov1343}
1344`
1345
e18dad04 2026-09-30 Pavel Safronov1346const FROST_NOISE_GLSL = /* glsl */`
1347float frostHash(vec2 p) { return fract(sin(dot(mod(p, 289.0), vec2(127.1, 311.7))) * 43758.5453); }
1348float frostNoise(vec2 p) {
1349 vec2 i = floor(p), f = fract(p);
1350 f = f * f * (3.0 - 2.0 * f);
1351 return mix(mix(frostHash(i), frostHash(i + vec2(1, 0)), f.x),
1352 mix(frostHash(i + vec2(0, 1)), frostHash(i + vec2(1, 1)), f.x), f.y);
1353}
1354`
1355
1356/**
1357 * How high the ice wall stands against the far cap (D-093), metres above the datum, by the
1358 * angle round the axis: a strip the terrain fills in from the ground at the cap's foot
1359 * whenever the world changes (\`setCapIce\`). Zero where there is no grounded ice.
1360 */
1361const CAP_ICE_MAX_M = 4080
1362const CAP_ICE_SAMPLES = 2048
1363const CAP_ICE_GLSL = /* glsl */`
1364uniform sampler2D uCapIce;
1365float capIceTop(vec2 xy) {
1366 float u = fract(atan(xy.y, xy.x) / 6.2831853);
1367 return texture2D(uCapIce, vec2(u, 0.5)).r * ${CAP_ICE_MAX_M.toFixed(1)};
1368}
1369`
1370
1371/**
1372 * The bore between the world's end and the glazing. Plain structure, hazed — or, where the
1373 * ice wall stands against the cap (D-093), buried in it: the kilometre of bore is all below
1374 * the ice's top, so it is ice, lit like the rest of the far end.
1375 */
6bb061a6 2026-09-14 Pavel Safronov1376const REVEAL_FRAG = /* glsl */`
1377#include <common>
1378#include <logdepthbuf_pars_fragment>
1379uniform vec3 uTint;
1380varying vec3 vWorld;
1381${HAZE_GLSL}
e18dad04 2026-09-30 Pavel Safronov1382${SUN_GLSL}
1383${CAP_ICE_GLSL}
6bb061a6 2026-09-14 Pavel Safronov1384void main() {
1385 #include <logdepthbuf_fragment>
1386 vec3 T = towerTransmittance(uCam, vWorld);
e18dad04 2026-09-30 Pavel Safronov1387 vec3 wall = uTint;
1388 if (capIceTop(vWorld.xy) > 1.0) wall = ${ICE_LINEAR} * lightShare(vWorld);
1389 gl_FragColor = vec4(wall * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0);
6bb061a6 2026-09-14 Pavel Safronov1390 #include <colorspace_fragment>
1391}
1392`
1393
1394/**
1395 * The sun cap: also a window, and what fills it is the back of the collector.
1396 *
1397 * §1.4 gave this cap "heavy glare management" and no view, on the assumption that
1398 * it faces the sun. It does not face the sun — it faces the **collector**, which is
1399 * 41.8° across from here against the sun's 0.53° and sits squarely between the two.
1400 * Tower is inside its umbra permanently, so no direct sunlight has ever reached
1401 * this cap and there is nothing here to manage (D-050).
1402 *
1403 * What there is instead is a 244 km disc of unlit structure. Its front is a mirror
1404 * pointed at the sun; its back radiates the collector's own waste heat at about
1405 * 245 K, which is infrared and therefore black. So the sky through this window is a
1406 * black disc with stars around the edge of it — and the disc does not turn with
1407 * them, because the collector flies in formation rather than spinning.
1408 *
1409 * Seen from outside none of that is drawn here: the pane is clear, and the real
1410 * collector and the real stars are behind it in the exterior's own scene.
1411 */
1412const SUN_CAP_FRAG = /* glsl */`
1413#include <common>
1414#include <logdepthbuf_pars_fragment>
1415uniform vec3 uTint;
1416uniform sampler2D uGlazing;
1417varying vec3 vWorld;
1418${HAZE_GLSL}
1419${STAR_GLSL}
1420void main() {
1421 #include <logdepthbuf_fragment>
1422 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
1423 vec4 frame = texture2D(uGlazing, uv);
1424 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
1425
1426 vec3 sky = vec3(0.0);
1427 if (uSeeThrough < 0.5) {
1428 vec3 dir = normalize(vWorld - uCam);
1429 sky = starField(dir);
1430 /**
1431 * Cast on through the window to the collector's plane. Anything landing on the
1432 * disc sees its unlit back, which is as black as the sky is not — and the edge,
1433 * which is the opposite: structure standing proud of the mirror plane with the
1434 * sun striking it side-on (D-054). So the collector reads the way any backlit
1435 * thing does, as a hole in the stars with a lit rim, rather than as an absence.
1436 */
1437 float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4);
1438 if (t > 0.0) {
1439 float r = length(vWorld.xy + dir.xy * t);
1440 sky = mix(sky, vec3(0.0), step(r, ${COLLECTOR_R.toFixed(2)}));
1441 // Tight core for the structure itself, and a wide dim skirt for the scatter
1442 // around it. One exponential alone reads as a drawn outline rather than light.
1443 float d = abs(r - ${COLLECTOR_R.toFixed(2)});
1444 float rim = exp(-d / ${COLLECTOR_RIM.toFixed(3)})
1445 + 0.10 * exp(-d / ${(COLLECTOR_RIM * 3).toFixed(3)});
1446 /**
1447 * Not scaled by uExposure — the sun does not dim when Tower banks its beam.
1448 * It does fade as the eye adapts, like the stars, but from a much higher floor:
1449 * this is sunlit structure, and it stays visible at noon where a star does not.
1450 */
1451 sky += vec3(1.0, 0.93, 0.78) * rim * (0.3 + 0.7 * uStarGain) * 0.42;
1452 }
1453 }
1454
1455 gl_FragColor = windowColour(mix(sky, mullion, frame.a), mullion, frame.a);
1456 #include <colorspace_fragment>
1457}
1458`
1459
1460/**
1461 * The far cap: a window, and the frame holding it.
1462 *
1463 * `uGlazing`'s alpha is the mask — opaque where there is a mullion, clear where
1464 * there is a pane. Through a pane is deep anti-sun space (§1.4): black, with stars
1465 * in it, and the haze in front doing the rest. By day the airlight term swamps
1466 * everything and the cap is simply blue, which is what §1.5 already said would
1467 * happen to anything 540 km away. At night the airlight collapses with the beam
1468 * and what is left is the sky.
1469 */
1470const GLAZED_CAP_FRAG = /* glsl */`
1471#include <common>
1472#include <logdepthbuf_pars_fragment>
1473uniform vec3 uTint;
1474uniform sampler2D uGlazing;
e18dad04 2026-09-30 Pavel Safronov1475uniform sampler2D uFrost;
6bb061a6 2026-09-14 Pavel Safronov1476varying vec3 vWorld;
1477${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov1478${SUN_GLSL}
6bb061a6 2026-09-14 Pavel Safronov1479${STAR_GLSL}
e18dad04 2026-09-30 Pavel Safronov1480
1481/**
1482 * The frost on the pane a point is in (D-090, frost.ts): the lattice cell and which half
1483 * of it, looked up in a small table the clock rewrites — lower triangles in red, upper in
1484 * green, cell (a, b) at texel (a + ${FROST_OFFSET}, b + ${FROST_OFFSET}).
1485 */
1486float paneFrost(vec2 xy) {
1487 float B = xy.y / ${(PANE_KM * Math.sqrt(3) / 2).toFixed(6)};
1488 float A = xy.x / ${PANE_KM.toFixed(6)} - B * 0.5;
1489 vec2 c = floor(vec2(A, B));
1490 float up = step(1.0, (A - c.x) + (B - c.y));
1491 vec4 t = texture2D(uFrost, (c + ${FROST_OFFSET.toFixed(1)} + 0.5) / ${FROST_SIZE.toFixed(1)});
1492 return mix(t.r, t.g, up);
1493}
1494${FROST_NOISE_GLSL}
1495${CAP_ICE_GLSL}
1496
6bb061a6 2026-09-14 Pavel Safronov1497void main() {
1498 #include <logdepthbuf_fragment>
1499 vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5;
1500 vec4 frame = texture2D(uGlazing, uv);
1501 vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure;
e18dad04 2026-09-30 Pavel Safronov1502 /**
1503 * Frost (D-090). The glass is the coldest thing in the world and whitens on its inside;
1504 * Tower clears a pane a turn. Frost is feathered, not a sheet, so a pane half-built reads
1505 * as crystals spreading rather than as grey glass — and it is lit by what lights the room
1506 * at the cap, which at the far end is the rod's dusk.
1507 */
1508 float thick = paneFrost(vWorld.xy);
1509 // Crystals at tens of metres, in streaks a few hundred long; beyond a pixel they average.
1510 vec2 fp = vec2(vWorld.x * 0.8 - vWorld.y * 0.6, vWorld.x * 0.6 + vWorld.y * 0.8);
1511 float feather = frostNoise(fp / vec2(0.05, 0.2)) * 0.6 + frostNoise(vWorld.xy / 0.03 + 9.1) * 0.4;
1512 float fine = smoothstep(0.02, 0.4, length(fwidth(vWorld.xy)));
1513 feather = mix(feather, 0.5, fine);
1514 float frost = clamp(thick * (0.8 + 0.4 * feather) * 1.1, 0.0, 0.96);
1515 vec3 rime = vec3(0.74, 0.79, 0.84) * uExposure * lightShare(vWorld) * (0.9 + 0.2 * feather);
6bb061a6 2026-09-14 Pavel Safronov1516 /**
1517 * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam
1518 * changes how bright Vega is — the exposure models the beam lighting the world,
1519 * and the sky outside is not lit by it. uStarGain carries the other half of the
1520 * same fact: the eye adapts, so the stars are there at noon and unseeable.
1521 */
1522 vec3 sky = uSeeThrough < 0.5 ? starField(vWorld - uCam) : vec3(0.0);
e18dad04 2026-09-30 Pavel Safronov1523 vec3 face = mix(mix(sky, rime, frost), mullion, frame.a);
1524 // Seen from outside the frost covers the pane as the frame covers its own width.
1525 float cover = frame.a + (1.0 - frame.a) * frost;
1526 vec3 solid = (mullion * frame.a + rime * (1.0 - frame.a) * frost) / max(cover, 1e-4);
1527 /**
1528 * The ice wall (D-093) stands against the glass as high as it stands against the cap, so
1529 * the window's foot is buried in it — frame and panes alike — to the wall's own height
1530 * there, with a ragged top where meltwater from the panes above ran down and froze.
1531 */
1532 float onIce = 0.0;
1533 float top = capIceTop(vWorld.xy);
1534 if (top > 1.0) {
1535 float elevM = (${R.toFixed(1)} - length(vWorld.xy)) * 1000.0;
1536 float around = atan(vWorld.y, vWorld.x) * ${(R * 1000).toFixed(1)};
1537 float runs = frostNoise(vec2(around / 18.0, 0.5)) * 0.7 + frostNoise(vec2(around / 90.0, 3.5)) * 0.3;
1538 float edge = top + (runs - 0.4) * 40.0;
1539 float aa = max(length(fwidth(vWorld.xy)) * 1000.0, 1.0);
1540 onIce = 1.0 - smoothstep(edge - aa, edge + aa, elevM);
1541 vec3 ice = ${ICE_LINEAR} * uExposure * lightShare(vWorld) * (0.85 + 0.2 * runs);
1542 face = mix(face, ice, onIce);
1543 solid = mix(solid, ice, onIce / max(max(cover, onIce), 1e-4));
1544 cover = max(cover, onIce);
1545 }
6bb061a6 2026-09-14 Pavel Safronov1546 /**
1547 * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it
1548 * is lit by the beam rather than by the room: it blazes at noon, banks at night
1549 * and goes out entirely when the beam does. The stars behind it do none of
1550 * those, which is the whole difference between the two things in this shader.
1551 *
1552 * It is on the axis, but that is NOT the centre of the pinwheel: starField turns
1553 * about the direction parallel to the axis, which lands on the window at the
1554 * camera's own radius rather than at the cap's middle. The two are ~10° apart
1555 * from the floor and coincide only for an observer standing on the axis.
1556 */
1557 float rc = length(vWorld.xy) / ${R.toFixed(1)};
1558 float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)},
1559 ${MIRROR_UV_RADIUS.toFixed(5)}, rc);
f16f084e 2026-09-28 Pavel Safronov1560 // Lit by whatever beam arrives: by night the banked beam, by day what gets past the
1561 // sun (D-079), so the rod ends on a lit mirror at every hour.
1562 vec3 glow = mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6 * (uSun.z + uSun.y * BEAM_PAST_SUN);
e18dad04 2026-09-30 Pavel Safronov1563 gl_FragColor = windowColour(face + glow, solid + glow, cover);
6bb061a6 2026-09-14 Pavel Safronov1564 #include <colorspace_fragment>
1565}
1566`
1567
1568const BEAM_VERT = /* glsl */`
1569#include <common>
1570#include <logdepthbuf_pars_vertex>
1571uniform mat4 uFrame;
1572varying vec3 vWorld;
1573varying vec3 vNormal2;
1574void main() {
1575 vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz;
1576 vNormal2 = normalize(mat3(uFrame * modelMatrix) * normal);
1577 gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
1578 #include <logdepthbuf_vertex>
1579}
1580`
1581
1582/**
1583 * The beam is glowing air, not a surface, so it is brightest where the sightline
1584 * passes through the most of it — down the middle — and fades at the limb. A tube
1585 * shaded the usual way does the exact opposite and reads as a chrome pipe.
1586 */
1587const BEAM_FRAG = /* glsl */`
1588#include <common>
1589#include <logdepthbuf_pars_fragment>
1590uniform vec3 uBeam;
1591varying vec3 vWorld;
1592varying vec3 vNormal2;
1593${HAZE_GLSL}
59c12827 2026-09-28 Pavel Safronov1594${SUN_GLSL}
6bb061a6 2026-09-14 Pavel Safronov1595void main() {
1596 #include <logdepthbuf_fragment>
1597 vec3 view = normalize(vWorld - uCam);
1598 // Additive white clips long before the limb, so the falloff has to start early
1599 // or the shaft renders as a hard-edged slab instead of a glowing rod.
1600 float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1);
59c12827 2026-09-28 Pavel Safronov1601 /**
f16f084e 2026-09-28 Pavel Safronov1602 * The rod runs from cap to cap, day and night (D-079). By day the sun's shoal takes part
1603 * of the beam where it gathers, so past the sun the rod is dimmer — BEAM_PAST_SUN of
1604 * itself — but never gone. By night the banked beam runs the whole length evenly.
59c12827 2026-09-28 Pavel Safronov1605 */
f16f084e 2026-09-28 Pavel Safronov1606 float spent = smoothstep(uSun.x - 1.5, uSun.x + 1.5, vWorld.z) * uSun.y * (1.0 - BEAM_PAST_SUN);
6bb061a6 2026-09-14 Pavel Safronov1607 vec3 T = towerTransmittance(uCam, vWorld);
59c12827 2026-09-28 Pavel Safronov1608 gl_FragColor = vec4(uBeam * uExposure * through * (1.0 - spent) * T, 1.0);
1609 #include <colorspace_fragment>
1610}
1611`
1612
1613/**
1614 * Tower's sun (D-074): the swarm gathered into one shoal on the beam, throwing the beam's
1615 * light out sideways. It is drawn on a sphere well outside the disc, and every pixel works
1616 * out how close its line of sight passes to the sun's centre: inside the disc it is far
1617 * brighter than white, and outside it falls off as glare. One mesh, so the disc and its
1618 * glow cannot drift apart, and the same haze as everything else between it and the eye —
1619 * which is what makes it the same sun from the floor, from the air, and through a window.
1620 */
1621const SUN_FRAG = /* glsl */`
1622#include <common>
1623#include <logdepthbuf_pars_fragment>
1624varying vec3 vWorld;
1625${HAZE_GLSL}
1626${SUN_GLSL}
1627void main() {
1628 #include <logdepthbuf_fragment>
1629 // Each pixel once: the near face from outside the glow, the far face from inside it.
1630 bool outside = distance(uCam, sunPos()) > ${SUN_GLOW_R.toFixed(1)};
1631 if (gl_FrontFacing != outside) discard;
1632 vec3 v = normalize(vWorld - uCam);
1633 vec3 oc = sunPos() - uCam;
1634 float along = dot(oc, v);
1635 if (along <= 0.0) discard;
1636 // How far the line of sight passes from the sun's centre, km.
1637 float d = length(oc - v * along);
1638 float disc = 1.0 - smoothstep(${(SUN_R * 0.9).toFixed(3)}, ${SUN_R.toFixed(3)}, d);
1639 float glare = 0.9 * exp(-max(d - ${SUN_R.toFixed(3)}, 0.0) / 1.6)
1640 + 0.22 * exp(-d / 7.0);
1641 glare *= 1.0 - smoothstep(${(SUN_GLOW_R * 0.6).toFixed(1)}, ${SUN_GLOW_R.toFixed(1)}, d);
1642 vec3 T = towerTransmittance(uCam, sunPos());
1643 vec3 col = vec3(1.0, 0.96, 0.86) * (disc * 40.0 + glare * uExposure) * uSun.y;
1644 gl_FragColor = vec4(col * T, 1.0);
6bb061a6 2026-09-14 Pavel Safronov1645 #include <colorspace_fragment>
1646}
1647`
1648
42b4554e 2026-09-29 Pavel Safronov1649/**
1650 * Tower's moon (D-088): the sun's counterpart by night, a smaller gathering of the swarm on
1651 * the banked beam, drawn the way the sun is. Its disc shows Earth's Moon's phase: the lit
1652 * part is bounded by the terminator's ellipse, and lies toward the far cap while it waxes —
1653 * the end the sun went out at, as a waxing moon on Earth is lit on the side of the set sun —
1654 * and toward the sun cap while it wanes, the end the sun will light at. The dark part is not
1655 * drawn: those mirrorlets are simply turned away.
1656 */
1657const MOON_FRAG = /* glsl */`
1658#include <common>
1659#include <logdepthbuf_pars_fragment>
1660varying vec3 vWorld;
1661${HAZE_GLSL}
1662${SUN_GLSL}
1663void main() {
1664 #include <logdepthbuf_fragment>
1665 bool outside = distance(uCam, moonPos()) > ${MOON_GLOW_R.toFixed(1)};
1666 if (gl_FrontFacing != outside) discard;
1667 vec3 v = normalize(vWorld - uCam);
1668 vec3 oc = moonPos() - uCam;
1669 float along = dot(oc, v);
1670 if (along <= 0.0) discard;
1671 vec3 off = v * along - oc;
1672 float d = length(off);
1673 // Across the disc: x toward the lit limb, along the axis as seen; y across it.
1674 vec3 axis = vec3(0.0, 0.0, 1.0) - v * v.z;
1675 axis = length(axis) > 1e-4 ? normalize(axis) : normalize(cross(v, vec3(1.0, 0.0, 0.0)));
1676 float x = dot(off, axis) / ${MOON_R.toFixed(3)} * sign(uMoon.w);
1677 float y = length(off - axis * dot(off, axis)) / ${MOON_R.toFixed(3)};
1678 float e = 1.0 - 2.0 * abs(uMoon.w);
1679 float lit = step(e * sqrt(max(0.0, 1.0 - y * y)), x);
1680 float disc = (1.0 - smoothstep(0.9, 1.0, d / ${MOON_R.toFixed(3)})) * lit;
1681 float glare = 0.35 * exp(-max(d - ${MOON_R.toFixed(3)}, 0.0) / 1.2) * abs(uMoon.w);
1682 glare *= 1.0 - smoothstep(${(MOON_GLOW_R * 0.6).toFixed(1)}, ${MOON_GLOW_R.toFixed(1)}, d);
1683 vec3 T = towerTransmittance(uCam, moonPos());
1684 vec3 col = vec3(0.86, 0.9, 1.0) * (disc * 3.0 + glare * uExposure) * uMoon.z;
1685 gl_FragColor = vec4(col * T, 1.0);
1686 #include <colorspace_fragment>
1687}
1688`
1689
6bb061a6 2026-09-14 Pavel Safronov1690// ------------------------------------------------------------------------ model
1691
1692export interface InteriorModelOptions {
376f2ce6 2026-09-16 Pavel Safronov1693 /** How finely the ground is tiled. The exterior never needs more than the roots. */
1694 lod: TerrainLODOptions
6bb061a6 2026-09-14 Pavel Safronov1695 /**
1696 * True when the model is looked at from outside, through its windows: the panes
1697 * go clear instead of painting the sky, and the caps blend with what is behind.
1698 */
1699 seeThrough: boolean
1700}
1701
28896dab 2026-09-27 Pavel Safronov1702/** Where the see-through windows draw among the transparent things (see `buildCaps`). */
59c12827 2026-09-28 Pavel Safronov1703export const WINDOW_RENDER_ORDER = 4
28896dab 2026-09-27 Pavel Safronov1704
6bb061a6 2026-09-14 Pavel Safronov1705export class InteriorModel {
1706 /** Every material here, so a caller can set a shared uniform in one loop. */
1707 readonly materials: ShaderMaterial[] = []
1708 readonly groundMat: ShaderMaterial
80bcc97a 2026-09-17 Pavel Safronov1709 readonly waterMat: ShaderMaterial
00ceeedb 2026-09-27 Pavel Safronov1710 readonly canopyMat: ShaderMaterial
1711 /** The trees near the eye, and the cards beyond them (forest-view.ts). */
1712 readonly forest: ForestView
42b4554e 2026-09-29 Pavel Safronov1713 /** Tower's moon (D-088), moved along the axis by `setSky`, and out by day. */
1714 readonly moon: Mesh
59c12827 2026-09-28 Pavel Safronov1715 /** Tower's sun, moved along the axis by `setSky`. */
1716 readonly sun: Mesh
80bcc97a 2026-09-17 Pavel Safronov1717 /** Every waterfall in the world, rebuilt when the world is. */
1718 readonly falls = new Group()
376f2ce6 2026-09-16 Pavel Safronov1719 /** The generated ground: a quadtree of tiles, refined around the eye. */
1720 readonly lod: TerrainLOD
1721 readonly ground: Group
6bb061a6 2026-09-14 Pavel Safronov1722 /** Everything to add to a scene, the ground included. */
376f2ce6 2026-09-16 Pavel Safronov1723 readonly meshes: Object3D[]
a088e5d5 2026-09-17 Pavel Safronov1724 /** The fleet: every hull, and every sail. Both sit at the eye (see SHIP_VERT). */
1725 readonly hulls: InstancedMesh
1726 readonly sails: InstancedMesh
1727 readonly jibs: InstancedMesh
99a2821b 2026-09-29 Pavel Safronov1728 /** The railways (terrain/rails.ts), set by the interior view: the exterior does not draw them. */
1729 readonly rails = new Group()
6bb061a6 2026-09-14 Pavel Safronov1730
1731 constructor(private opts: InteriorModelOptions) {
376f2ce6 2026-09-16 Pavel Safronov1732 this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} })
80bcc97a 2026-09-17 Pavel Safronov1733 /**
1734 * Water blends over the ground it covers, and writes no depth: a lake seen through
1735 * the shallows of another would otherwise punch holes in it. Both faces, since a wave
1736 * near the eye can tip a triangle away.
1737 */
a088e5d5 2026-09-17 Pavel Safronov1738 this.waterMat = this.material(WATER_VERT, WATER_FRAG, { side: DoubleSide, uniforms: {
1739 uBodies: { value: this.bodies }, uWaveOffset: { value: this.waveOffsets }, uSurfRef: { value: this.surfRef },
1740 } })
80bcc97a 2026-09-17 Pavel Safronov1741 this.waterMat.transparent = true
1742 this.waterMat.depthWrite = false
00ceeedb 2026-09-27 Pavel Safronov1743 this.canopyMat = this.material(CANOPY_VERT, CANOPY_FRAG, { side: DoubleSide, uniforms: {} })
1744 this.forest = new ForestView(
1745 terrain(),
1746 this.material(TREE_VERT, TREE_FRAG, { side: FrontSide, uniforms: {} }),
1747 this.material(CARD_VERT, CARD_FRAG, { side: DoubleSide, uniforms: {} }))
1748 this.lod = new TerrainLOD(terrain(), this.groundMat, this.waterMat, this.canopyMat, opts.lod)
376f2ce6 2026-09-16 Pavel Safronov1749 this.ground = this.lod.group
80bcc97a 2026-09-17 Pavel Safronov1750 this.falls.name = 'falls'
a088e5d5 2026-09-17 Pavel Safronov1751 const fleet = (geometry: BufferGeometry, colour: number, name: string): InstancedMesh => {
1752 const m = this.material(SHIP_VERT, SHIP_FRAG, {
1753 side: DoubleSide, uniforms: { uColour: { value: new Color(colour) } },
1754 })
1755 const mesh = new InstancedMesh(geometry, m, MAX_SHIPS)
1756 mesh.name = name
1757 mesh.count = 0
1758 // The instances are placed around the eye every frame; the mesh's own bounds mean nothing.
1759 mesh.frustumCulled = false
1760 return mesh
1761 }
1762 this.hulls = fleet(hullGeometry(), 0x4a3526, 'hulls')
1763 this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails')
1764 this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs')
59c12827 2026-09-28 Pavel Safronov1765 this.sun = this.buildSun()
42b4554e 2026-09-29 Pavel Safronov1766 this.moon = this.buildMoon()
99a2821b 2026-09-29 Pavel Safronov1767 this.rails.name = 'rails'
42b4554e 2026-09-29 Pavel Safronov1768 this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, this.rails, ...this.buildCaps(), this.buildBeam(), this.sun, this.moon]
80bcc97a 2026-09-17 Pavel Safronov1769 terrain().onWorld(() => this.buildFalls())
1770 if (terrain().compiled) this.buildFalls()
1771 }
1772
1773 /**
1774 * One sheet per fall. They are few — a handful per river — so each is its own mesh and
1775 * its own material, which carries the fall's height for the streaks' speed.
1776 */
1777 private buildFalls(): void {
1778 for (const child of [...this.falls.children]) {
1779 const mesh = child as Mesh
1780 mesh.geometry.dispose()
1781 const m = mesh.material as ShaderMaterial
1782 this.materials.splice(this.materials.indexOf(m), 1)
1783 m.dispose()
1784 this.falls.remove(mesh)
1785 }
1786 for (const f of terrain().compiled?.falls ?? []) {
1787 const sheet = fallSheet(f)
1788 const g = new BufferGeometry()
1789 g.setAttribute('position', new BufferAttribute(sheet.positions, 3))
1790 g.setAttribute('uv', new BufferAttribute(sheet.uvs, 2))
1791 g.setIndex(new BufferAttribute(sheet.indices, 1))
1792 g.computeBoundingSphere()
1793 const m = this.material(FALL_VERT, FALL_FRAG, {
1794 side: DoubleSide, uniforms: { uHeightM: { value: sheet.heightM } },
1795 })
1796 m.transparent = true
1797 m.depthWrite = false
1798 // Copy the shared uniforms from the ground, so a sheet built mid-session starts in
1799 // the light of the moment rather than at the defaults.
1800 const donor = this.groundMat.uniforms
59c12827 2026-09-28 Pavel Safronov1801 for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime']) {
80bcc97a 2026-09-17 Pavel Safronov1802 m.uniforms[key]!.value = donor[key]!.value
1803 }
1804 ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color)
1805 ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3)
59c12827 2026-09-28 Pavel Safronov1806 ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3)
42b4554e 2026-09-29 Pavel Safronov1807 ;(m.uniforms['uMoon']!.value as Vector4).copy(donor['uMoon']!.value as Vector4)
1808 ;(m.uniforms['uMoon']!.value as Vector4).copy(donor['uMoon']!.value as Vector4)
80bcc97a 2026-09-17 Pavel Safronov1809 ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4)
1810 const mesh = new Mesh(g, m)
1811 mesh.name = `fall ${f.river}`
1812 mesh.position.set(...sheet.origin)
1813 mesh.renderOrder = 2
1814 this.falls.add(mesh)
1815 }
6bb061a6 2026-09-14 Pavel Safronov1816 }
1817
a088e5d5 2026-09-17 Pavel Safronov1818 /** Each lake and sea's waves, as the shader reads them (sea-state.ts). */
1819 readonly bodies = new Float32Array(MAX_BODIES * BODY_STRIDE)
1820 /** Each body's wave phases at the surface reference (see surfaceMetres in WAVES_GLSL). */
1821 private readonly waveOffsets = new Float32Array(MAX_BODIES * WAVE_LEN.length)
1822 private readonly surfRef = new Vector4(1, 0, 0, 0)
1823 private surfRefA = 0
1824 seaStates: SeaState[] = []
1825
1826 /**
1827 * Put the waves where the weather has them at `hours` since day 0. Cheap, but not free,
1828 * and the sea state changes over hours: callers need not run it every frame.
1829 */
1830 setWeather(hours: number): void {
1831 const t = terrain()
1832 if (!t.world || !t.compiled) return
1833 this.seaStates = seaStateTable(t.world, t.compiled, hours, this.bodies)
1834 this.updateWaveOffsets()
1835 }
1836
1837 /** What the surface reference's own position adds to each wave's phase, mod 2π. */
1838 private updateWaveOffsets(): void {
1839 const n = WAVE_LEN.length
1840 const z = this.surfRef.z, a = this.surfRefA
1841 for (let b = 0; b < MAX_BODIES; b++) {
1842 const o = b * BODY_STRIDE
1843 const len = this.bodies[o + 1]!, dir = this.bodies[o + 2]!
1844 for (let i = 0; i < n; i++) {
1845 if (len <= 0) { this.waveOffsets[b * n + i] = 0; continue }
1846 const k = (2 * Math.PI) / (len * WAVE_LEN[i]!)
1847 const th = dir + WAVE_TURN[i]!
1848 const ph = k * (Math.cos(th) * z + Math.sin(th) * a)
1849 this.waveOffsets[b * n + i] = ph - Math.floor(ph / (2 * Math.PI)) * 2 * Math.PI
1850 }
1851 }
1852 }
1853
99a2821b 2026-09-29 Pavel Safronov1854 private railAt = new Vector3()
1855 /** How far out railways are drawn, km. */
1856 private static readonly RAIL_RANGE_KM = 45
1857
a088e5d5 2026-09-17 Pavel Safronov1858 /** How far out ships are drawn, km. Past this a hull is under a pixel and all haze. */
1859 private static readonly SHIP_RANGE_KM = 40
1860
1861 /**
1862 * Put the fleet where it is at `hours`, riding the waves drawn at `timeS` (the water
1863 * shader's clock). Cheap — a few dozen ships — so it runs every frame.
1864 */
1865 setShips(hours: number, timeS: number, eye: Vector3): void {
1866 const t = terrain()
1867 const world = t.world, compiled = t.compiled
1868 const fleet = [this.hulls, this.sails, this.jibs]
1869 if (!world || !compiled) { for (const m of fleet) m.count = 0; return }
1870 const R_M = RADIUS_M
1871 const hull = new Matrix4(), sail = new Matrix4(), turn = new Matrix4()
1872 const up = new Vector3(), fwd = new Vector3(), side = new Vector3(), pos = new Vector3()
1873 const deep = new Vector3(0, 0, 1), spin = new Vector3()
1874 for (const m of fleet) m.position.copy(eye)
1875 let n = 0
1876 for (const { lane, fix } of shipsAt(world, hours)) {
1877 if (n >= MAX_SHIPS) break
1878 const b = compiled.basins.find((x) => x.id === lane.sea)
1879 if (!b) continue
1880 const theta = fix.a / R_M
1881 const cos = Math.cos(theta), sin = Math.sin(theta)
1882 // Cull by distance first: the wave sum is the costly part.
1883 const rough = pos.set((R_M / 1000) * cos, (R_M / 1000) * sin, fix.z / 1000)
1884 if (rough.distanceTo(eye) > InteriorModel.SHIP_RANGE_KM) continue
1885 const o = b.index * BODY_STRIDE
1886 const amp = this.bodies[o]!, len = this.bodies[o + 1]!, dir = this.bodies[o + 2]!
1887 const w = waveAt(amp, len, dir, fix.z, fix.a, timeS)
1888 // A hull spans its waves: the longer it is against them, the less it pitches and rolls.
1889 const ride = len > 0 ? 1 / (1 + lane.lengthM / (0.35 * len)) : 0
1890 const r = (R_M - b.levelM - w.h * (0.4 + 0.6 * ride)) / 1000
1891 pos.set(r * cos, r * sin, fix.z / 1000).sub(eye)
1892 up.set(-cos, -sin, 0)
1893 spin.set(-sin, cos, 0)
1894 up.addScaledVector(deep, -w.gz * ride).addScaledVector(spin, -w.ga * ride).normalize()
1895 fwd.set(0, 0, 0).addScaledVector(deep, Math.cos(fix.headingRad)).addScaledVector(spin, Math.sin(fix.headingRad))
1896 fwd.addScaledVector(up, -fwd.dot(up)).normalize()
1897 side.crossVectors(fwd, up)
1898 const scale = lane.lengthM / 1000
1899 hull.makeBasis(fwd, up, side).scale(new Vector3(scale, scale, scale)).setPosition(pos)
1900 this.hulls.setMatrixAt(n, hull)
1901 this.jibs.setMatrixAt(n, hull)
1902 const state = this.seaStates[b.index]
1903 const windRad = ((state?.wind.directionDeg ?? 0) * Math.PI) / 180
1904 // The instance's +z points to heading − 90°, and a positive boom angle swings toward
1905 // heading + 90°, so the rotation is negated.
1906 turn.makeRotationY(-boomAngle(fix.headingRad, windRad))
1907 sail.makeTranslation(MAST_X, 0, 0).multiply(turn)
1908 this.sails.setMatrixAt(n, hull.clone().multiply(sail))
1909 n++
1910 }
1911 for (const m of fleet) {
1912 m.count = n
1913 m.instanceMatrix.needsUpdate = true
1914 }
1915 }
1916
376f2ce6 2026-09-16 Pavel Safronov1917 /**
1918 * The eye, in the tower's frame. Call every frame: it is also what chooses which
1919 * tiles of ground to draw.
1920 */
6bb061a6 2026-09-14 Pavel Safronov1921 setCamera(eye: Vector3): void {
1922 for (const m of this.materials) (m.uniforms['uCam']!.value as Vector3).copy(eye)
a088e5d5 2026-09-17 Pavel Safronov1923 // The water's surface reference, in whole steps near the eye, computed in float64.
1924 const aM = Math.atan2(eye.y, eye.x) * RADIUS_M
1925 const refA = Math.round(aM / SURF_SNAP_M) * SURF_SNAP_M
1926 const refZ = Math.round((eye.z * 1000) / SURF_SNAP_M) * SURF_SNAP_M
1927 this.surfRef.set(Math.cos(refA / RADIUS_M), Math.sin(refA / RADIUS_M), refZ, 0)
1928 this.surfRefA = refA
1929 this.updateWaveOffsets()
00ceeedb 2026-09-27 Pavel Safronov1930 // The trees around the eye, rebuilt only when it has moved far enough to matter.
1931 this.forest.update(eye)
376f2ce6 2026-09-16 Pavel Safronov1932 this.lod.update(eye)
99a2821b 2026-09-29 Pavel Safronov1933 // A railway 7 m wide is under a pixel and all haze past a few tens of kilometres: those
1934 // chunks are not drawn at all, which keeps some 280 draw calls off every frame.
1935 for (const c of this.rails.children) {
1936 const m = c as Mesh
1937 const b = m.geometry.boundingSphere
1938 if (!b) continue
1939 this.railAt.copy(b.center).add(m.position)
1940 m.visible = this.railAt.distanceTo(eye) - b.radius < InteriorModel.RAIL_RANGE_KM
1941 }
1942 }
1943
1944 /**
1945 * A lit, hazed material of one colour, for meshes that are neither ground nor water nor
1946 * instanced: the scenic tour's craft, and the railways.
1947 */
1948 plainMaterial(colour: number): ShaderMaterial {
1949 const m = this.material(PLAIN_VERT, SHIP_FRAG, { side: DoubleSide, uniforms: { uColour: { value: new Color(colour) } } })
1950 // Built mid-session, so take the light of the moment rather than the defaults.
1951 const donor = this.groundMat.uniforms
1952 for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime']) m.uniforms[key]!.value = donor[key]!.value
1953 ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color)
1954 ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3)
1955 ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3)
1956 ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4)
1957 return m
1958 }
1959
1960 private railMats: { deck: ShaderMaterial; girder: ShaderMaterial } | null = null
1961
1962 /**
1963 * Lay the railways (D-086): a deck on a girder along each line at the height rails.ts gave
1964 * it, and a pier down to the ground every 100 m wherever it stands clear. Rebuilt whole when
1965 * the world changes; a few hundred kilometres of box is cheap.
1966 */
1967 setRails(lines: readonly RailLine[]): void {
1968 for (const child of [...this.rails.children]) {
1969 ;(child as Mesh).geometry.dispose()
1970 this.rails.remove(child)
1971 }
1972 this.railMats ??= { deck: this.plainMaterial(0x57514a), girder: this.plainMaterial(0x9a958c) }
1973 const HALF = 3.5, GIRDER = 1.8, PIER = 1.6
1974 for (const r of lines) {
1975 const n = r.line.s.length
1976 const P = (i: number) => {
1977 const k = r.loop ? ((i % (n - 1)) + (n - 1)) % (n - 1) : Math.max(0, Math.min(n - 1, i))
1978 const th = r.line.pts[2 * k + 1]! / RADIUS_M, rad = RADIUS_M - r.deckM[k]!
1979 return { v: new Vector3(rad * Math.cos(th), rad * Math.sin(th), r.line.pts[2 * k]!), k }
1980 }
1981 for (let c0 = 0; c0 < n - 1; c0 += RAIL_CHUNK) {
1982 const c1 = Math.min(n - 1, c0 + RAIL_CHUNK)
1983 const origin = P(c0).v.clone().divideScalar(1000)
1984 const deck: number[] = [], girder: number[] = []
1985 const at = (v: Vector3) => [v.x / 1000 - origin.x, v.y / 1000 - origin.y, v.z / 1000 - origin.z]
1986 const quad = (out: number[], a: Vector3, b: Vector3, c: Vector3, d: Vector3) =>
1987 out.push(...at(a), ...at(b), ...at(c), ...at(a), ...at(c), ...at(d))
1988 const edge = (i: number) => {
1989 const p = P(i).v, fwd = P(i + 1).v.clone().sub(P(i - 1).v).normalize()
1990 const up = new Vector3(-p.x, -p.y, 0).normalize()
1991 const side = new Vector3().crossVectors(fwd, up).normalize().multiplyScalar(HALF)
1992 const down = up.clone().multiplyScalar(-GIRDER)
1993 return {
1994 tl: p.clone().sub(side), tr: p.clone().add(side),
1995 bl: p.clone().sub(side).add(down), br: p.clone().add(side).add(down), p, up,
1996 }
1997 }
1998 let prev = edge(c0)
1999 for (let i = c0 + 1; i <= c1; i++) {
2000 const e = edge(i)
2001 quad(deck, prev.tl, prev.tr, e.tr, e.tl)
2002 quad(girder, prev.tl, e.tl, e.bl, prev.bl)
2003 quad(girder, prev.tr, prev.br, e.br, e.tr)
2004 quad(girder, prev.bl, e.bl, e.br, prev.br)
2005 prev = e
2006 }
2007 // Piers, where the deck stands clear of the ground.
2008 const every = Math.round(PIER_EVERY_M / (r.line.s[1]! - r.line.s[0]!)) || 2
2009 for (let i = c0 - (c0 % every) + every; i < c1; i += every) {
2010 const { k } = P(i)
2011 const clear = r.deckM[k]! - r.groundM[k]!
2012 if (clear < GIRDER + 2) continue
2013 const e = edge(i)
2014 const fwd = P(i + 1).v.clone().sub(P(i - 1).v).normalize().multiplyScalar(PIER / 2)
2015 const side = new Vector3().crossVectors(fwd, e.up).normalize().multiplyScalar(PIER / 2)
2016 const top = e.p.clone().addScaledVector(e.up, -GIRDER)
2017 const foot = e.p.clone().addScaledVector(e.up, -(clear + 3))
2018 const corners = [fwd.clone().add(side), fwd.clone().sub(side), fwd.clone().negate().sub(side), fwd.clone().negate().add(side)]
2019 for (let q = 0; q < 4; q++) {
2020 const a = corners[q]!, b = corners[(q + 1) % 4]!
2021 quad(girder, top.clone().add(a), top.clone().add(b), foot.clone().add(b), foot.clone().add(a))
2022 }
2023 }
2024 for (const [pos, mat] of [[deck, this.railMats.deck], [girder, this.railMats.girder]] as const) {
2025 const g = new BufferGeometry()
2026 g.setAttribute('position', new BufferAttribute(new Float32Array(pos), 3))
2027 g.computeVertexNormals()
2028 g.computeBoundingSphere()
2029 const mesh = new Mesh(g, mat)
2030 mesh.position.copy(origin)
2031 mesh.name = `rail ${r.id}`
2032 this.rails.add(mesh)
2033 }
2034 }
2035 }
6bb061a6 2026-09-14 Pavel Safronov2036 }
2037
2038 /** World to tower coordinates. Only the exterior, which turns the hull, needs it. */
2039 setFrame(worldToTower: Matrix4): void {
2040 for (const m of this.materials) (m.uniforms['uFrame']!.value as Matrix4).copy(worldToTower)
2041 }
2042
2043 private material(
2044 vertexShader: string, fragmentShader: string,
376f2ce6 2026-09-16 Pavel Safronov2045 params: { side: typeof BackSide | typeof DoubleSide | typeof FrontSide; uniforms: Record<string, { value: unknown }>; additive?: boolean },
6bb061a6 2026-09-14 Pavel Safronov2046 ): ShaderMaterial {
2047 const m = new ShaderMaterial({
2048 vertexShader, fragmentShader, side: params.side,
2049 uniforms: { ...params.uniforms, ...this.sharedUniforms() },
2050 ...(params.additive
2051 ? { transparent: true, blending: AdditiveBlending, depthWrite: false }
2052 : {}),
2053 })
2054 this.materials.push(m)
2055 return m
2056 }
2057
2058 private sharedUniforms(): Record<string, { value: unknown }> {
2059 return {
2060 uCam: { value: new Vector3() },
2061 uFrame: { value: new Matrix4() },
2062 uK: { value: new Vector3(...K_RGB) },
2063 uHaze: { value: new Color(HAZE_DAY) },
2064 uExposure: { value: 1 },
2065 uSeeThrough: { value: this.opts.seeThrough ? 1 : 0 },
2066 // Only the glazed caps read these, but every material carries them so the
2067 // per-frame update can walk one list instead of knowing who wants what.
2068 uSpin: { value: 0 },
80bcc97a 2026-09-17 Pavel Safronov2069 // Seconds, for anything that moves: the water.
2070 uTime: { value: 0 },
6bb061a6 2026-09-14 Pavel Safronov2071 uStarGain: { value: 0 },
e331ec2a 2026-09-28 Pavel Safronov2072 // The real sky (D-075): the catalogue, and which way it lies at the clock's instant.
2073 uStars: { value: starTexture() },
2074 uSkyFrame: { value: skyMatrix(Date.now(), new Matrix3()) },
59c12827 2026-09-28 Pavel Safronov2075 // The sun's position (km), how lit it is, and the banked rod's share (SUN_GLSL).
2076 uSun: { value: new Vector3(L / 2, 1, 0) },
42b4554e 2026-09-29 Pavel Safronov2077 // The moon's position (km), its light, its ramp and its signed phase (SUN_GLSL).
2078 uMoon: { value: new Vector4(L / 2, 0, 0, 0) },
e18dad04 2026-09-30 Pavel Safronov2079 // The far end's dim, and the sky's light by where it comes from (SUN_GLSL, D-091).
2080 uDim: { value: new Vector4(0, 0, 0, 0) },
abc0aad0 2026-10-01 Pavel Safronov2081 // The farms' own days (GROUND_FRAG, D-095).
2082 uFarm: { value: new Vector4(0, 12, 1, 1) },
e18dad04 2026-09-30 Pavel Safronov2083 uLightMix: { value: new Vector3(0, 1, 0) },
6bb061a6 2026-09-14 Pavel Safronov2084 }
2085 }
2086
2087 /**
2088 * Which caps are windows, and how they blend. Inside, a window is opaque to the
2089 * renderer — the sky it shows is computed, and nothing is drawn behind it. From
2090 * outside it has to be transparent, so it is sorted and blended with the scene
2091 * that really is behind it: the land beyond the near cap, the collector and the
2092 * stars beyond the far one.
2093 */
e18dad04 2026-09-30 Pavel Safronov2094 private windowMaterial(frag: string, tint: number, glazing: Texture, extra: Record<string, { value: unknown }> = {}): ShaderMaterial {
6bb061a6 2026-09-14 Pavel Safronov2095 const m = this.material(FLAT_VERT, frag, {
2096 side: DoubleSide,
e18dad04 2026-09-30 Pavel Safronov2097 uniforms: { uTint: { value: new Color(tint) }, uGlazing: { value: glazing }, ...extra },
6bb061a6 2026-09-14 Pavel Safronov2098 })
2099 m.transparent = this.opts.seeThrough
2100 return m
2101 }
2102
2103 /**
2104 * The caps close the world at both ends. From the midpoint they are 270 km away
2105 * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct
2106 * and slightly unnerving result, and worth drawing precisely because it is.
2107 *
2108 * Both are windows (D-045, D-050). The far cap is set a kilometre into its own
2109 * frame so the wall depth of D-044 has somewhere to be visible from; the sun cap
2110 * is an annulus, because the beam enters through the hole in its middle.
2111 */
2112 private buildCaps(): Mesh[] {
2113 // The tint is a multiplier over the pattern, not the colour of the cap.
2114 const sun = new Mesh(
2115 new RingGeometry(BEAM_R, R, 128),
2116 this.windowMaterial(SUN_CAP_FRAG, 0xffffff, sunCapGlazingTexture()))
2117 sun.name = 'sunCap'
2118
2119 const far = new Mesh(
2120 new CircleGeometry(R, 128),
e18dad04 2026-09-30 Pavel Safronov2121 this.windowMaterial(GLAZED_CAP_FRAG, 0xbcc2cc, capGlazingTexture(), { uFrost: { value: this.frostTexture }, uCapIce: { value: this.capIceTexture } }))
6bb061a6 2026-09-14 Pavel Safronov2122 far.name = 'farCap'
2123 far.position.z = L + HULL_D
2124
2125 // The reveal: a kilometre of bore standing between the end of the world and
2126 // the glass. BackSide, because it is only ever seen from inside.
2127 const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true)
2128 bore.rotateX(Math.PI / 2)
2129 bore.translate(0, 0, L + HULL_D / 2)
2130 const reveal = new Mesh(bore, this.material(FLAT_VERT, REVEAL_FRAG, {
e18dad04 2026-09-30 Pavel Safronov2131 side: BackSide, uniforms: { uTint: { value: new Color(0x9aa0aa) }, uCapIce: { value: this.capIceTexture } },
6bb061a6 2026-09-14 Pavel Safronov2132 }))
2133 reveal.name = 'bore'
28896dab 2026-09-27 Pavel Safronov2134 /**
59c12827 2026-09-28 Pavel Safronov2135 * From outside, a window is drawn after everything behind it: the water (1), the
2136 * falls (2), the beam (3) and the sun (3.5). Left at 0 it went first, wrote its depth,
2137 * and whatever was behind the glass failed the depth test — looking in at the sun cap
2138 * showed the Sunward Sea's bed, brown, where the sea should have been, and the rod and
2139 * the sun were missing from a view that has to show the same sky as the one inside
2140 * (D-074). The exterior's own transparent things share this order (exterior.ts), so they
2141 * still sort against the glass by distance.
28896dab 2026-09-27 Pavel Safronov2142 */
2143 if (this.opts.seeThrough) sun.renderOrder = far.renderOrder = WINDOW_RENDER_ORDER
6bb061a6 2026-09-14 Pavel Safronov2144 return [sun, far, reveal]
2145 }
2146
2147 private buildBeam(): Mesh {
2148 const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true)
2149 g.rotateX(Math.PI / 2)
2150 g.translate(0, 0, L / 2)
2151 const m = this.material(BEAM_VERT, BEAM_FRAG, {
2152 side: DoubleSide, additive: true,
2153 uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) } },
2154 })
2155 const beam = new Mesh(g, m)
2156 beam.name = 'beam'
80bcc97a 2026-09-17 Pavel Safronov2157 /**
2158 * After the water (1) and the falls (2). Three draws lower render orders first, and the
2159 * beam is always in front of any water — water lies on the floor, the beam down the
2160 * axis — so drawn earlier, the far side's seas were blended over it and it went fuzzy
2161 * wherever it crossed them.
2162 */
2163 beam.renderOrder = 3
6bb061a6 2026-09-14 Pavel Safronov2164 return beam
2165 }
59c12827 2026-09-28 Pavel Safronov2166
2167 /** After the beam it sits in, and before the windows, which are in front of it from outside. */
2168 private buildSun(): Mesh {
2169 const m = this.material(FLAT_VERT, SUN_FRAG, { side: DoubleSide, additive: true, uniforms: {} })
2170 const sun = new Mesh(new SphereGeometry(SUN_GLOW_R, 48, 24), m)
2171 sun.name = 'sun'
2172 sun.renderOrder = 3.5
2173 sun.frustumCulled = false
2174 return sun
2175 }
2176
42b4554e 2026-09-29 Pavel Safronov2177 /** The moon, drawn as the sun is: after the beam, before the windows. */
2178 private buildMoon(): Mesh {
2179 const m = this.material(FLAT_VERT, MOON_FRAG, { side: DoubleSide, additive: true, uniforms: {} })
2180 const moon = new Mesh(new SphereGeometry(MOON_GLOW_R, 32, 16), m)
2181 moon.name = 'moon'
2182 moon.renderOrder = 3.5
2183 moon.frustumCulled = false
2184 moon.visible = false
2185 return moon
2186 }
2187
59c12827 2026-09-28 Pavel Safronov2188 /**
2189 * Light the model for a moment of Tower's day (sky.ts). Both views call this with the
2190 * same sky, so the sun, the rod and the light on the ground agree inside and out.
2191 *
2192 * Eyes adapt, so twilight is not 6.7x darker than temperate to look at: the exponent
2193 * compresses the canon watts into something a screen can show while keeping the ordering
2194 * honest. The stars are the other half of that — always exactly as bright as they are,
2195 * and whether you can see them depends on what the eye is set to: full at night, 2% at
2196 * temperate, which is the honest answer to why nobody looks out at lunchtime.
2197 */
e331ec2a 2026-09-28 Pavel Safronov2198 private skyFrame = new Matrix3()
2199
e18dad04 2026-09-30 Pavel Safronov2200 /**
2201 * The far cap's frost, pane by pane (frost.ts), as the table GLAZED_CAP_FRAG reads.
2202 * Rewritten from the clock whenever it has moved: 282 panes, nothing to it.
2203 */
2204 private readonly frostTexture = (() => {
2205 const t = new DataTexture(new Uint8Array(FROST_SIZE * FROST_SIZE * 4), FROST_SIZE, FROST_SIZE, RGBAFormat)
2206 t.magFilter = NearestFilter
2207 t.minFilter = NearestFilter
2208 t.needsUpdate = true
2209 return t
2210 })()
2211 private readonly frost = new Float32Array(panes().length)
2212 private frostAtS = NaN
2213
2214 /**
2215 * How high the ice wall stands against the far cap, round the ring (CAP_ICE_GLSL, D-093):
2216 * read off the ground at the cap's foot once per generation of the world. 2048 samples is
2217 * one every 153 m of the circumference, filtered between.
2218 */
2219 private readonly capIceTexture = (() => {
2220 const t = new DataTexture(new Uint8Array(CAP_ICE_SAMPLES * 4), CAP_ICE_SAMPLES, 1, RGBAFormat)
2221 t.magFilter = LinearFilter
2222 t.minFilter = LinearFilter
2223 t.wrapS = RepeatWrapping
2224 t.needsUpdate = true
2225 return t
2226 })()
2227 private capIceGeneration = -1
2228
2229 private updateCapIce(): void {
2230 const t = terrain()
2231 if (!t.compiled || t.generation === this.capIceGeneration) return
2232 this.capIceGeneration = t.generation
2233 const data = this.capIceTexture.image.data as Uint8Array
2234 for (let i = 0; i < CAP_ICE_SAMPLES; i++) {
2235 // Just short of the end: the ground there is the wall's top against the cap.
2236 const g = t.groundAt(((i + 0.5) / CAP_ICE_SAMPLES) * 2 * Math.PI, LENGTH_M - 20, 20)
2237 // Dry ground at the foot of the cap is the range: its ice, or a ridge of it standing bare.
2238 const top = g && !g.water ? Math.max(0, g.h) : 0
2239 data[i * 4] = Math.round(Math.min(1, top / CAP_ICE_MAX_M) * 255)
2240 }
2241 this.capIceTexture.needsUpdate = true
2242 }
2243
2244 private updateFrost(hours: number): void {
2245 const tS = hours * 3600
2246 const seed = terrain().world?.seed ?? 0
2247 if (tS === this.frostAtS) return
2248 this.frostAtS = tS
2249 frostAt(seed, tS, this.frost)
2250 const data = this.frostTexture.image.data as Uint8Array
2251 panes().forEach((p, i) => {
2252 const o = ((p.b + FROST_OFFSET) * FROST_SIZE + (p.a + FROST_OFFSET)) * 4
2253 data[o + p.up] = Math.round(this.frost[i]! * 255)
2254 })
2255 this.frostTexture.needsUpdate = true
2256 }
2257
2258 setSky(sky: Sky, eyeZ?: number): { exposure: number; haze: Color } {
2259 this.updateFrost(sky.hours)
2260 this.updateCapIce()
2261 /**
2262 * The far end's dim (D-091): the sea Tower lets freeze, if there is one. The shaders
2263 * dim what is lit there (SUN_GLSL \`lightShare\`); the stars go by the light where the
2264 * eye is, so someone on the fast ice at dusk sees the brightest of them.
2265 */
2266 const iced = terrain().compiled?.basins.find((b) => b.ice) ?? null
2267 const mix = lightMix(sky)
2268 const total = mix.sun + mix.rod + mix.moon
2269 const local = iced && eyeZ !== undefined ? lightShareAt(iced.ice!, iced.openM, eyeZ, mix) : 1
59c12827 2026-09-28 Pavel Safronov2270 const exposure = (sky.watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
abc0aad0 2026-10-01 Pavel Safronov2271 // The farms (D-095): where in its day each crop is, and how bright Tower's noon is now.
2272 const sinceSunrise = (((sky.hourOfDay - sky.times.sunriseH) % 24) + 24) % 24
2273 const dayLen = sky.times.sunsetH - sky.times.sunriseH
2274 const farmBoost = (DIALS[sky.dial] / Math.max(sky.watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.35
e18dad04 2026-09-30 Pavel Safronov2275 const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts * local, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
59c12827 2026-09-28 Pavel Safronov2276 const haze = new Color(sky.watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
2277 const zKm = (sky.sunZ ?? 0) / KM
2278 // The banked rod lights the night; with the beam shut down there is not even that.
2279 const rod = sky.dial === 'dark' ? 0 : 1 - sky.power
42b4554e 2026-09-29 Pavel Safronov2280 const moonKm = (sky.moonZ ?? 0) / KM
2281 const phase = sky.moonPhase.fraction * (sky.moonPhase.waxing ? 1 : -1)
e331ec2a 2026-09-28 Pavel Safronov2282 const frame = skyMatrix(sky.at, this.skyFrame)
59c12827 2026-09-28 Pavel Safronov2283 for (const m of this.materials) {
e331ec2a 2026-09-28 Pavel Safronov2284 ;(m.uniforms['uSkyFrame']!.value as Matrix3).copy(frame)
59c12827 2026-09-28 Pavel Safronov2285 m.uniforms['uExposure']!.value = exposure
2286 m.uniforms['uStarGain']!.value = starGain
2287 ;(m.uniforms['uHaze']!.value as Color).copy(haze)
2288 ;(m.uniforms['uSun']!.value as Vector3).set(zKm, sky.power, rod)
42b4554e 2026-09-29 Pavel Safronov2289 ;(m.uniforms['uMoon']!.value as Vector4).set(moonKm, sky.moonLight, sky.moonRamp, phase)
e18dad04 2026-09-30 Pavel Safronov2290 const dim = m.uniforms['uDim']!.value as Vector4
2291 if (iced) dim.set(iced.openM / KM, iced.ice!.edgeM / KM, iced.ice!.fastM / KM, Math.sign(iced.ice!.fastM - iced.ice!.edgeM))
2292 else dim.set(0, 0, 0, 0)
abc0aad0 2026-10-01 Pavel Safronov2293 ;(m.uniforms['uFarm']!.value as Vector4).set(sinceSunrise, dayLen, farmBoost, sky.dial === 'dark' ? 0 : 1)
e18dad04 2026-09-30 Pavel Safronov2294 const lm = m.uniforms['uLightMix']!.value as Vector3
2295 if (total > 0) lm.set(mix.sun / total, mix.rod / total, mix.moon / total)
2296 else lm.set(0, 1, 0)
59c12827 2026-09-28 Pavel Safronov2297 }
2298 this.sun.position.set(0, 0, zKm)
2299 this.sun.visible = sky.power > 0
42b4554e 2026-09-29 Pavel Safronov2300 this.moon.position.set(0, 0, moonKm)
2301 this.moon.visible = sky.moonRamp > 0 && sky.moonPhase.fraction > 0.005
59c12827 2026-09-28 Pavel Safronov2302 return { exposure, haze }
2303 }
6bb061a6 2026-09-14 Pavel Safronov2304}