worldmap-viewer

git clone https://git.godosa.eu/worldmap-viewer

master

raw · 12622 bytes

// 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); }
}