// globe.js — 3D globe: textured unit sphere, orbit camera (north up), analytic picking import * as THREE from 'three'; import { GlobeControls } from './globecontrols.js'; import { SkyDome, atmosphere, HAZE_MAX } from './atmosphere.js'; import { clampPose, eyeOf, lift, kmPerPxAt, eyeAltitude, rangeForAltitude, R_M } from './camera.js'; import { sphereMesh, toVec, toLatLon, R_KM } from './geo.js'; import { globeSegments, drapedSegments } from './overlay_geom.js'; import { LineSet } from './lines.js'; import { SHADE_GLSL, shadeUniforms, applyOver, applySun } from './shading.js'; import { MIN_ALT, MAX_ALT } from './limits.js'; export { MIN_ALT, MAX_ALT }; export const BACKGROUND = 0x10121a; const DRAPE_ALT = 0.05; // below this eye altitude (planet radii) lines lie on the 3D ground const TRENCH_KM = 12; // below the deepest sea floor (≈ −11 km) const VERT = /* glsl */ ` varying vec2 vUv; varying vec3 vNormal; varying vec3 vPos; void main() { vUv = uv; vPos = position; vNormal = normalize(normalMatrix * position); gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`; const FRAG = /* glsl */ `${SHADE_GLSL} varying vec2 vUv; varying vec3 vNormal; varying vec3 vPos; void main() { gl_FragColor = vec4(shadeColor(vUv, vPos, 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0)), 1.0); }`; export class GlobeView { constructor(renderer, onChange) { this.kind = 'globe'; this.renderer = renderer; this.w = 1; this.h = 1; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(BACKGROUND); this.camera = new THREE.PerspectiveCamera(35, 1, 0.01, 10); const m = sphereMesh(256, 512); const geom = new THREE.BufferGeometry(); geom.setAttribute('position', new THREE.BufferAttribute(m.positions, 3)); geom.setAttribute('uv', new THREE.BufferAttribute(m.uvs, 2)); geom.setIndex(new THREE.BufferAttribute(m.indices, 1)); this.shade = shadeUniforms(); this.material = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, uniforms: this.shade }); this.base = new THREE.Mesh(geom, this.material); this.scene.add(this.base); this.lines = new LineSet(this.scene); this.raw = new Map(); // id → {lines, color}: kept so they can be laid on the 3D ground this.drape = null; // the current draping frame, or null (lines at sea level) this.horizon = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0); // keeps only the cap the camera can see this.lines.setClip([this.horizon]); renderer.localClippingEnabled = true; this.sphere = new THREE.Sphere(new THREE.Vector3(), 1); this.raycaster = new THREE.Raycaster(); this.dome = new SkyDome(this.scene, BACKGROUND); this.onChange = onChange; this.terrain = 0; // 3D exaggeration; 0 = flat tiles (the app sets it) this.groundU = 0; // ground under the eye, planet radii (for the near plane) this.seaFloor = false; // Water off: the ground may lie below 0 m (the app sets it) this.pose = clampPose({ lat: 20, lon: 0, range: 2 }); this.controls = new GlobeControls(this, renderer.domElement); this.#place(); } activate() { this.controls.enabled = true; } deactivate() { this.controls.enabled = false; } resize(w, h) { this.w = w; this.h = h; this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); this.#tune(); } setTexture(tex) { this.material.uniforms.map.value = tex; this.material.uniforms.hasMap.value = tex ? 1 : 0; } setOver(tex, op) { applyOver(this.shade, tex, op); } setSun(v) { applySun(this.shade, v); this.#air(); } setLines(id, lines, color) { this.raw.set(id, { lines, color }); this.#line(id); } removeLines(id) { this.raw.delete(id); this.lines.remove(id); } #line(id) { const { lines, color } = this.raw.get(id); if (this.drape) this.lines.set(id, drapedSegments(lines, this.drape), color, this.drape.origin); else this.lines.set(id, globeSegments(lines, 1.0008, 1), color); } updateDrape(now = performance.now()) { // each frame: re-lay lines on the 3D ground when heights or the view change const p = this.camera.position, d = p.length(), alt = d - 1, dr = this.drape; if (!(this.terrain > 0 && this.tiles && alt < DRAPE_ALT)) { if (dr) this.#relay(null); return; } const n = [p.x / d, p.y / d, p.z / d], gen = this.tiles.heightGen ?? 0; const moved = !dr || n[0] * dr.n[0] + n[1] * dr.n[1] + n[2] * dr.n[2] < Math.cos(dr.capRad / 4) || Math.abs(Math.log(alt / dr.alt)) > 0.3 || this.terrain !== dr.terrain; if (!moved && (gen === dr.gen || now - dr.at < 250)) return; const capRad = Math.acos(1 / d) + 0.0376 * Math.sqrt(this.terrain); // horizon + 9 km (× exaggeration) peaks beyond it this.#relay({ eye: [p.x, p.y, p.z], n, origin: n, alt, gen, at: now, capRad, cosCap: Math.cos(capRad), terrain: this.terrain, heightM: (la, lo) => this.#groundM(la, lo) }); } #relay(drape) { this.drape = drape; this.lines.setClip(drape ? [] : [this.horizon]); for (const id of this.raw.keys()) this.#line(id); this.#tune(); } #groundM(lat, lon, shown = true, unknown = 0) { // exaggerated height (m) of the drawn 3D ground; 0 without it if (!(this.terrain > 0)) return 0; const h = this.tiles?.heightAt?.(lat, lon, shown); // the drawn surface: sea at 0 m with water on, dry hollows below it return h == null ? unknown : h * this.terrain; // tiles reloading: keep the last known ground } #floor(p) { // the eye stays 20 m above the drawn ground under it (sea level without 3D terrain or loaded heights) const known = this.groundU * R_M; this.groundU = 0; for (let i = 0; i < 2; i++) { // the ground under a tilted eye moves as the range grows: settle twice const at = toLatLon(eyeOf(p).eye); const h = Math.max(this.#groundM(at.lat, at.lon, false, known), // the finest heights loaded, and what is drawn: this.#groundM(at.lat, at.lon, true, -Infinity)); // a coarser stand-in may stand higher this.groundU = h / R_M; p = lift(p, h, true); // h is the drawn ground, below 0 m only where something is drawn there } return p; } #held = false; // set from state (URL, search, zoom buttons): the range holds from the ground as it loads setPose(p) { this.#held = false; this.#apply(p); } #apply(p) { p = clampPose(p); p.ground = this.#groundM(p.lat, p.lon, false, this.pose?.ground ?? 0); // the target sits on the (loaded) ground: range counts from there this.pose = this.#floor(p); this.#place(); this.onChange(); } #place() { const { eye, up, target } = eyeOf(this.pose); this.camera.position.set(eye[0], eye[1], eye[2]); this.camera.up.set(up[0], up[1], up[2]); this.camera.lookAt(target[0], target[1], target[2]); this.#tune(); } keepAboveGround() { // finer heights loaded: the target follows the ground, the eye stays put unless swallowed let p = this.pose; const g = this.#groundM(p.lat, p.lon, false, p.ground); if (this.#held) { // a restored view: its ground loads under it (unknown: wait for it) if (this.terrain > 0 && this.tiles?.heightAt?.(p.lat, p.lon, false) != null && Math.abs(g - p.ground) > 0.01) p = { ...p, ground: g }; } else if (Math.abs(g - p.ground) > 0.01) { const r = rangeForAltitude(eyeAltitude(p.range, p.pitch, p.ground), p.pitch, g); p = { ...p, ground: g, range: Math.max(MIN_ALT, Number.isFinite(r) ? r : MIN_ALT) }; } p = this.#floor(p); if (p === this.pose) return; this.pose = p; this.#place(); this.onChange(); } #tune() { // depth range and overlay lines follow the eye; the coarse sphere sinks under the tile patches const p = this.camera.position, d = p.length(), alt = d - 1; this.camera.near = Math.max(1e-7, (alt - this.groundU) * 0.3); this.camera.far = Math.sqrt(Math.max(d * d - 1, 0)) + 0.1; // to the horizon and a bit beyond (far mountains) this.camera.updateProjectionMatrix(); this.camera.updateMatrixWorld(); if (this.drape) this.lines.setTransform(1, true); // laid on the ground: depth hides what is behind hills else { const hidden = alt > 0.05 || (this.terrain > 0 && this.pose.pitch > 0); // tilted 3D: sea-level lines would float this.lines.setTransform((1 + Math.max(2e-6, Math.min(0.0008, alt * 0.02))) / 1.0008, hidden); } this.horizon.normal.copy(p).divideScalar(d); // lines drawn on top when zoomed in: drop the far side explicitly this.horizon.constant = -1 / d; this.base.scale.setScalar(alt < 0.6 ? 1 - (this.terrain > 0 ? (this.seaFloor ? Math.max(2e-3, (TRENCH_KM * this.terrain) / R_KM) : 4e-4) : 3e-5) : 1); this.#air(); } #air() { // sky pass and haze follow the eye height and the sun const p = this.camera.position, d = p.length(), up = p.clone().divideScalar(d); const air = atmosphere((d - 1) * R_KM, this.shade.sky.value ? up.dot(this.shade.sunDir.value) : null); this.dome.update(this.camera, up, air); this.shade.camPos.value.copy(p); this.shade.hazeCol.value.set(...air.horizon); this.shade.hazeAmt.value = air.amount * HAZE_MAX * Math.min(1, this.pose.pitch / 45); // no horizon, no haze } getState() { const p = this.pose; return { lat: p.lat, lon: p.lon, alt: p.range, hd: p.heading, pt: p.pitch }; } setState({ lat, lon, alt, hd = 0, pt = 0 }) { this.#apply({ lat, lon, range: alt, heading: hd, pitch: pt }); this.#held = true; } #ray(x, y) { this.camera.updateMatrixWorld(); this.raycaster.setFromCamera(new THREE.Vector2((x / this.w) * 2 - 1, 1 - (y / this.h) * 2), this.camera); return this.raycaster.ray; } #march(ray) { // where a ray first meets the drawn 3D ground: growing steps, then bisection; null if it doesn't const { origin: o, direction: dir } = ray, top = (9000 * this.terrain) / R_M; const at = t => [o.x + dir.x * t, o.y + dir.y * t, o.z + dir.z * t]; const below = t => { const v = at(t), ll = toLatLon(v); return Math.hypot(v[0], v[1], v[2]) - 1 <= this.#groundM(ll.lat, ll.lon) / R_M; }; const ceil = ray.intersectSphere(new THREE.Sphere(new THREE.Vector3(), 1 + top), new THREE.Vector3()); let lo = ceil && o.length() > 1 + top ? ceil.distanceTo(o) : 0; // nothing stands above the ceiling sphere for (let t = Math.max(1e-7, lo * 1.01), i = 0; i < 120; i++, t *= 1.2) { if (below(t)) { let hi = t; for (let k = 0; k < 16; k++) { const m = (lo + hi) / 2; if (below(m)) hi = m; else lo = m; } return at(hi); } const v = at(t); if (Math.hypot(v[0], v[1], v[2]) - 1 > top && v[0] * dir.x + v[1] * dir.y + v[2] * dir.z > 0) return null; // skyward lo = t; } return null; } pickVec(x, y, radius = null) { // the ground this pixel shows (3D ground if drawn), or the sphere of a given radius const ray = this.#ray(x, y); if (radius === null && this.terrain > 0 && this.tiles) { const v = this.#march(ray); if (v) return v; } const hit = ray.intersectSphere(radius === null ? this.sphere : new THREE.Sphere(new THREE.Vector3(), radius), new THREE.Vector3()); return hit ? [hit.x, hit.y, hit.z] : null; } pick(x, y, ground = true) { // ground = false: the sea-level sphere (cheap; tile sampling looking straight down) const v = ground ? this.pickVec(x, y) : this.pickVec(x, y, 1); return v ? toLatLon(v) : null; } groundSample(x, y) { // tilted tile sampling: the ground this pixel sees and km per pixel there const v = this.pickVec(x, y); if (!v) return null; const e = this.camera.position, d = Math.hypot(v[0] - e.x, v[1] - e.y, v[2] - e.z); return { ...toLatLon(v), km: (d * R_KM * 2 * Math.tan((this.camera.fov * Math.PI) / 360)) / this.h }; } kmPerPxAt(p) { const e = this.camera.position; return kmPerPxAt([e.x, e.y, e.z], p, this.camera.fov, this.h); } toScreen(lat, lon) { const v = new THREE.Vector3(...toVec(lat, lon)); if (v.dot(this.camera.position) <= 1) return null; // beyond the horizon v.multiplyScalar(1 + this.#groundM(lat, lon) / R_M); // on the drawn 3D ground this.camera.updateMatrixWorld(); v.project(this.camera); return { x: ((v.x + 1) / 2) * this.w, y: ((1 - v.y) / 2) * this.h }; } render() { this.renderer.render(this.scene, this.camera); } }