raw · 12622 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | // 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); } } |