diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 00:14:38 +0200 |
| commit | 3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch) | |
| tree | 4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/tilelayer.js | |
| download | worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip | |
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/tilelayer.js')
| -rw-r--r-- | viewer/js/tilelayer.js | 401 |
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); + } +} |
