/* 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 } `; 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 // 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 } `; // 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)); }, }); })();