worldmap-viewer

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

master

raw · 15504 bytes

// tilelayer.js — deep-zoom tile patches for one view: pick the tiles on screen, load ≤ 6 at a time,
// let the nearest loaded ancestor stand in, evict the least recently used.
import * as THREE from 'three';
import { SHADE_GLSL } from './shading.js';
import { MIN_TILE_Z, MAX_TILE_Z, MESH_SEGS, TILE, DETAIL_TILES, detailOrigin, targetZoom, lodTiles, tileKey, tileOf, parentTile,
  globePatch, flatPatch, gridHeight }
  from './tiles.js';

export const CONCURRENCY = 6;
const MAX_WANTED = 96, CACHE = 256, SEGS = 16;   // defaults; the app sizes the budget from the viewport
export const GRAIN = 0.35;   // 3D fine grain: ± brightness at full strength (×0.5 from the noise)

const GLOBE_VERT = /* glsl */ `
attribute float skirt;
attribute float wet;
varying float vWet;
varying vec2 vUv;
varying vec3 vPos;
varying vec3 vNormal;
varying float vSkirt;
varying vec3 vRel;
void main() {
  vUv = uv;
  vSkirt = skirt;
  vWet = wet;
  vRel = mat3(modelMatrix) * position;   // world-oriented but patch-relative: float32 keeps its slope at street zoom
  vec4 wp = modelMatrix * vec4(position, 1.0);
  vPos = wp.xyz;
  vNormal = normalize(mat3(viewMatrix) * wp.xyz);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;

const FLAT_VERT = /* glsl */ `
attribute vec2 ll;
varying vec2 vUv;
varying vec3 vPos;
varying vec3 vNormal;
void main() {
  vUv = uv;
  vPos = vec3(cos(ll.x) * cos(ll.y), sin(ll.x), -cos(ll.x) * sin(ll.y));
  vNormal = vec3(0.0, 0.0, 1.0);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`;

const FRAG = /* glsl */ `${SHADE_GLSL}
varying vec2 vUv;
varying vec3 vPos;
varying vec3 vNormal;
void main() {
  gl_FragColor = vec4(shadeColor(vUv, vPos, 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0)), 1.0);
}`;

export const STEEP_GLSL = /* glsl */ `
const vec3 ROCK = vec3(0.47, 0.43, 0.38);
vec3 steepColor(vec3 c, vec3 pos, vec3 rel, float limb) {   // cliffs: rock, not the colour of the texel at their foot
  vec3 n = normalize(cross(dFdx(rel), dFdy(rel)));
  float up = abs(dot(n, normalize(pos)));
  return mix(ROCK * limb, c, smoothstep(0.42, 0.71, up));
}`;

export const GRAIN_GLSL = /* glsl */ `
uniform vec2 detailOrg;
uniform float waterOn;
float grainHash(vec2 p) {   // Dave Hoskins, "Hash without Sine" (MIT)
  vec3 p3 = fract(vec3(p.xyx) * 0.1031);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}
