tower / commit
feat(exterior): look in at the interior's own world
README.md | 7 +- decisions.md | 74 ++++ design.md | 64 ++-- index.html | 8 +- src/ui/app.ts | 6 - src/views/exterior.ts | 320 +++++++++--------- src/views/interior-model.ts | 796 ++++++++++++++++++++++++++++++++++++++++++++ src/views/interior.ts | 712 ++------------------------------------- 8 files changed, 1105 insertions(+), 882 deletions(-) diff --git a/README.md b/README.md index 5a17d21..93f1f16 100644 --- a/README.md +++ b/README.md @@ -206,12 +206,13 @@ Any selection updates the URL, so a link is always shareable: ## Views **Map** is the unrolled inner surface. **Exterior** is Tower from outside — a -kilometre of hull, the checkered sun cap, the glazed far cap, the 244 km collector +kilometre of hull, a window at each cap, the interior's own star field, the 244 km collector flying ahead and the beam converging into the sun cap. Each piece is captioned. The hull reads two ways and the **Hull** switch picks one. **True** is the outer -skin: bare structure, and the far cap a real window — look in through it and the -land is actually there, lit and receding 540 km down the tube. It is the only line +skin: bare structure, and both caps real windows — look in through either and the +land is actually there, hazed exactly as the interior view hazes it and receding +540 km down the tube. It is the only line of sight into Tower that is not a diagram. **X-ray** wraps the terrain onto the outside instead, which is a diagram of what is inside rather than a view of it, and useful precisely because the wall is opaque. The active view is remembered and travels in the permalink, so diff --git a/decisions.md b/decisions.md index f85d4d7..33f85c7 100644 --- a/decisions.md +++ b/decisions.md @@ -1862,3 +1862,77 @@ was. sampled before the moving pointer is folded in and after, never across a finger arriving or leaving — otherwise the second finger touching down would jerk the world by half the distance between the two. + +## D-060 — The exterior looks in at the interior's own world + +**Implemented at the author's request, three changes at once.** + +**The place markers are gone from the exterior.** They were coloured spheres on the +hull, and they had become a crutch the view no longer needs: the sidebar lists the +places, the map shows them, and selecting one still swings the exterior camera round +to it (D-038). With the markers went their outline shells and their per-frame +scaling. A cap is still framed from 40° off its axis, which now also means looking +in through its window. + +**Looking in from outside now shows the interior view's world, not a sketch of it.** +Before this the exterior had its own inside: an emissive terrain texture on the back +of the hull, no haze, and a checkered sun cap that was opaque. The interior had +glazed that cap long ago (D-050), so the two views disagreed about the one surface +you could see from both. The fix is not to copy the interior's look but to use it: +the ground, both windows, the bore and the beam move out of `interior.ts` into +`interior-model.ts`, and both views add the same meshes with the same shaders. + +Two things had to change for the shaders to be looked at from outside. + +- **The haze integral clips to the air.** It used to integrate the whole segment from + eye to fragment, which is right when the eye is inside and absurd when it is 300 km + out in vacuum. The segment is now cut to the part inside the tube — radius out to + the deepest lake floor, z from the sun cap to the far cap's glass — before it is + sampled. From inside that is the whole segment, so the interior renders as before; + the only difference is the density is held at floor value rather than rising past + it below the datum. +- **The windows are see-through from outside.** Inside, a pane paints the computed + sky and is opaque to the renderer. From outside the same shader writes the pane + transparent, carrying only the airlight in front of it, and blends with whatever is + really behind: the land beyond the near cap, and the real collector and stars beyond + the far one down the tube. Mullions stay opaque and hazed. Alpha is taken on the + green channel, where canon quotes extinction. + +The exterior turns its hull and the air turns with it, so the shaders work in the +tower's frame: `uFrame` carries world into tower coordinates and the exterior updates +it each frame after the spin. Inside, nothing turns and it is the identity. + +**The blue light on the collector was the fill lights, reflected.** D-055 made the +collector's sun face polished metal under the scene's lights, expecting a single sun +glint. But the scene also carries two fill lights that exist only to make the hull +readable (§2.4), and a mirror reflects them as faithfully as it reflects the sun: at +any angle lined up with the bluish rim fill, a blue-white sweep crossed the disc. +Reproduced by aiming the camera along that light's reflection. A standard material +cannot be told to ignore some lights, so the face is now a small shader that knows +exactly one: the same GGX lobe Three drew for the sun at the same roughness, over the +same dark base, and nothing for the fills. + +The consequence is worth writing down, because it surprised the author: **the bright +band that had been read as the sun's reflection was the fill light too**, white at +its core where it saturated. The sun is on the axis behind the collector, so its +real glint only appears when looking nearly straight down that axis from sunward. + +**The exterior's stars are the interior's.** The old field was 3,500 points 26 km +wide at 18,000 km — about a pixel each, and on a retina display simply absent. The +sky is now a sphere around the scene painted with the windows' own star field +(D-056), sampled by direction from the camera so the stars are at infinity and hold +still while it orbits. The pinwheel phase is zero, since the camera is not riding the +hull, and the gain is full, since there is no daylight out here to adapt to. The +same pole star sits on the deepward axis in both views. + +**Showing the whole sky exposed that the field was never uniform.** It was laid out +in an azimuthal-equidistant projection about the axis, which spends area as θ dθ dφ +where the sky has sin θ dθ dφ: stars per square degree grew as θ / sin θ, correct at +the deepward pole, half again by the equator and without bound sunward, where they +also shrank into grey dust. The windows had only ever shown narrow parts of it. The +author saw it at once from outside — sparse bright stars one way, a haze of faint +ones the other. Stars are now scattered in 3D instead: a cubic lattice of cells each +rolls a star, and only stars inside a one-cell shell around the eye count, which +holds the same number of cells per steradian in every direction. The pinwheel is +kept by turning the direction about z before the lookup, and the density matches +what the old field had at its pole, so the far cap window looks as it did. diff --git a/design.md b/design.md index 2958ae6..e328e57 100644 --- a/design.md +++ b/design.md @@ -648,7 +648,8 @@ src/ state/ store.ts selected point, time-of-day, spin phase, view mode, writer flag views/ - exterior.ts Three.js: hull, caps, collector, beam, spin, point picker + exterior.ts Three.js: hull, collector, beam, spin, point picker + interior-model.ts ground, caps, bore, beam and haze, shared by both 3D views map.ts Canvas 2D: unrolled surface, slats, terminator, point picker interior.ts Three.js: shell geometry, the haze shader of §1.5, beam, rain ui/ @@ -712,6 +713,16 @@ map's hillshade: mirror, which shows the empty sky in front of it and not the sun behind the eye. What makes it legible is the sunlit rim of D-054 — the same 4 km ring the sun cap window sees from inside, drawn here as geometry rather than in a shader (D-055). + The mirror face is lit **by the sun alone**, in its own shader: as a standard + metal material it also reflected the fill lights, which do not exist, as a + blue-white sweep across the disc (D-060). The sun sits on the axis behind the + collector, so its glint shows only when looking nearly straight down that axis. + +**The stars are the interior's.** The sky is a sphere around the scene painted with +the same star field the windows use (D-056), sampled by direction from the camera so +the stars sit at infinity and hold still as the camera orbits. Nothing turns it — +the camera is not riding the hull — and there is no daylight to adapt to, so it is +drawn at full gain (D-060). Everything in the scene is captioned — collector, beam, both caps — with screen-space labels. A dark disc and a cone of light are not self-explanatory, and the collector @@ -721,31 +732,39 @@ The default camera frames the whole system rather than Tower alone: the collecto is 244 km across and flies 320 km ahead, and it is the larger object. **The hull has two readings, and the view switches between them** (D-047). **True** -is the outer skin: bare structure, the checkered sun cap, and the glazed far cap. +is the outer skin: bare structure, with a window at each cap. **X-ray** wraps the terrain raster onto the outside instead — from outside Tower is structure, so what that texture shows is what is *inside*, which makes it a diagram and not a view. Both are worth having and they are not the same claim, so the control is a two-way switch rather than a checkbox. -The wall is drawn as two meshes rather than one double-sided one, because its two -faces show different things: the outer skin changes with the mode, and the inner -surface is always the land. Since the far cap became a window (D-045) that inner -surface is genuinely visible from outside — look in through the glass in true mode -and the world is really there, lit and 540 km deep. It is the only line of sight -into Tower that is not a projection. Three's cylinder runs u around and v along, while the raster is the other -way round, so the UVs are rewritten from the original attribute rather than derived -from vertex positions — the seam vertices are duplicated, and deriving an angle from -them smears one column of triangles across the whole image. +**The inside of Tower is the interior view's own model** (D-060), not a lookalike. +The displaced ground, both glazed caps, the far cap's bore and the beam are built +once in `interior-model.ts` and added to both scenes, so looking in through either +window from outside shows the same land, hazed by the same integral, as standing in +it does. Two things make that work from outside. The haze integral **clips its +segment to the air** — the part of the sightline inside the tube, between the sun +cap and the far cap's glass — so the kilometres of vacuum between the camera and the +window add nothing; from inside the clip is the whole segment and nothing changes. +And the windows are **see-through**: a pane that paints the computed sky from inside +is transparent from outside, carrying only the haze in front of it, so what shows +through is the scene that is really there — the land beyond the near cap, and the +collector and the stars beyond the far one. The shaders work in the tower's frame, +and the exterior hands them the world-to-tower transform each frame, because the +hull is turning and the air turns with it. + +The outer skin keeps its own mesh and its own two modes. Three's cylinder runs u +around and v along, while the raster is the other way round, so the UVs are rewritten +from the original attribute rather than derived from vertex positions — the seam +vertices are duplicated, and deriving an angle from them smears one column of +triangles across the whole image. **The camera position persists** across reloads, alongside the active view, so -returning to the page returns to the vantage you left. Both end caps are places in -their own right, so the view can frame them: a cap's marker sits on the **axis**, -because a cap is a disc across the end and a point on its rim would ride round the -edge as Tower turns. +returning to the page returns to the vantage you left. -Markers are scaled per frame to hold a roughly constant size on screen. A 4 km dot -is legible from 200 km and sub-pixel from the 1,200 km of the default view, which is -to say the places would simply not be there. +**There are no place markers** (D-060). Selecting a place still swings the camera +round to it, and a cap is framed from about 40° off the axis so its window faces the +camera; the sidebar and the map carry the places themselves. ### 2.4 The interior view — built @@ -755,9 +774,9 @@ Real: the shell is the actual cylinder, curving up on both sides and closing overhead, displaced by `terrain-height.png` and painted with `terrain-color.png`. **Stars only through the far cap** — the wall is sealed (§1.4), so for 539 of the 540 km the sky is land and light. The far cap is glazed (D-045) and the sky beyond -it is sampled by view direction in an azimuthal-equidistant projection about the -spin axis, which makes the pinwheel fall out of the projection rather than being -animated into it: azimuth plus spin phase, one turn every 7 min 29 s, and the +its stars are scattered evenly over the whole sphere — rolled in a lattice of 3D cells +and kept only where they land in a one-cell shell around the eye — and looked up by +view direction turned about the spin axis by the spin phase: one turn every 7 min 29 s, and the point on the axis genuinely motionless. Simple: the ground is the raster plus a little procedural grain in the near field, @@ -805,7 +824,8 @@ polar, six rings, cells about 8 km. **The far cap is glazed** (D-045), and the checker survives on it as the window's mullions: the same tessellation at the same pitch, so it is still the only object in the world with a known size and still doubles as a scale for judging distance, now read against sky rather than plate. -Both patterns are shared with the exterior view. +The exterior draws these same caps — the same meshes and shaders, not a copy of +them (D-060). The far cap's glazing is set a kilometre into its own frame, so from inside there is a **1 km reveal** around it. That is the only place the wall depth of D-044 can be diff --git a/index.html b/index.html index d704ba3..f1367c4 100644 --- a/index.html +++ b/index.html @@ -48,10 +48,10 @@ <option value="true">True — what Tower looks like</option> <option value="xray" selected>X-ray — the inner surface projected out</option> </select></label> - <p class="hint">True shows the outer skin: bare hull, checkered sun cap, and - the far cap window — look in through it and the land is really there, - 540 km deep. X-ray wraps the terrain onto the outside instead, which is a - diagram of what is inside rather than a view of it.</p> + <p class="hint">True shows the outer skin: bare hull, and a window at each + cap — look in through either and the land is really there, hazed as it is + from inside, 540 km deep. X-ray wraps the terrain onto the outside instead, + which is a diagram of what is inside rather than a view of it.</p> <label class="row"><input id="f-spin" type="checkbox" checked /> Spin</label> <label>Speed <span id="speed-value" class="dim"></span> <input id="f-speed" type="range" min="0" max="4" step="1" value="2" /></label> diff --git a/src/ui/app.ts b/src/ui/app.ts index c552f85..cc33396 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -283,10 +283,6 @@ export class App { this.selectedId = null this.render() }, - onPickPlace: (id) => { - this.selectedId = id - this.render() - }, }, $('ext-labels')) const spin = $<HTMLInputElement>('f-spin') const speed = $<HTMLInputElement>('f-speed') @@ -295,7 +291,6 @@ export class App { this.exterior.showTerrain = $<HTMLSelectElement>('f-ext-view').value === 'xray' } this.exterior.resize() - this.exterior.setPlaces(this.store.places, this.selectedId) this.exterior.start() this.render() } @@ -697,7 +692,6 @@ export class App { this.map.selectedId = this.selectedId this.map.dragEnabled = this.tool === 'select' if (this.view === 'exterior') { - this.exterior?.setPlaces(this.store.places, this.selectedId) this.renderSpinReadout() } if (this.view === 'interior') { diff --git a/src/views/exterior.ts b/src/views/exterior.ts index 84ff81c..f63568f 100644 --- a/src/views/exterior.ts +++ b/src/views/exterior.ts @@ -8,13 +8,11 @@ */ import type { Texture } from 'three' import { - AdditiveBlending, AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, - FrontSide, - CylinderGeometry, DirectionalLight, DoubleSide, Float32BufferAttribute, Group, Mesh, - LineBasicMaterial, LineSegments, MeshBasicMaterial, MeshStandardMaterial, - PerspectiveCamera, Points, PointsMaterial, - Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, SphereGeometry, - TextureLoader, Vector2, Vector3, WebGLRenderer, + AmbientLight, BackSide, BufferGeometry, CircleGeometry, Color, CylinderGeometry, + DirectionalLight, DoubleSide, Float32BufferAttribute, FrontSide, Group, LineBasicMaterial, + LineSegments, Matrix4, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, + Raycaster, RepeatWrapping, RingGeometry, SRGBColorSpace, Scene, ShaderMaterial, + SphereGeometry, TextureLoader, Vector2, Vector3, WebGLRenderer, AdditiveBlending, } from 'three' import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { @@ -22,15 +20,13 @@ import { OMEGA_RAD_S, RADIUS_M, } from '../canon/constants.js' import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' -import type { Place } from '../world/schema.js' -import { capGlazingTexture, capPatternTexture } from './cap-pattern.js' -import { KIND_COLOR } from './palette.js' +import { InteriorModel, STAR_GLSL } from './interior-model.js' const KM = 1000 const R = RADIUS_M / KM const L = LENGTH_M / KM const BEAM_R = BEAM_RADIUS_M / KM -/** Wall depth in km (D-044). The caps sit at the outer face of it. */ +/** Wall depth in km (D-044). The far cap sits at the outer face of it. */ const HULL_D = HULL_DEPTH_M / KM const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 const COLLECTOR_STANDOFF = COLLECTOR_STANDOFF_M / KM @@ -42,9 +38,71 @@ const isCapZ = (zKm: number): boolean => zKm < CAP_TOLERANCE || zKm > L - CAP_TO const CAMERA_KEY = 'tower.exteriorCamera' const CAMERA_SAVE_MS = 400 +/** The real light: along the axis, from beyond the collector. */ +const SUN_POSITION = new Vector3(0, 0, -8000) +const SUN_COLOR = 0xfff3dd +const SUN_INTENSITY = 2.2 + +const WORLD_VERT = /* glsl */` +#include <common> +varying vec3 vWorld; +void main() { + vWorld = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +` + +/** + * The collector's mirror face, lit by the sun and by nothing else. + * + * The specular term is Three's own for a metal under one directional light — GGX + * distribution, correlated Smith visibility, Schlick Fresnel — so the glint is the + * one MeshStandardMaterial drew. What is missing is the other lights (D-060). + */ +const MIRROR_FRAG = /* glsl */` +#include <common> +uniform vec3 uBase; +uniform vec3 uF0; +uniform vec3 uSun; +uniform vec3 uToSun; +uniform float uAlpha; +varying vec3 vWorld; +void main() { + // Only ever seen from the sun side, so the normal is the sunward axis. + vec3 n = vec3(0.0, 0.0, -1.0); + vec3 v = normalize(cameraPosition - vWorld); + vec3 l = normalize(uToSun); + vec3 h = normalize(l + v); + float nl = max(dot(n, l), 0.0); + float nv = max(dot(n, v), 1e-4); + float nh = max(dot(n, h), 0.0); + float vh = max(dot(v, h), 0.0); + float a2 = uAlpha * uAlpha; + float dd = nh * nh * (a2 - 1.0) + 1.0; + float D = a2 / (PI * dd * dd); + float gv = nl * sqrt(a2 + (1.0 - a2) * nv * nv); + float gl = nv * sqrt(a2 + (1.0 - a2) * nl * nl); + float G = 0.5 / max(gv + gl, 1e-6); + vec3 F = uF0 + (1.0 - uF0) * pow(1.0 - vh, 5.0); + gl_FragColor = vec4(uBase + uSun * nl * F * G * D, 1.0); + #include <colorspace_fragment> +} +` + +/** The sky: the interior's star field, by direction from the camera. */ +const SKY_FRAG = /* glsl */` +#include <common> +uniform vec3 uBase; +varying vec3 vWorld; +${STAR_GLSL} +void main() { + gl_FragColor = vec4(uBase + starField(vWorld - cameraPosition), 1.0); + #include <colorspace_fragment> +} +` + export interface ExteriorCallbacks { onPickPoint: (p: SurfacePoint) => void - onPickPlace: (id: string) => void } export class ExteriorView { @@ -52,12 +110,14 @@ export class ExteriorView { private scene = new Scene() private camera: PerspectiveCamera private controls: OrbitControls - /** Everything that co-rotates with the hull, so markers stay put on the surface. */ + /** Everything that co-rotates with the hull. */ private tower = new Group() - private markers = new Group() private hull: Mesh private hullMat: MeshStandardMaterial - private innerMat!: MeshStandardMaterial + /** The inside of Tower, the same meshes and shaders the interior view stands in. */ + private model: InteriorModel + private worldToTower = new Matrix4() + private eyeInTower = new Vector3() private terrainMap: Texture | null = null private _showTerrain = true private raycaster = new Raycaster() @@ -100,10 +160,13 @@ export class ExteriorView { this.hullMat = new MeshStandardMaterial() this.hull = this.buildHull() - this.tower.add(this.hull, this.markers) + this.model = new InteriorModel({ + seeThrough: true, anisotropy: this.renderer.capabilities.getMaxAnisotropy(), + }) + this.tower.add(this.hull, ...this.model.meshes) this.scene.add(this.tower) this.buildCollars() - this.buildCaps() + this.buildAperture() this.buildSunSystem() this.buildStars() this.buildLights() @@ -125,14 +188,12 @@ export class ExteriorView { // ------------------------------------------------------------------ geometry /** - * Two meshes, not one, because the two sides of Tower's wall show different - * things and a single DoubleSide material cannot say so. + * The outer skin. The inside of the wall is not drawn here any more: it is the + * interior view's own ground (D-060), displaced and hazed, and it is seen only + * where there is a line of sight to it — in through the windows at either end. * - * The outer skin is what Tower looks like: bare metal, or — in x-ray mode — the - * inner surface projected onto it, which is a diagram and not a view (D-047). - * The inner surface is the land itself, and since the far cap became a window - * (D-045) it is genuinely visible from outside: look in through the glass and - * the far wall of the world is right there, lit and 540 km deep. + * The skin is what Tower looks like: bare metal, or — in x-ray mode — the inner + * surface projected onto it, which is a diagram and not a view (D-047). */ private buildHull(): Mesh { const g = new CylinderGeometry(R, R, L, 160, 1, true) @@ -143,13 +204,6 @@ export class ExteriorView { this.hullMat = new MeshStandardMaterial({ color: 0x8892a3, roughness: 0.62, metalness: 0.28, side: FrontSide, }) - // The land, seen from inside, through the window. Always the terrain — there - // is no mode in which the inside of Tower is bare metal. - this.innerMat = new MeshStandardMaterial({ - color: 0x2a2f38, roughness: 0.95, metalness: 0.0, side: BackSide, - }) - this.tower.add(new Mesh(g.clone(), this.innerMat)) - const hull = new Mesh(g, this.hullMat) this.loadTerrain() return hull @@ -220,18 +274,6 @@ export class ExteriorView { this.hullMat.metalness = on ? 0.06 : 0.28 this.hullMat.roughness = on ? 0.9 : 0.62 this.hullMat.needsUpdate = true - - // The inside carries the land in both modes, and always emissive: it is lit by - // a beam this scene does not model, and unlit it would read as a black hole - // behind the window rather than as a world. - if (this.terrainMap) { - this.innerMat.map = this.terrainMap - this.innerMat.emissiveMap = this.terrainMap - this.innerMat.emissive.set(0xffffff) - this.innerMat.emissiveIntensity = 0.5 - this.innerMat.color.set(0xffffff) - this.innerMat.needsUpdate = true - } } set showTerrain(v: boolean) { @@ -241,61 +283,36 @@ export class ExteriorView { /** * The kilometre of wall (D-044), which from outside is only visible as depth at - * the two ends: the inner surface stops at z = 0 and z = L, the caps sit a - * kilometre further out at the skin, and what stands between them is a collar - * you can see down. There is no terrain on a collar — it is wall, not world. + * the two ends. There is no terrain on a collar — it is wall, not world. + * + * The far collar is outer skin only: its inside is the interior model's bore, + * hazed like everything else in there, and drawing both would put two surfaces at + * one depth. The sun collar has no bore behind it — the sun cap window sits at the + * inner face, z = 0 — so it keeps both faces, as the lip you look past to see in. */ private buildCollars(): void { - const mat = new MeshStandardMaterial({ - color: 0x6f7787, roughness: 0.55, metalness: 0.42, side: DoubleSide, - }) for (const z0 of [-HULL_D, L]) { const g = new CylinderGeometry(R, R, HULL_D, 160, 1, true) g.rotateX(Math.PI / 2) g.translate(0, 0, z0 + HULL_D / 2) - this.tower.add(new Mesh(g, mat)) + this.tower.add(new Mesh(g, new MeshStandardMaterial({ + color: 0x6f7787, roughness: 0.55, metalness: 0.42, + side: z0 < 0 ? DoubleSide : FrontSide, + }))) } } - private buildCaps(): void { - // The same pattern the interior view paints on their far side (D-041). A cap is - // structure, not landscape, and the checker is what says so from either side. - const pattern = capPatternTexture() - // The sun cap is an annulus: the beam enters through the hole in the middle. - const sun = new Mesh( - new RingGeometry(BEAM_R, R, 96), - new MeshStandardMaterial({ - color: 0xdfe5ee, map: pattern, roughness: 0.5, metalness: 0.3, side: DoubleSide, - // Same licence as the hull's terrain: sunlight runs along the axis, so a cap - // is edge-on to every real light and renders as a dark disc exactly when it - // is being looked at. Enough emissive to keep the pattern legible. - emissiveMap: pattern, emissive: new Color(0xffffff), emissiveIntensity: 0.42, - })) - sun.position.z = -HULL_D - this.tower.add(sun) - + /** + * The beam's way in: the hole in the middle of the sun cap's annulus, where the + * converging rays arrive. The caps themselves are the interior model's windows + * (D-060), so this is the one piece of the sun cap the exterior still draws. + */ + private buildAperture(): void { const aperture = new Mesh( new CircleGeometry(BEAM_R, 48), - new MeshBasicMaterial({ color: 0xfff0cf })) - aperture.position.z = -HULL_D + 0.4 + new MeshBasicMaterial({ color: 0xfff0cf, side: DoubleSide })) + aperture.position.z = -0.05 this.tower.add(aperture) - - /** - * The far cap is a window (D-045), and from outside it is a window in the - * ordinary sense: the mullions are structure and the panes are clear, so what - * is behind them is the inside of Tower. That is the whole of the "true" view's - * payoff — the only line of sight into the world that is not a diagram. - */ - const glazing = capGlazingTexture() - const far = new Mesh( - new CircleGeometry(R, 96), - new MeshStandardMaterial({ - color: 0xd8dee8, map: glazing, transparent: true, side: DoubleSide, - roughness: 0.72, metalness: 0.25, - emissiveMap: glazing, emissive: new Color(0xffffff), emissiveIntensity: 0.5, - })) - far.position.z = L + HULL_D + 0.02 - this.tower.add(far) } private buildSunSystem(): void { @@ -325,15 +342,29 @@ export class ExteriorView { this.scene.add(back) /** * The mirror face. A flat mirror in vacuum shows what is in front of it, and - * what is in front of this one is empty sky — so it is dark too, with the - * sun's own reflection as a glint where the angle happens to line up. Metal - * with almost no roughness does exactly that on its own: no diffuse term, one - * tight specular lobe. The emissive is only enough to keep it a surface - * rather than a hole when the glint is elsewhere. + * what is in front of this one is empty sky — so it is dark, with the sun's own + * reflection as a glint where the angle happens to line up. + * + * The glint is computed here rather than left to the scene's lights (D-060). As + * a standard metal material the mirror reflected *every* light, and the scene + * also carries fill lights that exist only to make the hull readable (§2.4) — so + * a blue-white sweep of light that is not there crossed the disc beside the real + * glint. A standard material cannot be told to ignore some lights, so this is a + * shader that knows exactly one: the lobe Three drew for the sun at the same + * roughness, and nothing for anything else. */ - const front = new Mesh(disc, new MeshStandardMaterial({ - color: 0xdfe8ff, roughness: 0.12, metalness: 1.0, side: BackSide, - emissive: new Color(0x1a2334), emissiveIntensity: 1.0, + const front = new Mesh(disc, new ShaderMaterial({ + vertexShader: WORLD_VERT, fragmentShader: MIRROR_FRAG, side: BackSide, + uniforms: { + uBase: { value: new Color(0x161b24) }, + // Near-neutral silver. The old tint was a little blue, which is the colour + // this change exists to take off the disc. + uF0: { value: new Color(0xe8ebf0) }, + uSun: { value: new Color(SUN_COLOR).multiplyScalar(SUN_INTENSITY) }, + uToSun: { value: SUN_POSITION.clone().normalize() }, + // Three squares roughness into alpha; 0.12 is what the metal material had. + uAlpha: { value: 0.12 * 0.12 }, + }, })) front.position.z = -COLLECTOR_STANDOFF this.scene.add(front) @@ -387,16 +418,28 @@ export class ExteriorView { }))) } + /** + * The sky, as the interior sees it through its windows: the same star field + * (D-056), sampled by direction from the camera, so the stars sit at infinity and + * do not slide as the camera orbits (D-060). + * + * Out here nothing turns the sky — the camera is not riding the hull — so the + * pinwheel's phase is held at zero, and there is no daylight for the eye to adapt + * to, so the gain is full. The field it replaces was 3,500 points 26 km wide at + * 18,000 km: about a pixel each, and on a retina display not there at all. + */ private buildStars(): void { - const n = 3500 - const pos = new Float32Array(n * 3) - for (let i = 0; i < n; i++) { - const v = new Vector3().randomDirection().multiplyScalar(18_000) - pos.set([v.x, v.y, v.z], i * 3) - } - const g = new BufferGeometry() - g.setAttribute('position', new Float32BufferAttribute(pos, 3)) - this.scene.add(new Points(g, new PointsMaterial({ color: 0xdfe8ff, size: 26, sizeAttenuation: true }))) + const sky = new Mesh(new SphereGeometry(20_000, 48, 24), new ShaderMaterial({ + vertexShader: WORLD_VERT, fragmentShader: SKY_FRAG, side: BackSide, depthWrite: false, + uniforms: { + uBase: { value: new Color(0x05070d) }, uSpin: { value: 0 }, uStarGain: { value: 1 }, + }, + })) + // First, behind everything. And it surrounds the camera, which culling does not + // expect of a bounding sphere. + sky.renderOrder = -1 + sky.frustumCulled = false + this.scene.add(sky) const glow = new Mesh( new SphereGeometry(300, 24, 24), @@ -449,8 +492,8 @@ export class ExteriorView { private buildLights(): void { // The real light runs along the axis, from beyond the collector. - const sun = new DirectionalLight(0xfff3dd, 2.2) - sun.position.set(0, 0, -8000) + const sun = new DirectionalLight(SUN_COLOR, SUN_INTENSITY) + sun.position.copy(SUN_POSITION) this.scene.add(sun) /** @@ -465,53 +508,14 @@ export class ExteriorView { const rim = new DirectionalLight(0x8fa8d8, 0.7) rim.position.set(1.2, 0.9, -0.4) this.scene.add(rim) - // The far cap's outer face points away from the sun and from both fills, so it - // renders as a black hole exactly when you focus on it. + // The far collar's outer face points away from the sun and from both fills, so + // it renders as a black band exactly when you focus on that end. const behind = new DirectionalLight(0x9fb2d4, 0.55) behind.position.set(-0.5, -0.8, 1) this.scene.add(behind) this.scene.add(new AmbientLight(0x3a4356, 1.6)) } - // ------------------------------------------------------------------- markers - - /** Put a dot on the hull for every place, at its canon (theta, z). */ - setPlaces(places: readonly Place[], selectedId: string | null): void { - this.markers.clear() - for (const p of places) { - const g = p.geometry - const at: SurfacePoint = g.type === 'point' - ? { theta: g.theta, z: g.z } - : g.type === 'band' - // A band rings the world, so any theta is as good as another. - ? { theta: 0, z: (g.zFrom + g.zTo) / 2 } - : { theta: g.points[0]![0], z: g.points[0]![1] } - const selected = p.id === selectedId - const r = selected ? 6.5 : 4.2 - const dot = new Mesh( - new SphereGeometry(r, 14, 14), - new MeshBasicMaterial({ color: new Color(KIND_COLOR[p.kind] ?? '#ccc') })) - // A dark shell behind each dot. Small bright spheres read fine against a bare - // hull and vanish into the terrain once it is painted on; the outline is what - // keeps them legible over green forest and tan desert alike. - dot.add(new Mesh( - new SphereGeometry(r * 1.45, 14, 14), - new MeshBasicMaterial({ color: 0x05070d, side: BackSide }))) - const zKm = at.z / KM - if (isCapZ(zKm)) { - // A cap is a disc across the end, so its place is the axis — not a point on - // the rim, which would ride round the edge as Tower turns. - dot.position.set(0, 0, zKm < L / 2 ? -5 : L + 5) - } else { - dot.position.set( - (R + 1.6) * Math.cos(at.theta), (R + 1.6) * Math.sin(at.theta), zKm) - } - dot.userData['placeId'] = p.id - dot.renderOrder = 2 - this.markers.add(dot) - } - } - // ------------------------------------------------------------------- camera /** @@ -576,9 +580,6 @@ export class ExteriorView { -((e.clientY - r.top) / r.height) * 2 + 1) this.raycaster.setFromCamera(ndc, this.camera) - const onMarker = this.raycaster.intersectObjects(this.markers.children, false)[0] - if (onMarker) return this.cb.onPickPlace(onMarker.object.userData['placeId'] as string) - const onHull = this.raycaster.intersectObject(this.hull, false)[0] if (!onHull) return // Into the hull's own frame, so the spin is undone and theta is canon. @@ -607,9 +608,9 @@ export class ExteriorView { /** * A cap is a wall across the end, not a patch of ground, so the usual * from-the-side framing shows only an edge. This sits about 40° off the axis: - * far enough round to see the cap's face — which now carries a pattern worth - * seeing (D-041) — without looking straight down the barrel and foreshortening - * the whole 540 km behind it. + * far enough round to see the cap's face — and in through its window (D-060) — + * without looking straight down the barrel and foreshortening the whole 540 km + * behind it. */ if (isCapZ(z)) { const atSun = z < L / 2 @@ -639,14 +640,17 @@ export class ExteriorView { this.lastFrame = now if (this.spinning) this.phase = (this.phase + OMEGA_RAD_S * dt * this.timeScale) % (2 * Math.PI) this.tower.rotation.z = this.phase + this.controls.update() /** - * Keep markers a roughly constant size on screen. A 4 km dot is legible from - * 200 km away and sub-pixel from 1,200 km, which is where the default view - * sits — so at world scale the places would simply not be there. + * The interior model's shaders integrate haze in the tower's own frame, and + * the tower is turning. So they are handed the world-to-tower transform and + * the eye already carried through it, fresh each frame after both the spin + * and the camera have moved. */ - const scale = Math.max(0.8, Math.min(2.5, this.camera.position.distanceTo(this.controls.target) / 700)) - for (const m of this.markers.children) m.scale.setScalar(scale) - this.controls.update() + this.tower.updateMatrixWorld() + this.worldToTower.copy(this.tower.matrixWorld).invert() + this.model.setFrame(this.worldToTower) + this.model.setCamera(this.eyeInTower.copy(this.camera.position).applyMatrix4(this.worldToTower)) this.renderer.render(this.scene, this.camera) this.updateLabels() requestAnimationFrame(tick) diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts new file mode 100644 index 0000000..69eb057 --- /dev/null +++ b/src/views/interior-model.ts @@ -0,0 +1,796 @@ +/** + * Tower's inside, as meshes both 3D views draw: the displaced ground, the two glazed + * caps, the bore behind the far one and the beam — with the integrated haze in every + * one of their shaders. See design.md §1.8, §2.4. + * + * The interior view stands in it. The exterior view looks in at it through the + * windows (D-060), and that is why every shader here works in the **tower's frame** + * rather than the world's: the exterior turns the whole model with the hull, and + * haze has to be integrated where the air is, not where the scene graph put it. + * `uFrame` carries world into tower coordinates and `uCam` is the eye already in + * them. Inside, nothing turns, so both are the identity and the eye as it stands. + * + * Scene units are kilometres, so the numbers in the code are the numbers in canon. + */ +import type { Texture } from 'three' +import { + AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide, + LinearFilter, Matrix4, Mesh, RepeatWrapping, RingGeometry, ShaderMaterial, + TextureLoader, Vector3, +} from 'three' +import { + BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M, + HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M, + SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, +} from '../canon/constants.js' +import { mapThetaOrigin } from '../canon/geometry.js' +import { MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture } from './cap-pattern.js' +import { + COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, + RASTER_MIN_HEIGHT_M, RASTER_WIDTH, +} from './raster.js' + +const KM = 1000 +const R = RADIUS_M / KM +const L = LENGTH_M / KM +const BEAM_R = BEAM_RADIUS_M / KM +/** Wall depth in km. The far cap's window is set this far into its own frame. */ +const HULL_D = HULL_DEPTH_M / KM +/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ +const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 +const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM +/** Width of the sunlit edge structure, in km (D-054). */ +const COLLECTOR_RIM = COLLECTOR_RIM_M / KM + +/** + * Rayleigh extinction per kilometre at floor density, per channel. + * + * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about + * twice as far as green and blue about half as far — which is the whole reason + * distant land in Tower goes blue rather than merely grey, and it costs nothing to + * carry three numbers instead of one. + */ +export const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM +const K_RGB: [number, number, number] = [ + K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4, +] +/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */ +const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2) + +/** + * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL. + * + * The obvious move is to make it sky blue, and it is wrong: blue already scatters + * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and + * every distant thing turns mauve regardless of what it is made of. With a neutral + * source the blue falls out of the physics, and the far side of the world stays + * recognisably forest and desert at the 78% canon says it should be. + */ +export const HAZE_DAY = 0x9aa3ad +export const HAZE_DUSK = 0x8d8792 + +/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ +export const SEG_AROUND = 512 +export const SEG_ALONG = 768 + +// ---------------------------------------------------------------------- shaders + +/** + * Transmittance from `a` to `b` through Tower's air, per channel. + * + * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with + * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and + * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint + * samples land within a percent of the 512-sample version the instruments use, and + * the two agree on the canon table in design.md §1.8. + * + * **The segment is clipped to the air first.** From inside that changes nothing — + * the eye and everything it sees are both in the air already. From outside it is + * the whole difference between a view in through a window and a view through a + * thousand kilometres of make-believe atmosphere: only the part of the sightline + * that is actually inside the tube, between the sun cap and the far cap's glass, + * gets extinguished. The radius allows for the lake floors cut below the datum, and + * the density is held at floor value there rather than climbing past it. + */ +const HAZE_GLSL = /* glsl */` +uniform vec3 uCam; +uniform vec3 uK; +uniform vec3 uHaze; +uniform float uExposure; +const float A = ${DENSITY_A.toExponential(8)}; +const float RR = ${(R * R).toFixed(4)}; +const float RR_AIR = ${((R - RASTER_MIN_HEIGHT_M / KM + 0.05) ** 2).toFixed(4)}; +const float Z_AIR0 = 0.0; +const float Z_AIR1 = ${(L + HULL_D).toFixed(4)}; + +vec3 towerTransmittance(vec3 from, vec3 to) { + vec3 d = to - from; + float t0 = 0.0; + float t1 = 1.0; + float a = dot(d.xy, d.xy); + float b = dot(from.xy, d.xy); + float c = dot(from.xy, from.xy) - RR_AIR; + if (a > 1e-8) { + float disc = b * b - a * c; + if (disc <= 0.0) return vec3(1.0); + float s = sqrt(disc); + t0 = max(t0, (-b - s) / a); + t1 = min(t1, (-b + s) / a); + } else if (c > 0.0) { + return vec3(1.0); + } + if (abs(d.z) > 1e-8) { + float za = (Z_AIR0 - from.z) / d.z; + float zb = (Z_AIR1 - from.z) / d.z; + t0 = max(t0, min(za, zb)); + t1 = min(t1, max(za, zb)); + } else if (from.z < Z_AIR0 || from.z > Z_AIR1) { + return vec3(1.0); + } + if (t1 <= t0) return vec3(1.0); + + vec3 start = from + d * t0; + vec3 span = d * (t1 - t0); + float len = length(span); + const int N = 24; + float tau = 0.0; + for (int i = 0; i < N; i++) { + float t = (float(i) + 0.5) / float(N); + vec2 p = start.xy + span.xy * t; + tau += exp(-A * max(RR - dot(p, p), 0.0)); + } + return exp(-uK * (tau * len / float(N))); +} + +/** + * How a window is written out. Inside, \`face\` is what fills a pane — sky — and the + * glass is opaque to the renderer because nothing behind it is drawn. Outside, + * \`uSeeThrough\` is set and a pane is clear: it keeps only the haze that lies in + * front of it, and lets the scene behind show through by the rest. The straight + * alpha is taken on green, where canon quotes its extinction. + */ +uniform float uSeeThrough; +vec4 windowColour(vec3 face, vec3 solid, float cover) { + vec3 T = towerTransmittance(uCam, vWorld); + vec3 air = uHaze * uExposure * (1.0 - T); + if (uSeeThrough < 0.5) return vec4(face * T + air, 1.0); + float haze = 1.0 - T.g; + float alpha = cover + (1.0 - cover) * haze; + vec3 col = cover * (solid * T + air) + (1.0 - cover) * air; + return vec4(col / max(alpha, 1e-4), alpha); +} +` + +const GROUND_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform sampler2D uHeight; +uniform vec2 uTexel; +uniform float uMppKm; +varying vec2 vUv; +varying vec3 vWorld; +varying vec3 vNormal2; + +/** + * The raster is stretched across a declared working range, not 0..anything. + * + * This fetch is in a *vertex* shader, and that is why uHeight is built with no + * mipmaps at all (loadRasters). A vertex shader has no derivatives, so there is + * nothing for texture2D to pick a mip level from — the level is left to the + * driver, and a driver is entitled to pick any of them. On one that picks badly + * the shell gets displaced by a downsampled height field: the terrain flattens + * into blocks with the cell seams showing as creases, and it comes back on a + * reload, which is what made it look intermittent rather than wrong. Naming the + * level here instead would be the obvious guard and is not available — the + * vertex-only texture2DLod does not survive Three's WebGL2 shader prefix — so + * the ambiguity is removed at the other end, by giving the sampler exactly one + * level to choose between. + */ +float elevation(vec2 at) { + return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)} + + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)}; +} + +void main() { + vUv = uv; + vec3 radial = normalize(vec3(position.xy, 0.0)); + vec3 up = -radial; + // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself. + vec3 spinward = vec3(-radial.y, radial.x, 0.0); + vec3 deepward = vec3(0.0, 0.0, 1.0); + + float h = elevation(uv); + // Central differences on the raster, in km of rise per km of run. + float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0))) + / (2.0 * uMppKm); + float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y))) + / (2.0 * uMppKm); + vNormal2 = normalize(up - deepward * dz - spinward * dt); + + // The ground mesh sits untransformed in the tower's frame in both views, so its + // own coordinates already are that frame and uFrame is not needed here. + vec3 p = position + up * h; + vWorld = p; + gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); + #include <logdepthbuf_vertex> +} +` + +const GROUND_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform sampler2D uColor; +uniform float uFrontZ; +uniform float uFrontWidth; +uniform float uBelowFront; +uniform float uAboveFront; +varying vec2 vUv; +varying vec3 vWorld; +varying vec3 vNormal2; +${HAZE_GLSL} + +/** Cheap value noise, to keep the ground from being flat colour underfoot. */ +float hash(vec2 p) { + return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); +} +float vnoise(vec2 p) { + vec2 i = floor(p), f = fract(p); + f = f * f * (3.0 - 2.0 * f); + return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), + mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); +} + +void main() { + #include <logdepthbuf_fragment> + // The raster is an sRGB image and every uniform Colour here is already linear, + // so the texture is the one thing that has to be brought into the working space. + // Three inserts that decode into its own materials and never into a custom one. + vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2)); + + /** + * The raster is 264 m per pixel, so within a few hundred metres of the observer + * one texel fills the screen. This is texture, not terrain: it breaks up the + * flat block without claiming there is anything there. It fades out past 4 km, + * where the raster is carrying real detail again. + */ + float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld)); + // Unrolled surface coordinates. World xyz shears the pattern into radial streaks + // exactly where the ground is most grazing, which is most of the view. + vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)}); + // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads + // as a woven diamond lattice, which is worse than the flat colour it replaced. + vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86); + float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45; + base *= 1.0 + (grain - 0.5) * 0.12 * near; + + // The beam is a line source on the axis, so the light comes from straight up. + vec3 radial = normalize(vec3(vWorld.xy, 0.0)); + float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0); + /** + * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is + * one beam — but the diffusers hand the light down the axis, so the *ground* + * lights as a line running deepward at 225 m/s. Below the front and above it are + * different times of day, and this is the only place in Tower where that is true. + * + * Outside dawn and dusk both sides are 1.0 and this costs a mix. + */ + float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); + float daylight = mix(uBelowFront, uAboveFront, side); + + // The rest of the world is lit land, and lit land is what fills the shadows. + vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight; + + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +const FLAT_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform mat4 uFrame; +varying vec3 vWorld; +void main() { + vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + #include <logdepthbuf_vertex> +} +` + +/** + * The sky through the windows (§1.4, D-045), and the exterior's sky (D-060). + * + * **Stars are scattered in 3D, not in a projection.** Around the eye is a shell of + * radius STAR_SCALE, in cells of a cubic lattice. Every cell rolls whether it holds a + * star and where in the cell it sits, and a star counts only if it lands inside the + * shell, one cell thick. A shell that thick holds the same number of cells per + * steradian in every direction, so the field is uniform over the whole sphere. + * + * The projection this replaces was not. It was azimuthal-equidistant about +z, + * chosen because the spin axis is the celestial pole and azimuth plus spin phase was + * the pinwheel. But it spends plane area as θ dθ dφ where the sky has sin θ dθ dφ, so + * stars per square degree grew as θ / sin θ — true at the deepward pole, half again + * as dense by the equator, and without bound toward the sun, where the cells also + * shrank below a pixel into grey dust. Inside it hid behind the windows' narrow + * view; the exterior shows the whole sky at once, and it was plain there. + * + * The pinwheel means the same thing it did: the direction is turned about z by the + * spin phase before it is looked up, so every star runs a circle about the axis once + * every 7 min 29 s, and the one on the axis does not move. + */ +export const STAR_GLSL = /* glsl */` +uniform float uSpin; +uniform float uStarGain; + +float starHash(vec3 p) { + p = fract(p * 0.1031); + p += dot(p, p.zyx + 31.32); + return fract((p.x + p.y) * p.z); +} +vec3 starHash3(vec3 p) { + p = fract(p * vec3(0.1031, 0.1030, 0.0973)); + p += dot(p, p.yxz + 33.33); + return fract((p.xxy + p.yxx) * p.zyx); +} + +// Cells per radian at the shell. ~1.2 deg cells, which at 22% occupancy lands near +// the naked-eye sky's real density of one star per 7 square degrees — the same +// density the old projection had at its pole, and now everywhere. +const float STAR_SCALE = 46.0; + +vec3 starField(vec3 dir) { + vec3 d0 = normalize(dir); + float cs = cos(uSpin); + float sn = sin(uSpin); + vec3 d = vec3(cs * d0.x - sn * d0.y, sn * d0.x + cs * d0.y, d0.z); + float th = acos(clamp(d.z, -1.0, 1.0)); + vec3 cell = floor(d * STAR_SCALE); + /** + * How wide to draw a star, and the whole reason the sky used to flicker. + * + * A star was a spike: brightness (1 - d/0.09)^4, which on a retina panel is a + * core about a pixel across. The sky is sampled once per pixel at the pixel's + * centre, so what got drawn was that spike read at one arbitrary point on it — + * and half a pixel of head movement moved the reading from the peak to a third + * of it. Every star in the field did that independently, every frame. That is + * flicker, and it is wrong twice over: vacuum has no air to twinkle through, + * and the interior is deliberately still. + * + * The fix is to stop drawing a shape smaller than the thing sampling it. The + * star becomes a gaussian one pixel wide — sigma tied to the pixel footprint, + * so it is one pixel on any display and at any zoom — which is what a point + * source genuinely looks like through any real optic: an Airy blob the width of + * the aperture's resolution, not a mathematical point. A curve that smooth + * barely changes between neighbouring samples, so the star sits still and the + * eye reads it as a point anyway. + * + * The footprint is measured from the direction vector: |dd/dpixel| on a unit + * vector is the pixel's angular size in radians, and it has no seam anywhere on + * the sphere. Clamped at the top because the cell search below is only 3x3x3: a + * sigma near the cell size would want light from stars this loop never visits. + */ + float sg = clamp(length(fwidth(d)) * STAR_SCALE * 0.85, 1e-4, 0.3); + float inv2s2 = 0.5 / (sg * sg); + vec3 sum = vec3(0.0); + for (int k = -1; k <= 1; k++) { + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec3 g = cell + vec3(float(i), float(j), float(k)); + float mag = starHash(g); + if (mag < 0.78) continue; // most of the sky is empty + vec3 q = g + starHash3(g + 11.7); + float r = length(q); + // The shell only. Without it one direction would collect every star in + // the column of cells behind it. + if (abs(r - STAR_SCALE) > 0.5) continue; + vec3 at = (d - q / r) * STAR_SCALE; + float b = exp(-dot(at, at) * inv2s2) * ((mag - 0.78) / 0.22); + // A few warm ones among the cool. Without it the field reads as noise. + sum += b * mix(vec3(0.72, 0.80, 1.0), vec3(1.0, 0.86, 0.68), starHash(g + 5.1)); + } + } + } + /** + * The pole star, placed rather than left to the hash. §1.4 promises one + * motionless star at the centre of the pinwheel; it is the fixed point the whole + * phenomenon is read against, and a sky that happened not to roll one there + * would be missing the only part of it anybody in Tower would have named. + * + * It lands at the observer's own radius on the window — the axis is horizontal + * from the floor, so this is a star on the horizon, not overhead. + */ + float pole = th * STAR_SCALE; + // Drawn a little wider than the rest, as the one star that is placed rather than + // rolled: it is the fixed point the pinwheel is read against, and it has to be + // findable before anything can be seen to turn about it. + float ps = sg * 1.6; + sum += exp(-pole * pole * 0.5 / (ps * ps)) * vec3(1.0, 0.95, 0.86) * 1.6; + return sum * 2.6 * uStarGain; +} +` + +/** The bore between the world's end and the glazing. Plain structure, hazed. */ +const REVEAL_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uTint; +varying vec3 vWorld; +${HAZE_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); + #include <colorspace_fragment> +} +` + +/** + * The sun cap: also a window, and what fills it is the back of the collector. + * + * §1.4 gave this cap "heavy glare management" and no view, on the assumption that + * it faces the sun. It does not face the sun — it faces the **collector**, which is + * 41.8° across from here against the sun's 0.53° and sits squarely between the two. + * Tower is inside its umbra permanently, so no direct sunlight has ever reached + * this cap and there is nothing here to manage (D-050). + * + * What there is instead is a 244 km disc of unlit structure. Its front is a mirror + * pointed at the sun; its back radiates the collector's own waste heat at about + * 245 K, which is infrared and therefore black. So the sky through this window is a + * black disc with stars around the edge of it — and the disc does not turn with + * them, because the collector flies in formation rather than spinning. + * + * Seen from outside none of that is drawn here: the pane is clear, and the real + * collector and the real stars are behind it in the exterior's own scene. + */ +const SUN_CAP_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uTint; +uniform sampler2D uGlazing; +varying vec3 vWorld; +${HAZE_GLSL} +${STAR_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; + vec4 frame = texture2D(uGlazing, uv); + vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure; + + vec3 sky = vec3(0.0); + if (uSeeThrough < 0.5) { + vec3 dir = normalize(vWorld - uCam); + sky = starField(dir); + /** + * Cast on through the window to the collector's plane. Anything landing on the + * disc sees its unlit back, which is as black as the sky is not — and the edge, + * which is the opposite: structure standing proud of the mirror plane with the + * sun striking it side-on (D-054). So the collector reads the way any backlit + * thing does, as a hole in the stars with a lit rim, rather than as an absence. + */ + float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4); + if (t > 0.0) { + float r = length(vWorld.xy + dir.xy * t); + sky = mix(sky, vec3(0.0), step(r, ${COLLECTOR_R.toFixed(2)})); + // Tight core for the structure itself, and a wide dim skirt for the scatter + // around it. One exponential alone reads as a drawn outline rather than light. + float d = abs(r - ${COLLECTOR_R.toFixed(2)}); + float rim = exp(-d / ${COLLECTOR_RIM.toFixed(3)}) + + 0.10 * exp(-d / ${(COLLECTOR_RIM * 3).toFixed(3)}); + /** + * Not scaled by uExposure — the sun does not dim when Tower banks its beam. + * It does fade as the eye adapts, like the stars, but from a much higher floor: + * this is sunlit structure, and it stays visible at noon where a star does not. + */ + sky += vec3(1.0, 0.93, 0.78) * rim * (0.3 + 0.7 * uStarGain) * 0.42; + } + } + + gl_FragColor = windowColour(mix(sky, mullion, frame.a), mullion, frame.a); + #include <colorspace_fragment> +} +` + +/** + * The far cap: a window, and the frame holding it. + * + * `uGlazing`'s alpha is the mask — opaque where there is a mullion, clear where + * there is a pane. Through a pane is deep anti-sun space (§1.4): black, with stars + * in it, and the haze in front doing the rest. By day the airlight term swamps + * everything and the cap is simply blue, which is what §1.5 already said would + * happen to anything 540 km away. At night the airlight collapses with the beam + * and what is left is the sky. + */ +const GLAZED_CAP_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uTint; +uniform sampler2D uGlazing; +varying vec3 vWorld; +${HAZE_GLSL} +${STAR_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; + vec4 frame = texture2D(uGlazing, uv); + vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure; + /** + * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam + * changes how bright Vega is — the exposure models the beam lighting the world, + * and the sky outside is not lit by it. uStarGain carries the other half of the + * same fact: the eye adapts, so the stars are there at noon and unseeable. + */ + vec3 sky = uSeeThrough < 0.5 ? starField(vWorld - uCam) : vec3(0.0); + vec3 face = mix(sky, mullion, frame.a); + /** + * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it + * is lit by the beam rather than by the room: it blazes at noon, banks at night + * and goes out entirely when the beam does. The stars behind it do none of + * those, which is the whole difference between the two things in this shader. + * + * It is on the axis, but that is NOT the centre of the pinwheel: starField turns + * about the direction parallel to the axis, which lands on the window at the + * camera's own radius rather than at the cap's middle. The two are ~10° apart + * from the floor and coincide only for an observer standing on the axis. + */ + float rc = length(vWorld.xy) / ${R.toFixed(1)}; + float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)}, + ${MIRROR_UV_RADIUS.toFixed(5)}, rc); + vec3 glow = mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6; + gl_FragColor = windowColour(face + glow, mullion + glow, frame.a); + #include <colorspace_fragment> +} +` + +const BEAM_VERT = /* glsl */` +#include <common> +#include <logdepthbuf_pars_vertex> +uniform mat4 uFrame; +varying vec3 vWorld; +varying vec3 vNormal2; +void main() { + vWorld = (uFrame * modelMatrix * vec4(position, 1.0)).xyz; + vNormal2 = normalize(mat3(uFrame * modelMatrix) * normal); + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + #include <logdepthbuf_vertex> +} +` + +/** + * The beam is glowing air, not a surface, so it is brightest where the sightline + * passes through the most of it — down the middle — and fades at the limb. A tube + * shaded the usual way does the exact opposite and reads as a chrome pipe. + */ +const BEAM_FRAG = /* glsl */` +#include <common> +#include <logdepthbuf_pars_fragment> +uniform vec3 uBeam; +varying vec3 vWorld; +varying vec3 vNormal2; +${HAZE_GLSL} +void main() { + #include <logdepthbuf_fragment> + vec3 view = normalize(vWorld - uCam); + // Additive white clips long before the limb, so the falloff has to start early + // or the shaft renders as a hard-edged slab instead of a glowing rod. + float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1); + vec3 T = towerTransmittance(uCam, vWorld); + gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0); + #include <colorspace_fragment> +} +` + +// ------------------------------------------------------------------------ model + +export interface InteriorModelOptions { + /** + * True when the model is looked at from outside, through its windows: the panes + * go clear instead of painting the sky, and the caps blend with what is behind. + */ + seeThrough: boolean + /** The renderer's anisotropy ceiling, for the colour raster. */ + anisotropy: number +} + +export class InteriorModel { + /** Every material here, so a caller can set a shared uniform in one loop. */ + readonly materials: ShaderMaterial[] = [] + readonly groundMat: ShaderMaterial + readonly ground: Mesh + /** Everything to add to a scene, the ground included. */ + readonly meshes: Mesh[] + /** Resolves with the height raster once both rasters are on the ground, or null. */ + readonly rasters: Promise<Texture | null> + + constructor(private opts: InteriorModelOptions) { + this.groundMat = this.material(GROUND_VERT, GROUND_FRAG, { + side: BackSide, + uniforms: { + uColor: { value: null }, uHeight: { value: null }, + uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] }, + uMppKm: { value: L / RASTER_WIDTH }, + }, + }) + this.ground = this.buildGround() + this.meshes = [this.ground, ...this.buildCaps(), this.buildBeam()] + this.rasters = this.loadRasters() + } + + /** The eye, in the tower's frame. */ + setCamera(eye: Vector3): void { + for (const m of this.materials) (m.uniforms['uCam']!.value as Vector3).copy(eye) + } + + /** World to tower coordinates. Only the exterior, which turns the hull, needs it. */ + setFrame(worldToTower: Matrix4): void { + for (const m of this.materials) (m.uniforms['uFrame']!.value as Matrix4).copy(worldToTower) + } + + private material( + vertexShader: string, fragmentShader: string, + params: { side: typeof BackSide | typeof DoubleSide; uniforms: Record<string, { value: unknown }>; additive?: boolean }, + ): ShaderMaterial { + const m = new ShaderMaterial({ + vertexShader, fragmentShader, side: params.side, + uniforms: { ...params.uniforms, ...this.sharedUniforms() }, + ...(params.additive + ? { transparent: true, blending: AdditiveBlending, depthWrite: false } + : {}), + }) + this.materials.push(m) + return m + } + + private sharedUniforms(): Record<string, { value: unknown }> { + return { + uCam: { value: new Vector3() }, + uFrame: { value: new Matrix4() }, + uK: { value: new Vector3(...K_RGB) }, + uHaze: { value: new Color(HAZE_DAY) }, + uExposure: { value: 1 }, + uSeeThrough: { value: this.opts.seeThrough ? 1 : 0 }, + // Only the glazed caps read these, but every material carries them so the + // per-frame update can walk one list instead of knowing who wants what. + uSpin: { value: 0 }, + uStarGain: { value: 0 }, + // Only the ground declares these; carried here so one loop can set them. + uFrontZ: { value: 0 }, + uFrontWidth: { value: 24 }, + uBelowFront: { value: 1 }, + uAboveFront: { value: 1 }, + } + } + + private buildGround(): Mesh { + const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true) + g.rotateX(Math.PI / 2) + g.translate(0, 0, L / 2) + /** + * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs + * u around and v along. Deriving theta from vertex positions instead would smear + * a column of triangles across the image, because the seam vertices are + * duplicated and atan2 gives both the same angle (D-023). + * + * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle + * in the XY plane is t - pi/2; canon theta is measured from +X. + */ + const uv = g.attributes['uv']! + const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) + for (let i = 0; i < uv.count; i++) { + uv.setXY(i, uv.getY(i), uv.getX(i) - shift) + } + uv.needsUpdate = true + const mesh = new Mesh(g, this.groundMat) + // Named for the per-surface probe in the interior's diagnose(), which asks each + // one in turn whether it is the one that lost its colour. + mesh.name = 'ground' + return mesh + } + + /** + * Load a raster. + * + * `mipped` is the whole reason this takes an argument. The colour raster is read + * in the fragment shader, at every grazing angle the inside of a cylinder can + * offer, so it wants mipmaps and all the anisotropy the card will give. The + * height raster is read in the *vertex* shader, once per cell corner, at a fixed + * rate that has nothing to do with how the cell lands on screen — mips and + * anisotropy there are not merely wasted, they are a level the vertex stage has + * no way to choose between (see elevation()). So it is built with exactly one. + */ + private async loadRasters(): Promise<Texture | null> { + const load = (name: string, mipped: boolean): Promise<Texture> => + new Promise((resolve, reject) => { + new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => { + tex.wrapS = RepeatWrapping + tex.wrapT = RepeatWrapping + // uv.y is computed rather than read from the file's row order, so no flip. + tex.flipY = false + if (mipped) { + tex.anisotropy = this.opts.anisotropy + } else { + tex.generateMipmaps = false + tex.minFilter = LinearFilter + tex.magFilter = LinearFilter + tex.anisotropy = 1 + } + resolve(tex) + }, undefined, reject) + }) + try { + const [color, height] = await Promise.all([ + load(COLOR_RASTER, true), load(HEIGHT_RASTER, false), + ]) + this.groundMat.uniforms['uColor']!.value = color + this.groundMat.uniforms['uHeight']!.value = height + this.groundMat.needsUpdate = true + return height + } catch { + // No rasters yet. The shell still renders; it is simply blank. + return null + } + } + + /** + * Which caps are windows, and how they blend. Inside, a window is opaque to the + * renderer — the sky it shows is computed, and nothing is drawn behind it. From + * outside it has to be transparent, so it is sorted and blended with the scene + * that really is behind it: the land beyond the near cap, the collector and the + * stars beyond the far one. + */ + private windowMaterial(frag: string, tint: number, glazing: Texture): ShaderMaterial { + const m = this.material(FLAT_VERT, frag, { + side: DoubleSide, + uniforms: { uTint: { value: new Color(tint) }, uGlazing: { value: glazing } }, + }) + m.transparent = this.opts.seeThrough + return m + } + + /** + * The caps close the world at both ends. From the midpoint they are 270 km away + * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct + * and slightly unnerving result, and worth drawing precisely because it is. + * + * Both are windows (D-045, D-050). The far cap is set a kilometre into its own + * frame so the wall depth of D-044 has somewhere to be visible from; the sun cap + * is an annulus, because the beam enters through the hole in its middle. + */ + private buildCaps(): Mesh[] { + // The tint is a multiplier over the pattern, not the colour of the cap. + const sun = new Mesh( + new RingGeometry(BEAM_R, R, 128), + this.windowMaterial(SUN_CAP_FRAG, 0xffffff, sunCapGlazingTexture())) + sun.name = 'sunCap' + + const far = new Mesh( + new CircleGeometry(R, 128), + this.windowMaterial(GLAZED_CAP_FRAG, 0xbcc2cc, capGlazingTexture())) + far.name = 'farCap' + far.position.z = L + HULL_D + + // The reveal: a kilometre of bore standing between the end of the world and + // the glass. BackSide, because it is only ever seen from inside. + const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true) + bore.rotateX(Math.PI / 2) + bore.translate(0, 0, L + HULL_D / 2) + const reveal = new Mesh(bore, this.material(FLAT_VERT, REVEAL_FRAG, { + side: BackSide, uniforms: { uTint: { value: new Color(0x9aa0aa) } }, + })) + reveal.name = 'bore' + return [sun, far, reveal] + } + + private buildBeam(): Mesh { + const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true) + g.rotateX(Math.PI / 2) + g.translate(0, 0, L / 2) + const m = this.material(BEAM_VERT, BEAM_FRAG, { + side: DoubleSide, additive: true, + uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) } }, + }) + const beam = new Mesh(g, m) + beam.name = 'beam' + return beam + } +} diff --git a/src/views/interior.ts b/src/views/interior.ts index fe749e8..900b983 100644 --- a/src/views/interior.ts +++ b/src/views/interior.ts @@ -19,71 +19,24 @@ */ import type { Texture } from 'three' import { - AdditiveBlending, BackSide, CircleGeometry, Color, CylinderGeometry, DoubleSide, - LinearFilter, Mesh, PerspectiveCamera, RepeatWrapping, RingGeometry, - Scene, ShaderMaterial, TextureLoader, Vector3, WebGLRenderer, + Color, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer, } from 'three' import { - BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, COLLECTOR_DIAMETER_M, COLLECTOR_STANDOFF_M, - COLLECTOR_RIM_M, DAY_LENGTH_S, DAY_SWEEP_FRACTION, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, - BRIGHTNESS_TWILIGHT_W_M2, G0, HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, - RAYLEIGH_FLOOR_PER_M, - SPECIFIC_GAS_CONSTANT, TEMPERATURE_K, gravityAtHeight, + BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, DAY_LENGTH_S, DAY_SWEEP_FRACTION, + BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2, G0, + LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight, } from '../canon/constants.js' import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js' import type { Place } from '../world/schema.js' import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js' import { - MIRROR_UV_RADIUS, capGlazingTexture, sunCapGlazingTexture, -} from './cap-pattern.js' -import { - COLOR_RASTER, HEIGHT_RASTER, RASTER_HEIGHT, RASTER_HEIGHT_SPAN_M, - RASTER_MIN_HEIGHT_M, RASTER_WIDTH, -} from './raster.js' + HAZE_DAY, HAZE_DUSK, InteriorModel, K_GREEN, SEG_ALONG, SEG_AROUND, +} from './interior-model.js' +import { RASTER_HEIGHT_SPAN_M, RASTER_MIN_HEIGHT_M } from './raster.js' const KM = 1000 const R = RADIUS_M / KM const L = LENGTH_M / KM -const BEAM_R = BEAM_RADIUS_M / KM -/** Wall depth in km. The far cap's window is set this far into its own frame. */ -const HULL_D = HULL_DEPTH_M / KM -/** The collector, in km: 122 km of radius flying 320 km ahead of the sun cap. */ -const COLLECTOR_R = COLLECTOR_DIAMETER_M / KM / 2 -const COLLECTOR_Z = -COLLECTOR_STANDOFF_M / KM -/** Width of the sunlit edge structure, in km (D-054). */ -const COLLECTOR_RIM = COLLECTOR_RIM_M / KM - -/** - * Rayleigh extinction per kilometre at floor density, per channel. - * - * Canon quotes 550 nm (green). Scattering goes as lambda^-4, so red survives about - * twice as far as green and blue about half as far — which is the whole reason - * distant land in Tower goes blue rather than merely grey, and it costs nothing to - * carry three numbers instead of one. - */ -const K_GREEN = RAYLEIGH_FLOOR_PER_M * KM -const K_RGB: [number, number, number] = [ - K_GREEN * (550 / 650) ** 4, K_GREEN, K_GREEN * (550 / 440) ** 4, -] -/** Density falls as exp(-a(R^2 - s^2)) — the rotational profile, in 1/km^2. */ -const DENSITY_A = ((OMEGA_RAD_S ** 2 / (2 * SPECIFIC_GAS_CONSTANT * TEMPERATURE_K)) * KM ** 2) - -/** - * Airlight: the radiance the haze itself adds. It is deliberately NEUTRAL. - * - * The obvious move is to make it sky blue, and it is wrong: blue already scatters - * 2.4x harder than green in K_RGB, so a blue source colours the haze twice and - * every distant thing turns mauve regardless of what it is made of. With a neutral - * source the blue falls out of the physics, and the far side of the world stays - * recognisably forest and desert at the 78% canon says it should be. - */ -const HAZE_DAY = 0x9aa3ad -const HAZE_DUSK = 0x8d8792 - - -/** Shell tessellation. 0.7 km triangles: distant relief is real, your feet are not. */ -const SEG_AROUND = 512 -const SEG_ALONG = 768 /** * The five settings, darkest first. @@ -142,446 +95,6 @@ export interface InteriorCallbacks { onLook: () => void } -// ---------------------------------------------------------------------- shaders - -/** - * Transmittance from `a` to `b` through Tower's air, per channel. - * - * The integral has no closed form worth having — the exponent is a(s^2 - R^2) with - * s^2 quadratic along the ray — so it is sampled. The integrand is smooth and - * monotonic on each side of the ray's closest approach to the axis, so 24 midpoint - * samples land within a percent of the 512-sample version the instruments use, and - * the two agree on the canon table in design.md §1.8. - */ -const HAZE_GLSL = /* glsl */` -uniform vec3 uCam; -uniform vec3 uK; -uniform vec3 uHaze; -uniform float uExposure; -const float A = ${DENSITY_A.toExponential(8)}; -const float RR = ${(R * R).toFixed(4)}; - -vec3 towerTransmittance(vec3 from, vec3 to) { - vec3 d = to - from; - float len = length(d); - const int N = 24; - float tau = 0.0; - for (int i = 0; i < N; i++) { - float t = (float(i) + 0.5) / float(N); - vec2 p = from.xy + d.xy * t; - tau += exp(-A * (RR - dot(p, p))); - } - return exp(-uK * (tau * len / float(N))); -} -` - -const GROUND_VERT = /* glsl */` -#include <common> -#include <logdepthbuf_pars_vertex> -uniform sampler2D uHeight; -uniform vec2 uTexel; -uniform float uMppKm; -varying vec2 vUv; -varying vec3 vWorld; -varying vec3 vNormal2; - -/** - * The raster is stretched across a declared working range, not 0..anything. - * - * This fetch is in a *vertex* shader, and that is why uHeight is built with no - * mipmaps at all (loadRasters). A vertex shader has no derivatives, so there is - * nothing for texture2D to pick a mip level from — the level is left to the - * driver, and a driver is entitled to pick any of them. On one that picks badly - * the shell gets displaced by a downsampled height field: the terrain flattens - * into blocks with the cell seams showing as creases, and it comes back on a - * reload, which is what made it look intermittent rather than wrong. Naming the - * level here instead would be the obvious guard and is not available — the - * vertex-only texture2DLod does not survive Three's WebGL2 shader prefix — so - * the ambiguity is removed at the other end, by giving the sampler exactly one - * level to choose between. - */ -float elevation(vec2 at) { - return ${(RASTER_MIN_HEIGHT_M / KM).toFixed(6)} - + texture2D(uHeight, at).r * ${(RASTER_HEIGHT_SPAN_M / KM).toFixed(6)}; -} - -void main() { - vUv = uv; - vec3 radial = normalize(vec3(position.xy, 0.0)); - vec3 up = -radial; - // Spinward: z-hat x s-hat. The floor's other tangent is the axis itself. - vec3 spinward = vec3(-radial.y, radial.x, 0.0); - vec3 deepward = vec3(0.0, 0.0, 1.0); - - float h = elevation(uv); - // Central differences on the raster, in km of rise per km of run. - float dz = (elevation(uv + vec2(uTexel.x, 0.0)) - elevation(uv - vec2(uTexel.x, 0.0))) - / (2.0 * uMppKm); - float dt = (elevation(uv + vec2(0.0, uTexel.y)) - elevation(uv - vec2(0.0, uTexel.y))) - / (2.0 * uMppKm); - vNormal2 = normalize(up - deepward * dz - spinward * dt); - - vec3 p = position + up * h; - vWorld = p; - gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); - #include <logdepthbuf_vertex> -} -` - -const GROUND_FRAG = /* glsl */` -#include <common> -#include <logdepthbuf_pars_fragment> -uniform sampler2D uColor; -uniform float uFrontZ; -uniform float uFrontWidth; -uniform float uBelowFront; -uniform float uAboveFront; -varying vec2 vUv; -varying vec3 vWorld; -varying vec3 vNormal2; -${HAZE_GLSL} - -/** Cheap value noise, to keep the ground from being flat colour underfoot. */ -float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); -} -float vnoise(vec2 p) { - vec2 i = floor(p), f = fract(p); - f = f * f * (3.0 - 2.0 * f); - return mix(mix(hash(i), hash(i + vec2(1, 0)), f.x), - mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), f.x), f.y); -} - -void main() { - #include <logdepthbuf_fragment> - // The raster is an sRGB image and every uniform Colour here is already linear, - // so the texture is the one thing that has to be brought into the working space. - // Three inserts that decode into its own materials and never into a custom one. - vec3 base = pow(texture2D(uColor, vUv).rgb, vec3(2.2)); - - /** - * The raster is 264 m per pixel, so within a few hundred metres of the observer - * one texel fills the screen. This is texture, not terrain: it breaks up the - * flat block without claiming there is anything there. It fades out past 4 km, - * where the raster is carrying real detail again. - */ - float near = 1.0 - smoothstep(0.4, 4.0, distance(uCam, vWorld)); - // Unrolled surface coordinates. World xyz shears the pattern into radial streaks - // exactly where the ground is most grazing, which is most of the view. - vec2 surf = vec2(vWorld.z, atan(vWorld.y, vWorld.x) * ${R.toFixed(3)}); - // Rotated and off-harmonic octaves. Axis-aligned value noise at one scale reads - // as a woven diamond lattice, which is worse than the flat colour it replaced. - vec2 rot = vec2(surf.x * 0.86 - surf.y * 0.51, surf.x * 0.51 + surf.y * 0.86); - float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45; - base *= 1.0 + (grain - 0.5) * 0.12 * near; - - // The beam is a line source on the axis, so the light comes from straight up. - vec3 radial = normalize(vec3(vWorld.xy, 0.0)); - float lambert = clamp(dot(vNormal2, -radial), 0.0, 1.0); - /** - * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is - * one beam — but the diffusers hand the light down the axis, so the *ground* - * lights as a line running deepward at 225 m/s. Below the front and above it are - * different times of day, and this is the only place in Tower where that is true. - * - * Outside dawn and dusk both sides are 1.0 and this costs a mix. - */ - float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z); - float daylight = mix(uBelowFront, uAboveFront, side); - - // The rest of the world is lit land, and lit land is what fills the shadows. - vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight; - - vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0); - #include <colorspace_fragment> -} -` - -const FLAT_VERT = /* glsl */` -#include <common> -#include <logdepthbuf_pars_vertex> -varying vec3 vWorld; -void main() { - vWorld = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - #include <logdepthbuf_vertex> -} -` - -/** - * The sky through the far cap window (§1.4, D-045). - * - * Sampled by view direction, in an azimuthal-equidistant projection about +z. - * - * The projection is the whole trick. +z is the spin axis and therefore the - * celestial pole, so polar angle becomes radius and azimuth becomes angle, and - * adding the spin phase to the azimuth *is* the pinwheel: every star runs a circle - * about the axis once every 7 min 29 s and the one on the axis does not move. - * - * Two projections were wrong before this one. Gnomonic (xy/z) blows up as the ray - * goes perpendicular to the axis, which is the ordinary grazing view from the foot - * of the cap. Painting the field on the cap plane instead is stable but has no - * fixed angular scale — from a kilometre away the whole 60° field lands inside one - * star cell, so the sky comes out empty. Equidistant has neither failure: it is - * defined for every direction but exactly sunward, and stars stay the same angular - * size however far away the window is, because they are at infinity and that is - * what being at infinity means. - */ -const STAR_GLSL = /* glsl */` -uniform float uSpin; -uniform float uStarGain; - -float starHash(vec2 p) { - vec3 q = fract(vec3(p.xyx) * 0.1031); - q += dot(q, q.yzx + 33.33); - return fract((q.x + q.y) * q.z); -} -vec2 starHash2(vec2 p) { - vec3 q = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973)); - q += dot(q, q.yzx + 33.33); - return fract((q.xx + q.yz) * q.zy); -} - -// Cells per radian. ~1.2 deg cells, which at 22% occupancy lands near the naked-eye -// sky's real density of one star per 7 square degrees. -const float STAR_SCALE = 46.0; - -vec3 starField(vec3 dir) { - vec3 d = normalize(dir); - float th = acos(clamp(d.z, -1.0, 1.0)); - float ph = atan(d.y, d.x) + uSpin; - vec2 p = vec2(cos(ph), sin(ph)) * th * STAR_SCALE; - vec2 cell = floor(p); - vec2 f = p - cell; - /** - * How wide to draw a star, and the whole reason the sky used to flicker. - * - * A star was a spike: brightness (1 - d/0.09)^4, which on a retina panel is a - * core about a pixel across. The sky is sampled once per pixel at the pixel's - * centre, so what got drawn was that spike read at one arbitrary point on it — - * and half a pixel of head movement moved the reading from the peak to a third - * of it. Every star in the field did that independently, every frame. That is - * flicker, and it is wrong twice over: vacuum has no air to twinkle through, - * and the interior is deliberately still. - * - * The fix is to stop drawing a shape smaller than the thing sampling it. The - * star becomes a gaussian one pixel wide — sigma tied to the pixel footprint, - * so it is one pixel on any display and at any zoom — which is what a point - * source genuinely looks like through any real optic: an Airy blob the width of - * the aperture's resolution, not a mathematical point. A curve that smooth - * barely changes between neighbouring samples, so the star sits still and the - * eye reads it as a point anyway. - * - * The footprint is measured from the direction vector, not from p: p carries - * the branch cut of that atan, and a derivative across the seam is a garbage - * line down the middle of the sky. |dd/dpixel| on a unit vector is the pixel's - * angular size in radians, which is the same quantity without the seam. - * - * Clamped at the top because the cell search below is only 3x3: a sigma near - * the cell size would want light from stars this loop never visits. - */ - float sg = clamp(length(fwidth(d)) * STAR_SCALE * 0.85, 1e-4, 0.3); - float inv2s2 = 0.5 / (sg * sg); - vec3 sum = vec3(0.0); - for (int j = -1; j <= 1; j++) { - for (int i = -1; i <= 1; i++) { - vec2 o = vec2(float(i), float(j)); - vec2 g = cell + o; - float mag = starHash(g); - if (mag < 0.78) continue; // most of the sky is empty - vec2 at = o + starHash2(g + 11.7) - f; - float b = exp(-dot(at, at) * inv2s2) * ((mag - 0.78) / 0.22); - // A few warm ones among the cool. Without it the field reads as noise. - sum += b * mix(vec3(0.72, 0.80, 1.0), vec3(1.0, 0.86, 0.68), starHash(g + 5.1)); - } - } - /** - * The pole star, placed rather than left to the hash. §1.4 promises one - * motionless star at the centre of the pinwheel; it is the fixed point the whole - * phenomenon is read against, and a sky that happened not to roll one there - * would be missing the only part of it anybody in Tower would have named. - * - * It lands at the observer's own radius on the window — the axis is horizontal - * from the floor, so this is a star on the horizon, not overhead. - */ - float pole = th * STAR_SCALE; - // Drawn a little wider than the rest, as the one star that is placed rather than - // rolled: it is the fixed point the pinwheel is read against, and it has to be - // findable before anything can be seen to turn about it. - float ps = sg * 1.6; - sum += exp(-pole * pole * 0.5 / (ps * ps)) * vec3(1.0, 0.95, 0.86) * 1.6; - return sum * 2.6 * uStarGain; -} -` - -/** The bore between the world's end and the glazing. Plain structure, hazed. */ -const REVEAL_FRAG = /* glsl */` -#include <common> -#include <logdepthbuf_pars_fragment> -uniform vec3 uTint; -varying vec3 vWorld; -${HAZE_GLSL} -void main() { - #include <logdepthbuf_fragment> - vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(uTint * uExposure * T + uHaze * uExposure * (1.0 - T), 1.0); - #include <colorspace_fragment> -} -` - -/** - * The sun cap: also a window, and what fills it is the back of the collector. - * - * §1.4 gave this cap "heavy glare management" and no view, on the assumption that - * it faces the sun. It does not face the sun — it faces the **collector**, which is - * 41.8° across from here against the sun's 0.53° and sits squarely between the two. - * Tower is inside its umbra permanently, so no direct sunlight has ever reached - * this cap and there is nothing here to manage (D-050). - * - * What there is instead is a 244 km disc of unlit structure. Its front is a mirror - * pointed at the sun; its back radiates the collector's own waste heat at about - * 245 K, which is infrared and therefore black. So the sky through this window is a - * black disc with stars around the edge of it — and the disc does not turn with - * them, because the collector flies in formation rather than spinning. - */ -const SUN_CAP_FRAG = /* glsl */` -#include <common> -#include <logdepthbuf_pars_fragment> -uniform vec3 uTint; -uniform sampler2D uGlazing; -varying vec3 vWorld; -${HAZE_GLSL} -${STAR_GLSL} -void main() { - #include <logdepthbuf_fragment> - vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; - vec4 frame = texture2D(uGlazing, uv); - vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure; - - vec3 dir = normalize(vWorld - uCam); - vec3 sky = starField(dir); - /** - * Cast on through the window to the collector's plane. Anything landing on the - * disc sees its unlit back, which is as black as the sky is not — and the edge, - * which is the opposite: structure standing proud of the mirror plane with the - * sun striking it side-on (D-054). So the collector reads the way any backlit - * thing does, as a hole in the stars with a lit rim, rather than as an absence. - */ - float t = (${COLLECTOR_Z.toFixed(1)} - vWorld.z) / min(dir.z, -1e-4); - if (t > 0.0) { - float r = length(vWorld.xy + dir.xy * t); - sky = mix(sky, vec3(0.0), step(r, ${COLLECTOR_R.toFixed(2)})); - // Tight core for the structure itself, and a wide dim skirt for the scatter - // around it. One exponential alone reads as a drawn outline rather than light. - float d = abs(r - ${COLLECTOR_R.toFixed(2)}); - float rim = exp(-d / ${COLLECTOR_RIM.toFixed(3)}) - + 0.10 * exp(-d / ${(COLLECTOR_RIM * 3).toFixed(3)}); - /** - * Not scaled by uExposure — the sun does not dim when Tower banks its beam. - * It does fade as the eye adapts, like the stars, but from a much higher floor: - * this is sunlit structure, and it stays visible at noon where a star does not. - */ - sky += vec3(1.0, 0.93, 0.78) * rim * (0.3 + 0.7 * uStarGain) * 0.42; - } - - vec3 face = mix(sky, mullion, frame.a); - vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0); - #include <colorspace_fragment> -} -` - -/** - * The far cap: a window, and the frame holding it. - * - * `uGlazing`'s alpha is the mask — opaque where there is a mullion, clear where - * there is a pane. Through a pane is deep anti-sun space (§1.4): black, with stars - * in it, and the haze in front doing the rest. By day the airlight term swamps - * everything and the cap is simply blue, which is what §1.5 already said would - * happen to anything 540 km away. At night the airlight collapses with the beam - * and what is left is the sky. - */ -const GLAZED_CAP_FRAG = /* glsl */` -#include <common> -#include <logdepthbuf_pars_fragment> -uniform vec3 uTint; -uniform sampler2D uGlazing; -varying vec3 vWorld; -${HAZE_GLSL} -${STAR_GLSL} -void main() { - #include <logdepthbuf_fragment> - vec2 uv = vWorld.xy / ${(2 * R).toFixed(1)} + 0.5; - vec4 frame = texture2D(uGlazing, uv); - vec3 mullion = pow(frame.rgb, vec3(2.2)) * uTint * uExposure; - /** - * The stars are NOT scaled by uExposure. Nothing Tower does to its own beam - * changes how bright Vega is — the exposure models the beam lighting the world, - * and the sky outside is not lit by it. uStarGain carries the other half of the - * same fact: the eye adapts, so the stars are there at noon and unseeable. - */ - vec3 sky = starField(vWorld - uCam); - vec3 face = mix(sky, mullion, frame.a); - /** - * The bullseye mirror (D-048) is returning 1.7 MW/m² back up the column, so it - * is lit by the beam rather than by the room: it blazes at noon, banks at night - * and goes out entirely when the beam does. The stars behind it do none of - * those, which is the whole difference between the two things in this shader. - * - * It is on the axis, but that is NOT the centre of the pinwheel: starField turns - * about the direction parallel to the axis, which lands on the window at the - * camera's own radius rather than at the cap's middle. The two are ~10° apart - * from the floor and coincide only for an observer standing on the axis. - */ - float rc = length(vWorld.xy) / ${R.toFixed(1)}; - float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)}, - ${MIRROR_UV_RADIUS.toFixed(5)}, rc); - face += mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6; - vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(face * T + uHaze * uExposure * (1.0 - T), 1.0); - #include <colorspace_fragment> -} -` - - -const BEAM_VERT = /* glsl */` -#include <common> -#include <logdepthbuf_pars_vertex> -varying vec3 vWorld; -varying vec3 vNormal2; -void main() { - vWorld = (modelMatrix * vec4(position, 1.0)).xyz; - vNormal2 = normalize(mat3(modelMatrix) * normal); - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - #include <logdepthbuf_vertex> -} -` - -/** - * The beam is glowing air, not a surface, so it is brightest where the sightline - * passes through the most of it — down the middle — and fades at the limb. A tube - * shaded the usual way does the exact opposite and reads as a chrome pipe. - */ -const BEAM_FRAG = /* glsl */` -#include <common> -#include <logdepthbuf_pars_fragment> -uniform vec3 uBeam; -varying vec3 vWorld; -varying vec3 vNormal2; -${HAZE_GLSL} -void main() { - #include <logdepthbuf_fragment> - vec3 view = normalize(vWorld - uCam); - // Additive white clips long before the limb, so the falloff has to start early - // or the shaft renders as a hard-edged slab instead of a glowing rod. - float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1); - vec3 T = towerTransmittance(uCam, vWorld); - gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0); - #include <colorspace_fragment> -} -` - // ------------------------------------------------------------ flight recorder /** @@ -625,9 +138,10 @@ export class InteriorView { private renderer: WebGLRenderer private scene = new Scene() private camera: PerspectiveCamera - private ground: Mesh private groundMat: ShaderMaterial - private shared: ShaderMaterial[] = [] + private shared: ShaderMaterial[] + /** Ground, caps, bore and beam — shared with the exterior's look inside (D-060). */ + private model: InteriorModel private running = false private lastFrame = 0 /** @@ -699,11 +213,19 @@ export class InteriorView { */ this.camera = new PerspectiveCamera(this.fovDeg, 1, 0.0005, 1500) - this.groundMat = this.buildGroundMaterial() - this.ground = this.buildGround() - this.scene.add(this.ground) - this.buildCaps() - this.buildBeam() + this.model = new InteriorModel({ + seeThrough: false, anisotropy: this.renderer.capabilities.getMaxAnisotropy(), + }) + this.shared = this.model.materials + this.groundMat = this.model.groundMat + this.scene.add(...this.model.meshes) + void this.model.rasters.then((height) => { + if (!height) return + this.readHeights(height) + this.place() + // The panel's elevation readout was drawn before any of this existed. + this.cb.onLook() + }) this.restoreState() this.applyLighting() @@ -747,113 +269,6 @@ export class InteriorView { // ------------------------------------------------------------------ geometry - private buildGroundMaterial(): ShaderMaterial { - const m = new ShaderMaterial({ - vertexShader: GROUND_VERT, - fragmentShader: GROUND_FRAG, - side: BackSide, - uniforms: { - uColor: { value: null }, uHeight: { value: null }, - uTexel: { value: [1 / RASTER_WIDTH, 1 / RASTER_HEIGHT] }, - uMppKm: { value: L / RASTER_WIDTH }, - ...this.sharedUniforms(), - }, - }) - this.shared.push(m) - void this.loadRasters(m) - return m - } - - private sharedUniforms(): Record<string, { value: unknown }> { - return { - uCam: { value: new Vector3() }, - uK: { value: new Vector3(...K_RGB) }, - uHaze: { value: new Color(HAZE_DAY) }, - uExposure: { value: 1 }, - // Only the glazed cap reads these, but every material carries them so the - // per-frame update can walk one list instead of knowing who wants what. - uSpin: { value: 0 }, - uStarGain: { value: 0 }, - // Only the ground declares these; carried here so one loop can set them. - uFrontZ: { value: 0 }, - uFrontWidth: { value: 24 }, - uBelowFront: { value: 1 }, - uAboveFront: { value: 1 }, - } - } - - private buildGround(): Mesh { - const g = new CylinderGeometry(R, R, L, SEG_AROUND, SEG_ALONG, true) - g.rotateX(Math.PI / 2) - g.translate(0, 0, L / 2) - /** - * The rasters run x = z and y = theta from the map's cut; Three's cylinder runs - * u around and v along. Deriving theta from vertex positions instead would smear - * a column of triangles across the image, because the seam vertices are - * duplicated and atan2 gives both the same angle (D-023). - * - * Three builds the ring as x = R sin t, z = R cos t, so after rotateX the angle - * in the XY plane is t - pi/2; canon theta is measured from +X. - */ - const uv = g.attributes['uv']! - const shift = 0.25 + mapThetaOrigin() / (2 * Math.PI) - for (let i = 0; i < uv.count; i++) { - uv.setXY(i, uv.getY(i), uv.getX(i) - shift) - } - uv.needsUpdate = true - const mesh = new Mesh(g, this.groundMat) - // Named for the per-surface probe in diagnose(), which asks each one in turn - // whether it is the one that lost its colour. - mesh.name = 'ground' - return mesh - } - - /** - * Load a raster. - * - * `mipped` is the whole reason this takes an argument. The colour raster is read - * in the fragment shader, at every grazing angle the inside of a cylinder can - * offer, so it wants mipmaps and all the anisotropy the card will give. The - * height raster is read in the *vertex* shader, once per cell corner, at a fixed - * rate that has nothing to do with how the cell lands on screen — mips and - * anisotropy there are not merely wasted, they are a level the vertex stage has - * no way to choose between (see elevation()). So it is built with exactly one. - */ - private async loadRasters(m: ShaderMaterial): Promise<void> { - const load = (name: string, mipped: boolean): Promise<Texture> => - new Promise((resolve, reject) => { - new TextureLoader().load(`${import.meta.env.BASE_URL}${name}`, (tex) => { - tex.wrapS = RepeatWrapping - tex.wrapT = RepeatWrapping - // uv.y is computed rather than read from the file's row order, so no flip. - tex.flipY = false - if (mipped) { - tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy() - } else { - tex.generateMipmaps = false - tex.minFilter = LinearFilter - tex.magFilter = LinearFilter - tex.anisotropy = 1 - } - resolve(tex) - }, undefined, reject) - }) - try { - const [color, height] = await Promise.all([ - load(COLOR_RASTER, true), load(HEIGHT_RASTER, false), - ]) - m.uniforms['uColor']!.value = color - m.uniforms['uHeight']!.value = height - m.needsUpdate = true - this.readHeights(height) - this.place() - // The panel's elevation readout was drawn before any of this existed. - this.cb.onLook() - } catch { - // No rasters yet. The shell still renders; it is simply blank. - } - } - /** * Read the height raster back onto the CPU. * @@ -929,87 +344,6 @@ export class InteriorView { return this.groundKm(this.at) * KM } - private glazedCapMaterial(): ShaderMaterial { - const m = new ShaderMaterial({ - vertexShader: FLAT_VERT, fragmentShader: GLAZED_CAP_FRAG, side: DoubleSide, - uniforms: { - uTint: { value: new Color(0xbcc2cc) }, - uGlazing: { value: capGlazingTexture() }, - ...this.sharedUniforms(), - }, - }) - this.shared.push(m) - return m - } - - private sunCapMaterial(): ShaderMaterial { - const m = new ShaderMaterial({ - vertexShader: FLAT_VERT, fragmentShader: SUN_CAP_FRAG, side: DoubleSide, - uniforms: { - uTint: { value: new Color(0xffffff) }, - uGlazing: { value: sunCapGlazingTexture() }, - ...this.sharedUniforms(), - }, - }) - this.shared.push(m) - return m - } - - private revealMaterial(tint: number): ShaderMaterial { - const m = new ShaderMaterial({ - vertexShader: FLAT_VERT, fragmentShader: REVEAL_FRAG, side: BackSide, - uniforms: { uTint: { value: new Color(tint) }, ...this.sharedUniforms() }, - }) - this.shared.push(m) - return m - } - - /** - * The caps close the world at both ends. From the midpoint they are 270 km away - * and 4% transmitted (§1.8), so they are all but pure haze — which is the correct - * and slightly unnerving result, and worth drawing precisely because it is. - * - * They are not the same object any more. The sun cap faces the sun and the - * collector and keeps its checker; the far cap looks into deep anti-sun space and - * is glazed (D-045), set a kilometre into its own frame so the wall depth of - * D-044 has somewhere to be visible from. - */ - private buildCaps(): void { - // The tint is a multiplier over the pattern, not the colour of the cap. - const sun = new Mesh(new RingGeometry(BEAM_R, R, 128), this.sunCapMaterial()) - sun.name = 'sunCap' - this.scene.add(sun) - - const far = new Mesh(new CircleGeometry(R, 128), this.glazedCapMaterial()) - far.name = 'farCap' - far.position.z = L + HULL_D - this.scene.add(far) - - // The reveal: a kilometre of bore standing between the end of the world and - // the glass. BackSide, because it is only ever seen from inside. - const bore = new CylinderGeometry(R, R, HULL_D, 128, 1, true) - bore.rotateX(Math.PI / 2) - bore.translate(0, 0, L + HULL_D / 2) - const reveal = new Mesh(bore, this.revealMaterial(0x9aa0aa)) - reveal.name = 'bore' - this.scene.add(reveal) - } - - private buildBeam(): void { - const g = new CylinderGeometry(BEAM_R, BEAM_R, L, 48, 1, true) - g.rotateX(Math.PI / 2) - g.translate(0, 0, L / 2) - const m = new ShaderMaterial({ - vertexShader: BEAM_VERT, fragmentShader: BEAM_FRAG, - uniforms: { uBeam: { value: new Color(0xfff2d6).multiplyScalar(0.85) }, ...this.sharedUniforms() }, - transparent: true, blending: AdditiveBlending, depthWrite: false, side: DoubleSide, - }) - this.shared.push(m) - const beam = new Mesh(g, m) - beam.name = 'beam' - this.scene.add(beam) - } - // -------------------------------------------------------------------- camera /** Basis at the observer: up is toward the axis, deepward is +z, spinward is theta-hat. */