6959 lines
335 KiB
JavaScript
6959 lines
335 KiB
JavaScript
/* 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() {
|
|
|
|
// Progress and build diagnostics are only interesting while the debug panel
|
|
// is on; a wall display must not fill the console with them.
|
|
var DEBUG_LOG = !!(window.CONFIG && window.CONFIG.debug);
|
|
function dbgLog() {
|
|
if (DEBUG_LOG) console.log.apply(console, arguments);
|
|
}
|
|
|
|
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 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 float uTerrainExag;', // terrain vertical 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, 2 = atmosphere shell
|
|
'uniform float uAtmoModelOn;',
|
|
'uniform float uAtmoHeightUnits;',
|
|
'uniform float uAtmoScaleHeightUnits;',
|
|
'uniform float uAtmoDensity;',
|
|
'uniform float uAtmoGrayTau;',
|
|
'uniform float uAtmoGain;',
|
|
'uniform float uAtmoMaxAlpha;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform vec3 uCamPos;',
|
|
'uniform vec3 uAtmoBlueColor;',
|
|
'uniform vec3 uAtmoGrayColor;',
|
|
'uniform vec3 uSunColor;', // direct beam after atmospheric extinction
|
|
'uniform float uSunInt;',
|
|
'uniform vec3 uMoonColor;',
|
|
'uniform float uMoonInt;',
|
|
'uniform float uSkyAmb;',
|
|
'uniform float uStarAmb;', // diffuse starlight + airglow
|
|
'uniform float uPhysical;',
|
|
'uniform vec3 uAirColor;', // Rayleigh airlight at the horizon
|
|
'uniform vec3 uRayleighBeta;', // vertical Rayleigh optical depth RGB
|
|
'uniform float uSunAirMass;', // relative air mass of the sun beam
|
|
'uniform float uRayleighGain;',
|
|
'uniform float uPhysicalSkyOn;',
|
|
'uniform float uAirHazeOn;',
|
|
'uniform float uAirHazeDensity;',
|
|
'uniform float uAirHazeScaleHeightUnits;',
|
|
'uniform float uAirHazeGain;',
|
|
'uniform float uAirHazeMaxAlpha;',
|
|
'uniform vec3 uAirHazeColor;',
|
|
'uniform sampler2D uTerrainDistTex;',
|
|
'uniform float uCloudsOn;',
|
|
'uniform float uTerrainDistOk;',
|
|
'uniform float uFrontHazeOn;',
|
|
'uniform float uFrontHazeDensity;',
|
|
'uniform float uFrontHazeScaleHeightUnits;',
|
|
'uniform float uFrontHazeGain;',
|
|
'uniform float uFrontHazeMaxAlpha;',
|
|
'uniform vec3 uFrontHazeColor;',
|
|
'uniform float uFarPlane;',
|
|
'uniform vec3 uCloudAlbedo;', // theme-tinted but brightness-normalised
|
|
'uniform float uAirDensity;', // slant optical depth per world unit
|
|
'uniform float uCloudHaze;', // clouds sit above most of the haze: much weaker
|
|
'uniform sampler2D uMoonTex;', // equirectangular near-side lunar map
|
|
'uniform float uMoonTexOn;', // 0 until the map is loaded (flat disc)
|
|
'uniform float uMoonR;', // disc angular radius in radians
|
|
'uniform float uMoonFrac;', // illuminated fraction (drives the halo)
|
|
'uniform float uMoonEarth;', // earthshine on the dark side
|
|
'uniform float uMoonLimb;', // limb darkening strength
|
|
'uniform float uMoonBright;', // per-pixel luminance of the disc
|
|
'uniform float uMoonHalo;',
|
|
'uniform float uMoonFlipU;',
|
|
'uniform float uMoonFlipV;',
|
|
'uniform float uSunR;', // sun disc angular radius (radians)
|
|
// 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);',
|
|
'}',
|
|
// Interleaved Gradient Noise: deterministic per pixel, low discrepancy.
|
|
// hash21(gl_FragCoord.xy + uTime) changed every frame, which made the
|
|
// cloud march start on a different phase each frame - visible as
|
|
// crawling edges and flickering dark specks inside dense cloud.
|
|
'float ign(vec2 p) {',
|
|
'return fract(0.01593035 * fract(dot(p, vec2(0.06711056, 0.08056550))));',
|
|
'}',
|
|
'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);',
|
|
'}',
|
|
// Four octaves (the name stayed): the extra high-frequency octave breaks
|
|
// up the iso-surfaces that made the deck read as stacked slabs. Sum is
|
|
// renormalised to the old 0.875 range so baseThresh still means the same
|
|
// cloud coverage.
|
|
'float fbm3(vec3 p) {',
|
|
'float v = 0.0;',
|
|
'float a = 0.5;',
|
|
'for (int i = 0; i < 4; i++) {',
|
|
'v += a * noise3(p);',
|
|
'p = p * 2.03 + vec3(13.7, 7.1, 19.3);',
|
|
'a *= 0.5;',
|
|
'}',
|
|
'return v * 0.93333;',
|
|
'}',
|
|
// 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.
|
|
// 'soft' is the procedural equivalent of a mipmap level: 0 near the
|
|
// camera, 1 for cloud far away. The detail octave is what aliases into
|
|
// shimmering speckle once a cloud only covers a few pixels, so it is
|
|
// dropped there and the threshold is widened - the large shapes stay,
|
|
// the fine structure that cannot be resolved disappears.
|
|
'float earthDrop(vec2 xz) {',
|
|
'if (uCurvatureOn < 0.5) return 0.0;',
|
|
'vec2 d = xz - uCamPos.xz;',
|
|
'return dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'float cloudDensity(vec3 p, float baseThresh, float soft) {',
|
|
'float py = p.y + earthDrop(p.xz);',
|
|
'vec3 cp = vec3(p.x, py, p.z);',
|
|
'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.05));',
|
|
// The detail octave weighs at most 0.20 and fades to zero with
|
|
// distance; past soft = 0.95 it contributes under 1 %, so the whole
|
|
// 4-octave lookup is skipped for the (numerous) far samples.
|
|
'if (soft < 0.75) {',
|
|
'float d = fbm3(cp * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));',
|
|
'n = n + d * mix(0.22, 0.0, soft / 0.75);',
|
|
'}',
|
|
// Cloud layer between uCloudBot and uCloudTop, densest in the
|
|
// middle (bell curve). The layer follows the Earth curvature.
|
|
'float h = clamp((py - 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.34 + soft * 0.28, 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;',
|
|
'}',
|
|
'vec2 curvedLayerRoots(float layerY, vec3 ro, vec3 rd) {',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float a = (uCurvatureOn > 0.5) ? dot(rd.xz, rd.xz) / (2.0 * R) : 0.0;',
|
|
'float b = rd.y;',
|
|
'float c = ro.y - layerY;',
|
|
'if (abs(a) < 1e-6) {',
|
|
'if (abs(b) < 1e-6) return vec2(-1.0, -1.0);',
|
|
'float t = -c / b;',
|
|
'return vec2(t, t);',
|
|
'}',
|
|
'float disc = b * b - 4.0 * a * c;',
|
|
'if (disc < 0.0) return vec2(-1.0, -1.0);',
|
|
'float sq = sqrt(disc);',
|
|
'return vec2((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));',
|
|
'}',
|
|
'float minPositiveRoot(vec2 r) {',
|
|
'float m = 1e9;',
|
|
'if (r.x > 0.0) m = min(m, r.x);',
|
|
'if (r.y > 0.0) m = min(m, r.y);',
|
|
'return m;',
|
|
'}',
|
|
'float maxPositiveRoot(vec2 r) {',
|
|
'float m = -1e9;',
|
|
'if (r.x > 0.0) m = max(m, r.x);',
|
|
'if (r.y > 0.0) m = max(m, r.y);',
|
|
'return m;',
|
|
'}',
|
|
'vec3 sphericalRayleighTau(vec3 rd) {',
|
|
'if (uCurvatureOn < 0.5) {',
|
|
'float viewY = max(rd.y, 0.0);',
|
|
'float mView = (viewY > 0.02) ? min(38.0, 1.0 / viewY) : 38.0;',
|
|
'return uRayleighBeta * mView;',
|
|
'}',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float H = max(1.0, uAtmoHeightUnits);',
|
|
'float Hs = max(0.05, uAtmoScaleHeightUnits);',
|
|
'vec3 C = vec3(uCamPos.x, -R, uCamPos.z);',
|
|
'vec3 oc = uCamPos - C;',
|
|
'float b = dot(oc, rd);',
|
|
'float rOut = R + H;',
|
|
'float cOut = dot(oc, oc) - rOut * rOut;',
|
|
'float tStart = 0.0;',
|
|
'float tEnd = 100.0;',
|
|
'float discOut = b * b - cOut;',
|
|
'if (discOut > 0.0) {',
|
|
'float sq = sqrt(discOut);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (cOut > 0.0) tStart = max(0.0, t1);',
|
|
'tEnd = max(0.0, t2);',
|
|
'}',
|
|
'float cIn = dot(oc, oc) - R * R;',
|
|
'float discIn = b * b - cIn;',
|
|
'if (discIn > 0.0) {',
|
|
'float sq = sqrt(discIn);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (t1 > 0.0) tEnd = min(tEnd, t1);',
|
|
'else if (t2 > 0.0) tEnd = min(tEnd, t2);',
|
|
'}',
|
|
'if (tEnd <= tStart) return vec3(0.0);',
|
|
'float step = (tEnd - tStart) / 16.0;',
|
|
'float t = tStart + step * 0.5;',
|
|
'float column = 0.0;',
|
|
'for (int i = 0; i < 16; i++) {',
|
|
'vec3 p = uCamPos + rd * t;',
|
|
'float alt = max(0.0, length(p - C) - R);',
|
|
'column += exp(-alt / Hs) * step / Hs;',
|
|
't += step;',
|
|
'}',
|
|
'return uRayleighBeta * column;',
|
|
'}',
|
|
'float airHazeColumn(vec3 rd) {',
|
|
'if (uCurvatureOn < 0.5) {',
|
|
'float viewY = max(rd.y, 0.0);',
|
|
'float mView = (viewY > 0.02) ? min(38.0, 1.0 / viewY) : 38.0;',
|
|
'return mView;',
|
|
'}',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float H = max(1.0, uAtmoHeightUnits);',
|
|
'float Hs = max(0.05, uAirHazeScaleHeightUnits);',
|
|
'vec3 C = vec3(uCamPos.x, -R, uCamPos.z);',
|
|
'vec3 oc = uCamPos - C;',
|
|
'float b = dot(oc, rd);',
|
|
'float rOut = R + H;',
|
|
'float cOut = dot(oc, oc) - rOut * rOut;',
|
|
'float tStart = 0.0;',
|
|
'float tEnd = 100.0;',
|
|
'float discOut = b * b - cOut;',
|
|
'if (discOut > 0.0) {',
|
|
'float sq = sqrt(discOut);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (cOut > 0.0) tStart = max(0.0, t1);',
|
|
'tEnd = max(0.0, t2);',
|
|
'}',
|
|
'float cIn = dot(oc, oc) - R * R;',
|
|
'float discIn = b * b - cIn;',
|
|
'if (discIn > 0.0) {',
|
|
'float sq = sqrt(discIn);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (t1 > 0.0) tEnd = min(tEnd, t1);',
|
|
'else if (t2 > 0.0) tEnd = min(tEnd, t2);',
|
|
'}',
|
|
'if (tEnd <= tStart) return 0.0;',
|
|
'float step = (tEnd - tStart) / 16.0;',
|
|
'float t = tStart + step * 0.5;',
|
|
'float column = 0.0;',
|
|
'for (int i = 0; i < 16; i++) {',
|
|
'vec3 p = uCamPos + rd * t;',
|
|
'float alt = max(0.0, length(p - C) - R);',
|
|
'column += exp(-alt / Hs) * step / Hs;',
|
|
't += step;',
|
|
'}',
|
|
'return column;',
|
|
'}',
|
|
'vec2 cloudShellRoots(float alt, vec3 oc, vec3 rd) {',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float r = R + max(0.0, alt);',
|
|
'float b = 2.0 * dot(oc, rd);',
|
|
'float c = dot(oc, oc) - r * r;',
|
|
'float disc = b * b - 4.0 * c;',
|
|
'if (disc < 0.0) return vec2(-1.0, -1.0);',
|
|
'float sq = sqrt(disc);',
|
|
'return vec2((-b - sq) * 0.5, (-b + sq) * 0.5);',
|
|
'}',
|
|
'vec2 cloudLayerRoots(float layerY, vec3 rd) {',
|
|
'float camY = uCamPos.y;',
|
|
'if (uCurvatureOn < 0.5) {',
|
|
'if (abs(rd.y) < 1e-5) return vec2(-1.0, -1.0);',
|
|
'float t = (layerY - camY) / rd.y;',
|
|
'return vec2(t, t);',
|
|
'}',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float a = (rd.x * rd.x + rd.z * rd.z) / (2.0 * R);',
|
|
'float b = rd.y;',
|
|
'float c = camY - layerY;',
|
|
'if (abs(a) < 1e-8) {',
|
|
'if (abs(b) < 1e-5) return vec2(-1.0, -1.0);',
|
|
'float t = -c / b;',
|
|
'return vec2(t, t);',
|
|
'}',
|
|
'float disc = b * b - 4.0 * a * c;',
|
|
'if (disc < 0.0) return vec2(-1.0, -1.0);',
|
|
'float sq = sqrt(disc);',
|
|
'return vec2((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));',
|
|
'}',
|
|
'float cloudDensitySpherical(vec3 p, float alt, float baseThresh, float soft) {',
|
|
'vec3 q = vec3(p.x, alt, p.z);',
|
|
'float n = fbm3(q * 0.22 + vec3(0.0, 0.0, uTime * 0.05));',
|
|
'if (soft < 0.75) {',
|
|
'float d = fbm3(q * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));',
|
|
'n = n + d * mix(0.22, 0.0, soft / 0.75);',
|
|
'}',
|
|
'float h = clamp((alt - uCloudBot) / max(0.01, uCloudTop - uCloudBot), 0.0, 1.0);',
|
|
'float profile = 4.0 * h * (1.0 - h);',
|
|
'profile = max(profile, 0.0);',
|
|
'return smoothstep(baseThresh, baseThresh + 0.34 + soft * 0.28, n) * profile;',
|
|
'}',
|
|
'float hazeColumnToDistance(vec3 rd, float dist) {',
|
|
'if (dist <= 0.0) return 0.0;',
|
|
'if (uCurvatureOn < 0.5) return dist;',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float Hs = max(0.05, uFrontHazeScaleHeightUnits);',
|
|
'vec3 C = vec3(uCamPos.x, -R, uCamPos.z);',
|
|
'float column = 0.0;',
|
|
'for (int i = 0; i < 8; i++) {',
|
|
'float t = dist * (float(i) + 0.5) / 8.0;',
|
|
'vec3 p = uCamPos + rd * t;',
|
|
'float alt = max(0.0, length(p - C) - R);',
|
|
'column += exp(-alt / Hs) * dist / 8.0 / Hs;',
|
|
'}',
|
|
'return column;',
|
|
'}',
|
|
// 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 == 2) {',
|
|
'if (uAtmoModelOn < 0.5) { gl_FragColor = vec4(0.0); return; }',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float H = max(0.1, uAtmoHeightUnits);',
|
|
'float Hs = max(0.01, uAtmoScaleHeightUnits);',
|
|
'vec3 C = vec3(ro.x, -R, ro.z);',
|
|
'vec3 oc = ro - C;',
|
|
'float b = dot(oc, rd);',
|
|
'float rOut = R + H;',
|
|
'float cOut = dot(oc, oc) - rOut * rOut;',
|
|
'float discOut = b * b - cOut;',
|
|
'float tStart = 0.0;',
|
|
'float tEnd = 100.0;',
|
|
'if (discOut > 0.0) {',
|
|
'float sq = sqrt(discOut);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (dot(oc, oc) > rOut * rOut) tStart = max(0.0, t1);',
|
|
'tEnd = max(0.0, t2);',
|
|
'}',
|
|
'float cIn = dot(oc, oc) - R * R;',
|
|
'float discIn = b * b - cIn;',
|
|
'float tInner = 100000.0;',
|
|
'float hitsInner = 0.0;',
|
|
'if (discIn > 0.0) {',
|
|
'float sq = sqrt(discIn);',
|
|
'float t1 = -b - sq;',
|
|
'float t2 = -b + sq;',
|
|
'if (t1 > 0.0) { tInner = t1; hitsInner = 1.0; }',
|
|
'else if (t2 > 0.0) { tInner = t2; hitsInner = 1.0; }',
|
|
'}',
|
|
'if (tInner < 100000.0) tEnd = min(tEnd, tInner);',
|
|
'if (tEnd <= tStart) { gl_FragColor = vec4(0.0); return; }',
|
|
'float step = (tEnd - tStart) / 32.0;',
|
|
'float t = tStart + step * 0.5;',
|
|
'float tau = 0.0;',
|
|
'float hitsTerrain = hitsInner;',
|
|
'vec3 scatter = vec3(0.0);',
|
|
'float cosTA = dot(rd, uSunDir);',
|
|
'float cosTM = dot(rd, uMoonDir);',
|
|
'float sunPhase = 0.25 + 0.75 * pow(max(0.0, cosTA), 2.0);',
|
|
'float moonPhase = 0.25 + 0.75 * pow(max(0.0, cosTM), 2.0);',
|
|
'for (int i = 0; i < 32; i++) {',
|
|
'vec3 p = ro + rd * t;',
|
|
'float alt = max(0.0, length(p - C) - R);',
|
|
'if (uElevOn > 0.5 && alt < terrElev(p.xz) * uElevScale) hitsTerrain = 1.0;',
|
|
'float dens = exp(-alt / Hs) / Hs;',
|
|
'float dtau = uAtmoDensity * dens * step;',
|
|
'float transBefore = exp(-tau);',
|
|
'vec3 atmoCol = uAtmoGrayColor;',
|
|
'vec3 light = uSunColor * uSunInt * sunPhase + uMoonColor * uMoonInt * moonPhase * 2.0 + max(vec3(uSkyAmb), uStarAmb * vec3(0.7, 0.85, 1.0)) * 0.65;',
|
|
'scatter += atmoCol * light * dtau * transBefore;',
|
|
'tau += dtau;',
|
|
't += step;',
|
|
'}',
|
|
'float rawAlpha = clamp(1.0 - exp(-tau), 0.0, 1.0);',
|
|
'float alpha = clamp(rawAlpha * uAtmoGain * hitsTerrain, 0.0, uAtmoMaxAlpha);',
|
|
'vec3 col = scatter / max(rawAlpha, 1e-4);',
|
|
'gl_FragColor = vec4(col, alpha);',
|
|
'return;',
|
|
'}',
|
|
'if (uMode == 3) {',
|
|
'if (uFrontHazeOn < 0.5) { gl_FragColor = vec4(0.0); return; }',
|
|
'vec2 uv = gl_FragCoord.xy / uResolution;',
|
|
'vec4 distTex = texture2D(uTerrainDistTex, uv);',
|
|
'float column = 0.0;',
|
|
'if (distTex.a > 0.5) {',
|
|
'float distNorm = (distTex.r * 65536.0 + distTex.g * 256.0 + distTex.b) / 16777215.0;',
|
|
'column = hazeColumnToDistance(rd, distNorm * max(1.0, uFarPlane));',
|
|
'} else {',
|
|
'column = airHazeColumn(rd);',
|
|
'}',
|
|
'float tau = uFrontHazeDensity * column;',
|
|
'float alpha = clamp((1.0 - exp(-tau)) * uFrontHazeGain, 0.0, uFrontHazeMaxAlpha);',
|
|
'float dither = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);',
|
|
'alpha = clamp(alpha + (dither - 0.5) / 255.0, 0.0, uFrontHazeMaxAlpha);',
|
|
'gl_FragColor = vec4(uFrontHazeColor, alpha);',
|
|
'return;',
|
|
'}',
|
|
'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.45 + 0.5, 0.5, 1.0);',
|
|
'vec3 col = mix(uBg, uSky, skyT);',
|
|
'if (uPhysicalSkyOn > 0.5) {',
|
|
'float cosT = dot(rd, uSunDir);',
|
|
'float phase = 0.5 + 0.5 * cosT * cosT;',
|
|
'float sunUp = smoothstep(-0.25, 0.05, uSunAlt);',
|
|
'vec3 sunTop = uSunColor * uSunInt * sunUp;',
|
|
'vec3 tauView = sphericalRayleighTau(rd);',
|
|
'vec3 single = phase * sunTop * (1.0 - exp(-tauView));',
|
|
'vec3 multi = phase * sunTop * tauView * tauView * 0.12;',
|
|
'vec3 ambient = max(vec3(uSkyAmb), uStarAmb * vec3(0.7, 0.85, 1.0)) *',
|
|
'(1.0 - exp(-tauView)) * vec3(0.35, 0.55, 1.0);',
|
|
'vec3 phys = (single + multi + ambient) * uRayleighGain;',
|
|
'col = mix(col, phys, 0.82);',
|
|
'}',
|
|
'if (uAirHazeOn > 0.5) {',
|
|
'float hazeColumn = airHazeColumn(rd);',
|
|
'float hazeTau = uAirHazeDensity * hazeColumn;',
|
|
'float hazeAlpha = clamp((1.0 - exp(-hazeTau)) * uAirHazeGain, 0.0, uAirHazeMaxAlpha);',
|
|
'col = mix(col, uAirHazeColor, hazeAlpha);',
|
|
'}',
|
|
'float nightFactor = 1.0 - smoothstep(-0.15, 0.05, uSunAlt);',
|
|
'float skyMask = smoothstep(-0.20, 0.40, rd.y);',
|
|
// Stars must not sit on the moon: the disc is opaque, so every
|
|
// star term is masked out inside (a little beyond) its rim.
|
|
// 0 inside the disc, 1 outside it - the smoothstep runs along the
|
|
// angular distance, so an extra "1.0 -" would blank the whole sky.
|
|
'float moonOcc = smoothstep(uMoonR * 0.92, uMoonR * 1.35, length(rd - uMoonDir));',
|
|
// 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
|
|
'float starVis = 1.0 - smoothstep(-0.30, -0.05, uSunAlt);',
|
|
'if (uSkyOn > 0.5 && starVis > 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)));',
|
|
// stars only once the sky is actually dark: the dusk palette
|
|
// is rust/blood, and additive white stars on a red sky read
|
|
// as red stars
|
|
'col += st * starVis * skyMask * twk * 1.45 * moonOcc;',
|
|
'}',
|
|
// ---- Sun disc ------------------------------------------------
|
|
// angular radius ~0.27 deg -> chord length ~0.0047
|
|
'if (uSunAlt > -0.03) {',
|
|
'vec3 qs = rd - uSunDir;',
|
|
'float angS = length(qs);',
|
|
// The disc is drawn at uSunR (CONFIG.sun.angularRadius, 12x
|
|
// physical by default) but the surface brightness stays
|
|
// exactly what it was: the halo profile is normalised to the
|
|
// disc radius, so the sun is bigger, not brighter.
|
|
'float angN = angS * (0.00465 / uSunR);',
|
|
'float discS = 1.0 - smoothstep(uSunR * 0.86, uSunR, angS);',
|
|
'float haloS = exp(-angN * 55.0) * 0.30 + exp(-angN * 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: lunar map + geometric terminator --------------
|
|
// The near side always faces the observer, so the map is sampled
|
|
// in a selenographic frame built from the north celestial pole:
|
|
// north up, east to the right, exactly like a printed lunar map,
|
|
// and it rotates as the moon crosses the sky. The terminator is
|
|
// pure geometry (surface normal dot sun direction), so crescent,
|
|
// gibbous and the orientation towards the sun all fall out.
|
|
'if (uMoonDir.y > -0.02) {',
|
|
'float MR = uMoonR;',
|
|
'vec3 qm = rd - uMoonDir;',
|
|
'float angM = length(qm);',
|
|
'if (angM < MR) {',
|
|
'float rr = angM / MR;',
|
|
'float z = sqrt(max(0.0, 1.0 - rr * rr));',
|
|
// outward normal of the NEAR hemisphere: the offset part
|
|
// plus -uMoonDir (towards the observer), not +uMoonDir.
|
|
// With the wrong sign the lit side was the far side and
|
|
// the disc came out black at and near full moon.
|
|
'vec3 nM = normalize(qm / MR - uMoonDir * z);',
|
|
'vec3 upTry = uPole - uMoonDir * dot(uPole, uMoonDir);',
|
|
'vec3 fb = (abs(uMoonDir.y) < 0.9) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);',
|
|
'vec3 fb2 = fb - uMoonDir * dot(fb, uMoonDir);',
|
|
'vec3 uU = (dot(upTry, upTry) > 1e-6) ? normalize(upTry) : normalize(fb2);',
|
|
'vec3 uE = cross(uU, uMoonDir);',
|
|
'float lon = atan(dot(nM, uE), dot(nM, -uMoonDir));',
|
|
'float lat = asin(clamp(dot(nM, uU), -1.0, 1.0));',
|
|
'vec2 uv = vec2(lon / 6.2831853 + 0.5, lat / 3.14159265 + 0.5);',
|
|
'uv.x = mix(uv.x, 1.0 - uv.x, uMoonFlipU);',
|
|
'uv.y = mix(uv.y, 1.0 - uv.y, uMoonFlipV);',
|
|
'vec3 alb = mix(vec3(0.62, 0.63, 0.66), texture2D(uMoonTex, uv).rgb, uMoonTexOn);',
|
|
'float litM = dot(nM, uSunDir);',
|
|
'float phase = smoothstep(-0.05, 0.12, litM);',
|
|
'float lam = max(0.0, litM);',
|
|
'float limb = 1.0 - uMoonLimb * rr * rr * rr;',
|
|
'float shade = lam * lam * 0.85 + lam * 0.18;',
|
|
'float earth = uMoonEarth * (1.0 - phase) * (0.35 + 0.65 * (1.0 - rr));',
|
|
'float discM = 1.0 - smoothstep(MR * 0.965, MR, angM);',
|
|
'col += alb * (shade * limb + earth) * discM * uMoonBright;',
|
|
'}',
|
|
'float haloM = exp(-angM * 60.0) * 0.10 + exp(-angM * 16.0) * 0.04;',
|
|
'col += vec3(0.75, 0.78, 0.85) * haloM * uMoonHalo * (0.25 + 0.75 * uMoonFrac);',
|
|
'}',
|
|
// faint procedural fill for stars below the catalog limit
|
|
'float stars = starField(rd) * skyMask * nightFactor;',
|
|
'col += stars * uLight * 0.85 * moonOcc;',
|
|
// ---- Horizon airlight -------------------------------------
|
|
// At and below the horizon the sky shows the same scattered
|
|
// column the terrain fades into. Without it the outer rim of
|
|
// the ground has a different colour than the sky next to it
|
|
// and reads as a hard edge.
|
|
'if (uPhysical > 0.5) {',
|
|
'float lowBand = 1.0 - smoothstep(-0.10, 0.55, rd.y);',
|
|
'float cosTA = dot(rd, uSunDir);',
|
|
'float phaseA = 0.5 + 0.5 * cosTA * cosTA;',
|
|
'col = mix(col, uAirColor * phaseA * 1.45, lowBand * 0.95);',
|
|
'}',
|
|
'gl_FragColor = vec4(col, 1.0);',
|
|
'return;',
|
|
'}',
|
|
|
|
'if (uMode == 1) {',
|
|
'if (uCloudsOn < 0.5) { gl_FragColor = vec4(0.0); return; }',
|
|
'float camAlt = uCamPos.y;',
|
|
'vec2 top = cloudLayerRoots(uCloudTop, rd);',
|
|
'vec2 bot = cloudLayerRoots(uCloudBot, rd);',
|
|
'float tEnter = 1e9;',
|
|
'float tExit = -1e9;',
|
|
'if (camAlt >= uCloudBot && camAlt <= uCloudTop) {',
|
|
'tEnter = 0.0;',
|
|
'tExit = max(maxPositiveRoot(top), maxPositiveRoot(bot));',
|
|
'} else if (camAlt > uCloudTop) {',
|
|
'tEnter = minPositiveRoot(top);',
|
|
'tExit = maxPositiveRoot(bot);',
|
|
'if (tExit < 0.0) tExit = maxPositiveRoot(top);',
|
|
'} else {',
|
|
'tEnter = minPositiveRoot(bot);',
|
|
'tExit = maxPositiveRoot(top);',
|
|
'if (tExit < 0.0) tExit = maxPositiveRoot(bot);',
|
|
'}',
|
|
'if (tEnter > 1e8 || tExit <= tEnter) { gl_FragColor = vec4(0.0); return; }',
|
|
'vec2 uv = gl_FragCoord.xy / uResolution;',
|
|
'if (uTerrainDistOk > 0.5) {',
|
|
'vec4 distTex = texture2D(uTerrainDistTex, uv);',
|
|
'if (distTex.a > 0.5) {',
|
|
'float distNorm = (distTex.r * 65536.0 + distTex.g * 256.0 + distTex.b) / 16777215.0;',
|
|
'float dist = distNorm * max(1.0, uFarPlane);',
|
|
'vec3 pT = uCamPos + rd * dist;',
|
|
'float terrainAlt = (pT.y + earthDrop(pT.xz)) / max(1.0, uTerrainExag);',
|
|
'if (terrainAlt > uCloudTop + 0.05) { gl_FragColor = vec4(0.0); return; }',
|
|
'}',
|
|
'}',
|
|
'tExit = min(tExit, tEnter + 30.0);',
|
|
'if (tExit <= tEnter) { gl_FragColor = vec4(0.0); return; }',
|
|
'float stepSize = (tExit - tEnter) / 24.0;',
|
|
'float t = tEnter + stepSize * 0.5;',
|
|
'float coverage = clamp(uDensity, 0.0, 1.0);',
|
|
'float baseThresh = mix(0.62, 0.20, coverage);',
|
|
'float densityGain = mix(0.45, 1.0, coverage);',
|
|
'float sunUp = smoothstep(-0.20, 0.05, uSunAlt);',
|
|
'float cosT = dot(rd, uSunDir);',
|
|
'float phase = 0.30 + 0.70 * pow(max(0.0, cosT), 2.5);',
|
|
'float trans = 1.0;',
|
|
'vec3 cloudSum = vec3(0.0);',
|
|
'for (int i = 0; i < 24; i++) {',
|
|
'vec3 p = uCamPos + rd * t;',
|
|
'p += rd * (ign(gl_FragCoord.xy + vec2(float(i) * 7.13, float(i) * 3.71)) - 0.5) * stepSize * 0.25;',
|
|
'float alt = p.y + earthDrop(p.xz);',
|
|
'if (alt > uCloudTop + 0.1 || alt < uCloudBot - 0.1) break;',
|
|
'float soft = smoothstep(4.0, 20.0, t);',
|
|
'float d = cloudDensitySpherical(p, alt, baseThresh, soft);',
|
|
'if (d > 0.02) {',
|
|
'float lit = clamp((alt - uCloudBot) / max(0.01, uCloudTop - uCloudBot), 0.0, 1.0);',
|
|
'vec3 albC = uCloudAlbedo * mix(0.78, 1.0, lit);',
|
|
'float selfLit = clamp(1.0 - d * 0.8, 0.18, 1.0);',
|
|
'vec3 direct = albC * uSunColor * uSunInt * phase * sunUp * selfLit;',
|
|
'vec3 ambC = albC * max(vec3(uSkyAmb), uStarAmb * vec3(0.7, 0.85, 1.0)) * 1.7;',
|
|
'vec3 moonC = albC * uMoonColor * (0.35 + 0.65 * max(0.0, dot(rd, uMoonDir))) * uMoonInt * 3.0;',
|
|
'vec3 cloudCol = direct + ambC + moonC;',
|
|
'float alpha = clamp(d * stepSize * 3.50 * densityGain, 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, clamp(cloudAlpha, 0.0, 1.0));',
|
|
'return;',
|
|
'}',
|
|
'}'
|
|
].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;',
|
|
'attribute vec3 aColor;',
|
|
'uniform mat4 uView;',
|
|
'uniform mat4 uProj;',
|
|
'uniform vec3 uCamPos;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'varying vec3 vNormal;',
|
|
'varying vec2 vUV;',
|
|
'varying float vY;',
|
|
'varying vec3 vViewPos;',
|
|
'varying vec3 vWorld;',
|
|
'varying vec3 vColor;',
|
|
'void main() {',
|
|
'vec3 pos = aPos;',
|
|
'vec3 n = aNormal;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'float r = max(1.0, uEarthRadius);',
|
|
'pos.y -= dot(d, d) / (2.0 * r);',
|
|
'n.x -= d.x / r;',
|
|
'n.z -= d.y / r;',
|
|
'n = normalize(n);',
|
|
'}',
|
|
'vNormal = n;',
|
|
'vUV = aUV;',
|
|
'vColor = aColor;',
|
|
'vY = pos.y;',
|
|
'vWorld = pos;',
|
|
'vec4 vp = uView * vec4(pos, 1.0);',
|
|
'vViewPos = vp.xyz;',
|
|
'gl_Position = uProj * vp;',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// Same noise + cloud-density functions as the sky program, so the terrain
|
|
// can ask "is there a cloud above me, on the way to the sun?" and get the
|
|
// exact same cloud field the eye sees in the sky pass.
|
|
var CLOUD_SAMPLER_GLSL = [
|
|
'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);',
|
|
'}',
|
|
'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);',
|
|
'}',
|
|
// Four octaves (the name stayed): the extra high-frequency octave breaks
|
|
// up the iso-surfaces that made the deck read as stacked slabs. Sum is
|
|
// renormalised to the old 0.875 range so baseThresh still means the same
|
|
// cloud coverage.
|
|
'float fbm3(vec3 p) {',
|
|
'float v = 0.0;',
|
|
'float a = 0.5;',
|
|
'for (int i = 0; i < 4; i++) {',
|
|
'v += a * noise3(p);',
|
|
'p = p * 2.03 + vec3(13.7, 7.1, 19.3);',
|
|
'a *= 0.5;',
|
|
'}',
|
|
'return v * 0.93333;',
|
|
'}',
|
|
// Height field lookup on the same grid the mesh was built from, so a
|
|
// ridge occludes exactly the ridge you can see.
|
|
'float terrElevT(vec2 xz) {',
|
|
'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;',
|
|
'}',
|
|
// Terrain self-shadow: march from the surface towards the sun over the
|
|
// height field and keep the smallest clearance between ray and ground.
|
|
// The clearance (not a hard hit test) gives a soft penumbra, and the
|
|
// march stops once the ray is above the tallest terrain.
|
|
'float terrainShadow(vec3 p, vec3 L) {',
|
|
'if (uTerrainShadowOn < 0.5 || L.y < 0.01) return 1.0;',
|
|
'float stepT = 0.22;',
|
|
'float clearance = 1000.0;',
|
|
'float y = p.y;',
|
|
'float top = p.y + 1.4;',
|
|
'for (int i = 0; i < 11; i++) {',
|
|
'vec2 q = p.xz + L.xz * (stepT * (float(i) + 0.5));',
|
|
'float h = terrElevT(q) * uElevScale;',
|
|
'y += L.y * stepT;',
|
|
'clearance = min(clearance, y - h);',
|
|
'if (y > top) break;',
|
|
'}',
|
|
// Wide penumbra plus a floor: a shadowed slope stays clearly darker
|
|
// but never goes into the black, which is what made the patches read
|
|
// as hard spots on the ridges.
|
|
'return mix(uTerrainShadowFloor, 1.0, smoothstep(0.0, 0.22, clearance));',
|
|
'}',
|
|
'float earthDrop(vec2 xz) {',
|
|
'if (uCurvatureOn < 0.5) return 0.0;',
|
|
'vec2 d = xz - uCamPos.xz;',
|
|
'return dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vec2 curvedLayerRoots(float layerY, vec3 ro, vec3 rd) {',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float a = (uCurvatureOn > 0.5) ? dot(rd.xz, rd.xz) / (2.0 * R) : 0.0;',
|
|
'float b = rd.y;',
|
|
'float c = ro.y - layerY;',
|
|
'if (abs(a) < 1e-6) {',
|
|
'if (abs(b) < 1e-6) return vec2(-1.0, -1.0);',
|
|
'float t = -c / b;',
|
|
'return vec2(t, t);',
|
|
'}',
|
|
'float disc = b * b - 4.0 * a * c;',
|
|
'if (disc < 0.0) return vec2(-1.0, -1.0);',
|
|
'float sq = sqrt(disc);',
|
|
'return vec2((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));',
|
|
'}',
|
|
'float cloudDensity(vec3 p, float baseThresh, float soft) {',
|
|
'float py = p.y + earthDrop(p.xz);',
|
|
'vec3 cp = vec3(p.x, py, p.z);',
|
|
'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.05));',
|
|
// The detail octave weighs at most 0.20 and fades to zero with
|
|
// distance; past soft = 0.95 it contributes under 1 %, so the whole
|
|
// 4-octave lookup is skipped for the (numerous) far samples.
|
|
'if (soft < 0.75) {',
|
|
'float d = fbm3(cp * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));',
|
|
'n = n + d * mix(0.22, 0.0, soft / 0.75);',
|
|
'}',
|
|
'float h = clamp((py - uCloudBot) / (uCloudTop - uCloudBot), 0.0, 1.0);',
|
|
'float profile = 4.0 * h * (1.0 - h);',
|
|
'profile = max(profile, 0.0);',
|
|
'return smoothstep(baseThresh, baseThresh + 0.34 + soft * 0.28, n) * profile;',
|
|
'}',
|
|
].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 uMoonDir;', // unit vector towards the moon (world)
|
|
'uniform vec3 uSunColor;', // direct beam after atmospheric extinction
|
|
'uniform float uSunInt;', // direct beam strength (1 = zenith noon)
|
|
'uniform vec3 uMoonColor;', // moonlight after the same extinction
|
|
'uniform float uMoonInt;', // moonlight strength
|
|
'uniform float uSkyAmb;', // diffuse skylight
|
|
'uniform vec3 uAlbedoTint;', // theme fine-tuning of the surface colour
|
|
'uniform vec3 uHorizonColor;', // fog colour taken from the same light model
|
|
'uniform float uStarAmb;', // diffuse starlight + airglow (night only)
|
|
'uniform float uTime;', // must match the sky pass: same cloud field
|
|
'uniform float uCloudBot;',
|
|
'uniform float uCloudTop;',
|
|
'uniform float uDensity;', // cloud coverage, same as the sky pass
|
|
'uniform float uCloudShadowOn;',
|
|
'uniform float uCloudShadowDensity;', // optical depth per density sample
|
|
'uniform float uCloudShadowFloor;', // darkest the shade may get
|
|
'uniform float uCloudShadowAmbient;', // how much of the skylight a cloud also blocks
|
|
'uniform sampler2D uElev;', // height field, same grid the mesh was built from
|
|
'uniform float uElevScale;', // metres -> world units (incl. exaggeration)
|
|
'uniform float uTerrainShadowOn;',
|
|
'uniform float uTerrainShadowFloor;', // brightness a fully shadowed slope keeps
|
|
'uniform vec3 uAirColor;', // Rayleigh airlight (sun-tinted, blue-biased)
|
|
'uniform float uAirDensity;', // slant optical depth per world unit
|
|
'uniform vec3 uCamPos;', // world-space camera, for the view direction
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform float uHazeOn;',
|
|
'uniform float uHazeDensity;',
|
|
'uniform float uHazeScaleHeightUnits;',
|
|
'uniform float uHazeGain;',
|
|
'uniform float uHazeMaxAlpha;',
|
|
'uniform vec3 uHazeColor;',
|
|
'uniform float uDistancePassOn;',
|
|
'uniform float uFarPlane;',
|
|
'uniform float uEdgeFadeTiles;',
|
|
'uniform vec3 uWireColor;', // non-zero -> debug wireframe overlay (flat colour)
|
|
'varying vec3 vNormal;',
|
|
'varying vec2 vUV;',
|
|
'varying float vY;',
|
|
'varying vec3 vViewPos;',
|
|
'varying vec3 vWorld;',
|
|
'varying vec3 vColor;',
|
|
CLOUD_SAMPLER_GLSL,
|
|
'float terrainHazeColumn(vec3 world) {',
|
|
'float dist = length(world - uCamPos);',
|
|
'if (dist <= 0.0) return 0.0;',
|
|
'if (uCurvatureOn < 0.5) return dist;',
|
|
'float R = max(1.0, uEarthRadius);',
|
|
'float Hs = max(0.05, uHazeScaleHeightUnits);',
|
|
'vec3 C = vec3(uCamPos.x, -R, uCamPos.z);',
|
|
'float column = 0.0;',
|
|
'for (int i = 0; i < 8; i++) {',
|
|
'vec3 p = mix(uCamPos, world, (float(i) + 0.5) / 8.0);',
|
|
'float alt = max(0.0, length(p - C) - R);',
|
|
'column += exp(-alt / Hs) * dist / 8.0 / Hs;',
|
|
'}',
|
|
'return column;',
|
|
'}',
|
|
'void main() {',
|
|
'if (uDistancePassOn > 0.5) {',
|
|
'float d = clamp(length(vViewPos) / max(1.0, uFarPlane), 0.0, 1.0);',
|
|
'float v = d * 16777215.0;',
|
|
'float r = floor(v / 65536.0) / 255.0;',
|
|
'float g = floor(mod(v, 65536.0) / 256.0) / 255.0;',
|
|
'float b = floor(mod(v, 256.0)) / 255.0;',
|
|
'gl_FragColor = vec4(r, g, b, 1.0);',
|
|
'return;',
|
|
'}',
|
|
'vec2 gUv = vUV;',
|
|
// Edge fade: beyond the canvas the texture is clamped to the
|
|
// edge pixel, so the rim dissolves into the airlight instead.
|
|
// Distance from the mosaic border in TILES, so the fade band has a
|
|
// real width (uEdgeFadeTiles) instead of a fixed 5 % slice - the
|
|
// rim dissolves into the airlight over tens of kilometres.
|
|
'float edgeX = min(gUv.x, 1.0 - gUv.x) * uGroundSize.x;',
|
|
'float edgeZ = min(gUv.y, 1.0 - gUv.y) * uGroundSize.y;',
|
|
'float edgeFade = smoothstep(0.0, uEdgeFadeTiles, min(edgeX, edgeZ));',
|
|
'float outside = step(0.0, -gUv.x) + step(1.0001, gUv.x) +',
|
|
'step(0.0, -gUv.y) + step(1.0001, gUv.y);',
|
|
'if (outside > 0.0) edgeFade = 1.0;',
|
|
'vec3 col = texture2D(uGround, gUv).rgb;',
|
|
'vec3 n = normalize(vNormal);',
|
|
// House lights are not painted into the texture any more (the VIIRS
|
|
// overlay was removed) - settlement lights are their own point pass
|
|
// (CITY_VERT_SRC / CITY_FRAG_SRC), which also does the per-block
|
|
// twilight switching.
|
|
// ---- Physical lighting -----------------------------------------
|
|
// Direct beam (extinction-coloured by the JS air-mass model) +
|
|
// hemispherical skylight + moonlight. No hand-picked day/night
|
|
// ramp: the sun altitude drives colour and strength continuously.
|
|
'float outsideMask = clamp(outside, 0.0, 1.0);',
|
|
'vec3 alb = mix(texture2D(uGround, gUv).rgb, vColor, outsideMask) * uAlbedoTint;',
|
|
'float ndl = max(0.0, dot(n, uSunDir));',
|
|
// Cloud shadow: march from the surface point towards the sun
|
|
// through the cloud layer and accumulate optical depth. Same
|
|
// density function, same uTime as the sky pass, so the patch of
|
|
// shade on the ground sits exactly under the cloud in the sky.
|
|
'float cShadow = 1.0;',
|
|
'if (uCloudShadowOn > 0.5 && uSunDir.y > 0.02) {',
|
|
'float baseTh = mix(0.72, 0.40, uDensity);',
|
|
// Sample only the part of the ray that is INSIDE the deck:
|
|
// sampling the whole ground-to-cloudtop path put most
|
|
// samples under the layer, where the layer profile is zero,
|
|
// so the accumulated density was near nothing and no shade
|
|
// appeared at all.
|
|
'vec2 bot = curvedLayerRoots(uCloudBot, vWorld, uSunDir);',
|
|
'vec2 top = curvedLayerRoots(uCloudTop, vWorld, uSunDir);',
|
|
'float tBot = 1e9;',
|
|
'if (bot.x > 0.0) tBot = min(tBot, bot.x);',
|
|
'if (bot.y > 0.0) tBot = min(tBot, bot.y);',
|
|
'if (top.x > 0.0) tBot = min(tBot, top.x);',
|
|
'if (top.y > 0.0) tBot = min(tBot, top.y);',
|
|
'float tTop = max(max(bot.x, bot.y), max(top.x, top.y));',
|
|
'float groundPy = vY + earthDrop(vWorld.xz);',
|
|
'if (groundPy >= uCloudBot && groundPy <= uCloudTop) tBot = 0.0;',
|
|
'if (tBot > 1e8) { tBot = 0.0; tTop = 14.0; }',
|
|
'if (tTop > 0.0 && tTop > tBot && tTop < 90.0) {',
|
|
'float dens = 0.0;',
|
|
'for (int ci = 0; ci < 5; ci++) {',
|
|
'float tt = mix(tBot, tTop, (float(ci) + 0.5) / 5.0);',
|
|
'dens += cloudDensity(vWorld + uSunDir * tt, baseTh, smoothstep(4.0, 18.0, tt));',
|
|
'}',
|
|
'cShadow = mix(uCloudShadowFloor, 1.0,',
|
|
' exp(-dens * uCloudShadowDensity));',
|
|
'}',
|
|
'}',
|
|
// A deck does not only block the beam: it also cuts most of the
|
|
// skylight above it, so a cloud patch reads as real shade and
|
|
// not as a slightly dimmer area.
|
|
'float ambShadow = mix(1.0, cShadow, uCloudShadowAmbient);',
|
|
'float tShadow = terrainShadow(vWorld + vec3(0.0, 0.004, 0.0), uSunDir);',
|
|
'vec3 direct = alb * uSunColor * ndl * uSunInt * cShadow * tShadow;',
|
|
// starlight carries a cool cast, the ordinary skylight does not
|
|
// max(), not a sum: skylight rules until it decays below the
|
|
// starlight floor, then the floor takes over. That keeps the
|
|
// evening and the morning monotonic - no dark hole in the
|
|
// twilight, no brightening after sunset.
|
|
'vec3 ambT = max(vec3(uSkyAmb), uStarAmb * vec3(0.72, 0.82, 1.0));',
|
|
// a ridge also cuts part of the sky visible from the valley
|
|
'vec3 sky = alb * ambT * (0.62 + 0.38 * n.y)',
|
|
' * ambShadow * mix(1.0, tShadow, 0.18);',
|
|
'vec3 moon = alb * uMoonColor * max(0.0, dot(n, uMoonDir)) * uMoonInt * ambShadow;',
|
|
'col = direct + sky + moon;',
|
|
// ---- Distance fog ----------------------------------------------
|
|
// Real view-space distance, not a height proxy.
|
|
'float dist = length(vViewPos);',
|
|
'vec3 horizonCol = uHorizonColor;',
|
|
// ---- Aerial perspective --------------------------------------
|
|
// Physical mode: Beer-Lambert along the view ray. The surface is
|
|
// attenuated by exp(-tau) and the light scattered in the same air
|
|
// column is added, so distant ground does not fade into a flat grey
|
|
// veil but into the airlight itself - blue-biased (Rayleigh,
|
|
// 1/lambda^4) and brighter towards the sun through the (1+cos^2)/2
|
|
// phase function.
|
|
'float hazeTau = 0.0;',
|
|
'if (uHazeOn > 0.5) {',
|
|
'hazeTau = uHazeDensity * terrainHazeColumn(vWorld);',
|
|
'} else {',
|
|
'hazeTau = uAirDensity * dist * (0.75 + 0.25 * smoothstep(0.4, 0.0, vY));',
|
|
'}',
|
|
'float trans = exp(-hazeTau);',
|
|
'vec3 vd = normalize(vWorld - uCamPos);',
|
|
'float cosT = dot(vd, uSunDir);',
|
|
'float rphase = 0.5 + 0.5 * cosT * cosT;',
|
|
'float hazeAlpha = clamp((1.0 - trans) * uHazeGain, 0.0, uHazeMaxAlpha);',
|
|
'vec3 hazeCol = mix(uHazeColor, uAirColor, 0.25) * (0.75 + 0.25 * rphase);',
|
|
'col = mix(col, hazeCol, hazeAlpha);',
|
|
// 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) {
|
|
// CONFIG.antialias (default on) asks for MSAA on the default framebuffer.
|
|
// It costs fill rate, and at renderScale < 1 the pixel blocks dominate
|
|
// anyway - set it to false if the scene is GPU bound.
|
|
var AA_ON = !(window.CONFIG && window.CONFIG.antialias === false);
|
|
var glOpts = { antialias: AA_ON, alpha: false, powerPreference: 'high-performance' };
|
|
var gl = canvas.getContext('webgl2', glOpts) || canvas.getContext('webgl', glOpts);
|
|
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 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 uTerrainExag = gl.getUniformLocation(prog, 'uTerrainExag');
|
|
var uSunDir = gl.getUniformLocation(prog, 'uSunDir');
|
|
var uMoonDir = gl.getUniformLocation(prog, 'uMoonDir');
|
|
var uMode = gl.getUniformLocation(prog, 'uMode');
|
|
var uAtmoModelOn = gl.getUniformLocation(prog, 'uAtmoModelOn');
|
|
var uAtmoHeightUnits = gl.getUniformLocation(prog, 'uAtmoHeightUnits');
|
|
var uAtmoScaleHeightUnits = gl.getUniformLocation(prog, 'uAtmoScaleHeightUnits');
|
|
var uAtmoDensity = gl.getUniformLocation(prog, 'uAtmoDensity');
|
|
var uAtmoGrayTau = gl.getUniformLocation(prog, 'uAtmoGrayTau');
|
|
var uAtmoGain = gl.getUniformLocation(prog, 'uAtmoGain');
|
|
var uAtmoMaxAlpha = gl.getUniformLocation(prog, 'uAtmoMaxAlpha');
|
|
var uEarthRadius = gl.getUniformLocation(prog, 'uEarthRadius');
|
|
var uCurvatureOn = gl.getUniformLocation(prog, 'uCurvatureOn');
|
|
var uCamPos = gl.getUniformLocation(prog, 'uCamPos');
|
|
var uAtmoBlueColor = gl.getUniformLocation(prog, 'uAtmoBlueColor');
|
|
var uAtmoGrayColor = gl.getUniformLocation(prog, 'uAtmoGrayColor');
|
|
var uSkyTex = gl.getUniformLocation(prog, 'uSkyTex');
|
|
var uSkyOn = gl.getUniformLocation(prog, 'uSkyOn');
|
|
var uStarLon = gl.getUniformLocation(prog, 'uStarLon');
|
|
var uPole = gl.getUniformLocation(prog, 'uPole');
|
|
var uMoonTex = gl.getUniformLocation(prog, 'uMoonTex');
|
|
var uMoonTexOn = gl.getUniformLocation(prog, 'uMoonTexOn');
|
|
var uMoonR = gl.getUniformLocation(prog, 'uMoonR');
|
|
var uMoonFrac = gl.getUniformLocation(prog, 'uMoonFrac');
|
|
var uMoonEarth = gl.getUniformLocation(prog, 'uMoonEarth');
|
|
var uMoonLimb = gl.getUniformLocation(prog, 'uMoonLimb');
|
|
var uMoonBright = gl.getUniformLocation(prog, 'uMoonBright');
|
|
var uMoonHalo = gl.getUniformLocation(prog, 'uMoonHalo');
|
|
var uMoonFlipU = gl.getUniformLocation(prog, 'uMoonFlipU');
|
|
var uMoonFlipV = gl.getUniformLocation(prog, 'uMoonFlipV');
|
|
var uSunR = gl.getUniformLocation(prog, 'uSunR');
|
|
var uSunColor = gl.getUniformLocation(prog, 'uSunColor');
|
|
var uSunInt = gl.getUniformLocation(prog, 'uSunInt');
|
|
var uMoonColor = gl.getUniformLocation(prog, 'uMoonColor');
|
|
var uMoonInt = gl.getUniformLocation(prog, 'uMoonInt');
|
|
var uSkyAmb = gl.getUniformLocation(prog, 'uSkyAmb');
|
|
var uStarAmb = gl.getUniformLocation(prog, 'uStarAmb');
|
|
var uPhysical = gl.getUniformLocation(prog, 'uPhysical');
|
|
var uAirColor = gl.getUniformLocation(prog, 'uAirColor');
|
|
var uRayleighBeta = gl.getUniformLocation(prog, 'uRayleighBeta');
|
|
var uSunAirMass = gl.getUniformLocation(prog, 'uSunAirMass');
|
|
var uRayleighGain = gl.getUniformLocation(prog, 'uRayleighGain');
|
|
var uPhysicalSkyOn = gl.getUniformLocation(prog, 'uPhysicalSkyOn');
|
|
var uAirHazeOn = gl.getUniformLocation(prog, 'uAirHazeOn');
|
|
var uAirHazeDensity = gl.getUniformLocation(prog, 'uAirHazeDensity');
|
|
var uAirHazeScaleHeightUnits = gl.getUniformLocation(prog, 'uAirHazeScaleHeightUnits');
|
|
var uAirHazeGain = gl.getUniformLocation(prog, 'uAirHazeGain');
|
|
var uAirHazeMaxAlpha = gl.getUniformLocation(prog, 'uAirHazeMaxAlpha');
|
|
var uAirHazeColor = gl.getUniformLocation(prog, 'uAirHazeColor');
|
|
var uTerrainDistTex = gl.getUniformLocation(prog, 'uTerrainDistTex');
|
|
var uCloudsOn = gl.getUniformLocation(prog, 'uCloudsOn');
|
|
var uTerrainDistOk = gl.getUniformLocation(prog, 'uTerrainDistOk');
|
|
var uFrontHazeOn = gl.getUniformLocation(prog, 'uFrontHazeOn');
|
|
var uFrontHazeDensity = gl.getUniformLocation(prog, 'uFrontHazeDensity');
|
|
var uFrontHazeScaleHeightUnits = gl.getUniformLocation(prog, 'uFrontHazeScaleHeightUnits');
|
|
var uFrontHazeGain = gl.getUniformLocation(prog, 'uFrontHazeGain');
|
|
var uFrontHazeMaxAlpha = gl.getUniformLocation(prog, 'uFrontHazeMaxAlpha');
|
|
var uFrontHazeColor = gl.getUniformLocation(prog, 'uFrontHazeColor');
|
|
var uFarPlane = gl.getUniformLocation(prog, 'uFarPlane');
|
|
var uCloudAlbedo = gl.getUniformLocation(prog, 'uCloudAlbedo');
|
|
var uAirDensity = gl.getUniformLocation(prog, 'uAirDensity');
|
|
var uCloudHaze = gl.getUniformLocation(prog, 'uCloudHaze');
|
|
|
|
// ---- 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 terrainAcolor = terrainProg ? gl.getAttribLocation(terrainProg, 'aColor') : -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 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;
|
|
var terrainUsunColor = terrainProg ? gl.getUniformLocation(terrainProg, 'uSunColor') : null;
|
|
var terrainUsunInt = terrainProg ? gl.getUniformLocation(terrainProg, 'uSunInt') : null;
|
|
var terrainUmoonColor = terrainProg ? gl.getUniformLocation(terrainProg, 'uMoonColor') : null;
|
|
var terrainUmoonInt = terrainProg ? gl.getUniformLocation(terrainProg, 'uMoonInt') : null;
|
|
var terrainUskyAmb = terrainProg ? gl.getUniformLocation(terrainProg, 'uSkyAmb') : null;
|
|
var terrainUalbedoTint = terrainProg ? gl.getUniformLocation(terrainProg, 'uAlbedoTint') : null;
|
|
var terrainUhorizon = terrainProg ? gl.getUniformLocation(terrainProg, 'uHorizonColor') : null;
|
|
var terrainUairColor = terrainProg ? gl.getUniformLocation(terrainProg, 'uAirColor') : null;
|
|
var terrainUairDensity = terrainProg ? gl.getUniformLocation(terrainProg, 'uAirDensity') : null;
|
|
var terrainUcampos = terrainProg ? gl.getUniformLocation(terrainProg, 'uCamPos') : null;
|
|
var terrainUEarthRadius = terrainProg ? gl.getUniformLocation(terrainProg, 'uEarthRadius') : null;
|
|
var terrainUCurvatureOn = terrainProg ? gl.getUniformLocation(terrainProg, 'uCurvatureOn') : null;
|
|
var terrainUedgeFade = terrainProg ? gl.getUniformLocation(terrainProg, 'uEdgeFadeTiles') : null;
|
|
var terrainUHazeOn = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeOn') : null;
|
|
var terrainUHazeDensity = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeDensity') : null;
|
|
var terrainUHazeScale = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeScaleHeightUnits') : null;
|
|
var terrainUHazeGain = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeGain') : null;
|
|
var terrainUHazeMaxAlpha = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeMaxAlpha') : null;
|
|
var terrainUHazeColor = terrainProg ? gl.getUniformLocation(terrainProg, 'uHazeColor') : null;
|
|
var terrainUDistancePass = terrainProg ? gl.getUniformLocation(terrainProg, 'uDistancePassOn') : null;
|
|
var terrainUFarPlane = terrainProg ? gl.getUniformLocation(terrainProg, 'uFarPlane') : null;
|
|
var terrainUstarAmb = terrainProg ? gl.getUniformLocation(terrainProg, 'uStarAmb') : null;
|
|
var terrainUtime = terrainProg ? gl.getUniformLocation(terrainProg, 'uTime') : null;
|
|
var terrainUcloudbot = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudBot') : null;
|
|
var terrainUcloudtop = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudTop') : null;
|
|
var terrainUdensity = terrainProg ? gl.getUniformLocation(terrainProg, 'uDensity') : null;
|
|
var terrainUcloudshadow = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudShadowOn') : null;
|
|
var terrainUcsDens = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudShadowDensity') : null;
|
|
var terrainUcsFloor = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudShadowFloor') : null;
|
|
var terrainUcsAmb = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudShadowAmbient') : null;
|
|
var terrainUelev = terrainProg ? gl.getUniformLocation(terrainProg, 'uElev') : null;
|
|
var terrainUterrainShadow = terrainProg ? gl.getUniformLocation(terrainProg, 'uTerrainShadowOn') : null;
|
|
var terrainUterrainShadowFloor = terrainProg ? gl.getUniformLocation(terrainProg, 'uTerrainShadowFloor') : null;
|
|
|
|
// 1x1 black texture: bound wherever a real layer does not exist yet
|
|
// (e.g. the basemap before the first tile, or terrain disabled) 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,
|
|
uCloudTop: uCloudTop, uCloudBot: uCloudBot,
|
|
uElev: uElev, uElevOn: uElevOn, uElevScale: uElevScale, uTerrainExag: uTerrainExag,
|
|
uSunDir: uSunDir, uMoonDir: uMoonDir, uMode: uMode,
|
|
uAtmoModelOn: uAtmoModelOn, uAtmoHeightUnits: uAtmoHeightUnits,
|
|
uAtmoScaleHeightUnits: uAtmoScaleHeightUnits, uAtmoDensity: uAtmoDensity,
|
|
uAtmoGrayTau: uAtmoGrayTau, uAtmoGain: uAtmoGain,
|
|
uAtmoMaxAlpha: uAtmoMaxAlpha,
|
|
uEarthRadius: uEarthRadius,
|
|
uCurvatureOn: uCurvatureOn, uCamPos: uCamPos,
|
|
uAtmoBlueColor: uAtmoBlueColor, uAtmoGrayColor: uAtmoGrayColor,
|
|
uSunColor: uSunColor, uSunInt: uSunInt, uMoonColor: uMoonColor,
|
|
uMoonInt: uMoonInt, uSkyAmb: uSkyAmb, uStarAmb: uStarAmb,
|
|
uPhysical: uPhysical, uAirColor: uAirColor,
|
|
uRayleighBeta: uRayleighBeta, uSunAirMass: uSunAirMass,
|
|
uRayleighGain: uRayleighGain, uPhysicalSkyOn: uPhysicalSkyOn,
|
|
uAirHazeOn: uAirHazeOn, uAirHazeDensity: uAirHazeDensity,
|
|
uAirHazeScaleHeightUnits: uAirHazeScaleHeightUnits,
|
|
uAirHazeGain: uAirHazeGain, uAirHazeMaxAlpha: uAirHazeMaxAlpha,
|
|
uAirHazeColor: uAirHazeColor,
|
|
uTerrainDistTex: uTerrainDistTex,
|
|
uCloudsOn: uCloudsOn, uTerrainDistOk: uTerrainDistOk,
|
|
uFrontHazeOn: uFrontHazeOn, uFrontHazeDensity: uFrontHazeDensity,
|
|
uFrontHazeScaleHeightUnits: uFrontHazeScaleHeightUnits,
|
|
uFrontHazeGain: uFrontHazeGain, uFrontHazeMaxAlpha: uFrontHazeMaxAlpha,
|
|
uFrontHazeColor: uFrontHazeColor, uFarPlane: uFarPlane,
|
|
uCloudAlbedo: uCloudAlbedo,
|
|
uAirDensity: uAirDensity, uCloudHaze: uCloudHaze,
|
|
uSkyTex: uSkyTex, uSkyOn: uSkyOn, uStarLon: uStarLon, uPole: uPole,
|
|
uMoonTex: uMoonTex, uMoonTexOn: uMoonTexOn, uMoonR: uMoonR,
|
|
uMoonFrac: uMoonFrac, uMoonEarth: uMoonEarth, uMoonLimb: uMoonLimb,
|
|
uMoonBright: uMoonBright, uMoonHalo: uMoonHalo,
|
|
uMoonFlipU: uMoonFlipU, uMoonFlipV: uMoonFlipV, uSunR: uSunR,
|
|
// terrain program
|
|
terrain: {
|
|
prog: terrainProg,
|
|
aPos: terrainApos, aNormal: terrainAnormal, aUV: terrainAuv,
|
|
aColor: terrainAcolor,
|
|
uView: terrainUview, uProj: terrainUproj,
|
|
uGround: terrainUground, uSunDir: terrainUsundir,
|
|
uGroundCenter: terrainUgroundcenter, uGroundSize: terrainUgroundsize,
|
|
uCorridorRot: terrainUcorridor, uCloudsOn: terrainUcloudsOn,
|
|
uElevScale: terrainUelevScale,
|
|
uWireColor: terrainUwireColor,
|
|
uSunColor: terrainUsunColor, uSunInt: terrainUsunInt,
|
|
uMoonColor: terrainUmoonColor, uMoonInt: terrainUmoonInt,
|
|
uSkyAmb: terrainUskyAmb, uAlbedoTint: terrainUalbedoTint,
|
|
uHorizonColor: terrainUhorizon,
|
|
uAirColor: terrainUairColor, uAirDensity: terrainUairDensity,
|
|
uCamPos: terrainUcampos,
|
|
uEarthRadius: terrainUEarthRadius, uCurvatureOn: terrainUCurvatureOn,
|
|
uEdgeFadeTiles: terrainUedgeFade,
|
|
uHazeOn: terrainUHazeOn, uHazeDensity: terrainUHazeDensity,
|
|
uHazeScaleHeightUnits: terrainUHazeScale, uHazeGain: terrainUHazeGain,
|
|
uHazeMaxAlpha: terrainUHazeMaxAlpha, uHazeColor: terrainUHazeColor,
|
|
uDistancePassOn: terrainUDistancePass, uFarPlane: terrainUFarPlane,
|
|
uStarAmb: terrainUstarAmb,
|
|
uTime: terrainUtime, uCloudBot: terrainUcloudbot,
|
|
uCloudTop: terrainUcloudtop, uDensity: terrainUdensity,
|
|
uCloudShadowOn: terrainUcloudshadow,
|
|
uCloudShadowDensity: terrainUcsDens,
|
|
uCloudShadowFloor: terrainUcsFloor,
|
|
uCloudShadowAmbient: terrainUcsAmb,
|
|
uElev: terrainUelev, uTerrainShadowOn: terrainUterrainShadow,
|
|
uTerrainShadowFloor: terrainUterrainShadowFloor
|
|
// 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 (y before x). ESRI answers cross-origin
|
|
// GETs with Access-Control-Allow-Origin: *, so the standalone build needs
|
|
// no server at all; the same-origin nginx paths are used instead when this
|
|
// page has them (proxy cache, friendlier to the upstream). 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_DIRECT = (GROUND_STYLE === 'satellite')
|
|
? 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/'
|
|
: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/';
|
|
var GROUND_TILE_PROXY = (GROUND_STYLE === 'satellite') ? '/esri/imagery/' : '/esri/streets/';
|
|
// direct by default: works from any origin, including a plain file:// open
|
|
var GROUND_TILE_URL = GROUND_TILE_DIRECT;
|
|
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
|
|
// ---- Camera aim (CONFIG.camera) ---------------------------------------
|
|
// The camera always flies the configured path; 'aim' only changes where it
|
|
// looks. 'moon' turns the view to the moon - heading to its azimuth, pitch
|
|
// to its altitude - while the flight path, the fetch corridor and the
|
|
// traffic stay untouched. aimBob keeps a little altitude bob so the view
|
|
// never looks frozen, aimEase smooths the turn.
|
|
var CAM_CFG = (window.CONFIG && window.CONFIG.camera) || {};
|
|
var CAM_AIM_MOON = (CAM_CFG.aim === 'moon');
|
|
var CAM_AIM_EASE = (typeof CAM_CFG.aimEase === 'number') ? Math.max(0, Math.min(0.999, CAM_CFG.aimEase)) : 0.94;
|
|
var CAM_AIM_BOB = (typeof CAM_CFG.aimBob === 'number') ? Math.max(0, Math.min(1, CAM_CFG.aimBob)) : 0.25;
|
|
var aimHdX = 0, aimHdZ = 1, aimPitch = 0.45, aimReady = false;
|
|
// 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);
|
|
var CLOUDS_ON = !(window.CONFIG && window.CONFIG.clouds && window.CONFIG.clouds.enabled === false);
|
|
|
|
// 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;
|
|
// Width of the soft rim at the mosaic border (tiles, 1 tile ~ 13 km): the
|
|
// mesh sinks and the colour dissolves into the airlight over this band.
|
|
var EDGE_FADE_TILES = (typeof TERRAIN_CFG.edgeFadeTiles === 'number')
|
|
? Math.max(0.5, TERRAIN_CFG.edgeFadeTiles) : 4.0;
|
|
var EDGE_SINK_TILES = 0.06;
|
|
var OUTER_DEM_CFG = TERRAIN_CFG.outerDem || {};
|
|
var OUTER_DEM_ON = OUTER_DEM_CFG.enabled !== false && TERRAIN_ON > 0.5;
|
|
var OUTER_DEM_Z = Math.max(4, Math.min(11,
|
|
(typeof OUTER_DEM_CFG.zoom === 'number') ? OUTER_DEM_CFG.zoom : 7));
|
|
var OUTER_TILES_PER = 1 << (11 - OUTER_DEM_Z);
|
|
var OUTER_PX_PER_Z11 = 256 / OUTER_TILES_PER;
|
|
var outerElevCanvas = null, outerElevCtx = null, outerElevData = null;
|
|
var outerElevReady = false;
|
|
var outerHalfX = 0, outerHalfY = 0, outerW = 0, outerH = 0;
|
|
function terrainSkirtUnits() {
|
|
var km = (typeof TERRAIN_CFG.edgeSkirtKm === 'number')
|
|
? Math.max(0, TERRAIN_CFG.edgeSkirtKm) : 800;
|
|
return km / KM_PER_TILE;
|
|
}
|
|
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);
|
|
});
|
|
}
|
|
};
|
|
|
|
// ---- tile source selection ---------------------------------------------
|
|
// Start from the public upstreams so the page works anywhere, then switch a
|
|
// source over to the same-origin proxy if a probe shows it is really served
|
|
// by our nginx (an image content-type is required, so a SPA fallback that
|
|
// answers 200 + HTML for every path cannot hijack the switch).
|
|
var _tileProxyChecked = false;
|
|
function tileProbeProxy() {
|
|
if (_tileProxyChecked) return;
|
|
_tileProxyChecked = true;
|
|
if (location.protocol === 'file:') return;
|
|
function probe(path, apply) {
|
|
fetch(path, { method: 'HEAD' }).then(function (r) {
|
|
var type = (r.headers.get('Content-Type') || '').toLowerCase();
|
|
if (r.ok && type.indexOf('image/') === 0) {
|
|
apply();
|
|
dbgLog('[clouds3d] tiles: using dev proxy ' + path);
|
|
}
|
|
}, function () {});
|
|
}
|
|
probe(ELEV_TILE_PROXY + '11/0/0.png', function () { ELEV_TILE_URL = ELEV_TILE_PROXY; });
|
|
probe(GROUND_TILE_PROXY + GROUND_TILE_Z + '/0/0.jpg',
|
|
function () { GROUND_TILE_URL = GROUND_TILE_PROXY; });
|
|
}
|
|
function fetchTileBlob(tileX, tileY) {
|
|
// ESRI World_Street_Map format: {z}/{y}/{x}.jpg (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;
|
|
|
|
// 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);
|
|
}
|
|
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);
|
|
} 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);
|
|
}
|
|
// 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
|
|
// elevation-tiles-prod (AWS) answers cross-origin GETs with CORS, so the
|
|
// DEM also loads without our server; /terrarium/ is preferred when present.
|
|
var ELEV_TILE_DIRECT = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/';
|
|
var ELEV_TILE_PROXY = '/terrarium/';
|
|
var ELEV_TILE_URL = ELEV_TILE_DIRECT;
|
|
// (the probe needs both URL pairs assigned, so it runs here, not at the
|
|
// ground-source block)
|
|
tileProbeProxy();
|
|
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 loadOuterElevTile(zx, zy) {
|
|
var id = 'elev/' + OUTER_DEM_Z + '/' + zx + '/' + zy;
|
|
function place(img) {
|
|
var cx = (OUTER_TILES_PER * zx - GROUND_BASE_X + outerHalfX) * OUTER_PX_PER_Z11;
|
|
var cy = (OUTER_TILES_PER * zy - GROUND_BASE_Y + outerHalfY) * OUTER_PX_PER_Z11;
|
|
outerElevCtx.drawImage(img, cx, cy);
|
|
}
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, id).then(function (rec) {
|
|
if (rec && rec.blob) {
|
|
return decodeToBitmap(rec.blob).then(place, function () {
|
|
return fetchFresh(db);
|
|
});
|
|
}
|
|
return fetchFresh(db);
|
|
});
|
|
});
|
|
function fetchFresh(db) {
|
|
return fetch(ELEV_TILE_URL + OUTER_DEM_Z + '/' + zx + '/' + zy + '.png')
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error('outer 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] outer elev tile failed z' + OUTER_DEM_Z + '/' + zx + '/' + zy + ': ' + (e && e.message || e));
|
|
});
|
|
}
|
|
}
|
|
|
|
function fetchOuterElevationGrid() {
|
|
if (!outerElevCanvas || !outerElevCtx) return;
|
|
var jobs = [];
|
|
var zx0 = Math.floor((GROUND_BASE_X - outerHalfX) / OUTER_TILES_PER);
|
|
var zx1 = Math.floor((GROUND_BASE_X + outerHalfX) / OUTER_TILES_PER);
|
|
var zy0 = Math.floor((GROUND_BASE_Y - outerHalfY) / OUTER_TILES_PER);
|
|
var zy1 = Math.floor((GROUND_BASE_Y + outerHalfY) / OUTER_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) {
|
|
try {
|
|
outerElevData = outerElevCtx.getImageData(0, 0, outerW, outerH).data;
|
|
outerElevReady = true;
|
|
} catch (e) {
|
|
console.warn('[clouds3d] outer DEM getImageData failed: ' + (e && e.message || e));
|
|
outerElevReady = false;
|
|
}
|
|
if (outerElevReady && terrainVbo && elevGl) {
|
|
terrainVbo = null;
|
|
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(loadOuterElevTile(jobs[k][0], jobs[k][1]));
|
|
Promise.all(batch).then(function () {
|
|
setTimeout(function () { next(n); }, GROUND_QUEUE_YIELD_MS);
|
|
});
|
|
}
|
|
next(0);
|
|
}
|
|
|
|
function buildOuterElevationSystem() {
|
|
if (!OUTER_DEM_ON || outerElevCanvas) return;
|
|
var skirt = terrainSkirtUnits();
|
|
outerHalfX = groundTilesX() / 2 + skirt;
|
|
outerHalfY = groundTilesY() / 2 + skirt;
|
|
outerW = Math.max(16, Math.ceil(outerHalfX * 2 * OUTER_PX_PER_Z11));
|
|
outerH = Math.max(16, Math.ceil(outerHalfY * 2 * OUTER_PX_PER_Z11));
|
|
outerElevCanvas = document.createElement('canvas');
|
|
outerElevCanvas.width = outerW;
|
|
outerElevCanvas.height = outerH;
|
|
outerElevCtx = outerElevCanvas.getContext('2d');
|
|
outerElevCtx.fillStyle = 'rgb(128,0,0)';
|
|
outerElevCtx.fillRect(0, 0, outerW, outerH);
|
|
fetchOuterElevationGrid();
|
|
}
|
|
|
|
function outerHeightAt(x, z) {
|
|
if (!outerElevReady || !outerElevData) return null;
|
|
var px = Math.round((x + outerHalfX) * OUTER_PX_PER_Z11);
|
|
var py = Math.round((outerHalfY - z) * OUTER_PX_PER_Z11);
|
|
if (px < 0 || py < 0 || px >= outerW || py >= outerH) return null;
|
|
var idx = (py * outerW + px) * 4;
|
|
var m = outerElevData[idx] * 256 + outerElevData[idx + 1] + outerElevData[idx + 2] / 256 - 32768;
|
|
return m * ELEV_SCALE_UNITS;
|
|
}
|
|
|
|
function skirtColorForHeight(hUnits) {
|
|
var maxM = (typeof OUTER_DEM_CFG.maxMeters === 'number')
|
|
? Math.max(100, OUTER_DEM_CFG.maxMeters) : 2500;
|
|
var m = hUnits / ELEV_SCALE_UNITS;
|
|
var t = Math.max(0, Math.min(1, m / maxM));
|
|
var stops = [
|
|
[0.36, 0.42, 0.30],
|
|
[0.55, 0.52, 0.47],
|
|
[0.78, 0.75, 0.68],
|
|
[0.92, 0.90, 0.84]
|
|
];
|
|
var q = t * (stops.length - 1);
|
|
var i = Math.max(0, Math.min(stops.length - 2, Math.floor(q)));
|
|
var f = q - i;
|
|
var a = stops[i], b = stops[i + 1];
|
|
return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
|
|
}
|
|
|
|
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();
|
|
buildOuterElevationSystem();
|
|
}
|
|
}
|
|
|
|
// ---- 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 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
|
|
var terrainDistFbo = null;
|
|
var terrainDistTex = null;
|
|
var terrainDistDepth = null;
|
|
var terrainDistW = 0;
|
|
var terrainDistH = 0;
|
|
var terrainDistOk = false;
|
|
function ensureTerrainDistFbo(gl, w, h) {
|
|
if ((!FRONT_HAZE_ON && !CLOUDS_ON) || w <= 0 || h <= 0) { terrainDistOk = false; return false; }
|
|
if (terrainDistFbo && terrainDistW === w && terrainDistH === h) return terrainDistOk;
|
|
if (terrainDistFbo) {
|
|
gl.deleteFramebuffer(terrainDistFbo);
|
|
gl.deleteTexture(terrainDistTex);
|
|
gl.deleteRenderbuffer(terrainDistDepth);
|
|
terrainDistFbo = terrainDistTex = terrainDistDepth = null;
|
|
}
|
|
terrainDistW = w; terrainDistH = h;
|
|
terrainDistTex = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, terrainDistTex);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
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);
|
|
terrainDistDepth = gl.createRenderbuffer();
|
|
gl.bindRenderbuffer(gl.RENDERBUFFER, terrainDistDepth);
|
|
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, w, h);
|
|
terrainDistFbo = gl.createFramebuffer();
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, terrainDistFbo);
|
|
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, terrainDistTex, 0);
|
|
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, terrainDistDepth);
|
|
terrainDistOk = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
|
|
if (!terrainDistOk) {
|
|
console.warn('[clouds3d] terrain distance FBO incomplete; front haze disabled');
|
|
}
|
|
return terrainDistOk;
|
|
}
|
|
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);
|
|
// Soft rim: within EDGE_FADE_TILES of the border the height is eased
|
|
// down to slightly below zero. The mesh then runs out under the
|
|
// horizon instead of ending in a cut-off ridge line.
|
|
for (var ej = 0; ej < gh; ej++) {
|
|
var erow = ej * gw;
|
|
var dz = Math.min(ej, gh - 1 - ej) * s;
|
|
for (var ei = 0; ei < gw; ei++) {
|
|
var dx = Math.min(ei, gw - 1 - ei) * s;
|
|
var t = Math.max(0, Math.min(1, Math.min(dx, dz) / EDGE_FADE_TILES));
|
|
t = t * t * (3 - 2 * t);
|
|
h[erow + ei] = h[erow + ei] * t - (1 - t) * EDGE_SINK_TILES;
|
|
}
|
|
}
|
|
// Build vertex list: 2 triangles per cell, 6 vertices each.
|
|
// Attributes: position (3), normal (3), uv (2), color (3) = 11 floats.
|
|
var cols = gw - 1, rows = gh - 1;
|
|
var SKIRT_UNITS = terrainSkirtUnits();
|
|
var SKIRT_RINGS = Math.max(1, (typeof TERRAIN_CFG.edgeSkirtRings === 'number')
|
|
? TERRAIN_CFG.edgeSkirtRings : 24);
|
|
var SKIRT_NOISE = (typeof TERRAIN_CFG.edgeSkirtNoise === 'number')
|
|
? Math.max(0, Math.min(1, TERRAIN_CFG.edgeSkirtNoise)) : 0.28;
|
|
var skirtSegs = 2 * cols + 2 * rows;
|
|
var skirtVerts = SKIRT_UNITS > 0 ? skirtSegs * SKIRT_RINGS * 6 : 0;
|
|
var vCount = cols * rows * 6 + skirtVerts;
|
|
var verts = new Float32Array(vCount * 11);
|
|
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;
|
|
// Detailed terrain uses the basemap texture; the color attribute is
|
|
// only consumed by the outer skirt.
|
|
verts[vi++] = 1; verts[vi++] = 1; verts[vi++] = 1;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ---- Outer skirt -------------------------------------------------
|
|
// Coarse outer terrain from reduced-resolution heightmap tiles. The
|
|
// skirt has no basemap texture; its colour comes from vertex heights.
|
|
function pushRaw(x, y, z, nx, ny, nz, u, v, cr, cg, cb) {
|
|
verts[vi++] = x; verts[vi++] = y; verts[vi++] = z;
|
|
verts[vi++] = nx; verts[vi++] = ny; verts[vi++] = nz;
|
|
verts[vi++] = u; verts[vi++] = v;
|
|
verts[vi++] = cr; verts[vi++] = cg; verts[vi++] = cb;
|
|
}
|
|
function uvFromXZ(x, z) {
|
|
return [0.5 - x / tilesX, z / tilesY + 0.5];
|
|
}
|
|
function skirtHeight(base, x, z, t) {
|
|
var real = outerHeightAt(x, z);
|
|
if (real === null) {
|
|
if (SKIRT_NOISE <= 0) return base;
|
|
var amp = SKIRT_NOISE * (0.15 + 0.85 * t);
|
|
var n = Math.sin(x * 0.11 + t * 2.1) * Math.cos(z * 0.09 - t * 1.7)
|
|
+ 0.5 * Math.sin((x * 0.7 + z * 0.5) * 0.13 + t * 3.3);
|
|
return base + n * amp;
|
|
}
|
|
if (t <= 0) return base;
|
|
var blend = Math.min(1, t * 2.0);
|
|
return base + (real - base) * blend;
|
|
}
|
|
function quadNormal(x0,y0,z0, x1,y1,z1, x2,y2,z2) {
|
|
var ax = x1 - x0, ay = y1 - y0, az = z1 - z0;
|
|
var bx = x2 - x0, by = y2 - y0, bz = z2 - z0;
|
|
var nx = ay * bz - az * by;
|
|
var ny = az * bx - ax * bz;
|
|
var nz = ax * by - ay * bx;
|
|
var nl = Math.hypot(nx, ny, nz);
|
|
if (!(nl > 1e-6)) return [0, 1, 0];
|
|
nx /= nl; ny /= nl; nz /= nl;
|
|
if (ny < 0) { nx = -nx; ny = -ny; nz = -nz; }
|
|
return [nx, ny, nz];
|
|
}
|
|
function ringT(k, rings) {
|
|
var u = k / rings;
|
|
return Math.pow(u, 1.35);
|
|
}
|
|
function outwardFor(i, j) {
|
|
var ox = 0, oz = 0;
|
|
if (j === 0) oz = 1;
|
|
else if (j === gh - 1) oz = -1;
|
|
if (i === 0) ox = -1;
|
|
else if (i === gw - 1) ox = 1;
|
|
if (ox !== 0 && oz !== 0) {
|
|
var inv = 1 / Math.SQRT2;
|
|
ox *= inv; oz *= inv;
|
|
}
|
|
return [ox, oz];
|
|
}
|
|
function boundaryLoop() {
|
|
var loop = [];
|
|
function add(i, j) {
|
|
var o = outwardFor(i, j);
|
|
loop.push({
|
|
x: i * s - halfX,
|
|
z: halfY - j * s,
|
|
y: hAt(i, j),
|
|
ox: o[0], oz: o[1]
|
|
});
|
|
}
|
|
for (var i = 0; i < gw; i++) add(i, 0);
|
|
for (var j = 1; j < gh; j++) add(gw - 1, j);
|
|
for (var i = gw - 2; i >= 0; i--) add(i, gh - 1);
|
|
for (var j = gh - 2; j >= 1; j--) add(0, j);
|
|
return loop;
|
|
}
|
|
function pushSkirtLoop() {
|
|
if (SKIRT_UNITS <= 0) return;
|
|
var loop = boundaryLoop();
|
|
for (var seg = 0; seg < loop.length; seg++) {
|
|
var a = loop[seg], b = loop[(seg + 1) % loop.length];
|
|
for (var k = 0; k < SKIRT_RINGS; k++) {
|
|
var t0 = ringT(k, SKIRT_RINGS);
|
|
var t1 = ringT(k + 1, SKIRT_RINGS);
|
|
var d0 = SKIRT_UNITS * t0, d1 = SKIRT_UNITS * t1;
|
|
var x00 = a.x + a.ox * d0, z00 = a.z + a.oz * d0;
|
|
var x01 = b.x + b.ox * d0, z01 = b.z + b.oz * d0;
|
|
var x10 = a.x + a.ox * d1, z10 = a.z + a.oz * d1;
|
|
var x11 = b.x + b.ox * d1, z11 = b.z + b.oz * d1;
|
|
var y00 = skirtHeight(a.y, x00, z00, t0);
|
|
var y01 = skirtHeight(b.y, x01, z01, t0);
|
|
var y10 = skirtHeight(a.y, x10, z10, t1);
|
|
var y11 = skirtHeight(b.y, x11, z11, t1);
|
|
var uv00 = uvFromXZ(x00, z00), uv01 = uvFromXZ(x01, z01);
|
|
var uv10 = uvFromXZ(x10, z10), uv11 = uvFromXZ(x11, z11);
|
|
var nrm = quadNormal(x00,y00,z00, x01,y01,z01, x10,y10,z10);
|
|
var nx = nrm[0], ny = nrm[1], nz = nrm[2];
|
|
var c00 = skirtColorForHeight(y00), c01 = skirtColorForHeight(y01);
|
|
var c10 = skirtColorForHeight(y10), c11 = skirtColorForHeight(y11);
|
|
pushRaw(x00, y00, z00, nx, ny, nz, uv00[0], uv00[1], c00[0], c00[1], c00[2]);
|
|
pushRaw(x01, y01, z01, nx, ny, nz, uv01[0], uv01[1], c01[0], c01[1], c01[2]);
|
|
pushRaw(x11, y11, z11, nx, ny, nz, uv11[0], uv11[1], c11[0], c11[1], c11[2]);
|
|
pushRaw(x00, y00, z00, nx, ny, nz, uv00[0], uv00[1], c00[0], c00[1], c00[2]);
|
|
pushRaw(x11, y11, z11, nx, ny, nz, uv11[0], uv11[1], c11[0], c11[1], c11[2]);
|
|
pushRaw(x10, y10, z10, nx, ny, nz, uv10[0], uv10[1], c10[0], c10[1], c10[2]);
|
|
}
|
|
}
|
|
}
|
|
pushSkirtLoop();
|
|
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 };
|
|
// Terrain just became available: (re)build both off-thread layers. They
|
|
// upload themselves once the worker answers, so nothing here blocks.
|
|
kickCarBuild(false);
|
|
kickCityBuild();
|
|
}
|
|
|
|
// ---- Night dimming ------------------------------------------------------
|
|
// How far the day map is dimmed at full night (0 = pitch black, 1 =
|
|
// unchanged). The NASA VIIRS city-lights layer was removed: settlement
|
|
// lights come from OSM place nodes (see the settlement lights section).
|
|
|
|
// ---- 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.
|
|
// ---- Physical sun / moon light -----------------------------------------
|
|
// Direct-beam transmittance from a Kasten-Young air mass plus Rayleigh,
|
|
// ozone and aerosol optical depths (Angstrom law). Everything about the
|
|
// colour of daylight - neutral at noon, orange at 10 deg, deep red at 3 deg
|
|
// - falls out of this instead of being painted with hand-picked gradients.
|
|
var ATMO = (window.CONFIG && window.CONFIG.atmosphere) || {};
|
|
var ATMO_TURBIDITY = (typeof ATMO.turbidity === 'number') ? Math.max(0.2, ATMO.turbidity) : 1.0;
|
|
var ATMO_MOON_GAIN = (typeof ATMO.moonGain === 'number') ? Math.max(0, ATMO.moonGain) : 0.16;
|
|
// ---- Moon appearance (CONFIG.moon) ------------------------------------
|
|
// The real disc is 0.0046 rad; the scene draws it larger so texture and
|
|
// phase are readable. Scene moonlight follows the disc, but only half of
|
|
// the excess over the physical size counts (see CONFIG.moon.lightGain).
|
|
var MOON_CFG = (window.CONFIG && window.CONFIG.moon) || {};
|
|
var MOON_PHYS_R = 0.0046;
|
|
var MOON_ANG_R = (typeof MOON_CFG.angularRadius === 'number')
|
|
? Math.max(MOON_PHYS_R, MOON_CFG.angularRadius) : MOON_PHYS_R * 8;
|
|
var MOON_LIGHT_GAIN = (typeof MOON_CFG.lightGain === 'number')
|
|
? Math.max(0, MOON_CFG.lightGain) : 1 + 0.5 * (MOON_ANG_R / MOON_PHYS_R - 1);
|
|
var MOON_EARTHSHINE = (typeof MOON_CFG.earthshine === 'number') ? Math.max(0, MOON_CFG.earthshine) : 0.10;
|
|
var MOON_LIMB_DARK = (typeof MOON_CFG.limbDarkening === 'number') ? Math.max(0, Math.min(0.9, MOON_CFG.limbDarkening)) : 0.30;
|
|
var MOON_BRIGHT = (typeof MOON_CFG.brightness === 'number') ? Math.max(0, MOON_CFG.brightness) : 1.25;
|
|
var MOON_HALO = (typeof MOON_CFG.halo === 'number') ? Math.max(0, MOON_CFG.halo) : 1.0;
|
|
var MOON_FLIP_U = MOON_CFG.flipU ? 1 : 0;
|
|
var MOON_FLIP_V = MOON_CFG.flipV ? 1 : 0;
|
|
// The real sun is 0.00465 rad. Scaling the disc changes its size only -
|
|
// the scene lighting (_beam/_beamL) is untouched.
|
|
var SUN_CFG = (window.CONFIG && window.CONFIG.sun) || {};
|
|
var SUN_ANG_R = (typeof SUN_CFG.angularRadius === 'number')
|
|
? Math.max(0.00465, SUN_CFG.angularRadius) : 0.00465 * 12;
|
|
var MOON_TEX_SRC = (typeof window.__moonTex === 'string' && window.__moonTex)
|
|
? window.__moonTex
|
|
: ((typeof MOON_CFG.texture === 'string' && MOON_CFG.texture) ? MOON_CFG.texture : 'vendor/moon.jpg');
|
|
var ATMO_NIGHT_FLOOR = (typeof ATMO.nightFloor === 'number') ? Math.max(0.01, ATMO.nightFloor) : 0.05;
|
|
var ATMO_ALBEDO_GAIN = (typeof ATMO.albedoGain === 'number') ? Math.max(0.1, ATMO.albedoGain) : 1.0;
|
|
var ATMO_SKY_GAIN = (typeof ATMO.skyGain === 'number') ? Math.max(0.05, ATMO.skyGain) : 0.55;
|
|
var ATMO_PHYSICAL_SKY = ATMO.physicalSky !== false;
|
|
var ATMO_RAYLEIGH_GAIN = (typeof ATMO.rayleighGain === 'number')
|
|
? Math.max(0.1, Math.min(30.0, ATMO.rayleighGain)) : 8.0;
|
|
|
|
// ---- Artificial light switching (CONFIG.lights) -------------------------
|
|
// Every individual light owns a sun-altitude threshold inside the window
|
|
// [onDeg, offDeg]; the aggregate (road ribbon, density curves) uses the
|
|
// mean of that window, which is the plain linear ramp.
|
|
var LIGHT_TUN = (window.CONFIG && window.CONFIG.lights) || {};
|
|
var DEG2RAD = Math.PI / 180;
|
|
var LIGHT_HI = ((typeof LIGHT_TUN.offDeg === 'number') ? LIGHT_TUN.offDeg : 5) * DEG2RAD;
|
|
var LIGHT_LO = ((typeof LIGHT_TUN.onDeg === 'number') ? LIGHT_TUN.onDeg : -6) * DEG2RAD;
|
|
var LIGHT_SOFT = ((typeof LIGHT_TUN.softDeg === 'number') ? LIGHT_TUN.softDeg : 1.2) * DEG2RAD;
|
|
// Relative Rayleigh scattering at 680 / 550 / 450 nm (1/lambda^4),
|
|
// normalised to the blue channel: the colour distant air takes when it is
|
|
// lit by the same beam that lights the ground.
|
|
var RAYLEIGH = [0.192, 0.451, 1.0];
|
|
var ATMO_AIR_GAIN = (typeof ATMO.airGain === 'number') ? Math.max(0.05, ATMO.airGain) : 0.55;
|
|
var ATMO_HAZE_RESIDUAL = (typeof ATMO.hazeResidual === 'number')
|
|
? Math.max(0.02, Math.min(0.95, ATMO.hazeResidual)) : 0.30;
|
|
var ATMO_DISTANCE_FOG = ATMO.distanceFog !== false;
|
|
var ATMO_MODEL = ATMO.model || {};
|
|
var HAZE_CFG = ATMO.haze || {};
|
|
var ATMO_MODEL_ON = (typeof HAZE_CFG.enabled === 'boolean')
|
|
? HAZE_CFG.enabled
|
|
: (ATMO_MODEL.enabled === true);
|
|
var ATMO_MODEL_HEIGHT_KM = (typeof HAZE_CFG.heightKm === 'number')
|
|
? Math.max(1.0, Math.min(120.0, HAZE_CFG.heightKm))
|
|
: ((typeof ATMO_MODEL.heightKm === 'number')
|
|
? Math.max(1.0, Math.min(80.0, ATMO_MODEL.heightKm)) : 60.0);
|
|
var ATMO_MODEL_HEIGHT_UNITS = ATMO_MODEL_HEIGHT_KM / KM_PER_TILE;
|
|
var ATMO_MODEL_SCALE_HEIGHT_KM = (typeof HAZE_CFG.scaleHeightKm === 'number')
|
|
? Math.max(1.0, Math.min(30.0, HAZE_CFG.scaleHeightKm))
|
|
: ((typeof ATMO_MODEL.scaleHeightKm === 'number')
|
|
? Math.max(1.0, Math.min(30.0, ATMO_MODEL.scaleHeightKm)) : 8.5);
|
|
var ATMO_MODEL_SCALE_HEIGHT_UNITS = ATMO_MODEL_SCALE_HEIGHT_KM / KM_PER_TILE;
|
|
var ATMO_MODEL_DENSITY = (typeof HAZE_CFG.density === 'number')
|
|
? Math.max(0.01, Math.min(5.0, HAZE_CFG.density))
|
|
: ((typeof ATMO_MODEL.density === 'number')
|
|
? Math.max(0.01, Math.min(5.0, ATMO_MODEL.density)) : 0.25);
|
|
var ATMO_MODEL_GRAY_TAU = (typeof HAZE_CFG.grayTau === 'number')
|
|
? Math.max(0.1, Math.min(10.0, HAZE_CFG.grayTau))
|
|
: ((typeof ATMO_MODEL.grayTau === 'number')
|
|
? Math.max(0.1, Math.min(10.0, ATMO_MODEL.grayTau)) : 1.2);
|
|
var ATMO_MODEL_GAIN = (typeof HAZE_CFG.gain === 'number')
|
|
? Math.max(0.1, Math.min(5.0, HAZE_CFG.gain))
|
|
: ((typeof ATMO_MODEL.gain === 'number')
|
|
? Math.max(0.1, Math.min(5.0, ATMO_MODEL.gain)) : 1.0);
|
|
var ATMO_MODEL_MAX_ALPHA = (typeof HAZE_CFG.maxAlpha === 'number')
|
|
? Math.max(0.05, Math.min(1.0, HAZE_CFG.maxAlpha))
|
|
: ((typeof ATMO_MODEL.maxAlpha === 'number')
|
|
? Math.max(0.05, Math.min(1.0, ATMO_MODEL.maxAlpha)) : 0.55);
|
|
var TERRAIN_HAZE_CFG = ATMO.terrainHaze || {};
|
|
var TERRAIN_HAZE_ON = TERRAIN_HAZE_CFG.enabled !== false;
|
|
var TERRAIN_HAZE_SCALE_HEIGHT_KM = (typeof TERRAIN_HAZE_CFG.scaleHeightKm === 'number')
|
|
? Math.max(1.0, Math.min(30.0, TERRAIN_HAZE_CFG.scaleHeightKm)) : 8.5;
|
|
var TERRAIN_HAZE_SCALE_HEIGHT_UNITS = TERRAIN_HAZE_SCALE_HEIGHT_KM / KM_PER_TILE;
|
|
var TERRAIN_HAZE_DENSITY = (typeof TERRAIN_HAZE_CFG.density === 'number')
|
|
? Math.max(0.01, Math.min(5.0, TERRAIN_HAZE_CFG.density)) : 0.22;
|
|
var TERRAIN_HAZE_GAIN = (typeof TERRAIN_HAZE_CFG.gain === 'number')
|
|
? Math.max(0.1, Math.min(5.0, TERRAIN_HAZE_CFG.gain)) : 1.0;
|
|
var TERRAIN_HAZE_MAX_ALPHA = (typeof TERRAIN_HAZE_CFG.maxAlpha === 'number')
|
|
? Math.max(0.05, Math.min(1.0, TERRAIN_HAZE_CFG.maxAlpha)) : 0.65;
|
|
var TERRAIN_HAZE_COLOR = new Float32Array(
|
|
Array.isArray(TERRAIN_HAZE_CFG.color) && TERRAIN_HAZE_CFG.color.length >= 3
|
|
? [TERRAIN_HAZE_CFG.color[0], TERRAIN_HAZE_CFG.color[1], TERRAIN_HAZE_CFG.color[2]]
|
|
: [0.58, 0.64, 0.72]
|
|
);
|
|
var AIR_HAZE_CFG = ATMO.airHaze || TERRAIN_HAZE_CFG || {};
|
|
var AIR_HAZE_ON = (typeof AIR_HAZE_CFG.enabled === 'boolean')
|
|
? AIR_HAZE_CFG.enabled
|
|
: (TERRAIN_HAZE_ON && AIR_HAZE_CFG.enabled !== false);
|
|
var AIR_HAZE_SCALE_HEIGHT_KM = (typeof AIR_HAZE_CFG.scaleHeightKm === 'number')
|
|
? Math.max(1.0, Math.min(30.0, AIR_HAZE_CFG.scaleHeightKm)) : 8.5;
|
|
var AIR_HAZE_SCALE_HEIGHT_UNITS = AIR_HAZE_SCALE_HEIGHT_KM / KM_PER_TILE;
|
|
var AIR_HAZE_DENSITY = (typeof AIR_HAZE_CFG.density === 'number')
|
|
? Math.max(0.01, Math.min(5.0, AIR_HAZE_CFG.density)) : 0.16;
|
|
var AIR_HAZE_GAIN = (typeof AIR_HAZE_CFG.gain === 'number')
|
|
? Math.max(0.1, Math.min(5.0, AIR_HAZE_CFG.gain)) : 1.0;
|
|
var AIR_HAZE_MAX_ALPHA = (typeof AIR_HAZE_CFG.maxAlpha === 'number')
|
|
? Math.max(0.05, Math.min(1.0, AIR_HAZE_CFG.maxAlpha)) : 0.45;
|
|
var AIR_HAZE_COLOR = new Float32Array(
|
|
Array.isArray(AIR_HAZE_CFG.color) && AIR_HAZE_CFG.color.length >= 3
|
|
? [AIR_HAZE_CFG.color[0], AIR_HAZE_CFG.color[1], AIR_HAZE_CFG.color[2]]
|
|
: [0.58, 0.64, 0.72]
|
|
);
|
|
var FRONT_HAZE_CFG = ATMO.frontHaze || AIR_HAZE_CFG || {};
|
|
var FRONT_HAZE_ON = FRONT_HAZE_CFG.enabled !== false;
|
|
var FRONT_HAZE_SCALE_HEIGHT_KM = (typeof FRONT_HAZE_CFG.scaleHeightKm === 'number')
|
|
? Math.max(1.0, Math.min(30.0, FRONT_HAZE_CFG.scaleHeightKm)) : 8.5;
|
|
var FRONT_HAZE_SCALE_HEIGHT_UNITS = FRONT_HAZE_SCALE_HEIGHT_KM / KM_PER_TILE;
|
|
var FRONT_HAZE_DENSITY = (typeof FRONT_HAZE_CFG.density === 'number')
|
|
? Math.max(0.01, Math.min(5.0, FRONT_HAZE_CFG.density)) : 0.22;
|
|
var FRONT_HAZE_GAIN = (typeof FRONT_HAZE_CFG.gain === 'number')
|
|
? Math.max(0.1, Math.min(5.0, FRONT_HAZE_CFG.gain)) : 1.0;
|
|
var FRONT_HAZE_MAX_ALPHA = (typeof FRONT_HAZE_CFG.maxAlpha === 'number')
|
|
? Math.max(0.05, Math.min(1.0, FRONT_HAZE_CFG.maxAlpha)) : 0.65;
|
|
var FRONT_HAZE_COLOR = new Float32Array(
|
|
Array.isArray(FRONT_HAZE_CFG.color) && FRONT_HAZE_CFG.color.length >= 3
|
|
? [FRONT_HAZE_CFG.color[0], FRONT_HAZE_CFG.color[1], FRONT_HAZE_CFG.color[2]]
|
|
: [0.58, 0.64, 0.72]
|
|
);
|
|
var CURV_CFG = (window.CONFIG && window.CONFIG.curvature) || {};
|
|
var CURVATURE_ON = CURV_CFG.enabled !== false;
|
|
var CURV_EXAG = (typeof CURV_CFG.exaggeration === 'number')
|
|
? Math.max(0.25, CURV_CFG.exaggeration) : 2.0;
|
|
var EARTH_RADIUS_UNITS = (typeof CURV_CFG.earthRadiusKm === 'number'
|
|
? Math.max(1000, CURV_CFG.earthRadiusKm) : 6371) / KM_PER_TILE / CURV_EXAG;
|
|
var ATMO_MODEL_BLUE_COLOR = new Float32Array([0.22, 0.42, 0.95]);
|
|
var ATMO_MODEL_GRAY_COLOR = new Float32Array([0.72, 0.76, 0.82]);
|
|
// Integrated starlight + airglow. Physically this is about a millionth of
|
|
// daylight, so it is a stylised floor (CONFIG.atmosphere.starGain) that
|
|
// keeps clouds, contrails and the ground from going fully black on a
|
|
// starlit night.
|
|
var ATMO_CLOUD_SHADOWS = ATMO.cloudShadows !== false;
|
|
var ATMO_CS_DENSITY = (typeof ATMO.cloudShadowDensity === 'number') ? Math.max(0.2, ATMO.cloudShadowDensity) : 1.8;
|
|
var ATMO_CS_FLOOR = (typeof ATMO.cloudShadowFloor === 'number') ? Math.max(0.0, Math.min(0.6, ATMO.cloudShadowFloor)) : 0.06;
|
|
var ATMO_CS_AMBIENT = (typeof ATMO.cloudShadowAmbient === 'number') ? Math.max(0, Math.min(1, ATMO.cloudShadowAmbient)) : 0.65;
|
|
var ATMO_TERRAIN_SHADOWS = ATMO.terrainShadows !== false;
|
|
// how much light a fully terrain-shadowed slope keeps (0 = black)
|
|
var ATMO_TERRAIN_SHADOW_FLOOR = (typeof ATMO.terrainShadowFloor === 'number')
|
|
? Math.max(0, Math.min(1, ATMO.terrainShadowFloor)) : 0.42;
|
|
var ATMO_CLOUD_HAZE = (typeof ATMO.cloudHaze === 'number') ? Math.max(0, Math.min(1, ATMO.cloudHaze)) : 0.22;
|
|
var ATMO_CLOUD_BRIGHT = (typeof ATMO.cloudBrightness === 'number') ? Math.max(0.2, ATMO.cloudBrightness) : 1.0;
|
|
var ATMO_STAR_GAIN = (typeof ATMO.starGain === 'number') ? Math.max(0, ATMO.starGain) : 0.18;
|
|
// Extra share for the ground: a starlit landscape is what makes the night
|
|
// readable, while clouds and contrails stay dimmer.
|
|
var ATMO_STAR_GROUND = (typeof ATMO.starGainGround === 'number') ? Math.max(1, ATMO.starGainGround) : 2.5;
|
|
function starAmbient(altRad) {
|
|
// ramps from the horizon to full at -8 deg so it overlaps the decaying
|
|
// skylight instead of starting after it has already bottomed out
|
|
var dark = Math.max(0, Math.min(1, -altRad / 0.14));
|
|
return ATMO_STAR_GAIN * dark;
|
|
}
|
|
// Airlight of the view column. By day it is sunlit haze; at night the same
|
|
// formula runs on the moon beam, scaled by how much of the moon is lit
|
|
// (new moon = starlight only, full moon = clearly brighter haze).
|
|
function airlightColor(altRad, moonAlt, moonFrac) {
|
|
var beam = beamColor(altRad);
|
|
var kDay = ATMO_AIR_GAIN * skyAmbient(altRad) * beamLuma(altRad);
|
|
var up = Math.max(0, Math.sin(moonAlt || 0));
|
|
var mDiff = ATMO_MOON_GAIN * MOON_LIGHT_GAIN * (moonFrac === undefined ? 0.6 : moonFrac) * up * 0.9;
|
|
var mBeam = beamColor(moonAlt || 0);
|
|
var kMoon = ATMO_AIR_GAIN * mDiff * beamLuma(moonAlt || 0);
|
|
return [RAYLEIGH[0] * (beam[0] * kDay + mBeam[0] * kMoon),
|
|
RAYLEIGH[1] * (beam[1] * kDay + mBeam[1] * kMoon),
|
|
RAYLEIGH[2] * (beam[2] * kDay + mBeam[2] * kMoon)];
|
|
}
|
|
// ---- Theme lighting -----------------------------------------------------
|
|
// The physical model stays the base in every theme: the theme only pulls
|
|
// the beam, the airlight and the moonlight toward its own hue by 'tint'
|
|
// (0 = pure physics, 1 = the theme colour), and gain / nightGain scale the
|
|
// day and night exposure. Configured per theme in window.THEMES[theme].
|
|
// .lighting; a theme without that block is pure physics.
|
|
function hazeColorDynamic(base, air, amb, star, moonI) {
|
|
var target = 0.07 + 1.15 * Math.max(0, amb) + 0.55 * Math.max(0, star)
|
|
+ 0.25 * Math.max(0, moonI);
|
|
target = Math.max(0.06, Math.min(0.72, target));
|
|
var col = mixRgb(base, air, 0.55);
|
|
var lum = 0.2126 * col[0] + 0.7152 * col[1] + 0.0722 * col[2];
|
|
var scale = target / Math.max(0.02, lum);
|
|
scale = Math.max(0.05, Math.min(2.5, scale));
|
|
return [col[0] * scale, col[1] * scale, col[2] * scale];
|
|
}
|
|
function themeColor(v, fallback) {
|
|
if (typeof v === 'number') return hex(v);
|
|
if (typeof v === 'string') {
|
|
var t = v.replace('#', '');
|
|
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
|
|
if (t.length !== 6 || isNaN(parseInt(t, 16))) return fallback;
|
|
var n = parseInt(t, 16);
|
|
return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
|
|
}
|
|
return fallback;
|
|
}
|
|
function mixRgb(a, b, k) {
|
|
return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k];
|
|
}
|
|
function themeLighting() {
|
|
var name = (window.CONFIG && window.CONFIG.theme) || 'ash';
|
|
var th = (window.THEMES && window.THEMES[name]) || null;
|
|
var L = th && th.lighting;
|
|
if (!L) return null;
|
|
var tint = (typeof L.tint === 'number') ? Math.max(0, Math.min(1, L.tint)) : 0.30;
|
|
return {
|
|
tint: tint,
|
|
sun: themeColor(L.sun, [1, 1, 1]),
|
|
sky: themeColor(L.sky, [0.55, 0.65, 0.85]),
|
|
air: themeColor(L.air, [0.45, 0.60, 0.85]),
|
|
moon: themeColor(L.moon, [0.80, 0.85, 0.95]),
|
|
gain: (typeof L.gain === 'number') ? Math.max(0.2, L.gain) : 1,
|
|
nightGain: (typeof L.nightGain === 'number') ? Math.max(0.2, L.nightGain) : 1
|
|
};
|
|
}
|
|
|
|
function lightFactor(altRad) {
|
|
var span = LIGHT_HI - LIGHT_LO;
|
|
if (span <= 0) return 0;
|
|
return Math.max(0, Math.min(1, (LIGHT_HI - altRad) / span));
|
|
}
|
|
|
|
function airMass(altRad) {
|
|
var h = Math.max(altRad, 0.0087) * 57.2957795;
|
|
return 1 / (Math.sin(Math.max(altRad, 0.0087)) + 0.50546 * Math.pow(h + 6.07995, -1.6364));
|
|
}
|
|
var TAU_RAYLEIGH = [0.0085, 0.0141, 0.0315];
|
|
var TAU_OZONE = [0.0020, 0.0700, 0.1400];
|
|
var TAU_LAMBDA = [700 / 550, 1.0, 450 / 550];
|
|
function beamTransmittance(altRad) {
|
|
var m = airMass(altRad), out = [0, 0, 0];
|
|
for (var i = 0; i < 3; i++) {
|
|
var tauM = 0.10 * ATMO_TURBIDITY * Math.pow(TAU_LAMBDA[i], -1.3);
|
|
out[i] = Math.exp(-(TAU_RAYLEIGH[i] + TAU_OZONE[i] + tauM) * m);
|
|
}
|
|
return out;
|
|
}
|
|
var TAU_ZENITH = beamTransmittance(Math.PI / 2);
|
|
// normalised so a sun at the zenith is 1.0 and a low sun only loses the
|
|
// part of the spectrum the atmosphere actually scatters away
|
|
function beamColor(altRad) {
|
|
var t = beamTransmittance(Math.max(altRad, 0.0));
|
|
return [t[0] / TAU_ZENITH[0], t[1] / TAU_ZENITH[1], t[2] / TAU_ZENITH[2]];
|
|
}
|
|
function beamLuma(altRad) {
|
|
var c = beamColor(altRad);
|
|
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
}
|
|
// Earth-Sun distance from the simulated scene date. This is not a scene
|
|
// light source: it only scales the solar irradiance entering the top of
|
|
// the atmosphere (perihelion ~+3.4 %, aphelion ~-3.3 %).
|
|
function sunDistanceFactor(date) {
|
|
var d = date || new Date();
|
|
var year = d.getUTCFullYear();
|
|
var start = Date.UTC(year, 0, 0);
|
|
var day = Math.floor((d.getTime() - start) / 86400000);
|
|
var g = 2 * Math.PI * (day - 3) / 365.24;
|
|
var au = 1.0 - 0.0167 * Math.cos(g);
|
|
return 1.0 / (au * au);
|
|
}
|
|
// Diffuse skylight: grows with sun altitude, then decays through civil
|
|
// twilight (-6 deg) toward the night floor (airglow + starlight).
|
|
function skyAmbient(altRad) {
|
|
var s = Math.sin(altRad);
|
|
// 0.32 at the horizon: real skylight at sunset is around a tenth of
|
|
// noon, and this level has to stay above the starlight floor or the
|
|
// evening would get brighter after sunset.
|
|
var day = 0.32 + 0.68 * Math.pow(Math.min(1, Math.max(0, s) * 2.2), 0.6);
|
|
// decay over ~8 deg per halving instead of 6, so the skylight does not
|
|
// bottom out before the starlight floor takes over
|
|
var tw = s > 0 ? 1 : Math.max(ATMO_NIGHT_FLOOR, Math.pow(2, altRad * 57.2957795 / 8.0));
|
|
return day * tw;
|
|
}
|
|
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, extraSel) {
|
|
var out = [];
|
|
var sel = extraSel || '';
|
|
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),
|
|
box: [la, lo, Math.min(n, la + latStep), Math.min(e, lo + lonStep)],
|
|
q: '[out:json][timeout:90];way["highway"~"' + tagRx + '"]' + sel + '(' +
|
|
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)$';
|
|
// "Side roads" = the through-road tiers below motorway/trunk: primary and
|
|
// secondary. That is what works worldwide - trunk is barely used in France,
|
|
// Italy, Spain, the UK or Poland, where the top non-motorway class is
|
|
// primary, while secondary is the regional tier everywhere (US state and
|
|
// county roads, routes departementales, A-roads, Staats-/Landesstrassen).
|
|
// tertiary/unclassified/residential stay out: they are the mass that used to
|
|
// explode a single Overpass cell to several megabytes until the mirrors
|
|
// timed out and the whole layer came back empty.
|
|
var CAR_SECONDARY_RX = '^(primary|primary_link|secondary|secondary_link)$';
|
|
// Side-road coverage follows the CONFIG location and matches the traffic
|
|
// disc (rangeKm = 200 km ~ 1.8 deg latitude / 2.5 deg longitude at 48 deg N),
|
|
// in cells small enough that no single query explodes.
|
|
var SEC_LAT_SPAN = 1.8, SEC_LON_SPAN = 2.5;
|
|
var SEC_LAT_STEP = 0.3, SEC_LON_STEP = 0.35;
|
|
var SEC_S = Math.max(45.8, Math.round((_baseLat - SEC_LAT_SPAN) * 10) / 10);
|
|
var SEC_N = Math.min(51.5, Math.round((_baseLat + SEC_LAT_SPAN) * 10) / 10);
|
|
var SEC_W = Math.max(7.5, Math.round((_baseLon - SEC_LON_SPAN) * 10) / 10);
|
|
var SEC_E = Math.min(18.5, Math.round((_baseLon + SEC_LON_SPAN) * 10) / 10);
|
|
// Both motorway boxes use the same 1.1 x 1.3 deg grid. The west half used to
|
|
// be ONE 3.3 x 4.0 deg cell: it timed out on every mirror, so everything
|
|
// west of 13.4 E - the whole approach to Munich - was missing while the
|
|
// east half (A3 north, A8 to Linz) loaded fine.
|
|
var CAR_QUERIES = carCells('geo/mw11', CAR_MOTORWAY_RX, 46.8, 9.4, 50.1, 13.4, 1.1, 1.3).concat(
|
|
carCells('geo/mw_e', CAR_MOTORWAY_RX, 46.8, 13.4, 50.1, 17.3, 1.1, 1.3),
|
|
// Side roads around the configured location, in cells small enough that
|
|
// no single query explodes (a 0.55 deg cell here cost 6.5 MB and the
|
|
// mirrors answered 504).
|
|
carCells('geo/sec10', CAR_SECONDARY_RX, SEC_S, SEC_W, SEC_N, SEC_E, SEC_LAT_STEP, SEC_LON_STEP)
|
|
);
|
|
// Drop what can never be seen (the world disc is ~278 km across, so cells
|
|
// far outside it cost quota and add no light), then fill it RING BY RING
|
|
// instead of nearest-first: nearest-first meant the first minutes of traffic
|
|
// sat in a blob under the camera while the rest of the disc was still empty.
|
|
// Cells are grouped into distance bands and taken round-robin, so the whole
|
|
// disc gets lights at the same time.
|
|
(function orderCarQueries() {
|
|
var km = ((window.CONFIG && window.CONFIG.carLights) || {}).rangeKm || 200;
|
|
var kx = 111.32 * Math.cos(_baseLat * Math.PI / 180);
|
|
function dist(b) {
|
|
var la = Math.max(b[0], Math.min(_baseLat, b[2]));
|
|
var lo = Math.max(b[1], Math.min(_baseLon, b[3]));
|
|
return Math.hypot((la - _baseLat) * 111.32, (lo - _baseLon) * kx);
|
|
}
|
|
var keep = CAR_QUERIES.filter(function (e) { return !e.box || dist(e.box) <= km + 25; });
|
|
var BANDS = 6, band = Math.max(1, km / BANDS);
|
|
var rings = [];
|
|
for (var i = 0; i < keep.length; i++) {
|
|
var r = Math.min(BANDS - 1, Math.floor(dist(keep[i].box) / band));
|
|
(rings[r] = rings[r] || []).push(keep[i]);
|
|
}
|
|
// Innermost ring first (it carries most of the visible traffic), then
|
|
// one cell from every further ring in turn.
|
|
rings.sort(function (x, y) { return (x && y) ? y.length - x.length : (x ? 1 : -1); });
|
|
var out = [], done = false, k = 0;
|
|
while (!done) {
|
|
done = true;
|
|
for (var r = 0; r < rings.length; r++) {
|
|
if (rings[r] && k < rings[r].length) { out.push(rings[r][k]); done = false; }
|
|
}
|
|
k++;
|
|
}
|
|
CAR_QUERIES = out;
|
|
})();
|
|
// Same-origin paths through the nginx proxy in default.conf. The public
|
|
// The page must work as a self-contained HTML file, so the default list is
|
|
// the public CORS-enabled mirrors - no server needed. The same-origin nginx
|
|
// paths are a development convenience only: they are used when a probe shows
|
|
// the proxy is actually there (see opProbeProxy), otherwise they are dropped.
|
|
var OP_PUBLIC = [
|
|
'https://overpass-api.de/api/interpreter',
|
|
'https://maps.mail.ru/osm/tools/overpass/api/interpreter'
|
|
];
|
|
var OP_PROXY = ['/overpass/api/interpreter', '/overpass/mail/interpreter'];
|
|
var CAR_ENDPOINTS = OP_PUBLIC.slice();
|
|
var _opProxyChecked = false;
|
|
function opProbeProxy() {
|
|
if (_opProxyChecked) return;
|
|
_opProxyChecked = true;
|
|
if (location.protocol === 'file:') return;
|
|
fetch('/overpass/api/interpreter', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: 'data=' + encodeURIComponent('[out:json][timeout:5];out;')
|
|
}).then(function (r) {
|
|
if (r.status === 200) {
|
|
CAR_ENDPOINTS = OP_PUBLIC.concat(OP_PROXY);
|
|
dbgLog('[clouds3d] overpass: dev proxy available');
|
|
}
|
|
}, function () {});
|
|
}
|
|
// spread first-attempt load across the mirrors per cell index
|
|
CAR_QUERIES.forEach(function (e, i) { e.start = i % CAR_ENDPOINTS.length; });
|
|
opProbeProxy(); // dev-only: switch to the nginx mirror if this page has one
|
|
|
|
// ---- 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; }
|
|
}
|
|
// Parse once and reuse the object: validating and then re-parsing the same
|
|
// multi-megabyte cell was a needless main-thread cost per cycle.
|
|
function parseOverpass(t) {
|
|
if (!t) return null;
|
|
try {
|
|
var d = JSON.parse(t);
|
|
return (d && Array.isArray(d.elements)) ? d : null;
|
|
} catch (e) { return null; }
|
|
}
|
|
// Cell manifest: one tiny record that maps every cached Overpass cell to the
|
|
// time it was stored. The cached fast paths used to read every raw cell
|
|
// (hundreds of multi-megabyte JSON blobs) just to prove the chain cache is
|
|
// still current - the manifest answers the same question in one small read.
|
|
// Records are only ever written after parseOverpass() accepted them, so a
|
|
// poisoned cell cannot enter the manifest.
|
|
var cellManifest = null;
|
|
var _manifestPutAt = 0;
|
|
function loadManifest(db) {
|
|
return tileDbGet(db, 'geo/cells_v1').then(function (rec) {
|
|
if (!cellManifest) cellManifest = {};
|
|
// merge, never replace: notes taken this session may not be on disk
|
|
// yet (writes are throttled) and must not be lost.
|
|
if (rec && rec.map) {
|
|
for (var k in rec.map) {
|
|
if (!cellManifest[k] || cellManifest[k] < rec.map[k]) cellManifest[k] = rec.map[k];
|
|
}
|
|
}
|
|
return cellManifest;
|
|
});
|
|
}
|
|
function manifestNote(id, savedAt) {
|
|
if (!cellManifest) cellManifest = {};
|
|
if (cellManifest[id] !== savedAt) {
|
|
cellManifest[id] = savedAt;
|
|
manifestPersist(false);
|
|
}
|
|
}
|
|
function manifestPersist(force) {
|
|
if (!cellManifest) return;
|
|
var now = Date.now();
|
|
if (!force && now - _manifestPutAt < 3000) return;
|
|
_manifestPutAt = now;
|
|
openTileDB().then(function (db) {
|
|
if (db) tileDbPut(db, { id: 'geo/cells_v1', map: cellManifest, savedAt: now });
|
|
});
|
|
}
|
|
function _opPump() {
|
|
var now = Date.now();
|
|
while (_opActive < 1 && _opQueue.length && now - _opLast >= 2500) {
|
|
_opActive++; _opLast = now; _opQueue.shift()();
|
|
}
|
|
if (_opQueue.length) setTimeout(_opPump, Math.max(50, 2550 - (Date.now() - _opLast)));
|
|
}
|
|
function opGate() {
|
|
return new Promise(function (go) {
|
|
_opQueue.push(function () {
|
|
go(function () { _opActive--; _opPump(); });
|
|
});
|
|
_opPump();
|
|
});
|
|
}
|
|
var _opCool = {};
|
|
function opCool(ep, ms) { _opCool[ep] = Date.now() + ms; }
|
|
function opPick(startIdx, attempt) {
|
|
var n = CAR_ENDPOINTS.length, now = Date.now();
|
|
for (var i = 0; i < n; i++) {
|
|
var ep = CAR_ENDPOINTS[((startIdx || 0) + (attempt || 0) + i) % n];
|
|
if ((_opCool[ep] || 0) <= now) return ep;
|
|
}
|
|
return CAR_ENDPOINTS[((startIdx || 0) + (attempt || 0)) % n]; // all cooling
|
|
}
|
|
function opQuery(q, startIdx, attempt) {
|
|
var release;
|
|
return opGate().then(function (r) {
|
|
release = r;
|
|
var ep = opPick(startIdx, attempt);
|
|
var ctl = null, timer = null;
|
|
try { ctl = new AbortController(); timer = setTimeout(function () { ctl.abort(); }, 125000); } 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) {
|
|
// a mirror that pushed back stays out of the rotation for a
|
|
// while - hammering it is what gets a whole network banned
|
|
opCool(ep, resp.status === 429 ? 180000 : 60000);
|
|
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);
|
|
opCool(ep, 45000);
|
|
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 * 2; // two 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, 5000 + Math.random() * 5000); })
|
|
.then(function () { return go(a + 1); });
|
|
});
|
|
}
|
|
return go(0);
|
|
}
|
|
// Tuning lives in CONFIG.carLights; everything there is given in metres or
|
|
// screen pixels and converted to world units here.
|
|
var CAR_TUN = (window.CONFIG && window.CONFIG.carLights) || {};
|
|
function carMetres(m) { return m / 1000.0 / KM_PER_TILE; }
|
|
// ---- Which side of the road do vehicles drive on? ----------------------
|
|
// Which lane a vehicle occupies depends on the country, not on the map: in
|
|
// right-hand traffic a car drives on the RIGHT side of its own direction of
|
|
// travel, in left-hand traffic on the LEFT. The sign is derived from the
|
|
// configured location, so the same scene shows correct lanes anywhere.
|
|
// CONFIG.carLights.trafficSide overrides the lookup with 'left' or 'right'.
|
|
// The boxes list the countries/territories that drive on the LEFT;
|
|
// everything else defaults to right-hand traffic. Mainland China drives on
|
|
// the right - only Hong Kong and Macau are listed.
|
|
var LEFT_TRAFFIC_BOXES = [
|
|
// [name, latMin, latMax, lonMin, lonMax]
|
|
['United Kingdom / Ireland', 50.0, 59.5, -11.0, 2.2],
|
|
['Malta', 35.7, 36.2, 14.1, 14.7],
|
|
['Cyprus', 34.4, 35.8, 32.0, 34.7],
|
|
['Japan', 30.0, 46.0, 128.0, 146.0],
|
|
['Australia', -44.0, -10.0, 112.0, 154.0],
|
|
['New Zealand', -47.5, -34.0, 166.0, 179.0],
|
|
['Papua New Guinea', -11.5, -1.0, 140.0, 148.5],
|
|
['Fiji', -19.5, -12.0, 174.0, 180.0],
|
|
['Samoa', -15.5, -13.0, -173.5, -171.0],
|
|
['India / Nepal / Bhutan', 6.0, 36.0, 68.0, 92.5],
|
|
['Bangladesh', 20.0, 27.0, 88.0, 92.7],
|
|
['Pakistan', 23.0, 37.0, 61.0, 77.0],
|
|
['Sri Lanka', 5.5, 10.0, 79.5, 82.0],
|
|
['Thailand', 5.5, 20.6, 97.0, 105.8],
|
|
['Malaysia', 0.8, 7.0, 99.5, 119.5],
|
|
['Singapore', 1.1, 1.6, 103.5, 104.2],
|
|
['Indonesia', -11.5, 6.0, 95.0, 141.0],
|
|
['Hong Kong / Macau', 21.9, 22.8, 113.4, 114.6],
|
|
// Southern + Eastern Africa: Madagascar (right-hand traffic) sits east
|
|
// of this box, Ethiopia and Somalia north of it
|
|
['Southern & Eastern Africa', -35.5, -11.0, 11.0, 41.5],
|
|
['Mauritius', -20.7, -10.0, 57.3, 63.6],
|
|
['Guyana / Suriname', 1.0, 9.0, -61.0, -53.5]
|
|
];
|
|
function trafficSideFor(lat, lon) {
|
|
var forced = CAR_TUN.trafficSide;
|
|
if (forced === 'left') return -1;
|
|
if (forced === 'right') return 1;
|
|
for (var i = 0; i < LEFT_TRAFFIC_BOXES.length; i++) {
|
|
var b = LEFT_TRAFFIC_BOXES[i];
|
|
if (lat >= b[1] && lat <= b[2] && lon >= b[3] && lon <= b[4]) return -1;
|
|
}
|
|
return 1;
|
|
}
|
|
// +1 = right-hand traffic, -1 = left-hand traffic
|
|
var CAR_TRAFFIC = trafficSideFor(_baseLat, _baseLon);
|
|
var CAR_EVERY = 0.16; // world units (~2 km) between ribbon nodes
|
|
// The ribbon is only the carriageway line now - the lights are points.
|
|
var CAR_W = carMetres((CAR_TUN.roadWidthM || 24) / 2); // ribbon half width
|
|
var CAR_Y = 0.004; // lift above terrain surface (~50 m)
|
|
// Ribbon: thin line with a small pixel floor so it never disappears.
|
|
var CAR_BAND_PX = CAR_TUN.bandPx || 1.2;
|
|
var CAR_BAND_BRIGHT = CAR_TUN.bandBrightness || 0.3;
|
|
// Lights: point sprites with a guaranteed pixel diameter, like the city dots.
|
|
var CAR_MIN_PX = CAR_TUN.minPx || 3.6;
|
|
var CAR_MAX_PX = Math.max(CAR_MIN_PX, CAR_TUN.maxPx || 10);
|
|
var CAR_BOOST = CAR_TUN.boost || 5;
|
|
// Real distance between the two traffic directions: each one sits half of it
|
|
// off the way centreline, on the side its country's rule gives it. A fixed
|
|
// WORLD offset (not a screen gap) so the streams never drift apart with the
|
|
// camera distance - they merge into one line far away, as they do in reality.
|
|
var CAR_LANE_W = carMetres((CAR_TUN.laneM || 8) / 2);
|
|
// Screen floor for that offset: below it the two directions would collapse
|
|
// into one stream and the traffic would read as a single road.
|
|
var CAR_LANE_PX = CAR_TUN.lanePx || 2.6;
|
|
var CAR_BRIGHT = CAR_TUN.brightness || 1.3;
|
|
var CAR_SPEED = CAR_TUN.speed || 1;
|
|
var CAR_DENS = CAR_TUN.density || 1;
|
|
// Gaussian spread of traffic around the motorway network: sigma (spreadKm)
|
|
// and the floor that distant side roads keep (spreadFloor).
|
|
var CAR_SIGMA = carMetres((CAR_TUN.spreadKm || 25) * 1000);
|
|
var CAR_FLOOR = CAR_TUN.spreadFloor || 0.18;
|
|
// Traffic disc around the configured location, and the distance fade that
|
|
// belongs to it: lights start fading at fadeKm and are gone at fadeSpanKm
|
|
// past that. Keeping the fade just outside the disc means the whole traffic
|
|
// region is readable and nothing ends in a hard cut.
|
|
var CAR_RANGE = carMetres((CAR_TUN.rangeKm || 200) * 1000);
|
|
var CAR_FADE_A = carMetres((CAR_TUN.fadeKm || 150) * 1000);
|
|
var CAR_FADE_B = carMetres(((CAR_TUN.fadeKm || 150) + (CAR_TUN.fadeSpanKm || 60)) * 1000);
|
|
// 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 = carMetres(CAR_TUN.dashM || 620);
|
|
// Slot rate = real driving speed / vehicle spacing: 620 m spacing at ~34 m/s
|
|
// is 0.055 slots per second (~120 km/h). Side roads are spaced wider and
|
|
// drive slower -> 0.017 slots/s (~68 km/h at 1116 m spacing). Their peak
|
|
// density sits below the motorway peak and the gaussian falloff shapes it
|
|
// further by distance to the nearest motorway.
|
|
var CAR_M_SPEED = 0.055 * CAR_SPEED, CAR_M_DENS = 0.85 * CAR_DENS;
|
|
var CAR_B_DASH = CAR_M_DASH * 1.15, CAR_B_SPEED = 0.017 * CAR_SPEED, CAR_B_DENS = 1.0 * CAR_DENS;
|
|
var carGeo = null; // { motorway: packedChains, secondary: packedChains }
|
|
var carVboM = null, carCountM = 0;
|
|
var carVboB = null, carCountB = 0;
|
|
var carPtsVboM = null, carPtsCountM = 0;
|
|
var carPtsVboB = null, carPtsCountB = 0;
|
|
var carProg = null, carAttribs = null, carUniforms = null;
|
|
var carPtsProg = null, carPtsAttribs = null, carPtsUniforms = 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 doneCells = 0, totalCells = CAR_QUERIES.length;
|
|
dbgLog('[clouds3d] road cells: starting 0/' + totalCells);
|
|
function progCell(cached) {
|
|
doneCells++;
|
|
if (doneCells % 10 === 0 || doneCells === totalCells) {
|
|
dbgLog('[clouds3d] road cells: ' + doneCells + '/' + totalCells +
|
|
(cached ? ' (cached)' : ' (fresh ' + fresh + ')'));
|
|
}
|
|
}
|
|
carRaw.motorway.length = 0;
|
|
carRaw.secondary.length = 0;
|
|
function parseWays(data) {
|
|
(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'
|
|
: (/^(primary|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 };
|
|
carRaw[key].push(rp);
|
|
}
|
|
});
|
|
}
|
|
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) {
|
|
var cached = parseOverpass(rec.json);
|
|
if (cached) {
|
|
entry._cached = true;
|
|
fpParts.push(entry.id + ':' + rec.savedAt);
|
|
manifestNote(entry.id, rec.savedAt);
|
|
progCell(true);
|
|
return cached;
|
|
}
|
|
}
|
|
return opTry(entry.q, entry.start).then(function (txt) {
|
|
var obj = parseOverpass(txt);
|
|
if (!obj) return null;
|
|
if (db) {
|
|
var ts = Date.now();
|
|
fpParts.push(entry.id + ':' + ts);
|
|
manifestNote(entry.id, ts);
|
|
tileDbPut(db, { id: entry.id, json: txt, savedAt: ts });
|
|
progCell(false);
|
|
}
|
|
return obj;
|
|
});
|
|
});
|
|
}).then(function (obj) {
|
|
if (obj) {
|
|
parseWays(obj);
|
|
if (!entry._cached) fresh++;
|
|
// Stitching and ribbon geometry run in the geo worker. The
|
|
// debounce collapses a burst of arriving cells into ONE
|
|
// build, so the lights still grow region by region while the
|
|
// render loop keeps running at full frame rate.
|
|
if (!entry._cached || !carGeo) kickCarBuild(true);
|
|
}
|
|
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 -> hand the stored chains straight to the
|
|
// worker for geometry. No stitching and no raw-JSON parsing at all.
|
|
openTileDB().then(function (db) {
|
|
if (!db) return false;
|
|
return loadManifest(db).then(function (map) {
|
|
return tileDbGet(db, 'geo/roadchains_v1').then(function (rec) {
|
|
if (!rec || !rec.mCnts || !rec.ids) return false;
|
|
// Valid as long as the chains were built from the CURRENT query
|
|
// set. Individual raw cells may be missing right now - a mirror
|
|
// that refuses us must not blank a network we already have.
|
|
var sameSet = rec.ids.length === CAR_QUERIES.length && CAR_QUERIES.every(function (e) {
|
|
return rec.ids.indexOf(e.id) >= 0;
|
|
});
|
|
if (!sameSet) return false;
|
|
// The manifest lists the cached cells and their timestamps, so
|
|
// currency is checked without touching a single raw cell.
|
|
var newest = 0, missing = 0, total = CAR_QUERIES.length;
|
|
for (var i = 0; i < CAR_QUERIES.length; i++) {
|
|
var at = map[CAR_QUERIES[i].id];
|
|
if (!at) missing++;
|
|
else if (at > newest) newest = at;
|
|
}
|
|
// Reuse only when the raw set is essentially complete. A
|
|
// partial build must stay rebuildable, otherwise a network
|
|
// that was assembled while the mirrors were banning us would
|
|
// be replayed forever and never grow.
|
|
if (newest > rec.savedAt) return false;
|
|
if (missing > Math.max(1, total * 0.1)) {
|
|
dbgLog('[clouds3d] car layer: ' + missing + '/' + total +
|
|
' cells missing - rebuilding instead of replaying the cache');
|
|
return false;
|
|
}
|
|
carGeo = { motorway: { cnts: Int32Array.from(rec.mCnts), pts: b64ToF32(rec.mData) },
|
|
secondary: { cnts: Int32Array.from(rec.bCnts), pts: b64ToF32(rec.bData) } };
|
|
kickCarBuild(false);
|
|
dbgLog('[clouds3d] car layer: ' + carGeo.motorway.cnts.length +
|
|
' motorway + ' + carGeo.secondary.cnts.length + ' chains (from cache' +
|
|
(missing ? ', ' + missing + ' cells unavailable' : '') + ')');
|
|
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;
|
|
manifestPersist(true);
|
|
carPersistFp = geoFp(fpParts);
|
|
carCycleFp = carPersistFp;
|
|
kickCarBuild(true);
|
|
dbgLog('[clouds3d] car layer: ' + carRaw.motorway.length +
|
|
' motorway + ' + carRaw.secondary.length +
|
|
' secondary polylines queued (' + fresh + ' cells re-fetched)');
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- 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; // packed Float32Array [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 = [];
|
|
var doneCells = 0, totalCells = 0; // totalCells set once the cell list exists
|
|
function progCell(cached) {
|
|
doneCells++;
|
|
if (doneCells % 5 === 0 || doneCells === totalCells) {
|
|
dbgLog('[clouds3d] place cells: ' + doneCells + '/' + totalCells +
|
|
(cached ? ' (cached)' : ' (fresh ' + fresh + ')'));
|
|
}
|
|
}
|
|
// 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++;
|
|
}
|
|
}
|
|
totalCells = cells.length;
|
|
dbgLog('[clouds3d] place cells: starting 0/' + totalCells);
|
|
var list = [];
|
|
var seenB = {}; // bucket -> index in list: dedupe node/way/rel of one place
|
|
function parse(data) {
|
|
(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 publish() {
|
|
cityGeo = packPlaces(list);
|
|
kickCityBuild();
|
|
// settlement weight feeds the car density field, so a fresh place
|
|
// list must re-shape the traffic too
|
|
if (carGeo) kickCarBuild(false);
|
|
}
|
|
function loadCell(entry) {
|
|
return openTileDB().then(function (db) {
|
|
return tileDbGet(db, entry.id).then(function (rec) {
|
|
if (rec && rec.json) {
|
|
var cached = parseOverpass(rec.json);
|
|
if (cached) {
|
|
entry._cached = true;
|
|
fpParts.push(entry.id + ':' + rec.savedAt);
|
|
manifestNote(entry.id, rec.savedAt);
|
|
progCell(true);
|
|
return cached;
|
|
}
|
|
}
|
|
return opTry(entry.q, entry.start).then(function (txt) {
|
|
var obj = parseOverpass(txt);
|
|
if (!obj) return null;
|
|
if (db) {
|
|
var ts = Date.now();
|
|
fpParts.push(entry.id + ':' + ts);
|
|
manifestNote(entry.id, ts);
|
|
tileDbPut(db, { id: entry.id, json: txt, savedAt: ts });
|
|
progCell(false);
|
|
}
|
|
return obj;
|
|
});
|
|
});
|
|
}).then(function (obj) {
|
|
if (obj) {
|
|
var before = list.length;
|
|
parse(obj);
|
|
if (list.length > before)
|
|
dbgLog('[clouds3d] place cell ' + entry.id + ': +' +
|
|
(list.length - before) + ' places (total ' + list.length + ')');
|
|
if (!entry._cached) fresh++;
|
|
// dot geometry is generated in the geo worker; the debounce
|
|
// keeps a burst of cells to a single build
|
|
if (!entry._cached || !cityGeo) publish();
|
|
}
|
|
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 loadManifest(db).then(function (map) {
|
|
return tileDbGet(db, 'geo/placecache_v1').then(function (rec) {
|
|
if (!rec || !rec.cnt || !rec.fp) return false;
|
|
var parts = [];
|
|
for (var i = 0; i < cells.length; i++) {
|
|
var at = map[cells[i].id];
|
|
if (!at) return false;
|
|
parts.push(cells[i].id + ':' + at);
|
|
}
|
|
if (geoFp(parts) !== rec.fp) return false;
|
|
cityGeo = b64ToF32(rec.data);
|
|
kickCityBuild();
|
|
if (carGeo) kickCarBuild(false);
|
|
dbgLog('[clouds3d] settlements: ' + rec.cnt + ' 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;
|
|
manifestPersist(true);
|
|
publish();
|
|
openTileDB().then(function (db) {
|
|
if (!db) return;
|
|
tileDbPut(db, { id: 'geo/placecache_v1', fp: geoFp(fpParts),
|
|
cnt: list.length, data: f32ToB64(packPlaces(list)), savedAt: Date.now() });
|
|
});
|
|
dbgLog('[clouds3d] settlements: ' + list.length + ' places queued for build' +
|
|
(terrainSample ? '' : ' (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;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform float uGrow;', // half width needed per unit distance (px floor)',
|
|
'uniform float uHalfW;', // ribbon half width baked into the geometry',
|
|
'varying float vLen;',
|
|
'varying float vSide;',
|
|
'varying float vDens;',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'varying float vHw;', // ribbon half width in world units (= 2 x dot radius)',
|
|
'void main() {',
|
|
'vLen = aMeta.x;',
|
|
'vSide = aMeta.y;',
|
|
'vDens = aDens;',
|
|
'vec3 pos = aPos;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'pos.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vec3 toCam = uCamPos - pos;',
|
|
'vDist = length(toCam);',
|
|
// Travel direction of a ribbon half is -tangent (see the flow sign
|
|
// note in the fragment shader). The tangent is IDENTICAL on both
|
|
// edges of a segment, so this dot product interpolates across the
|
|
// ribbon without a sign flip; the per-lane sign is applied in the
|
|
// fragment shader from vSide. Mixing the two lanes' signs here is
|
|
// what used to wash out headlight vs taillight colours.
|
|
'vApproach = dot(vec3(aMeta.z, 0.0, aMeta.w), normalize(toCam));',
|
|
// The ribbon is only the ROAD line now - it stays thin (bandPx) so
|
|
// it follows the real carriageway instead of swallowing the
|
|
// surroundings at distance. Vehicle lights are separate point
|
|
// sprites (CARP_* shaders); widening this band used to paint the
|
|
// A1 into the Attersee because a 3.6 px floor at 70 km is ~640 m.
|
|
'vHw = max(uHalfW, uGrow * vDist);',
|
|
'vec3 perp = vec3(-aMeta.w, 0.0, aMeta.z) * aMeta.y;',
|
|
'vec3 wp = pos + perp * (vHw - uHalfW);',
|
|
'gl_Position = uProj * uView * vec4(wp, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CAR_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'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 uBright;', // band brightness (CONFIG.carLights.bandBrightness)',
|
|
'uniform float uFadeA;', // distance where the fade starts',
|
|
'uniform float uFadeB;', // distance where it is fully faded',
|
|
'varying float vLen;',
|
|
'varying float vSide;',
|
|
'varying float vDens;',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'varying float vHw;', // ribbon half width in world units',
|
|
'void main() {',
|
|
// per-fragment density = global curve x local falloff
|
|
'float dens = uDens * vDens;',
|
|
'if (dens < 0.02) discard;',
|
|
'float df = 1.0 - smoothstep(uFadeA, uFadeB, vDist);',
|
|
'if (df <= 0.0) discard;',
|
|
// The ribbon is the road itself: a thin carriageway glow. This is',
|
|
// what a road looks like when the individual vehicle points are',
|
|
// closer together than one light - the "bar" along the A-net.',
|
|
'float body = 1.0 - smoothstep(0.0, 1.0, abs(vSide));',
|
|
'float a = dens * body * uNight * df * uBright;',
|
|
'if (a <= 0.002) discard;',
|
|
'gl_FragColor = vec4(vec3(1.0, 0.82, 0.58) * a, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// ---- Vehicle lights: real point sprites ----------------------------------
|
|
// One gl.POINTS pass per road class. Positions come from the geo worker
|
|
// (one point per vehicle slot per direction, walked along the chain at the
|
|
// class spacing), so a light is a round sprite with a guaranteed pixel
|
|
// floor - like the city dots - and never a band that swallows the terrain
|
|
// around the road.
|
|
var CARP_VERT_SRC = [
|
|
'attribute vec3 aPos;',
|
|
'attribute vec4 aMeta;', // xy: unit tangent, z: direction (+/-1), w: chain phase',
|
|
'attribute float aDens;', // local density scale (secondary falloff)',
|
|
'attribute float aSeed;', // per-vehicle seed (spacing jitter + lifecycle)',
|
|
'attribute float aLen;', // length of this vehicle's own chain',
|
|
'uniform mat4 uProj;',
|
|
'uniform mat4 uView;',
|
|
'uniform vec3 uCamPos;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform float uTime;',
|
|
'uniform float uSpeed;', // slots per second (frame flow)',
|
|
'uniform float uDash;', // vehicle spacing (world units)',
|
|
'uniform float uLaneW;', // world offset between the two directions',
|
|
'uniform float uLanePx;', // screen floor for that offset',
|
|
'uniform float uPxW;', // world units per pixel at unit distance',
|
|
'uniform float uTraffic;', // +1 right-hand traffic, -1 left-hand traffic',
|
|
'uniform float uMinPx;', // guaranteed light diameter',
|
|
'uniform float uMaxPx;', // ceiling for a light right above',
|
|
'uniform float uBoost;', // extra size close to the camera',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'varying float vSeed;',
|
|
'varying float vDir;',
|
|
'varying float vDens;',
|
|
'void main() {',
|
|
'float dir = aMeta.z;',
|
|
'vDens = aDens;',
|
|
'vSeed = aSeed;',
|
|
'vDir = dir;',
|
|
// Whole columns of traffic slide together: the phase is shared by a',
|
|
// whole chain, so when it wraps every vehicle lands on the slot its',
|
|
// neighbour just left. The constant per-vehicle jitter keeps the',
|
|
// spacing natural without ever breaking that continuity.',
|
|
// A vehicle never travels farther than its own chain: on a short',
|
|
// side road it drives the length of that road and re-enters at the',
|
|
// start, instead of sliding off the end into the terrain.',
|
|
'float span = min(uDash, aLen);',
|
|
'float off = fract(uTime * uSpeed + aMeta.w) * span + (aSeed - 0.5) * span * 0.12;',
|
|
'vec3 base = aPos - vec3(aMeta.x, 0.0, aMeta.y) * dir * off;',
|
|
'vec3 pos = base;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'pos.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vec3 toCam = uCamPos - pos;',
|
|
'vDist = length(toCam);',
|
|
'vApproach = dot(vec3(aMeta.x, 0.0, aMeta.y), normalize(toCam));',
|
|
// perp is the RIGHT of the digitisation tangent. This point travels',
|
|
// along -tangent*dir, so the side of the road that belongs to it is',
|
|
// -perp*dir in right-hand traffic and +perp*dir in left-hand traffic.',
|
|
// The two directions really are a few metres apart, so close to the',
|
|
// camera the offset is the true world distance. Farther away 8 m is',
|
|
// far below one pixel and both streams would collapse onto a single',
|
|
// line, so the offset gets a screen floor: the oncoming headlights',
|
|
// and the departing taillights stay readable as two streams at any',
|
|
// distance without ever growing into a gap wider than the road.',
|
|
'vec3 perp = vec3(-aMeta.y, 0.0, aMeta.x) * (-dir * uTraffic);',
|
|
'float gap = max(uLaneW, uLanePx * uPxW * vDist);',
|
|
'gl_PointSize = clamp(uMinPx + uBoost / max(vDist, 0.35), uMinPx, uMaxPx);',
|
|
'gl_Position = uProj * uView * vec4(pos + perp * gap, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CARP_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform float uTime;',
|
|
'uniform float uNight;',
|
|
'uniform float uSunAlt;', // current sun altitude (radians)
|
|
'uniform float uLightHi;', // altitude where no car has its lights on
|
|
'uniform float uLightLo;', // altitude where every car has them on
|
|
'uniform float uLightSoft;', // width of the individual switch
|
|
'uniform float uDens;', // global vehicle density (0..1)',
|
|
'uniform float uBright;', // overall light intensity',
|
|
'uniform float uFadeA;', // distance where the fade starts',
|
|
'uniform float uFadeB;', // distance where it is fully faded',
|
|
'varying float vApproach;',
|
|
'varying float vDist;',
|
|
'varying float vSeed;',
|
|
'varying float vDir;',
|
|
'varying float vDens;',
|
|
'float hash1(float s) {',
|
|
'return fract(sin(s * 12.9898) * 43758.5453);',
|
|
'}',
|
|
'void main() {',
|
|
'float df = 1.0 - smoothstep(uFadeA, uFadeB, vDist);',
|
|
'if (df <= 0.0) discard;',
|
|
'vec2 pc = gl_PointCoord - 0.5;',
|
|
'float d = length(pc) * 2.0;',
|
|
// hot core plus a wide faint halo: a round light at any size',
|
|
'float core = exp(-d * d * 4.0);',
|
|
'float halo = (1.0 - smoothstep(0.0, 1.0, d)) * 0.30;',
|
|
'float g = max(core, halo);',
|
|
'if (g <= 0.004) discard;',
|
|
// soft occupancy + per-vehicle life cycle: a car fades in as the',
|
|
// rush-hour curve crosses its threshold, drives for a random while',
|
|
// and rests again - no hard popping, no fixed pattern',
|
|
'float dens = uDens * vDens;',
|
|
'float h = hash1(vSeed * 7.31 + 17.3);',
|
|
'float vis = smoothstep(h - 0.16, h + 0.16, dens);',
|
|
'if (vis <= 0.0) discard;',
|
|
'float life = 700.0 + 2400.0 * hash1(vSeed * 0.371 + 9.4);',
|
|
'float rest = 60.0 + 900.0 * hash1(vSeed * 0.617 + 3.2);',
|
|
'float per = life + rest;',
|
|
'float age = mod(uTime + 3607.0 * hash1(vSeed * 0.913 + 7.7), per);',
|
|
'float on = smoothstep(0.0, 25.0, age) * (1.0 - smoothstep(life - 25.0, life, age));',
|
|
'float inten = on * vis;',
|
|
'if (inten <= 0.001) discard;',
|
|
'inten = pow(inten, 1.25) * uBright;',
|
|
// tangent/view dot without a lane sign, sign applied here: toward',
|
|
// the camera = white headlights, away = red taillights',
|
|
'float approach = -vApproach * vDir;',
|
|
'vec3 col = mix(vec3(1.0, 0.12, 0.08), vec3(1.0, 0.96, 0.88),',
|
|
'smoothstep(-0.1, 0.1, approach));',
|
|
// Switching on/off individually: this vehicle's own threshold sits
|
|
// somewhere in the twilight window, so the A-net lights up piece by
|
|
// piece in the evening and goes dark piece by piece in the morning.
|
|
'float thr = mix(uLightHi, uLightLo, hash1(vSeed * 1.618 + 5.13));',
|
|
'float lit = 1.0 - smoothstep(thr - uLightSoft, thr + uLightSoft, uSunAlt);',
|
|
'if (lit <= 0.001) discard;',
|
|
'gl_FragColor = vec4(col * inten * g * lit * df, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// ---- Off-main-thread geo build -------------------------------------------
|
|
// Chain stitching, the motorway distance field and the car/settlement
|
|
// ribbon geometry are pure functions of plain data, so they run in a Web
|
|
// Worker. This is the freeze fix: a full re-merge of the accumulated road
|
|
// network used to block the main thread for tens of seconds every time an
|
|
// Overpass cell landed, which stalled requestAnimationFrame and froze the
|
|
// whole scene until the last cell was cached.
|
|
// The worker source is an inline string (Blob URL), so build.py's
|
|
// single-file bundle still needs no sibling file, and the same string is
|
|
// compiled into main-thread fallbacks when Worker/Blob is unavailable.
|
|
//
|
|
// Transfer formats (packed, so nothing clones per-point objects):
|
|
// ways { cnts, pts [x,z,d...], dirs [hx,hz,tx,tz...] }
|
|
// chains { cnts, pts [x,z,d...] }
|
|
// verts Float32Array (car: 8 floats/vertex, city: 5 floats/vertex)
|
|
var GEO_ALGO_SRC = `
|
|
function unpackWays(w) {
|
|
var out = [], k = 0;
|
|
for (var i = 0; i < w.cnts.length; i++) {
|
|
var arr = [];
|
|
for (var j = 0; j < w.cnts[i]; j++) {
|
|
arr.push({ x: w.pts[k], z: w.pts[k + 1], d: w.pts[k + 2] }); k += 3;
|
|
}
|
|
arr.headD = { x: w.dirs[i * 4], z: w.dirs[i * 4 + 1] };
|
|
arr.tailD = { x: w.dirs[i * 4 + 2], z: w.dirs[i * 4 + 3] };
|
|
out.push(arr);
|
|
}
|
|
return out;
|
|
}
|
|
function unpackChains(c) {
|
|
var out = [], k = 0;
|
|
for (var i = 0; i < c.cnts.length; i++) {
|
|
var arr = [];
|
|
for (var j = 0; j < c.cnts[i]; j++) {
|
|
arr.push({ x: c.pts[k], z: c.pts[k + 1], d: c.pts[k + 2] }); k += 3;
|
|
}
|
|
out.push(arr);
|
|
}
|
|
return out;
|
|
}
|
|
function packChains(chains) {
|
|
var i, j, n = chains.length, total = 0;
|
|
var cnts = new Int32Array(n);
|
|
for (i = 0; i < n; i++) { cnts[i] = chains[i].length; total += chains[i].length; }
|
|
var pts = new Float32Array(total * 3), k = 0;
|
|
for (i = 0; i < n; i++)
|
|
for (j = 0; j < chains[i].length; j++) {
|
|
var p = chains[i][j];
|
|
pts[k++] = p.x; pts[k++] = p.z; pts[k++] = p.d;
|
|
}
|
|
return { cnts: cnts, pts: pts };
|
|
}
|
|
// Terrain height on the smoothed mesh grid (the same grid the mesh was
|
|
// built from, so ribbons and settlement dots sit exactly on the surface).
|
|
function terrainH(ts, x, z) {
|
|
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];
|
|
return (a * (1 - u) + b2 * u) * (1 - v) + (c * (1 - u) + d * u) * v;
|
|
}
|
|
// 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(chains, ts) {
|
|
var BIG = 1e9, i, j, k, d;
|
|
var f = new Float32Array(ts.gw * ts.gh);
|
|
f.fill(BIG);
|
|
for (var p = 0; p < chains.length; p++) {
|
|
var pts = chains[p];
|
|
for (var q = 0; q < pts.length; q++) {
|
|
i = Math.round((pts[q].x + ts.halfX) / ts.s);
|
|
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 (j = 0; j < ts.gh; j++) {
|
|
for (i = 0; i < ts.gw; i++) {
|
|
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 (j = ts.gh - 1; j >= 0; j--) {
|
|
for (i = ts.gw - 1; i >= 0; i--) {
|
|
k = j * ts.gw + i; d = f[k];
|
|
if (i < ts.gw - 1) d = Math.min(d, f[k + 1] + 1);
|
|
if (j < ts.gh - 1) {
|
|
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] = Math.min(f[k], d);
|
|
}
|
|
}
|
|
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.
|
|
// 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.
|
|
// Junction and pair bookkeeping uses NUMERIC keys in Map/Set: the previous
|
|
// string-keyed version allocated millions of temporary keys per merge, and
|
|
// that allocation plus the resulting GC pressure was a large part of the
|
|
// freeze this refactor removes.
|
|
// Progress out of the worker: the merge runs over tens of thousands of ways
|
|
// and the fetch cycles over hundreds of cells, so both report where they are
|
|
// instead of looking idle.
|
|
function geoProgress(kind, done, total) {
|
|
if (typeof self !== 'undefined' && self.postMessage) {
|
|
self.postMessage({ progress: 1, kind: kind, done: done, total: total });
|
|
}
|
|
}
|
|
|
|
function carMergeChains(polys, cfg) {
|
|
var JOINED = 0;
|
|
var MAX_D = 45; // world units ~ 580 km
|
|
var NEAR = 0.03; // junction search radius (~390 m)
|
|
var PK = 4194304; // 2^22 end-id slot width: safe up to ~2M ways
|
|
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);
|
|
var eps = [];
|
|
var grid = new Map();
|
|
var cell = function (x, z) { return Math.floor(x / NEAR) + '_' + Math.floor(z / NEAR); };
|
|
for (i = 0; i < n; i++) {
|
|
if ((i & 8191) === 8191) geoProgress('index', i, n);
|
|
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);
|
|
var b0 = grid.get(ck);
|
|
if (!b0) { b0 = []; grid.set(ck, b0); }
|
|
b0.push(r);
|
|
eps.push(r);
|
|
}
|
|
}
|
|
// canonical numeric key for an endpoint pair (way index + end side)
|
|
function pairKey(A, B) {
|
|
var x = A.w * 2 + A.e, y = B.w * 2 + B.e;
|
|
return x < y ? x * PK + y : y * PK + x;
|
|
}
|
|
var pairGeom = new Map();
|
|
function geomFor(A, B) {
|
|
var pk = pairKey(A, B);
|
|
var g = pairGeom.get(pk);
|
|
if (g !== undefined) return g;
|
|
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;
|
|
g = { s: score - Math.hypot(gpx, gpz) * 5, dot: score,
|
|
along: along, lat: Math.hypot(latx, latz) };
|
|
pairGeom.set(pk, g);
|
|
return g;
|
|
}
|
|
var candsByTier = [[], []];
|
|
for (var t = 0; t < 2; t++) {
|
|
var T = TIERS[t];
|
|
var cands = candsByTier[t];
|
|
var seenPair = new Set();
|
|
for (i = 0; i < eps.length; i++) {
|
|
if ((i & 8191) === 8191) geoProgress('join tier ' + t, i, eps.length);
|
|
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.get((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 = pairKey(A2, B2);
|
|
if (seenPair.has(pk2)) continue;
|
|
seenPair.add(pk2);
|
|
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]);
|
|
}
|
|
return { out: out, n: n, joins: JOINED,
|
|
maxChainKm: n ? maxD * cfg.KM_PER_TILE : 0 };
|
|
}
|
|
|
|
// Ribbon geometry for both road classes. Vertices go straight into a
|
|
// preallocated Float32Array (the old plain-array + new Float32Array(verts)
|
|
// copy churned hundreds of MB on every rebuild).
|
|
function buildCarGeometry(chainsM, chainsB, ts, cfg, places) {
|
|
var limX = cfg.limX, limZ = cfg.limZ, CAR_W = cfg.CAR_W, CAR_Y = cfg.CAR_Y;
|
|
// Traffic lives on a DISC around the configured location (rangeKm), not
|
|
// on the full rectangular map: a road that leaves the disc simply stops
|
|
// carrying vehicles, so the traffic field reads as a local region
|
|
// instead of a corridor running from Munich to Vienna.
|
|
var R2 = cfg.CAR_RANGE * cfg.CAR_RANGE;
|
|
function inView(x, z) {
|
|
return Math.abs(x) < limX && Math.abs(z) < limZ && x * x + z * z < R2;
|
|
}
|
|
var distField = (chainsB.length && chainsM.length) ? motorwayDistField(chainsM, ts) : 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
|
|
// Settlement weight: traffic follows people. Every OSM place node (the
|
|
// same data the city lights use) stamps a small gaussian onto the
|
|
// terrain grid, weighted by rank and population. A big city pulls the
|
|
// local density to 1, a village adds a modest bump - this is what keeps
|
|
// the flatland and the countryside alive where no motorway passes.
|
|
var settleField = null;
|
|
if (places && places.length >= 4) {
|
|
settleField = new Float32Array(ts.gw * ts.gh);
|
|
var RANK_W = [1.0, 0.62, 0.45, 0.30, 0.18];
|
|
var RANK_R = [3, 3, 2, 2, 1]; // stamp radius in grid steps (~3.2 km)
|
|
var s0, di, dj;
|
|
for (s0 = 0; s0 + 3 < places.length; s0 += 4) {
|
|
var px = places[s0], pz = places[s0 + 1];
|
|
var rank = places[s0 + 2] | 0, pop = places[s0 + 3];
|
|
var ci = Math.round((px + ts.halfX) / ts.s);
|
|
var cj = Math.round((ts.halfY - pz) / ts.s);
|
|
if (ci < 0 || ci >= ts.gw || cj < 0 || cj >= ts.gh) continue;
|
|
var base = RANK_W[rank] !== undefined ? RANK_W[rank] : 0.12;
|
|
var popf = pop > 0 ? Math.min(1.4, 0.45 + 0.9 * Math.sqrt(pop / 250000)) : 1;
|
|
var R = RANK_R[rank] !== undefined ? RANK_R[rank] : 1;
|
|
var ksum = 0;
|
|
for (di = -R; di <= R; di++)
|
|
for (dj = -R; dj <= R; dj++)
|
|
ksum += Math.exp(-(di * di + dj * dj) / (R * R));
|
|
var amp = base * popf / ksum;
|
|
for (di = -R; di <= R; di++) {
|
|
var ii = ci + di; if (ii < 0 || ii >= ts.gw) continue;
|
|
for (dj = -R; dj <= R; dj++) {
|
|
var jj = cj + dj; if (jj < 0 || jj >= ts.gh) continue;
|
|
settleField[jj * ts.gw + ii] += amp * Math.exp(-(di * di + dj * dj) / (R * R));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function settleAt(x, z) {
|
|
if (!settleField) return 0;
|
|
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 v = settleField[j * ts.gw + i];
|
|
return v > 1 ? 1 : v;
|
|
}
|
|
// Local vehicle density: a base share everywhere, plus the gaussian
|
|
// falloff around the motorway network, plus the settlement weight.
|
|
// spreadKm is the sigma of the motorway distribution.
|
|
function densScale(x, z) {
|
|
var st = settleAt(x, z);
|
|
if (!distField) return Math.min(1, 0.72 + 0.45 * st);
|
|
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 d = distField[j * ts.gw + i] * ts.s; // world units to a motorway
|
|
var t = d / cfg.CAR_SIGMA;
|
|
var mw = cfg.CAR_FLOOR + (1 - cfg.CAR_FLOOR) * Math.exp(-t * t);
|
|
// Flat enough that a side road 60 km from the A-net still carries
|
|
// clearly visible traffic; the motorway gaussian only adds a modest
|
|
// boost on top instead of dominating the whole field.
|
|
return Math.min(1, 0.72 + 0.28 * mw + 0.45 * st);
|
|
}
|
|
function build(polys, withField, dash) {
|
|
var li, i, segs = 0, totalLen = 0;
|
|
for (li = 0; li < polys.length; li++) {
|
|
if ((li & 511) === 511) geoProgress('slots', li, polys.length);
|
|
var P = polys[li];
|
|
for (i = 0; i + 1 < P.length; i++) {
|
|
var p0 = P[i], p1 = P[i + 1];
|
|
if (!inView(p0.x, p0.z) || !inView(p1.x, p1.z)) continue;
|
|
segs++;
|
|
totalLen += Math.hypot(p1.x - p0.x, p1.z - p0.z);
|
|
}
|
|
}
|
|
// pos(3) + meta(len, side, tangent 2) + dens = 8 floats, 6 per segment
|
|
var verts = new Float32Array(segs * 48), vi = 0;
|
|
// vehicle lights: x, y, z, tangent x, tangent z, direction, chainSeed,
|
|
// dens, vehicleSeed, chainLen (10 floats = 40 B stride) - 2 per slot
|
|
// Slots are totalLen/dash PLUS one minimum slot per chain (a chain
|
|
// shorter than the spacing still gets a vehicle), so the capacity
|
|
// must count both - otherwise a network of many short side roads
|
|
// overflows the buffer and the tail of it is silently dropped.
|
|
var pArr = new Float32Array((Math.ceil(totalLen / dash) + polys.length + 2) * 20);
|
|
var pi = 0, pn = 0;
|
|
for (li = 0; li < polys.length; li++) {
|
|
var pts = polys[li];
|
|
// total length of this chain: a vehicle may only slide as far as
|
|
// its own chain is long. Side roads are often a few hundred
|
|
// metres, and sliding them by the full class spacing (1.4 km)
|
|
// threw their lights off the road and into the terrain, where
|
|
// the depth test ate them - which is why only the long motorway
|
|
// chains showed traffic.
|
|
var clen = pts.length ? pts[pts.length - 1].d : 0;
|
|
var next = 0, acc = 0;
|
|
for (i = 0; i + 1 < pts.length; i++) {
|
|
var a = pts[i], b = pts[i + 1];
|
|
if (!inView(a.x, a.z) || !inView(b.x, b.z)) 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 = terrainH(ts, a.x, a.z) * cfg.ELEV_SCALE_UNITS + CAR_Y;
|
|
var yb = terrainH(ts, b.x, b.z) * cfg.ELEV_SCALE_UNITS + CAR_Y;
|
|
var dens = withField ? densScale((a.x + b.x) / 2, (a.z + b.z) / 2)
|
|
: Math.min(1, 0.55 + 0.65 * settleAt((a.x + b.x) / 2, (a.z + b.z) / 2));
|
|
verts[vi++] = a.x + nx; verts[vi++] = ya; verts[vi++] = a.z + nz;
|
|
verts[vi++] = a.d; verts[vi++] = 1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
verts[vi++] = a.x - nx; verts[vi++] = ya; verts[vi++] = a.z - nz;
|
|
verts[vi++] = a.d; verts[vi++] = -1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
verts[vi++] = b.x + nx; verts[vi++] = yb; verts[vi++] = b.z + nz;
|
|
verts[vi++] = b.d; verts[vi++] = 1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
verts[vi++] = a.x - nx; verts[vi++] = ya; verts[vi++] = a.z - nz;
|
|
verts[vi++] = a.d; verts[vi++] = -1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
verts[vi++] = b.x + nx; verts[vi++] = yb; verts[vi++] = b.z + nz;
|
|
verts[vi++] = b.d; verts[vi++] = 1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
verts[vi++] = b.x - nx; verts[vi++] = yb; verts[vi++] = b.z - nz;
|
|
verts[vi++] = b.d; verts[vi++] = -1; verts[vi++] = tx; verts[vi++] = tz; verts[vi++] = dens;
|
|
// walk the chain at the class spacing: one light per vehicle
|
|
while (acc + L >= next) {
|
|
var f = next - acc;
|
|
if (f < 0) f = 0;
|
|
var qx = a.x + dx * f, qz = a.z + dz * f;
|
|
if (inView(qx, qz) && pi + 20 <= pArr.length) {
|
|
// one shared slide phase per chain: every vehicle of
|
|
// a chain moves in lockstep, so a wrap lands exactly
|
|
// on the slot the neighbour just left - continuous
|
|
// traffic flow instead of a sawtooth that snaps back
|
|
var cs = ((li * 2654435761) % 100000) / 100000;
|
|
var ps = ((li * 2654435761 + pn * 40503) % 100000) / 100000;
|
|
var qy = terrainH(ts, qx, qz) * cfg.ELEV_SCALE_UNITS + CAR_Y;
|
|
// x, y, z, tangent x, tangent z, direction, chainSeed,
|
|
// dens, vehicleSeed, chainLen (10 floats = 40 B stride)
|
|
pArr[pi++] = qx; pArr[pi++] = qy; pArr[pi++] = qz;
|
|
pArr[pi++] = tx; pArr[pi++] = tz;
|
|
pArr[pi++] = 1; pArr[pi++] = cs; pArr[pi++] = dens; pArr[pi++] = ps;
|
|
pArr[pi++] = clen;
|
|
pArr[pi++] = qx; pArr[pi++] = qy; pArr[pi++] = qz;
|
|
pArr[pi++] = tx; pArr[pi++] = tz;
|
|
pArr[pi++] = -1; pArr[pi++] = cs; pArr[pi++] = dens; pArr[pi++] = (ps + 0.371) % 1;
|
|
pArr[pi++] = clen;
|
|
pn++;
|
|
}
|
|
next += dash;
|
|
}
|
|
acc += L;
|
|
}
|
|
}
|
|
return { verts: verts, count: vi / 8, len: totalLen,
|
|
pts: pArr.subarray(0, pi), pCount: pn * 2 };
|
|
}
|
|
var m = build(chainsM, false, cfg.CAR_M_DASH);
|
|
var b = build(chainsB, true, cfg.CAR_B_DASH);
|
|
return { mVerts: m.verts, mCount: m.count, bVerts: b.verts, bCount: b.count,
|
|
mLen: m.len, bLen: b.len,
|
|
mPts: m.pts, mPtsCount: m.pCount, bPts: b.pts, bPtsCount: b.pCount };
|
|
}
|
|
// Settlement glow dots: rank class scaled by population, jittered into a
|
|
// cluster so a town reads as an area and not as one bright point.
|
|
function buildCityGeometry(places, ts, cfg) {
|
|
var CITY_CLASS = cfg.CITY_CLASS, limX = cfg.limX, limZ = cfg.limZ;
|
|
var nPlaces = places.length / 4, total = 0, s, k;
|
|
geoProgress('city dots', 0, nPlaces);
|
|
function dotCount(rank, pop) {
|
|
var cls = CITY_CLASS[rank];
|
|
if (!cls) return 0;
|
|
var scale;
|
|
if (rank === 0 && pop > 0)
|
|
scale = Math.min(12.5, 0.6 + 4 * Math.sqrt(pop / 250000));
|
|
else
|
|
scale = pop > 0 ? Math.min(4, 0.6 + Math.sqrt(pop / 250000)) : 1;
|
|
return Math.max(1, Math.round(cls.n * scale));
|
|
}
|
|
for (s = 0; s < nPlaces; s++) {
|
|
var px = places[s * 4], pz = places[s * 4 + 1];
|
|
if (Math.abs(px) > limX || Math.abs(pz) > limZ) continue;
|
|
total += dotCount(places[s * 4 + 2] | 0, places[s * 4 + 3]);
|
|
}
|
|
// pos(3) + size + bri = 5 floats per dot
|
|
var verts = new Float32Array(total * 5), vi = 0;
|
|
function cityRand(seed) {
|
|
var x = Math.sin(seed) * 43758.5453;
|
|
return x - Math.floor(x);
|
|
}
|
|
for (s = 0; s < nPlaces; s++) {
|
|
if ((s & 63) === 63) geoProgress('city dots', s, nPlaces);
|
|
var p = places[s * 4], q = places[s * 4 + 1];
|
|
var rank = places[s * 4 + 2] | 0, pop = places[s * 4 + 3];
|
|
if (Math.abs(p) > limX || Math.abs(q) > limZ) continue;
|
|
var cls = CITY_CLASS[rank];
|
|
if (!cls) continue;
|
|
var scale;
|
|
if (rank === 0 && pop > 0)
|
|
scale = Math.min(12.5, 0.6 + 4 * Math.sqrt(pop / 250000));
|
|
else
|
|
scale = pop > 0 ? Math.min(4, 0.6 + Math.sqrt(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 (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 + Math.cos(ang) * rad;
|
|
var z = q + Math.sin(ang) * rad;
|
|
var y = terrainH(ts, x, z) * cfg.ELEV_SCALE_UNITS + cfg.CAR_Y + 0.003;
|
|
verts[vi++] = x; verts[vi++] = y; verts[vi++] = z;
|
|
verts[vi++] = sz * (0.6 + 0.8 * h3); verts[vi++] = bri * (0.55 + 0.7 * h3);
|
|
}
|
|
}
|
|
return { verts: verts, count: vi / 5 };
|
|
}
|
|
// Worker entry points: 'car' either stitches raw ways (slow path) or takes
|
|
// already-stitched chains (cache fast path); both return chains + geometry.
|
|
// Vehicle LIGHTS as discrete points: walk every chain at the class spacing
|
|
// and emit one point per vehicle slot per direction. Six floats per point:
|
|
// x, z, tangent x, tangent z, direction (+/-1), seed. The shader slides the
|
|
// point along its own tangent for the flow animation and separates the two
|
|
// directions on screen, so headlight and taillight never share a pixel.
|
|
function buildCarFromWays(m) {
|
|
var cm = carMergeChains(unpackWays(m.motorway), m.cfg);
|
|
var cb = carMergeChains(unpackWays(m.secondary), m.cfg);
|
|
var g = buildCarGeometry(cm.out, cb.out, m.terrain, m.cfg, m.places);
|
|
return {
|
|
mVerts: g.mVerts, mCount: g.mCount, bVerts: g.bVerts, bCount: g.bCount,
|
|
mLen: g.mLen, bLen: g.bLen,
|
|
mPts: g.mPts, mPtsCount: g.mPtsCount, bPts: g.bPts, bPtsCount: g.bPtsCount,
|
|
chainsM: packChains(cm.out), chainsB: packChains(cb.out),
|
|
rawCounts: [cm.n, cb.n], joins: cm.joins + cb.joins,
|
|
maxChainKm: Math.max(cm.maxChainKm, cb.maxChainKm)
|
|
};
|
|
}
|
|
function buildCarFromChains(m) {
|
|
var cm = unpackChains(m.chainsM), cb = unpackChains(m.chainsB);
|
|
var g = buildCarGeometry(cm, cb, m.terrain, m.cfg, m.places);
|
|
return {
|
|
mVerts: g.mVerts, mCount: g.mCount, bVerts: g.bVerts, bCount: g.bCount,
|
|
mLen: g.mLen, bLen: g.bLen,
|
|
mPts: g.mPts, mPtsCount: g.mPtsCount, bPts: g.bPts, bPtsCount: g.bPtsCount,
|
|
chainsM: m.chainsM, chainsB: m.chainsB,
|
|
rawCounts: [cm.length, cb.length], joins: 0, maxChainKm: m.maxChainKm || 0
|
|
};
|
|
}
|
|
`;
|
|
|
|
var GEO_DISPATCH_SRC = `
|
|
self.onmessage = function (e) {
|
|
var m = e.data, r;
|
|
try {
|
|
if (m.op === 'car') {
|
|
r = m.chains ? buildCarFromChains(m) : buildCarFromWays(m);
|
|
r.id = m.id;
|
|
self.postMessage(r, [r.mVerts.buffer, r.bVerts.buffer,
|
|
r.mPts.buffer, r.bPts.buffer,
|
|
r.chainsM.pts.buffer, r.chainsB.pts.buffer]);
|
|
} else {
|
|
r = buildCityGeometry(m.places, m.terrain, m.cfg);
|
|
r.id = m.id;
|
|
self.postMessage(r, [r.verts.buffer]);
|
|
}
|
|
} catch (err) {
|
|
self.postMessage({ op: m.op, id: m.id, error: String((err && err.message) || err) });
|
|
}
|
|
};
|
|
`;
|
|
|
|
// ---- Worker plumbing (main thread) --------------------------------------
|
|
// Two worker lanes. A full road merge runs over tens of thousands of ways
|
|
// and takes tens of seconds; on a single worker the settlement build queued
|
|
// behind it, so the city lights stayed invisible for the whole build.
|
|
var geoWorkers = { car: null, city: null }, geoWorkerBad = false, geoLocal = null;
|
|
var geoSeq = 0, geoPending = {};
|
|
|
|
function geoWorkerGet(lane) {
|
|
if (geoWorkerBad) return null;
|
|
if (geoWorkers[lane]) return geoWorkers[lane];
|
|
try {
|
|
var blob = new Blob([GEO_ALGO_SRC + GEO_DISPATCH_SRC], { type: 'text/javascript' });
|
|
var w = new Worker(URL.createObjectURL(blob));
|
|
w.onmessage = function (e) {
|
|
var res = e.data || {};
|
|
if (res.progress) {
|
|
dbgLog('[clouds3d] ' + lane + ' worker ' + res.kind + ': ' +
|
|
res.done + '/' + res.total);
|
|
return;
|
|
}
|
|
var cb = geoPending[res.id];
|
|
delete geoPending[res.id];
|
|
if (!cb) return;
|
|
if (res.error) console.warn('[clouds3d] geo ' + res.op + ' build failed: ' + res.error);
|
|
cb(res);
|
|
};
|
|
w.onerror = function (ev) {
|
|
console.warn('[clouds3d] geo worker (' + lane + ') unavailable, falling back to main thread: ' +
|
|
((ev && ev.message) || 'unknown'));
|
|
geoWorkerBad = true;
|
|
geoWorkers[lane] = null;
|
|
// release every job waiting on this worker, or its single-flight
|
|
// slot would stay occupied and the layer would never rebuild
|
|
var ids = Object.keys(geoPending);
|
|
for (var i = 0; i < ids.length; i++) {
|
|
var cb = geoPending[ids[i]];
|
|
delete geoPending[ids[i]];
|
|
cb({ error: 'geo worker failed' });
|
|
}
|
|
try { w.terminate(); } catch (e) {}
|
|
};
|
|
geoWorkers[lane] = w;
|
|
} catch (e) {
|
|
geoWorkerBad = true;
|
|
}
|
|
return geoWorkers[lane];
|
|
}
|
|
// Same source, compiled into plain functions for the no-Worker fallback.
|
|
function geoLocalFns() {
|
|
if (geoLocal) return geoLocal;
|
|
geoLocal = new Function(GEO_ALGO_SRC +
|
|
'\nreturn { buildCarFromWays: buildCarFromWays,' +
|
|
' buildCarFromChains: buildCarFromChains,' +
|
|
' buildCityGeometry: buildCityGeometry };')();
|
|
return geoLocal;
|
|
}
|
|
function geoRun(msg, cb) {
|
|
var w = geoWorkerGet(msg.op === 'car' ? 'car' : 'city');
|
|
msg.id = ++geoSeq;
|
|
if (!w) {
|
|
var fns = geoLocalFns(), res;
|
|
try {
|
|
res = msg.op === 'car'
|
|
? (msg.chains ? fns.buildCarFromChains(msg) : fns.buildCarFromWays(msg))
|
|
: fns.buildCityGeometry(msg.places, msg.terrain, msg.cfg);
|
|
res.id = msg.id;
|
|
} catch (e) {
|
|
console.warn('[clouds3d] geo ' + msg.op + ' build failed: ' + e.message);
|
|
return;
|
|
}
|
|
cb(res);
|
|
return;
|
|
}
|
|
geoPending[msg.id] = cb;
|
|
// packed way arrays are throwaway -> transfer (zero copy); the terrain
|
|
// grid stays on the main thread (carYAt still needs it), so it clones.
|
|
var tr = [];
|
|
if (msg.op === 'car' && !msg.chains) {
|
|
tr = [msg.motorway.pts.buffer, msg.motorway.dirs.buffer,
|
|
msg.secondary.pts.buffer, msg.secondary.dirs.buffer];
|
|
}
|
|
w.postMessage(msg, tr);
|
|
}
|
|
|
|
// One build in flight per layer, with a trailing debounce: Overpass cells
|
|
// land one by one over many minutes, and each arrival used to trigger its
|
|
// own full re-merge. Now a burst of arrivals collapses into a single build,
|
|
// and a build that is already running is simply re-run once when it ends.
|
|
function makeGeoJob(run) {
|
|
var t = null, running = false, pending = false;
|
|
function fire(delay) {
|
|
if (t) clearTimeout(t);
|
|
t = setTimeout(function () {
|
|
t = null;
|
|
if (running) { pending = true; return; }
|
|
running = true;
|
|
run(function () {
|
|
running = false;
|
|
if (pending) { pending = false; fire(0); }
|
|
});
|
|
}, typeof delay === 'number' ? delay : 4000);
|
|
}
|
|
return { kick: fire };
|
|
}
|
|
|
|
// ---- Packed transfer helpers (main thread) ------------------------------
|
|
function packWays(polys) {
|
|
var i, j, n = polys.length, total = 0;
|
|
var cnts = new Int32Array(n), dirs = new Float32Array(n * 4);
|
|
for (i = 0; i < n; i++) {
|
|
var P = polys[i];
|
|
cnts[i] = P.length; total += P.length;
|
|
var hd = P.headD, td = P.tailD;
|
|
dirs[i * 4] = hd ? hd.x : 0; dirs[i * 4 + 1] = hd ? hd.z : 0;
|
|
dirs[i * 4 + 2] = td ? td.x : 0; dirs[i * 4 + 3] = td ? td.z : 0;
|
|
}
|
|
var pts = new Float32Array(total * 3), k = 0;
|
|
for (i = 0; i < n; i++) {
|
|
var Q = polys[i];
|
|
for (j = 0; j < Q.length; j++) { pts[k++] = Q[j].x; pts[k++] = Q[j].z; pts[k++] = Q[j].d; }
|
|
}
|
|
return { cnts: cnts, pts: pts, dirs: dirs };
|
|
}
|
|
function packPlaces(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 f;
|
|
}
|
|
function terrainMsg() {
|
|
var ts = terrainSample;
|
|
return { h: ts.h, gw: ts.gw, gh: ts.gh, s: ts.s, halfX: ts.halfX, halfY: ts.halfY };
|
|
}
|
|
function carCfg() {
|
|
return { limX: groundTilesX() / 2 + 1, limZ: groundTilesY() / 2 + 1,
|
|
CAR_W: CAR_W, CAR_Y: CAR_Y, ELEV_SCALE_UNITS: ELEV_SCALE_UNITS,
|
|
KM_PER_TILE: KM_PER_TILE,
|
|
CAR_M_DASH: CAR_M_DASH, CAR_B_DASH: CAR_B_DASH,
|
|
CAR_SIGMA: CAR_SIGMA, CAR_FLOOR: CAR_FLOOR, CAR_RANGE: CAR_RANGE };
|
|
}
|
|
function cityCfg() {
|
|
return { limX: groundTilesX() / 2 + 1, limZ: groundTilesY() / 2 + 1,
|
|
CITY_CLASS: CITY_CLASS, ELEV_SCALE_UNITS: ELEV_SCALE_UNITS, CAR_Y: CAR_Y };
|
|
}
|
|
|
|
// ---- Car layer: build off-thread, upload here ----------------------------
|
|
var carMaxChainKm = 0;
|
|
var carRawCounts = [0, 0];
|
|
var carJoinCount = 0;
|
|
var carRaw = { motorway: [], secondary: [] }; // raw ways collected this cycle
|
|
var carNeedMerge = false;
|
|
var carPersistFp = null; // set when a cycle finished and the chains should be saved
|
|
var carCycleFp = null; // fingerprint of the raw set that cycle used
|
|
var carBuildMerged = false; // did the last worker build merge fresh cells?
|
|
var carLastPersistAt = 0;
|
|
// Saving the whole network is a base64 encode of every chain, so an interim
|
|
// save is throttled - it only exists so a closed browser does not throw away
|
|
// a build that took minutes.
|
|
var CAR_PERSIST_MIN_MS = 20000;
|
|
|
|
function persistCarChains(fp) {
|
|
if (!carGeo) return;
|
|
openTileDB().then(function (db) {
|
|
if (!db) return;
|
|
tileDbPut(db, { id: 'geo/roadchains_v1', fp: fp,
|
|
ids: CAR_QUERIES.map(function (e) { return e.id; }),
|
|
mCnts: Array.from(carGeo.motorway.cnts), mData: f32ToB64(carGeo.motorway.pts),
|
|
bCnts: Array.from(carGeo.secondary.cnts), bData: f32ToB64(carGeo.secondary.pts),
|
|
savedAt: Date.now() });
|
|
manifestPersist(true);
|
|
});
|
|
}
|
|
|
|
function kickCarBuild(needMerge) {
|
|
if (!CAR_ON) return;
|
|
if (needMerge) carNeedMerge = true;
|
|
carGeoJob.kick(needMerge ? 6000 : 1500);
|
|
}
|
|
|
|
function runCarBuild(done) {
|
|
var gl = elevGl || (clouds3dState && clouds3dState.gl);
|
|
if (!gl || !terrainSample) { done(); return; }
|
|
var msg, t0 = performance.now();
|
|
if (carNeedMerge) {
|
|
carNeedMerge = false;
|
|
carBuildMerged = true;
|
|
msg = { op: 'car', motorway: packWays(carRaw.motorway),
|
|
secondary: packWays(carRaw.secondary),
|
|
places: cityGeo, terrain: terrainMsg(), cfg: carCfg() };
|
|
} else {
|
|
if (!carGeo) { done(); return; }
|
|
carBuildMerged = false;
|
|
msg = { op: 'car', chains: true, chainsM: carGeo.motorway, chainsB: carGeo.secondary,
|
|
places: cityGeo, terrain: terrainMsg(), cfg: carCfg(), maxChainKm: carMaxChainKm };
|
|
}
|
|
geoRun(msg, function (res) {
|
|
if (!res || res.error) { done(); return; }
|
|
carGeo = { motorway: res.chainsM, secondary: res.chainsB };
|
|
carRawCounts = res.rawCounts;
|
|
if (res.joins) carJoinCount = res.joins;
|
|
if (res.maxChainKm) carMaxChainKm = res.maxChainKm;
|
|
applyCarBuild(gl, res);
|
|
dbgLog('[clouds3d] car build: ' + carCountM + ' + ' + carCountB +
|
|
' ribbon verts, ' + carGeo.motorway.cnts.length + '+' +
|
|
carGeo.secondary.cnts.length + ' chains, ' +
|
|
carPtsCountM + ' motorway + ' + carPtsCountB + ' side lights, longest ~' +
|
|
Math.round(carMaxChainKm) + ' km, ' +
|
|
Math.round(performance.now() - t0) + ' ms off-thread');
|
|
dbgLog('[clouds3d] car slots: motorway ' + (carPtsCountM / 2) +
|
|
' slots on ' + Math.round(res.mLen * KM_PER_TILE) + ' km (want ' +
|
|
Math.round(res.mLen / CAR_M_DASH) + ', dash ' +
|
|
Math.round(CAR_M_DASH * KM_PER_TILE * 1000) + ' m) | side ' + (carPtsCountB / 2) +
|
|
' slots on ' + Math.round(res.bLen * KM_PER_TILE) + ' km (want ' +
|
|
Math.round(res.bLen / CAR_B_DASH) + ', dash ' +
|
|
Math.round(CAR_B_DASH * KM_PER_TILE * 1000) + ' m)');
|
|
// Persist after a finished cycle, and also in between: every merge
|
|
// that brought new cells saves the network built so far, so closing
|
|
// the browser mid-build does not discard the whole Overpass work.
|
|
var now = performance.now();
|
|
if (carPersistFp || (carBuildMerged && now - carLastPersistAt > CAR_PERSIST_MIN_MS)) {
|
|
var fp = carPersistFp || carCycleFp;
|
|
carPersistFp = null;
|
|
carLastPersistAt = now;
|
|
persistCarChains(fp);
|
|
}
|
|
done();
|
|
});
|
|
}
|
|
|
|
function applyCarBuild(gl, res) {
|
|
if (carVboM) gl.deleteBuffer(carVboM);
|
|
carVboM = null; carCountM = 0;
|
|
if (res.mCount > 0) {
|
|
carVboM = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, carVboM);
|
|
gl.bufferData(gl.ARRAY_BUFFER, res.mVerts, gl.STATIC_DRAW);
|
|
carCountM = res.mCount;
|
|
}
|
|
if (carVboB) gl.deleteBuffer(carVboB);
|
|
carVboB = null; carCountB = 0;
|
|
if (res.bCount > 0) {
|
|
carVboB = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, carVboB);
|
|
gl.bufferData(gl.ARRAY_BUFFER, res.bVerts, gl.STATIC_DRAW);
|
|
carCountB = res.bCount;
|
|
}
|
|
// vehicle lights: one point sprite per vehicle per direction
|
|
if (carPtsVboM) gl.deleteBuffer(carPtsVboM);
|
|
carPtsVboM = null; carPtsCountM = 0;
|
|
if (res.mPtsCount > 0) {
|
|
carPtsVboM = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, carPtsVboM);
|
|
gl.bufferData(gl.ARRAY_BUFFER, res.mPts, gl.STATIC_DRAW);
|
|
carPtsCountM = res.mPtsCount;
|
|
}
|
|
if (carPtsVboB) gl.deleteBuffer(carPtsVboB);
|
|
carPtsVboB = null; carPtsCountB = 0;
|
|
if (res.bPtsCount > 0) {
|
|
carPtsVboB = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, carPtsVboB);
|
|
gl.bufferData(gl.ARRAY_BUFFER, res.bPts, gl.STATIC_DRAW);
|
|
carPtsCountB = res.bPtsCount;
|
|
}
|
|
window.__carDbg = { motorwayIn: carRawCounts[0], secondaryIn: carRawCounts[1],
|
|
motorwayChains: carGeo.motorway.cnts.length,
|
|
secondaryChains: carGeo.secondary.cnts.length,
|
|
lightsMotorway: carPtsCountM, lightsSide: carPtsCountB,
|
|
trafficSide: CAR_TRAFFIC > 0 ? 'right' : 'left',
|
|
maxChainKm: Math.round(carMaxChainKm * 10) / 10 };
|
|
// After deleting a 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 use them again.
|
|
var safe = clouds3dState && clouds3dState.buf;
|
|
if (safe) {
|
|
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);
|
|
}
|
|
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'),
|
|
earthRadius: gl.getUniformLocation(carProg, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(carProg, 'uCurvatureOn'),
|
|
time: gl.getUniformLocation(carProg, 'uTime'),
|
|
night: gl.getUniformLocation(carProg, 'uNight'),
|
|
dens: gl.getUniformLocation(carProg, 'uDens'),
|
|
grow: gl.getUniformLocation(carProg, 'uGrow'),
|
|
halfW: gl.getUniformLocation(carProg, 'uHalfW'),
|
|
bright: gl.getUniformLocation(carProg, 'uBright'),
|
|
fadeA: gl.getUniformLocation(carProg, 'uFadeA'),
|
|
fadeB: gl.getUniformLocation(carProg, 'uFadeB')
|
|
};
|
|
}
|
|
}
|
|
|
|
// ---- Settlement lights: build off-thread, upload here --------------------
|
|
var cityBuildMs = 0;
|
|
|
|
function kickCityBuild() {
|
|
if (!CITY_ON) return;
|
|
cityGeoJob.kick(4000);
|
|
}
|
|
|
|
function runCityBuild(done) {
|
|
var gl = elevGl || (clouds3dState && clouds3dState.gl);
|
|
if (!gl || !terrainSample || !cityGeo) {
|
|
// silent skip made the layer look dead: say what is still missing
|
|
dbgLog('[clouds3d] city build skipped - ' +
|
|
(!gl ? 'no gl context' : '') +
|
|
(!gl || terrainSample ? '' : ', no elevation sample yet') +
|
|
(cityGeo ? '' : ', no place nodes fetched yet'));
|
|
done(); return;
|
|
}
|
|
dbgLog('[clouds3d] city build: ' + (cityGeo.length / 4) + ' places queued');
|
|
var t0 = performance.now();
|
|
geoRun({ op: 'city', places: cityGeo, terrain: terrainMsg(), cfg: cityCfg() },
|
|
function (res) {
|
|
if (!res || res.error) { done(); return; }
|
|
cityBuildMs = Math.round(performance.now() - t0);
|
|
applyCityBuild(gl, res);
|
|
dbgLog('[clouds3d] settlements: ' + cityCount + ' lights from ' +
|
|
(cityGeo.length / 4) + ' places, ' + cityBuildMs + ' ms off-thread');
|
|
done();
|
|
});
|
|
}
|
|
|
|
function applyCityBuild(gl, res) {
|
|
if (cityVbo) gl.deleteBuffer(cityVbo);
|
|
cityVbo = null; cityCount = 0;
|
|
if (res.count > 0) {
|
|
var vbo = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.bufferData(gl.ARRAY_BUFFER, res.verts, gl.STATIC_DRAW);
|
|
cityVbo = vbo; cityCount = res.count;
|
|
}
|
|
window.__cityDbg = { places: cityGeo ? cityGeo.length / 4 : 0, lights: cityCount,
|
|
buildMs: cityBuildMs };
|
|
var safe = clouds3dState && clouds3dState.buf;
|
|
if (safe) {
|
|
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);
|
|
}
|
|
if (!cityProg && cityCount) {
|
|
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'),
|
|
earthRadius: gl.getUniformLocation(cityProg, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(cityProg, 'uCurvatureOn'),
|
|
scale: gl.getUniformLocation(cityProg, 'uScale'),
|
|
night: gl.getUniformLocation(cityProg, 'uNight'),
|
|
sunAlt: gl.getUniformLocation(cityProg, 'uSunAlt'),
|
|
lightHi: gl.getUniformLocation(cityProg, 'uLightHi'),
|
|
lightLo: gl.getUniformLocation(cityProg, 'uLightLo'),
|
|
lightSoft: gl.getUniformLocation(cityProg, 'uLightSoft')
|
|
};
|
|
}
|
|
}
|
|
|
|
var carGeoJob = makeGeoJob(runCarBuild);
|
|
var cityGeoJob = makeGeoJob(runCityBuild);
|
|
|
|
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 uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform float uScale;', // px scale (viewport height / 600)',
|
|
'varying float vBri;',
|
|
'varying float vDist;',
|
|
'varying float vSeed;',
|
|
'void main() {',
|
|
'vec3 pos = aPos;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'pos.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vDist = length(uCamPos - pos);',
|
|
'vBri = aBri;',
|
|
// stable per-dot threshold seed: derived from the dot's own position
|
|
'vSeed = fract(sin(dot(aPos.xz, vec2(12.9898, 78.233))) * 43758.5453);',
|
|
'gl_PointSize = clamp(aSize * uScale * 750.0 / max(vDist, 0.1), 1.6, 18.0);',
|
|
'gl_Position = uProj * uView * vec4(pos, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CITY_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform float uNight;',
|
|
'uniform float uSunAlt;',
|
|
'uniform float uLightHi;',
|
|
'uniform float uLightLo;',
|
|
'uniform float uLightSoft;',
|
|
'varying float vBri;',
|
|
'varying float vDist;',
|
|
'varying float vSeed;',
|
|
'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;',
|
|
'float thr = mix(uLightHi, uLightLo, vSeed);',
|
|
'float lit = 1.0 - smoothstep(thr - uLightSoft, thr + uLightSoft, uSunAlt);',
|
|
'if (lit <= 0.001) discard;',
|
|
'gl_FragColor = vec4(vec3(1.0, 0.80, 0.45) * core * vBri * lit * df, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// deterministic per-settlement, per-dot jitter lives in the geo worker now
|
|
// (see buildCityGeometry) - the main thread never touches individual dots.
|
|
|
|
// 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;
|
|
}
|
|
// The derived caches (chains, settlement dots) store ABSOLUTE world
|
|
// coordinates, and carLonToX/carLatToZ anchor those at GROUND_BASE_X/Y -
|
|
// i.e. at CONFIG.location. cellsFp() alone is location-blind, so after a
|
|
// location change the old chains were reused at the old anchor and the
|
|
// traffic kept showing up over the previous city. Salting the fingerprint
|
|
// with the anchor invalidates them; the raw Overpass cells stay cached.
|
|
function geoFp(parts) {
|
|
return cellsFp(parts) + ':a' + GROUND_BASE_X + '_' + GROUND_BASE_Y;
|
|
}
|
|
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); }
|
|
|
|
// ---- 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);
|
|
// world units: trails are gone by contrailFadeKm, ramping from 35% of it
|
|
var CT_FADE_END = Math.max(1, fltNum(FLT_CFG.contrailFadeKm, 100) / KM_PER_TILE);
|
|
// Night readability. A physically scaled contrail on a starlit night is a
|
|
// faint grey thread that the pixel grid removes entirely, so the night share
|
|
// of its opacity and a gain on the moon/star light are configurable.
|
|
var CT_NIGHT = Math.max(0, Math.min(1, fltNum(FLT_CFG.contrailNight, 0.85)));
|
|
var CT_NIGHT_BOOST = 1.0 + CT_NIGHT * 2.2;
|
|
var CT_FADE_START = CT_FADE_END * 0.35;
|
|
var CT_MAX = FLT_MAX;
|
|
var CT_SEG = 8; // curve resolution per trail
|
|
// Strobe appearance. Point size shrinks with distance but never below
|
|
// lightPx (same floor idea as the city dots), and the white strobe is a
|
|
// real double flash: two equal flashes blinkGapS apart, then the remainder
|
|
// of the cycle stays dark (blinkPeriodS - blinkGapS - blinkFlashS pause).
|
|
var FLT_LPX = fltNum(FLT_CFG.lightPx, 7);
|
|
var FLT_LMAX = Math.max(FLT_LPX, fltNum(FLT_CFG.lightMaxPx, 20));
|
|
var FLT_LBOOST = fltNum(FLT_CFG.lightBoost, 9);
|
|
var FLT_BLINK_T = Math.max(0.3, fltNum(FLT_CFG.blinkPeriodS, 1.0));
|
|
var FLT_BLINK_GAP = Math.max(0.02, fltNum(FLT_CFG.blinkGapS, 0.12));
|
|
var FLT_BLINK_W = Math.max(0.01, fltNum(FLT_CFG.blinkFlashS, 0.07));
|
|
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);
|
|
dbgLog('[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 uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'uniform float uScale;',
|
|
'uniform float uMinPx;', // hard pixel floor for the light',
|
|
'uniform float uMaxPx;', // ceiling so a near jet is not a blob',
|
|
'uniform float uBoost;', // extra size close to the camera',
|
|
'varying float vBri;',
|
|
'varying float vPhase;',
|
|
'varying float vDist;',
|
|
'void main() {',
|
|
'vec3 pos = aPos;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'pos.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vDist = length(uCamPos - pos);',
|
|
'vBri = aBlink.x;',
|
|
'vPhase = aBlink.y;',
|
|
// distance aware, but never below the pixel floor - the city dots
|
|
// use the same clamp; aBlink.z carries the destination rank
|
|
'gl_PointSize = clamp(uMinPx + uBoost * uScale * aBlink.z / max(vDist, 0.35),',
|
|
'uMinPx, uMaxPx);',
|
|
'gl_Position = uProj * uView * vec4(pos, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var FLT_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform float uTime;',
|
|
'uniform float uNight;',
|
|
'uniform float uBlinkT;', // full strobe cycle',
|
|
'uniform float uBlinkGap;', // distance between the two flashes',
|
|
'uniform float uBlinkW;', // length of one flash',
|
|
'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;',
|
|
// hot core plus a wide faint halo, so the sprite really uses the
|
|
// pixels it was given instead of burning one in the middle
|
|
'float core = exp(-d * d * 4.0);',
|
|
'float halo = (1.0 - smoothstep(0.0, 1.0, d)) * 0.30;',
|
|
'float g = max(core, halo);',
|
|
'if (g <= 0.004) discard;',
|
|
// white strobe: two equal flashes uBlinkGap apart, then the rest of
|
|
// the cycle stays dark - a real anti-collision double flash
|
|
'float t = fract(uTime / uBlinkT + vPhase);',
|
|
'float p1 = max(0.0, 1.0 - t / uBlinkW);',
|
|
'float p2 = max(0.0, 1.0 - abs(t - uBlinkGap) / uBlinkW);',
|
|
'float flash = p1 * p1 + 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.5 + vec3(1.0, 0.12, 0.06) * red;',
|
|
'float a = uNight * vBri * df; // day: never drawn',
|
|
'gl_FragColor = vec4(col * g * a, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
// ---- Airliner model pass (day and night) -------------------------------
|
|
// Low-poly jet (vendor/plane3d.js, CC0-derived) for the SAME traffic, drawn
|
|
// at every time of day: sun-shaded by day, cool sky-lit silhouette by night.
|
|
// Scale is tied to the trail - nose-to-tail equals the FULL contrail width,
|
|
// so jet and trail 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 uCamPos;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'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;',
|
|
'vec3 origin = uOrigin;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = origin.xz - uCamPos.xz;',
|
|
'origin.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'gl_Position = uProj * uView * vec4(origin + p, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var PLANE_FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform vec3 uSun;', // world sun direction (x,y,z like __currentSunDir)',
|
|
'uniform float uLight;', // 1 = day (sun shaded), 0 = night (moon lit)',
|
|
'uniform vec3 uMoon;', // world moon direction (x,y,z like __currentMoonDir)',
|
|
'uniform float uMoonAmt;', // moon above horizon x illuminated fraction',
|
|
'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);',
|
|
'vec3 n = normalize(vN);',
|
|
'vec3 day = alb * (0.22 + 0.78 * max(0.0, dot(n, uSun)));',
|
|
// night: moonlight from above. The moon direction is used when it is
|
|
// up, otherwise the light comes straight down; a weak sky term keeps a
|
|
// readable silhouette even on a new moon. Moonlight is cooler than
|
|
// sunlight, so the tint is a silver blue rather than a warm white.
|
|
'vec3 mdir = (uMoon.y > 0.0) ? normalize(uMoon) : vec3(0.0, 1.0, 0.0);',
|
|
'float mdl = max(0.0, dot(n, mdir));',
|
|
'vec3 night = alb * (0.12 + 0.16 * max(0.0, n.y) + 0.62 * mdl * uMoonAmt)',
|
|
' * vec3(0.66, 0.76, 1.0) + vec3(0.03, 0.04, 0.07);',
|
|
'gl_FragColor = vec4(mix(night, day, uLight), 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;',
|
|
'uniform vec3 uCamPos;',
|
|
'uniform float uEarthRadius;',
|
|
'uniform float uCurvatureOn;',
|
|
'varying float vU;',
|
|
'varying float vV;',
|
|
'varying float vS;',
|
|
'varying float vP;',
|
|
'varying vec3 vWorld;',
|
|
'void main() {',
|
|
'vU = aU; vV = aV; vS = aS; vP = aP;',
|
|
'vec3 pos = aPos;',
|
|
'if (uCurvatureOn > 0.5) {',
|
|
'vec2 d = pos.xz - uCamPos.xz;',
|
|
'pos.y -= dot(d, d) / (2.0 * max(1.0, uEarthRadius));',
|
|
'}',
|
|
'vWorld = pos;',
|
|
'gl_Position = uProj * uView * vec4(pos, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var CT_FRAG_SRC = [
|
|
'precision mediump float;',
|
|
'uniform vec3 uSunColor;',
|
|
'uniform float uSunInt;',
|
|
'uniform vec3 uMoonColor;',
|
|
'uniform float uMoonInt;',
|
|
'uniform float uSkyAmb;',
|
|
'uniform float uStarAmb;',
|
|
'uniform float uNightBoost;', // extra gain on the moon/star terms
|
|
'uniform vec3 uSunDir;',
|
|
'uniform vec3 uCamPos;',
|
|
'varying float vU;',
|
|
'varying float vV;',
|
|
'varying float vS;',
|
|
'varying float vP;',
|
|
'varying vec3 vWorld;',
|
|
'void main() {',
|
|
// Procedural mip: a contrail far away is a handful of pixels wide, so
|
|
// its along-stripe density and its edge wobble are higher frequency
|
|
// than the pixel grid can carry - that is the shimmer. Both fade to
|
|
// their mean with distance, which leaves a steady faint stripe.
|
|
'float cDist = length(vWorld - uCamPos);',
|
|
'float cSoft = smoothstep(9.0, 22.0, cDist);',
|
|
// 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);',
|
|
'dens = mix(dens, 0.62, cSoft);',
|
|
'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));',
|
|
'wob = mix(wob, 0.78, cSoft);',
|
|
'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;',
|
|
// A contrail is a cloud: sunlit head-on, moonlit at night, and on a
|
|
// dark starlit night the diffuse starlight keeps it faintly visible
|
|
// instead of a hard white stripe on black.
|
|
'vec3 vd = normalize(vWorld - uCamPos);',
|
|
'float cosT = dot(vd, -uSunDir);',
|
|
'float phase = 0.35 + 0.65 * pow(max(0.0, cosT), 2.0);',
|
|
// Same multipliers as the cloud deck, so the contrail/cloud
|
|
// brightness ratio at night is the same as in daylight.
|
|
'vec3 light = uSunColor * uSunInt * phase * 1.25',
|
|
' + (uMoonColor * uMoonInt * 3.0',
|
|
' + max(vec3(uSkyAmb), uStarAmb * vec3(0.75, 0.85, 1.0)) * 1.7)',
|
|
' * uNightBoost;',
|
|
'gl_FragColor = vec4(vec3(0.95, 0.96, 1.0) * light, 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_BRIGHT = (((window.CONFIG && window.CONFIG.stars) || {}).brightness !== undefined)
|
|
? Math.max(0.2, Number(window.CONFIG.stars.brightness)) : 1.6;
|
|
// Star frame phase. The rotation is driven by real local sidereal time
|
|
// (window.__currentSiderealRad), so RA alpha crosses the meridian exactly
|
|
// when LST = alpha. STAR_PH is only a user offset (CONFIG.stars.phaseOffset).
|
|
var STAR_PH = (((window.CONFIG && window.CONFIG.stars) || {}).phaseOffset) || 0;
|
|
function starSidereal() {
|
|
var v = window.__currentSiderealRad;
|
|
return (typeof v === 'number') ? v + STAR_PH : null;
|
|
}
|
|
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 [0.98, 0.84, 0.70];
|
|
if (k < 5000) return [0.99, 0.90, 0.80];
|
|
if (k < 6300) return [1.00, 0.97, 0.94];
|
|
if (k < 8000) return [0.95, 0.97, 1.00];
|
|
return [0.88, 0.92, 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;',
|
|
'uniform vec3 uMoonDir;',
|
|
'uniform float uMoonR;',
|
|
'varying vec4 vC;',
|
|
'varying float vAlt;',
|
|
'varying float vSeed;',
|
|
'varying float vMoon;',
|
|
'void main() {',
|
|
'vec3 d = aSd.xyz;',
|
|
// stable per-star seed: derived from the star's own direction, so it
|
|
// never changes when the camera moves
|
|
'vSeed = fract(dot(d, vec3(127.1, 311.7, 74.7)));',
|
|
// 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;',
|
|
'vMoon = smoothstep(uMoonR * 0.92, uMoonR * 1.35, length(rp - uMoonDir));',
|
|
// 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, 1.4, 9.0);',
|
|
'vC = aScol;',
|
|
'}'
|
|
].join('\n');
|
|
|
|
var SKYPTS_FRAG = [
|
|
'precision mediump float;',
|
|
'uniform float uNight;',
|
|
'uniform float uTime;',
|
|
'uniform float uBright;', // CONFIG.stars.brightness
|
|
'varying vec4 vC;',
|
|
'varying float vAlt;',
|
|
'varying float vSeed;',
|
|
'varying float vMoon;',
|
|
'void main() {',
|
|
'vec2 pc = gl_PointCoord - 0.5;',
|
|
'float d = length(pc) * 2.0;',
|
|
// pure gaussian, no hard disc: a hard edge on a sub-pixel sprite
|
|
// flips coverage as the star crosses the pixel grid, which is what
|
|
// read as boiling/flicker while the camera moved
|
|
'float g = exp(-d * d * 3.4);',
|
|
'float twk = 0.96 + 0.04 * sin(uTime * 1.6 + vSeed * 628.0);',
|
|
'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 * vMoon;',
|
|
'gl_FragColor = vec4(vC.rgb * g * a * uBright, 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, mr = 0, mg = 0, mb = 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);
|
|
// white base with only a hint of the star's real colour; the mix
|
|
// factor is CONFIG.stars.tint (0 = pure white)
|
|
var STAR_TUN = (window.CONFIG && window.CONFIG.stars) || {};
|
|
var TINT = STAR_TUN.tint;
|
|
TINT = (typeof TINT === 'number') ? Math.max(0, Math.min(1, TINT)) : 0.15;
|
|
rgb[0] = TINT * rgb[0] + (1 - TINT);
|
|
rgb[1] = TINT * rgb[1] + (1 - TINT);
|
|
rgb[2] = TINT * rgb[2] + (1 - TINT);
|
|
// diagnostic override: CONFIG.stars.debugColor = 'blue' paints every
|
|
// single catalogue point the same colour, which tells apart a colour
|
|
// bug in this pass from a red cast coming from somewhere else
|
|
if (STAR_TUN.debugColor === 'blue') { rgb[0] = 0.15; rgb[1] = 0.35; rgb[2] = 1.0; }
|
|
mr += rgb[0]; mg += rgb[1]; mb += rgb[2];
|
|
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.7 so even 6.5-mag stars glow
|
|
// clearly; doubles as the twinkle phase seed
|
|
var b = 0.7 + 0.3 * Math.min(1, Math.pow(2.512, -mag * 0.7) * 1.3);
|
|
data[di++] = b;
|
|
}
|
|
dbgLog('[clouds3d] stars: ' + stars.length + ' points, mean rgb ' +
|
|
(mr / stars.length).toFixed(3) + '/' + (mg / stars.length).toFixed(3) + '/' +
|
|
(mb / stars.length).toFixed(3));
|
|
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'),
|
|
moonDir: gl.getUniformLocation(prog, 'uMoonDir'),
|
|
moonR: gl.getUniformLocation(prog, 'uMoonR'),
|
|
night: gl.getUniformLocation(prog, 'uNight'),
|
|
time: gl.getUniformLocation(prog, 'uTime'),
|
|
bright: gl.getUniformLocation(prog, 'uBright')
|
|
}
|
|
};
|
|
}
|
|
|
|
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. The settlement grid (78 small cells) goes
|
|
// first: it answers quickly and its lights are what shows progress,
|
|
// while the road grid (230 heavy cells) would otherwise hold the single
|
|
// request gate for many minutes and no city light would ever appear.
|
|
rebuildGroundMosaic(GROUND_BASE_X, GROUND_BASE_Y);
|
|
buildElevationSystem(glCtx);
|
|
fetchSettlements();
|
|
fetchAirports();
|
|
setTimeout(fetchMotorways, 15000);
|
|
// 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,
|
|
// rain and storm both map to 'stormy' (open-meteo codes 51-67, 71-77,
|
|
// 80-86, 95+). Full 1.0 packed the deck so hard that the march showed
|
|
// its own layering, so the top end is capped below overcast.
|
|
stormy: 0.80,
|
|
night: 0.45
|
|
};
|
|
if (window.CONFIG && window.CONFIG.clouds && window.CONFIG.clouds.density) {
|
|
for (var _wd in window.CONFIG.clouds.density) {
|
|
var _wv = window.CONFIG.clouds.density[_wd];
|
|
if (typeof _wv === 'number') WEATHER_DENSITY[_wd] = Math.max(0, Math.min(1, _wv));
|
|
}
|
|
}
|
|
|
|
var moonTexture = null, moonTexFailed = false;
|
|
function loadMoonTexture(gl) {
|
|
var img = new Image();
|
|
img.onload = function () {
|
|
moonTexture = gl.createTexture();
|
|
// flip so v = 0 is the southern edge: the map is north-up
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
|
gl.bindTexture(gl.TEXTURE_2D, moonTexture);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, img);
|
|
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.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
dbgLog('[clouds3d] moon map ready (' + img.width + 'x' + img.height + ')');
|
|
};
|
|
img.onerror = function () {
|
|
moonTexFailed = true;
|
|
console.warn('[clouds3d] moon map failed to load - flat disc instead');
|
|
};
|
|
img.src = MOON_TEX_SRC;
|
|
}
|
|
|
|
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%';
|
|
// Upscaling the low-res backbuffer without smoothing (pixel art).
|
|
canvas.style.imageRendering = 'pixelated';
|
|
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 renders the 3D at lower internal resolution and the
|
|
// CSS upscale (image-rendering: pixelated) blows it back up as blocks.
|
|
// The internal size is snapped to an exact integer divisor of the CSS
|
|
// size, otherwise some pixels become 2 wide and others 3 and the grid
|
|
// looks uneven.
|
|
var cw = target.clientWidth || window.innerWidth;
|
|
var ch = target.clientHeight || window.innerHeight;
|
|
var rs = (window.CONFIG && window.CONFIG.renderScale) || 1;
|
|
rs = Math.max(0.2, Math.min(1, rs));
|
|
var k = (rs >= 1) ? 1 : Math.max(1, Math.round(1 / rs));
|
|
var rw = (k === 1) ? cw : Math.floor(cw / k);
|
|
var rh = (k === 1) ? ch : Math.floor(ch / k);
|
|
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);
|
|
gl.uniform1f(s.uTerrainExag, TERRAIN_EXAG);
|
|
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]);
|
|
// the physical model is the only lighting model now
|
|
gl.uniform1f(s.uPhysical, 1.0);
|
|
|
|
// 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);
|
|
// Lunar map on texture unit 6. Until it is loaded the shader falls back
|
|
// to a flat grey disc (uMoonTexOn = 0), so a missing asset never blanks
|
|
// the moon.
|
|
if (!moonTexture && !moonTexFailed) loadMoonTexture(gl);
|
|
gl.activeTexture(gl.TEXTURE6);
|
|
gl.bindTexture(gl.TEXTURE_2D, moonTexture || s.blackTex);
|
|
gl.uniform1i(s.uMoonTex, 6);
|
|
gl.uniform1f(s.uMoonTexOn, moonTexture ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uMoonR, MOON_ANG_R);
|
|
gl.uniform1f(s.uMoonEarth, MOON_EARTHSHINE);
|
|
gl.uniform1f(s.uMoonLimb, MOON_LIMB_DARK);
|
|
gl.uniform1f(s.uMoonBright, MOON_BRIGHT);
|
|
gl.uniform1f(s.uMoonHalo, MOON_HALO);
|
|
gl.uniform1f(s.uMoonFlipU, MOON_FLIP_U);
|
|
gl.uniform1f(s.uMoonFlipV, MOON_FLIP_V);
|
|
gl.uniform1f(s.uSunR, SUN_ANG_R);
|
|
// texture path: RA alpha must land at world angle alpha + PI/2 - LST,
|
|
// which with u = fract(0.5 + (H + uStarLon)/2PI) means uStarLon = LST - 3PI/2
|
|
var _sidTex = starSidereal();
|
|
gl.uniform1f(s.uStarLon, _sidTex === null
|
|
? -((window.__currentDayProgress || 0) * 6.2831853)
|
|
: _sidTex - 4.712388980846547);
|
|
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);
|
|
// ---- One light state for the whole frame --------------------------
|
|
// Sky, clouds, terrain, contrails and planes all get the same beam,
|
|
// skylight, moonlight and starlight, so nothing is lit by a different
|
|
// sun than everything else.
|
|
var _moonAlt = Math.asin(Math.max(-1, Math.min(1,
|
|
(window.__currentMoonDir && window.__currentMoonDir[1] !== undefined)
|
|
? window.__currentMoonDir[1] : 0)));
|
|
var _moonFrac = (typeof window.__currentMoonFrac === 'number') ? window.__currentMoonFrac : 0.6;
|
|
var _beam = beamColor(sunAlt), _beamL = beamLuma(sunAlt);
|
|
var _sunDist = sunDistanceFactor(window.__currentSceneDate);
|
|
_beamL *= _sunDist;
|
|
var _star = starAmbient(sunAlt);
|
|
var _mBeam = beamColor(_moonAlt), _mL = beamLuma(_moonAlt);
|
|
var _moonUp = Math.max(0, Math.sin(_moonAlt));
|
|
var _moonI = ATMO_MOON_GAIN * MOON_LIGHT_GAIN * _moonFrac * _moonUp * _mL;
|
|
// diffuse moonlight: the whole sky gets brighter with the phase, not
|
|
// only the side the moon happens to point at
|
|
var _moonDiff = ATMO_MOON_GAIN * MOON_LIGHT_GAIN * _moonFrac * _moonUp * 0.9;
|
|
var _amb = skyAmbient(sunAlt) + _moonDiff;
|
|
var _air = airlightColor(sunAlt, _moonAlt, _moonFrac);
|
|
gl.uniform1f(s.uMoonFrac, _moonFrac);
|
|
// Theme tint on top of the physics (see themeLighting above). The
|
|
// exposure factor ramps from gain (day) to nightGain (night) with the
|
|
// same altitude ramp the terrain uses for its night cross-fade.
|
|
var _tl = themeLighting();
|
|
if (_tl) {
|
|
var _nf = Math.max(0, Math.min(1, -sunAlt / 0.4));
|
|
var _exp = _tl.gain * (1 - _nf) + _tl.nightGain * _nf;
|
|
_beam = mixRgb(_beam, _tl.sun, _tl.tint);
|
|
_mBeam = mixRgb(_mBeam, _tl.moon, _tl.tint);
|
|
_air = mixRgb(mixRgb(_air, _tl.air, _tl.tint), _tl.sky, _tl.tint * 0.45);
|
|
_beamL *= _exp;
|
|
_moonI *= _exp;
|
|
_amb *= _exp;
|
|
}
|
|
var _terrainHazeColor = hazeColorDynamic(TERRAIN_HAZE_COLOR, _air, _amb, _star, _moonI);
|
|
var _frontHazeColor = hazeColorDynamic(FRONT_HAZE_COLOR, _air, _amb, _star, _moonI);
|
|
var _airHazeColor = hazeColorDynamic(AIR_HAZE_COLOR, _air, _amb, _star, _moonI);
|
|
var _hazeMaxScale = 0.35 + 1.1 * Math.max(0, _amb) + 0.45 * Math.max(0, _star)
|
|
+ 0.25 * Math.max(0, _moonI);
|
|
_hazeMaxScale = Math.max(0.25, Math.min(1.0, _hazeMaxScale));
|
|
gl.uniform3f(s.uAirColor, _air[0], _air[1], _air[2]);
|
|
gl.uniform3f(s.uRayleighBeta, TAU_RAYLEIGH[0], TAU_RAYLEIGH[1], TAU_RAYLEIGH[2]);
|
|
gl.uniform1f(s.uSunAirMass, airMass(Math.max(sunAlt, 0.0)));
|
|
gl.uniform1f(s.uRayleighGain, ATMO_RAYLEIGH_GAIN);
|
|
gl.uniform1f(s.uPhysicalSkyOn, ATMO_PHYSICAL_SKY ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uAirHazeOn, AIR_HAZE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uAirHazeDensity, AIR_HAZE_DENSITY);
|
|
gl.uniform1f(s.uAirHazeScaleHeightUnits, AIR_HAZE_SCALE_HEIGHT_UNITS);
|
|
gl.uniform1f(s.uAirHazeGain, AIR_HAZE_GAIN);
|
|
gl.uniform1f(s.uAirHazeMaxAlpha, AIR_HAZE_MAX_ALPHA);
|
|
gl.uniform3f(s.uAirHazeColor, _airHazeColor[0], _airHazeColor[1], _airHazeColor[2]);
|
|
gl.uniform1i(s.uTerrainDistTex, 7);
|
|
gl.uniform1f(s.uCloudsOn, CLOUDS_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uFrontHazeOn, FRONT_HAZE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uFrontHazeDensity, FRONT_HAZE_DENSITY);
|
|
gl.uniform1f(s.uFrontHazeScaleHeightUnits, FRONT_HAZE_SCALE_HEIGHT_UNITS);
|
|
gl.uniform1f(s.uFrontHazeGain, FRONT_HAZE_GAIN);
|
|
gl.uniform1f(s.uFrontHazeMaxAlpha, FRONT_HAZE_MAX_ALPHA * _hazeMaxScale);
|
|
gl.uniform3f(s.uFrontHazeColor, _frontHazeColor[0], _frontHazeColor[1], _frontHazeColor[2]);
|
|
gl.uniform1f(s.uFarPlane, 100.0);
|
|
gl.uniform3f(s.uSunColor, _beam[0], _beam[1], _beam[2]);
|
|
gl.uniform1f(s.uSunInt, _beamL);
|
|
gl.uniform3f(s.uMoonColor, _mBeam[0], _mBeam[1], _mBeam[2]);
|
|
gl.uniform1f(s.uMoonInt, _moonI);
|
|
gl.uniform1f(s.uSkyAmb, _amb * ATMO_SKY_GAIN);
|
|
gl.uniform1f(s.uAtmoModelOn, ATMO_MODEL_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(s.uAtmoHeightUnits, ATMO_MODEL_HEIGHT_UNITS);
|
|
gl.uniform1f(s.uAtmoScaleHeightUnits, ATMO_MODEL_SCALE_HEIGHT_UNITS);
|
|
gl.uniform1f(s.uAtmoDensity, ATMO_MODEL_DENSITY);
|
|
gl.uniform1f(s.uAtmoGrayTau, ATMO_MODEL_GRAY_TAU);
|
|
gl.uniform1f(s.uAtmoGain, ATMO_MODEL_GAIN);
|
|
gl.uniform1f(s.uAtmoMaxAlpha, ATMO_MODEL_MAX_ALPHA);
|
|
gl.uniform1f(s.uEarthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform3fv(s.uAtmoBlueColor, ATMO_MODEL_BLUE_COLOR);
|
|
gl.uniform3fv(s.uAtmoGrayColor, ATMO_MODEL_GRAY_COLOR);
|
|
gl.uniform1f(s.uStarAmb, _star);
|
|
// Theme cloud hue kept, brightness normalised: the light model, not the
|
|
// palette, decides how bright a cloud is.
|
|
var _palNow = window.__currentPalette || pal;
|
|
var _pc = hex(_palNow.cloud);
|
|
var _pcm = (_pc[0] + _pc[1] + _pc[2]) / 3 || 1;
|
|
gl.uniform1f(s.uAirDensity, -Math.log(ATMO_HAZE_RESIDUAL) / Math.max(FOG_TILES, 0.001));
|
|
gl.uniform1f(s.uCloudHaze, ATMO_CLOUD_HAZE);
|
|
gl.uniform3f(s.uCloudAlbedo, Math.min(1, _pc[0] / _pcm * ATMO_CLOUD_BRIGHT),
|
|
Math.min(1, _pc[1] / _pcm * ATMO_CLOUD_BRIGHT),
|
|
Math.min(1, _pc[2] / _pcm * ATMO_CLOUD_BRIGHT));
|
|
|
|
var speed = (typeof themeSpeed === 'number')
|
|
? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0;
|
|
gl.uniform1f(s.uSpeed, speed);
|
|
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;
|
|
// ---- aim override: look at the moon, fly the same path -------------
|
|
// __currentMoonDir is a unit vector in the same world frame the view
|
|
// matrix uses, so the heading is just its horizontal part and the pitch
|
|
// is its altitude (negative pitch = above the horizon). Below the
|
|
// horizon the view holds the moon's azimuth at the horizon line instead
|
|
// of snapping back to the flight heading.
|
|
if (CAM_AIM_MOON) {
|
|
var _md = window.__currentMoonDir;
|
|
if (_md) {
|
|
var _hlen = Math.hypot(_md[0], _md[2]);
|
|
if (_hlen > 1e-4) {
|
|
var _alt = Math.asin(Math.max(-1, Math.min(1, _md[1])));
|
|
if (_alt < 0.02) _alt = 0.02;
|
|
else if (_alt > 1.40) _alt = 1.40;
|
|
var _tx = _md[0] / _hlen, _tz = _md[2] / _hlen, _tp = -_alt;
|
|
if (!aimReady) { aimHdX = _tx; aimHdZ = _tz; aimPitch = _tp; aimReady = true; }
|
|
var _k = 1 - CAM_AIM_EASE;
|
|
aimHdX += (_tx - aimHdX) * _k;
|
|
aimHdZ += (_tz - aimHdZ) * _k;
|
|
aimPitch += (_tp - aimPitch) * _k;
|
|
var _al = Math.hypot(aimHdX, aimHdZ) || 1;
|
|
hdX = aimHdX / _al;
|
|
hdZ = aimHdZ / _al;
|
|
pitch = aimPitch - (BOB_ON ? BOB_TILT * Math.cos(bob) * CAM_AIM_BOB : 0);
|
|
}
|
|
}
|
|
}
|
|
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.uniform3f(s.uCamPos, camX, camY, camZ);
|
|
gl.uniform1f(s.uCurvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
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.
|
|
// same ramp as starVis in the sky shader: no catalogue points while the
|
|
// horizon is still in the red dusk palette
|
|
var starNight = 1 - Math.max(0, Math.min(1, (sunAlt + 0.05) / 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);
|
|
// colour starts at float 4 of the 8-float record (dx,dy,dz,mag,r,g,b,
|
|
// brightness) -> byte offset 16. With 12 the shader read
|
|
// (mag, r, g, b): mag clamped to 1.0 became the RED channel, which is
|
|
// why every catalogue star looked red no matter what tint was set.
|
|
gl.vertexAttribPointer(skyPts.a1, 4, gl.FLOAT, false, 32, 16);
|
|
gl.uniformMatrix4fv(skyPts.u.mvp, false, starMVP);
|
|
gl.uniform3f(skyPts.u.pole, 0, SKY_SIN_LAT, SKY_COS_LAT);
|
|
// point path: the frame is rotated about the pole, so RA alpha sits at
|
|
// world angle alpha + lon -> lon = PI/2 - LST puts alpha on the
|
|
// meridian exactly when LST = alpha
|
|
var _sidPts = starSidereal();
|
|
gl.uniform1f(skyPts.u.lon, _sidPts === null
|
|
? -((window.__currentDayProgress || 0) * 6.2831853)
|
|
: 1.5707963267948966 - _sidPts);
|
|
gl.uniform1f(skyPts.u.sizeScale, canvas.height / 900);
|
|
var _mpt = window.__currentMoonDir || [0, 1, 0];
|
|
gl.uniform3f(skyPts.u.moonDir, _mpt[0], _mpt[1], _mpt[2]);
|
|
gl.uniform1f(skyPts.u.moonR, MOON_ANG_R);
|
|
gl.uniform1f(skyPts.u.night, starNight);
|
|
gl.uniform1f(skyPts.u.time, now);
|
|
gl.uniform1f(skyPts.u.bright, STAR_BRIGHT);
|
|
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);
|
|
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
|
|
// Cloud shadows: the terrain samples the SAME cloud field as the
|
|
// sky pass (same noise, same uTime, same coverage) and marches from
|
|
// the surface towards the sun through the deck.
|
|
gl.uniform1f(terr.uTime, now);
|
|
gl.uniform1f(terr.uCloudBot, CLOUD_BOT);
|
|
gl.uniform1f(terr.uCloudTop, CLOUD_TOP);
|
|
gl.uniform1f(terr.uDensity, density);
|
|
gl.uniform1f(terr.uCloudShadowOn, ATMO_CLOUD_SHADOWS ? 1.0 : 0.0);
|
|
gl.uniform1f(terr.uCloudShadowDensity, ATMO_CS_DENSITY);
|
|
gl.uniform1f(terr.uCloudShadowFloor, ATMO_CS_FLOOR);
|
|
gl.uniform1f(terr.uCloudShadowAmbient, ATMO_CS_AMBIENT);
|
|
// height field on texture unit 5 for the terrain's own shadow march
|
|
gl.activeTexture(gl.TEXTURE5);
|
|
gl.bindTexture(gl.TEXTURE_2D, elevTexture || blackTex);
|
|
gl.uniform1i(terr.uElev, 5);
|
|
gl.uniform1f(terr.uTerrainShadowOn, (ATMO_TERRAIN_SHADOWS && elevTexture) ? 1.0 : 0.0);
|
|
gl.uniform1f(terr.uTerrainShadowFloor, ATMO_TERRAIN_SHADOW_FLOOR);
|
|
gl.uniform3f(terr.uSunDir, sd[0], sd[1], sd[2]);
|
|
// ---- physical light for the terrain ----------------------------
|
|
var _pl = hex(pal.light);
|
|
var _pm = (_pl[0] + _pl[1] + _pl[2]) / 3 || 1;
|
|
gl.uniform3f(terr.uSunColor, _beam[0], _beam[1], _beam[2]);
|
|
gl.uniform1f(terr.uSunInt, _beamL);
|
|
gl.uniform3f(terr.uMoonColor, _mBeam[0], _mBeam[1], _mBeam[2]);
|
|
gl.uniform1f(terr.uMoonInt, _moonI);
|
|
gl.uniform1f(terr.uSkyAmb, _amb * ATMO_SKY_GAIN);
|
|
gl.uniform1f(terr.uStarAmb, _star * ATMO_STAR_GROUND);
|
|
gl.uniform3f(terr.uAlbedoTint, _pl[0] / _pm * ATMO_ALBEDO_GAIN,
|
|
_pl[1] / _pm * ATMO_ALBEDO_GAIN, _pl[2] / _pm * ATMO_ALBEDO_GAIN);
|
|
// The mosaic edge fades into the same airlight the aerial
|
|
// perspective produces, so the rim of the ground matches the haze
|
|
// instead of a separate grey veil.
|
|
var _hor = 0.75;
|
|
gl.uniform3f(terr.uHorizonColor, _air[0] * _hor, _air[1] * _hor, _air[2] * _hor);
|
|
gl.uniform3f(terr.uAirColor, _air[0], _air[1], _air[2]);
|
|
gl.uniform1f(terr.uAirDensity, -Math.log(ATMO_HAZE_RESIDUAL) / Math.max(FOG_TILES, 0.001));
|
|
gl.uniform3f(terr.uCamPos, camX, camY, camZ);
|
|
gl.uniform1f(terr.uEarthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(terr.uCurvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(terr.uEdgeFadeTiles, EDGE_FADE_TILES);
|
|
gl.uniform1f(terr.uHazeOn, TERRAIN_HAZE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(terr.uHazeDensity, TERRAIN_HAZE_DENSITY);
|
|
gl.uniform1f(terr.uHazeScaleHeightUnits, TERRAIN_HAZE_SCALE_HEIGHT_UNITS);
|
|
gl.uniform1f(terr.uHazeGain, TERRAIN_HAZE_GAIN);
|
|
gl.uniform1f(terr.uHazeMaxAlpha, TERRAIN_HAZE_MAX_ALPHA * _hazeMaxScale);
|
|
gl.uniform3f(terr.uHazeColor, _terrainHazeColor[0], _terrainHazeColor[1], _terrainHazeColor[2]);
|
|
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, terr.vbo);
|
|
var STRIDE = 11 * 4; // 11 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);
|
|
}
|
|
if (terr.aColor >= 0) {
|
|
gl.enableVertexAttribArray(terr.aColor);
|
|
gl.vertexAttribPointer(terr.aColor, 3, gl.FLOAT, false, STRIDE, 32);
|
|
}
|
|
var distPassOk = (FRONT_HAZE_ON || CLOUDS_ON) && ensureTerrainDistFbo(gl, canvas.width, canvas.height);
|
|
if (distPassOk) {
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, terrainDistFbo);
|
|
gl.viewport(0, 0, terrainDistW, terrainDistH);
|
|
gl.clearColor(0, 0, 0, 0);
|
|
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(true);
|
|
gl.disable(gl.BLEND);
|
|
gl.uniform1f(terr.uDistancePassOn, 1.0);
|
|
gl.uniform1f(terr.uFarPlane, 100.0);
|
|
gl.drawArrays(gl.TRIANGLES, 0, terr.vertCount);
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
}
|
|
gl.uniform1f(terr.uDistancePassOn, 0.0);
|
|
gl.uniform1f(terr.uFarPlane, 100.0);
|
|
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);
|
|
if (terr.aColor >= 0) gl.disableVertexAttribArray(terr.aColor);
|
|
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).
|
|
// Aggregate artificial-light level (road ribbon, density curves,
|
|
// aircraft): the mean over the per-light thresholds.
|
|
var carNight = lightFactor(window.__currentSunAlt || 0);
|
|
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.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(carUniforms.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(carUniforms.time, now);
|
|
gl.uniform1f(carUniforms.night, carNight);
|
|
// dot radius the light needs per unit distance to still reach
|
|
// CAR_MIN_PX diameter (0.5774 = tan(fovY/2) of the 60 deg vertical fov)
|
|
gl.uniform1f(carUniforms.grow, CAR_BAND_PX * 0.5774 / gl.drawingBufferHeight);
|
|
gl.uniform1f(carUniforms.halfW, CAR_W);
|
|
gl.uniform1f(carUniforms.bright, CAR_BAND_BRIGHT);
|
|
gl.uniform1f(carUniforms.fadeA, CAR_FADE_A);
|
|
gl.uniform1f(carUniforms.fadeB, CAR_FADE_B);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
gl.depthMask(false);
|
|
gl.enable(gl.BLEND);
|
|
gl.blendFunc(gl.ONE, gl.ONE); // additive
|
|
var carDrawGroup = function (vbo, count, 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.dens, maxDens * tCurve * rush);
|
|
gl.drawArrays(gl.TRIANGLES, 0, count);
|
|
};
|
|
carDrawGroup(carVboM, carCountM, CAR_M_DENS);
|
|
carDrawGroup(carVboB, carCountB, CAR_B_DENS);
|
|
// -- vehicle lights: round point sprites --
|
|
if (carPtsCountM || carPtsCountB) {
|
|
if (!carPtsProg) {
|
|
var cpp = makeProgram(gl, CARP_VERT_SRC, CARP_FRAG_SRC);
|
|
if (cpp) {
|
|
carPtsProg = cpp;
|
|
carPtsAttribs = {
|
|
pos: gl.getAttribLocation(cpp, 'aPos'),
|
|
meta: gl.getAttribLocation(cpp, 'aMeta'),
|
|
dens: gl.getAttribLocation(cpp, 'aDens'),
|
|
seed: gl.getAttribLocation(cpp, 'aSeed'),
|
|
len: gl.getAttribLocation(cpp, 'aLen')
|
|
};
|
|
carPtsUniforms = {
|
|
proj: gl.getUniformLocation(cpp, 'uProj'),
|
|
view: gl.getUniformLocation(cpp, 'uView'),
|
|
camPos: gl.getUniformLocation(cpp, 'uCamPos'),
|
|
earthRadius: gl.getUniformLocation(cpp, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(cpp, 'uCurvatureOn'),
|
|
time: gl.getUniformLocation(cpp, 'uTime'),
|
|
night: gl.getUniformLocation(cpp, 'uNight'),
|
|
speed: gl.getUniformLocation(cpp, 'uSpeed'),
|
|
dash: gl.getUniformLocation(cpp, 'uDash'),
|
|
laneW: gl.getUniformLocation(cpp, 'uLaneW'),
|
|
lanePx: gl.getUniformLocation(cpp, 'uLanePx'),
|
|
pxW: gl.getUniformLocation(cpp, 'uPxW'),
|
|
traffic: gl.getUniformLocation(cpp, 'uTraffic'),
|
|
minPx: gl.getUniformLocation(cpp, 'uMinPx'),
|
|
maxPx: gl.getUniformLocation(cpp, 'uMaxPx'),
|
|
boost: gl.getUniformLocation(cpp, 'uBoost'),
|
|
dens: gl.getUniformLocation(cpp, 'uDens'),
|
|
bright: gl.getUniformLocation(cpp, 'uBright'),
|
|
fadeA: gl.getUniformLocation(cpp, 'uFadeA'),
|
|
fadeB: gl.getUniformLocation(cpp, 'uFadeB'),
|
|
sunAlt: gl.getUniformLocation(cpp, 'uSunAlt'),
|
|
lightHi: gl.getUniformLocation(cpp, 'uLightHi'),
|
|
lightLo: gl.getUniformLocation(cpp, 'uLightLo'),
|
|
lightSoft: gl.getUniformLocation(cpp, 'uLightSoft')
|
|
};
|
|
}
|
|
}
|
|
if (carPtsProg) {
|
|
gl.useProgram(carPtsProg);
|
|
gl.uniformMatrix4fv(carPtsUniforms.proj, false, proj);
|
|
gl.uniformMatrix4fv(carPtsUniforms.view, false, view);
|
|
gl.uniform3f(carPtsUniforms.camPos, camX, camY, camZ);
|
|
gl.uniform1f(carPtsUniforms.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(carPtsUniforms.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(carPtsUniforms.time, now);
|
|
gl.uniform1f(carPtsUniforms.night, carNight);
|
|
gl.uniform1f(carPtsUniforms.sunAlt, window.__currentSunAlt || 0);
|
|
gl.uniform1f(carPtsUniforms.lightHi, LIGHT_HI);
|
|
gl.uniform1f(carPtsUniforms.lightLo, LIGHT_LO);
|
|
gl.uniform1f(carPtsUniforms.lightSoft, LIGHT_SOFT);
|
|
// world units covered by one pixel at unit distance:
|
|
// 2*tan(fovY/2)/H = 1.1547/H for the 60 deg vertical fov
|
|
gl.uniform1f(carPtsUniforms.laneW, CAR_LANE_W);
|
|
gl.uniform1f(carPtsUniforms.lanePx, CAR_LANE_PX);
|
|
// world units covered by one pixel at unit distance:
|
|
// 2*tan(fovY/2)/H = 1.1547/H for the 60 deg vertical fov
|
|
gl.uniform1f(carPtsUniforms.pxW, 1.1547 / gl.drawingBufferHeight);
|
|
gl.uniform1f(carPtsUniforms.traffic, CAR_TRAFFIC);
|
|
gl.uniform1f(carPtsUniforms.minPx, CAR_MIN_PX);
|
|
gl.uniform1f(carPtsUniforms.maxPx, CAR_MAX_PX);
|
|
gl.uniform1f(carPtsUniforms.boost, CAR_BOOST);
|
|
gl.uniform1f(carPtsUniforms.bright, CAR_BRIGHT);
|
|
gl.uniform1f(carPtsUniforms.fadeA, CAR_FADE_A);
|
|
gl.uniform1f(carPtsUniforms.fadeB, CAR_FADE_B);
|
|
var carPtsGroup = function (vbo, count, dash, speed, maxDens) {
|
|
if (!vbo || !count) return;
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
gl.enableVertexAttribArray(carPtsAttribs.pos);
|
|
gl.vertexAttribPointer(carPtsAttribs.pos, 3, gl.FLOAT, false, 40, 0);
|
|
gl.enableVertexAttribArray(carPtsAttribs.meta);
|
|
gl.vertexAttribPointer(carPtsAttribs.meta, 4, gl.FLOAT, false, 40, 12);
|
|
gl.enableVertexAttribArray(carPtsAttribs.dens);
|
|
gl.vertexAttribPointer(carPtsAttribs.dens, 1, gl.FLOAT, false, 40, 28);
|
|
gl.enableVertexAttribArray(carPtsAttribs.seed);
|
|
gl.vertexAttribPointer(carPtsAttribs.seed, 1, gl.FLOAT, false, 40, 32);
|
|
gl.enableVertexAttribArray(carPtsAttribs.len);
|
|
gl.vertexAttribPointer(carPtsAttribs.len, 1, gl.FLOAT, false, 40, 36);
|
|
gl.uniform1f(carPtsUniforms.dash, dash);
|
|
gl.uniform1f(carPtsUniforms.speed, speed);
|
|
gl.uniform1f(carPtsUniforms.dens, maxDens * tCurve * rush);
|
|
gl.drawArrays(gl.POINTS, 0, count);
|
|
};
|
|
carPtsGroup(carPtsVboM, carPtsCountM, CAR_M_DASH, CAR_M_SPEED, CAR_M_DENS);
|
|
carPtsGroup(carPtsVboB, carPtsCountB, 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.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(cityUniforms.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(cityUniforms.night, Math.min(1, carNight * 1.35));
|
|
gl.uniform1f(cityUniforms.sunAlt, window.__currentSunAlt || 0);
|
|
gl.uniform1f(cityUniforms.lightHi, LIGHT_HI);
|
|
gl.uniform1f(cityUniforms.lightLo, LIGHT_LO);
|
|
gl.uniform1f(cityUniforms.lightSoft, LIGHT_SOFT);
|
|
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 (ATMO_MODEL_ON) {
|
|
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.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, 2);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
}
|
|
|
|
if (FRONT_HAZE_ON && terrainDistOk && terrainDistTex) {
|
|
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.activeTexture(gl.TEXTURE7);
|
|
gl.bindTexture(gl.TEXTURE_2D, terrainDistTex);
|
|
gl.uniform1i(s.uTerrainDistTex, 7);
|
|
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, 3);
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
}
|
|
|
|
if (CLOUDS_ON) {
|
|
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.activeTexture(gl.TEXTURE7);
|
|
gl.bindTexture(gl.TEXTURE_2D, terrainDistTex || null);
|
|
gl.uniform1i(s.uTerrainDistTex, 7);
|
|
gl.uniform1f(s.uTerrainDistOk, (terrainDistOk && terrainDistTex) ? 1.0 : 0.0);
|
|
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);
|
|
}
|
|
|
|
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] = f.big; // destination rank also drives light size
|
|
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'),
|
|
earthRadius: gl.getUniformLocation(fltProg, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(fltProg, 'uCurvatureOn'),
|
|
scale: gl.getUniformLocation(fltProg, 'uScale'),
|
|
time: gl.getUniformLocation(fltProg, 'uTime'),
|
|
night: gl.getUniformLocation(fltProg, 'uNight'),
|
|
minPx: gl.getUniformLocation(fltProg, 'uMinPx'),
|
|
maxPx: gl.getUniformLocation(fltProg, 'uMaxPx'),
|
|
boost: gl.getUniformLocation(fltProg, 'uBoost'),
|
|
blinkT: gl.getUniformLocation(fltProg, 'uBlinkT'),
|
|
blinkGap: gl.getUniformLocation(fltProg, 'uBlinkGap'),
|
|
blinkW: gl.getUniformLocation(fltProg, 'uBlinkW')
|
|
};
|
|
}
|
|
}
|
|
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.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(fltUniforms.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform1f(fltUniforms.scale, gl.drawingBufferHeight / 600);
|
|
gl.uniform1f(fltUniforms.time, now);
|
|
gl.uniform1f(fltUniforms.night, Math.min(1, carNight * 1.2));
|
|
gl.uniform1f(fltUniforms.minPx, FLT_LPX);
|
|
gl.uniform1f(fltUniforms.maxPx, FLT_LMAX);
|
|
gl.uniform1f(fltUniforms.boost, FLT_LBOOST);
|
|
gl.uniform1f(fltUniforms.blinkT, FLT_BLINK_T);
|
|
gl.uniform1f(fltUniforms.blinkGap, FLT_BLINK_GAP);
|
|
gl.uniform1f(fltUniforms.blinkW, FLT_BLINK_W);
|
|
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);
|
|
}
|
|
}
|
|
// -- aircraft model: drawn day AND night --
|
|
decodePlaneVerts();
|
|
var sunA = window.__currentSunAlt || 0;
|
|
var dayf = 1 - Math.min(1, Math.max(0, -sunA / 0.30));
|
|
if (planeN) {
|
|
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'),
|
|
camPos: gl.getUniformLocation(pp, 'uCamPos'),
|
|
earthRadius: gl.getUniformLocation(pp, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(pp, 'uCurvatureOn'),
|
|
origin: gl.getUniformLocation(pp, 'uOrigin'),
|
|
orit: gl.getUniformLocation(pp, 'uOrit'),
|
|
s: gl.getUniformLocation(pp, 'uS'),
|
|
sun: gl.getUniformLocation(pp, 'uSun'),
|
|
light: gl.getUniformLocation(pp, 'uLight'),
|
|
moon: gl.getUniformLocation(pp, 'uMoon'),
|
|
moonAmt: gl.getUniformLocation(pp, 'uMoonAmt')
|
|
};
|
|
}
|
|
}
|
|
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.camPos, camX, camY, camZ);
|
|
gl.uniform1f(planeU.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(planeU.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform3f(planeU.sun, sunv[0], sunv[1], sunv[2]);
|
|
gl.uniform1f(planeU.light, dayf);
|
|
var moonv = window.__currentMoonDir || [0, 1, 0];
|
|
var mFrac = (typeof window.__currentMoonFrac === 'number') ? window.__currentMoonFrac : 0.6;
|
|
// only the part of the moon that is actually above the horizon
|
|
// lights the aircraft, scaled by how much of it is lit
|
|
gl.uniform3f(planeU.moon, moonv[0], moonv[1], moonv[2]);
|
|
gl.uniform1f(planeU.moonAmt, Math.max(0, Math.min(1, moonv[1] * 2.0)) * (0.30 + 0.70 * mFrac));
|
|
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);
|
|
// mesh spans ~2.0 along its axis: uS = CT_HALF_W * 2 puts the
|
|
// nose-to-tail at twice the contrail width
|
|
// CT_HALF_W doubled with the trail width, so the factor
|
|
// halves: the aircraft silhouette keeps its old size.
|
|
var S0 = CT_HALF_W * 1.0;
|
|
var _meshDrawn = 0;
|
|
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 = full 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);
|
|
_meshDrawn++;
|
|
}
|
|
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 * CT_NIGHT);
|
|
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);
|
|
// distance fade: full out to 35% of contrailFadeKm, gone at
|
|
// contrailFadeKm. Trails that thin only at 200-340 km were
|
|
// the remaining shimmer at 4x pixel scale.
|
|
var dfc = 1 - (distc - CT_FADE_START) / (CT_FADE_END - CT_FADE_START);
|
|
if (dfc > 1) dfc = 1; else if (dfc <= 0.02) continue;
|
|
// sun-lit by day, moonlit after dusk. dayf alone made the
|
|
// stripes vanish completely at night although the pass ran.
|
|
var lit = Math.max(dayf, carNight * CT_NIGHT);
|
|
var strg = gt * lit * 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'),
|
|
camPos: gl.getUniformLocation(cpm, 'uCamPos'),
|
|
earthRadius: gl.getUniformLocation(cpm, 'uEarthRadius'),
|
|
curvatureOn: gl.getUniformLocation(cpm, 'uCurvatureOn'),
|
|
sunDir: gl.getUniformLocation(cpm, 'uSunDir'),
|
|
sunColor: gl.getUniformLocation(cpm, 'uSunColor'),
|
|
sunInt: gl.getUniformLocation(cpm, 'uSunInt'),
|
|
moonColor: gl.getUniformLocation(cpm, 'uMoonColor'),
|
|
moonInt: gl.getUniformLocation(cpm, 'uMoonInt'),
|
|
skyAmb: gl.getUniformLocation(cpm, 'uSkyAmb'),
|
|
starAmb: gl.getUniformLocation(cpm, 'uStarAmb'),
|
|
nightBoost: gl.getUniformLocation(cpm, 'uNightBoost') };
|
|
}
|
|
}
|
|
if (ctProg) {
|
|
gl.useProgram(ctProg);
|
|
gl.uniformMatrix4fv(ctU.proj, false, proj);
|
|
gl.uniformMatrix4fv(ctU.view, false, view);
|
|
gl.uniform3f(ctU.camPos, camX, camY, camZ);
|
|
gl.uniform1f(ctU.earthRadius, EARTH_RADIUS_UNITS);
|
|
gl.uniform1f(ctU.curvatureOn, CURVATURE_ON ? 1.0 : 0.0);
|
|
gl.uniform3f(ctU.sunDir, sd[0], sd[1], sd[2]);
|
|
gl.uniform3f(ctU.sunColor, _beam[0], _beam[1], _beam[2]);
|
|
gl.uniform1f(ctU.sunInt, _beamL);
|
|
gl.uniform3f(ctU.moonColor, _mBeam[0], _mBeam[1], _mBeam[2]);
|
|
gl.uniform1f(ctU.moonInt, _moonI);
|
|
gl.uniform1f(ctU.skyAmb, _amb * ATMO_SKY_GAIN);
|
|
gl.uniform1f(ctU.starAmb, _star);
|
|
gl.uniform1f(ctU.nightBoost, 1.0 + carNight * (CT_NIGHT_BOOST - 1.0));
|
|
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.disable(gl.DEPTH_TEST);
|
|
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);
|
|
gl.enable(gl.DEPTH_TEST);
|
|
}
|
|
}
|
|
// Diagnostic snapshot: how many aircraft are in range, how many
|
|
// meshes were actually drawn and how much contrail geometry made
|
|
// it past the distance gates.
|
|
window.__flightDbg = {
|
|
aircraft: fltCount,
|
|
meshDrawn: _meshDrawn,
|
|
ctVerts: ctVerts,
|
|
day: +dayf.toFixed(2),
|
|
night: +carNight.toFixed(2),
|
|
meshRangeKm: Math.round(16 * KM_PER_TILE)
|
|
};
|
|
if (DEBUG_LOG && now - _flightDbgT > 5000) {
|
|
_flightDbgT = now;
|
|
dbgLog('[clouds3d] flights:', JSON.stringify(window.__flightDbg));
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
};
|
|
|
|
})(); |