aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/globe.js
blob: 0850e2f609abc96d3a2853126a7605945feddf33 (plain)
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); }
}