aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/tilelayer.js
diff options
context:
space:
mode:
Diffstat (limited to 'viewer/js/tilelayer.js')
-rw-r--r--viewer/js/tilelayer.js401
1 files changed, 401 insertions, 0 deletions
diff --git a/viewer/js/tilelayer.js b/viewer/js/tilelayer.js
new file mode 100644
index 0000000..7116d87
--- /dev/null
+++ b/viewer/js/tilelayer.js
@@ -0,0 +1,401 @@
+// 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);
+ }
+}