summaryrefslogtreecommitdiff
path: root/public/js/components/sky.js
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 05:43:24 -0800
committerskylar <me@casper>2026-11-01 05:43:24 -0800
commite69ad01e700e80fa959dfee08cd556ca0d777ffd (patch)
tree645fc9db2142e87a5ef302733a77ba2fec848d88 /public/js/components/sky.js
Yesod: self-hosted WebXR rooms
Start a room in a world and hang out in VR from the headset's browser: A-Frame + Networked-A-Frame over EasyRTC, with a coturn relay that can hide everyone's IP address. Ships with the Campfire, Temple, Cabin, Balcony and Test room worlds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'public/js/components/sky.js')
-rw-r--r--public/js/components/sky.js498
1 files changed, 498 insertions, 0 deletions
diff --git a/public/js/components/sky.js b/public/js/components/sky.js
new file mode 100644
index 0000000..49867bd
--- /dev/null
+++ b/public/js/components/sky.js
@@ -0,0 +1,498 @@
+/* global AFRAME, THREE, Astro */
+// The night sky over the site's location, live: the 9,096 stars of the Yale Bright Star
+// Catalog, the Milky Way from NASA's Deep Star Maps, and the Moon and planets
+// where they really are (js/astro.js). It always stays night: the Sun isn't
+// drawn, but it still lights the Moon, so the Moon shows its real phase, and a
+// bright Moon washes out the faint stars and lights the clearing a little.
+//
+// The sky is drawn as from a dark site; a city's own light would hide most of
+// it. The location comes from the server (SKY_LATITUDE, SKY_LONGITUDE), which
+// app.js passes in when it loads a world. The celestial sphere follows the head, since stars show no parallax, and
+// turns with the sidereal time every frame.
+//
+// To see another time, add ?sky=2027-07-04T23:00-07:00 to the page's address,
+// and &skyspeed=600 to run the sky 600 times faster.
+(function () {
+ const SKY_DIR = 'assets/sky/';
+ const RAD = Math.PI / 180;
+ const R_MILKY_WAY = 450;
+ const R_STARS = 400;
+ const R_PLANETS = 390;
+ const R_MOON = 380;
+ const EARTH_RADIUS_KM = 6378.14;
+ const MOON_RADIUS_KM = 1737.4;
+
+ const PLANET_COLORS = {
+ mercury: '#e8ddd0', venus: '#fff8e8', mars: '#ffb98a',
+ jupiter: '#fff0d8', saturn: '#f6e2b0', uranus: '#d8f4f6',
+ };
+
+ // The larger maria, to paint the Moon's face: selenographic latitude,
+ // longitude (east positive) and radius, in degrees.
+ const MARIA = [
+ [33, -16, 17], [28, 17, 10], [8, 31, 12], [17, 59, 8], [-8, 51, 9],
+ [-15, 35, 5.5], [-21, -17, 10], [-24, -39, 6], [13, 4, 4], [-10, -23, 6],
+ [25, -55, 16], [5, -50, 13], [7.5, -31, 6], [56, -10, 6], [57, 15, 6],
+ ];
+
+ // Night-sky colours (sRGB). The horizon colour also becomes the fog colour,
+ // so the forest fades into the sky. Moonlight adds a deep blue.
+ const ZENITH = '#000002';
+ const MOON_SKY = '#0a1330';
+ const MOON_GLOW = '#c4d0ff';
+
+ // Air near the horizon dims what's behind it: the airmass for a direction
+ // whose sine of altitude is h, and 0.25 magnitudes lost per airmass.
+ const AIRMASS = /* glsl */ `
+ float airmass(float h) {
+ h = max(h, 0.0);
+ return 1.0 / (h + 0.025 * exp(-11.0 * h));
+ }
+ `;
+
+ const STAR_VERTEX = /* glsl */ `
+ attribute float aMag;
+ attribute vec3 aColor;
+ attribute float aTwinkle;
+ uniform float uTime;
+ uniform float uPxScale;
+ uniform float uFaint;
+ uniform float uWash;
+ varying vec3 vColor;
+ ${AIRMASS}
+ void main() {
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vec3 dir = normalize(world.xyz - cameraPosition);
+ float x = airmass(dir.y);
+ // 1 for a star at the naked-eye limit (6.5) overhead, times uFaint.
+ float lum = uFaint * exp(0.921 * (6.5 - aMag - 0.25 * (x - 1.0)));
+ // Stars twinkle, more so low down; planets hardly do.
+ float tw = sin(uTime * 6.3 + position.x * 0.37) * sin(uTime * 2.9 + position.y * 0.53);
+ lum *= 1.0 + aTwinkle * min(x, 8.0) * 0.05 * tw;
+ lum = max(lum - uWash, 0.0);
+ vColor = aColor * min(lum, 1.0);
+ // Brighter than the screen can show: grow instead.
+ gl_PointSize = 2.2 * uPxScale * min(pow(max(lum, 1.0), 0.25), 7.0);
+ gl_Position = projectionMatrix * viewMatrix * world;
+ // Below the horizon or washed out: move it outside the clip volume.
+ if (dir.y < 0.0 || lum < 0.004) gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
+ }
+ `;
+
+ const STAR_FRAGMENT = /* glsl */ `
+ varying vec3 vColor;
+ void main() {
+ vec2 p = gl_PointCoord * 2.0 - 1.0;
+ float r2 = dot(p, p);
+ if (r2 > 1.0) discard;
+ gl_FragColor = vec4(vColor * exp(-3.0 * r2), 1.0);
+ #include <colorspace_fragment>
+ }
+ `;
+
+ const SKY_VERTEX = /* glsl */ `
+ varying vec2 vUv;
+ varying vec3 vDir;
+ void main() {
+ vUv = uv;
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vDir = world.xyz - cameraPosition;
+ gl_Position = projectionMatrix * viewMatrix * world;
+ }
+ `;
+
+ const SKY_FRAGMENT = /* glsl */ `
+ uniform sampler2D uMap;
+ uniform float uGain;
+ uniform float uSaturation;
+ uniform vec3 uZenith;
+ uniform vec3 uHorizon;
+ uniform vec3 uMoonDir;
+ uniform vec3 uMoonGlow;
+ varying vec2 vUv;
+ varying vec3 vDir;
+ ${AIRMASS}
+ void main() {
+ vec3 dir = normalize(vDir);
+ float up = step(0.0, dir.y);
+ vec3 col = mix(uZenith, uHorizon, pow(1.0 - max(dir.y, 0.0), 4.0));
+ vec3 mw = texture2D(uMap, vUv).rgb;
+ mw = mix(vec3(dot(mw, vec3(0.2126, 0.7152, 0.0722))), mw, uSaturation);
+ col += mw * uGain * exp(-0.23 * (airmass(dir.y) - 1.0)) * up;
+ // Moonlight scattered in the air: a bright aureole and a wide glow.
+ float theta = sqrt(max(2.0 * (1.0 - dot(dir, uMoonDir)), 0.0));
+ col += uMoonGlow * (0.8 * exp(-theta / 0.03) + 0.2 * exp(-theta / 0.35)) * up;
+ gl_FragColor = vec4(col, 1.0);
+ #include <colorspace_fragment>
+ // Dither, so the dark gradients don't band.
+ gl_FragColor.rgb += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
+ }
+ `;
+
+ const MOON_VERTEX = /* glsl */ `
+ varying vec3 vLocal;
+ varying vec3 vNormal;
+ varying vec3 vView;
+ void main() {
+ vLocal = normalize(position);
+ vec4 world = modelMatrix * vec4(position, 1.0);
+ vNormal = mat3(modelMatrix) * position;
+ vView = normalize(cameraPosition - world.xyz);
+ gl_Position = projectionMatrix * viewMatrix * world;
+ }
+ `;
+
+ const MOON_FRAGMENT = /* glsl */ `
+ uniform vec4 uMaria[${MARIA.length}];
+ uniform vec3 uSunDir;
+ uniform vec3 uTint;
+ varying vec3 vLocal;
+ varying vec3 vNormal;
+ varying vec3 vView;
+ void main() {
+ vec3 n = normalize(vNormal);
+ // Lommel-Seeliger scattering: why the full Moon looks like a flat disc.
+ float mu0 = dot(n, uSunDir);
+ float mu = max(dot(n, vView), 0.0);
+ float lit = mu0 > 0.0 ? 2.0 * mu0 / (mu0 + mu + 1e-3) : 0.0;
+ float sea = 0.0;
+ for (int i = 0; i < ${MARIA.length}; i++) {
+ float a = acos(clamp(dot(vLocal, uMaria[i].xyz), -1.0, 1.0));
+ sea = max(sea, 1.0 - smoothstep(uMaria[i].w * 0.7, uMaria[i].w, a));
+ }
+ // Faint earthshine on the dark part.
+ gl_FragColor = vec4(uTint * (1.0 - 0.42 * sea) * (0.95 * lit + 0.006), 1.0);
+ #include <colorspace_fragment>
+ }
+ `;
+
+ // A sphere in J2000 equatorial coordinates (pole on +z) whose texture
+ // coordinates follow the NASA map: plate carrée, 0h in the middle, right
+ // ascension increasing to the left.
+ function celestialSphere(radius, raSteps, decSteps) {
+ const positions = [];
+ const uvs = [];
+ const index = [];
+ for (let j = 0; j <= decSteps; j++) {
+ const v = j / decSteps;
+ const dec = (v - 0.5) * Math.PI;
+ for (let i = 0; i <= raSteps; i++) {
+ const u = i / raSteps;
+ const ra = (0.5 - u) * 2 * Math.PI;
+ positions.push(radius * Math.cos(dec) * Math.cos(ra), radius * Math.cos(dec) * Math.sin(ra), radius * Math.sin(dec));
+ uvs.push(u, v);
+ }
+ }
+ for (let j = 0; j < decSteps; j++) {
+ for (let i = 0; i < raSteps; i++) {
+ const a = j * (raSteps + 1) + i;
+ const c = a + raSteps + 1;
+ index.push(a, a + 1, c + 1, a, c + 1, c);
+ }
+ }
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
+ geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
+ geometry.setIndex(index);
+ return geometry;
+ }
+
+ // B-V colour index to a gentle tint: Ballesteros' formula for the
+ // temperature, then a blackbody's colour (Tanner Helland's fit), mostly
+ // washed back toward white, because the eye sees little colour in starlight.
+ function starColor(bv, out) {
+ const b = Math.min(2, Math.max(-0.4, bv));
+ const k = 46 * (1 / (0.92 * b + 1.7) + 1 / (0.92 * b + 0.62));
+ const clamp = (x) => Math.min(255, Math.max(0, x)) / 255;
+ const r = k <= 66 ? 1 : clamp(329.698727446 * Math.pow(k - 60, -0.1332047592));
+ const g = k <= 66 ? clamp(99.4708025861 * Math.log(k) - 161.1195681661) : clamp(288.1221695283 * Math.pow(k - 60, -0.0755148492));
+ const bl = k >= 66 ? 1 : k <= 19 ? 0 : clamp(138.5177312231 * Math.log(k - 10) - 305.0447927307);
+ const max = Math.max(r, g, bl);
+ return out.setRGB(0.5 + 0.5 * r / max, 0.5 + 0.5 * g / max, 0.5 + 0.5 * bl / max, THREE.SRGBColorSpace);
+ }
+
+ function toMatrix4(rows, out) {
+ const [a, b, c] = rows;
+ return out.set(a[0], a[1], a[2], 0, b[0], b[1], b[2], 0, c[0], c[1], c[2], 0, 0, 0, 0, 1);
+ }
+
+ const smooth = THREE.MathUtils.smoothstep;
+
+ AFRAME.registerComponent('sky', {
+ schema: {
+ latitude: { default: 51.4769 }, // Greenwich, until app.js sets the site's
+ longitude: { default: -0.0005 },
+ // Which way true South lies in the scene, as an angle like campsite.js's
+ // polar(): toward the pond, the most open view. The Moon, the planets
+ // and the Milky Way's core all pass through the southern sky here.
+ south: { default: 132 },
+ milkyWay: { default: 0.1 },
+ // How much a full Moon overhead adds to the two lights.
+ moonlight: { default: 0.08 },
+ ambientLight: { type: 'selector', default: '#ambient-light' },
+ moonLight: { type: 'selector', default: '#moon-light' },
+ // A world seen through one window can hold the Moon in it: the sky turns
+ // about the Moon to keep it at this bearing (an angle like `south`) and
+ // altitude (degrees), so the stars stay right around it and its phase is
+ // still real. moonSize makes it look bigger than it really is.
+ pinMoon: { default: false },
+ moonBearing: { default: 180 },
+ moonAltitude: { default: 10 },
+ moonSize: { default: 1 },
+ },
+
+ init() {
+ const params = new URLSearchParams(location.search);
+ const start = Date.parse(params.get('sky') || '');
+ this.speed = Number(params.get('skyspeed')) || 1;
+ this.realStart = Date.now();
+ this.skyStart = Number.isFinite(start) ? start : this.realStart;
+ this.bodiesAt = -Infinity;
+
+ const scene = this.el.sceneEl.object3D;
+ this.horizonBase = scene.fog ? scene.fog.color.clone() : new THREE.Color('#020306');
+ this.ambientBase = this.data.ambientLight ? this.data.ambientLight.getAttribute('light').intensity : 0;
+ this.moonLightBase = this.data.moonLight ? this.data.moonLight.getAttribute('light').intensity : 0;
+ this.moonLightHome = this.data.moonLight ? this.data.moonLight.object3D.position.clone() : null;
+
+ // Equator and equinox of the date; the catalogue and map are J2000.
+ this.celestial = new THREE.Group();
+ this.j2000 = new THREE.Group();
+ this.celestial.matrixAutoUpdate = this.j2000.matrixAutoUpdate = false;
+ this.celestial.add(this.j2000);
+ scene.add(this.celestial);
+
+ const s = this.data.south * RAD;
+ const north = new THREE.Vector3(-Math.sin(s), 0, -Math.cos(s));
+ const east = new THREE.Vector3(-north.z, 0, north.x);
+ this.basis = new THREE.Matrix4().makeBasis(north, east, new THREE.Vector3(0, 1, 0));
+ this.horizon = new THREE.Matrix4();
+ this.rotation = new THREE.Matrix4();
+ this.lightRotation = new THREE.Matrix4();
+ this.pin = new THREE.Matrix4();
+ this.pinQuat = new THREE.Quaternion();
+ this.moonNow = new THREE.Vector3();
+ const b = this.data.moonBearing * RAD;
+ const h = this.data.moonAltitude * RAD;
+ this.moonTarget = new THREE.Vector3(Math.sin(b) * Math.cos(h), Math.sin(h), Math.cos(b) * Math.cos(h));
+ this.head = new THREE.Vector3();
+ this.bufferSize = new THREE.Vector2();
+ this.sunEq = new THREE.Vector3(1, 0, 0);
+ this.moonEq = new THREE.Vector3(1, 0, 0);
+ this.moonLit = 0;
+
+ this.buildMilkyWay();
+ this.buildPoints();
+ this.buildMoon();
+ this.loadStars();
+ },
+
+ remove() {
+ this.el.sceneEl.object3D.remove(this.celestial);
+ },
+
+ buildMilkyWay() {
+ const texture = new THREE.TextureLoader().load(SKY_DIR + 'milkyway-4k.jpg');
+ texture.colorSpace = THREE.SRGBColorSpace;
+ this.skyUniforms = {
+ uMap: { value: texture },
+ uGain: { value: this.data.milkyWay },
+ uSaturation: { value: 0.6 },
+ uZenith: { value: new THREE.Color(ZENITH) },
+ uHorizon: { value: this.horizonBase.clone() },
+ uMoonDir: { value: new THREE.Vector3(0, -1, 0) },
+ uMoonGlow: { value: new THREE.Color(0, 0, 0) },
+ };
+ const sphere = new THREE.Mesh(celestialSphere(R_MILKY_WAY, 96, 48), new THREE.ShaderMaterial({
+ uniforms: this.skyUniforms, vertexShader: SKY_VERTEX, fragmentShader: SKY_FRAGMENT,
+ side: THREE.DoubleSide, depthWrite: false, fog: false,
+ }));
+ sphere.renderOrder = -10;
+ sphere.frustumCulled = false;
+ this.j2000.add(sphere);
+ },
+
+ buildPoints() {
+ this.pointUniforms = {
+ uTime: { value: 0 },
+ uPxScale: { value: 1 },
+ uFaint: { value: 0.14 },
+ uWash: { value: 0 },
+ };
+ this.pointMaterial = new THREE.ShaderMaterial({
+ uniforms: this.pointUniforms, vertexShader: STAR_VERTEX, fragmentShader: STAR_FRAGMENT,
+ transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, fog: false,
+ });
+
+ const names = Object.keys(PLANET_COLORS);
+ const geometry = new THREE.BufferGeometry();
+ const color = new THREE.Color();
+ geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(names.length * 3), 3));
+ geometry.setAttribute('aMag', new THREE.BufferAttribute(new Float32Array(names.length).fill(99), 1));
+ geometry.setAttribute('aColor', new THREE.Float32BufferAttribute(names.flatMap((n) => color.set(PLANET_COLORS[n]).toArray()), 3));
+ geometry.setAttribute('aTwinkle', new THREE.BufferAttribute(new Float32Array(names.length).fill(0.15), 1));
+ this.planets = new THREE.Points(geometry, this.pointMaterial);
+ this.planets.renderOrder = 2;
+ this.planets.frustumCulled = false;
+ this.celestial.add(this.planets);
+ },
+
+ buildMoon() {
+ this.moonUniforms = {
+ uMaria: {
+ value: MARIA.map(([lat, lon, r]) => new THREE.Vector4(
+ Math.cos(lat * RAD) * Math.sin(lon * RAD), Math.sin(lat * RAD), Math.cos(lat * RAD) * Math.cos(lon * RAD), r * RAD,
+ )),
+ },
+ uSunDir: { value: new THREE.Vector3(1, 0, 0) },
+ uTint: { value: new THREE.Color(1, 1, 1) },
+ };
+ // Local +z faces the observer and +y celestial north (see updateBodies),
+ // so selenographic east is +x: Mare Crisium on the right, as seen from
+ // the northern hemisphere.
+ this.moon = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), new THREE.ShaderMaterial({
+ uniforms: this.moonUniforms, vertexShader: MOON_VERTEX, fragmentShader: MOON_FRAGMENT,
+ transparent: true, blending: THREE.AdditiveBlending, fog: false,
+ }));
+ this.moon.renderOrder = 1; // before the stars, so it hides the ones behind it
+ this.moon.visible = false;
+ this.moon.frustumCulled = false;
+ this.celestial.add(this.moon);
+ },
+
+ loadStars() {
+ fetch(SKY_DIR + 'stars.bin')
+ .then((res) => {
+ if (!res.ok) throw new Error(res.statusText);
+ return res.arrayBuffer();
+ })
+ .then((buffer) => {
+ // Four floats a star: J2000 right ascension and declination (radians), V magnitude, B-V.
+ const data = new Float32Array(buffer);
+ const count = data.length / 4;
+ const position = new Float32Array(count * 3);
+ const color = new Float32Array(count * 3);
+ const mag = new Float32Array(count);
+ const c = new THREE.Color();
+ for (let i = 0; i < count; i++) {
+ const ra = data[i * 4];
+ const dec = data[i * 4 + 1];
+ position[i * 3] = R_STARS * Math.cos(dec) * Math.cos(ra);
+ position[i * 3 + 1] = R_STARS * Math.cos(dec) * Math.sin(ra);
+ position[i * 3 + 2] = R_STARS * Math.sin(dec);
+ mag[i] = data[i * 4 + 2];
+ starColor(data[i * 4 + 3], c).toArray(color, i * 3);
+ }
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute('position', new THREE.BufferAttribute(position, 3));
+ geometry.setAttribute('aMag', new THREE.BufferAttribute(mag, 1));
+ geometry.setAttribute('aColor', new THREE.BufferAttribute(color, 3));
+ geometry.setAttribute('aTwinkle', new THREE.BufferAttribute(new Float32Array(count).fill(1), 1));
+ const stars = new THREE.Points(geometry, this.pointMaterial);
+ stars.renderOrder = 2;
+ stars.frustumCulled = false;
+ this.j2000.add(stars);
+ })
+ .catch((err) => console.warn("Couldn't load the stars", err));
+ },
+
+ tick(time) {
+ const { latitude, longitude } = this.data;
+ const ms = this.skyStart + (Date.now() - this.realStart) * this.speed;
+ if (Math.abs(ms - this.bodiesAt) > 30000) this.updateBodies(ms);
+
+ this.skyRotation(ms, this.rotation);
+ const camera = this.el.sceneEl.camera;
+ if (camera) camera.getWorldPosition(this.head);
+ this.celestial.matrix.copy(this.rotation).setPosition(this.head);
+ this.celestial.matrixWorldNeedsUpdate = true;
+
+ this.moonUniforms.uSunDir.value.copy(this.sunEq).transformDirection(this.rotation);
+ this.skyUniforms.uMoonDir.value.copy(this.moonEq).transformDirection(this.rotation);
+ this.pointUniforms.uTime.value = time / 1000;
+ this.pointUniforms.uPxScale.value = THREE.MathUtils.clamp(this.pixelsPerRadian() / 1300, 1, 2.5);
+ },
+
+ /** Equatorial to scene directions at a time: the real sky, or turned to hold the Moon where pinMoon wants it. */
+ skyRotation(ms, out) {
+ out.multiplyMatrices(this.basis, toMatrix4(Astro.horizonMatrix(ms, this.data.latitude, this.data.longitude), this.horizon));
+ if (!this.data.pinMoon) return out;
+ this.moonNow.copy(this.moonEq).transformDirection(out);
+ return out.premultiply(this.pin.makeRotationFromQuaternion(this.pinQuat.setFromUnitVectors(this.moonNow, this.moonTarget)));
+ },
+
+ /** Roughly how many pixels a radian of sky covers, so stars look the same size on any screen. */
+ pixelsPerRadian() {
+ const renderer = this.el.sceneEl.renderer;
+ if (renderer.xr.isPresenting) {
+ const view = renderer.xr.getCamera().cameras[0];
+ return view ? view.viewport.w * 0.5 * view.projectionMatrix.elements[5] : 0;
+ }
+ const camera = this.el.sceneEl.camera;
+ if (!camera) return 0;
+ return renderer.getDrawingBufferSize(this.bufferSize).y * 0.5 * camera.projectionMatrix.elements[5];
+ },
+
+ updateBodies(ms) {
+ this.bodiesAt = ms;
+ const { latitude, longitude } = this.data;
+ const sky = Astro.bodies(ms, latitude, longitude);
+ const horizon = Astro.horizonMatrix(ms, latitude, longitude);
+ toMatrix4(Astro.precessionMatrix(ms), this.j2000.matrix);
+ this.j2000.matrixWorldNeedsUpdate = true;
+ this.sunEq.fromArray(sky.sun);
+ this.moonEq.fromArray(sky.moon.dir);
+
+ const position = this.planets.geometry.attributes.position;
+ const mag = this.planets.geometry.attributes.aMag;
+ sky.planets.forEach((p, i) => {
+ position.setXYZ(i, p.dir[0] * R_PLANETS, p.dir[1] * R_PLANETS, p.dir[2] * R_PLANETS);
+ mag.setX(i, p.magnitude);
+ });
+ position.needsUpdate = mag.needsUpdate = true;
+
+ // The Moon, at its true size, turned so its north points to the celestial pole.
+ // A pinned Moon is lit and coloured as if well up, even when it's held low
+ // in a window, and it's there whether or not it has really risen.
+ const moonAlt = this.data.pinMoon ? Math.max(this.data.moonAltitude, 30) : Astro.altAz(sky.moon.dir, horizon).alt;
+ const toUs = this.moonEq.clone().negate();
+ const up = new THREE.Vector3(0, 0, 1).addScaledVector(this.moonEq, -this.moonEq.z).normalize();
+ const right = new THREE.Vector3().crossVectors(up, toUs);
+ this.moon.quaternion.setFromRotationMatrix(new THREE.Matrix4().makeBasis(right, up, toUs));
+ this.moon.position.copy(this.moonEq).multiplyScalar(R_MOON);
+ this.moon.scale.setScalar(this.data.moonSize * R_MOON * Math.tan(Math.asin(MOON_RADIUS_KM / (sky.moon.dist * EARTH_RADIUS_KM))));
+ this.moon.visible = moonAlt > -0.5;
+ // Low down, the Moon is dimmer and orange.
+ const high = smooth(moonAlt, 0, 20);
+ this.moonUniforms.uTint.value.setRGB(1, 0.62 + 0.36 * high, 0.36 + 0.58 * high).multiplyScalar(0.55 + 0.45 * high);
+
+ // How much the Moon lights things: 1 for a full Moon well up, about a
+ // third at quarter (by eye, not by light meter), nothing once it's set.
+ const flux = Math.pow(10, -0.4 * (sky.moon.magnitude + 12.73));
+ const m = Math.sqrt(flux) * smooth(moonAlt, -0.5, 12);
+ this.moonLit = m;
+
+ const moonSky = new THREE.Color(MOON_SKY).multiplyScalar(m);
+ this.skyUniforms.uHorizon.value.copy(this.horizonBase).add(moonSky);
+ this.skyUniforms.uZenith.value.set(ZENITH).add(moonSky.multiplyScalar(0.6));
+ this.skyUniforms.uMoonGlow.value.set(MOON_GLOW).multiplyScalar(0.05 * m);
+ this.skyUniforms.uGain.value = this.data.milkyWay * (1 - 0.75 * m);
+ this.pointUniforms.uWash.value = 0.5 * m;
+ const fog = this.el.sceneEl.object3D.fog;
+ if (fog) fog.color.copy(this.skyUniforms.uHorizon.value);
+
+ this.updateLights(m);
+ },
+
+ updateLights(m) {
+ const { ambientLight, moonLight, moonlight } = this.data;
+ if (ambientLight) ambientLight.setAttribute('light', 'intensity', this.ambientBase + moonlight * 0.5 * m);
+ if (!moonLight) return;
+ moonLight.setAttribute('light', 'intensity', this.moonLightBase + moonlight * m);
+ // Shine from the Moon while it's up.
+ const toMoon = this.moonEq.clone().transformDirection(this.skyRotation(this.bodiesAt, this.lightRotation));
+ const home = this.moonLightHome.clone().normalize();
+ moonLight.object3D.position.copy(home.lerp(toMoon, Math.min(1, m * 4)).normalize().multiplyScalar(40));
+ },
+ });
+})();