raw · 15504 bytes
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 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); } } |