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