3127 lines
141 KiB
JavaScript
3127 lines
141 KiB
JavaScript
/* Volumetric 3D cloud renderer.
|
|
* Raymarches a horizontal cloud layer with the camera flying
|
|
* slowly above and through the cloud deck. Density threshold
|
|
* comes from the weather theme, so 'sunny' gives breaks in the
|
|
* deck and 'stormy' puts you inside overcast most of the time.
|
|
*
|
|
* Reads palette + weather + speed from window globals (same
|
|
* contract via window.__current* globals, theme-agnostic).
|
|
*/
|
|
|
|
(function() {
|
|
|
|
function makeShader(gl, type, src) {
|
|
var s = gl.createShader(type);
|
|
gl.shaderSource(s, src);
|
|
gl.compileShader(s);
|
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
console.error('clouds3d: Shader compile error:', gl.getShaderInfoLog(s));
|
|
gl.deleteShader(s);
|
|
return null;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function makeProgram(gl, vsSrc, fsSrc) {
|
|
var vs = makeShader(gl, gl.VERTEX_SHADER, vsSrc);
|
|
var fs = makeShader(gl, gl.FRAGMENT_SHADER, fsSrc);
|
|
if (!vs || !fs) return null;
|
|
var p = gl.createProgram();
|
|
gl.attachShader(p, vs);
|
|
gl.attachShader(p, fs);
|
|
gl.linkProgram(p);
|
|
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
console.error('clouds3d: Program link error:', gl.getProgramInfoLog(p));
|
|
gl.deleteProgram(p);
|
|
return null;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
var VERT_SRC = [
|
|
'attribute vec2 aPos;',
|
|
'void main() { gl_Position = vec4(aPos, 0.0, 1.0); }'
|
|
].join('\n');
|
|
|
|
// Volumetric raymarch through a horizontal cloud layer.
|
|
// Cheap 3D value noise + 3-octave fbm. 18 raymarch steps.
|
|
// Tuned for low-end hardware: fewer steps + fewer noise samples
|
|
// per step keep the frame within budget on integrated GPUs.
|
|
// Camera path is sinusoidal so the view occasionally dips
|
|
// into the cloud deck for the "fly through the edges" feel.
|
|
var FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform vec2 uResolution;',
|
|
'uniform float uTime;',
|
|
'uniform sampler2D uGround;',
|
|
'uniform vec2 uGroundCenter;', // current mosaic centre (world units)
|
|
'uniform vec2 uGroundSize;', // mosaic side extents (canvas tiles: W x H)
|
|
'uniform vec2 uCorridorRot;', // corridor (fetch) rotation as (cos, sin)
|
|
'uniform mat4 uViewProjInv;', // inverse of (proj * view) for ray reconstruction
|
|
'uniform vec3 uSky;',
|
|
'uniform vec3 uCloud;',
|
|
'uniform vec3 uCloudShadow;',
|
|
'uniform vec3 uLight;',
|
|
'uniform vec3 uBg;',
|
|
'uniform float uSpeed;',
|
|
'uniform float uDensity;',
|
|
'uniform float uDayProgress;',
|
|
'uniform float uSunAlt;',
|
|
'uniform float uFogDist;', // world units (=tiles) where ground fog saturates
|
|
'uniform float uCloudTop;', // cloud deck upper edge (world Y)
|
|
'uniform float uCloudBot;', // cloud deck lower edge (world Y)
|
|
'uniform sampler2D uElev;', // terrarium RGB height grid, same UV as uGround
|
|
'uniform float uElevOn;', // terrain enabled (0/1)
|
|
'uniform float uElevScale;', // metres -> world units (incl. exaggeration)
|
|
'uniform vec3 uSunDir;', // unit vector towards the sun (world X,Y,Z)
|
|
'uniform vec3 uMoonDir;', // unit vector towards the moon (world X,Y,Z)
|
|
'uniform sampler2D uSkyTex;', // baked star catalogue + Milky Way (equirect)
|
|
'uniform float uSkyOn;', // 0 until the star texture is built
|
|
'uniform float uStarLon;', // sidereal rotation offset (radians)
|
|
'uniform vec3 uPole;', // north celestial pole in world coords
|
|
'uniform int uMode;', // 0 = sky+stars, 1 = clouds (transparent over mesh)
|
|
// 3D hash and value noise
|
|
'float hash3(vec3 p) {',
|
|
'p = fract(p * vec3(0.1031, 0.1030, 0.0973));',
|
|
'p += dot(p, p.yxz + 33.33);',
|
|
'return fract((p.x + p.y) * p.z);',
|
|
'}',
|
|
// 2D hash for per-pixel dithering. Adding uTime to the input
|
|
// means the dither pattern changes each frame so noise doesn't
|
|
// crawl on slow camera motion.
|
|
'float hash21(vec2 p) {',
|
|
'p = fract(p * vec2(123.34, 456.21));',
|
|
'p += dot(p, p + 45.32);',
|
|
'return fract(p.x * p.y);',
|
|
'}',
|
|
'float noise3(vec3 p) {',
|
|
'vec3 i = floor(p);',
|
|
'vec3 f = fract(p);',
|
|
'f = f * f * (3.0 - 2.0 * f);',
|
|
'return mix(',
|
|
'mix(mix(hash3(i + vec3(0,0,0)), hash3(i + vec3(1,0,0)), f.x),',
|
|
'mix(hash3(i + vec3(0,1,0)), hash3(i + vec3(1,1,0)), f.x), f.y),',
|
|
'mix(mix(hash3(i + vec3(0,0,1)), hash3(i + vec3(1,0,1)), f.x),',
|
|
'mix(hash3(i + vec3(0,1,1)), hash3(i + vec3(1,1,1)), f.x), f.y),',
|
|
'f.z);',
|
|
'}',
|
|
'float fbm3(vec3 p) {',
|
|
'float v = 0.0;',
|
|
'float a = 0.5;',
|
|
'for (int i = 0; i < 3; i++) {',
|
|
'v += a * noise3(p);',
|
|
'p = p * 2.03 + vec3(13.7, 7.1, 19.3);',
|
|
'a *= 0.5;',
|
|
'}',
|
|
'return v;',
|
|
'}',
|
|
// Density at a point in the cloud layer. Two fBm layers: the
|
|
// base one at low frequency gives the large puffs, the detail
|
|
// layer at higher frequency breaks up the silhouette so clouds
|
|
// don't look like soft blobs.
|
|
'float cloudDensity(vec3 p, float baseThresh) {',
|
|
'float n = fbm3(p * 0.35 + vec3(0.0, 0.0, uTime * 0.06));',
|
|
'float d = fbm3(p * 1.4 + vec3(uTime * 0.04, 7.3, uTime * 0.03));',
|
|
'n = n + d * 0.20;',
|
|
// Cloud layer between uCloudBot and uCloudTop, densest in the
|
|
// middle (bell curve).
|
|
'float h = clamp((p.y - uCloudBot) / (uCloudTop - uCloudBot), 0.0, 1.0);',
|
|
'float profile = 4.0 * h * (1.0 - h); // bell curve',
|
|
'profile = max(profile, 0.0);',
|
|
'return smoothstep(baseThresh, baseThresh + 0.20, n) * profile;',
|
|
'}',
|
|
// Star field: project ray onto a far sphere, hash-grid for
|
|
// positions. Visible above the horizon at night with a small
|
|
// twinkle.
|
|
'float starField(vec3 r) {',
|
|
'vec3 sp = r * 240.0;',
|
|
'vec3 sf = floor(sp);',
|
|
'vec3 sfp = fract(sp) - 0.5;',
|
|
// irrational-looking offsets break the lattice bias a plain
|
|
// integer grid feeds into hash3 (stars vanished deterministically)
|
|
'float h = hash3(sf + 0.317);',
|
|
'float starD = length(sfp);',
|
|
'float size = 0.16 + 0.16 * fract(h * 7919.0);',
|
|
'float mask = step(0.915, h) * (1.0 - smoothstep(0.0, size, starD));',
|
|
'float bright = 0.45 + 0.55 * fract(h * 13.73);',
|
|
'float twinkle = 0.8 + 0.2 * sin(uTime * 0.6 + h * 47.0);',
|
|
'return mask * bright * twinkle;',
|
|
'}',
|
|
// Terrain height lookup: world XZ offset -> metres, via the terrarium
|
|
// grid which shares the street canvas' world->UV convention. Used
|
|
// by the CLOUDS pass to compute the ray-terrain hit and discard
|
|
// clouds that are entirely behind the (mesh) terrain.
|
|
'float terrElev(vec2 xz) {',
|
|
// euv.x is mirrored to match the mesh: heights are read with the
|
|
// DEM mosaic's west-right layout against the world's east +x.
|
|
'vec2 euv = vec2(0.5 - xz.x / uGroundSize.x, xz.y / uGroundSize.y + 0.5);',
|
|
'if (euv.x < 0.0 || euv.x > 1.0 || euv.y < 0.0 || euv.y > 1.0) return 0.0;',
|
|
'vec3 t = texture2D(uElev, euv).rgb;',
|
|
'return (t.r * 256.0 + t.g + t.b / 256.0) * 255.0 - 32768.0;',
|
|
'}',
|
|
// Two modes driven by uMode:
|
|
// 0 = SKY: full-screen pass that paints the sky gradient and
|
|
// stars into the framebuffer (opaque, alpha = 1).
|
|
// 1 = CLOUDS: full-screen pass that raymarches the cloud deck
|
|
// and outputs the cloud's effective colour and
|
|
// coverage. The render pass is alpha-blended so the
|
|
// terrain mesh from the previous pass shows through
|
|
// transparent cloud pixels. Clouds entirely behind
|
|
// the terrain are discarded.
|
|
// The world ray is reconstructed from the inverse view-projection
|
|
// matrix, which is shared with the terrain mesh, so the cloud/sky
|
|
// pass and the terrain mesh share exactly the same perspective.
|
|
'void main() {',
|
|
'vec2 ndc = (gl_FragCoord.xy / uResolution.xy) * 2.0 - 1.0;',
|
|
'vec4 nearH = uViewProjInv * vec4(ndc, -1.0, 1.0);',
|
|
'vec4 farH = uViewProjInv * vec4(ndc, 1.0, 1.0);',
|
|
'vec3 ro = nearH.xyz / nearH.w;',
|
|
'vec3 rd = normalize(farH.xyz / farH.w - ro);',
|
|
|
|
'if (uMode == 0) {',
|
|
// skyT floor at 0.5: at and below the horizon the sky keeps
|
|
// the exact horizon colour - the same value the terrain's
|
|
// distance fog saturates to, so no seam at the silhouette.
|
|
'float skyT = clamp(rd.y * 0.7 + 0.5, 0.5, 1.0);',
|
|
'vec3 col = mix(uBg, uSky, skyT);',
|
|
'float nightFactor = 1.0 - smoothstep(-0.15, 0.05, uSunAlt);',
|
|
'float skyMask = smoothstep(-0.20, 0.40, rd.y);',
|
|
// night darkening of the gradient BEFORE star emission is added
|
|
'col *= (1.0 - nightFactor * 0.55);',
|
|
// ---- real star catalog (Yale BSC5 baked into a texture) ----
|
|
// world -> equatorial: pole tilted by the observer's latitude
|
|
'if (uSkyOn > 0.5 && nightFactor > 0.02 && rd.y > -0.015) {',
|
|
'vec3 P = uPole;',
|
|
'float ydec = dot(rd, P);',
|
|
'vec3 q = rd - P * ydec;',
|
|
'vec3 b2 = normalize(vec3(0.0, P.z, -P.y));',
|
|
'float H = atan(dot(q, b2), dot(q, vec3(1.0, 0.0, 0.0)));',
|
|
'float u = fract(0.5 + (H + uStarLon) * 0.15915494);',
|
|
// texture uploaded WITHOUT flipY: canvas top (v=0) = Dec +90
|
|
'float v = 0.5 - asin(clamp(ydec, -1.0, 1.0)) * 0.31830989;',
|
|
'vec3 st = texture2D(uSkyTex, vec2(u, v)).rgb;',
|
|
'float twk = 0.88 + 0.12 * sin(uTime * 0.7 + dot(rd, vec3(91.7, 43.3, 17.1)));',
|
|
'col += st * nightFactor * skyMask * twk * 1.45;',
|
|
'}',
|
|
// ---- Sun disc ------------------------------------------------
|
|
// angular radius ~0.27 deg -> chord length ~0.0047
|
|
'if (uSunAlt > -0.03) {',
|
|
'vec3 qs = rd - uSunDir;',
|
|
'float angS = length(qs);',
|
|
'float discS = 1.0 - smoothstep(0.0040, 0.0050, angS);',
|
|
'float haloS = exp(-angS * 55.0) * 0.30 + exp(-angS * 14.0) * 0.12;',
|
|
'vec3 sunCol = mix(uLight, vec3(1.0, 0.92, 0.72), 0.35);',
|
|
'col += sunCol * (discS * 1.15 + haloS) * (1.0 - nightFactor * 0.85);',
|
|
'}',
|
|
// ---- Moon disc with real terminator ---------------------------
|
|
// lit side = dot(surface normal, sun dir) -> crescent/gibbous
|
|
// falls out of geometry, phase tracks __currentMoonDir.
|
|
'if (uMoonDir.y > -0.01) {',
|
|
'vec3 qm = rd - uMoonDir;',
|
|
'float angM = length(qm);',
|
|
'if (angM < 0.02) {',
|
|
'float z = sqrt(max(0.0, 0.0046 * 0.0046 - angM * angM));',
|
|
'vec3 nM = normalize(qm + uMoonDir * (z + 0.00001));',
|
|
'float litM = dot(nM, uSunDir);',
|
|
'float phase = smoothstep(-0.08, 0.12, litM);',
|
|
'float discM = 1.0 - smoothstep(0.0041, 0.0046, angM);',
|
|
'vec3 moonCol = mix(vec3(0.62, 0.64, 0.68), vec3(0.97, 0.95, 0.88), phase);',
|
|
'float haloM = exp(-angM * 60.0) * 0.10 * (0.25 + 0.75 * phase);',
|
|
'col += (moonCol * discM * phase + vec3(0.75, 0.78, 0.85) * haloM) * 1.25;',
|
|
'}',
|
|
'}',
|
|
// faint procedural fill for stars below the catalog limit
|
|
'float stars = starField(rd) * skyMask * nightFactor;',
|
|
'col += stars * uLight * 0.85;',
|
|
'gl_FragColor = vec4(col, 1.0);',
|
|
'return;',
|
|
'}',
|
|
|
|
// ---- CLOUDS pass ----
|
|
// Ray-terrain intersection (used to discard clouds that are
|
|
// entirely behind the terrain mesh).
|
|
'float tTerrain = (ro.y > 0.0 && rd.y < -0.001) ? -ro.y / rd.y : -1.0;',
|
|
'if (tTerrain > 0.0 && uElevOn > 0.5 && rd.y < -0.01) {',
|
|
'for (int ti = 0; ti < 4; ti++) {',
|
|
'vec3 hp = ro + rd * tTerrain;',
|
|
'float dh = terrElev(hp.xz) * uElevScale - hp.y;',
|
|
'if (abs(dh) < 0.02) break;',
|
|
'tTerrain += dh / -rd.y;',
|
|
'if (tTerrain < 0.1 || tTerrain > 90.0) { tTerrain = -1.0; break; }',
|
|
'}',
|
|
'}',
|
|
// Cloud fully behind the terrain -> discard (framebuffer keeps
|
|
// the terrain color from the mesh pass).
|
|
'if (tTerrain > 0.0) {',
|
|
'float tCloudTop = (ro.y - uCloudTop) / -rd.y;',
|
|
'if (tCloudTop > tTerrain) discard;',
|
|
'}',
|
|
|
|
// Cloud march. The output is the cloud's effective colour and
|
|
// coverage (alpha); the render pass uses alpha-blend so the
|
|
// terrain mesh from the previous pass shows through transparent
|
|
// cloud pixels.
|
|
'float baseThresh = mix(0.72, 0.40, uDensity);',
|
|
'float t = (ro.y > uCloudTop && rd.y < -0.001) ? (ro.y - uCloudTop) / -rd.y : 0.0;',
|
|
't += hash21(gl_FragCoord.xy + uTime) * 0.85;',
|
|
'float stepSize = 0.85;',
|
|
'vec3 cloudSum = vec3(0.0);',
|
|
'float trans = 1.0;',
|
|
'for (int i = 0; i < 18; i++) {',
|
|
'vec3 p = ro + rd * t;',
|
|
'if (p.y > uCloudTop || p.y < uCloudBot - 0.1) break;',
|
|
'if (tTerrain > 0.0 && t >= tTerrain) break;',
|
|
'float d = cloudDensity(p, baseThresh);',
|
|
'if (d > 0.005) {',
|
|
'float lit = clamp((p.y - uCloudBot) / ((uCloudTop - uCloudBot) * 1.167), 0.0, 1.0);',
|
|
'vec3 cloudCol = mix(uCloudShadow, uCloud, lit);',
|
|
'cloudCol = mix(cloudCol, uLight, max(0.15, uDayProgress) * 0.35 * lit);',
|
|
'float alpha = clamp(d * stepSize * 0.9, 0.0, 1.0);',
|
|
'cloudSum += cloudCol * alpha * trans;',
|
|
'trans *= (1.0 - alpha);',
|
|
'if (trans < 0.04) break;',
|
|
'}',
|
|
't += stepSize;',
|
|
'}',
|
|
'float cloudAlpha = 1.0 - trans;',
|
|
'vec3 cloudColor = (cloudAlpha > 0.001) ? cloudSum / cloudAlpha : vec3(0.0);',
|
|
'gl_FragColor = vec4(cloudColor, cloudAlpha);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// ---- Terrain mesh program -----------------------------------------------
|
|
// A real 3D heightfield mesh (one VBO, built once from the elevation
|
|
// canvas) replaces the old per-pixel heightfield lookup so the ground
|
|
// has actual silhouettes and parallax. The fragment shader just samples
|
|
// the street texture by UV and applies lambert shading against the real
|
|
// sun direction; everything else is in the geometry (positions, normals).
|
|
var TERRAIN_VERT_SRC = [
|
|
'attribute vec3 aPos;',
|
|
'attribute vec3 aNormal;',
|
|
'attribute vec2 aUV;',
|
|
'uniform mat4 uView;',
|
|
'uniform mat4 uProj;',
|
|
'varying vec3 vNormal;',
|
|
'varying vec2 vUV;',
|
|
'varying float vY;',
|
|
'varying vec3 vViewPos;',
|
|
'void main() {',
|
|
'vNormal = aNormal;',
|
|
'vUV = aUV;',
|
|
'vY = aPos.y;',
|
|
'vec4 vp = uView * vec4(aPos, 1.0);',
|
|
'vViewPos = vp.xyz;',
|
|
'gl_Position = uProj * vp;',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var TERRAIN_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform sampler2D uGround;',
|
|
'uniform vec2 uGroundCenter;',
|
|
'uniform vec2 uGroundSize;',
|
|
'uniform vec2 uCorridorRot;',
|
|
'uniform vec3 uSunDir;',
|
|
'uniform vec3 uLight;',
|
|
'uniform vec3 uBg;',
|
|
'uniform vec3 uSky;',
|
|
'uniform vec3 uMoonDir;', // unit vector towards the moon (world)
|
|
'uniform float uFogDist;',
|
|
'uniform float uNightFactor;',
|
|
'uniform float uNightLights;', // 1 = VIIRS city-light overlay active
|
|
'uniform sampler2D uNight;', // VIIRS night-lights layer (same layout as uGround)
|
|
'uniform vec3 uWireColor;', // non-zero -> debug wireframe overlay (flat colour)
|
|
'varying vec3 vNormal;',
|
|
'varying vec2 vUV;',
|
|
'varying float vY;',
|
|
'varying vec3 vViewPos;',
|
|
'void main() {',
|
|
'vec2 gUv = vUV;',
|
|
// Edge fade: beyond the canvas the texture is clamped to the
|
|
// edge pixel, so we fade toward uBg over the last 5%.
|
|
'float perpFade = smoothstep(-0.08, 0.05, gUv.x) * smoothstep(1.08, 0.95, gUv.x);',
|
|
'float longFade = smoothstep(-0.08, 0.05, gUv.y) * smoothstep(1.08, 0.95, gUv.y);',
|
|
'float edgeFade = perpFade * longFade;',
|
|
'vec3 col = texture2D(uGround, gUv).rgb;',
|
|
// ---- Hillshade -------------------------------------------------
|
|
// Classic shaded-relief: strong lambert term for sun-facing vs
|
|
// shadow-facing slopes, plus a sky-ambient term (upward normals
|
|
// collect more skylight), plus a subtle warm/cool slope tint.
|
|
'vec3 n = normalize(vNormal);',
|
|
'float lam = dot(n, uSunDir);',
|
|
'float relief = 0.62 + 0.58 * lam;',
|
|
// shadowed slopes keep a little bounce light instead of going black
|
|
'relief += 0.16 * max(0.0, n.y);',
|
|
'relief = clamp(relief, 0.28, 1.5);',
|
|
'float warm = clamp(lam, 0.0, 1.0);',
|
|
'float cool = clamp(-lam, 0.0, 1.0);',
|
|
// sun-facing slopes get a warm cast, shaded slopes a cool cast
|
|
'vec3 tint = mix(vec3(1.0), vec3(1.09, 1.03, 0.9), warm * 0.8);',
|
|
'tint = mix(tint, vec3(0.82, 0.88, 1.06), cool * 0.6);',
|
|
'col *= relief * tint;',
|
|
// night: lambert relief against a sun BELOW the horizon would
|
|
// crush the map; fade shading back toward the neutral texture
|
|
'col = mix(col, texture2D(uGround, gUv).rgb, uNightFactor * 0.7);',
|
|
// ---- Day/night cross-fade --------------------------------------
|
|
// Starts exactly at sunset and blends over ~the following hour of
|
|
// simulated time (uNightFactor: 0 at alt 0, 1 at -0.4 rad, which
|
|
// the sun covers in ~1 h at 48 deg N). City lights from the VIIRS
|
|
// layer rise out of the dimmed day map.
|
|
'if (uNightFactor > 0.002) {',
|
|
'vec3 lights = vec3(0.0);',
|
|
'if (uNightLights > 0.5) {',
|
|
// uNight is pre-processed offline per tile: luma threshold +
|
|
// heavy blur, colour already multiplied by the glow mask and
|
|
// composited onto black in the canvas - only city lights
|
|
// remain. Just warm-tint and boost it a bit.
|
|
'lights = texture2D(uNight, gUv).rgb;',
|
|
'lights = min(vec3(1.0), lights * vec3(1.35, 1.15, 0.95) * 1.35);',
|
|
'}',
|
|
// with city lights on, the day map may go dark (the lights carry
|
|
// the scene); without them keep it visibly brighter
|
|
'vec3 nightBase = col * mix(0.48, 0.28, uNightLights);',
|
|
'col = mix(col, nightBase + lights, uNightFactor);',
|
|
'}',
|
|
// ---- Distance fog ----------------------------------------------
|
|
// Real view-space distance (uFogDist is in tiles, matching the
|
|
// world-unit scale of the mesh), not a height proxy.
|
|
'float dist = length(vViewPos);',
|
|
'float fogAmount = clamp(dist / max(uFogDist, 0.001), 0.0, 1.0);',
|
|
'fogAmount = pow(fogAmount, 0.8);',
|
|
// lowlands hold more haze than exposed peaks
|
|
'fogAmount *= 0.75 + 0.25 * smoothstep(0.4, 0.0, vY);',
|
|
// horizon colour = EXACTLY what the sky pass paints at/below the
|
|
// horizon (mix(uBg,uSky,0.5) with the same night darkening), so
|
|
// the terrain's outer rim dissolves into the sky without a line.
|
|
'vec3 horizonCol = mix(uBg, uSky, 0.5) * (1.0 - uNightFactor * 0.55);',
|
|
'col = mix(col, horizonCol, fogAmount);',
|
|
// Edge fade to the same horizon colour
|
|
'col = mix(horizonCol, col, edgeFade);',
|
|
// Wireframe overlay: when CONFIG.terrain.wireframe is set the
|
|
// JS sets a non-zero uWireColor; output it flat for line
|
|
// primitives (the mesh edges) so the geometry is clearly
|
|
// visible without the texture/lighting.
|
|
'if (length(uWireColor) > 0.0001) {',
|
|
'gl_FragColor = vec4(uWireColor, 1.0);',
|
|
'return;',
|
|
'}',
|
|
'gl_FragColor = vec4(col, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
function init(canvas) {
|
|
var gl = canvas.getContext('webgl2', { antialias: false, alpha: false })
|
|
|| canvas.getContext('webgl', { antialias: false, alpha: false });
|
|
if (!gl) {
|
|
console.error('clouds3d: WebGL not available');
|
|
return null;
|
|
}
|
|
IS_WEBGL2 = typeof WebGL2RenderingContext !== 'undefined'
|
|
&& gl instanceof WebGL2RenderingContext;
|
|
anisoExt = gl.getExtension('EXT_texture_filter_anisotropic')
|
|
|| gl.getExtension('WEBKIT_EXT_texture_filter_anisotropic')
|
|
|| gl.getExtension('MOZ_EXT_texture_filter_anisotropic');
|
|
if (anisoExt) {
|
|
ANISO_MAX = Math.min(8, gl.getParameter(anisoExt.MAX_TEXTURE_MAX_ANISOTROPY_EXT));
|
|
}
|
|
var prog = makeProgram(gl, VERT_SRC, FRAG_SRC);
|
|
if (!prog) return null;
|
|
|
|
var buf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(gl.ARRAY_BUFFER,
|
|
new Float32Array([ -1,-1, 3,-1, -1,3 ]),
|
|
gl.STATIC_DRAW);
|
|
|
|
var aPos = gl.getAttribLocation(prog, 'aPos');
|
|
var uRes = gl.getUniformLocation(prog, 'uResolution');
|
|
var uTime = gl.getUniformLocation(prog, 'uTime');
|
|
var uGround = gl.getUniformLocation(prog, 'uGround');
|
|
var uGroundCenter = gl.getUniformLocation(prog, 'uGroundCenter');
|
|
var uGroundSize = gl.getUniformLocation(prog, 'uGroundSize');
|
|
var uCorridorRot = gl.getUniformLocation(prog, 'uCorridorRot');
|
|
var uViewProjInv = gl.getUniformLocation(prog, 'uViewProjInv');
|
|
var uSky = gl.getUniformLocation(prog, 'uSky');
|
|
var uCloud = gl.getUniformLocation(prog, 'uCloud');
|
|
var uCloudShadow = gl.getUniformLocation(prog, 'uCloudShadow');
|
|
var uLight = gl.getUniformLocation(prog, 'uLight');
|
|
var uBg = gl.getUniformLocation(prog, 'uBg');
|
|
var uSpeed = gl.getUniformLocation(prog, 'uSpeed');
|
|
var uDensity = gl.getUniformLocation(prog, 'uDensity');
|
|
var uDayProgress = gl.getUniformLocation(prog, 'uDayProgress');
|
|
var uSunAlt = gl.getUniformLocation(prog, 'uSunAlt');
|
|
var uFogDist = gl.getUniformLocation(prog, 'uFogDist');
|
|
var uCloudTop = gl.getUniformLocation(prog, 'uCloudTop');
|
|
var uCloudBot = gl.getUniformLocation(prog, 'uCloudBot');
|
|
var uElev = gl.getUniformLocation(prog, 'uElev');
|
|
var uElevOn = gl.getUniformLocation(prog, 'uElevOn');
|
|
var uElevScale = gl.getUniformLocation(prog, 'uElevScale');
|
|
var uSunDir = gl.getUniformLocation(prog, 'uSunDir');
|
|
var uMoonDir = gl.getUniformLocation(prog, 'uMoonDir');
|
|
var uMode = gl.getUniformLocation(prog, 'uMode');
|
|
var uSkyTex = gl.getUniformLocation(prog, 'uSkyTex');
|
|
var uSkyOn = gl.getUniformLocation(prog, 'uSkyOn');
|
|
var uStarLon = gl.getUniformLocation(prog, 'uStarLon');
|
|
var uPole = gl.getUniformLocation(prog, 'uPole');
|
|
|
|
// ---- Terrain mesh program -----------------------------------------
|
|
// The terrain mesh (positions, normals, UVs in a VBO) is built once
|
|
// from the elevation grid, then rendered as a triangle mesh with
|
|
// proper lambert shading. This replaces the per-pixel heightfield
|
|
// lookup that made the ground look flat and watery.
|
|
var terrainProg = makeProgram(gl, TERRAIN_VERT_SRC, TERRAIN_FRAG_SRC);
|
|
if (!terrainProg) {
|
|
// try to surface the failure explicitly
|
|
var vs = gl.createShader(gl.VERTEX_SHADER);
|
|
gl.shaderSource(vs, TERRAIN_VERT_SRC);
|
|
gl.compileShader(vs);
|
|
var fs = gl.createShader(gl.FRAGMENT_SHADER);
|
|
gl.shaderSource(fs, TERRAIN_FRAG_SRC);
|
|
gl.compileShader(fs);
|
|
console.error('terrain VS log:', gl.getShaderInfoLog(vs));
|
|
console.error('terrain FS log:', gl.getShaderInfoLog(fs));
|
|
}
|
|
var terrainApos = terrainProg ? gl.getAttribLocation(terrainProg, 'aPos') : -1;
|
|
var terrainAnormal = terrainProg ? gl.getAttribLocation(terrainProg, 'aNormal') : -1;
|
|
var terrainAuv = terrainProg ? gl.getAttribLocation(terrainProg, 'aUV') : -1;
|
|
var terrainUview = terrainProg ? gl.getUniformLocation(terrainProg, 'uView') : null;
|
|
var terrainUproj = terrainProg ? gl.getUniformLocation(terrainProg, 'uProj') : null;
|
|
var terrainUground = terrainProg ? gl.getUniformLocation(terrainProg, 'uGround') : null;
|
|
var terrainUsundir = terrainProg ? gl.getUniformLocation(terrainProg, 'uSunDir') : null;
|
|
var terrainUlight = terrainProg ? gl.getUniformLocation(terrainProg, 'uLight') : null;
|
|
var terrainUbg = terrainProg ? gl.getUniformLocation(terrainProg, 'uBg') : null;
|
|
var terrainUsky = terrainProg ? gl.getUniformLocation(terrainProg, 'uSky') : null;
|
|
var terrainUfogdist = terrainProg ? gl.getUniformLocation(terrainProg, 'uFogDist') : null;
|
|
var terrainUnight = terrainProg ? gl.getUniformLocation(terrainProg, 'uNightFactor') : null;
|
|
var terrainUnightlights = terrainProg ? gl.getUniformLocation(terrainProg, 'uNight') : null;
|
|
var terrainUnightsw = terrainProg ? gl.getUniformLocation(terrainProg, 'uNightLights') : null;
|
|
var terrainUgroundcenter = terrainProg ? gl.getUniformLocation(terrainProg, 'uGroundCenter') : null;
|
|
var terrainUgroundsize = terrainProg ? gl.getUniformLocation(terrainProg, 'uGroundSize') : null;
|
|
var terrainUcorridor = terrainProg ? gl.getUniformLocation(terrainProg, 'uCorridorRot') : null;
|
|
var terrainUcloudsOn = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudsOn') : null;
|
|
var terrainUelevScale = terrainProg ? gl.getUniformLocation(terrainProg, 'uElevScale') : null;
|
|
var terrainUwireColor = terrainProg ? gl.getUniformLocation(terrainProg, 'uWireColor') : null;
|
|
|
|
// 1x1 black texture: bound wherever a real layer does not exist yet
|
|
// (e.g. night lights before the first tile, or nightLights: false)
|
|
// so the shaders always sample a valid black.
|
|
var blackTex = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, blackTex);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA,
|
|
gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
|
|
return {
|
|
gl: gl, prog: prog, buf: buf, aPos: aPos,
|
|
blackTex: blackTex,
|
|
uRes: uRes, uTime: uTime, uGround: uGround,
|
|
uGroundCenter: uGroundCenter, uGroundSize: uGroundSize,
|
|
uCorridorRot: uCorridorRot,
|
|
uViewProjInv: uViewProjInv,
|
|
uSky: uSky, uCloud: uCloud,
|
|
uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg,
|
|
uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress,
|
|
uSunAlt: uSunAlt, uFogDist: uFogDist,
|
|
uCloudTop: uCloudTop, uCloudBot: uCloudBot,
|
|
uElev: uElev, uElevOn: uElevOn, uElevScale: uElevScale,
|
|
uSunDir: uSunDir, uMoonDir: uMoonDir, uMode: uMode,
|
|
uSkyTex: uSkyTex, uSkyOn: uSkyOn, uStarLon: uStarLon, uPole: uPole,
|
|
// terrain program
|
|
terrain: {
|
|
prog: terrainProg,
|
|
aPos: terrainApos, aNormal: terrainAnormal, aUV: terrainAuv,
|
|
uView: terrainUview, uProj: terrainUproj,
|
|
uGround: terrainUground, uSunDir: terrainUsundir, uLight: terrainUlight,
|
|
uBg: terrainUbg, uSky: terrainUsky, uFogDist: terrainUfogdist, uNightFactor: terrainUnight,
|
|
uGroundCenter: terrainUgroundcenter, uGroundSize: terrainUgroundsize,
|
|
uCorridorRot: terrainUcorridor, uCloudsOn: terrainUcloudsOn,
|
|
uNight: terrainUnightlights,
|
|
uNightLightsOn: terrainUnightsw,
|
|
uElevScale: terrainUelevScale,
|
|
uWireColor: terrainUwireColor
|
|
// vbo / vertCount / wireVbo / wireVertCount are read from the
|
|
// module-level terrainVbo/terrainVertCount/terrainWireVbo/
|
|
// terrainWireVertCount (set by buildTerrainMesh).
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---- Ground: tile fetching from ESRI World_Street_Map ------------------
|
|
// We fetch a 5x51 tile corridor (plus 2 label-margin canvas columns) of
|
|
// the Passau region, compose it on a 2D canvas and upload it as one
|
|
// WebGL texture. As the camera drifts along the corridor the system
|
|
// re-centres the mosaic (shifting the canvas by whole tiles) when the
|
|
// camera approaches the forward or side buffer edge.
|
|
//
|
|
// 1 world unit = 1 tile at zoom 11 (~13 km). world +X = east = tileX+,
|
|
// world +Z = north = tileY- (slippy tileY grows southward!).
|
|
// uGroundCenter points at the CENTRE of the centre tile; uGroundSize is
|
|
// the canvas extent in tiles, so the shader's world->UV mapping lands
|
|
// exactly where drawTileAt drew each tile: canvas top = corridor front
|
|
// = north, canvas left = west. For fetch=N that means: north up, east
|
|
// right, no mirroring, and re-centring DECREASes tileY as we fly north.
|
|
// Tile source per CONFIG.ground.style:
|
|
// 'streets' - ESRI World_Street_Map (place names), /esri/streets/
|
|
// 'satellite' - ESRI World_Imagery (no labels), /esri/imagery/
|
|
// Both are z11 {z}/{y}/{x}.jpg via the nginx CORS proxy. The IndexedDB
|
|
// id carries a style prefix so both styles can coexist in the cache.
|
|
var GROUND_STYLE_CFG = (window.CONFIG && window.CONFIG.ground &&
|
|
window.CONFIG.ground.style) || 'streets';
|
|
var GROUND_STYLE = GROUND_STYLE_CFG === 'satellite' ? 'satellite' : 'streets';
|
|
var GROUND_TILE_URL = GROUND_STYLE === 'satellite' ? '/esri/imagery/' : '/esri/streets/';
|
|
var GROUND_TILE_ID_PREFIX = GROUND_STYLE === 'satellite' ? 'sat/' : '';
|
|
// Debug tile overlays (border + N/S + x/y labels) - off unless
|
|
// CONFIG.tiles.debug is true.
|
|
var DEBUG_TILES = !!(window.CONFIG && window.CONFIG.tiles && window.CONFIG.tiles.debug);
|
|
var GROUND_TILE_Z = 11;
|
|
// Network tiles are 256px; the mosaic canvas stores them downscaled.
|
|
// Halving the canvas tile size keeps texture memory constant when the
|
|
// mosaic radius doubles, and the on-screen view upscales the texture
|
|
// several times anyway (LINEAR filtered).
|
|
var GROUND_TILE_SIZE = 128;
|
|
// Fetch direction: which way the tile corridor is oriented.
|
|
// Independent of flight direction (set via #fetch=N|NE|E|...).
|
|
// Default N (= +Z).
|
|
var GROUND_TRAVEL_COS = (window.__fetchDir && window.__fetchDir.cos) || 0.0;
|
|
var GROUND_TRAVEL_SIN = (window.__fetchDir && window.__fetchDir.sin) || 1.0;
|
|
var GROUND_CORRIDOR_LONG = 51; // tiles along travel direction (odd: exact centre column)
|
|
var GROUND_CORRIDOR_PERP = 5; // tiles perpendicular to travel
|
|
var GROUND_X_MARGIN_TILES = 2; // extra canvas columns for the x= labels
|
|
// Canvas dimensions in tiles. The shader maps world coords onto the
|
|
// FULL canvas (uGroundSize), so these are the single source of truth.
|
|
function groundTilesX() { return GROUND_CORRIDOR_PERP + GROUND_X_MARGIN_TILES; }
|
|
function groundTilesY() { return GROUND_CORRIDOR_LONG; }
|
|
function groundCanvasW() { return GROUND_TILE_SIZE * groundTilesX(); }
|
|
function groundCanvasH() { return GROUND_TILE_SIZE * groundTilesY(); }
|
|
var GROUND_BUFFER_LONG = 11; // refetch when this close to forward edge
|
|
var GROUND_BUFFER_PERP = 1; // refetch when this close to side edge
|
|
|
|
// ---- Orbit mode (CONFIG.orbit) + ground fog (CONFIG.fog) ---------------
|
|
// When enabled the camera flies a circle around the configured lat/lon
|
|
// instead of a straight line. The mosaic becomes an axis-aligned square
|
|
// centred on the config location; as long as the orbit radius plus the
|
|
// fog visibility range stay inside it, no re-centring ever triggers.
|
|
var _lat = (window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) || 48.5667;
|
|
var KM_PER_TILE = 40075.017 / Math.pow(2, GROUND_TILE_Z) *
|
|
Math.cos(_lat * Math.PI / 180);
|
|
|
|
// Ground fog: distance haze fading terrain into a milky haze. Saturated
|
|
// at CONFIG.fog.distanceKm (default 80); false/0 disables it.
|
|
var FOG_TILES = 99999.0;
|
|
if (window.CONFIG && window.CONFIG.fog && window.CONFIG.fog.distanceKm > 0) {
|
|
FOG_TILES = window.CONFIG.fog.distanceKm / KM_PER_TILE;
|
|
}
|
|
|
|
// Cloud deck vertical extent in world units.
|
|
var ALT_BOB = (window.CONFIG && window.CONFIG.orbit && window.CONFIG.orbit.altBob) || {};
|
|
var BOB_ON = ALT_BOB.enabled !== false;
|
|
// Flight band 15-35 km (hard clamp). The dual sine bob is centered on the
|
|
// band midline; amp ~0.77 world units (= half band of 10 km) makes the
|
|
// waveform reach both limits without clipping.
|
|
var FL_MIN_KM = 15, FL_MAX_KM = 35;
|
|
var CAM_MID_Y = (FL_MIN_KM + FL_MAX_KM) / 2 / KM_PER_TILE;
|
|
var BOB_AMP = typeof ALT_BOB.amp === 'number' ? ALT_BOB.amp : 0.77;
|
|
var BOB_W = 6.2831853 / (typeof ALT_BOB.periodS === 'number' ? ALT_BOB.periodS : 90);
|
|
var BOB_TILT = typeof ALT_BOB.tilt === 'number' ? ALT_BOB.tilt : 0.30; // pitch response
|
|
// CONFIG.clouds = { top: ..., bottom: ... } overrides the defaults; the
|
|
// deck must stay below the camera and keep a minimum thickness.
|
|
var CLOUD_TOP = 0.9, CLOUD_BOT = -0.3;
|
|
if (window.CONFIG && window.CONFIG.clouds) {
|
|
if (typeof window.CONFIG.clouds.top === 'number') CLOUD_TOP = window.CONFIG.clouds.top;
|
|
if (typeof window.CONFIG.clouds.bottom === 'number') CLOUD_BOT = window.CONFIG.clouds.bottom;
|
|
}
|
|
CLOUD_TOP = Math.max(0.2, Math.min(CLOUD_TOP, 2.0));
|
|
CLOUD_BOT = Math.min(CLOUD_BOT, CLOUD_TOP - 0.4);
|
|
|
|
// Terrain elevation grid (CONFIG.terrain). Heights come from Mapzen/
|
|
// TILEZEN "terrarium" tiles (Copernicus DEM) at ELEV_Z, fetched once
|
|
// per session and composited into a second canvas that uses the SAME
|
|
// world->UV convention as the street canvas (64 px per z11 tile), so
|
|
// the shader can sample both textures with the same UVs.
|
|
// NOTE: the grid is axis-aligned to the canvas (N up). That matches
|
|
// the 8-mode square mosaic and straight mode with fetch=N.
|
|
var TERRAIN_CFG = (window.CONFIG && window.CONFIG.terrain) || {};
|
|
var TERRAIN_ON = TERRAIN_CFG.enabled === false ? 0.0 : 1.0;
|
|
var TERRAIN_EXAG = (typeof TERRAIN_CFG.exaggeration === 'number' &&
|
|
TERRAIN_CFG.exaggeration > 0) ? TERRAIN_CFG.exaggeration : 8;
|
|
// metres -> world units, including the vertical exaggeration
|
|
var ELEV_SCALE_UNITS = TERRAIN_EXAG / (KM_PER_TILE * 1000.0);
|
|
|
|
var ORBIT_CFG = (window.CONFIG && window.CONFIG.orbit) || null;
|
|
var ORBIT_R = 0; // figure-8 half-diagonal (lobe tip reach) in tiles
|
|
var ORBIT_DIR = 1; // +1 forward along the path, -1 reverse
|
|
var ORBIT_REORIENT = true; // pick a random bearing at each crossing
|
|
if (ORBIT_CFG && ORBIT_CFG.radiusKm > 0) {
|
|
// 43x43 tile square (~278 km across at z11/48deg N; half radius
|
|
// ~21.5 tiles). At 128 canvas px/tile the texture stays ~127 MB.
|
|
GROUND_TRAVEL_COS = 0.0;
|
|
GROUND_TRAVEL_SIN = 1.0;
|
|
GROUND_CORRIDOR_LONG = 43;
|
|
GROUND_CORRIDOR_PERP = 43;
|
|
GROUND_BUFFER_LONG = 2;
|
|
GROUND_BUFFER_PERP = 2;
|
|
if (typeof ORBIT_CFG.clockwise === 'boolean')
|
|
ORBIT_DIR = ORBIT_CFG.clockwise ? 1 : -1;
|
|
if (typeof ORBIT_CFG.reorient === 'boolean')
|
|
ORBIT_REORIENT = ORBIT_CFG.reorient;
|
|
// Lobe tips sweep a circle of radius ORBIT_R around the crossing
|
|
// (= config location); the fog dome must still fit inside the
|
|
// pre-fade area. Max ~16 tiles (~210 km) with the 43x43 square.
|
|
var _maxR = groundTilesY() * 0.5 * 0.90 - Math.max(3, FOG_TILES * 0.5);
|
|
ORBIT_R = Math.max(0.5, Math.min(ORBIT_CFG.radiusKm / KM_PER_TILE, _maxR));
|
|
}
|
|
|
|
// ---- Figure-eight flight path (Bernoulli lemniscate) --------------------
|
|
// The curve's self-crossing sits exactly at the world origin, which is
|
|
// the configured lat/lon (mosaic centre). The whole eight is rotated
|
|
// about that centre by `figBearing` — so the aircraft passes through the
|
|
// centre at the SAME point no matter what the bearing is. At every
|
|
// crossing we pick a NEW RANDOM bearing and ease into it over one lobe
|
|
// with a smoothstep: that blend is the spline transition through the
|
|
// central crossing (zero slope at both ends, so the rotation starts and
|
|
// settles exactly at the crossings).
|
|
// The lemniscate is re-parameterised by ARC LENGTH (lookup table),
|
|
// because the raw parameter would rush through the crossing and crawl
|
|
// around the lobe tips.
|
|
var FIG8 = null;
|
|
function buildFigure8(a) {
|
|
var DENSE = 4096;
|
|
var xs = new Array(DENSE + 1), zs = new Array(DENSE + 1);
|
|
var cum = new Array(DENSE + 1);
|
|
var i;
|
|
for (i = 0; i <= DENSE; i++) {
|
|
var t = 2 * Math.PI * i / DENSE;
|
|
var st = Math.sin(t), ct = Math.cos(t);
|
|
var den = 1.0 + st * st;
|
|
xs[i] = a * ct / den;
|
|
zs[i] = a * st * ct / den;
|
|
cum[i] = (i === 0) ? 0
|
|
: cum[i - 1] + Math.hypot(xs[i] - xs[i - 1], zs[i] - zs[i - 1]);
|
|
}
|
|
var L = cum[DENSE];
|
|
var N = 2880;
|
|
var pts = [];
|
|
var j = 0;
|
|
for (i = 0; i < N; i++) {
|
|
var target = L * i / N;
|
|
while (j < DENSE - 1 && cum[j + 1] < target) j++;
|
|
var seg = cum[j + 1] - cum[j];
|
|
var f = seg > 0 ? (target - cum[j]) / seg : 0;
|
|
pts.push([xs[j] + (xs[j + 1] - xs[j]) * f,
|
|
zs[j] + (zs[j + 1] - zs[j]) * f]);
|
|
}
|
|
return { pts: pts, N: N, L: L, half: 0.5 * L, firstCross: 0.25 * L };
|
|
}
|
|
|
|
// central-difference unit tangent of the polyline at sample i
|
|
function figTangentAt(i) {
|
|
var N = FIG8.N;
|
|
var pp = FIG8.pts[(i + N - 1) % N], pn = FIG8.pts[(i + 1) % N];
|
|
var tx = pn[0] - pp[0], tz = pn[1] - pp[1];
|
|
var tl = Math.hypot(tx, tz) || 1;
|
|
return [tx / tl, tz / tl];
|
|
}
|
|
|
|
// arc-length position -> {x, z, tx, tz} (unit tangent at +arc direction)
|
|
// Both position AND tangent are interpolated between the bracketing
|
|
// samples: a per-segment constant chord would ratchet the heading in
|
|
// ~0.1s steps, which reads as stutter exactly on the tight turns.
|
|
function figPoint(sMod) {
|
|
var N = FIG8.N;
|
|
var idx = (sMod / FIG8.L) * N;
|
|
var i0 = Math.floor(idx); i0 = ((i0 % N) + N) % N;
|
|
var f = idx - Math.floor(idx);
|
|
var i1 = (i0 + 1) % N;
|
|
var p0 = FIG8.pts[i0], p1 = FIG8.pts[i1];
|
|
var t0 = figTangentAt(i0), t1 = figTangentAt(i1);
|
|
var tx = t0[0] + (t1[0] - t0[0]) * f;
|
|
var tz = t0[1] + (t1[1] - t0[1]) * f;
|
|
var tl = Math.hypot(tx, tz) || 1;
|
|
return {
|
|
x: p0[0] + (p1[0] - p0[0]) * f,
|
|
z: p0[1] + (p1[1] - p0[1]) * f,
|
|
tx: tx / tl, tz: tz / tl
|
|
};
|
|
}
|
|
|
|
// runtime state
|
|
var figArc = 0; // unwrapped travelled arc length (tiles)
|
|
var figPrevArc = 0;
|
|
var figPrevTime = null;
|
|
var figBearing = null; // current rotation of the eight (radians)
|
|
var figFrom = 0, figTo = 0;
|
|
var figBlendStart = -1e9, figBlendSpan = 1;
|
|
|
|
function figNewTurnTarget() {
|
|
// uniform-random bearing mod pi (the lemniscate is symmetric under
|
|
// 180 deg rotation), reached along the SHORTEST equivalent delta
|
|
var target = Math.random() * Math.PI;
|
|
var d = (target - figBearing) % Math.PI;
|
|
if (d > Math.PI / 2) d -= Math.PI;
|
|
if (d < -Math.PI / 2) d += Math.PI;
|
|
return figBearing + d;
|
|
}
|
|
|
|
function figure8Step(nowSec, groundSpeed) {
|
|
if (!FIG8) return { x: 0, z: 0, hx: 1, hz: 0 };
|
|
if (figBearing === null) {
|
|
figBearing = Math.random() * Math.PI;
|
|
figFrom = figTo = figBearing;
|
|
}
|
|
var dt = (figPrevTime === null) ? 0
|
|
: Math.max(0, Math.min(nowSec - figPrevTime, 30)); // clamp tab-away jumps
|
|
figPrevTime = nowSec;
|
|
figPrevArc = figArc;
|
|
figArc += groundSpeed * dt;
|
|
|
|
// crossings sit at arc ≡ firstCross (mod half); count those we
|
|
// passed during this frame (works even for huge dt)
|
|
var first = FIG8.firstCross, h = FIG8.half;
|
|
var kPrev = Math.floor((figPrevArc - first) / h);
|
|
var kNow = Math.floor((figArc - first) / h);
|
|
if (kNow > kPrev && ORBIT_REORIENT) {
|
|
figFrom = figBearing;
|
|
figTo = figNewTurnTarget();
|
|
figBlendStart = figArc;
|
|
figBlendSpan = h; // finished exactly at the next crossing
|
|
}
|
|
|
|
var u = (figArc - figBlendStart) / figBlendSpan;
|
|
u = u < 0 ? 0 : (u > 1 ? 1 : u);
|
|
var e = u * u * (3 - 2 * u);
|
|
figBearing = figFrom + (figTo - figFrom) * e;
|
|
|
|
var sMod = ((ORBIT_DIR * figArc) % FIG8.L + FIG8.L) % FIG8.L;
|
|
var p = figPoint(sMod);
|
|
var cb = Math.cos(figBearing), sb = Math.sin(figBearing);
|
|
return {
|
|
x: p.x * cb - p.z * sb,
|
|
z: p.x * sb + p.z * cb,
|
|
hx: (p.tx * cb - p.tz * sb) * ORBIT_DIR,
|
|
hz: (p.tx * sb + p.tz * cb) * ORBIT_DIR
|
|
};
|
|
}
|
|
|
|
if (ORBIT_R > 0) FIG8 = buildFigure8(ORBIT_R);
|
|
// Anchor tile at zoom 11, computed from the user's CONFIG.location with
|
|
// the same Web Mercator math as the OSM->world transforms (carLonToX/
|
|
// carLatToZ). Fallback if unset: Passau 1100/707.
|
|
var _baseLoc = (window.CONFIG && window.CONFIG.location) || {};
|
|
var _baseLat = typeof _baseLoc.lat === 'number' ? _baseLoc.lat : 48.5667;
|
|
var _baseLon = typeof _baseLoc.lon === 'number' ? _baseLoc.lon : 13.4319;
|
|
var _baseN = 1 << GROUND_TILE_Z;
|
|
var _baseRad = _baseLat * Math.PI / 180;
|
|
var GROUND_BASE_X = Math.floor((_baseLon + 180) / 360 * _baseN);
|
|
var GROUND_BASE_Y = Math.floor((1 - Math.log(Math.tan(_baseRad) + 1 / Math.cos(_baseRad)) / Math.PI) / 2 * _baseN);
|
|
|
|
// Module-level state, shared across renderer instances
|
|
var groundCanvas = null;
|
|
var groundCtx = null;
|
|
var groundTexture = null;
|
|
var groundGl = null; // WebGL context, set by initGroundSystem
|
|
// Set by init(): true when the context is WebGL2. NPOT mipmapping (the
|
|
// ground mosaic is 5760x5504 - not a power of two) only works there.
|
|
var IS_WEBGL2 = false;
|
|
var anisoExt = null; // EXT_texture_filter_anisotropic, or null
|
|
var ANISO_MAX = 1;
|
|
var groundCenterTile = { x: GROUND_BASE_X, y: GROUND_BASE_Y };
|
|
var groundLoadedTiles = {}; // "x_y" -> true
|
|
var groundLastCheck = 0;
|
|
var groundUpdatePending = false;
|
|
var groundCameraX = 0;
|
|
var groundCameraZ = 0;
|
|
|
|
// ---- Tile cache (IndexedDB) ---------------------------------------------
|
|
// Tiles are cached as Blobs in IndexedDB (localStorage would cap out far
|
|
// below the ~16 MB corridor). Map data is effectively static, so cached
|
|
// tiles are kept PERMANENTLY and never re-fetched; set
|
|
// CONFIG.tiles.expireDays to a number to opt back into periodic refresh.
|
|
// A cached tile is only skipped by the network if it is present at all -
|
|
// with a warm cache the whole app renders without any network.
|
|
// The DB is also exposed via window.BackgroundCache (used by
|
|
// background.html for the last weather response and geo label).
|
|
var TILE_DB_NAME = 'background-tiles';
|
|
var TILE_STORE = 'tiles';
|
|
var _tileExpireCfg = window.CONFIG && window.CONFIG.tiles;
|
|
var TILE_MAX_AGE_MS = (_tileExpireCfg && typeof _tileExpireCfg.expireDays === 'number' && _tileExpireCfg.expireDays > 0)
|
|
? _tileExpireCfg.expireDays * 24 * 3600 * 1000
|
|
: Infinity;
|
|
var tileDbPromise = null;
|
|
|
|
function openTileDB() {
|
|
if (tileDbPromise) return tileDbPromise;
|
|
tileDbPromise = new Promise(function (resolve) {
|
|
if (!('indexedDB' in window)) { resolve(null); return; }
|
|
var req;
|
|
try { req = indexedDB.open(TILE_DB_NAME, 1); }
|
|
catch (e) { resolve(null); return; }
|
|
req.onupgradeneeded = function () {
|
|
if (!req.result.objectStoreNames.contains(TILE_STORE)) {
|
|
req.result.createObjectStore(TILE_STORE, { keyPath: 'id' });
|
|
}
|
|
};
|
|
req.onsuccess = function () { resolve(req.result); };
|
|
req.onerror = function () { resolve(null); };
|
|
req.onblocked = function () { resolve(null); };
|
|
});
|
|
return tileDbPromise;
|
|
}
|
|
|
|
function tileDbGet(db, id) {
|
|
return new Promise(function (resolve) {
|
|
if (!db) { resolve(null); return; }
|
|
try {
|
|
var tx = db.transaction(TILE_STORE, 'readonly');
|
|
tx.objectStore(TILE_STORE).get(id).onsuccess = function (e) {
|
|
resolve(e.target.result || null);
|
|
};
|
|
tx.onerror = tx.onabort = function () { resolve(null); };
|
|
} catch (e) { resolve(null); }
|
|
});
|
|
}
|
|
|
|
function tileDbPut(db, rec) {
|
|
if (!db) return;
|
|
try {
|
|
var tx = db.transaction(TILE_STORE, 'readwrite');
|
|
tx.objectStore(TILE_STORE).put(rec);
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
|
|
// Shared cache API: background.html stores the last weather payload and
|
|
// the reverse-geocoded label here as well, so the page works offline.
|
|
window.BackgroundCache = {
|
|
get: function (id) {
|
|
return openTileDB().then(function (db) { return tileDbGet(db, id); });
|
|
},
|
|
put: function (id, fields) {
|
|
return openTileDB().then(function (db) {
|
|
var rec = fields || {};
|
|
rec.id = id;
|
|
if (!rec.savedAt) rec.savedAt = Date.now();
|
|
tileDbPut(db, rec);
|
|
});
|
|
}
|
|
};
|
|
|
|
function fetchTileBlob(tileX, tileY) {
|
|
// ESRI World_Street_Map format: {z}/{y}/{x}.jpg via local
|
|
// proxy. (y before x, jpg not png)
|
|
return fetch(GROUND_TILE_URL + GROUND_TILE_Z + '/' + tileY + '/' + tileX + '.jpg')
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('tile http ' + r.status);
|
|
return r.blob();
|
|
});
|
|
}
|
|
|
|
function decodeToBitmap(blob) {
|
|
if (window.createImageBitmap) return createImageBitmap(blob);
|
|
return new Promise(function (resolve, reject) {
|
|
var img = new Image();
|
|
img.onload = function () { resolve(img); };
|
|
img.onerror = function () { reject(new Error('tile decode failed')); };
|
|
img.src = URL.createObjectURL(blob);
|
|
});
|
|
}
|
|
|
|
// Resolve { img, upgrade }. upgrade (when present) is a promise of a
|
|
// fresher ImageBitmap to redraw with once the background refresh lands.
|
|
function loadTile(tileX, tileY) {
|
|
var id = GROUND_TILE_ID_PREFIX + GROUND_TILE_Z + '/' + tileX + '/' + tileY;
|
|
function fromNetwork() {
|
|
return fetchTileBlob(tileX, tileY).then(function (blob) {
|
|
return decodeToBitmap(blob).then(function (img) {
|
|
return openTileDB().then(function (db) {
|
|
tileDbPut(db, { id: id, blob: blob, savedAt: Date.now() });
|
|
return { img: img, upgrade: null };
|
|
});
|
|
});
|
|
});
|
|
}
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, id).then(function (rec) {
|
|
if (rec && (Date.now() - rec.savedAt < TILE_MAX_AGE_MS)) {
|
|
// fresh cache: no network at all
|
|
return decodeToBitmap(rec.blob).then(
|
|
function (img) { return { img: img, upgrade: null }; },
|
|
function () { return fromNetwork(); });
|
|
}
|
|
if (rec) {
|
|
// stale cache: draw it now (offline-safe), refresh in background
|
|
return decodeToBitmap(rec.blob).then(
|
|
function (img) {
|
|
return {
|
|
img: img,
|
|
upgrade: fromNetwork().then(function (r) { return r.img; })
|
|
};
|
|
},
|
|
function () { return fromNetwork(); });
|
|
}
|
|
return fromNetwork(); // cold: network or gray placeholder
|
|
});
|
|
});
|
|
}
|
|
|
|
// Blit one tile plus its border and debug labels at a canvas position.
|
|
// All offsets/fonts scale with GROUND_TILE_SIZE.
|
|
function drawTileImage(img, canvasX, canvasY, tileX, tileY) {
|
|
var T = GROUND_TILE_SIZE;
|
|
var pad = Math.max(3, Math.round(T * 0.047));
|
|
groundCtx.drawImage(img, canvasX, canvasY, T, T);
|
|
groundLoadedTiles[tileX + '_' + tileY] = true;
|
|
// keep the night-lights layer in lockstep with the street mosaic
|
|
placeNightTile(tileX, tileY, canvasX, canvasY);
|
|
|
|
// Debug tile overlays (CONFIG.tiles.debug): border + N/S + x/y labels.
|
|
if (DEBUG_TILES) {
|
|
// Thin border so the user can see where one tile ends and the
|
|
// next begins.
|
|
groundCtx.strokeStyle = 'rgba(0,0,0,0.55)';
|
|
groundCtx.lineWidth = 2;
|
|
groundCtx.strokeRect(canvasX + 1, canvasY + 1, T - 2, T - 2);
|
|
|
|
// Corner labels (debug aids), all in normal orientation.
|
|
groundCtx.fillStyle = 'rgba(255,255,255,0.95)';
|
|
groundCtx.strokeStyle = 'rgba(0,0,0,0.85)';
|
|
groundCtx.lineWidth = Math.max(2, Math.round(T * 0.012));
|
|
groundCtx.font = 'bold ' + Math.max(10, Math.round(T * 0.156)) + 'px sans-serif';
|
|
groundCtx.textAlign = 'left';
|
|
// N at top-left of tile area (image north is at canvas top)
|
|
groundCtx.strokeText('N', canvasX + pad, canvasY + Math.round(T * 0.172));
|
|
groundCtx.fillText('N', canvasX + pad, canvasY + Math.round(T * 0.172));
|
|
// S at bottom-left
|
|
groundCtx.strokeText('S', canvasX + pad, canvasY + Math.round(T * 0.953));
|
|
groundCtx.fillText('S', canvasX + pad, canvasY + Math.round(T * 0.953));
|
|
// Tile coordinates
|
|
groundCtx.font = Math.max(7, Math.round(T * 0.07)) + 'px monospace';
|
|
groundCtx.textAlign = 'right';
|
|
// y at top-right
|
|
groundCtx.strokeText('y=' + tileY, canvasX + T - pad, canvasY + Math.round(T * 0.11));
|
|
groundCtx.fillText('y=' + tileY, canvasX + T - pad, canvasY + Math.round(T * 0.11));
|
|
// x at bottom-right
|
|
groundCtx.strokeText('x=' + tileX, canvasX + T - pad, canvasY + T - pad);
|
|
groundCtx.fillText('x=' + tileX, canvasX + T - pad, canvasY + T - pad);
|
|
}
|
|
}
|
|
|
|
// Fallback tile for "offline and nothing cached": a paper-white dummy
|
|
// with a border and an OFFLINE stamp. Not hashed as loaded, so a later
|
|
// rebuild (or page reload once the network is back) replaces it.
|
|
function drawOfflineTile(canvasX, canvasY, tileX, tileY) {
|
|
var T = GROUND_TILE_SIZE;
|
|
var pad = Math.max(3, Math.round(T * 0.047));
|
|
groundCtx.fillStyle = '#f4f1ea';
|
|
groundCtx.fillRect(canvasX, canvasY, T, T);
|
|
groundCtx.strokeStyle = 'rgba(0,0,0,0.35)';
|
|
groundCtx.lineWidth = 2;
|
|
groundCtx.strokeRect(canvasX + 1, canvasY + 1, T - 2, T - 2);
|
|
groundCtx.fillStyle = '#9a958b';
|
|
groundCtx.font = 'bold ' + Math.max(9, Math.round(T * 0.133)) + 'px sans-serif';
|
|
groundCtx.textAlign = 'center';
|
|
groundCtx.fillText('OFFLINE', canvasX + T / 2, canvasY + T / 2 + Math.round(T * 0.05));
|
|
// tile coords bottom-right, same style as the real tiles
|
|
groundCtx.font = Math.max(7, Math.round(T * 0.07)) + 'px monospace';
|
|
groundCtx.textAlign = 'right';
|
|
groundCtx.fillStyle = '#6b665e';
|
|
groundCtx.fillText('x=' + tileX + ' y=' + tileY,
|
|
canvasX + T - pad, canvasY + T - pad);
|
|
}
|
|
|
|
function drawTileAt(lng, perp, dx, dz, centerTileX, centerTileY) {
|
|
var tileX = centerTileX + dx;
|
|
var tileY = centerTileY + dz;
|
|
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
|
|
// Canvas placement, matching the shader mapping exactly:
|
|
// - canvas columns left->right = world west->east for fetch N
|
|
// (canvas col = (tilesX-1)/2 - perp, and perp = -worldX)
|
|
// - tileY grows SOUTHWARD in the slippy/ESRI scheme, while world +Z
|
|
// is the flight (north) direction: worldZ = BASE_Y - tileY. So the
|
|
// canvas row = (halfLong + tileY-offset): the northernmost tiles
|
|
// (smallest tileY) sit at canvas top. With UNPACK_FLIP_Y_WEBGL
|
|
// the canvas top is texture V=1, and the shader samples corridor
|
|
// front (+worldZ, north) at gUv.y = 1.
|
|
// No image flipping anywhere: tile content and labels read normally.
|
|
var canvasX = ((groundTilesX() - 1) / 2 - perp) * GROUND_TILE_SIZE;
|
|
var canvasY = (halfLong + lng) * GROUND_TILE_SIZE;
|
|
if (canvasX < 0 || canvasX >= groundCanvas.width || canvasY < 0 || canvasY >= groundCanvas.height) {
|
|
return Promise.resolve(false);
|
|
}
|
|
return loadTile(tileX, tileY).then(function (r) {
|
|
drawTileImage(r.img, canvasX, canvasY, tileX, tileY);
|
|
if (r.upgrade) {
|
|
r.upgrade.then(function (img) {
|
|
if (!img) return;
|
|
drawTileImage(img, canvasX, canvasY, tileX, tileY);
|
|
groundTextureDirty = true;
|
|
}).catch(function () { /* offline: keep cached draw */ });
|
|
}
|
|
return true;
|
|
}).catch(function () {
|
|
// Offline with empty cache (or tile fetch failure): paper dummy
|
|
drawOfflineTile(canvasX, canvasY, tileX, tileY);
|
|
return false;
|
|
});
|
|
}
|
|
|
|
// Rebuild the mosaic centred on the given tile. Draws every corridor
|
|
// tile that is not yet loaded (already-drawn tiles are skipped, so a
|
|
// re-centre flashes nothing), then uploads the canvas to the texture.
|
|
// With a 43x43 square the burst of 1849 fetch+decode+draw operations
|
|
// would stutter the flight for seconds, so the work is queued: a few
|
|
// tiles in flight at a time, ordered from the centre outwards (the
|
|
// area under the aircraft fills first), yielding between batches so
|
|
// the main thread keeps the render loop smooth.
|
|
var GROUND_QUEUE_CONC = 3; // tiles fetched concurrently
|
|
var GROUND_QUEUE_YIELD_MS = 24; // main-thread breathing between batches
|
|
function rebuildGroundMosaic(centerTileX, centerTileY) {
|
|
groundCenterTile = { x: centerTileX, y: centerTileY };
|
|
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
|
|
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
|
|
var jobs = [];
|
|
for (var perp = -halfPerp; perp <= halfPerp; perp++) {
|
|
for (var lng = -halfLong; lng <= halfLong; lng++) {
|
|
// Tile offset from camera in world (tile) coords, rotated
|
|
// along the travel direction so the corridor follows the
|
|
// camera's actual path, not just the +X axis.
|
|
var dx = Math.round(lng * GROUND_TRAVEL_COS - perp * GROUND_TRAVEL_SIN);
|
|
var dz = Math.round(lng * GROUND_TRAVEL_SIN + perp * GROUND_TRAVEL_COS);
|
|
var tileX = centerTileX + dx;
|
|
var tileY = centerTileY + dz;
|
|
if (groundLoadedTiles[tileX + '_' + tileY]) continue;
|
|
jobs.push({ lng: lng, perp: perp, dx: dx, dz: dz });
|
|
}
|
|
}
|
|
// centre-outwards: the tiles the camera sees right now come first
|
|
jobs.sort(function (a, b) {
|
|
return (a.lng * a.lng + a.perp * a.perp) - (b.lng * b.lng + b.perp * b.perp);
|
|
});
|
|
return runGroundQueue(jobs, centerTileX, centerTileY, 0);
|
|
}
|
|
|
|
function runGroundQueue(jobs, centerTileX, centerTileY, idx) {
|
|
if (idx >= jobs.length) {
|
|
groundTextureDirty = true;
|
|
return Promise.resolve();
|
|
}
|
|
var batch = [];
|
|
var n = Math.min(idx + GROUND_QUEUE_CONC, jobs.length);
|
|
for (var i = idx; i < n; i++) {
|
|
var j = jobs[i];
|
|
batch.push(drawTileAt(j.lng, j.perp, j.dx, j.dz, centerTileX, centerTileY));
|
|
}
|
|
return Promise.all(batch).then(function () {
|
|
// let the render loop upload at its own coalesced cadence
|
|
groundTextureDirty = true;
|
|
return new Promise(function (res) { setTimeout(res, GROUND_QUEUE_YIELD_MS); })
|
|
.then(function () {
|
|
return runGroundQueue(jobs, centerTileX, centerTileY, n);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Copy the mosaic canvas into the WebGL texture. pixelStorei
|
|
// (UNPACK_FLIP_Y_WEBGL=true) is set once at init and persists.
|
|
// 5760x5504 uploads cost tens of ms of main-thread time, so callers
|
|
// should set groundTextureDirty and let the render loop coalesce them
|
|
// (at most one upload per GROUND_UPLOAD_MIN_MS) instead of uploading
|
|
// inline - inline uploads right on top of a rAF frame are felt as
|
|
// flight jank.
|
|
var groundTextureDirty = false;
|
|
var groundLastUpload = 0;
|
|
var GROUND_UPLOAD_MIN_MS = 1500;
|
|
function uploadGroundTexture() {
|
|
if (groundTexture && groundGl) {
|
|
groundGl.bindTexture(groundGl.TEXTURE_2D, groundTexture);
|
|
groundGl.texImage2D(groundGl.TEXTURE_2D, 0, groundGl.RGBA, groundGl.RGBA, groundGl.UNSIGNED_BYTE, groundCanvas);
|
|
if (IS_WEBGL2) groundGl.generateMipmap(groundGl.TEXTURE_2D);
|
|
}
|
|
// the night canvas is small (8 MB) - safe to upload together
|
|
if (nightTextureDirty) uploadNightTexture();
|
|
groundLastUpload = performance.now();
|
|
groundTextureDirty = false;
|
|
}
|
|
|
|
// Periodic ground maintenance, called from the render loop every ~1.5s.
|
|
// Re-centres the mosaic when the camera approaches the corridor's front
|
|
// or side edge. groundCameraX/Z are the camera's continuous world
|
|
// position (1 world unit = 1 tile).
|
|
function refreshGroundMosaic() {
|
|
if (!groundCtx || groundUpdatePending) return;
|
|
var cameraTileX = GROUND_BASE_X + Math.floor(groundCameraX);
|
|
// world +Z = north, tileY grows south: flying forward DEcreases tileY
|
|
var cameraTileY = GROUND_BASE_Y - Math.floor(groundCameraZ);
|
|
var dx = cameraTileX - groundCenterTile.x;
|
|
var dy = cameraTileY - groundCenterTile.y;
|
|
// Project the centre->camera offset onto the corridor axes (tile space).
|
|
var dLng = dx * GROUND_TRAVEL_COS + dy * GROUND_TRAVEL_SIN;
|
|
var dPerp = -dx * GROUND_TRAVEL_SIN + dy * GROUND_TRAVEL_COS;
|
|
var halfLong = Math.floor(GROUND_CORRIDOR_LONG / 2);
|
|
var halfPerp = Math.floor(GROUND_CORRIDOR_PERP / 2);
|
|
if (dLng > halfLong - GROUND_BUFFER_LONG ||
|
|
dLng < -halfLong + GROUND_BUFFER_LONG ||
|
|
dPerp > halfPerp - GROUND_BUFFER_PERP ||
|
|
dPerp < -halfPerp + GROUND_BUFFER_PERP) {
|
|
groundUpdatePending = true;
|
|
recenterGroundMosaic(cameraTileX, cameraTileY, dLng, dPerp).then(function() {
|
|
groundUpdatePending = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Move the mosaic centre to (centerTileX, centerTileY). The canvas is
|
|
// corridor-fixed, so changing the centre must not leave the already-
|
|
// drawn tiles at stale positions: a tile that was at corridor coord
|
|
// (lng, perp) relative to the old centre is at (lng - dLng, perp - dPerp)
|
|
// relative to the new one, so its canvas position moves by
|
|
// (+dPerp, -dLng) tiles (rows grow toward +lng). For the fractional
|
|
// shifts that diagonal corridors produce we cannot shift whole-tile,
|
|
// so wipe and redraw everything.
|
|
function recenterGroundMosaic(centerTileX, centerTileY, dLng, dPerp) {
|
|
var wholeShift = Math.abs(dLng - Math.round(dLng)) < 1e-9 &&
|
|
Math.abs(dPerp - Math.round(dPerp)) < 1e-9;
|
|
if (wholeShift && (dPerp !== 0 || dLng !== 0)) {
|
|
var tmp = document.createElement('canvas');
|
|
tmp.width = groundCanvas.width;
|
|
tmp.height = groundCanvas.height;
|
|
tmp.getContext('2d').drawImage(groundCanvas, 0, 0);
|
|
groundCtx.fillStyle = '#7c9aa1';
|
|
groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height);
|
|
groundCtx.drawImage(tmp,
|
|
Math.round(dPerp) * GROUND_TILE_SIZE,
|
|
-Math.round(dLng) * GROUND_TILE_SIZE);
|
|
// shift the night layer identically (4x smaller pixel scale)
|
|
if (nightCtx) {
|
|
var tmpn = document.createElement('canvas');
|
|
tmpn.width = nightCanvas.width;
|
|
tmpn.height = nightCanvas.height;
|
|
tmpn.getContext('2d').drawImage(nightCanvas, 0, 0);
|
|
nightCtx.fillStyle = '#000';
|
|
nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height);
|
|
nightCtx.drawImage(tmpn,
|
|
Math.round(dPerp) * NIGHT_UNIT_PX,
|
|
-Math.round(dLng) * NIGHT_UNIT_PX);
|
|
nightTextureDirty = true;
|
|
}
|
|
} else {
|
|
// Wipe: already-loaded tiles would sit at wrong canvas positions
|
|
// relative to the new centre. Drop them all and redraw.
|
|
groundLoadedTiles = {};
|
|
groundCtx.fillStyle = '#7c9aa1';
|
|
groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height);
|
|
if (nightCtx) {
|
|
nightCtx.fillStyle = '#000';
|
|
nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height);
|
|
nightTextureDirty = true;
|
|
}
|
|
}
|
|
// Upload the shifted/wiped canvas right away: the shader already
|
|
// uses the new centre, so the texture must match it even while the
|
|
// newly exposed tiles are still fetching.
|
|
uploadGroundTexture();
|
|
return rebuildGroundMosaic(centerTileX, centerTileY);
|
|
}
|
|
|
|
// ---- Terrain elevation grid (terrarium tiles) ---------------------------
|
|
var ELEV_Z = 10;
|
|
var ELEV_TILES_PER = 1 << (11 - ELEV_Z); // z11 tiles covered per DEM tile
|
|
var ELEV_UNIT_PX = 256 / ELEV_TILES_PER; // canvas px per z11 tile
|
|
var ELEV_TILE_URL = '/terrarium/';
|
|
var elevCanvas = null;
|
|
var elevCtx = null;
|
|
var elevTexture = null;
|
|
var elevGl = null;
|
|
var elevLastUpload = 0;
|
|
var clouds3dState = null; // set by init(), read by buildElevationSystem/buildTerrainMesh
|
|
|
|
function uploadElevTexture() {
|
|
elevLastUpload = performance.now();
|
|
if (!elevGl || !elevTexture) return;
|
|
elevGl.bindTexture(elevGl.TEXTURE_2D, elevTexture);
|
|
elevGl.pixelStorei(elevGl.UNPACK_FLIP_Y_WEBGL, true);
|
|
elevGl.texImage2D(elevGl.TEXTURE_2D, 0, elevGl.RGBA, elevGl.RGBA,
|
|
elevGl.UNSIGNED_BYTE, elevCanvas);
|
|
elevGl.texParameteri(elevGl.TEXTURE_2D, elevGl.TEXTURE_WRAP_S, elevGl.CLAMP_TO_EDGE);
|
|
elevGl.texParameteri(elevGl.TEXTURE_2D, elevGl.TEXTURE_WRAP_T, elevGl.CLAMP_TO_EDGE);
|
|
elevGl.texParameteri(elevGl.TEXTURE_2D, elevGl.TEXTURE_MIN_FILTER, elevGl.LINEAR);
|
|
elevGl.texParameteri(elevGl.TEXTURE_2D, elevGl.TEXTURE_MAG_FILTER, elevGl.LINEAR);
|
|
}
|
|
|
|
function loadElevTile(zx, zy, halfX, halfY) {
|
|
var id = 'elev/' + ELEV_Z + '/' + zx + '/' + zy;
|
|
function place(img) {
|
|
// canvas placement mirrors drawTileAt: left->right = worldX+,
|
|
// top = worldZ max (north), via the shared flipY upload
|
|
var cx = (ELEV_TILES_PER * zx - GROUND_BASE_X + halfX) * ELEV_UNIT_PX;
|
|
var cy = (ELEV_TILES_PER * zy - GROUND_BASE_Y + halfY) * ELEV_UNIT_PX;
|
|
elevCtx.drawImage(img, cx, cy);
|
|
}
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, id).then(function (rec) {
|
|
if (rec && rec.blob) {
|
|
// cached DEM is static: use it, never re-fetch
|
|
return decodeToBitmap(rec.blob).then(place, function () {
|
|
return fetchFresh(db);
|
|
});
|
|
}
|
|
return fetchFresh(db);
|
|
});
|
|
});
|
|
function fetchFresh(db) {
|
|
return fetch(ELEV_TILE_URL + ELEV_Z + '/' + zx + '/' + zy + '.png')
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('elev http ' + r.status);
|
|
return r.blob();
|
|
})
|
|
.then(function (blob) {
|
|
if (db) tileDbPut(db, { id: id, blob: blob, savedAt: Date.now() });
|
|
return decodeToBitmap(blob);
|
|
})
|
|
.then(place)
|
|
.catch(function (e) {
|
|
console.warn('[clouds3d] elev tile failed z' + ELEV_Z + '/' + zx + '/' + zy + ': ' + (e && e.message || e));
|
|
/* keep 0 m fill for this tile */
|
|
});
|
|
}
|
|
}
|
|
|
|
// Dripping-queue fetch of the ~22x22 z10 tiles covering the mosaic.
|
|
// Cached permanently in IndexedDB (the DEM does not change).
|
|
function fetchElevationGrid() {
|
|
var halfX = groundTilesX() / 2;
|
|
var halfY = groundTilesY() / 2;
|
|
var jobs = [];
|
|
var zx0 = Math.floor((GROUND_BASE_X - halfX) / ELEV_TILES_PER);
|
|
var zx1 = Math.floor((GROUND_BASE_X + halfX) / ELEV_TILES_PER);
|
|
var zy0 = Math.floor((GROUND_BASE_Y - halfY) / ELEV_TILES_PER);
|
|
var zy1 = Math.floor((GROUND_BASE_Y + halfY) / ELEV_TILES_PER);
|
|
for (var zx = zx0; zx <= zx1; zx++)
|
|
for (var zy = zy0; zy <= zy1; zy++)
|
|
jobs.push([zx, zy]);
|
|
function next(i) {
|
|
if (i >= jobs.length) {
|
|
uploadElevTexture();
|
|
if (TERRAIN_ON > 0.5) {
|
|
buildTerrainMesh(elevGl, clouds3dState);
|
|
}
|
|
return;
|
|
}
|
|
var n = Math.min(i + GROUND_QUEUE_CONC, jobs.length);
|
|
var batch = [];
|
|
for (var k = i; k < n; k++)
|
|
batch.push(loadElevTile(jobs[k][0], jobs[k][1], halfX, halfY));
|
|
Promise.all(batch).then(function () {
|
|
// progressive so terrain grows in, but throttled: the z10
|
|
// mosaic is 5760x5504 and every upload costs ~120 MB of
|
|
// GPU traffic, so at most one per 1500 ms mid-queue.
|
|
if (performance.now() - elevLastUpload > 1500) uploadElevTexture();
|
|
setTimeout(function () { next(n); }, GROUND_QUEUE_YIELD_MS);
|
|
});
|
|
}
|
|
next(0);
|
|
}
|
|
|
|
function buildElevationSystem(glCtx) {
|
|
if (elevCanvas) return;
|
|
elevGl = glCtx;
|
|
elevCanvas = document.createElement('canvas');
|
|
elevCanvas.width = groundTilesX() * ELEV_UNIT_PX;
|
|
elevCanvas.height = groundTilesY() * ELEV_UNIT_PX;
|
|
elevCtx = elevCanvas.getContext('2d');
|
|
// 0 m in terrarium encoding (R=128, G=0, B=0)
|
|
elevCtx.fillStyle = 'rgb(128,0,0)';
|
|
elevCtx.fillRect(0, 0, elevCanvas.width, elevCanvas.height);
|
|
elevTexture = elevGl.createTexture();
|
|
uploadElevTexture();
|
|
if (TERRAIN_ON > 0.5) fetchElevationGrid();
|
|
}
|
|
|
|
// ---- 3D math helpers (used by the terrain mesh + view-proj matrices) ----
|
|
// 4x4 matrix laid out column-major (the same layout gl.uniformMatrix4fv
|
|
// expects) as a flat Float32Array of 16 entries.
|
|
function mat4Identity(out) {
|
|
out[0]=1;out[1]=0;out[2]=0;out[3]=0;
|
|
out[4]=0;out[5]=1;out[6]=0;out[7]=0;
|
|
out[8]=0;out[9]=0;out[10]=1;out[11]=0;
|
|
out[12]=0;out[13]=0;out[14]=0;out[15]=1;
|
|
return out;
|
|
}
|
|
function mat4LookAt(eye, target, up) {
|
|
// z axis = (eye - target) normalized (camera looks down -z)
|
|
var zx = eye[0]-target[0], zy = eye[1]-target[1], zz = eye[2]-target[2];
|
|
var zl = Math.hypot(zx, zy, zz) || 1;
|
|
zx /= zl; zy /= zl; zz /= zl;
|
|
// x = up x z (orthonormal)
|
|
var xx = up[1]*zz - up[2]*zy;
|
|
var xy = up[2]*zx - up[0]*zz;
|
|
var xz = up[0]*zy - up[1]*zx;
|
|
var xl = Math.hypot(xx, xy, xz) || 1;
|
|
xx /= xl; xy /= xl; xz /= xl;
|
|
// y = z x x
|
|
var yx = zy*xz - zz*xy;
|
|
var yy = zz*xx - zx*xz;
|
|
var yz = zx*xy - zy*xx;
|
|
return new Float32Array([
|
|
xx, yx, zx, 0,
|
|
xy, yy, zy, 0,
|
|
xz, yz, zz, 0,
|
|
-(xx*eye[0]+xy*eye[1]+xz*eye[2]),
|
|
-(yx*eye[0]+yy*eye[1]+yz*eye[2]),
|
|
-(zx*eye[0]+zy*eye[1]+zz*eye[2]),
|
|
1
|
|
]);
|
|
}
|
|
function mat4Perspective(fovY, aspect, near, far) {
|
|
var f = 1 / Math.tan(fovY / 2);
|
|
var nf = 1 / (near - far);
|
|
return new Float32Array([
|
|
f / aspect, 0, 0, 0,
|
|
0, f, 0, 0,
|
|
0, 0, (far + near) * nf, -1,
|
|
0, 0, 2 * far * near * nf, 0
|
|
]);
|
|
}
|
|
// 4x4 matrix multiply: out = a * b (column-major Float32Array of 16).
|
|
function mat4Multiply(out, a, b) {
|
|
var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3];
|
|
var a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7];
|
|
var a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11];
|
|
var a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
|
|
for (var i = 0; i < 4; i++) {
|
|
var b0 = b[i * 4], b1 = b[i * 4 + 1], b2 = b[i * 4 + 2], b3 = b[i * 4 + 3];
|
|
out[i * 4] = a00 * b0 + a10 * b1 + a20 * b2 + a30 * b3;
|
|
out[i * 4 + 1] = a01 * b0 + a11 * b1 + a21 * b2 + a31 * b3;
|
|
out[i * 4 + 2] = a02 * b0 + a12 * b1 + a22 * b2 + a32 * b3;
|
|
out[i * 4 + 3] = a03 * b0 + a13 * b1 + a23 * b2 + a33 * b3;
|
|
}
|
|
return out;
|
|
}
|
|
// 4x4 matrix inverse (column-major, generic).
|
|
function mat4Inverse(out, m) {
|
|
var m00 = m[0], m01 = m[1], m02 = m[2], m03 = m[3];
|
|
var m10 = m[4], m11 = m[5], m12 = m[6], m13 = m[7];
|
|
var m20 = m[8], m21 = m[9], m22 = m[10], m23 = m[11];
|
|
var m30 = m[12], m31 = m[13], m32 = m[14], m33 = m[15];
|
|
var b00 = m00 * m11 - m01 * m10;
|
|
var b01 = m00 * m12 - m02 * m10;
|
|
var b02 = m00 * m13 - m03 * m10;
|
|
var b03 = m01 * m12 - m02 * m11;
|
|
var b04 = m01 * m13 - m03 * m11;
|
|
var b05 = m02 * m13 - m03 * m12;
|
|
var b06 = m20 * m31 - m21 * m30;
|
|
var b07 = m20 * m32 - m22 * m30;
|
|
var b08 = m20 * m33 - m23 * m30;
|
|
var b09 = m21 * m32 - m22 * m31;
|
|
var b10 = m21 * m33 - m23 * m31;
|
|
var b11 = m22 * m33 - m23 * m32;
|
|
var det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
|
|
if (!det) return null;
|
|
det = 1.0 / det;
|
|
out[0] = ( m11 * b11 - m12 * b10 + m13 * b09) * det;
|
|
out[1] = (-m01 * b11 + m02 * b10 - m03 * b09) * det;
|
|
out[2] = ( m31 * b05 - m32 * b04 + m33 * b03) * det;
|
|
out[3] = (-m21 * b05 + m22 * b04 - m23 * b03) * det;
|
|
out[4] = (-m10 * b11 + m12 * b08 - m13 * b07) * det;
|
|
out[5] = ( m00 * b11 - m02 * b08 + m03 * b07) * det;
|
|
out[6] = (-m30 * b05 + m32 * b02 - m33 * b01) * det;
|
|
out[7] = ( m20 * b05 - m22 * b02 + m23 * b01) * det;
|
|
out[8] = ( m10 * b10 - m11 * b08 + m13 * b06) * det;
|
|
out[9] = (-m00 * b10 + m01 * b08 - m03 * b06) * det;
|
|
out[10] = ( m30 * b04 - m31 * b02 + m33 * b00) * det;
|
|
out[11] = (-m20 * b04 + m21 * b02 - m23 * b00) * det;
|
|
out[12] = (-m10 * b09 + m11 * b07 - m12 * b06) * det;
|
|
out[13] = ( m00 * b09 - m01 * b07 + m02 * b06) * det;
|
|
out[14] = (-m30 * b03 + m31 * b01 - m32 * b00) * det;
|
|
out[15] = ( m20 * b03 - m21 * b01 + m22 * b00) * det;
|
|
return out;
|
|
}
|
|
|
|
// ---- Terrain mesh (real 3D heightfield) ------------------------------
|
|
// One VBO built once from the elevation canvas: a ~181x173 grid sampled
|
|
// from the z10 DEM mosaic, smoothed, 6 vertices per cell (two
|
|
// triangles), positions+normals+UVs pre-packed. ~186k vertices ~= 6 MB.
|
|
// The terrain is rendered as a triangle mesh with hillshade; the
|
|
// cloud/sky pass then alpha-blends on top of it.
|
|
var terrainVbo = null;
|
|
var terrainVertCount = 0;
|
|
var terrainSample = null; // {h,gw,gh,s,halfX,halfY} — live smoothed grid
|
|
function buildTerrainMesh(gl, state) {
|
|
if (!elevCanvas || !elevCtx || terrainVbo) return;
|
|
if (typeof elevCtx.getImageData !== 'function') return;
|
|
var W = elevCanvas.width, H = elevCanvas.height;
|
|
var data;
|
|
try { data = elevCtx.getImageData(0, 0, W, H).data; }
|
|
catch (e) {
|
|
console.warn('[clouds3d] buildTerrainMesh: getImageData failed: ' + e.message);
|
|
return; // tainted canvas, etc.
|
|
}
|
|
var tilesX = groundTilesX(), tilesY = groundTilesY();
|
|
var halfX = tilesX / 2, halfY = tilesY / 2;
|
|
// Decode elevation in metres on the sampling grid: one vertex per
|
|
// STEP canvas px (z10 mosaic ~101 m/px source -> ~3.2 km mesh step,
|
|
// 4x finer per axis than the old 13 km tile-centre grid that made
|
|
// ridges look faceted).
|
|
var STEP = 32; // canvas px between mesh vertices
|
|
var s = STEP / ELEV_UNIT_PX; // world units (tiles) per grid step
|
|
var gw = Math.floor((W - 1) / STEP) + 1;
|
|
var gh = Math.floor((H - 1) / STEP) + 1;
|
|
var h = new Float32Array(gw * gh);
|
|
for (var j = 0; j < gh; j++) {
|
|
var py = Math.min(H - 1, j * STEP);
|
|
var row = j * gw;
|
|
for (var i = 0; i < gw; i++) {
|
|
// mirrored X: the DEM mosaic runs west-right while the world
|
|
// axis +x runs east (verified against the street texture) -
|
|
// read the height column mirrored so ridges sit on the same
|
|
// side as the roads.
|
|
var pxx = Math.max(0, W - 1 - i * STEP);
|
|
var idx = (py * W + pxx) * 4;
|
|
h[row + i] = data[idx] * 256 + data[idx + 1] + data[idx + 2] / 256 - 32768;
|
|
}
|
|
}
|
|
// Separable [1,2,1] blur, one round: smooths DEM staircase noise
|
|
// and tile seams without flattening the z10 detail again (two rounds
|
|
// made the relief look too soft/flat).
|
|
function hblur(src, out) {
|
|
for (var j = 0; j < gh; j++) {
|
|
var row = j * gw;
|
|
for (var i = 0; i < gw; i++) {
|
|
var a = src[row + (i > 0 ? i - 1 : 0)];
|
|
var b = src[row + i];
|
|
var c = src[row + (i < gw - 1 ? i + 1 : gw - 1)];
|
|
out[row + i] = (a + 2 * b + c) * 0.25;
|
|
}
|
|
}
|
|
}
|
|
function vblur(src, out) {
|
|
for (var j = 0; j < gh; j++) {
|
|
var jm = (j > 0 ? j - 1 : 0) * gw;
|
|
var jp = (j < gh - 1 ? j + 1 : gh - 1) * gw;
|
|
var row = j * gw;
|
|
for (var i = 0; i < gw; i++) {
|
|
out[row + i] = (src[jm + i] + 2 * src[row + i] + src[jp + i]) * 0.25;
|
|
}
|
|
}
|
|
}
|
|
var t1 = new Float32Array(gw * gh);
|
|
hblur(h, t1); vblur(t1, h);
|
|
// Build vertex list: 2 triangles per cell, 6 vertices each.
|
|
// Attributes: position (3), normal (3), uv (2) = 8 floats per vertex.
|
|
var cols = gw - 1, rows = gh - 1;
|
|
var vCount = cols * rows * 6;
|
|
var verts = new Float32Array(vCount * 8);
|
|
var vi = 0;
|
|
// Per-vertex normals from central differences of the smoothed
|
|
// heightfield (Gouraud shading - no flat facets on the slopes).
|
|
function hAt(i, j) { return h[j * gw + i] * ELEV_SCALE_UNITS; }
|
|
function setVertex(i, j) {
|
|
var iL = Math.max(0, i - 1), iR = Math.min(gw - 1, i + 1);
|
|
var jD = Math.max(0, j - 1), jU = Math.min(gh - 1, j + 1);
|
|
var dx = (hAt(iR, j) - hAt(iL, j)) / ((iR - iL) * s);
|
|
var dz = (hAt(i, jU) - hAt(i, jD)) / ((jU - jD) * s);
|
|
var nx = -dx, ny = 1, nz = -dz;
|
|
var nl = Math.hypot(nx, ny, nz) || 1;
|
|
nx /= nl; ny /= nl; nz /= nl;
|
|
verts[vi++] = i * s - halfX;
|
|
verts[vi++] = hAt(i, j);
|
|
verts[vi++] = halfY - j * s;
|
|
verts[vi++] = nx;
|
|
verts[vi++] = ny;
|
|
verts[vi++] = nz;
|
|
// Ground UV: the street canvas' texture U runs mirrored against
|
|
// world +x (verified against landmarks), so u = 0.5 - x/tilesX.
|
|
// Heights are read with the same mirror (see decode loop), so
|
|
// ridges and roads agree. V matches the cloud-pass convention.
|
|
var _x = i * s - halfX, _z = halfY - j * s;
|
|
verts[vi++] = 0.5 - _x / tilesX;
|
|
verts[vi++] = _z / tilesY + 0.5;
|
|
}
|
|
for (var j = 0; j < rows; j++) {
|
|
for (var i = 0; i < cols; i++) {
|
|
var i0 = i, j0 = j, i1 = i + 1, j1 = j + 1;
|
|
// Winding order kept from the tile-centre mesh:
|
|
// (i0,j0) (i0,j1) (i1,j1) + (i0,j0) (i1,j1) (i1,j0)
|
|
setVertex(i0, j0); setVertex(i0, j1); setVertex(i1, j1);
|
|
setVertex(i0, j0); setVertex(i1, j1); setVertex(i1, j0);
|
|
}
|
|
}
|
|
var buf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
|
|
terrainVbo = buf;
|
|
terrainVertCount = vCount;
|
|
|
|
// ---- Wireframe overlay buffer --------------------------------
|
|
// Edges only: (cols-1)*rows vertical + cols*(rows-1) horizontal
|
|
// segments. Each segment is two vertices, position only (3 floats),
|
|
// sharing the terrain program's aPos attribute.
|
|
var wireVerts = ((cols - 1) * rows + cols * (rows - 1)) * 2 * 3;
|
|
var wire = new Float32Array(wireVerts);
|
|
var wi = 0;
|
|
function pushVert(i, j) {
|
|
wire[wi++] = i * s - halfX;
|
|
wire[wi++] = h[j * gw + i] * ELEV_SCALE_UNITS;
|
|
wire[wi++] = halfY - j * s;
|
|
}
|
|
for (var j = 0; j < rows; j++) {
|
|
for (var i = 0; i < cols - 1; i++) {
|
|
pushVert(i, j); pushVert(i + 1, j);
|
|
}
|
|
}
|
|
for (var i = 0; i < cols; i++) {
|
|
for (var j = 0; j < rows - 1; j++) {
|
|
pushVert(i, j); pushVert(i, j + 1);
|
|
}
|
|
}
|
|
var wireBuf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, wireBuf);
|
|
gl.bufferData(gl.ARRAY_BUFFER, wire, gl.STATIC_DRAW);
|
|
|
|
if (state && state.terrain) {
|
|
state.terrain.vbo = buf;
|
|
state.terrain.vertCount = vCount;
|
|
state.terrain.wireVbo = wireBuf;
|
|
state.terrain.wireVertCount = (cols - 1) * rows * 2 + cols * (rows - 1) * 2;
|
|
}
|
|
terrainSample = { h: h, gw: gw, gh: gh, s: s, halfX: halfX, halfY: halfY };
|
|
buildCarwayVBO(gl);
|
|
buildCityVBO(gl);
|
|
}
|
|
|
|
// ---- Night lights layer (NASA GIBS VIIRS "City Lights" composite) ------
|
|
// Dark ground with glowing cities for the night: z8 webmercator JPEGs
|
|
// from the static VIIRS 2012 Day/Night Band composite (750 m sensor -
|
|
// more zoom than z8 would just upscale). Blitted into its own low-res
|
|
// canvas in the SAME layout as the street mosaic (same tile indices),
|
|
// so the terrain shader samples it with the same gUv. CORS is open
|
|
// (Access-Control-Allow-Origin: *), cached permanently in IndexedDB.
|
|
var NIGHT_Z = 8;
|
|
var NIGHT_TILES_PER = 1 << (11 - NIGHT_Z); // z11 tiles per night tile
|
|
var NIGHT_UNIT_PX = 256 / NIGHT_TILES_PER; // night canvas px per z11 tile
|
|
var NIGHT_URL = 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/' +
|
|
'VIIRS_CityLights_2012/default/GoogleMapsCompatible_Level8/';
|
|
var NIGHT_CFG = (window.CONFIG && window.CONFIG.terrain) || {};
|
|
var NIGHT_ON = NIGHT_CFG.nightLights !== false;
|
|
var nightCanvas = null, nightCtx = null, nightTexture = null, nightGl = null;
|
|
var nightTextureDirty = false;
|
|
|
|
function initNightSystem(glCtx) {
|
|
if (!NIGHT_ON || nightCanvas) return;
|
|
nightGl = glCtx;
|
|
nightCanvas = document.createElement('canvas');
|
|
nightCanvas.width = groundTilesX() * NIGHT_UNIT_PX;
|
|
nightCanvas.height = groundTilesY() * NIGHT_UNIT_PX;
|
|
nightCtx = nightCanvas.getContext('2d');
|
|
nightCtx.fillStyle = '#000';
|
|
nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height);
|
|
nightTexture = nightGl.createTexture();
|
|
nightGl.bindTexture(nightGl.TEXTURE_2D, nightTexture);
|
|
nightGl.texImage2D(nightGl.TEXTURE_2D, 0, nightGl.RGBA, nightGl.RGBA,
|
|
nightGl.UNSIGNED_BYTE, nightCanvas);
|
|
nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_WRAP_S, nightGl.CLAMP_TO_EDGE);
|
|
nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_WRAP_T, nightGl.CLAMP_TO_EDGE);
|
|
nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MAG_FILTER, nightGl.LINEAR);
|
|
if (IS_WEBGL2) {
|
|
nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MIN_FILTER, nightGl.LINEAR_MIPMAP_LINEAR);
|
|
nightGl.generateMipmap(nightGl.TEXTURE_2D);
|
|
if (anisoExt) nightGl.texParameterf(nightGl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, ANISO_MAX);
|
|
} else {
|
|
nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MIN_FILTER, nightGl.LINEAR);
|
|
}
|
|
}
|
|
|
|
function uploadNightTexture() {
|
|
if (!nightGl || !nightTexture) return;
|
|
nightGl.bindTexture(nightGl.TEXTURE_2D, nightTexture);
|
|
nightGl.texImage2D(nightGl.TEXTURE_2D, 0, nightGl.RGBA, nightGl.RGBA,
|
|
nightGl.UNSIGNED_BYTE, nightCanvas);
|
|
if (IS_WEBGL2) nightGl.generateMipmap(nightGl.TEXTURE_2D);
|
|
nightTextureDirty = false;
|
|
}
|
|
|
|
function loadNightTile(nx, ny) {
|
|
var id = 'night/' + NIGHT_Z + '/' + nx + '/' + ny;
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, id).then(function (rec) {
|
|
if (rec && rec.blob) {
|
|
return decodeToBitmap(rec.blob).catch(function () {
|
|
return fetchFresh(db);
|
|
});
|
|
}
|
|
return fetchFresh(db);
|
|
});
|
|
});
|
|
function fetchFresh(db) {
|
|
// GIBS WMTS REST path: {TileMatrix}/{TileRow}/{TileCol} - row(y) first!
|
|
return fetch(NIGHT_URL + NIGHT_Z + '/' + ny + '/' + nx + '.jpg')
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('night http ' + r.status);
|
|
return r.blob();
|
|
})
|
|
.then(function (blob) {
|
|
if (db) tileDbPut(db, { id: id, blob: blob, savedAt: Date.now() });
|
|
return decodeToBitmap(blob);
|
|
})
|
|
.catch(function (e) {
|
|
console.warn('[clouds3d] night tile failed z8 ' + nx + '/' + ny +
|
|
': ' + (e && e.message || e));
|
|
return null;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Post-process one raw 256x256 night tile into a pure "glow" tile:
|
|
// luma -> threshold curve (only real city lights survive, dim albedo
|
|
// and sensor noise are cut) -> very strong blur of mask and colour
|
|
// (downscale to 32 and bilinear back) -> colour x mask as alpha.
|
|
// Resolved value: processed PNG Blob, or null for lightless tiles.
|
|
function processNightTile(img) {
|
|
var S = 256, DS = 32;
|
|
var src = document.createElement('canvas'); src.width = src.height = S;
|
|
var sx = src.getContext('2d', { willReadFrequently: true });
|
|
sx.drawImage(img, 0, 0);
|
|
var d;
|
|
try { d = sx.getImageData(0, 0, S, S); } catch (e) { return Promise.resolve(null); }
|
|
var px = d.data, i, o;
|
|
var mask = new Float32Array(S * S);
|
|
var maxL = 0;
|
|
for (i = 0; i < S * S; i++) {
|
|
o = i * 4;
|
|
var l = (0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2]) / 255;
|
|
if (l > maxL) maxL = l;
|
|
mask[i] = l;
|
|
}
|
|
if (maxL < 0.22) return Promise.resolve(null); // no lights here
|
|
// smoothstep threshold on the normalised luma: below 32 % of the
|
|
// tile's brightest pixel = nothing, above 78 % = full light
|
|
for (i = 0; i < S * S; i++) {
|
|
var t = (mask[i] / maxL - 0.32) / (0.78 - 0.32);
|
|
t = Math.max(0, Math.min(1, t));
|
|
mask[i] = t * t * (3 - 2 * t);
|
|
}
|
|
// encode mask as grey image, blur hard via 32px downscale
|
|
var mCanvas = document.createElement('canvas'); mCanvas.width = mCanvas.height = S;
|
|
var mc = mCanvas.getContext('2d');
|
|
var md = mc.createImageData(S, S);
|
|
for (i = 0; i < S * S; i++) {
|
|
var v = (mask[i] * 255) | 0; o = i * 4;
|
|
md.data[o] = v; md.data[o + 1] = v; md.data[o + 2] = v; md.data[o + 3] = 255;
|
|
}
|
|
mc.putImageData(md, 0, 0);
|
|
var small = document.createElement('canvas'); small.width = small.height = DS;
|
|
var smc = small.getContext('2d');
|
|
smc.imageSmoothingEnabled = true; smc.imageSmoothingQuality = 'high';
|
|
smc.drawImage(mCanvas, 0, 0, DS, DS);
|
|
var mb = document.createElement('canvas'); mb.width = mb.height = S;
|
|
var mbc = mb.getContext('2d', { willReadFrequently: true });
|
|
mbc.imageSmoothingEnabled = true; mbc.imageSmoothingQuality = 'high';
|
|
mbc.drawImage(small, 0, 0, S, S);
|
|
var mBlur = mbc.getImageData(0, 0, S, S).data;
|
|
// blur the colour the same way, then multiply it by the blurred mask
|
|
var cs = document.createElement('canvas'); cs.width = cs.height = DS;
|
|
var csc = cs.getContext('2d');
|
|
csc.imageSmoothingEnabled = true; csc.imageSmoothingQuality = 'high';
|
|
csc.drawImage(src, 0, 0, DS, DS);
|
|
var cb = document.createElement('canvas'); cb.width = cb.height = S;
|
|
var cbc = cb.getContext('2d', { willReadFrequently: true });
|
|
cbc.imageSmoothingEnabled = true; cbc.imageSmoothingQuality = 'high';
|
|
cbc.drawImage(cs, 0, 0, S, S);
|
|
var out = cbc.getImageData(0, 0, S, S);
|
|
var od = out.data;
|
|
for (i = 0; i < S * S; i++) {
|
|
o = i * 4;
|
|
var a = mBlur[o] / 255;
|
|
od[o] = (od[o] * a) | 0;
|
|
od[o + 1] = (od[o + 1] * a) | 0;
|
|
od[o + 2] = (od[o + 2] * a) | 0;
|
|
od[o + 3] = mBlur[o]; // alpha = glow strength
|
|
}
|
|
var oc = document.createElement('canvas'); oc.width = oc.height = S;
|
|
oc.getContext('2d').putImageData(out, 0, 0);
|
|
return new Promise(function (resolve) {
|
|
oc.toBlob(function (b) { resolve(b || null); }, 'image/png');
|
|
});
|
|
}
|
|
|
|
// Get the GLOW-processed bitmap of a z8 tile. Processed PNGs are cached
|
|
// under their own id ('nightg/...'); raw tiles under 'night/...'. Lightless
|
|
// tiles are cached as an empty marker so they never re-process.
|
|
function getProcessedNightTile(nx, ny) {
|
|
var rawId = 'night/' + NIGHT_Z + '/' + nx + '/' + ny;
|
|
var procId = 'nightg/' + NIGHT_Z + '/' + nx + '/' + ny;
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, procId).then(function (rec) {
|
|
if (rec && rec.empty) return null; // processed: no lights
|
|
if (rec && rec.blob) {
|
|
return decodeToBitmap(rec.blob).catch(function () { return null; });
|
|
}
|
|
return loadNightTile(nx, ny).then(function (img) {
|
|
if (!img) return null;
|
|
return processNightTile(img).then(function (blob) {
|
|
if (blob) {
|
|
tileDbPut(db, { id: procId, blob: blob, savedAt: Date.now() });
|
|
return decodeToBitmap(blob);
|
|
}
|
|
tileDbPut(db, { id: procId, empty: 1, savedAt: Date.now() });
|
|
return null;
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Draw the night-lights slice of one street tile at the tile's canvas
|
|
// position (scaled to the low-res night canvas).
|
|
function placeNightTile(tileX, tileY, canvasX, canvasY) {
|
|
if (!nightCtx) return;
|
|
var nx = Math.floor(tileX / NIGHT_TILES_PER);
|
|
var ny = Math.floor(tileY / NIGHT_TILES_PER);
|
|
var sx = (tileX - nx * NIGHT_TILES_PER) * NIGHT_UNIT_PX;
|
|
var sy = (tileY - ny * NIGHT_TILES_PER) * NIGHT_UNIT_PX;
|
|
getProcessedNightTile(nx, ny).then(function (img) {
|
|
if (!img) return;
|
|
nightCtx.drawImage(img, sx, sy, NIGHT_UNIT_PX, NIGHT_UNIT_PX,
|
|
Math.round(canvasX * NIGHT_UNIT_PX / GROUND_TILE_SIZE),
|
|
Math.round(canvasY * NIGHT_UNIT_PX / GROUND_TILE_SIZE),
|
|
NIGHT_UNIT_PX, NIGHT_UNIT_PX);
|
|
nightTextureDirty = true;
|
|
});
|
|
}
|
|
|
|
// ---- Motorway car-lights layer ----------------------------------------
|
|
// Motorway/trunk roads (Autobahnen) from OSM via Overpass API, fetched at
|
|
// runtime in small grid cells (each cell cached permanently in IndexedDB,
|
|
// retried across endpoints on transient 504s - the public instances are
|
|
// unreliable for big boxes, never for single ~100 km cells over time).
|
|
// Ribbons hug the terrain mesh; the shader paints individual vehicle
|
|
// POINTS (no streaks): white approaching, red driving away, opposite
|
|
// directions per ribbon half, jittered spacing and per-car speed wobble.
|
|
// Density ramps 0 -> max -> 0 across the night, lights fade toward ~150 km.
|
|
// Data (c) OpenStreetMap contributors, ODbL.
|
|
var CAR_CFG = (window.CONFIG && window.CONFIG.terrain) || {};
|
|
var CAR_ON = CAR_CFG.carLights !== false;
|
|
var CITY_ON = CAR_CFG.cityLights !== false;
|
|
|
|
function carCells(idBase, tagRx, s, w, n, e, latStep, lonStep) {
|
|
var out = [];
|
|
for (var la = s; la < n - 0.01; la += latStep) {
|
|
for (var lo = w; lo < e - 0.01; lo += lonStep) {
|
|
out.push({
|
|
id: idBase + '_' + la.toFixed(1) + '_' + lo.toFixed(1),
|
|
q: '[out:json][timeout:50];way["highway"~"' + tagRx + '"](' +
|
|
la.toFixed(1) + ',' + lo.toFixed(1) + ',' +
|
|
Math.min(n, la + latStep).toFixed(1) + ',' +
|
|
Math.min(e, lo + lonStep).toFixed(1) + ');out geom;'
|
|
});
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
var CAR_MOTORWAY_RX = '^(motorway|motorway_link|trunk|trunk_link)$';
|
|
var CAR_SECONDARY_RX = '^(secondary|secondary_link)$';
|
|
var CAR_QUERIES = [
|
|
{ id: 'geo/mw_w', q: '[out:json][timeout:50];way["highway"~"' + CAR_MOTORWAY_RX + '"](46.8,9.4,50.1,13.4);out geom;' }
|
|
].concat(
|
|
carCells('geo/mw_e', CAR_MOTORWAY_RX, 46.8, 13.4, 50.1, 17.3, 1.1, 1.3),
|
|
// Bundesstrassen in a fine 0.2 deg raster: every cell is a tiny fast
|
|
// query, so transient instance load (504s) only ever costs one cell
|
|
carCells('geo/sec', CAR_SECONDARY_RX, 47.5, 12.1, 49.5, 14.8, 0.2, 0.2)
|
|
);
|
|
var CAR_ENDPOINTS = [
|
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter',
|
|
'https://overpass-api.de/api/interpreter',
|
|
'https://overpass.kumi.systems/api/interpreter'
|
|
];
|
|
var CAR_EVERY = 0.16; // world units (~2 km) between ribbon nodes
|
|
var CAR_W = 0.005; // ribbon half width (world units)
|
|
var CAR_Y = 0.004; // lift above terrain surface (~50 m)
|
|
// per class: mean slot spacing, slot flow speed, max vehicle density
|
|
// (density is additionally shaped by the rush-hour curve: high evening /
|
|
// early morning, ~35 % during the deep night 23:00-04:00)
|
|
var CAR_M_DASH = 0.05, CAR_M_SPEED = 0.22, CAR_M_DENS = 0.85;
|
|
var CAR_B_DASH = 0.14, CAR_B_SPEED = 0.10, CAR_B_DENS = 0.50;
|
|
var carGeo = null; // { motorway: [polys], secondary: [polys] }
|
|
var carVboM = null, carCountM = 0;
|
|
var carVboB = null, carCountB = 0;
|
|
var carProg = null, carAttribs = null, carUniforms = null;
|
|
|
|
function carLonToX(lon) {
|
|
var n = 1 << GROUND_TILE_Z;
|
|
return GROUND_BASE_X - ((lon + 180) / 360) * n;
|
|
}
|
|
function carLatToZ(lat) {
|
|
var n = 1 << GROUND_TILE_Z;
|
|
var r = lat * Math.PI / 180;
|
|
var yf = (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * n;
|
|
return GROUND_BASE_Y - yf;
|
|
}
|
|
|
|
// Terrain height lookup on the smoothed mesh grid (same data the mesh
|
|
// was built from, so the ribbons sit exactly on the surface).
|
|
function carYAt(x, z) {
|
|
var ts = terrainSample;
|
|
if (!ts) return CAR_Y;
|
|
var fi = (x + ts.halfX) / ts.s, fj = (ts.halfY - z) / ts.s;
|
|
fi = Math.max(0, Math.min(ts.gw - 1.001, fi));
|
|
fj = Math.max(0, Math.min(ts.gh - 1.001, fj));
|
|
var i0 = fi | 0, j0 = fj | 0, u = fi - i0, v = fj - j0;
|
|
var h = ts.h;
|
|
var a = h[j0 * ts.gw + i0], b2 = h[j0 * ts.gw + i0 + 1];
|
|
var c = h[(j0 + 1) * ts.gw + i0], d = h[(j0 + 1) * ts.gw + i0 + 1];
|
|
var m = (a * (1 - u) + b2 * u) * (1 - v) + (c * (1 - u) + d * u) * v;
|
|
return m * ELEV_SCALE_UNITS + CAR_Y;
|
|
}
|
|
|
|
function resamplePoly(pts) {
|
|
var out = [];
|
|
if (pts.length < 2) return out;
|
|
var prev = { x: pts[0].x, z: pts[0].z };
|
|
out.push({ x: prev.x, z: prev.z, d: 0 });
|
|
var leftover = 0;
|
|
for (var i = 1; i < pts.length; i++) {
|
|
var p = pts[i];
|
|
var seg = Math.hypot(p.x - prev.x, p.z - prev.z);
|
|
while (seg > 1e-9 && leftover + seg >= CAR_EVERY) {
|
|
var f = (CAR_EVERY - leftover) / seg;
|
|
var qx = prev.x + (p.x - prev.x) * f;
|
|
var qz = prev.z + (p.z - prev.z) * f;
|
|
out.push({ x: qx, z: qz, d: out[out.length - 1].d + CAR_EVERY });
|
|
prev = { x: qx, z: qz };
|
|
seg -= (CAR_EVERY - leftover);
|
|
leftover = 0;
|
|
}
|
|
leftover += seg;
|
|
prev = p;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function fetchMotorways() {
|
|
if (!CAR_ON) return;
|
|
var groups = { motorway: [], secondary: [] };
|
|
function parseWays(txt) {
|
|
var data;
|
|
try { data = JSON.parse(txt); } catch (e) { return; }
|
|
(data.elements || []).forEach(function (el) {
|
|
if (el.type !== 'way' || !el.geometry || el.geometry.length < 2) return;
|
|
var hw = (el.tags && el.tags.highway) || '';
|
|
var key = /^(motorway|trunk)/.test(hw) ? 'motorway'
|
|
: (/^secondary/.test(hw) ? 'secondary' : null);
|
|
if (!key) return;
|
|
var pts = el.geometry.map(function (g) {
|
|
return { x: carLonToX(g.lon), z: carLatToZ(g.lat) };
|
|
});
|
|
var rp = resamplePoly(pts);
|
|
if (rp.length > 1) groups[key].push(rp);
|
|
});
|
|
}
|
|
function carDelay(ms) {
|
|
return new Promise(function (res) { setTimeout(res, ms); });
|
|
}
|
|
// rebuild ribbons shortly after each arriving cell - the lights grow
|
|
// in region by region instead of appearing after the whole chain
|
|
var rebuildT = null;
|
|
function scheduleRebuild() {
|
|
if (rebuildT) return;
|
|
rebuildT = setTimeout(function () {
|
|
rebuildT = null;
|
|
carGeo = groups;
|
|
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
}, 1500);
|
|
}
|
|
function loadCell(entry) {
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, entry.id).then(function (rec) {
|
|
if (rec && rec.json) { entry._cached = true; return rec.json; }
|
|
// two rounds over all endpoints; transient 504s are routine
|
|
var MAX_ATTEMPTS = CAR_ENDPOINTS.length * 2;
|
|
function tryAttempt(a) {
|
|
if (a >= MAX_ATTEMPTS) return null;
|
|
var ep = CAR_ENDPOINTS[a % CAR_ENDPOINTS.length];
|
|
return (a === 0 ? Promise.resolve() : carDelay(2500))
|
|
.then(function () {
|
|
return fetch(ep, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'data=' + encodeURIComponent(entry.q)
|
|
});
|
|
}).then(function (r) {
|
|
if (!r.ok) return tryAttempt(a + 1); // 504: next attempt
|
|
return r.text();
|
|
}, function () {
|
|
return tryAttempt(a + 1); // network/CORS error
|
|
}).then(function (txt) {
|
|
if (txt && db) {
|
|
tileDbPut(db, { id: entry.id, json: txt, savedAt: Date.now() });
|
|
}
|
|
return txt;
|
|
});
|
|
}
|
|
return tryAttempt(0);
|
|
});
|
|
}).then(function (txt) {
|
|
if (txt) { parseWays(txt); scheduleRebuild(); }
|
|
else console.warn('[clouds3d] overpass cell failed: ' + entry.id);
|
|
});
|
|
}
|
|
// sequential with breathing room for FETCHED cells; cached cells
|
|
// stream through without delay. Failed cells are retried on the next
|
|
// page load (successful ones stay cached forever)
|
|
var chain = Promise.resolve();
|
|
CAR_QUERIES.forEach(function (entry) {
|
|
chain = chain.then(function () {
|
|
return loadCell(entry).then(function () {
|
|
return carDelay(entry._cached ? 0 : 700);
|
|
});
|
|
});
|
|
});
|
|
chain.then(function () {
|
|
carGeo = groups;
|
|
buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
console.log('[clouds3d] car layer: ' + groups.motorway.length +
|
|
' motorway + ' + groups.secondary.length + ' secondary polylines');
|
|
});
|
|
}
|
|
|
|
// ---- Settlement lights -------------------------------------------------
|
|
// OSM place nodes (city/town/suburb/village/hamlet) as small pale yellow
|
|
// additive glow points scattered over the settlement footprint: bigger
|
|
// settlements get more dots, a wider spread and stronger brightness (the
|
|
// population tag, where present, scales all three). Static - unlike the
|
|
// moving car lights - and faded in with the same sunset ramp as them.
|
|
// Data (c) OpenStreetMap contributors, ODbL.
|
|
var cityGeo = null; // [{x, z, rank, pop}]
|
|
var cityVbo = null, cityCount = 0;
|
|
var cityProg = null, cityAttribs = null, cityUniforms = null;
|
|
var PLACE_RANK = { city: 0, town: 1, suburb: 2, village: 3, hamlet: 4 };
|
|
// per rank: dot count, spread radius (world units), brightness, dot size
|
|
var CITY_CLASS = [
|
|
{ n: 16, r: 0.050, b: 1.00, sz: 0.040 },
|
|
{ n: 8, r: 0.030, b: 0.80, sz: 0.032 },
|
|
{ n: 6, r: 0.032, b: 0.70, sz: 0.028 },
|
|
{ n: 3, r: 0.014, b: 0.55, sz: 0.024 },
|
|
{ n: 2, r: 0.007, b: 0.45, sz: 0.020 }
|
|
];
|
|
|
|
function fetchSettlements() {
|
|
if (!CITY_ON) return;
|
|
// same tiled pattern as the road queries (a single full-box place
|
|
// dump would be ~100k nodes); fine 0.55 x 0.65 deg cells answer in
|
|
// seconds even on busy instances - full 1 deg boxes drew 504s
|
|
var cells = [];
|
|
for (var la = 46.8; la < 50.09; la += 0.55) {
|
|
for (var lo = 9.4; lo < 17.29; lo += 0.65) {
|
|
cells.push({
|
|
id: 'geo/place_' + la.toFixed(2) + '_' + lo.toFixed(2),
|
|
q: '[out:json][timeout:45];node["place"~"^(city|town|suburb|village|hamlet)$"](' +
|
|
la.toFixed(2) + ',' + lo.toFixed(2) + ',' +
|
|
Math.min(50.1, la + 0.55).toFixed(2) + ',' +
|
|
Math.min(17.3, lo + 0.65).toFixed(2) + ');out tags;'
|
|
});
|
|
}
|
|
}
|
|
var list = [];
|
|
function delay(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
|
var rebuildT = null;
|
|
function scheduleRebuild() {
|
|
if (rebuildT) return;
|
|
rebuildT = setTimeout(function () {
|
|
rebuildT = null;
|
|
cityGeo = list.slice();
|
|
buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
}, 1500);
|
|
}
|
|
function parse(txt) {
|
|
var data;
|
|
try { data = JSON.parse(txt); } catch (e) { return; }
|
|
(data.elements || []).forEach(function (el) {
|
|
if (el.type !== 'node' || !el.tags || el.lat == null || el.lon == null) return;
|
|
var rank = PLACE_RANK[el.tags.place];
|
|
if (rank == null) return;
|
|
var x = carLonToX(el.lon), z = carLatToZ(el.lat);
|
|
if (Math.abs(x) > groundTilesX() / 2 + 2 || Math.abs(z) > groundTilesY() / 2 + 2) return;
|
|
list.push({ x: x, z: z, rank: rank, pop: parseFloat(el.tags.population) || 0 });
|
|
});
|
|
}
|
|
function loadCell(entry) {
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, entry.id).then(function (rec) {
|
|
if (rec && rec.json) { entry._cached = true; return rec.json; }
|
|
var MAX_ATTEMPTS = CAR_ENDPOINTS.length * 2;
|
|
function tryAttempt(a) {
|
|
if (a >= MAX_ATTEMPTS) return null;
|
|
var ep = CAR_ENDPOINTS[a % CAR_ENDPOINTS.length];
|
|
return (a === 0 ? Promise.resolve() : delay(2500))
|
|
.then(function () {
|
|
return fetch(ep, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'data=' + encodeURIComponent(entry.q)
|
|
});
|
|
}).then(function (r) {
|
|
if (!r.ok) return tryAttempt(a + 1);
|
|
return r.text();
|
|
}, function () {
|
|
return tryAttempt(a + 1);
|
|
}).then(function (txt) {
|
|
if (txt && db) tileDbPut(db, { id: entry.id, json: txt, savedAt: Date.now() });
|
|
return txt;
|
|
});
|
|
}
|
|
return tryAttempt(0);
|
|
});
|
|
}).then(function (txt) {
|
|
if (txt) { parse(txt); scheduleRebuild(); }
|
|
else console.warn('[clouds3d] place cell failed: ' + entry.id);
|
|
});
|
|
}
|
|
var chain = Promise.resolve();
|
|
cells.forEach(function (entry) {
|
|
chain = chain.then(function () {
|
|
return loadCell(entry).then(function () {
|
|
return delay(entry._cached ? 0 : 700);
|
|
});
|
|
});
|
|
});
|
|
chain.then(function () {
|
|
cityGeo = list.slice();
|
|
buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl));
|
|
console.log('[clouds3d] settlements: ' + list.length + ' places -> ' + cityCount + ' lights');
|
|
});
|
|
}
|
|
|
|
var CAR_VERT_SRC = [
|
|
'attribute vec3 aPos;',
|
|
'attribute vec4 aMeta;', // x: distance along road, y: side (+/-1), zw: tangent
|
|
'attribute float aDens;', // local vehicle density scale (secondary falloff)
|
|
'uniform mat4 uProj;',
|
|
'uniform mat4 uView;',
|
|
'uniform vec3 uCamPos;',
|
|
'varying float vLen;',
|
|
'varying float vSide;',
|
|
'varying float vDens;',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'void main() {',
|
|
'vLen = aMeta.x;',
|
|
'vSide = aMeta.y;',
|
|
'vDens = aDens;',
|
|
'float dir = aMeta.y >= 0.0 ? 1.0 : -1.0;',
|
|
'vec3 toCam = uCamPos - aPos;',
|
|
'vDist = length(toCam);',
|
|
'vApproach = dot(vec3(aMeta.z, 0.0, aMeta.w) * dir, normalize(toCam));',
|
|
'gl_Position = uProj * uView * vec4(aPos, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CAR_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform float uTime;',
|
|
'uniform float uNight;', // brightness ramp (sunset/dusk, same as night lights)',
|
|
'uniform float uDens;', // global vehicle density (0..1), ramps 0->max->0',
|
|
'uniform float uDash;', // mean slot spacing (world units)',
|
|
'uniform float uSpeed;', // slots per second (frame flow)',
|
|
'varying float vLen;',
|
|
'varying float vSide;',
|
|
'varying float vDens;',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'float hash1(float s) {',
|
|
'return fract(sin(s * 12.9898) * 43758.5453);',
|
|
'}',
|
|
'void main() {',
|
|
// per-fragment density = global curve x local falloff
|
|
'float dens = uDens * vDens;',
|
|
'if (dens < 0.02) discard;',
|
|
// far distance: fade the lights out smoothly toward ~150 km
|
|
'float df = 1.0 - smoothstep(7.0, 12.0, vDist);',
|
|
'if (df <= 0.0) discard;',
|
|
'float body = smoothstep(0.0, 0.5, 1.0 - abs(vSide));',
|
|
'float dir = vSide >= 0.0 ? 1.0 : -1.0;',
|
|
// cars are slotted pulses; hash1(s) jitters each slot's position
|
|
// (varying spacing) and a per-slot sine wobble makes every vehicle
|
|
// crawl or overtake a bit (varying speeds). No trails - points only.
|
|
'float flow = vLen / uDash + dir * uTime * uSpeed;',
|
|
'float n = floor(flow);',
|
|
'float inten = 0.0;',
|
|
'for (int k = -2; k <= 2; k++) {',
|
|
'float s = n + float(k);',
|
|
'float vis = step(hash1(s + 17.3), dens);',
|
|
'if (vis > 0.0) {',
|
|
'float off = 0.05 + hash1(s) * 0.8;',
|
|
'float w = 0.25 * sin(uTime * uSpeed * (1.5 + hash1(s + 7.1) * 4.0) + hash1(s + 3.7) * 6.283);',
|
|
'float d = abs(flow - (s + off + w));',
|
|
'inten = max(inten, max(0.0, 1.0 - d / 0.11));',
|
|
'}',
|
|
'}',
|
|
'if (inten <= 0.0) discard;',
|
|
'inten = pow(inten, 1.8);',
|
|
// approaching the camera -> white headlights, driving away -> red',
|
|
'vec3 col = mix(vec3(1.0, 0.12, 0.08), vec3(1.0, 0.96, 0.88),',
|
|
'smoothstep(-0.1, 0.1, vApproach));',
|
|
'gl_FragColor = vec4(col * inten * body * uNight * df, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// Chamfer distance transform of the motorway network on the terrain grid;
|
|
// used to thin Bundesstrassen traffic with growing distance from the A-net.
|
|
function motorwayDistField() {
|
|
var ts = terrainSample;
|
|
var BIG = 1e9;
|
|
var f = new Float32Array(ts.gw * ts.gh);
|
|
f.fill(BIG);
|
|
carGeo.motorway.forEach(function (pts) {
|
|
for (var q = 0; q < pts.length; q++) {
|
|
var i = Math.round((pts[q].x + ts.halfX) / ts.s);
|
|
var j = Math.round((ts.halfY - pts[q].z) / ts.s);
|
|
if (i >= 0 && i < ts.gw && j >= 0 && j < ts.gh) f[j * ts.gw + i] = 0;
|
|
}
|
|
});
|
|
for (var j = 0; j < ts.gh; j++) {
|
|
for (var i = 0; i < ts.gw; i++) {
|
|
var k = j * ts.gw + i, d = f[k];
|
|
if (i > 0) d = Math.min(d, f[k - 1] + 1);
|
|
if (j > 0) {
|
|
d = Math.min(d, f[k - ts.gw] + 1);
|
|
if (i > 0) d = Math.min(d, f[k - ts.gw - 1] + 1.41);
|
|
if (i < ts.gw - 1) d = Math.min(d, f[k - ts.gw + 1] + 1.41);
|
|
}
|
|
f[k] = d;
|
|
}
|
|
}
|
|
for (var j2 = ts.gh - 1; j2 >= 0; j2--) {
|
|
for (var i2 = ts.gw - 1; i2 >= 0; i2--) {
|
|
var k2 = j2 * ts.gw + i2, d2 = f[k2];
|
|
if (i2 < ts.gw - 1) d2 = Math.min(d2, f[k2 + 1] + 1);
|
|
if (j2 < ts.gh - 1) {
|
|
d2 = Math.min(d2, f[k2 + ts.gw] + 1);
|
|
if (i2 > 0) d2 = Math.min(d2, f[k2 + ts.gw - 1] + 1.41);
|
|
if (i2 < ts.gw - 1) d2 = Math.min(d2, f[k2 + ts.gw + 1] + 1.41);
|
|
}
|
|
f[k2] = Math.min(f[k2], d2);
|
|
}
|
|
}
|
|
return f;
|
|
}
|
|
|
|
function buildCarwayVBO(gl) {
|
|
if (!CAR_ON || !gl || !carGeo || !terrainSample) return;
|
|
var ts = terrainSample;
|
|
var limX = groundTilesX() / 2 + 1, limZ = groundTilesY() / 2 + 1;
|
|
var distField = (carGeo.secondary.length && carGeo.motorway.length)
|
|
? motorwayDistField() : null;
|
|
|
|
// local density scale for secondary roads: full near the A-net (<~50km),
|
|
// smoothly down to 10 % far away (>~210 km) - a gradient, never a cut
|
|
function densScale(x, z) {
|
|
if (!distField) return 1;
|
|
var i = Math.max(0, Math.min(ts.gw - 1, Math.round((x + ts.halfX) / ts.s)));
|
|
var j = Math.max(0, Math.min(ts.gh - 1, Math.round((ts.halfY - z) / ts.s)));
|
|
var dTiles = distField[j * ts.gw + i] * ts.s;
|
|
var t = Math.max(0, Math.min(1, (dTiles - 4) / 12));
|
|
t = t * t * (3 - 2 * t);
|
|
return 1 - 0.9 * t;
|
|
}
|
|
|
|
function build(polys, withField) {
|
|
var verts = [];
|
|
// pos(3) + meta(len, side, tangent 2) + dens = 8 floats
|
|
function push(x, y, z, len, side, tx, tz, dens) {
|
|
verts.push(x, y, z, len, side, tx, tz, dens);
|
|
}
|
|
for (var li = 0; li < polys.length; li++) {
|
|
var pts = polys[li];
|
|
for (var i = 0; i + 1 < pts.length; i++) {
|
|
var a = pts[i], b = pts[i + 1];
|
|
if (Math.abs(a.x) > limX || Math.abs(a.z) > limZ ||
|
|
Math.abs(b.x) > limX || Math.abs(b.z) > limZ) continue;
|
|
var dx = b.x - a.x, dz = b.z - a.z;
|
|
var L = Math.hypot(dx, dz) || 1;
|
|
var tx = dx / L, tz = dz / L;
|
|
var nx = -tz * CAR_W, nz = tx * CAR_W;
|
|
var ya = carYAt(a.x, a.z), yb = carYAt(b.x, b.z);
|
|
var dens = withField ? densScale((a.x + b.x) / 2, (a.z + b.z) / 2) : 1;
|
|
push(a.x + nx, ya, a.z + nz, a.d, 1, tx, tz, dens);
|
|
push(a.x - nx, ya, a.z - nz, a.d, -1, tx, tz, dens);
|
|
push(b.x + nx, yb, b.z + nz, b.d, 1, tx, tz, dens);
|
|
push(a.x - nx, ya, a.z - nz, a.d, -1, tx, tz, dens);
|
|
push(b.x + nx, yb, b.z + nz, b.d, 1, tx, tz, dens);
|
|
push(b.x - nx, yb, b.z - nz, b.d, -1, tx, tz, dens);
|
|
}
|
|
}
|
|
return verts;
|
|
}
|
|
|
|
function upload(old, verts) {
|
|
if (old) gl.deleteBuffer(old);
|
|
if (verts.length < 16) return { vbo: null, count: 0 };
|
|
var vbo = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
|
|
return { vbo: vbo, count: verts.length / 8 };
|
|
}
|
|
|
|
// After deleting the old car VBO, any still-enabled vertex attrib slot
|
|
// points at a destroyed buffer -> the sky/cloud full-screen draws warn
|
|
// "no buffer is bound to enabled attribute". Re-anchor every enabled
|
|
// slot to the persistent full-screen triangle buffer until the passes
|
|
// that use them run again.
|
|
function sanitizeAttribSlots() {
|
|
var safe = clouds3dState && clouds3dState.buf;
|
|
if (!safe) return;
|
|
var maxA = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
|
|
for (var i = 0; i < maxA; i++) {
|
|
if (gl.getVertexAttrib(i, gl.VERTEX_ATTRIB_ARRAY_ENABLED)) {
|
|
gl.vertexAttribPointer(i, 1, gl.FLOAT, false, 0, 0);
|
|
}
|
|
}
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, safe);
|
|
}
|
|
|
|
var m = upload(carVboM, build(carGeo.motorway, false));
|
|
carVboM = m.vbo; carCountM = m.count;
|
|
var bn = upload(carVboB, build(carGeo.secondary, true));
|
|
carVboB = bn.vbo; carCountB = bn.count;
|
|
sanitizeAttribSlots();
|
|
|
|
if (!carProg && (carCountM || carCountB)) {
|
|
carProg = makeProgram(gl, CAR_VERT_SRC, CAR_FRAG_SRC);
|
|
if (!carProg) return;
|
|
carAttribs = {
|
|
pos: gl.getAttribLocation(carProg, 'aPos'),
|
|
meta: gl.getAttribLocation(carProg, 'aMeta'),
|
|
dens: gl.getAttribLocation(carProg, 'aDens')
|
|
};
|
|
carUniforms = {
|
|
proj: gl.getUniformLocation(carProg, 'uProj'),
|
|
view: gl.getUniformLocation(carProg, 'uView'),
|
|
camPos: gl.getUniformLocation(carProg, 'uCamPos'),
|
|
time: gl.getUniformLocation(carProg, 'uTime'),
|
|
night: gl.getUniformLocation(carProg, 'uNight'),
|
|
dens: gl.getUniformLocation(carProg, 'uDens'),
|
|
dash: gl.getUniformLocation(carProg, 'uDash'),
|
|
speed: gl.getUniformLocation(carProg, 'uSpeed')
|
|
};
|
|
}
|
|
}
|
|
|
|
var CITY_VERT_SRC = [
|
|
'attribute vec3 aPos;',
|
|
'attribute float aSize;', // world-scale factor for the glow dot
|
|
'attribute float aBri;', // per-settlement brightness (rank x pop)
|
|
'uniform mat4 uProj;',
|
|
'uniform mat4 uView;',
|
|
'uniform vec3 uCamPos;',
|
|
'uniform float uScale;', // px scale (viewport height / 600)',
|
|
'varying float vBri;',
|
|
'varying float vDist;',
|
|
'void main() {',
|
|
'vDist = length(uCamPos - aPos);',
|
|
'vBri = aBri;',
|
|
'gl_PointSize = clamp(aSize * uScale * 750.0 / max(vDist, 0.1), 1.2, 18.0);',
|
|
'gl_Position = uProj * uView * vec4(aPos, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CITY_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform float uNight;',
|
|
'varying float vBri;',
|
|
'varying float vDist;',
|
|
'void main() {',
|
|
'float df = 1.0 - smoothstep(9.0, 14.0, vDist);', // out at ~180 km',
|
|
'if (df <= 0.0) discard;',
|
|
'vec2 pc = gl_PointCoord - 0.5;',
|
|
'float d = length(pc) * 2.0;',
|
|
'float core = smoothstep(1.0, 0.0, d);',
|
|
'core = core * core * (1.4 - core * 0.4);', // hot center, soft edge',
|
|
'if (core <= 0.0) discard;',
|
|
'gl_FragColor = vec4(vec3(1.0, 0.80, 0.45) * core * vBri * uNight * df, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// deterministic per-settlement, per-dot jitter
|
|
function cityRand(seed) {
|
|
var x = Math.sin(seed) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
|
|
// same guard as the car rebuild: after deleting the old city VBO every
|
|
// still-enabled attrib slot must point at a live buffer, or the next
|
|
// full-screen draw warns "no buffer is bound to enabled attribute".
|
|
function sanitizeCityAttribSlots(gl) {
|
|
var safe = clouds3dState && clouds3dState.buf;
|
|
if (!safe) return;
|
|
var maxA = gl.getParameter(gl.MAX_VERTEX_ATTRIBS);
|
|
for (var i = 0; i < maxA; i++) {
|
|
if (gl.getVertexAttrib(i, gl.VERTEX_ATTRIB_ARRAY_ENABLED)) {
|
|
gl.vertexAttribPointer(i, 1, gl.FLOAT, false, 0, 0);
|
|
}
|
|
}
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, safe);
|
|
}
|
|
|
|
function buildCityVBO(gl) {
|
|
if (!CITY_ON || !gl || !cityGeo || !terrainSample) return;
|
|
var limX = groundTilesX() / 2 + 1, limZ = groundTilesY() / 2 + 1;
|
|
var verts = []; // pos(3) + size + bri = 5 floats
|
|
for (var s = 0; s < cityGeo.length; s++) {
|
|
var p = cityGeo[s];
|
|
if (Math.abs(p.x) > limX || Math.abs(p.z) > limZ) continue;
|
|
var cls = CITY_CLASS[p.rank];
|
|
var scale = p.pop > 0 ? Math.min(4, 0.6 + Math.sqrt(p.pop / 250000)) : 1;
|
|
var n = Math.max(1, Math.round(cls.n * scale));
|
|
var spread = cls.r * (0.7 + 0.5 * scale);
|
|
var bri = cls.b * Math.min(1.4, 0.8 + 0.12 * scale);
|
|
var sz = cls.sz * Math.min(1.7, 0.9 + 0.13 * scale);
|
|
for (var k = 0; k < n; k++) {
|
|
var h1 = cityRand(s * 91.7 + k * 13.31 + 0.5);
|
|
var h2 = cityRand(s * 71.3 + k * 17.19 + 2.1);
|
|
var h3 = cityRand(s * 39.1 + k * 5.77 + 4.9);
|
|
var ang = h1 * 6.2831853;
|
|
var rad = spread * Math.sqrt(h2);
|
|
var x = p.x + Math.cos(ang) * rad;
|
|
var z = p.z + Math.sin(ang) * rad;
|
|
var y = carYAt(x, z) + 0.003;
|
|
// per-dot variation so the cluster reads as a town, not a blob
|
|
verts.push(x, y, z, sz * (0.6 + 0.8 * h3), bri * (0.55 + 0.7 * h3));
|
|
}
|
|
}
|
|
if (cityVbo) gl.deleteBuffer(cityVbo);
|
|
cityVbo = null; cityCount = 0;
|
|
if (verts.length < 5) { sanitizeCityAttribSlots(gl); return; }
|
|
var vbo = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
|
|
cityVbo = vbo; cityCount = verts.length / 5;
|
|
sanitizeCityAttribSlots(gl);
|
|
if (!cityProg) {
|
|
cityProg = makeProgram(gl, CITY_VERT_SRC, CITY_FRAG_SRC);
|
|
if (!cityProg) return;
|
|
cityAttribs = {
|
|
pos: gl.getAttribLocation(cityProg, 'aPos'),
|
|
size: gl.getAttribLocation(cityProg, 'aSize'),
|
|
bri: gl.getAttribLocation(cityProg, 'aBri')
|
|
};
|
|
cityUniforms = {
|
|
proj: gl.getUniformLocation(cityProg, 'uProj'),
|
|
view: gl.getUniformLocation(cityProg, 'uView'),
|
|
camPos: gl.getUniformLocation(cityProg, 'uCamPos'),
|
|
scale: gl.getUniformLocation(cityProg, 'uScale'),
|
|
night: gl.getUniformLocation(cityProg, 'uNight')
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---- Real-sky: star catalogue + Milky Way ------------------------------
|
|
// Yale Bright Star Catalog (vendor/stars.json): 8.7k real stars.
|
|
// The catalogue stars are rendered as GL_POINTS (crisp discs + glow,
|
|
// size from V-magnitude, tint from colour temperature, sidereal rotation) -
|
|
// no bitmap texture, so they are pixel-sharp at any zoom. The Milky Way
|
|
// band (low-frequency by nature) stays a baked equirect texture.
|
|
var skyTexCanvas = null, skyTexture = null, skyTexGl = null;
|
|
var skyPts = null; // { prog, vbo, count, a0, a1, u: {...} }
|
|
var STAR_PH = 2.9; // phase so the plausible constellations sit high at night
|
|
var _skyLat = ((window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) || 48.5667) * Math.PI / 180;
|
|
var SKY_SIN_LAT = Math.sin(_skyLat), SKY_COS_LAT = Math.cos(_skyLat);
|
|
|
|
function kelvinToRGB(k) {
|
|
// crude blackbody tint
|
|
if (k < 3400) return [1.00, 0.72, 0.52];
|
|
if (k < 5000) return [1.00, 0.86, 0.70];
|
|
if (k < 6300) return [1.00, 0.97, 0.92];
|
|
if (k < 8000) return [0.92, 0.95, 1.00];
|
|
return [0.80, 0.87, 1.00];
|
|
}
|
|
|
|
var SKYPTS_VERT = [
|
|
'attribute vec4 aSd;', // xyz = unit direction (equatorial frame), w = V mag
|
|
'attribute vec4 aScol;', // rgb = tint, w = twinkle phase
|
|
'uniform mat4 uMVP;', // proj * rotation-only view
|
|
'uniform vec3 uPole;',
|
|
'uniform float uLon;', // sidereal angle
|
|
'uniform float uSizeScale;',
|
|
'varying vec4 vC;',
|
|
'varying float vAlt;',
|
|
'void main() {',
|
|
'vec3 d = aSd.xyz;',
|
|
// Rodrigues rotation of the star frame around the celestial pole
|
|
'float c = cos(uLon), s = sin(uLon);',
|
|
'vec3 rp = d * c + cross(uPole, d) * s + uPole * dot(uPole, d) * (1.0 - c);',
|
|
'vAlt = rp.y;',
|
|
// finite radius (inside the far plane): w=0 would put z_ndc just
|
|
// outside [-1,1] and clip every single point away
|
|
'gl_Position = uMVP * vec4(rp * 88.0, 1.0);',
|
|
'gl_PointSize = clamp((7.2 - aSd.w) * 0.60 * uSizeScale, 0.8, 10.0);',
|
|
'vC = aScol;',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var SKYPTS_FRAG = [
|
|
'precision mediump float;',
|
|
'uniform float uNight;',
|
|
'uniform float uTime;',
|
|
'varying vec4 vC;',
|
|
'varying float vAlt;',
|
|
'void main() {',
|
|
'vec2 pc = gl_PointCoord - 0.5;',
|
|
'float d = length(pc) * 2.0;',
|
|
'float disc = 1.0 - smoothstep(0.45, 1.0, d);',
|
|
'float glow = 0.22 * exp(-d * 2.8);',
|
|
'float twk = 0.85 + 0.15 * sin(uTime * 1.6 + vC.w * 33.0 + vAlt * 451.7 + vC.r * 180.3);',
|
|
'float altf = smoothstep(-0.02, 0.12, vAlt);',
|
|
// vC.w = per-star brightness (mag-based, floored so faint stars
|
|
// stay clearly visible), giving an even overall look
|
|
'float a = uNight * altf * twk * vC.w;',
|
|
'gl_FragColor = vec4(vC.rgb * (disc + glow) * a * 1.35, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
function buildSkyTexture(stars) {
|
|
var W = 2048, H = 1024;
|
|
skyTexCanvas = document.createElement('canvas');
|
|
skyTexCanvas.width = W; skyTexCanvas.height = H;
|
|
var ctx = skyTexCanvas.getContext('2d');
|
|
ctx.fillStyle = 'rgb(0,0,0)';
|
|
ctx.fillRect(0, 0, W, H);
|
|
|
|
// --- Milky Way: galactic band, low res, upscaled with 'lighter'
|
|
var gw = 512, gh = 256;
|
|
var mc = document.createElement('canvas'); mc.width = gw; mc.height = gh;
|
|
var mctx = mc.getContext('2d', { willReadFrequently: true });
|
|
var md = mctx.createImageData(gw, gh);
|
|
var aNGP = 192.85 * Math.PI / 180, dNGP = 27.13 * Math.PI / 180;
|
|
var lNCP = 122.93 * Math.PI / 180;
|
|
function hashN(v) { var s = Math.sin(v * 127.1) * 43758.5453; return s - Math.floor(s); }
|
|
for (var iy = 0; iy < gh; iy++) {
|
|
var dec = (90 - (iy + 0.5) / gh * 180) * Math.PI / 180;
|
|
for (var ix = 0; ix < gw; ix++) {
|
|
var ra = ((ix + 0.5) / gw) * 2 * Math.PI;
|
|
var sb = Math.sin(dec) * Math.cos(dNGP) + Math.cos(dec) * Math.sin(dNGP) * Math.cos(ra - aNGP);
|
|
var b = Math.asin(Math.max(-1, Math.min(1, sb)));
|
|
var y = Math.cos(dec) * Math.sin(ra - aNGP);
|
|
var x = Math.sin(dec) * Math.sin(dNGP) - Math.cos(dec) * Math.cos(dNGP) * Math.cos(ra - aNGP);
|
|
var l = Math.atan2(y, x) - lNCP; while (l < 0) l += 2 * Math.PI; l %= (2 * Math.PI);
|
|
var band = Math.exp(-(b / 0.16) * (b / 0.16) * 0.5);
|
|
// rift modulation along galactic longitude + fine noise
|
|
var mod = 0.55 + 0.45 * Math.sin(l * 2 + 1.2) * 0.5 + hashN(ix * 7.13 + iy * 91.7) * 0.35;
|
|
var rim = Math.exp(-Math.abs(b) / 0.5);
|
|
var I = band * mod * rim * 0.052;
|
|
var o = (iy * gw + ix) * 4;
|
|
md.data[o] = (I * 190) | 0; md.data[o + 1] = (I * 200) | 0;
|
|
md.data[o + 2] = (I * 235) | 0; md.data[o + 3] = 255;
|
|
}
|
|
}
|
|
mctx.putImageData(md, 0, 0);
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.drawImage(mc, 0, 0, W, H);
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
|
|
var gl = skyTexGl;
|
|
if (!gl) return;
|
|
|
|
// Milky-Way band texture (sharpness irrelevant, LINEAR is right here)
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
skyTexture = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, skyTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, skyTexCanvas);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
|
|
// --- catalogue stars as point geometry ------------------------------
|
|
// equatorial -> world basis with the pole tilted by the latitude:
|
|
// u1 = east, u2 = cross(P, east), P = (0, sin lat, cos lat)
|
|
var sLat = SKY_SIN_LAT, cLat = SKY_COS_LAT;
|
|
var data = new Float32Array(stars.length * 8);
|
|
var di = 0;
|
|
for (var i = 0; i < stars.length; i++) {
|
|
var raR = stars[i][0] * Math.PI / 180, decR = stars[i][1] * Math.PI / 180;
|
|
var mag = stars[i][2], k = stars[i][3];
|
|
var cd = Math.cos(decR), sd = Math.sin(decR), cr = Math.cos(raR), sr = Math.sin(raR);
|
|
// dir = cd*(cr*u1 + sr*u2) + sd*P
|
|
var dx = cd * cr;
|
|
var dy = sLat * sd + cLat * cd * sr;
|
|
var dz = cLat * sd - sLat * cd * sr;
|
|
var rgb = kelvinToRGB(k);
|
|
// desaturate strongly towards white (tints were too strong)
|
|
rgb[0] = 0.25 * rgb[0] + 0.75;
|
|
rgb[1] = 0.25 * rgb[1] + 0.75;
|
|
rgb[2] = 0.25 * rgb[2] + 0.75;
|
|
data[di++] = dx; data[di++] = dy; data[di++] = dz; data[di++] = mag;
|
|
data[di++] = rgb[0]; data[di++] = rgb[1]; data[di++] = rgb[2];
|
|
// per-star brightness: floor at 0.6 so even 6.5-mag stars glow
|
|
// clearly; doubles as the twinkle phase seed
|
|
var b = 0.6 + 0.4 * Math.min(1, Math.pow(2.512, -mag * 0.7) * 1.3);
|
|
data[di++] = b;
|
|
}
|
|
var prog = makeProgram(gl, SKYPTS_VERT, SKYPTS_FRAG);
|
|
if (!prog) return;
|
|
var vbo = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
|
|
skyPts = {
|
|
prog: prog, vbo: vbo, count: stars.length,
|
|
a0: gl.getAttribLocation(prog, 'aSd'),
|
|
a1: gl.getAttribLocation(prog, 'aScol'),
|
|
u: {
|
|
mvp: gl.getUniformLocation(prog, 'uMVP'),
|
|
pole: gl.getUniformLocation(prog, 'uPole'),
|
|
lon: gl.getUniformLocation(prog, 'uLon'),
|
|
sizeScale: gl.getUniformLocation(prog, 'uSizeScale'),
|
|
night: gl.getUniformLocation(prog, 'uNight'),
|
|
time: gl.getUniformLocation(prog, 'uTime')
|
|
}
|
|
};
|
|
}
|
|
|
|
function loadSkyTexture(gl) {
|
|
if (skyTexture || skyTexCanvas) return;
|
|
skyTexGl = gl;
|
|
fetch('vendor/stars.json')
|
|
.then(function (r) { if (!r.ok) throw new Error('stars http ' + r.status); return r.json(); })
|
|
.then(function (stars) { buildSkyTexture(stars); })
|
|
.catch(function (e) { console.warn('[clouds3d] star catalogue failed: ' + (e && e.message || e)); });
|
|
}
|
|
|
|
// Initialise the ground state. Must be called once with the WebGL
|
|
// context before clouds3dRender uses it.
|
|
function initGroundSystem(glCtx) {
|
|
if (groundCanvas) return; // already initialised
|
|
groundGl = glCtx;
|
|
groundCanvas = document.createElement('canvas');
|
|
// Canvas is corridor-perp + label-margin columns wide and corridor
|
|
// long. The shader maps world coordinates onto these exact canvas
|
|
// extents (uGroundSize), so groundCanvasW/H is the single source of
|
|
// truth for both drawing and sampling.
|
|
groundCanvas.width = groundCanvasW();
|
|
groundCanvas.height = groundCanvasH();
|
|
groundCtx = groundCanvas.getContext('2d');
|
|
// Pre-fill with fog colour so partially-loaded mosaic still
|
|
// shows the right base colour
|
|
groundCtx.fillStyle = '#7c9aa1';
|
|
groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height);
|
|
groundTexture = groundGl.createTexture();
|
|
groundGl.bindTexture(groundGl.TEXTURE_2D, groundTexture);
|
|
groundGl.pixelStorei(groundGl.UNPACK_FLIP_Y_WEBGL, true);
|
|
groundGl.texImage2D(groundGl.TEXTURE_2D, 0, groundGl.RGBA, groundGl.RGBA, groundGl.UNSIGNED_BYTE, groundCanvas);
|
|
groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_WRAP_S, groundGl.CLAMP_TO_EDGE);
|
|
groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_WRAP_T, groundGl.CLAMP_TO_EDGE);
|
|
groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MAG_FILTER, groundGl.LINEAR);
|
|
if (IS_WEBGL2) {
|
|
// NPOT mipmapping (WebGL2): removes the shimmer on distant
|
|
// ground and lets the sampler read pre-filtered small mips
|
|
// instead of random-accessing the 5760x5504 base level.
|
|
groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MIN_FILTER, groundGl.LINEAR_MIPMAP_LINEAR);
|
|
groundGl.generateMipmap(groundGl.TEXTURE_2D);
|
|
if (anisoExt) {
|
|
groundGl.texParameterf(groundGl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, ANISO_MAX);
|
|
}
|
|
} else {
|
|
groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MIN_FILTER, groundGl.LINEAR);
|
|
}
|
|
// Kick off initial fetch
|
|
rebuildGroundMosaic(GROUND_BASE_X, GROUND_BASE_Y);
|
|
buildElevationSystem(glCtx);
|
|
initNightSystem(glCtx);
|
|
fetchMotorways();
|
|
fetchSettlements();
|
|
loadSkyTexture(glCtx);
|
|
}
|
|
|
|
function hex(rgb) {
|
|
return [((rgb >> 16) & 255) / 255,
|
|
((rgb >> 8) & 255) / 255,
|
|
(rgb & 255) / 255];
|
|
}
|
|
|
|
// Cloud density per weather (0 = clear, 1 = overcast).
|
|
// Names mirror the weather themes defined in background.html.
|
|
var WEATHER_DENSITY = {
|
|
sunny: 0.20,
|
|
cloudy: 0.70,
|
|
stormy: 1.00,
|
|
night: 0.45
|
|
};
|
|
|
|
window.clouds3dRender = function(target) {
|
|
if (!target || typeof target !== 'object') {
|
|
console.error('clouds3d: invalid target');
|
|
return;
|
|
}
|
|
var canvas = target.__clouds3dCanvas;
|
|
if (!canvas) {
|
|
canvas = document.createElement('canvas');
|
|
canvas.style.position = 'absolute';
|
|
canvas.style.top = '0';
|
|
canvas.style.left = '0';
|
|
canvas.style.width = '100%';
|
|
canvas.style.height = '100%';
|
|
while (target.firstChild) target.removeChild(target.firstChild);
|
|
target.appendChild(canvas);
|
|
target.style.position = 'relative';
|
|
target.__clouds3dCanvas = canvas;
|
|
}
|
|
|
|
if (!canvas.__state) {
|
|
canvas.__state = init(canvas);
|
|
if (!canvas.__state) return;
|
|
// Initialise the tile-based ground system. This sets up the
|
|
// canvas + GPU texture and kicks off the initial tile corridor
|
|
// fetch. Frames render normally before tiles arrive — they
|
|
// sample the fog-coloured initial fill.
|
|
clouds3dState = canvas.__state;
|
|
initGroundSystem(canvas.__state.gl);
|
|
}
|
|
|
|
var s = canvas.__state;
|
|
var gl = s.gl;
|
|
// Only touch canvas.width/height when it actually changes: assigning
|
|
// it every frame reallocates the backbuffer (visible as jank).
|
|
// CONFIG.renderScale (0.3..1) renders the 3D at lower internal
|
|
// resolution and upscales via CSS if the shader is GPU-bound.
|
|
var cw = target.clientWidth || window.innerWidth;
|
|
var ch = target.clientHeight || window.innerHeight;
|
|
var rs = (window.CONFIG && window.CONFIG.renderScale) || 1;
|
|
rs = Math.max(0.3, Math.min(1, rs));
|
|
var rw = Math.round(cw * rs);
|
|
var rh = Math.round(ch * rs);
|
|
if (canvas.width !== rw) canvas.width = rw;
|
|
if (canvas.height !== rh) canvas.height = rh;
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
|
|
gl.useProgram(s.prog);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, s.buf);
|
|
gl.enableVertexAttribArray(s.aPos);
|
|
gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
// Coalesced ground texture upload: at most one per GROUND_UPLOAD_MIN_MS,
|
|
// never inline from a queue callback.
|
|
if (groundTextureDirty && performance.now() - groundLastUpload > GROUND_UPLOAD_MIN_MS) {
|
|
uploadGroundTexture();
|
|
}
|
|
|
|
// Bind ground texture to texture unit 1.
|
|
gl.activeTexture(gl.TEXTURE1);
|
|
gl.bindTexture(gl.TEXTURE_2D, groundTexture);
|
|
gl.uniform1i(s.uGround, 1);
|
|
|
|
// Elevation grid on texture unit 2 (falls back to a bound dummy
|
|
// until the elev texture exists; uElevOn keeps it unused before).
|
|
gl.activeTexture(gl.TEXTURE2);
|
|
gl.bindTexture(gl.TEXTURE_2D, elevTexture || groundTexture);
|
|
gl.uniform1i(s.uElev, 2);
|
|
gl.uniform1f(s.uElevOn, (elevTexture && TERRAIN_ON > 0.5) ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uElevScale, ELEV_SCALE_UNITS);
|
|
var sd = window.__currentSunDir || [0, 0.7, 0.7];
|
|
gl.uniform3f(s.uSunDir, sd[0], sd[1], sd[2]);
|
|
var md = window.__currentMoonDir || sd;
|
|
gl.uniform3f(s.uMoonDir, md[0], md[1], md[2]);
|
|
|
|
// Star catalogue sky texture on texture unit 4
|
|
gl.activeTexture(gl.TEXTURE4);
|
|
gl.bindTexture(gl.TEXTURE_2D, skyTexture || s.blackTex);
|
|
gl.uniform1i(s.uSkyTex, 4);
|
|
gl.uniform1f(s.uSkyOn, skyTexture ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uStarLon, -((window.__currentDayProgress || 0) * 6.2831853 + STAR_PH));
|
|
gl.uniform3f(s.uPole, 0, SKY_SIN_LAT, SKY_COS_LAT);
|
|
|
|
gl.uniform2f(s.uRes, canvas.width, canvas.height);
|
|
var now = performance.now() / 1000;
|
|
gl.uniform1f(s.uTime, now);
|
|
|
|
var pal = window.__currentPalette;
|
|
if (!pal) {
|
|
gl.clearColor(0, 0, 0, 1);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
return;
|
|
}
|
|
|
|
var weather = window.__currentWeatherName;
|
|
var dayProgress = window.__currentDayProgress;
|
|
var themeSpeed = window.__currentThemeSpeed;
|
|
|
|
gl.uniform3fv(s.uSky, hex(pal.sky));
|
|
gl.uniform3fv(s.uCloud, hex(pal.cloud));
|
|
gl.uniform3fv(s.uCloudShadow, hex(
|
|
pal.cloudShadow !== undefined ? pal.cloudShadow : pal.sky));
|
|
gl.uniform3fv(s.uLight, hex(pal.light));
|
|
gl.uniform3fv(s.uBg, hex(pal.bg));
|
|
|
|
var density = (weather && WEATHER_DENSITY[weather] !== undefined)
|
|
? WEATHER_DENSITY[weather] : 0.5;
|
|
gl.uniform1f(s.uDensity, density);
|
|
|
|
var day = (typeof dayProgress === 'number')
|
|
? Math.max(0, Math.min(1, dayProgress)) : 0.5;
|
|
gl.uniform1f(s.uDayProgress, day);
|
|
|
|
var sunAlt = (typeof window.__currentSunAlt === 'number')
|
|
? window.__currentSunAlt : 0;
|
|
gl.uniform1f(s.uSunAlt, sunAlt);
|
|
|
|
var speed = (typeof themeSpeed === 'number')
|
|
? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0;
|
|
gl.uniform1f(s.uSpeed, speed);
|
|
gl.uniform1f(s.uFogDist, FOG_TILES);
|
|
gl.uniform1f(s.uCloudTop, CLOUD_TOP);
|
|
gl.uniform1f(s.uCloudBot, CLOUD_BOT);
|
|
|
|
// Ground mosaic uniforms + periodic re-fetch trigger.
|
|
// uGroundCenter is the world position (X, Z) of the centre tile's
|
|
// centre, with 1 world unit = 1 tile. World +X = east (tileX+),
|
|
// world +Z = north = tileY-, hence the mirrored expressions:
|
|
// x: centreTile - BASE + 0.5
|
|
// y: BASE - centreTile - 0.5
|
|
// uGroundSize is the full canvas extent in tiles (width includes the
|
|
// label margin); the shader maps world offsets linearly onto the
|
|
// canvas UVs.
|
|
var centerWorld = {
|
|
x: groundCenterTile.x - GROUND_BASE_X + 0.5,
|
|
y: GROUND_BASE_Y - groundCenterTile.y - 0.5
|
|
};
|
|
gl.uniform2f(s.uGroundCenter, centerWorld.x, centerWorld.y);
|
|
gl.uniform2f(s.uGroundSize, groundTilesX(), groundTilesY());
|
|
gl.uniform2f(s.uCorridorRot, GROUND_TRAVEL_COS, GROUND_TRAVEL_SIN);
|
|
var fd = (window.__flightDir && (window.__flightDir.dx !== undefined))
|
|
? window.__flightDir : { dx: 0, dz: 1 };
|
|
|
|
// Camera path: figure-eight around the configured point (crossing =
|
|
// config location, mosaic centre/world origin) or the classic
|
|
// straight drift. figure8Step integrates arc length so ground speed
|
|
// stays constant (speed * 0.12 tiles/s) and re-aims the eight's
|
|
// bearing randomly through each crossing.
|
|
var camX, camZ, hdX, hdZ;
|
|
if (ORBIT_R > 0) {
|
|
var fp = figure8Step(now, speed * 0.12);
|
|
camX = fp.x;
|
|
camZ = fp.z;
|
|
hdX = fp.hx;
|
|
hdZ = fp.hz;
|
|
} else {
|
|
var drift = now * speed * 0.12;
|
|
camX = (fd.dx !== 0) ? drift * fd.dx : 0;
|
|
camZ = (fd.dz !== 0) ? drift * fd.dz : 0;
|
|
hdX = fd.dx;
|
|
hdZ = fd.dz;
|
|
}
|
|
groundCameraX = camX;
|
|
groundCameraZ = camZ;
|
|
// View direction as compass bearing (world +X = east, +Z = north,
|
|
// matching the sun-vector convention): 0 = north, clockwise.
|
|
// The clock's compass needle reads this.
|
|
window.__flightBearing = Math.atan2(hdX, hdZ);
|
|
// Periodic check: when the camera approaches the corridor edge,
|
|
// re-centre the mosaic (whole-tile shift of the canvas content +
|
|
// fetch the newly exposed columns). At uSpeed=1 the camera moves
|
|
// 0.12 tiles/s, so this fires only every few minutes.
|
|
if (performance.now() - groundLastCheck > 1500) {
|
|
groundLastCheck = performance.now();
|
|
refreshGroundMosaic();
|
|
}
|
|
|
|
// ---- View + projection matrices (shared by the terrain mesh and the
|
|
// cloud/sky passes, so the perspective lines up).
|
|
// slow altitude bob: climb/dive within a window, pitch follows the
|
|
// vertical rate (nose lifts while climbing, dips while descending)
|
|
var bob = BOB_ON ? now * BOB_W : 0;
|
|
var camY = BOB_ON
|
|
? CAM_MID_Y + BOB_AMP * 0.74 * (Math.sin(bob) + 0.35 * Math.sin(bob * 0.31 + 1.7))
|
|
: CAM_MID_Y;
|
|
// hard flight band 15-35 km (1 world unit ~ 12.95 km at this latitude)
|
|
var CAM_MIN_Y = FL_MIN_KM / KM_PER_TILE;
|
|
var CAM_MAX_Y = FL_MAX_KM / KM_PER_TILE;
|
|
if (camY < CAM_MIN_Y) camY = CAM_MIN_Y;
|
|
else if (camY > CAM_MAX_Y) camY = CAM_MAX_Y;
|
|
var pitch = BOB_ON ? 0.45 - BOB_TILT * Math.cos(bob) : 0.45;
|
|
var aspect = canvas.width / canvas.height;
|
|
// The original cloud shader used a non-standard (wider) vertical fov
|
|
// so the top of the screen looked above the horizon (rd.y ~ 0.2 at
|
|
// uv.y=1). With a standard perspective we need a fovY large enough
|
|
// to see the sky above the cloud deck; the terrain mesh and the
|
|
// cloud pass share the same matrices so they line up exactly.
|
|
var fovY = 60 * Math.PI / 180; // ~60 deg vertical fov
|
|
var fovX = 2 * Math.atan(Math.tan(fovY / 2) * aspect);
|
|
var near = 0.1, far = 100.0;
|
|
var proj = mat4Perspective(fovY, aspect, near, far);
|
|
// Forward direction = the same tilt the cloud shader uses at uv=0:
|
|
// (heading.x, -0.45, heading.y), normalised.
|
|
// Debug override: point at world origin so the mesh is always in view.
|
|
var fwdLen = Math.hypot(hdX, pitch, hdZ) || 1;
|
|
var fwdX = hdX / fwdLen, fwdY = -pitch / fwdLen, fwdZ = hdZ / fwdLen;
|
|
var view = mat4LookAt(
|
|
[camX, camY, camZ],
|
|
[camX + fwdX * 5, camY + fwdY * 5, camZ + fwdZ * 5],
|
|
[0, 1, 0]
|
|
);
|
|
// Inverse of (proj * view) is uploaded to the cloud/sky shader so
|
|
// both can reconstruct the world ray from gl_FragCoord identically
|
|
// to the terrain mesh's projection.
|
|
var viewProj = new Float32Array(16);
|
|
mat4Multiply(viewProj, proj, view);
|
|
var viewProjInv = new Float32Array(16);
|
|
mat4Inverse(viewProjInv, viewProj);
|
|
|
|
// ---- Clear color + depth ------------------------------------------
|
|
gl.clearColor(0, 0, 0, 1);
|
|
gl.clearDepth(1.0);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthFunc(gl.LESS);
|
|
gl.disable(gl.BLEND);
|
|
gl.colorMask(true, true, true, true);
|
|
gl.depthMask(true);
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
|
|
// ---- Sky + stars pass (uMode = 0) --------------------------------
|
|
// Drawn FIRST, without depth test/write, so the terrain pass can
|
|
// overdraw it where the ground is in front.
|
|
gl.useProgram(s.prog);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, s.buf);
|
|
gl.enableVertexAttribArray(s.aPos);
|
|
gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
gl.uniformMatrix4fv(s.uViewProjInv, false, viewProjInv);
|
|
gl.disable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.uniform1i(s.uMode, 0);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
|
|
// ---- Catalogue star points (crisp, additive) ----
|
|
// Drawn right after the sky gradient, before the terrain: the depth
|
|
// test of the mesh pass hides them below the horizon automatically.
|
|
var starNight = 1 - Math.max(0, Math.min(1, (sunAlt + 0.20) / 0.25));
|
|
if (skyPts && starNight > 0.02) {
|
|
var viewR = new Float32Array(16);
|
|
viewR[0] = view[0]; viewR[1] = view[1]; viewR[2] = view[2];
|
|
viewR[4] = view[4]; viewR[5] = view[5]; viewR[6] = view[6];
|
|
viewR[8] = view[8]; viewR[9] = view[9]; viewR[10] = view[10];
|
|
viewR[12] = 0; viewR[13] = 0; viewR[14] = 0; viewR[15] = 1;
|
|
var starMVP = new Float32Array(16);
|
|
mat4Multiply(starMVP, proj, viewR);
|
|
gl.useProgram(skyPts.prog);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, skyPts.vbo);
|
|
gl.enableVertexAttribArray(skyPts.a0);
|
|
gl.vertexAttribPointer(skyPts.a0, 4, gl.FLOAT, false, 32, 0);
|
|
gl.enableVertexAttribArray(skyPts.a1);
|
|
gl.vertexAttribPointer(skyPts.a1, 4, gl.FLOAT, false, 32, 12);
|
|
gl.uniformMatrix4fv(skyPts.u.mvp, false, starMVP);
|
|
gl.uniform3f(skyPts.u.pole, 0, SKY_SIN_LAT, SKY_COS_LAT);
|
|
gl.uniform1f(skyPts.u.lon, -((window.__currentDayProgress || 0) * 6.2831853 + STAR_PH));
|
|
gl.uniform1f(skyPts.u.sizeScale, canvas.height / 900);
|
|
gl.uniform1f(skyPts.u.night, starNight);
|
|
gl.uniform1f(skyPts.u.time, now);
|
|
gl.disable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.ONE, gl.ONE);
|
|
gl.drawArrays(gl.POINTS, 0, skyPts.count);
|
|
gl.disable(gl.BLEND);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(true);
|
|
}
|
|
|
|
// ---- Terrain mesh pass (real 3D heightfield) ---------------------
|
|
// The mesh VBO is built once by buildTerrainMesh() after the
|
|
// elevation grid finishes loading. Depth test+write on so it
|
|
// overdraws the sky where the ground is in front and writes the
|
|
// depth that the cloud pass needs to be depth-occluded (we
|
|
// disable depth test for clouds, so we lean on the manual discard
|
|
// for cloud-vs-mesh occlusion).
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(true);
|
|
var terr = s.terrain;
|
|
if (terr && terr.vbo && terr.prog) {
|
|
gl.useProgram(terr.prog);
|
|
gl.uniformMatrix4fv(terr.uView, false, view);
|
|
gl.uniformMatrix4fv(terr.uProj, false, proj);
|
|
gl.uniform1i(terr.uGround, 1);
|
|
// Night lights on texture unit 3 (black until the layer exists)
|
|
gl.activeTexture(gl.TEXTURE3);
|
|
gl.bindTexture(gl.TEXTURE_2D, nightTexture || s.blackTex);
|
|
gl.uniform1i(terr.uNight, 3);
|
|
gl.uniform1f(terr.uNightLightsOn, NIGHT_ON ? 1.0 : 0.0);
|
|
gl.uniform2f(terr.uGroundCenter, centerWorld.x, centerWorld.y);
|
|
gl.uniform2f(terr.uGroundSize, groundTilesX(), groundTilesY());
|
|
gl.uniform2f(terr.uCorridorRot, GROUND_TRAVEL_COS, GROUND_TRAVEL_SIN);
|
|
gl.uniform1f(terr.uElevScale, ELEV_SCALE_UNITS);
|
|
gl.uniform1f(terr.uCloudsOn, 0.0); // mesh has no cloud logic
|
|
gl.uniform3f(terr.uSunDir, sd[0], sd[1], sd[2]);
|
|
gl.uniform3fv(terr.uLight, hex(pal.light));
|
|
gl.uniform3fv(terr.uBg, hex(pal.bg));
|
|
gl.uniform3fv(terr.uSky, hex(pal.sky));
|
|
gl.uniform1f(terr.uFogDist, FOG_TILES);
|
|
var nightFac = Math.max(0, Math.min(1, -sunAlt / 0.4));
|
|
gl.uniform1f(terr.uNightFactor, nightFac);
|
|
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, terr.vbo);
|
|
var STRIDE = 8 * 4; // 8 floats per vertex
|
|
if (terr.aPos >= 0) {
|
|
gl.enableVertexAttribArray(terr.aPos);
|
|
gl.vertexAttribPointer(terr.aPos, 3, gl.FLOAT, false, STRIDE, 0);
|
|
}
|
|
if (terr.aNormal >= 0) {
|
|
gl.enableVertexAttribArray(terr.aNormal);
|
|
gl.vertexAttribPointer(terr.aNormal, 3, gl.FLOAT, false, STRIDE, 12);
|
|
}
|
|
if (terr.aUV >= 0) {
|
|
gl.enableVertexAttribArray(terr.aUV);
|
|
gl.vertexAttribPointer(terr.aUV, 2, gl.FLOAT, false, STRIDE, 24);
|
|
}
|
|
gl.drawArrays(gl.TRIANGLES, 0, terr.vertCount);
|
|
|
|
// Wireframe overlay: draw the mesh edges as bright lines on top
|
|
// of the textured triangles so the geometry is clearly visible.
|
|
// Uses a separate position-only VBO built in buildTerrainMesh.
|
|
// We disable the depth WRITE for the wireframe so the lines
|
|
// don't z-fight the surface they're on, but keep the depth TEST
|
|
// so terrain in front still occludes wireframe behind.
|
|
if (window.CONFIG && window.CONFIG.terrain && window.CONFIG.terrain.wireframe
|
|
&& terr.wireVbo && terr.uWireColor) {
|
|
gl.uniform3f(terr.uWireColor, 1.0, 0.45, 0.1); // bright orange
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, terr.wireVbo);
|
|
gl.enableVertexAttribArray(terr.aPos);
|
|
gl.disableVertexAttribArray(terr.aNormal);
|
|
gl.disableVertexAttribArray(terr.aUV);
|
|
gl.vertexAttribPointer(terr.aPos, 3, gl.FLOAT, false, 0, 0);
|
|
gl.depthMask(false);
|
|
gl.drawArrays(gl.LINES, 0, terr.wireVertCount);
|
|
gl.depthMask(true);
|
|
// restore the aNormal/aUV attribs for the next frame
|
|
gl.enableVertexAttribArray(terr.aNormal);
|
|
gl.enableVertexAttribArray(terr.aUV);
|
|
gl.vertexAttribPointer(terr.aNormal, 3, gl.FLOAT, false, STRIDE, 12);
|
|
gl.vertexAttribPointer(terr.aUV, 2, gl.FLOAT, false, STRIDE, 24);
|
|
gl.uniform3f(terr.uWireColor, 0, 0, 0);
|
|
}
|
|
}
|
|
|
|
// ---- Motorway car lights pass --------------------------------------
|
|
// Two additive passes (A-net + Bundesstrassen). Brightness follows
|
|
// the sunset ramp; vehicle DENSITY ramps 0 -> max -> 0 with the depth
|
|
// of night, and secondary roads additionally thin out by distance
|
|
// from the A-net (per-vertex aDens, smooth - never a hard cut).
|
|
var carNight = Math.max(0, Math.min(1, -(window.__currentSunAlt || 0) / 0.4));
|
|
if (carProg && (carVboM || carVboB) && carNight > 0.01) {
|
|
var tCurve = Math.max(0, Math.min(1, (carNight - 0.10) / 0.40));
|
|
tCurve = tCurve * tCurve * (3 - 2 * tCurve); // smoothstep
|
|
// rush-hour shaping: busy evening/early morning, quiet 23:00-04:00
|
|
var rush = 1;
|
|
var tDay = window.__currentDayProgress;
|
|
if (typeof tDay === 'number') {
|
|
var hh = tDay * 24;
|
|
var nightPos = hh >= 23 ? (hh - 23) / 5 : (hh < 4 ? (hh + 1) / 5 : -1);
|
|
if (nightPos >= 0) {
|
|
var edge = Math.min(1, Math.min(nightPos, 1 - nightPos) * 5);
|
|
edge = edge * edge * (3 - 2 * edge);
|
|
rush = 1 - 0.65 * edge; // dips to 0.35
|
|
}
|
|
// weekend factor (window.__flightDow: 0=Sun..6=Sat), blended
|
|
// smoothly over the hour around midnight so nothing pops
|
|
var dow = window.__flightDow;
|
|
if (typeof dow === 'number') {
|
|
var wOf = function (d) { return d === 0 ? 0.55 : (d === 6 ? 0.65 : 1); };
|
|
var fNow = wOf(dow);
|
|
if (hh >= 23) {
|
|
fNow = fNow + (wOf((dow + 1) % 7) - fNow) * (hh - 23);
|
|
} else if (hh < 1) {
|
|
fNow = wOf((dow + 6) % 7) + (fNow - wOf((dow + 6) % 7)) * hh;
|
|
}
|
|
rush *= fNow;
|
|
}
|
|
}
|
|
gl.useProgram(carProg);
|
|
gl.uniformMatrix4fv(carUniforms.proj, false, proj);
|
|
gl.uniformMatrix4fv(carUniforms.view, false, view);
|
|
gl.uniform3f(carUniforms.camPos, camX, camY, camZ);
|
|
gl.uniform1f(carUniforms.time, now);
|
|
gl.uniform1f(carUniforms.night, carNight);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.ONE, gl.ONE); // additive
|
|
var carDrawGroup = function (vbo, count, dash, speed, maxDens) {
|
|
if (!vbo || !count) return;
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.enableVertexAttribArray(carAttribs.pos);
|
|
gl.vertexAttribPointer(carAttribs.pos, 3, gl.FLOAT, false, 32, 0);
|
|
gl.enableVertexAttribArray(carAttribs.meta);
|
|
gl.vertexAttribPointer(carAttribs.meta, 4, gl.FLOAT, false, 32, 12);
|
|
gl.enableVertexAttribArray(carAttribs.dens);
|
|
gl.vertexAttribPointer(carAttribs.dens, 1, gl.FLOAT, false, 32, 28);
|
|
gl.uniform1f(carUniforms.dash, dash);
|
|
gl.uniform1f(carUniforms.speed, speed);
|
|
gl.uniform1f(carUniforms.dens, maxDens * tCurve * rush);
|
|
gl.drawArrays(gl.TRIANGLES, 0, count);
|
|
};
|
|
carDrawGroup(carVboM, carCountM, CAR_M_DASH, CAR_M_SPEED, CAR_M_DENS);
|
|
carDrawGroup(carVboB, carCountB, CAR_B_DASH, CAR_B_SPEED, CAR_B_DENS);
|
|
gl.disable(gl.BLEND);
|
|
gl.depthMask(true);
|
|
}
|
|
|
|
// ---- Settlement lights pass -----------------------------------------
|
|
// Static pale-yellow glow points marking towns/cities, additive like
|
|
// the car lights and riding the same sunset ramp (slightly ahead of
|
|
// them: street lighting switches on right at dusk).
|
|
if (cityProg && cityVbo && cityCount && carNight > 0.005) {
|
|
gl.useProgram(cityProg);
|
|
gl.uniformMatrix4fv(cityUniforms.proj, false, proj);
|
|
gl.uniformMatrix4fv(cityUniforms.view, false, view);
|
|
gl.uniform3f(cityUniforms.camPos, camX, camY, camZ);
|
|
gl.uniform1f(cityUniforms.night, Math.min(1, carNight * 1.35));
|
|
gl.uniform1f(cityUniforms.scale, gl.drawingBufferHeight / 600);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.ONE, gl.ONE);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, cityVbo);
|
|
gl.enableVertexAttribArray(cityAttribs.pos);
|
|
gl.vertexAttribPointer(cityAttribs.pos, 3, gl.FLOAT, false, 20, 0);
|
|
gl.enableVertexAttribArray(cityAttribs.size);
|
|
gl.vertexAttribPointer(cityAttribs.size, 1, gl.FLOAT, false, 20, 12);
|
|
gl.enableVertexAttribArray(cityAttribs.bri);
|
|
gl.vertexAttribPointer(cityAttribs.bri, 1, gl.FLOAT, false, 20, 16);
|
|
gl.drawArrays(gl.POINTS, 0, cityCount);
|
|
gl.disable(gl.BLEND);
|
|
gl.depthMask(true);
|
|
}
|
|
|
|
// ---- Cloud pass (uMode = 1) --------------------------------------
|
|
// Alpha-blend over the framebuffer (terrain mesh + sky+stars).
|
|
// Depth test/write OFF — the manual discard in the cloud shader
|
|
// (tCloudTop > tTerrain) handles cloud-behind-mesh occlusion, and
|
|
// the depth values from the full-screen quad would only confuse the
|
|
// alpha blend.
|
|
gl.useProgram(s.prog);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, s.buf);
|
|
gl.enableVertexAttribArray(s.aPos);
|
|
gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
gl.uniformMatrix4fv(s.uViewProjInv, false, viewProjInv);
|
|
gl.disable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
|
gl.uniform1i(s.uMode, 1);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
// Restore default state for the next frame.
|
|
gl.disable(gl.BLEND);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(true);
|
|
};
|
|
|
|
// Rebuild the terrain mesh from the current (cached) DEM canvas without
|
|
// a page reload - used together with window.CLOUDS3D_UV_FLIP_U/_V to
|
|
// find/fix the ground texture orientation.
|
|
window.clouds3dRebuildTerrain = function() {
|
|
if (!elevGl || !clouds3dState) return;
|
|
terrainVbo = null;
|
|
buildTerrainMesh(elevGl, clouds3dState);
|
|
};
|
|
|
|
})(); |