/* Volumetric 3D cloud renderer - by Richard Zink. * 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.34, 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 configured 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) || 51.5074; 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: central London (tile 1023/761). var _baseLoc = (window.CONFIG && window.CONFIG.location) || {}; var _baseLat = typeof _baseLoc.lat === 'number' ? _baseLoc.lat : 51.5074; var _baseLon = typeof _baseLoc.lon === 'number' ? _baseLoc.lon : -0.1278; 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; // re-entrancy guard for the periodic fetch cycles (15 min timer) var carFetching = false, cityFetching = 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' // kumi.systems removed: dead upstream (HTTP 500, no CORS headers) ]; // spread first-attempt load across the mirrors per cell index CAR_QUERIES.forEach(function (e, i) { e.start = i % CAR_ENDPOINTS.length; }); // ---- Polite global Overpass gate --------------------------------------- // Cars, places and airports share ONE gate: at most 2 queries in flight // and at least 900 ms between starts. Three fetchers bursting their // grids concurrently used to trip the mirrors' rate limits, which made // whole layers fail. One opQuery = one HTTP attempt (rotated mirror, // 30 s client-side timeout so a hanging instance never blocks the gate). var _opActive = 0, _opLast = 0, _opQueue = []; function isValidOverpass(t) { if (!t) return false; try { var d = JSON.parse(t); return !!(d && Array.isArray(d.elements)); } catch (e) { return false; } } function _opPump() { var now = Date.now(); while (_opActive < 2 && _opQueue.length && now - _opLast >= 900) { _opActive++; _opLast = now; _opQueue.shift()(); } if (_opQueue.length) setTimeout(_opPump, Math.max(50, 950 - (Date.now() - _opLast))); } function opGate() { return new Promise(function (go) { _opQueue.push(function () { go(function () { _opActive--; _opPump(); }); }); _opPump(); }); } function opQuery(q, startIdx, attempt) { var release; return opGate().then(function (r) { release = r; var ep = CAR_ENDPOINTS[((attempt || 0) + (startIdx || 0)) % CAR_ENDPOINTS.length]; var ctl = null, timer = null; try { ctl = new AbortController(); timer = setTimeout(function () { ctl.abort(); }, 30000); } catch (e) {} return fetch(ep, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'data=' + encodeURIComponent(q), signal: ctl ? ctl.signal : undefined }).then(function (resp) { if (timer) clearTimeout(timer); if (resp.status !== 200) return null; return resp.text().then(function (t) { // only ever hand back - and cache - a valid Overpass JSON; // a stored HTML error page once poisoned a cell forever return isValidOverpass(t) ? t : null; }); }, function () { if (timer) clearTimeout(timer); return null; }).then(function (txt) { release(); return txt; }, function () { // resp.text() can still reject (abort mid-body) - the gate // slot MUST be returned or the whole queue deadlocks release(); return null; }); }); } // full retry ladder over all mirrors, 2.5 s pause between failed attempts function opTry(q, startIdx) { var MAX = CAR_ENDPOINTS.length * 3; // three full mirror rounds function go(a) { if (a >= MAX) return Promise.resolve(null); return opQuery(q, startIdx, a).then(function (txt) { if (txt) return txt; return new Promise(function (r) { setTimeout(r, 2500 + Math.random() * 2000); }) .then(function () { return go(a + 1); }); }); } return go(0); } 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; } // keep the ORIGINAL end point: OSM ways of one road share exact // junction coordinates, and carMergeChains matches those seams var end = pts[pts.length - 1]; var last = out[out.length - 1]; if (Math.hypot(end.x - last.x, end.z - last.z) > 1e-9) { out.push({ x: end.x, z: end.z, d: last.d + leftover }); } return out; } function fetchMotorways() { if (!CAR_ON || carFetching) return; carFetching = true; var fresh = 0; // cells that needed a real fetch this cycle var fpParts = []; // "id:savedAt" of every raw cell -> fp() 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) { // local heading at both ends from RAW consecutive nodes // (resampled 2 km chords cut curves too aggressively for // seam-direction matching on real motorway curves) var h0 = pts[1], h1 = pts[0]; var t0 = pts[pts.length - 1], t1 = pts[pts.length - 2]; var hl = Math.hypot(h0.x - h1.x, h0.z - h1.z) || 1; var tl = Math.hypot(t0.x - t1.x, t0.z - t1.z) || 1; rp.headD = { x: (h1.x - h0.x) / hl, z: (h1.z - h0.z) / hl }; // away from body rp.tailD = { x: (t0.x - t1.x) / tl, z: (t0.z - t1.z) / tl }; groups[key].push(rp); } }); } // stitch the raw ways into continuous chains, rebuild ribbons and // (optionally on cycle completion) persist the chains for fast loads function rebuildChains(persist) { var mCh = carMergeChains(groups.motorway); var bCh = carMergeChains(groups.secondary); carRawCounts = [groups.motorway.length, groups.secondary.length]; carGeo = { motorway: mCh, secondary: bCh }; buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl)); if (!persist) return; var em = encodeChains(mCh), eb = encodeChains(bCh); openTileDB().then(function (db) { if (!db) return; tileDbPut(db, { id: 'geo/roadchains_v1', fp: cellsFp(fpParts), mCnts: em.cnts, mData: em.data, bCnts: eb.cnts, bData: eb.data, savedAt: Date.now() }); }); } // 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; rebuildChains(false); }, 1500); } function loadCell(entry) { return openTileDB().then(function (db) { return tileDbGet(db, entry.id).then(function (rec) { // validate stored JSON - a poisoned cache record (HTML error // page saved by an older build) must trigger a refetch if (rec && rec.json && isValidOverpass(rec.json)) { entry._cached = true; fpParts.push(entry.id + ':' + rec.savedAt); return rec.json; } return opTry(entry.q, entry.start).then(function (txt) { if (txt && db) { var ts = Date.now(); fpParts.push(entry.id + ':' + ts); tileDbPut(db, { id: entry.id, json: txt, savedAt: ts }); } return txt; }); }); }).then(function (txt) { if (txt) { parseWays(txt); if (!entry._cached) fresh++; if (!entry._cached || !carGeo) scheduleRebuild(); } else console.warn('[clouds3d] overpass cell failed: ' + entry.id); }); } // Fast path: merged chains are cached AND the fingerprint over all raw // cell records is unchanged -> restore chains straight to the GPU. // Fingerprint check reads record metadata only, no JSON parsing. openTileDB().then(function (db) { if (!db) return false; return tileDbGet(db, 'geo/roadchains_v1').then(function (rec) { if (!rec || !rec.mCnts || !rec.fp) return false; var ok = true, parts = []; return runPool(CAR_QUERIES, 8, function (e) { if (!ok) return Promise.resolve(); return tileDbGet(db, e.id).then(function (r) { if (!r || !isValidOverpass(r.json)) ok = false; else parts.push(e.id + ':' + r.savedAt); }); }).then(function () { if (!ok || cellsFp(parts) !== rec.fp) return false; carGeo = { motorway: decodeChains(rec.mCnts, rec.mData), secondary: decodeChains(rec.bCnts, rec.bData) }; buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl)); console.log('[clouds3d] car layer: ' + carGeo.motorway.length + ' motorway + ' + carGeo.secondary.length + ' chains (from cache)'); return true; }); }); }).then(function (hit) { if (hit) { carFetching = false; return; } // slow path: fetch what is missing, then stitch + persist once runPool(CAR_QUERIES, 2, function (entry) { return loadCell(entry); }).then(function () { carFetching = false; rebuildChains(true); console.log('[clouds3d] car layer: ' + groups.motorway.length + ' motorway + ' + groups.secondary.length + ' secondary polylines -> longest chain ~' + Math.round(carMaxChainKm) + ' km'); }); }); } // ---- 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 || cityFetching) return; cityFetching = true; var fresh = 0; var fpParts = []; // fine 0.55 x 0.65 deg cells: every query answers in <20 s, which is // the only reliable regime on the public mirrors (big boxes server- // time out at any timeout: value and come back as unparseable junk). // The shared 2-lane gate keeps the 78-cell grid polite over time. var cells = []; var ci = 0; for (var la = 46.8; la < 50.09; la += 0.55) { for (var lo = 9.4; lo < 17.29; lo += 0.65) { var bb = la.toFixed(2) + ',' + lo.toFixed(2) + ',' + Math.min(50.1, la + 0.55).toFixed(2) + ',' + Math.min(17.3, lo + 0.65).toFixed(2); var rx = '["place"~"^(city|town|suburb|village|hamlet)$"]'; cells.push({ id: 'geo/place_' + la.toFixed(2) + '_' + lo.toFixed(2), start: ci % CAR_ENDPOINTS.length, // spread mirror load // node + way: way carries districts mapped as areas; the // relation tier costs the server ~3x the time for <1% extra // places and regularly timed whole cells out q: '[out:json][timeout:35];(node' + rx + '(' + bb + ');' + 'way' + rx + '(' + bb + '););out center tags;' }); ci++; } } var list = []; var seenB = {}; // bucket -> index in list: dedupe node/way/rel of one place 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.tags) return; var rank = PLACE_RANK[el.tags.place]; if (rank == null) return; // nodes carry lat/lon; ways/relations a computed center var lat = el.lat != null ? el.lat : (el.center && el.center.lat); var lon = el.lon != null ? el.lon : (el.center && el.center.lon); if (lat == null || lon == null) return; var x = carLonToX(lon), z = carLatToZ(lat); if (Math.abs(x) > groundTilesX() / 2 + 2 || Math.abs(z) > groundTilesY() / 2 + 2) return; var popv = parseFloat(el.tags.population) || 0; // many cities exist BOTH as a named node and as a place boundary // relation - merge per ~3 km bucket instead of double-drawing var key = Math.round(x * 4) + '_' + Math.round(z * 4); var prev = seenB[key]; if (prev !== undefined) { var old = list[prev]; if (rank < old.rank) old.rank = rank; if (popv > old.pop) old.pop = popv; return; } seenB[key] = list.length; list.push({ x: x, z: z, rank: rank, pop: popv }); }); } function loadCell(entry) { return openTileDB().then(function (db) { return tileDbGet(db, entry.id).then(function (rec) { if (rec && rec.json && isValidOverpass(rec.json)) { entry._cached = true; fpParts.push(entry.id + ':' + rec.savedAt); return rec.json; } return opTry(entry.q, entry.start).then(function (txt) { if (txt && db) { var ts = Date.now(); fpParts.push(entry.id + ':' + ts); tileDbPut(db, { id: entry.id, json: txt, savedAt: ts }); } return txt; }); }); }).then(function (txt) { if (txt) { var before = list.length; parse(txt); if (list.length > before) console.log('[clouds3d] place cell ' + entry.id + ': +' + (list.length - before) + ' places (total ' + list.length + ')'); if (!entry._cached) fresh++; if (!entry._cached || !cityGeo) scheduleRebuild(); } else console.warn('[clouds3d] place cell failed: ' + entry.id); }); } // fast path: decoded settlement list cached with a matching cell fp openTileDB().then(function (db) { if (!db) return false; return tileDbGet(db, 'geo/placecache_v1').then(function (rec) { if (!rec || !rec.cnt || !rec.fp) return false; var ok = true, parts = []; return runPool(cells, 8, function (e) { if (!ok) return Promise.resolve(); return tileDbGet(db, e.id).then(function (r) { if (!r || !isValidOverpass(r.json)) ok = false; else parts.push(e.id + ':' + r.savedAt); }); }).then(function () { if (!ok || cellsFp(parts) !== rec.fp) return false; cityGeo = decodePlaces(rec.cnt, rec.data); buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl)); console.log('[clouds3d] settlements: ' + cityGeo.length + ' places (from cache)'); return true; }); }); }).then(function (hit) { if (hit) { cityFetching = false; return; } runPool(cells, 2, function (entry) { return loadCell(entry); }).then(function () { cityFetching = false; cityGeo = list.slice(); buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl)); openTileDB().then(function (db) { if (!db) return; tileDbPut(db, { id: 'geo/placecache_v1', fp: cellsFp(fpParts), cnt: list.length, data: encodePlaces(list), savedAt: Date.now() }); }); console.log('[clouds3d] settlements: ' + list.length + ' places -> ' + cityCount + ' lights' + (cityCount ? '' : ' (terrain pending)')); }); }); } 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);', // NOTE the minus: the flow equation moves a pulse toward DECREASING // arc length, i.e. actual travel = -tangent for side +1. Without // the flip, approaching cars would show taillights (red). '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;', // distance-driven cross-section: widen the bright plateau as the // ribbon shrinks below a pixel (kills lateral shimmer). ~0.3 at // >6.5 units (~85 km), flat near the camera. 'float grow = clamp((vDist - 3.0) * 0.09, 0.0, 0.3);', 'float body = 1.0 - smoothstep(0.5 + grow, 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. // Each slot also has a per-vehicle LIFE CYCLE: it switches on with // a 5 s fade-in, drives for a random 1-20 min, fades out and rests // for a random ~0.5-30 min before its next trip (on/off ramps in // spirit) - the traffic field never sits on a fixed pattern. 'float flow = vLen / uDash + dir * uTime * uSpeed;', // pulse width grows with distance (~1.5 px floor at the fade edge) // so distant cars read as slightly larger, steady lights instead // of one-pixel flicker 'float slot = 0.11 + clamp((vDist - 2.5) * 0.05, 0.0, 0.4);', '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 life = 60.0 + 1140.0 * hash1(s * 0.371 + 9.4);', 'float rest = 30.0 + 1770.0 * hash1(s * 0.617 + 3.2);', 'float per = life + rest;', 'float age = mod(uTime + 3607.0 * hash1(s * 0.913 + 7.7), per);', 'float on = smoothstep(0.0, 5.0, age) * (1.0 - smoothstep(life - 5.0, life, age));', 'if (on > 0.001) {', '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, on * max(0.0, 1.0 - d / slot));', '}', '}', '}', // beyond ~55 km merge the pulses into a steady traffic glow: // a constant reads as a line of lights, individual slots would // pop as they cross pixel boundaries 'float far = smoothstep(4.0, 6.5, vDist);', 'float steady = clamp(dens * 0.55, 0.0, 0.55);', 'inten = max(inten * (1.0 - 0.75 * far), steady * far);', 'if (inten <= 0.001) 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; } // Chain OSM road segments into continuous routes by GLOBAL best-first // stitching. Junctions are found PROXIMITY-based (not exact-node): many // German motorways end each carriageway at its own node a few metres // apart (bridges!), so exact-key matching severed chains at every // overpass. A candidate join exists when two way-ends lie within ~130 m // along the heading AND within ~32 m laterally (that lateral test is // what keeps the two carriageways of a dual road from joining each // other). Candidates are scored by directional continuity and processed // best-first, so a mainline continuation always claims a junction before // any ramp or angled fork can. Chains cap at ~580 km. Result: the A1 // Munich -> Vienna reads as ONE ribbon a light can traverse end to end. var carMaxChainKm = 0; var carRawCounts = [0, 0]; var carJoinCount = 0; function carMergeChains(polys) { var JOINED = 0; var MAX_D = 45; // world units ~ 580 km var NEAR = 0.03; // junction search radius (~390 m) // two stitching tiers: TIGHT seams (same carriageway - bridge splits, // shared nodes) claim junctions first; only still-open ends get the // LOOSE pass (big interchange nodes where the mainline is cut ~150-300 // m apart with a small lateral jog). Lateral tolerance is what keeps // parallel opposite carriageways from pairing with each other. var TIERS = [ { dot: 0.8, lat: 0.0012, alongLo: -0.0015, alongHi: 0.012 }, { dot: 0.92, lat: 0.0035, alongLo: -0.004, alongHi: 0.026 } ]; var i, j, k; var n = polys.length; var wayLen = new Float64Array(n); // endpoint records: position + unit vector pointing AWAY from the way var eps = []; var grid = {}; var cell = function (x, z) { return Math.floor(x / NEAR) + '_' + Math.floor(z / NEAR); }; for (i = 0; i < n; i++) { var P = polys[i]; if (!P || P.length < 2) continue; wayLen[i] = Math.max(P[0].d, P[P.length - 1].d); var a = P[0], b = P[P.length - 1]; var ha = P.headD, ta = P.tailD; if (!ha || !ta) { var a2 = P[1], b2 = P[P.length - 2]; var al = Math.hypot(a.x - a2.x, a.z - a2.z) || 1; var bl = Math.hypot(b.x - b2.x, b.z - b2.z) || 1; ha = { x: (a.x - a2.x) / al, z: (a.z - a2.z) / al }; ta = { x: (b.x - b2.x) / bl, z: (b.z - b2.z) / bl }; } var recs = [ { w: i, e: 0, x: a.x, z: a.z, ax: ha.x, az: ha.z }, { w: i, e: 1, x: b.x, z: b.z, ax: ta.x, az: ta.z } ]; for (k = 0; k < 2; k++) { var r = recs[k]; var ck = cell(r.x, r.z); (grid[ck] || (grid[ck] = [])).push(r); eps.push(r); } } // proximity candidate joins, per tier var pairGeom = {}; function geomFor(A, B) { var pk = A.w < B.w ? A.w + ':' + A.e + '|' + B.w + ':' + B.e : B.w + ':' + B.e + '|' + A.w + ':' + A.e; if (pairGeom[pk]) return pairGeom[pk]; var score = (-A.ax) * B.ax + (-A.az) * B.az; var dirx = -A.ax, dirz = -A.az; // travel into junction var gpx = B.x - A.x, gpz = B.z - A.z; // gap A.end -> B.start var along = gpx * dirx + gpz * dirz; var latx = gpx - along * dirx, latz = gpz - along * dirz; var g = { s: score - Math.hypot(gpx, gpz) * 5, dot: score, along: along, lat: Math.hypot(latx, latz) }; pairGeom[pk] = g; return g; } var candsByTier = [[], []]; for (var t = 0; t < 2; t++) { var T = TIERS[t]; var cands = candsByTier[t]; var seenPair = {}; for (i = 0; i < eps.length; i++) { var A2 = eps[i]; var gx = Math.floor(A2.x / NEAR), gz = Math.floor(A2.z / NEAR); for (var ox = -1; ox <= 1; ox++) { for (var oz = -1; oz <= 1; oz++) { var bucket = grid[(gx + ox) + '_' + (gz + oz)]; if (!bucket) continue; for (j = 0; j < bucket.length; j++) { var B2 = bucket[j]; if (B2.w === A2.w) continue; var pk2 = A2.w < B2.w ? A2.w + ':' + A2.e + '|' + B2.w + ':' + B2.e : B2.w + ':' + B2.e + '|' + A2.w + ':' + A2.e; if (seenPair[pk2]) continue; seenPair[pk2] = 1; var gm = geomFor(A2, B2); if (gm.dot <= T.dot) continue; if (gm.lat > T.lat) continue; // opposite carriageway: reject if (gm.along < T.alongLo || gm.along > T.alongHi) continue; cands.push({ a: A2, b: B2, s: gm.s }); } } } } cands.sort(function (p, q) { return q.s - p.s; }); } // union-find over ways with open-end bookkeeping var parent = new Int32Array(n); for (i = 0; i < n; i++) parent[i] = i; function find(v) { while (parent[v] !== v) { parent[v] = parent[parent[v]]; v = parent[v]; } return v; } var open = new Array(n), link = new Array(n), chainLen = new Float64Array(n); for (i = 0; i < n; i++) { open[i] = [1, 1]; link[i] = [null, null]; chainLen[i] = wayLen[i]; } for (var tc = 0; tc < 2; tc++) { var candsT = candsByTier[tc]; for (var c = 0; c < candsT.length; c++) { var ca = candsT[c].a, cb = candsT[c].b; if (!open[ca.w][ca.e] || !open[cb.w][cb.e]) continue; var ra = find(ca.w), rb = find(cb.w); if (ra === rb) continue; // would close a loop if (chainLen[ra] + chainLen[rb] > MAX_D) continue; link[ca.w][ca.e] = { w: cb.w, e: cb.e }; link[cb.w][cb.e] = { w: ca.w, e: ca.e }; open[ca.w][ca.e] = 0; open[cb.w][cb.e] = 0; parent[rb] = ra; chainLen[ra] += chainLen[rb]; JOINED++; } } // walk each component from a free end, accumulating arc length var out = [], started = new Uint8Array(n), maxD = 0; for (i = 0; i < n; i++) { if (started[i] || !polys[i] || polys[i].length < 2) continue; var startSide = open[i][0] ? 0 : (open[i][1] ? 1 : -1); if (startSide < 0) continue; var chain = [], last = null, lastD = 0, cur = i, enter = startSide, guard = 0; while (cur !== null && guard++ < 5000) { started[cur] = 1; var Q = polys[cur]; var s0 = enter === 1 ? Q.length - 1 : 0; var st = enter === 1 ? -1 : 1; for (var q = s0; q >= 0 && q < Q.length; q += st) { var pt = Q[q]; var d = last === null ? pt.d : lastD + Math.hypot(pt.x - last.x, pt.z - last.z); chain.push({ x: pt.x, z: pt.z, d: d }); last = pt; lastD = d; } var exit = enter === 0 ? 1 : 0; var nxt = link[cur][exit]; if (nxt) { cur = nxt.w; enter = nxt.e; } else cur = null; } if (chain.length > 1) { var span = Math.max(chain[0].d, chain[chain.length - 1].d); if (span > maxD) maxD = span; out.push(chain); } } // isolated ways (no joins at all) still form a chain of one for (i = 0; i < n; i++) { if (!started[i] && polys[i] && polys[i].length > 1) out.push(polys[i]); } if (n) { carMaxChainKm = maxD * KM_PER_TILE; carJoinCount = JOINED; } // empty input must not reset the previous call's stats return out; } 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); } // carGeo already carries MERGED chains: fetchMotorways stitches the // raw ways once per data change and caches the chains (geo/roadchains) var chainsM = carGeo.motorway; var chainsB = carGeo.secondary; var m = upload(carVboM, build(chainsM, false)); carVboM = m.vbo; carCountM = m.count; var bn = upload(carVboB, build(chainsB, true)); carVboB = bn.vbo; carCountB = bn.count; window.__carDbg = { motorwayIn: carRawCounts[0], secondaryIn: carRawCounts[1], motorwayChains: chainsM.length, secondaryChains: chainsB.length, maxChainKm: Math.round(carMaxChainKm * 10) / 10 }; 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.6, 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); } // small worker pool: run per-cell fetchers with N concurrent lanes // (Overpass instances tolerate ~2-3 parallel queries politely) function runPool(entries, lanes, worker) { return new Promise(function (resolve) { var idx = 0, active = 0; function next() { if (idx >= entries.length && active === 0) { resolve(); return; } while (active < lanes && idx < entries.length) { (function (entry) { active++; worker(entry).then(function () { active--; next(); }); })(entries[idx++]); } } next(); }); } // ---- Cacheable derived geometry ----------------------------------------- // Raw Overpass JSON already lives in IndexedDB forever; the expensive // post-processing (resample + chain stitching) is PERSISTED too and only // redone when a cell is actually (re)fetched. A cellsFp() fingerprint over // "id:savedAt" of every source record detects that; the fingerprint is // built from record metadata only - no JSON parsing needed to check it. function cellsFp(parts) { var s = parts.slice().sort().join('|'); var h = 2166136261; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) + ':' + parts.length; } function u8ToB64(u8) { var s = ''; for (var i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, Math.min(i + 8192, u8.length))); return btoa(s); } function b64ToU8(b64) { var s = atob(b64), u = new Uint8Array(s.length); for (var i = 0; i < s.length; i++) u[i] = s.charCodeAt(i); return u; } function f32ToB64(f) { return u8ToB64(new Uint8Array(f.buffer, f.byteOffset, f.byteLength)); } function b64ToF32(b64) { return new Float32Array(b64ToU8(b64).buffer); } function encodeChains(chains) { var cnts = [], total = 0, i, j; for (i = 0; i < chains.length; i++) { cnts.push(chains[i].length); total += chains[i].length; } var f = new Float32Array(total * 3), k = 0; for (i = 0; i < chains.length; i++) for (j = 0; j < chains[i].length; j++) { var p = chains[i][j]; f[k++] = p.x; f[k++] = p.z; f[k++] = p.d; } return { cnts: cnts, data: f32ToB64(f) }; } function decodeChains(cnts, b64) { var f = b64ToF32(b64), out = [], k = 0; for (var i = 0; i < cnts.length; i++) { var arr = []; for (var j = 0; j < cnts[i]; j++) { arr.push({ x: f[k], z: f[k + 1], d: f[k + 2] }); k += 3; } out.push(arr); } return out; } function encodePlaces(list) { var f = new Float32Array(list.length * 4); for (var i = 0; i < list.length; i++) { f[i * 4] = list[i].x; f[i * 4 + 1] = list[i].z; f[i * 4 + 2] = list[i].rank; f[i * 4 + 3] = list[i].pop; } return f32ToB64(f); } function decodePlaces(cnt, b64) { var f = b64ToF32(b64), out = []; for (var i = 0; i < cnt; i++) out.push({ x: f[i * 4], z: f[i * 4 + 1], rank: Math.round(f[i * 4 + 2]), pop: f[i * 4 + 3] }); return out; } // 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; // debug counter (also used by headless smoke tests) window.__cityDbg = { places: cityGeo ? cityGeo.length : 0, lights: cityCount }; 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') }; } } // ---- Aircraft traffic --------------------------------------------------- // Civil aviation between the real aerodromes of the region (OSM // aeroway=aerodrome, one Overpass query, cached like the road/place // data). Each aircraft rolls a RANDOM destination airport at take-off // (hubs weighted via CONFIG.flights wHub/wField/wStrip) and flies a // gently curved leg there - a quadratic // Bezier resampled to arc-length, so constant speed means constant // ground speed - with the full profile: take-off and climb, cruise // (CONFIG.flights altitudeKm), descent, landing, a short ground hold // (holdMinS..holdMaxS), then a fresh destination. Low phase hugs the // terrain mesh (climb/descent start at field elevation, not at sea // level). By night they blink (white strobes + red beacon), by day they // cross the sky as sun-shaded low-poly silhouettes with condensation // trails (see the contrail section for all trail parameters). Traffic // density is deliberately UNIFORM around the clock - aviation does not // follow the car rush-hour curve. // Data (c) OpenStreetMap contributors, ODbL. var FLT_CFG = (window.CONFIG && window.CONFIG.flights) || {}; function fltNum(v, d) { return (typeof v === 'number' && isFinite(v)) ? v : d; } var FLT_ON = FLT_CFG.enabled !== false; var FLT_ALT_KM = fltNum(FLT_CFG.altitudeKm, 14); var FLT_Y = FLT_ALT_KM / KM_PER_TILE; // cruise altitude var FLT_CLIMB_U = fltNum(FLT_CFG.climbKm, 100) / KM_PER_TILE; var FLT_MAX = Math.max(4, Math.min(240, Math.round(fltNum(FLT_CFG.count, 84)))); var FLT_SPD = fltNum(FLT_CFG.speedKmh, 850) / 3600 / KM_PER_TILE; var FLT_SVAR = Math.max(0, Math.min(0.5, fltNum(FLT_CFG.speedVar, 0.18))); var FLT_MINLEG = fltNum(FLT_CFG.minLegKm, 60) / KM_PER_TILE; var FLT_HOLD0 = fltNum(FLT_CFG.holdMinS, 50); var FLT_HOLD1 = Math.max(FLT_HOLD0, fltNum(FLT_CFG.holdMaxS, 150)); var FLT_W = [fltNum(FLT_CFG.wHub, 4), fltNum(FLT_CFG.wField, 2), fltNum(FLT_CFG.wStrip, 1)]; var CT_CHANCE = Math.max(0, Math.min(1, fltNum(FLT_CFG.contrailChance, 0.6))); var CT_START_FRAC = Math.max(0.02, fltNum(FLT_CFG.contrailStartKm, 6) / FLT_ALT_KM); var CT_T = Math.max(10, fltNum(FLT_CFG.contrailLifeS, 300)); var CT_HALF_W = Math.max(0.002, fltNum(FLT_CFG.contrailWidthM, 310) / 2000 / KM_PER_TILE); var CT_MAX = FLT_MAX; var CT_SEG = 8; // curve resolution per trail var fltAirs = null; // [{x,z,rank}] - destination pool var fltData = []; // live aircraft: {v,leg,s,gnd,gndEnd,seed,phase,big} var fltBuf = null, fltVbo = null, fltCount = 0; var fltState = []; // per frame render poses var fltProg = null, fltAttribs = null, fltUniforms = null; var fltFetching = false; var _fltLast = -1; function fltRand(seed) { var x = Math.sin(seed * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); } // weighted random destination at each take-off (CONFIG.flights w*) function fltPickDest(from, seed) { var i, tot = 0, wt = function (a) { var w = FLT_W[a.rank]; return (w > 0 ? w : 1); }; for (i = 0; i < fltAirs.length; i++) if (i !== from) tot += wt(fltAirs[i]); if (!tot) return -1; var r = fltRand(seed) * tot; for (i = 0; i < fltAirs.length; i++) { if (i === from) continue; r -= wt(fltAirs[i]); if (r <= 0) return i; } return -1; } // gently curved leg: quadratic Bezier A->B whose control point sits // 3-14 % of the distance off the straight line (long-radius turns), // resampled to ARC LENGTH so constant v means constant ground speed function fltMakeLeg(a, b, seed) { var A = fltAirs[a], B = fltAirs[b]; var dx = B.x - A.x, dz = B.z - A.z; var L = Math.hypot(dx, dz) || 0.01; var bend = (fltRand(seed * 3.7 + 1.1) - 0.5) * 0.28 * Math.min(L, 30); var px = -dz / L, pz = dx / L; var cx = (A.x + B.x) / 2 + px * bend * 2, cz = (A.z + B.z) / 2 + pz * bend * 2; var n = Math.max(12, Math.min(40, Math.round(L * 3))); var pts = [], lx = 0, lz = 0, dsum = 0; for (var i = 0; i <= n; i++) { var t = i / n, it = 1 - t; var x = it * it * A.x + 2 * it * t * cx + t * t * B.x; var z = it * it * A.z + 2 * it * t * cz + t * t * B.z; if (i > 0) { var sx = x - lx, sz = z - lz, sl = Math.hypot(sx, sz) || 1; pts[pts.length - 1].tx = sx / sl; pts[pts.length - 1].tz = sz / sl; dsum += sl; } pts.push({ x: x, z: z, d: dsum, tx: 0, tz: 0 }); lx = x; lz = z; } pts[pts.length - 1].tx = pts[pts.length - 2].tx; pts[pts.length - 1].tz = pts[pts.length - 2].tz; return { a: a, b: b, pts: pts, len: dsum }; } // world pos + unit tangent at arc-length dist along a leg function fltLegAt(leg, dist, out) { var p = leg.pts, i = 1; while (i < p.length - 1 && p[i].d < dist) i++; var p0 = p[i - 1], p1 = p[i]; var seg = (p1.d - p0.d) || 1; var f = Math.max(0, Math.min(1, (dist - p0.d) / seg)); out.x = p0.x + (p1.x - p0.x) * f; out.z = p0.z + (p1.z - p0.z) * f; out.tx = p0.tx; out.tz = p0.tz; return out; } function buildFlights(airports) { fltAirs = airports; fltData = []; fltState = []; if (!airports || airports.length < 2) { fltCount = 0; fltBuf = new Float32Array(0); return; } var N = FLT_MAX; for (var i = 0; i < airports.length * 6 && fltData.length < N; i++) { var from = Math.floor(fltRand(i * 2.71 + 0.3) * airports.length) % airports.length; var to = fltPickDest(from, i * 5.13 + 7.7); if (to < 0) continue; var leg = fltMakeLeg(from, to, i + 1); if (leg.len < FLT_MINLEG) continue; // skip taxi hops fltData.push({ v: FLT_SPD * (1 - FLT_SVAR + 2 * FLT_SVAR * fltRand(i * 3.1)), leg: leg, s: leg.len * fltRand(i * 7.7 + 2.2), gnd: false, gndEnd: 0, seed: i + 1, phase: fltRand(i * 13.3), big: airports[to].rank === 0 ? 1.2 : (airports[to].rank === 1 ? 1.0 : 0.75) }); } fltCount = fltData.length; fltBuf = new Float32Array(fltCount * 6); // pos3 + bri + phase + size } // advance every aircraft with the frame clock; the destination is rolled // FRESH at each take-off, so flows drift organically over the day function stepFlights(now) { var dt = _fltLast < 0 ? 0.016 : Math.min(0.25, now - _fltLast); _fltLast = now; for (var i = 0; i < fltCount; i++) { var f = fltData[i]; if (f.gnd) { if (now >= f.gndEnd) { var dest = fltPickDest(f.leg.b, now * 0.017 + f.seed * 91.7); var nl = dest >= 0 ? fltMakeLeg(f.leg.b, dest, f.seed * 17.3 + now * 0.37) : null; if (nl && nl.len >= FLT_MINLEG) { f.leg = nl; f.s = 0; f.gnd = false; f.big = fltAirs[dest].rank === 0 ? 1.2 : (fltAirs[dest].rank === 1 ? 1.0 : 0.75); } else { f.gndEnd = now + 30; // pick collapsed: brief wait, retry } } continue; } f.s += f.v * dt; if (f.s >= f.leg.len) { f.s = f.leg.len; f.gnd = true; f.gndEnd = now + FLT_HOLD0 + (FLT_HOLD1 - FLT_HOLD0) * fltRand(f.seed * 5.3 + now * 0.11); } } } // current pose: world x/z, tangent, eased altitude fraction (0..1) var _fPos = { x: 0, z: 0, tx: 1, tz: 0 }; function fltPose(f, out) { var leg = f.leg, cl = Math.min(leg.len * 0.4, FLT_CLIMB_U), a; if (f.gnd) a = 0; else if (f.s < cl) a = f.s / cl; else if (f.s > leg.len - cl) a = (leg.len - f.s) / cl; else a = 1; a = a * a * (3 - 2 * a); fltLegAt(leg, f.s, _fPos); out.x = _fPos.x; out.z = _fPos.z; out.tx = _fPos.tx; out.tz = _fPos.tz; out.alt = a; out.s = f.s; out.gnd = f.gnd; out.leg = leg; return out; } function fetchAirports() { if (!FLT_ON || fltFetching) return; fltFetching = true; var q = '[out:json][timeout:30];(node["aeroway"="aerodrome"](46.8,9.4,50.1,17.3);' + 'way["aeroway"="aerodrome"](46.8,9.4,50.1,17.3););out center tags;', id = 'geo/airports_v1'; openTileDB().then(function (db) { return tileDbGet(db, id).then(function (rec) { if (rec && rec.json && isValidOverpass(rec.json)) return rec.json; return opTry(q, 0).then(function (txt) { if (txt && db) tileDbPut(db, { id: id, json: txt, savedAt: Date.now() }); return txt; }); }); }).then(function (txt) { fltFetching = false; if (!txt) { console.warn('[clouds3d] aerodrome query failed'); return; } var data; try { data = JSON.parse(txt); } catch (e) { return; } var seenXY = [], airports = []; (data.elements || []).forEach(function (el) { var lat = el.lat != null ? el.lat : (el.center && el.center.lat); var lon = el.lon != null ? el.lon : (el.center && el.center.lon); if (lat == null || lon == null) return; var t = el.tags || {}; var rank = t.iata ? 0 : (t.icao ? 1 : (t.name ? 2 : 3)); var x = carLonToX(lon), z = carLatToZ(lat); // collapse runways of one field: aerodromes ~ >4 km apart merge for (var s2 = 0; s2 < seenXY.length; s2++) { if (Math.hypot(seenXY[s2].x - x, seenXY[s2].z - z) < 0.3) { if (rank < seenXY[s2].rank) { seenXY[s2].rank = rank; seenXY[s2].x = (seenXY[s2].x + x) / 2; seenXY[s2].z = (seenXY[s2].z + z) / 2; } return; } } if (rank > 2) return; // nameless strips: skip var a = { x: x, z: z, rank: rank }; seenXY.push(a); airports.push(a); }); if (!airports.length) return; buildFlights(airports); console.log('[clouds3d] flights: ' + airports.length + ' airfields -> ' + fltData.length + ' aircraft (destinations rolled per take-off)'); }); } var FLT_VERT_SRC = [ 'attribute vec3 aPos;', 'attribute vec4 aBlink;', // x: brightness, y: blink phase, z: size, w: unused 'uniform mat4 uProj;', 'uniform mat4 uView;', 'uniform vec3 uCamPos;', 'uniform float uScale;', 'varying float vBri;', 'varying float vPhase;', 'varying float vDist;', 'void main() {', 'vDist = length(uCamPos - aPos);', 'vBri = aBlink.x;', 'vPhase = aBlink.y;', 'gl_PointSize = clamp(aBlink.z * uScale * 2.6, 3.0, 6.0);', 'gl_Position = uProj * uView * vec4(aPos, 1.0);', '}' ].join('\n'); var FLT_FRAG_SRC = [ 'precision highp float;', 'uniform float uTime;', 'uniform float uNight;', 'varying float vBri;', 'varying float vPhase;', 'varying float vDist;', 'void main() {', 'float df = 1.0 - smoothstep(10.0, 16.0, vDist);', // ~130-200 km 'if (df <= 0.0) discard;', 'vec2 pc = gl_PointCoord - 0.5;', 'float d = length(pc) * 2.0;', // small bright disk with a faint halo -> reads as a light even at 3px 'float core = max(exp(-d * d * 9.0), (1.0 - smoothstep(0.3, 0.55, d)) * 0.5);', 'if (core <= 0.01) discard;', // white strobe: fast double-flash every ~1.1 s 'float t = fract(uTime * 0.9 + vPhase);', 'float p1 = max(0.0, 1.0 - t * 8.0);', 'float p2 = max(0.0, 1.0 - abs(t - 0.11) * 8.0);', 'float flash = p1 * p1 + 0.7 * p2 * p2;', // red anti-collision beacon: slower single blink 'float red = 0.35 * pow(max(0.0, sin(uTime * 2.4 + vPhase * 6.283)), 4.0);', 'vec3 col = vec3(1.0) * flash * 1.7 + vec3(1.0, 0.12, 0.06) * red;', 'float a = uNight * vBri * df; // day: never drawn', 'gl_FragColor = vec4(col * core * a, 1.0);', '}' ].join('\n'); // ---- Daytime airliner model pass --------------------------------------- // Low-poly jet (vendor/plane3d.js, CC0-derived) for the SAME traffic: by // day the aircraft are sun-shaded silhouettes in a fixed stylized scale - // nose-to-tail exactly half the contrail width, so jet and trail always // read as one object at any distance. var PLANE_VERT_SRC = [ 'attribute vec3 aPos;', // unit-extent mesh, forward = +Z, up = +Y 'attribute vec3 aNrm;', 'attribute float aCol;', // 0 body, 1 glass 'uniform mat4 uProj;', 'uniform mat4 uView;', 'uniform vec3 uOrigin;', // world anchor (cruise position) 'uniform vec4 uOrit;', // cos(yaw), sin(yaw), sin(pitch), fade 'uniform float uS;', // model radius in world units 'varying float vCol;', 'varying vec3 vN;', 'void main() {', 'float cy = uOrit.x, sy = uOrit.y, sp = uOrit.z, cp2 = sqrt(1.0 - sp * sp);', 'vec3 p = aPos * uS;', 'p = vec3(p.x, p.y * cp2 + p.z * sp, -p.y * sp + p.z * cp2);', // pitch 'p = vec3(p.x * cy + p.z * sy, p.y, -p.x * sy + p.z * cy);', // yaw 'vec3 n = aNrm;', 'n = vec3(n.x, n.y * cp2 + n.z * sp, -n.y * sp + n.z * cp2);', 'n = vec3(n.x * cy + n.z * sy, n.y, -n.x * sy + n.z * cy);', 'vN = n; vCol = aCol;', 'gl_Position = uProj * uView * vec4(uOrigin + p, 1.0);', '}' ].join('\n'); var PLANE_FRAG_SRC = [ 'precision highp float;', 'uniform vec3 uSun;', // world sun direction (x,y,z like __currentSunDir)', 'varying float vCol;', 'varying vec3 vN;', 'void main() {', 'vec3 alb = vCol < 0.5 ? vec3(0.93, 0.93, 0.95) : vec3(0.14, 0.16, 0.21);', 'float l = 0.22 + 0.78 * max(0.0, dot(normalize(vN), uSun));', 'gl_FragColor = vec4(alb * l, 1.0);', '}' ].join('\n'); var planeV = null, planeVbo = null, planeProg = null, planeU = null, planeA = null, planeN = 0; // ---- Condensation trails ------------------------------------------------- // Aircraft above the configured formation altitude (CONFIG.flights // contrailStartKm) may leave a contrail - contrailChance of them do (the // ice-humidity window is not everywhere), and the stripe follows the // curved leg: the leg polyline is walked back contrailLifeS worth of // ground distance in 8 curve segments and swept into a ribbon // (contrailWidthM wide). The fragment shader dissolves it into a cloud // stripe: density patches along the length, wavy eroded edges (per-trail // noise phase), alpha ramping up toward the head. By day the stripes are // sun-lit white; by night they stay visible as dimmed moonlit streaks // (reusing the car-lights night ramp), around 40 % strength. var ctBuf = null, ctVbo = null, ctVerts = 0; var ctProg = null, ctA = null, ctU = null; function _ctVert(x, y, z, u, v, s, p) { var i = ctVerts * 7; ctBuf[i] = x; ctBuf[i + 1] = y; ctBuf[i + 2] = z; ctBuf[i + 3] = u; ctBuf[i + 4] = v; ctBuf[i + 5] = s; ctBuf[i + 6] = p; ctVerts++; } var CT_VERT_SRC = [ 'attribute vec3 aPos;', 'attribute float aU;', // 0 tail .. 1 head 'attribute float aV;', // -1 .. +1 across the ribbon 'attribute float aS;', // per-trail strength (gate x day x fade)', 'attribute float aP;', // per-trail noise phase 'uniform mat4 uProj;', 'uniform mat4 uView;', 'varying float vU;', 'varying float vV;', 'varying float vS;', 'varying float vP;', 'void main() {', 'vU = aU; vV = aV; vS = aS; vP = aP;', 'gl_Position = uProj * uView * vec4(aPos, 1.0);', '}' ].join('\n'); var CT_FRAG_SRC = [ 'precision mediump float;', 'varying float vU;', 'varying float vV;', 'varying float vS;', 'varying float vP;', 'void main() {', // density patches drifting along the stripe 'float along = vU * 26.0 + vP * 31.0;', 'float dens = 0.62 + 0.38 * sin(along) * sin(along * 2.7 + 1.3);', 'if (dens < 0.0) dens = 0.0;', // eroded, uneven edges: the stripe narrows and widens along u 'float wob = 0.55 + 0.45 * (0.5 + 0.5 * sin(along * 3.1 + 7.0));', 'float side = abs(vV);', 'float edge = 1.0 - smoothstep(wob * 0.22, wob, side);', // older tail also thins out across its width 'edge *= 0.45 + 0.55 * pow(vU, 0.6);', 'if (edge <= 0.0) discard;', 'float a = vS * pow(vU, 1.15) * 1.15 * dens * edge;', 'if (a <= 0.004) discard;', 'gl_FragColor = vec4(0.95, 0.96, 1.0, a);', '}' ].join('\n'); function decodePlaneVerts() { if (planeV || !window.__planeVerts) return; try { var bin = atob(window.__planeVerts); var buf = new ArrayBuffer(bin.length); var u8 = new Uint8Array(buf); for (var i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); var f = new Float32Array(buf); if (f.length % 7 === 0 && f.length > 21) { planeV = f; planeN = f.length / 7; } } catch (e) { /* malformed data: silently stay point-only */ } } // ---- 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) || 51.5074) * 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.96 + 0.04 * 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; // build.py-generated standalone HTML injects the catalogue into // window.__starsData so no fetch (and no vendor/ folder) is needed if (window.__starsData) { buildSkyTexture(window.__starsData); return; } 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(); fetchAirports(); // periodic re-runs (same rhythm as the weather poll): cached cells // short-circuit, so a cycle only ever costs the retries of cells // that previously failed; running cycles never overlap (flags) setInterval(fetchMotorways, 15 * 60 * 1000); setInterval(fetchSettlements, 15 * 60 * 1000); setInterval(fetchAirports, 15 * 60 * 1000); 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); } // ---- Aircraft pass -------------------------------------------------- // Shared position update (take-off/climb/cruise/descent/land/hold // profile), then: night = strobe point sprites, day = sun-shaded // low-poly silhouettes sized to half the contrail width, plus the // trails themselves. // Density is uniform over the whole day (flights follow neither the // car rush curve nor a night cut-off). if (FLT_ON && fltCount && fltAirs) { stepFlights(now); var fl = { x: 0, z: 0, alt: 0, s: 0, tx: 1, tz: 0, gnd: false, leg: null }; for (var fi = 0; fi < fltCount; fi++) { var f = fltData[fi]; fltPose(f, fl); var gy = carYAt(fl.x, fl.z); var yF = gy + (FLT_Y - gy) * fl.alt; var k = fi * 6; fltBuf[k] = fl.x; fltBuf[k + 1] = yF; fltBuf[k + 2] = fl.z; fltBuf[k + 3] = f.big; fltBuf[k + 4] = f.phase; fltBuf[k + 5] = 1.0; var st = fltState[fi] || (fltState[fi] = {}); st.y = yF; st.alt = fl.alt; st.s = fl.s; st.gnd = fl.gnd; st.leg = fl.leg; st.tx = fl.tx; st.tz = fl.tz; } // -- night: blinking lights -- if (carNight > 0.06) { if (!fltVbo) fltVbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, fltVbo); gl.bufferData(gl.ARRAY_BUFFER, fltBuf, gl.STREAM_DRAW); if (!fltProg) { var fp = makeProgram(gl, FLT_VERT_SRC, FLT_FRAG_SRC); if (!fp) { FLT_ON = false; } else { fltProg = fp; fltAttribs = { pos: gl.getAttribLocation(fltProg, 'aPos'), blink: gl.getAttribLocation(fltProg, 'aBlink') }; fltUniforms = { proj: gl.getUniformLocation(fltProg, 'uProj'), view: gl.getUniformLocation(fltProg, 'uView'), camPos: gl.getUniformLocation(fltProg, 'uCamPos'), scale: gl.getUniformLocation(fltProg, 'uScale'), time: gl.getUniformLocation(fltProg, 'uTime'), night: gl.getUniformLocation(fltProg, 'uNight') }; } } if (fltProg) { gl.useProgram(fltProg); gl.uniformMatrix4fv(fltUniforms.proj, false, proj); gl.uniformMatrix4fv(fltUniforms.view, false, view); gl.uniform3f(fltUniforms.camPos, camX, camY, camZ); gl.uniform1f(fltUniforms.scale, gl.drawingBufferHeight / 600); gl.uniform1f(fltUniforms.time, now); gl.uniform1f(fltUniforms.night, Math.min(1, carNight * 1.2)); gl.enable(gl.DEPTH_TEST); // mountains can pass in front gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.enableVertexAttribArray(fltAttribs.pos); gl.vertexAttribPointer(fltAttribs.pos, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(fltAttribs.blink); gl.vertexAttribPointer(fltAttribs.blink, 3, gl.FLOAT, false, 24, 12); gl.drawArrays(gl.POINTS, 0, fltCount); gl.disable(gl.BLEND); gl.depthMask(true); } } // -- day: silhouettes -- decodePlaneVerts(); var sunA = window.__currentSunAlt || 0; var dayf = 1 - Math.min(1, Math.max(0, -sunA / 0.30)); if (planeN && dayf > 0.02) { if (!planeVbo) { planeVbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, planeVbo); gl.bufferData(gl.ARRAY_BUFFER, planeV, gl.STATIC_DRAW); } if (!planeProg) { var pp = makeProgram(gl, PLANE_VERT_SRC, PLANE_FRAG_SRC); if (pp) { planeProg = pp; planeA = { pos: gl.getAttribLocation(pp, 'aPos'), nrm: gl.getAttribLocation(pp, 'aNrm'), col: gl.getAttribLocation(pp, 'aCol') }; planeU = { proj: gl.getUniformLocation(pp, 'uProj'), view: gl.getUniformLocation(pp, 'uView'), origin: gl.getUniformLocation(pp, 'uOrigin'), orit: gl.getUniformLocation(pp, 'uOrit'), s: gl.getUniformLocation(pp, 'uS'), sun: gl.getUniformLocation(pp, 'uSun') }; } } if (planeProg) { var sunv = window.__currentSunDir || [0, 1, 0]; gl.useProgram(planeProg); gl.uniformMatrix4fv(planeU.proj, false, proj); gl.uniformMatrix4fv(planeU.view, false, view); gl.uniform3f(planeU.sun, sunv[0], sunv[1], sunv[2]); gl.bindBuffer(gl.ARRAY_BUFFER, planeVbo); gl.enableVertexAttribArray(planeA.pos); gl.vertexAttribPointer(planeA.pos, 3, gl.FLOAT, false, 28, 0); gl.enableVertexAttribArray(planeA.nrm); gl.vertexAttribPointer(planeA.nrm, 3, gl.FLOAT, false, 28, 12); gl.enableVertexAttribArray(planeA.col); gl.vertexAttribPointer(planeA.col, 1, gl.FLOAT, false, 28, 24); gl.enable(gl.DEPTH_TEST); gl.depthMask(true); // opaque little solids gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); var S0 = CT_HALF_W * 0.5; // model is unit-extent: tip-to-tail // = CT_HALF_W = half the contrail width for (var dj = 0; dj < fltCount; dj++) { var briJ = fltBuf[dj * 6 + 3]; if (briJ <= 0) continue; var px = fltBuf[dj * 6], py = fltBuf[dj * 6 + 1], pz = fltBuf[dj * 6 + 2]; var ddx = px - camX, ddy = py - camY, ddz = pz - camZ; var dist = Math.sqrt(ddx * ddx + ddy * ddy + ddz * ddz); var dfa = 1 - (dist - 10) / 6; if (dfa > 1) dfa = 1; else if (dfa <= 0.02) continue; var stj = fltState[dj]; var yaw = Math.atan2(stj.tx, stj.tz); var cln = Math.min(stj.leg.len * 0.4, FLT_CLIMB_U); var pitch = 0; if (stj.alt > 0.02) { if (stj.s < cln) pitch = 0.16; else if (stj.s > stj.leg.len - cln) pitch = -0.16; } // fixed physical size: nose-to-tail = half the trail width var scl = S0; gl.uniform3f(planeU.origin, px, py, pz); gl.uniform4f(planeU.orit, Math.cos(yaw), Math.sin(yaw), pitch, 0); gl.uniform1f(planeU.s, scl); gl.drawArrays(gl.TRIANGLES, 0, planeN); } gl.depthMask(true); } } // -- contrails -- // Only CONFIG.flights.contrailChance of the aircraft leave a // persistent stripe (the ice-humidity window is not everywhere), // and the stripe follows the curved leg: walked backwards from // the aircraft in fixed steps. Visible by day (sun-lit) AND by // night as dimmed moonlit streaks. var tvis = Math.max(dayf, carNight * 0.42); if (tvis > 0.02) { if (!ctBuf) ctBuf = new Float32Array(CT_MAX * CT_SEG * 6 * 7); ctVerts = 0; var _cs = { x: 0, z: 0, tx: 1, tz: 0 }; var _sk0 = { x: 0, z: 0, px: 0, pz: 0, u: 0 }; var _sk1 = { x: 0, z: 0, px: 0, pz: 0, u: 0 }; for (var cj = 0; cj < fltCount; cj++) { if (ctVerts + CT_SEG * 6 > CT_MAX * CT_SEG * 6) break; var stc = fltState[cj]; if (!stc || stc.gnd) continue; if (fltRand(cj * 3.7 + 0.77) > CT_CHANCE) continue; // formation gate: above the configured trail altitude, smooth var gt = (stc.alt - (CT_START_FRAC - 0.06)) / 0.12; gt = gt < 0 ? 0 : (gt > 1 ? 1 : gt); gt = gt * gt * (3 - 2 * gt); if (gt < 0.05) continue; var tl = Math.min(CT_T * fltData[cj].v, stc.s); if (tl < 0.05) continue; fltLegAt(stc.leg, stc.s - tl * 0.5, _cs); var cdx = _cs.x - camX, cdy = stc.y - camY, cdz = _cs.z - camZ; var distc = Math.sqrt(cdx * cdx + cdy * cdy + cdz * cdz); // gentle distance thinning only var dfc = 1 - (distc - 16) / 10; if (dfc > 1) dfc = 1; else if (dfc <= 0.02) continue; var strg = gt * dayf * dfc; // widen with distance: a fixed 310 m ribbon is sub-pixel at // 150 km and blends away; grow it to hold ~2-4 screen px var hw = CT_HALF_W + distc * 0.0025; var ph = fltData[cj].phase; var hy = stc.y; var prev = null, cur = _sk0; for (var sk = 0; sk <= CT_SEG; sk++) { var dd = stc.s - tl + tl * (sk / CT_SEG); if (dd < 0) dd = 0; fltLegAt(stc.leg, dd, _cs); cur.x = _cs.x; cur.z = _cs.z; cur.px = _cs.tz * hw; cur.pz = -_cs.tx * hw; cur.u = sk / CT_SEG; if (prev) { _ctVert(prev.x + prev.px, hy, prev.z + prev.pz, prev.u, 1, strg, ph); _ctVert(prev.x - prev.px, hy, prev.z - prev.pz, prev.u, -1, strg, ph); _ctVert(cur.x - cur.px, hy, cur.z - cur.pz, cur.u, -1, strg, ph); _ctVert(prev.x + prev.px, hy, prev.z + prev.pz, prev.u, 1, strg, ph); _ctVert(cur.x - cur.px, hy, cur.z - cur.pz, cur.u, -1, strg, ph); _ctVert(cur.x + cur.px, hy, cur.z + cur.pz, cur.u, 1, strg, ph); } prev = cur; cur = (cur === _sk0) ? _sk1 : _sk0; } } if (ctVerts > 0) { if (!ctVbo) ctVbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, ctVbo); gl.bufferData(gl.ARRAY_BUFFER, ctBuf.subarray(0, ctVerts * 7), gl.STREAM_DRAW); if (!ctProg) { var cpm = makeProgram(gl, CT_VERT_SRC, CT_FRAG_SRC); if (cpm) { ctProg = cpm; ctA = { pos: gl.getAttribLocation(cpm, 'aPos'), u: gl.getAttribLocation(cpm, 'aU'), v: gl.getAttribLocation(cpm, 'aV'), s: gl.getAttribLocation(cpm, 'aS'), p: gl.getAttribLocation(cpm, 'aP') }; ctU = { proj: gl.getUniformLocation(cpm, 'uProj'), view: gl.getUniformLocation(cpm, 'uView') }; } } if (ctProg) { gl.useProgram(ctProg); gl.uniformMatrix4fv(ctU.proj, false, proj); gl.uniformMatrix4fv(ctU.view, false, view); gl.enableVertexAttribArray(ctA.pos); gl.vertexAttribPointer(ctA.pos, 3, gl.FLOAT, false, 28, 0); gl.enableVertexAttribArray(ctA.u); gl.vertexAttribPointer(ctA.u, 1, gl.FLOAT, false, 28, 12); gl.enableVertexAttribArray(ctA.v); gl.vertexAttribPointer(ctA.v, 1, gl.FLOAT, false, 28, 16); gl.enableVertexAttribArray(ctA.s); gl.vertexAttribPointer(ctA.s, 1, gl.FLOAT, false, 28, 20); gl.enableVertexAttribArray(ctA.p); gl.vertexAttribPointer(ctA.p, 1, gl.FLOAT, false, 28, 24); gl.depthMask(false); gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.drawArrays(gl.TRIANGLES, 0, ctVerts); 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); }; })();