tower / files / src / views / interior-model.ts
interior-model.ts
6bb061a6 2026-09-14 Pavel Safronov | 1 | /** |
| 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 Safronov | 15 | import type { Object3D, Texture } from 'three' |
6bb061a6 2026-09-14 Pavel Safronov | 16 | import { |
e18dad04 2026-09-30 Pavel Safronov | 17 | 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 Safronov | 21 | Vector3, Vector4, |
6bb061a6 2026-09-14 Pavel Safronov | 22 | } from 'three' |
| 23 | import { | |
59c12827 2026-09-28 Pavel Safronov | 24 | 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 Safronov | 26 | HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, |
42b4554e 2026-09-29 Pavel Safronov | 27 | MOON_RADIUS_M, SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K, |
6bb061a6 2026-09-14 Pavel Safronov | 28 | } from '../canon/constants.js' |
abc0aad0 2026-10-01 Pavel Safronov | 29 | import { DIALS, type Sky, lightMix } from '../world/sky.js' |
e18dad04 2026-09-30 Pavel Safronov | 30 | import { PANE_KM, frostAt, panes } from '../world/frost.js' |
| 31 | import { ICE, PALETTE_GLSL } from '../terrain/palette.js' | |
abc0aad0 2026-10-01 Pavel Safronov | 32 | import { FARM_GLSL } from '../terrain/farms.js' |
14d35b7d 2026-09-16 Pavel Safronov | 33 | import { LOWEST_GROUND_M } from '../terrain/tile.js' |
e18dad04 2026-09-30 Pavel Safronov | 34 | import { AIMED_AT_EDGE, AIMED_AT_FAST, ROD_GLOW_BY_DAY, lightShare as lightShareAt } from '../terrain/ice.js' |
80bcc97a 2026-09-17 Pavel Safronov | 35 | import { fallSheet } from '../terrain/falls.js' |
00ceeedb 2026-09-27 Pavel Safronov | 36 | import { ForestView } from './forest-view.js' |
a088e5d5 2026-09-17 Pavel Safronov | 37 | import { BODY_STRIDE, MAX_BODIES, type SeaState, seaStateTable } from '../terrain/sea-state.js' |
| 38 | import { WAVE_AMP, WAVE_LEN, WAVE_PHASE, WAVE_TURN, boomAngle, shipsAt, waveAt } from '../terrain/ships.js' | |
376f2ce6 2026-09-16 Pavel Safronov | 39 | import { terrain } from '../terrain/service.js' |
99a2821b 2026-09-29 Pavel Safronov | 40 | import type { RailLine } from '../terrain/rails.js' |
6bb061a6 2026-09-14 Pavel Safronov | 41 | import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js' |
376f2ce6 2026-09-16 Pavel Safronov | 42 | import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js' |
e331ec2a 2026-09-28 Pavel Safronov | 43 | import { skyMatrix, starTexture } from './stars.js' |
| 44 | import { STAR_FACE_CELLS, STAR_FACE_SIZE } from '../world/celestial.js' | |
6bb061a6 2026-09-14 Pavel Safronov | 45 | |
| 46 | const KM = 1000 | |
| 47 | const R = RADIUS_M / KM | |
59c12827 2026-09-28 Pavel Safronov | 48 | /** The sun's radius, and how far out its glow is drawn, km. */ |
| 49 | const SUN_R = SUN_RADIUS_M / KM | |
| 50 | const SUN_GLOW_R = 24 | |
42b4554e 2026-09-29 Pavel Safronov | 51 | /** The moon's radius, and how far out its glow is drawn, km (D-088). */ |
| 52 | const MOON_R = MOON_RADIUS_M / KM | |
| 53 | const MOON_GLOW_R = 12 | |
6bb061a6 2026-09-14 Pavel Safronov | 54 | const L = LENGTH_M / KM |
| 55 | const BEAM_R = BEAM_RADIUS_M / KM | |
| 56 | /** Wall depth in km. The far cap's window is set this far into its own frame. */ | |
| 57 | const HULL_D = HULL_DEPTH_M / KM | |
| 58 | /** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ | |
| 59 | const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 | |
| 60 | const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM | |
| 61 | /** Width of the sunlit edge structure, in km (D-054). */ | |
| 62 | const 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 | */ | |
| 72 | export const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM | |
| 73 | const 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. */ | |
| 77 | const 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 | */ | |
| 88 | export const HAZE_DAY = 0x9aa3ad | |
| 89 | export 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 | */ | |
| 111 | const HAZE_GLSL = /* glsl */` | |
| 112 | uniform vec3 uCam; | |
| 113 | uniform vec3 uK; | |
| 114 | uniform vec3 uHaze; | |
| 115 | uniform float uExposure; | |
| 116 | const float A = ${DENSITY_A.toExponential(8)}; | |
| 117 | const float RR = ${(R * R).toFixed(4)}; | |
14d35b7d 2026-09-16 Pavel Safronov | 118 | // The air reaches down past the deepest ground anything generates (tile.ts). |
376f2ce6 2026-09-16 Pavel Safronov | 119 | const float RR_AIR = ${((R - LOWEST_GROUND_M / KM + 0.05) ** 2).toFixed(4)}; |
6bb061a6 2026-09-14 Pavel Safronov | 120 | const float Z_AIR0 = 0.0; |
| 121 | const float Z_AIR1 = ${(L + HULL_D).toFixed(4)}; | |
| 122 | ||
| 123 | vec3 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 | */ | |
| 169 | uniform float uSeeThrough; | |
| 170 | vec4 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 Safronov | 181 | /** |
| 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 Safronov | 187 | * 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 Safronov | 189 | * |
| 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 | */ | |
| 193 | const SUN_GLSL = /* glsl */` | |
| 194 | uniform vec3 uSun; | |
42b4554e 2026-09-29 Pavel Safronov | 195 | /** 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). */ | |
| 197 | uniform vec4 uMoon; | |
f16f084e 2026-09-28 Pavel Safronov | 198 | /** |
| 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 | */ | |
| 202 | const float BEAM_PAST_SUN = 0.7; | |
59c12827 2026-09-28 Pavel Safronov | 203 | vec3 sunPos() { return vec3(0.0, 0.0, uSun.x); } |
| 204 | vec3 sunDir(vec3 p) { return normalize(sunPos() - p); } | |
42b4554e 2026-09-29 Pavel Safronov | 205 | vec3 moonPos() { return vec3(0.0, 0.0, uMoon.x); } |
| 206 | vec3 moonDir(vec3 p) { return normalize(moonPos() - p); } | |
59c12827 2026-09-28 Pavel Safronov | 207 | vec3 upAt(vec3 p) { return -normalize(vec3(p.xy, 0.0)); } |
| 208 | float direct(vec3 p, vec3 n) { | |
42b4554e 2026-09-29 Pavel Safronov | 209 | 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 Safronov | 211 | } |
e18dad04 2026-09-30 Pavel Safronov | 212 | |
| 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 | */ | |
| 221 | uniform vec4 uDim; | |
| 222 | uniform vec3 uLightMix; | |
| 223 | float 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 | */ | |
| 237 | float 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 Safronov | 241 | ` |
| 242 | ||
376f2ce6 2026-09-16 Pavel Safronov | 243 | /** |
| 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 Safronov | 248 | const GROUND_VERT = /* glsl */` |
| 249 | #include <common> | |
| 250 | #include <logdepthbuf_pars_vertex> | |
376f2ce6 2026-09-16 Pavel Safronov | 251 | uniform mat4 uFrame; |
| 252 | attribute vec4 aMatA; | |
| 253 | attribute vec4 aMatB; | |
abc0aad0 2026-10-01 Pavel Safronov | 254 | attribute vec3 aExtra; |
6bb061a6 2026-09-14 Pavel Safronov | 255 | varying vec3 vWorld; |
| 256 | varying vec3 vNormal2; | |
376f2ce6 2026-09-16 Pavel Safronov | 257 | varying vec4 vMatA; |
| 258 | varying vec4 vMatB; | |
abc0aad0 2026-10-01 Pavel Safronov | 259 | varying vec3 vExtra; |
6bb061a6 2026-09-14 Pavel Safronov | 260 | |
| 261 | void main() { | |
376f2ce6 2026-09-16 Pavel Safronov | 262 | vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; |
| 263 | vNormal2 = normal; | |
| 264 | vMatA = aMatA; | |
| 265 | vMatB = aMatB; | |
51c39d8b 2026-09-17 Pavel Safronov | 266 | vExtra = aExtra; |
376f2ce6 2026-09-16 Pavel Safronov | 267 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); |
6bb061a6 2026-09-14 Pavel Safronov | 268 | #include <logdepthbuf_vertex> |
| 269 | } | |
| 270 | ` | |
| 271 | ||
| 272 | const GROUND_FRAG = /* glsl */` | |
| 273 | #include <common> | |
| 274 | #include <logdepthbuf_pars_fragment> | |
| 275 | varying vec3 vWorld; | |
| 276 | varying vec3 vNormal2; | |
376f2ce6 2026-09-16 Pavel Safronov | 277 | varying vec4 vMatA; |
| 278 | varying vec4 vMatB; | |
abc0aad0 2026-10-01 Pavel Safronov | 279 | varying vec3 vExtra; |
6bb061a6 2026-09-14 Pavel Safronov | 280 | ${HAZE_GLSL} |
59c12827 2026-09-28 Pavel Safronov | 281 | ${SUN_GLSL} |
376f2ce6 2026-09-16 Pavel Safronov | 282 | ${PALETTE_GLSL} |
abc0aad0 2026-10-01 Pavel Safronov | 283 | ${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 | */ | |
| 289 | uniform vec4 uFarm; | |
6bb061a6 2026-09-14 Pavel Safronov | 290 | |
376f2ce6 2026-09-16 Pavel Safronov | 291 | /** Cheap value noise, to keep the ground from being flat colour. */ |
6bb061a6 2026-09-14 Pavel Safronov | 292 | float hash(vec2 p) { |
| 293 | return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); | |
| 294 | } | |
| 295 | float 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 | ||
| 302 | void main() { | |
| 303 | #include <logdepthbuf_fragment> | |
376f2ce6 2026-09-16 Pavel Safronov | 304 | // 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 Safronov | 308 | vec3 base = pow(groundColour(vMatA, vMatB, vExtra.xy, elevM), vec3(2.2)); |
51c39d8b 2026-09-17 Pavel Safronov | 309 | /** |
| 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 Safronov | 319 | // 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 Safronov | 321 | |
| 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 Safronov | 336 | |
| 337 | /** | |
376f2ce6 2026-09-16 Pavel Safronov | 338 | * 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 Safronov | 341 | */ |
| 342 | float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld)); | |
376f2ce6 2026-09-16 Pavel Safronov | 343 | // 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 Safronov | 345 | 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 Safronov | 347 | base *= 1.0 + (grain - 0.5) * 0.12 * near * land; |
6bb061a6 2026-09-14 Pavel Safronov | 348 | |
abc0aad0 2026-10-01 Pavel Safronov | 349 | /** |
| 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 Safronov | 389 | // 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 Safronov | 391 | vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure * lightShare(vWorld); |
abc0aad0 2026-10-01 Pavel Safronov | 392 | /** |
| 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 Safronov | 403 | |
| 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 Safronov | 410 | /** |
| 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 | */ | |
| 423 | const WAVES_GLSL = /* glsl */` | |
| 424 | uniform float uTime; | |
| 425 | const float G = 9.80665; | |
a088e5d5 2026-09-17 Pavel Safronov | 426 | // Generated from ships.ts, which runs the same sum so a ship rides the waves drawn under it. |
| 427 | const float WAVE_LEN[${WAVE_LEN.length}] = float[](${WAVE_LEN.map((v) => v.toFixed(3)).join(', ')}); | |
| 428 | const float WAVE_AMP[${WAVE_LEN.length}] = float[](${WAVE_AMP.map((v) => v.toFixed(3)).join(', ')}); | |
| 429 | const float WAVE_TURN[${WAVE_LEN.length}] = float[](${WAVE_TURN.map((v) => v.toFixed(3)).join(', ')}); | |
| 430 | const 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 | */ | |
| 442 | uniform vec4 uSurfRef; // cos and sin of the reference angle, its z in metres, unused | |
| 443 | vec2 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 | } | |
| 447 | uniform vec4 uBodies[${MAX_BODIES}]; | |
| 448 | uniform float uWaveOffset[${MAX_BODIES * WAVE_LEN.length}]; | |
| 449 | float waveOffset(int body, int i) { | |
| 450 | return body < 0 ? 0.0 : uWaveOffset[body * ${WAVE_LEN.length} + i]; | |
| 451 | } | |
80bcc97a 2026-09-17 Pavel Safronov | 452 | |
| 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 Safronov | 455 | vec3 waves(vec2 p, float amp, float len, float dir, int body, float footprint) { |
80bcc97a 2026-09-17 Pavel Safronov | 456 | vec3 out3 = vec3(0.0); |
| 457 | if (amp <= 0.0 || len <= 0.0) return out3; | |
a088e5d5 2026-09-17 Pavel Safronov | 458 | for (int i = 0; i < ${WAVE_LEN.length}; i++) { |
80bcc97a 2026-09-17 Pavel Safronov | 459 | float L = len * WAVE_LEN[i]; |
a088e5d5 2026-09-17 Pavel Safronov | 460 | float fade = smoothstep(4.0, 16.0, L / max(footprint, 1e-3)); |
80bcc97a 2026-09-17 Pavel Safronov | 461 | 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 Safronov | 469 | float ph = k * dot(d, p) - w * mod(uTime, 600.0) + WAVE_PHASE[i] + waveOffset(body, i); |
80bcc97a 2026-09-17 Pavel Safronov | 470 | out3.x += a * cos(ph); |
| 471 | out3.yz -= a * k * sin(ph) * d; | |
| 472 | } | |
| 473 | return out3; | |
| 474 | } | |
| 475 | ||
a088e5d5 2026-09-17 Pavel Safronov | 476 | /** |
| 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 | */ | |
| 487 | float 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 | ||
| 501 | int bodyOf(vec4 water) { return water.y >= 0.0 ? -1 : int(-water.y - 0.5); } | |
| 502 | vec4 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 Safronov | 505 | } |
| 506 | ` | |
| 507 | ||
e18dad04 2026-09-30 Pavel Safronov | 508 | /** |
| 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). */ | |
| 523 | const FROST_SIZE = 32 | |
| 524 | const FROST_OFFSET = 16 | |
| 525 | ||
| 526 | const FLOE_M = 110 | |
| 527 | const ICE_LINEAR = `vec3(${ICE.map((c) => ((c / 255) ** 2.2).toFixed(4)).join(', ')})` | |
| 528 | const FLOES_AROUND = Math.round((2 * Math.PI * R * 1000) / FLOE_M) | |
| 529 | const FAST_CRACK_M = 660 | |
| 530 | const FAST_AROUND = Math.round((2 * Math.PI * R * 1000) / FAST_CRACK_M) | |
| 531 | const ICE_GLSL = /* glsl */` | |
| 532 | float icePermute(float x) { return mod((34.0 * x + 1.0) * x, 289.0); } | |
| 533 | float 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. */ | |
| 538 | float 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 | */ | |
| 545 | vec4 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 | */ | |
| 571 | vec3 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 Safronov | 614 | const WATER_VERT = /* glsl */` |
| 615 | #include <common> | |
| 616 | #include <logdepthbuf_pars_vertex> | |
| 617 | uniform mat4 uFrame; | |
| 618 | uniform vec3 uCam; | |
| 619 | attribute vec4 aWater; | |
| 620 | attribute vec4 aFlow; | |
| 621 | varying vec3 vWorld; | |
| 622 | varying vec4 vWater; | |
| 623 | varying vec4 vFlow; | |
| 624 | ${WAVES_GLSL} | |
e18dad04 2026-09-30 Pavel Safronov | 625 | ${ICE_GLSL} |
80bcc97a 2026-09-17 Pavel Safronov | 626 | void 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 Safronov | 631 | 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 Safronov | 634 | h *= iceCalm(aFlow.w); |
80bcc97a 2026-09-17 Pavel Safronov | 635 | // 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 | ||
| 649 | const WATER_FRAG = /* glsl */` | |
| 650 | #include <common> | |
| 651 | #include <logdepthbuf_pars_fragment> | |
| 652 | varying vec3 vWorld; | |
| 653 | varying vec4 vWater; | |
| 654 | varying vec4 vFlow; | |
| 655 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 656 | ${SUN_GLSL} |
80bcc97a 2026-09-17 Pavel Safronov | 657 | ${WAVES_GLSL} |
e18dad04 2026-09-30 Pavel Safronov | 658 | ${ICE_GLSL} |
80bcc97a 2026-09-17 Pavel Safronov | 659 | |
a088e5d5 2026-09-17 Pavel Safronov | 660 | // Periodic every 256 cells, which is what lets surfaceMetres' reference step without a seam. |
| 661 | float 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 Safronov | 662 | float 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 | ||
| 668 | void 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 Safronov | 678 | vec4 wp = waveParams(vWater); |
| 679 | vec3 w = waves(sp, wp.x, wp.y, wp.z, bodyOf(vWater), footprint); | |
e18dad04 2026-09-30 Pavel Safronov | 680 | vec2 grad = w.yz * iceCalm(vFlow.w); |
80bcc97a 2026-09-17 Pavel Safronov | 681 | |
| 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 Safronov | 712 | float light = uExposure * (0.4 + 0.6 * direct(vWorld, up)) * lightShare(vWorld); |
80bcc97a 2026-09-17 Pavel Safronov | 713 | |
| 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 Safronov | 722 | // 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 Safronov | 725 | 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 Safronov | 727 | col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light * uSun.z; |
e18dad04 2026-09-30 Pavel Safronov | 728 | 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 Safronov | 729 | // And the moon, on a moonlit night: a path of light across the sea toward it. |
e18dad04 2026-09-30 Pavel Safronov | 730 | 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 Safronov | 731 | |
| 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 Safronov | 735 | 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 Safronov | 751 | /** |
| 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 Safronov | 759 | 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 Safronov | 763 | 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 Safronov | 765 | /** |
| 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 Safronov | 781 | // 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 Safronov | 785 | alpha = max(alpha, ice); |
80bcc97a 2026-09-17 Pavel Safronov | 786 | |
| 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 Safronov | 793 | /** |
| 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 | */ | |
| 804 | const CANOPY_NEAR_M = 620 | |
| 805 | const CANOPY_FAR_M = 950 | |
| 806 | ||
| 807 | const CANOPY_VERT = /* glsl */` | |
| 808 | #include <common> | |
| 809 | #include <logdepthbuf_pars_vertex> | |
| 810 | uniform mat4 uFrame; | |
| 811 | uniform vec3 uCam; | |
| 812 | attribute vec3 aCanopy; | |
| 813 | varying vec3 vWorld; | |
| 814 | varying vec3 vCanopy; | |
| 815 | varying float vLift; | |
| 816 | void 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 | ||
| 829 | const CANOPY_FRAG = /* glsl */` | |
| 830 | #include <common> | |
| 831 | #include <logdepthbuf_pars_fragment> | |
| 832 | varying vec3 vWorld; | |
| 833 | varying vec3 vCanopy; | |
| 834 | varying float vLift; | |
| 835 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 836 | ${SUN_GLSL} |
00ceeedb 2026-09-27 Pavel Safronov | 837 | float canopyHash(vec2 p) { |
| 838 | return fract(sin(dot(mod(p, 512.0), vec2(37.1, 91.7))) * 24634.6345); | |
| 839 | } | |
| 840 | ||
| 841 | void 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 Safronov | 849 | float lambert = direct(vWorld, n); |
00ceeedb 2026-09-27 Pavel Safronov | 850 | |
| 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 Safronov | 877 | vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure * lightShare(vWorld); |
00ceeedb 2026-09-27 Pavel Safronov | 878 | |
| 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 | */ | |
| 890 | const FOLIAGE_GLSL = /* glsl */` | |
| 891 | vec3 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 | ||
| 902 | const TREE_VERT = /* glsl */` | |
| 903 | #include <common> | |
| 904 | #include <logdepthbuf_pars_vertex> | |
| 905 | uniform mat4 uFrame; | |
| 906 | attribute float aPart; | |
| 907 | varying vec3 vWorld; | |
| 908 | varying vec3 vN; | |
| 909 | varying float vPart; | |
| 910 | varying float vTint; | |
| 911 | void 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 | ||
| 923 | const TREE_FRAG = /* glsl */` | |
| 924 | #include <common> | |
| 925 | #include <logdepthbuf_pars_fragment> | |
| 926 | varying vec3 vWorld; | |
| 927 | varying vec3 vN; | |
| 928 | varying float vPart; | |
| 929 | varying float vTint; | |
| 930 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 931 | ${SUN_GLSL} |
00ceeedb 2026-09-27 Pavel Safronov | 932 | ${FOLIAGE_GLSL} |
| 933 | void main() { | |
| 934 | #include <logdepthbuf_fragment> | |
| 935 | vec3 n = normalize(vN); | |
59c12827 2026-09-28 Pavel Safronov | 936 | // 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 Safronov | 938 | // 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 Safronov | 940 | vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure * lightShare(vWorld); |
00ceeedb 2026-09-27 Pavel Safronov | 941 | 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 | */ | |
| 953 | const CARD_VERT = /* glsl */` | |
| 954 | #include <common> | |
| 955 | #include <logdepthbuf_pars_vertex> | |
| 956 | uniform mat4 uFrame; | |
| 957 | uniform vec3 uCam; | |
| 958 | attribute vec2 aCard; | |
| 959 | varying vec3 vWorld; | |
| 960 | varying vec2 vUv; | |
| 961 | varying vec2 vCard; | |
| 962 | void 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 | ||
| 981 | const CARD_FRAG = /* glsl */` | |
| 982 | #include <common> | |
| 983 | #include <logdepthbuf_pars_fragment> | |
| 984 | varying vec3 vWorld; | |
| 985 | varying vec2 vUv; | |
| 986 | varying vec2 vCard; | |
| 987 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 988 | ${SUN_GLSL} |
00ceeedb 2026-09-27 Pavel Safronov | 989 | ${FOLIAGE_GLSL} |
| 990 | void 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 Safronov | 1026 | // 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 Safronov | 1028 | vec3 lit = foliageColour(part, vCard.y) * shade * sun * uExposure * lightShare(vWorld); |
00ceeedb 2026-09-27 Pavel Safronov | 1029 | 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 Safronov | 1035 | /** |
| 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 | */ | |
| 1040 | const SHIP_VERT = /* glsl */` | |
| 1041 | #include <common> | |
| 1042 | #include <logdepthbuf_pars_vertex> | |
| 1043 | uniform mat4 uFrame; | |
| 1044 | varying vec3 vWorld; | |
| 1045 | varying vec3 vN; | |
| 1046 | void 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 | ||
| 1055 | const SHIP_FRAG = /* glsl */` | |
| 1056 | #include <common> | |
| 1057 | #include <logdepthbuf_pars_fragment> | |
| 1058 | uniform vec3 uColour; | |
| 1059 | varying vec3 vWorld; | |
| 1060 | varying vec3 vN; | |
| 1061 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 1062 | ${SUN_GLSL} |
a088e5d5 2026-09-17 Pavel Safronov | 1063 | void main() { |
| 1064 | #include <logdepthbuf_fragment> | |
59c12827 2026-09-28 Pavel Safronov | 1065 | vec3 n = normalize(vN); |
| 1066 | // Both faces of a sail are lit, whichever way it has swung. | |
42b4554e 2026-09-29 Pavel Safronov | 1067 | 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 Safronov | 1069 | // Three.Color already converts its hex to linear. |
e18dad04 2026-09-30 Pavel Safronov | 1070 | vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure * lightShare(vWorld); |
a088e5d5 2026-09-17 Pavel Safronov | 1071 | 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 Safronov | 1077 | /** |
| 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 | */ | |
| 1081 | const PLAIN_VERT = /* glsl */` | |
| 1082 | #include <common> | |
| 1083 | #include <logdepthbuf_pars_vertex> | |
| 1084 | uniform mat4 uFrame; | |
| 1085 | varying vec3 vWorld; | |
| 1086 | varying vec3 vN; | |
| 1087 | void 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. */ | |
| 1096 | const PIER_EVERY_M = 100 | |
| 1097 | /** Samples of a railway per mesh: meshes with their own origins keep float32 precise. */ | |
| 1098 | const RAIL_CHUNK = 100 | |
| 1099 | ||
a088e5d5 2026-09-17 Pavel Safronov | 1100 | /** |
| 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 | */ | |
| 1105 | function 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. */ | |
| 1149 | function 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 | */ | |
| 1160 | function 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 | */ | |
| 1176 | const SURF_SNAP_M = 2457.6 | |
| 1177 | ||
| 1178 | /** Most ships drawn at once: two seas of two hundred each. */ | |
| 1179 | const MAX_SHIPS = 800 | |
| 1180 | /** Where the mast stands on a unit hull. */ | |
| 1181 | const MAST_X = 0.12 | |
| 1182 | ||
80bcc97a 2026-09-17 Pavel Safronov | 1183 | /** |
| 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 | */ | |
| 1187 | const FALL_VERT = /* glsl */` | |
| 1188 | #include <common> | |
| 1189 | #include <logdepthbuf_pars_vertex> | |
| 1190 | uniform mat4 uFrame; | |
| 1191 | varying vec3 vWorld; | |
| 1192 | varying vec2 vUv; | |
| 1193 | void 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 | ||
| 1201 | const FALL_FRAG = /* glsl */` | |
| 1202 | #include <common> | |
| 1203 | #include <logdepthbuf_pars_fragment> | |
| 1204 | uniform float uTime; | |
| 1205 | uniform float uHeightM; | |
| 1206 | varying vec3 vWorld; | |
| 1207 | varying vec2 vUv; | |
| 1208 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 1209 | ${SUN_GLSL} |
80bcc97a 2026-09-17 Pavel Safronov | 1210 | float fHash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); } |
| 1211 | float 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 | } | |
| 1216 | void 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 Safronov | 1225 | vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak) |
e18dad04 2026-09-30 Pavel Safronov | 1226 | * (0.45 + 0.6 * direct(vWorld, upAt(vWorld))) * lightShare(vWorld); |
80bcc97a 2026-09-17 Pavel Safronov | 1227 | 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 Safronov | 1233 | const FLAT_VERT = /* glsl */` |
| 1234 | #include <common> | |
| 1235 | #include <logdepthbuf_pars_vertex> | |
| 1236 | uniform mat4 uFrame; | |
| 1237 | varying vec3 vWorld; | |
| 1238 | void 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 Safronov | 1248 | * **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 Safronov | 1254 | * |
e331ec2a 2026-09-28 Pavel Safronov | 1255 | * 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 Safronov | 1259 | * |
e331ec2a 2026-09-28 Pavel Safronov | 1260 | * **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 Safronov | 1275 | */ |
| 1276 | export const STAR_GLSL = /* glsl */` | |
| 1277 | uniform float uSpin; | |
| 1278 | uniform float uStarGain; | |
e331ec2a 2026-09-28 Pavel Safronov | 1279 | uniform mat3 uSkyFrame; |
| 1280 | uniform highp sampler2D uStars; | |
6bb061a6 2026-09-14 Pavel Safronov | 1281 | |
e331ec2a 2026-09-28 Pavel Safronov | 1282 | const float STAR_CELLS = ${STAR_FACE_CELLS.toFixed(1)}; |
| 1283 | const int STAR_SIZE = ${STAR_FACE_SIZE}; | |
6bb061a6 2026-09-14 Pavel Safronov | 1284 | |
e331ec2a 2026-09-28 Pavel Safronov | 1285 | /** |
| 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 | */ | |
| 1290 | vec3 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 Safronov | 1297 | |
| 1298 | vec3 starField(vec3 dir) { | |
| 1299 | vec3 d0 = normalize(dir); | |
| 1300 | float cs = cos(uSpin); | |
| 1301 | float sn = sin(uSpin); | |
e331ec2a 2026-09-28 Pavel Safronov | 1302 | 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 Safronov | 1321 | vec3 sum = vec3(0.0); |
e331ec2a 2026-09-28 Pavel Safronov | 1322 | 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 Safronov | 1331 | } |
| 1332 | } | |
| 1333 | /** | |
e331ec2a 2026-09-28 Pavel Safronov | 1334 | * 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 Safronov | 1338 | */ |
e331ec2a 2026-09-28 Pavel Safronov | 1339 | float th = acos(clamp(d0.z, -1.0, 1.0)); |
6bb061a6 2026-09-14 Pavel Safronov | 1340 | float ps = sg * 1.6; |
e331ec2a 2026-09-28 Pavel Safronov | 1341 | 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 Safronov | 1343 | } |
| 1344 | ` | |
| 1345 | ||
e18dad04 2026-09-30 Pavel Safronov | 1346 | const FROST_NOISE_GLSL = /* glsl */` |
| 1347 | float frostHash(vec2 p) { return fract(sin(dot(mod(p, 289.0), vec2(127.1, 311.7))) * 43758.5453); } | |
| 1348 | float 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 | */ | |
| 1361 | const CAP_ICE_MAX_M = 4080 | |
| 1362 | const CAP_ICE_SAMPLES = 2048 | |
| 1363 | const CAP_ICE_GLSL = /* glsl */` | |
| 1364 | uniform sampler2D uCapIce; | |
| 1365 | float 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 Safronov | 1376 | const REVEAL_FRAG = /* glsl */` |
| 1377 | #include <common> | |
| 1378 | #include <logdepthbuf_pars_fragment> | |
| 1379 | uniform vec3 uTint; | |
| 1380 | varying vec3 vWorld; | |
| 1381 | ${HAZE_GLSL} | |
e18dad04 2026-09-30 Pavel Safronov | 1382 | ${SUN_GLSL} |
| 1383 | ${CAP_ICE_GLSL} | |
6bb061a6 2026-09-14 Pavel Safronov | 1384 | void main() { |
| 1385 | #include <logdepthbuf_fragment> | |
| 1386 | vec3 T = towerTransmittance(uCam, vWorld); | |
e18dad04 2026-09-30 Pavel Safronov | 1387 | 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 Safronov | 1390 | #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 | */ | |
| 1412 | const SUN_CAP_FRAG = /* glsl */` | |
| 1413 | #include <common> | |
| 1414 | #include <logdepthbuf_pars_fragment> | |
| 1415 | uniform vec3 uTint; | |
| 1416 | uniform sampler2D uGlazing; | |
| 1417 | varying vec3 vWorld; | |
| 1418 | ${HAZE_GLSL} | |
| 1419 | ${STAR_GLSL} | |
| 1420 | void 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 | */ | |
| 1470 | const GLAZED_CAP_FRAG = /* glsl */` | |
| 1471 | #include <common> | |
| 1472 | #include <logdepthbuf_pars_fragment> | |
| 1473 | uniform vec3 uTint; | |
| 1474 | uniform sampler2D uGlazing; | |
e18dad04 2026-09-30 Pavel Safronov | 1475 | uniform sampler2D uFrost; |
6bb061a6 2026-09-14 Pavel Safronov | 1476 | varying vec3 vWorld; |
| 1477 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 1478 | ${SUN_GLSL} |
6bb061a6 2026-09-14 Pavel Safronov | 1479 | ${STAR_GLSL} |
e18dad04 2026-09-30 Pavel Safronov | 1480 | |
| 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 | */ | |
| 1486 | float 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 Safronov | 1497 | void 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 Safronov | 1502 | /** |
| 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 Safronov | 1516 | /** |
| 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 Safronov | 1523 | 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 Safronov | 1546 | /** |
| 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 Safronov | 1560 | // 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 Safronov | 1563 | gl_FragColor = windowColour(face + glow, solid + glow, cover); |
6bb061a6 2026-09-14 Pavel Safronov | 1564 | #include <colorspace_fragment> |
| 1565 | } | |
| 1566 | ` | |
| 1567 | ||
| 1568 | const BEAM_VERT = /* glsl */` | |
| 1569 | #include <common> | |
| 1570 | #include <logdepthbuf_pars_vertex> | |
| 1571 | uniform mat4 uFrame; | |
| 1572 | varying vec3 vWorld; | |
| 1573 | varying vec3 vNormal2; | |
| 1574 | void 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 | */ | |
| 1587 | const BEAM_FRAG = /* glsl */` | |
| 1588 | #include <common> | |
| 1589 | #include <logdepthbuf_pars_fragment> | |
| 1590 | uniform vec3 uBeam; | |
| 1591 | varying vec3 vWorld; | |
| 1592 | varying vec3 vNormal2; | |
| 1593 | ${HAZE_GLSL} | |
59c12827 2026-09-28 Pavel Safronov | 1594 | ${SUN_GLSL} |
6bb061a6 2026-09-14 Pavel Safronov | 1595 | void 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 Safronov | 1601 | /** |
f16f084e 2026-09-28 Pavel Safronov | 1602 | * 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 Safronov | 1605 | */ |
f16f084e 2026-09-28 Pavel Safronov | 1606 | 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 Safronov | 1607 | vec3 T = towerTransmittance(uCam, vWorld); |
59c12827 2026-09-28 Pavel Safronov | 1608 | 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 | */ | |
| 1621 | const SUN_FRAG = /* glsl */` | |
| 1622 | #include <common> | |
| 1623 | #include <logdepthbuf_pars_fragment> | |
| 1624 | varying vec3 vWorld; | |
| 1625 | ${HAZE_GLSL} | |
| 1626 | ${SUN_GLSL} | |
| 1627 | void 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 Safronov | 1645 | #include <colorspace_fragment> |
| 1646 | } | |
| 1647 | ` | |
| 1648 | ||
42b4554e 2026-09-29 Pavel Safronov | 1649 | /** |
| 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 | */ | |
| 1657 | const MOON_FRAG = /* glsl */` | |
| 1658 | #include <common> | |
| 1659 | #include <logdepthbuf_pars_fragment> | |
| 1660 | varying vec3 vWorld; | |
| 1661 | ${HAZE_GLSL} | |
| 1662 | ${SUN_GLSL} | |
| 1663 | void 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 Safronov | 1690 | // ------------------------------------------------------------------------ model |
| 1691 | ||
| 1692 | export interface InteriorModelOptions { | |
376f2ce6 2026-09-16 Pavel Safronov | 1693 | /** How finely the ground is tiled. The exterior never needs more than the roots. */ |
| 1694 | lod: TerrainLODOptions | |
6bb061a6 2026-09-14 Pavel Safronov | 1695 | /** |
| 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 Safronov | 1702 | /** Where the see-through windows draw among the transparent things (see `buildCaps`). */ |
59c12827 2026-09-28 Pavel Safronov | 1703 | export const WINDOW_RENDER_ORDER = 4 |
28896dab 2026-09-27 Pavel Safronov | 1704 | |
6bb061a6 2026-09-14 Pavel Safronov | 1705 | export 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 Safronov | 1709 | readonly waterMat: ShaderMaterial |
00ceeedb 2026-09-27 Pavel Safronov | 1710 | 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 Safronov | 1713 | /** Tower's moon (D-088), moved along the axis by `setSky`, and out by day. */ |
| 1714 | readonly moon: Mesh | |
59c12827 2026-09-28 Pavel Safronov | 1715 | /** Tower's sun, moved along the axis by `setSky`. */ |
| 1716 | readonly sun: Mesh | |
80bcc97a 2026-09-17 Pavel Safronov | 1717 | /** Every waterfall in the world, rebuilt when the world is. */ |
| 1718 | readonly falls = new Group() | |
376f2ce6 2026-09-16 Pavel Safronov | 1719 | /** The generated ground: a quadtree of tiles, refined around the eye. */ |
| 1720 | readonly lod: TerrainLOD | |
| 1721 | readonly ground: Group | |
6bb061a6 2026-09-14 Pavel Safronov | 1722 | /** Everything to add to a scene, the ground included. */ |
376f2ce6 2026-09-16 Pavel Safronov | 1723 | readonly meshes: Object3D[] |
a088e5d5 2026-09-17 Pavel Safronov | 1724 | /** 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 Safronov | 1728 | /** 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 Safronov | 1730 | |
| 1731 | constructor(private opts: InteriorModelOptions) { | |
376f2ce6 2026-09-16 Pavel Safronov | 1732 | this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { side: FrontSide, uniforms: {} }) |
80bcc97a 2026-09-17 Pavel Safronov | 1733 | /** |
| 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 Safronov | 1738 | 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 Safronov | 1741 | this.waterMat.transparent = true |
| 1742 | this.waterMat.depthWrite = false | |
00ceeedb 2026-09-27 Pavel Safronov | 1743 | 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 Safronov | 1749 | this.ground = this.lod.group |
80bcc97a 2026-09-17 Pavel Safronov | 1750 | this.falls.name = 'falls' |
a088e5d5 2026-09-17 Pavel Safronov | 1751 | 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 Safronov | 1765 | this.sun = this.buildSun() |
42b4554e 2026-09-29 Pavel Safronov | 1766 | this.moon = this.buildMoon() |
99a2821b 2026-09-29 Pavel Safronov | 1767 | this.rails.name = 'rails' |
42b4554e 2026-09-29 Pavel Safronov | 1768 | 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 Safronov | 1769 | 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 Safronov | 1801 | for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime']) { |
80bcc97a 2026-09-17 Pavel Safronov | 1802 | 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 Safronov | 1806 | ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3) |
42b4554e 2026-09-29 Pavel Safronov | 1807 | ;(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 Safronov | 1809 | ;(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 Safronov | 1816 | } |
| 1817 | ||
a088e5d5 2026-09-17 Pavel Safronov | 1818 | /** 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 Safronov | 1854 | 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 Safronov | 1858 | /** 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 Safronov | 1917 | /** |
| 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 Safronov | 1921 | setCamera(eye: Vector3): void { |
| 1922 | for (const m of this.materials) (m.uniforms['uCam']!.value as Vector3).copy(eye) | |
a088e5d5 2026-09-17 Pavel Safronov | 1923 | // 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 Safronov | 1930 | // 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 Safronov | 1932 | this.lod.update(eye) |
99a2821b 2026-09-29 Pavel Safronov | 1933 | // 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 Safronov | 2036 | } |
| 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 Safronov | 2045 | params: { side: typeof BackSide | typeof DoubleSide | typeof FrontSide; uniforms: Record<string, { value: unknown }>; additive?: boolean }, |
6bb061a6 2026-09-14 Pavel Safronov | 2046 | ): 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 Safronov | 2069 | // Seconds, for anything that moves: the water. |
| 2070 | uTime: { value: 0 }, | |
6bb061a6 2026-09-14 Pavel Safronov | 2071 | uStarGain: { value: 0 }, |
e331ec2a 2026-09-28 Pavel Safronov | 2072 | // 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 Safronov | 2075 | // 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 Safronov | 2077 | // 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 Safronov | 2079 | // 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 Safronov | 2081 | // The farms' own days (GROUND_FRAG, D-095). |
| 2082 | uFarm: { value: new Vector4(0, 12, 1, 1) }, | |
e18dad04 2026-09-30 Pavel Safronov | 2083 | uLightMix: { value: new Vector3(0, 1, 0) }, |
6bb061a6 2026-09-14 Pavel Safronov | 2084 | } |
| 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 Safronov | 2094 | private windowMaterial(frag: string, tint: number, glazing: Texture, extra: Record<string, { value: unknown }> = {}): ShaderMaterial { |
6bb061a6 2026-09-14 Pavel Safronov | 2095 | const m = this.material(FLAT_VERT, frag, { |
| 2096 | side: DoubleSide, | |
e18dad04 2026-09-30 Pavel Safronov | 2097 | uniforms: { uTint: { value: new Color(tint) }, uGlazing: { value: glazing }, ...extra }, |
6bb061a6 2026-09-14 Pavel Safronov | 2098 | }) |
| 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 Safronov | 2121 | this.windowMaterial(GLAZED_CAP_FRAG, 0xbcc2cc, capGlazingTexture(), { uFrost: { value: this.frostTexture }, uCapIce: { value: this.capIceTexture } })) |
6bb061a6 2026-09-14 Pavel Safronov | 2122 | 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 Safronov | 2131 | side: BackSide, uniforms: { uTint: { value: new Color(0x9aa0aa) }, uCapIce: { value: this.capIceTexture } }, |
6bb061a6 2026-09-14 Pavel Safronov | 2132 | })) |
| 2133 | reveal.name = 'bore' | |
28896dab 2026-09-27 Pavel Safronov | 2134 | /** |
59c12827 2026-09-28 Pavel Safronov | 2135 | * 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 Safronov | 2142 | */ |
| 2143 | if (this.opts.seeThrough) sun.renderOrder = far.renderOrder = WINDOW_RENDER_ORDER | |
6bb061a6 2026-09-14 Pavel Safronov | 2144 | 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 Safronov | 2157 | /** |
| 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 Safronov | 2164 | return beam |
| 2165 | } | |
59c12827 2026-09-28 Pavel Safronov | 2166 | |
| 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 Safronov | 2177 | /** 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 Safronov | 2188 | /** |
| 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 Safronov | 2198 | private skyFrame = new Matrix3() |
| 2199 | ||
e18dad04 2026-09-30 Pavel Safronov | 2200 | /** |
| 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 Safronov | 2270 | const exposure = (sky.watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35 |
abc0aad0 2026-10-01 Pavel Safronov | 2271 | // 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 Safronov | 2275 | const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts * local, BRIGHTNESS_NIGHT_W_M2)) ** 0.6 |
59c12827 2026-09-28 Pavel Safronov | 2276 | 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 Safronov | 2280 | const moonKm = (sky.moonZ ?? 0) / KM |
| 2281 | const phase = sky.moonPhase.fraction * (sky.moonPhase.waxing ? 1 : -1) | |
e331ec2a 2026-09-28 Pavel Safronov | 2282 | const frame = skyMatrix(sky.at, this.skyFrame) |
59c12827 2026-09-28 Pavel Safronov | 2283 | for (const m of this.materials) { |
e331ec2a 2026-09-28 Pavel Safronov | 2284 | ;(m.uniforms['uSkyFrame']!.value as Matrix3).copy(frame) |
59c12827 2026-09-28 Pavel Safronov | 2285 | 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 Safronov | 2289 | ;(m.uniforms['uMoon']!.value as Vector4).set(moonKm, sky.moonLight, sky.moonRamp, phase) |
e18dad04 2026-09-30 Pavel Safronov | 2290 | 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 Safronov | 2293 | ;(m.uniforms['uFarm']!.value as Vector4).set(sinceSunrise, dayLen, farmBoost, sky.dial === 'dark' ? 0 : 1) |
e18dad04 2026-09-30 Pavel Safronov | 2294 | 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 Safronov | 2297 | } |
| 2298 | this.sun.position.set(0, 0, zKm) | |
| 2299 | this.sun.visible = sky.power > 0 | |
42b4554e 2026-09-29 Pavel Safronov | 2300 | this.moon.position.set(0, 0, moonKm) |
| 2301 | this.moon.visible = sky.moonRamp > 0 && sky.moonPhase.fraction > 0.005 | |
59c12827 2026-09-28 Pavel Safronov | 2302 | return { exposure, haze } |
| 2303 | } | |
6bb061a6 2026-09-14 Pavel Safronov | 2304 | } |