float grainNoise(vec2 p, float per) {   // value noise, periodic in per lattice cells
  vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
  float a = grainHash(mod(i, per)), b = grainHash(mod(i + vec2(1.0, 0.0), per));
  float c = grainHash(mod(i + vec2(0.0, 1.0), per)), d = grainHash(mod(i + vec2(1.0, 1.0), per));
  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y) - 0.5;
}
float fineGrain(vec2 uv) {   // below the map's resolution: octaves of 1, 1/2, 1/4 texel, each fading in once it spans a few px
  vec2 q = detailOrg + vec2(uv.x, 1.0 - uv.y) * ${TILE.toFixed(1)};   // texels, periodic over the tiles (seamless)
  float px = 1.0 / max(length(fwidth(q)), 1e-6);                   // screen px per texel
  float g = 0.0, f = 1.0, a = 0.5;
  for (int k = 0; k < 3; k++) {
    g += a * smoothstep(2.0, 6.0, px / f) * grainNoise(q * f, ${(DETAIL_TILES * TILE).toFixed(1)} * f);
    f *= 2.0;
    a *= 0.6;
  }
  return g;
}`;

const FRAG_3D = /* glsl */ `${SHADE_GLSL}
${STEEP_GLSL}
${GRAIN_GLSL}
varying vec2 vUv;
varying vec3 vPos;
varying vec3 vNormal;
varying float vSkirt;
varying float vWet;
varying vec3 vRel;
const float GRAIN = ${GRAIN.toFixed(3)};
void main() {
  float limb = 0.72 + 0.28 * clamp(vNormal.z, 0.0, 1.0);
  vec3 c = shadeColor(vUv, vPos, limb);
  c *= 1.0 + GRAIN * fineGrain(vUv) * (1.0 - vWet * waterOn);   // procedural, like the relief detail: not data
  gl_FragColor = vec4(vSkirt > 0.001 ? c : steepColor(c, vPos, vRel, limb), 1.0);   // a skirt seen through a crack: ground
}`;

let loader = null;
export function loadTexture(url) {
  loader ??= new THREE.TextureLoader();
  return loader.loadAsync(url).then(t => {
    t.colorSpace = THREE.NoColorSpace;   // shaders pass the JPEG's sRGB bytes straight through
    t.anisotropy = 4;
    return t;
  });
}

export function loadHeights(url) {
  return fetch(url).then(r => {
    if (!r.ok) throw new Error(`heights ${r.status}`);
    return r.arrayBuffer();
  }).then(b => {
    const n = (MESH_SEGS + 1) ** 2;
    if (b.byteLength !== n * 5) throw new Error(`heights: ${b.byteLength} bytes`);
    return { h: new Float32Array(b, 0, n), water: new Uint8Array(b, 4 * n, n) };
  });
}

export class TileLayer {
  constructor(view, { url, meshUrl = null, onChange = () => {}, load = loadTexture, loadMesh = loadHeights,
    now = () => performance.now(), later = (fn, ms) => setTimeout(fn, ms) }) {   // (Firefox: never a method call)
    Object.assign(this, { view, url, meshUrl, onChange, load, loadMesh, now, later });
    this.group = new THREE.Group();
    this.group.visible = false;
    view.scene.add(this.group);
    this.tiles = new Map();   // key → {key, t, state: new|queued|loading|ready|error, mesh, used}
    this.queue = [];
    this.inflight = 0;
    this.gen = 0;
    this.layer = null;
    this.over = null;
    this.z = -1;
    this.cacheSize = CACHE;
    this.terrain = 0;   // 3D exaggeration; 0 = flat patches
    this.heightGen = 0;   // bumps whenever the drawn heights change (lines draped on them follow)
    this.water = true;   // the water surface drawn at 0 m (off: the sea floor)
  }

  get active() { return this.group.visible; }

  setLayers(layer, over) {
    over = over || null;
    if (layer === this.layer && over === this.over) return;
    this.layer = layer;
    this.over = over;
    this.gen++;
    for (const k of [...this.tiles.keys()]) this.#drop(k);
    this.queue = [];
    this.onChange();
  }

  setTerrain(exag) {   // 0 = flat tiles; > 0 = 3D with heights × exag (globe only)
    exag = this.meshUrl && this.view.kind === 'globe' && exag > 0 ? exag : 0;
    if (exag === this.terrain) return;
    const was = this.terrain;
    this.terrain = exag;
    if (!was !== !exag) {   // on ↔ off: every tile needs (or sheds) its heights
      this.gen++;
      for (const k of [...this.tiles.keys()]) this.#drop(k);
      this.queue = [];
    } else for (const e of this.tiles.values()) if (e.mesh && e.h) this.#shape(e);
    this.heightGen++;
    this.onChange();
  }

  heightAt(lat, lon, shown = false) {   // metres (unexaggerated) from the finest loaded heights here, or null;
    // shown: only tiles drawn right now (what picks and draped lines must match)
    if (!this.terrain || (shown && !this.group.visible)) return null;
    for (let z = MAX_TILE_Z; z >= MIN_TILE_Z; z--) {
      const e = this.tiles.get(tileKey(tileOf(lat, lon, z)));
      if (e?.state === 'ready' && e.h && (!shown || e.mesh.visible)) return gridHeight(e.t, this.#drawn(e), lat, lon);
    }
    return null;
  }

  #drawn(e) {   // the heights this tile draws: the water surface at 0 m, or the floor
    if (!e.h) return null;
    if (!this.water) return e.h.h;
    e.surface ??= e.h.h.map((v, k) => (e.h.water[k] ? 0 : v));
    return e.surface;
  }

  setWater(on) {
    if (on === this.water) return;
    this.water = on;
    for (const e of this.tiles.values()) if (e.mesh && e.h) {
      this.#shape(e);
      e.mesh.material.uniforms.waterOn.value = on ? 1 : 0;
    }
    this.heightGen++;
    this.onChange();
  }

  #shape(e) {
    const g = e.mesh.geometry;
    g.attributes.position.array.set(globePatch(e.t, MESH_SEGS, this.#drawn(e), this.terrain).positions);
    g.attributes.position.needsUpdate = true;
    g.computeBoundingSphere();
  }

  setUrl(url, meshUrl = this.meshUrl) {   // regions rebuilt: new tile URLs, the old ones stop answering
    if (url === this.url && meshUrl === this.meshUrl) return;
    this.url = url;
    this.meshUrl = meshUrl;
    this.gen++;
    for (const k of [...this.tiles.keys()]) this.#drop(k);
    this.queue = [];
    this.onChange();
  }

  suspend() {   // the view is hidden: stop loading, free the GPU (in-flight loads are dropped on arrival)
    this.gen++;
    for (const k of [...this.tiles.keys()]) this.#drop(k);
    this.queue = [];
    this.group.visible = false;
  }

  update(kmPerPx, points, cap = MAX_WANTED) {
    const own = points.map(p => Math.min(MAX_TILE_Z, targetZoom(p.km ?? kmPerPx)));   // tilted: each sample its own zoom
    let drop = 0, zs = own, wanted = lodTiles(points, zs, 1);
    while (wanted.length > cap && Math.max(...zs) > MIN_TILE_Z) {   // coverage first: everything a level coarser
      drop++;
      zs = own.map(z => z - drop);
      wanted = lodTiles(points, zs, 1);
    }
    this.z = zs.length ? Math.max(...zs) : Math.min(MAX_TILE_Z, targetZoom(kmPerPx));
    const on = this.z >= MIN_TILE_Z && !!this.layer && points.length > 0;
    if (this.group.visible !== on) this.heightGen++;
    this.group.visible = on;
    if (!on) {
      for (const e of this.queue) e.state = 'new';
      this.queue = [];
      return;
    }
    wanted = wanted.slice(0, cap);
    this.cacheSize = Math.max(CACHE, 2 * cap);
    const want = new Set(wanted.map(tileKey)), show = new Set(), standIn = new Set();
    for (const t of wanted) {
      const e = this.#entry(t);
      e.used = this.now();   // wanted now: never the first to go when the cache trims (a failed one keeps its backoff)
      if (e.state === 'error' && this.now() >= e.retryAt) e.state = 'new';   // try again after the backoff
      if (e.state === 'ready') {
        show.add(e.key);
        continue;
      }
      if (e.state === 'new') e.state = 'queued';
      for (let p = parentTile(t); p.z >= MIN_TILE_Z; p = parentTile(p)) {
        const a = this.tiles.get(tileKey(p));
        if (a?.state === 'ready') {
          show.add(a.key);
          standIn.add(a.key);
          break;
        }
      }
    }
    if (this.terrain && standIn.size) {   // 3D: a stand-in's coarser ground would poke through finer tiles inside it
      for (const k of [...show]) {
        const [z, x, y] = k.split('/').map(Number);
        for (let q = parentTile({ z, x, y }); q.z >= MIN_TILE_Z; q = parentTile(q)) {
          if (standIn.has(tileKey(q))) {
            show.delete(k);
            break;
          }
        }
      }
    }
    for (const e of this.queue) if (!want.has(e.key)) e.state = 'new';   // left the view: forget, don't fetch
    this.queue = wanted.map(t => this.tiles.get(tileKey(t))).filter(e => e.state === 'queued');   // this view's order
    const now = this.now();
    let changed = false;
    for (const e of this.tiles.values()) {
      const vis = show.has(e.key);
      if (e.mesh && e.mesh.visible !== vis) {
        e.mesh.visible = vis;
        changed ||= !!e.h;
      }
      if (vis) e.used = now;
    }
    if (changed) this.heightGen++;
    this.#pump();
    this.#evict();
  }

  #entry(t) {
    const key = tileKey(t);
    let e = this.tiles.get(key);
    if (!e) {
      e = { key, t, state: 'new', mesh: null, used: 0 };
      this.tiles.set(key, e);
    }
    return e;
  }

  #url(layer, t) {
    return this.url.replace('{layer}', layer).replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y);
  }

  #meshUrl(t) { return this.meshUrl.replace('{z}', t.z).replace('{x}', t.x).replace('{y}', t.y); }

  #pump() {
    const cost = 1 + (this.over ? 1 : 0) + (this.terrain ? 1 : 0);   // HTTP requests per tile (heights ≈ 66 KB)
    while (this.inflight + cost <= CONCURRENCY && this.queue.length) {
      const e = this.queue.shift(), gen = this.gen;
      e.state = 'loading';
      this.inflight += cost;
      Promise.allSettled([this.load(this.#url(this.layer, e.t)), this.over ? this.load(this.#url(this.over, e.t)) : null,
        this.terrain ? this.loadMesh(this.#meshUrl(e.t)) : null])
        .then(([a, b, c]) => {
          const tex = a.status === 'fulfilled' ? a.value : null, tex2 = b.status === 'fulfilled' ? b.value : null;
          const h = this.terrain && c.status === 'fulfilled' ? c.value : null;
          const failed = !tex || (this.over && !tex2) || (this.terrain && !h);
          if (failed || gen !== this.gen || !this.tiles.has(e.key)) {
            tex?.dispose();
            tex2?.dispose();
            if (failed && gen === this.gen) this.#fail(e, gen);
            return;
          }
          try {
            e.h = h;
            e.mesh = this.#mesh(e.t, tex, tex2, h ? this.#drawn(e) : null, h?.water);
          } catch (err) {
            tex.dispose();
            tex2?.dispose();
            console.warn('tile', e.key, err);
            this.#fail(e, gen);
            return;
          }
          e.fails = 0;
          e.state = 'ready';
          this.onChange();
        })
        .finally(() => {
          this.inflight -= cost;
          this.#pump();
        });
    }
  }

  #fail(e, gen) {   // back off, then wake the map so an idle view retries too
    e.fails = (e.fails ?? 0) + 1;
    const delay = Math.min(60000, 2000 * 2 ** (e.fails - 1));
    e.retryAt = this.now() + delay;
    e.state = 'error';
    this.later(() => { if (gen === this.gen) this.onChange(); }, delay);
  }

  #mesh(t, tex, tex2, h = null, wet = null) {
    const kind = this.view.kind;
    const p = kind === 'globe' ? globePatch(t, h ? MESH_SEGS : SEGS, h, this.terrain) : flatPatch(t, kind, SEGS, 0.0002 + t.z * 1e-5);
    const g = new THREE.BufferGeometry();
    g.setAttribute('position', new THREE.BufferAttribute(p.positions, 3));
    g.setAttribute('uv', new THREE.BufferAttribute(p.uvs, 2));
    if (p.ll) g.setAttribute('ll', new THREE.BufferAttribute(p.ll, 2));
    if (p.skirt) g.setAttribute('skirt', new THREE.BufferAttribute(p.skirt, 1));
    if (wet) {   // per vertex: 1 on water (no grain there while the water surface is drawn); skirts 0
      const a = new Float32Array(p.positions.length / 3);
      for (let k = 0; k < wet.length; k++) a[k] = wet[k];
      g.setAttribute('wet', new THREE.BufferAttribute(a, 1));
    }
    g.setIndex(new THREE.BufferAttribute(p.indices, 1));
    const m = new THREE.ShaderMaterial({
      vertexShader: kind === 'globe' ? GLOBE_VERT : FLAT_VERT, fragmentShader: h ? FRAG_3D : FRAG,
      uniforms: { ...this.view.shade, map: { value: tex }, hasMap: { value: 1 }, map2: { value: tex2 },
        detailOrg: { value: new THREE.Vector2(...detailOrigin(t)) }, waterOn: { value: this.water ? 1 : 0 } },
      polygonOffset: true, polygonOffsetUnits: -4 * t.z, side: h ? THREE.DoubleSide : THREE.FrontSide,
      polygonOffsetFactor: h ? 0 : -1,   // 3D: none by slope, it would pull the edge-on skirts over the ground
    });
    const mesh = new THREE.Mesh(g, m);
    mesh.position.set(p.center[0], p.center[1], p.center[2]);
    mesh.renderOrder = 1 + t.z;
    mesh.visible = false;
    this.group.add(mesh);
    return mesh;
  }

  #evict() {
    if (this.tiles.size <= this.cacheSize) return;
    const idle = [...this.tiles.values()]
      .filter(e => (e.state === 'ready' && !e.mesh.visible) || e.state === 'error' || e.state === 'new')
      .sort((a, b) => a.used - b.used);
    for (const e of idle.slice(0, this.tiles.size - this.cacheSize)) this.#drop(e.key);
  }

  #drop(key) {
    const e = this.tiles.get(key);
    if (e?.mesh) {
      this.group.remove(e.mesh);
      e.mesh.geometry.dispose();
      e.mesh.material.uniforms.map.value?.dispose();
      e.mesh.material.uniforms.map2.value?.dispose();
      e.mesh.material.dispose();
    }
    this.tiles.delete(key);
  }
}