From 6eb25bdd03a0a3aaa3c10758f5ff71b04ee020bc Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 11 Oct 2026 15:04:21 +0200 Subject: [PATCH] physical lighting, volumetric clouds, terrain shadows, pixel-art render scale - atmosphere: physical terrain lighting (air mass, Rayleigh beam colour, skylight, moon, starlight floor), aerial perspective for terrain and cloud deck, configurable haze/albedo/ambient gains - clouds: march the deck in height instead of ray distance, IGN dither (no per-frame flicker), per-step height jitter, 4-octave fbm renormalised, softer density threshold, distance softening as a procedural mip, grazing path clamp, cloud shadows on the ground (cloudShadowDensity/ Floor/Ambient) and cloud haze factor - terrain: self-shadows from the elevation field (penumbra + shadow floor), soft mosaic rim (edgeFadeTiles), east/west axis corrected (+X = west) - lights: per-object twilight switching for cars, city dots and ground blocks - flights: contrail width 620 m, fade to zero by contrailFadeKm, aircraft size decoupled from trail width, count halved - render: CONFIG.renderScale with integer upscale + image-rendering: pixelated - data: nginx overpass proxy + cache, per-mirror cooldown ladder, ring-based cell ordering (disc fills evenly), settlements fetched before roads, interim chain persistence, two worker lanes (car/city) with progress logs - removed: NASA VIIRS night-lights layer (fetch, canvas, texture, uniforms) --- Dockerfile | 1 + background.html | 135 +- config.js | 166 ++- default.conf | 85 ++ overpass-cache.conf | 1 + vendor/clouds3d.js | 2986 ++++++++++++++++++++++++++++++++----------- 6 files changed, 2579 insertions(+), 795 deletions(-) create mode 100644 overpass-cache.conf diff --git a/Dockerfile b/Dockerfile index 4867274..a523159 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ FROM nginx:alpine +COPY overpass-cache.conf /etc/nginx/conf.d/00-overpass-cache.conf COPY default.conf /etc/nginx/conf.d/default.conf COPY ash.css /usr/share/nginx/html/ash.css COPY gruvbox.css /usr/share/nginx/html/gruvbox.css diff --git a/background.html b/background.html index 67456dc..7f75b40 100644 --- a/background.html +++ b/background.html @@ -191,21 +191,27 @@ bottom: 2vh; color: var(--bone); background: rgba(28, 27, 31, 0.7); - padding: 1vh 1vw; + padding: 1vh 1em; border-radius: 0.6vh; z-index: 9999; - font-size: clamp(10px, 1.2vh, 14px); + /* Everything below is derived from vh (font-size) or em (which + follows font-size), so the whole panel is one scale factor: + grow the viewport and it grows, shrink it and it shrinks. + No px floors - those were what made the content overflow the + box in both directions on short screens. */ + font-size: 1.5vh; line-height: 1.4; - width: clamp(260px, min(33vh, 28vw), 40vw); + width: min(32vh, 44vw); display: flex; flex-direction: column; + overflow-x: hidden; overflow-y: auto; align-items: stretch; } @media (orientation: portrait) and (max-width: 700px) { #info-panel { - width: 90vw; + width: min(88vw, 46vh); } } #analog-clock { @@ -218,7 +224,7 @@ #digital-clock { text-align: center; font-size: 1.3em; - letter-spacing: 2px; + letter-spacing: 0.12em; margin-bottom: 0.6vh; padding-bottom: 0.5vh; border-bottom: 1px solid rgba(230, 223, 209, 0.2); @@ -227,7 +233,7 @@ .info-row { display: flex; align-items: center; - gap: 0.6vw; + gap: 0.5em; padding: 0.05vh 0; } .info-icon { @@ -246,7 +252,7 @@ flex-grow: 1; display: flex; justify-content: space-between; - gap: 0.6vw; + gap: 0.5em; } .info-label { color: var(--icon); @@ -258,7 +264,7 @@ .info-sub { color: var(--stone); font-size: 0.85em; - margin-left: 4px; + margin-left: 0.3em; } .weather-divider { height: 1px; @@ -268,7 +274,7 @@ .weather-current { display: flex; align-items: center; - gap: 0.8vw; + gap: 0.7em; padding: 0.8vh 0 1vh 0; border-bottom: 1px solid rgba(230, 223, 209, 0.2); margin-bottom: 1vh; @@ -285,7 +291,7 @@ display: flex; justify-content: space-between; align-items: baseline; - gap: 1vw; + gap: 0.8em; } .weather-location { font-size: 0.45em; @@ -329,7 +335,7 @@ .weather-day-slots { display: flex; justify-content: space-between; - gap: 0.3vw; + gap: 0.3em; font-size: 0.9em; margin-top: 0.3vh; } @@ -583,8 +589,34 @@ var _maxFps = (window.CONFIG && typeof window.CONFIG.maxFps === 'number') ? window.CONFIG.maxFps : 30; var _frameGap = _maxFps > 0 ? 1000 / _maxFps : 0; var _lastDraw = 0; + +// ---- Info panel auto-fit ------------------------------------------------- +// The panel box itself is viewport-sized (top/bottom 2vh) and every size +// inside it is em/vh based, so a single font size scales the whole content. +// Binary search that font size until the content fills the box exactly: +// no scrollbar at any viewport size, and the proportions never change. +function fitInfoPanel() { + var p = document.getElementById('info-panel'); + if (!p) return; + var avail = p.clientHeight; + if (!avail) return; + var lo = 0.4, hi = 3.0; + for (var i = 0; i < 9; i++) { + var mid = (lo + hi) / 2; + p.style.fontSize = mid + 'vh'; + if (p.scrollHeight <= avail + 1) lo = mid; else hi = mid; + } + p.style.fontSize = lo.toFixed(3) + 'vh'; +} +var _fitTimer = null; +function scheduleFitInfoPanel() { + if (_fitTimer) clearTimeout(_fitTimer); + _fitTimer = setTimeout(fitInfoPanel, 120); +} + sceneRender(); window.addEventListener('resize', sceneRender); +window.addEventListener('resize', scheduleFitInfoPanel); (function sceneLoop() { window._sceneRaf = requestAnimationFrame(sceneLoop); var t = performance.now(); @@ -1316,6 +1348,13 @@ window.sunPalettes = [ function updateClockAndEvents() { var now = new Date(); + // Simulated wall clock: with CONFIG.day.simulation the scene runs + // a whole day in cycleSeconds, so the clock hands, the digital + // readout and every sun/moon table follow the accelerated date + // instead of the real one - the scene and the clock stay in step. + // simNow() is the same accelerated date the weather/calendar + // layer uses, so nothing drifts apart. + if (DAY_SIM) now = simNow(); // Calculate real day progress based on sun position // 0% = midnight, 50% = noon, 100% = next midnight @@ -1805,6 +1844,7 @@ window.weatherThemes = { function updateDebug(t, palette, sceneColors, themeName, realDayProgress) { var now = new Date(); + if (DAY_SIM) now = simNow(); var hh = now.getHours(), mm = now.getMinutes(), ss = now.getSeconds(); document.getElementById('dbg-time').textContent = (hh < 10 ? '0' : '') + hh + ':' + (mm < 10 ? '0' : '') + mm + ':' + (ss < 10 ? '0' : '') + ss; @@ -1948,6 +1988,12 @@ window.weatherThemes = { // bearing: 6:00 (t=0.25) = east (pi/2), 12:00 = south (pi), // 18:00 = west (3pi/2) -> bearing = 2*PI*t var _sunBearing = t * Math.PI * 2; + // World frame (verified from the star frame and the terrain + // mesh): +X points WEST, +Y up, +Z north. The star frame maps + // RA=0 to +X and rotates it to due west after 6 h of sidereal + // time, and the mesh samples the mosaic with u = 0.5 - x/size, + // so east is -X. The east component therefore has to land on + // -cos(alt)*sin(bearing). window.__currentSunDir = [ -Math.cos(_alt) * Math.sin(_sunBearing), Math.sin(_alt), @@ -1957,9 +2003,10 @@ window.weatherThemes = { var _sunPos = SunCalc.getPosition(new Date(), SITE_LAT, SITE_LON); window.__currentSunAlt = _sunPos.altitude; window.__flightDow = new Date().getDay(); - // Unit vector towards the sun in world space (X east, Z north, - // Y up). SunCalc azimuth is measured from south, clockwise -> - // bearing from north = azimuth + PI. + // Unit vector towards the sun in world space. SunCalc azimuth + // is measured from the south, positive towards the west, so + // the compass bearing (0 = north, clockwise) is azimuth + PI. + // World frame: +X = west, +Z = north, +Y = up -> east is -X. var _sunBearingR = _sunPos.azimuth + Math.PI; window.__currentSunDir = [ -Math.cos(_sunPos.altitude) * Math.sin(_sunBearingR), @@ -1967,6 +2014,26 @@ window.weatherThemes = { Math.cos(_sunPos.altitude) * Math.cos(_sunBearingR) ]; } + // ---- Local sidereal time ------------------------------------- + // Same formula SunCalc uses internally (Meeus): GMST in degrees + // plus the observer's east longitude. This is what the star + // frame has to follow - a solar day fraction is 3 min 56 s per + // day off and has no absolute phase at all. + var _sidD = (Date.now() - Date.UTC(2000, 0, 1, 12, 0, 0)) / 86400000; + var _sidReal = (280.16 + 360.9856235 * _sidD + SITE_LON) * Math.PI / 180; + if (DAY_SIM) { + if (typeof window.__sidStart !== 'number') { + window.__sidStart = _sidReal; + window.__sidStartMs = Date.now(); + } + // simulated days elapsed * sidereal rate (1.0027379 solar days + // per sidereal day) + var _sidDays = (Date.now() - window.__sidStartMs) / CYCLE; + window.__currentSiderealRad = window.__sidStart + _sidDays * 2 * Math.PI * 1.0027379; + } else { + window.__currentSiderealRad = _sidReal; + } + // ---- Moon direction ------------------------------------------ // Start elongation = real lunar phase (SunCalc: 0 new .. 0.5 // full); in day simulation the elongation advances 360/29.53 @@ -1985,11 +2052,30 @@ window.weatherThemes = { var _cx = _kp[1]*_sd[2] - _kp[2]*_sd[1]; var _cy = _kp[2]*_sd[0] - _kp[0]*_sd[2]; var _cz = _kp[0]*_sd[1] - _kp[1]*_sd[0]; - window.__currentMoonDir = [ - _sd[0]*_ecs + _cx*_esn + _kp[0]*_kdv*(1-_ecs), - _sd[1]*_ecs + _cy*_esn + _kp[1]*_kdv*(1-_ecs), - _sd[2]*_ecs + _cz*_esn + _kp[2]*_kdv*(1-_ecs) - ]; + if (DAY_SIM) { + window.__currentMoonDir = [ + _sd[0]*_ecs + _cx*_esn + _kp[0]*_kdv*(1-_ecs), + _sd[1]*_ecs + _cy*_esn + _kp[1]*_kdv*(1-_ecs), + _sd[2]*_ecs + _cz*_esn + _kp[2]*_kdv*(1-_ecs) + ]; + // illuminated fraction straight from the elongation used above + // (0 = new, 1 = full) so other layers can light themselves by it + window.__currentMoonFrac = (1 - Math.cos(_elong)) * 0.5; + } else { + // Real mode: the geometric construction above keeps the moon on + // the SUN's declination circle. The real moon leaves it by up to + // 28.5 deg (obliquity 23.4 + lunar inclination 5.1), which puts + // it in the wrong part of the sky. SunCalc solves the actual + // lunar ephemeris, so use its altitude/azimuth directly. + var _moonPos = SunCalc.getMoonPosition(new Date(), SITE_LAT, SITE_LON); + var _moonBearingR = _moonPos.azimuth + Math.PI; + window.__currentMoonDir = [ + -Math.cos(_moonPos.altitude) * Math.sin(_moonBearingR), + Math.sin(_moonPos.altitude), + Math.cos(_moonPos.altitude) * Math.cos(_moonBearingR) + ]; + window.__currentMoonFrac = SunCalc.getMoonIllumination(new Date()).fraction; + } var __skyHex = '#' + ('00000' + c.sky.toString(16)).slice(-6); if (__skyHex !== window.__lastSkyHex) { window.__lastSkyHex = __skyHex; @@ -2104,6 +2190,7 @@ window.weatherThemes = { if (window.BackgroundCache) { window.BackgroundCache.put('meta/geolabel', { value: name }); } + scheduleFitInfoPanel(); } }) .catch(function() { /* ignore errors */ }); @@ -2214,7 +2301,10 @@ window.weatherThemes = { window.BackgroundCache.put('meta/weather', { json: data }); } currentWeatherCode = data.current.weather_code; - setWeatherTheme(getWeatherThemeName(currentWeatherCode)); + // while the weather sim runs it owns the theme - a live + // poll must not override the configured test weather + if (!__weatherSimActive) + setWeatherTheme(getWeatherThemeName(currentWeatherCode)); updateWeatherDisplay(); }) .catch(function(err) { @@ -2227,7 +2317,8 @@ window.weatherThemes = { weatherData = rec.json; window.__weatherStale = true; currentWeatherCode = rec.json.current.weather_code; - setWeatherTheme(getWeatherThemeName(currentWeatherCode)); + if (!__weatherSimActive) + setWeatherTheme(getWeatherThemeName(currentWeatherCode)); updateWeatherDisplay(); } }); @@ -2356,11 +2447,13 @@ window.weatherThemes = { ''; } document.getElementById('weather-forecast').innerHTML = forecastHtml; + fitInfoPanel(); } // Update weather every 15 minutes applyI18n(); populateDebugSelects(); + fitInfoPanel(); // Apply debug flag from CONFIG var debugEl = document.getElementById('debug-panel'); if (debugEl && !(window.CONFIG && window.CONFIG.debug)) { diff --git a/config.js b/config.js index 10252e1..cd77efd 100644 --- a/config.js +++ b/config.js @@ -86,7 +86,9 @@ window.CONFIG = { // Internal 3D resolution scale (0.3..1, default 1). Lower values // render the clouds/map shader at reduced resolution and upscale // via CSS - use it if fps is low (GPU-bound) despite a quiet CPU. - renderScale: 1, + // 1 = native. 0.5 = a quarter of the pixels (half width, half height), + // upscaled 2x as hard blocks; the DOM UI on top stays crisp. + renderScale: 0.5, // WebGL scene frame cap (default 30; 0 = free-running/vsync). All // scene motion is time-based, so the cap only changes smoothness. maxFps: 30, @@ -95,7 +97,10 @@ window.CONFIG = { lat: 48.5667, lon: 13.4319 }, - weatherSim: true, + // weatherSim: true = Szenario-Wetter statt Open-Meteo (A/B-Durchlauf + // aller Wetterstufen alle weatherSimInterval ms). false = echtes + // Wetter von Open-Meteo. + weatherSim: false, weatherSimInterval: 30000, // All downloads (basemap tiles, elevation grid, weather JSON, geo // label) stay in IndexedDB permanently and are never re-fetched once @@ -104,13 +109,13 @@ window.CONFIG = { expireDays: null, // true = draw tile borders + N/S + x/y coords into the ground // texture (debug orientation). Off = clean map. - debug: false + debug: true }, // Ground imagery: 'satellite' = ESRI World_Imagery (aerial, no // labels), 'streets' = ESRI World_Street_Map (with place names). // Both cached permanently (separate cache prefix per style). ground: { - style: 'satellite' + style: 'streets' }, // Figure-eight course around location (lat/lon); the crossing point // sits exactly on the coordinate. null/removed = straight flight. @@ -145,7 +150,7 @@ window.CONFIG = { enabled: true, altitudeKm: 14, // cruise altitude climbKm: 100, // climb/descent run to/from cruise altitude - count: 84, // max simultaneous aircraft + count: 21, // max simultaneous aircraft speedKmh: 850, // mean ground speed speedVar: 0.18, // +/- deviation (0..0.5) minLegKm: 60, // shortest leg (no taxi hops) @@ -154,20 +159,28 @@ window.CONFIG = { wHub: 4, // destination weights: hub / airfield / strip wField: 2, wStrip: 1, - contrailChance: 0.6, // share of aircraft with a persistent trail + contrailChance: 0.85, // share of aircraft with a persistent trail contrailStartKm: 6, // trail formation altitude contrailLifeS: 300, // trail visibility duration - contrailWidthM: 310 // full trail width + contrailWidthM: 620, // full trail width + // trails fade out completely by this distance (start is 35% of it) + contrailFadeKm: 100, + // Night strobes. Size shrinks with distance but never below + // lightPx, and never grows past lightMaxPx for a jet right above. + // The white strobe is a double flash: two equal flashes + // blinkGapS apart, then blinkPeriodS - blinkGapS - blinkFlashS + // of darkness (1.4 - 0.16 - 0.08 = 1.16 s pause). + lightPx: 7, + lightMaxPx: 16, + lightBoost: 9, + blinkPeriodS: 1.4, + blinkGapS: 0.16, + blinkFlashS: 0.08 }, // 3D ground from real elevation data (Copernicus DEM, terrarium // tiles). enabled: false = flat plane. exaggeration: vertical // elevation scale (1 = real, nearly invisible; default 8). // wireframe: true = overlay mesh edges as orange lines (debug). - // nightLights: NASA VIIRS city-lights layer, cross-faded into the - // day map over ~1 h after sunset (dark ground, glowing cities). - // One-time fetch (~49 tiles), cached permanently. false = no light - // overlay; at night the normal texture stays much brighter (only - // gently dimmed). // carLights: moving lights on the real road network (OSM/Overpass // curves, additive over the terrain at night). JSON once, cached. // cityLights: settlement lights - OSM place nodes (city/town/ @@ -176,13 +189,127 @@ window.CONFIG = { // brighter dots. One Overpass query, cached permanently. // Data (c) OpenStreetMap contributors, ODbL. terrain: { + // soft rim at the mosaic border, in tiles (1 tile ~ 13 km) + edgeFadeTiles: 4, enabled: true, exaggeration: 8, wireframe: false, - nightLights: false, + // How far the day map is dimmed at full night: 0 = pitch black, + // 1 = unchanged. Only affects night; the day map is untouched. + // (The NASA VIIRS city-lights layer was removed; city lights come + // from OSM place nodes via cityLights.) + nightDim: 0.22, carLights: true, cityLights: true }, + // Car lights on the real road network (on/off stays in terrain.carLights). + // Two layers now: a thin road line (bandPx / bandBrightness) and one + // round point sprite per vehicle (minPx..maxPx), same idea as the city + // dots' gl_PointSize floor. laneM is the real distance between the two + // traffic directions, so they split close to the camera and merge into + // one line far away instead of drifting apart with the camera distance. + // dashM: mean vehicle spacing (secondary roads use dashM * 2.8). + // speed: multiplier on the real driving speed (motorway ~120 km/h, + // secondary ~70 km/h at dashM spacing). + carLights: { + roadWidthM: 24, // real carriageway width of the road line + bandPx: 1.2, // pixel floor for the road line + bandBrightness: 0.10, // road line glow (points are the main layer) + minPx: 3.6, // guaranteed vehicle light diameter + maxPx: 10, // ceiling for a light right above the camera + boost: 5, // extra light size close to the camera + laneM: 8, // real distance between the two directions + lanePx: 2.6, // screen floor for that distance, so the two + // directions never collapse into one stream + // Traffic lives on a disc around the configured location: roads + // outside rangeKm carry no vehicles. fadeKm / fadeSpanKm are the + // distance fade belonging to that disc (start, and how far past it + // until nothing is left) - keep them at or beyond rangeKm so the + // region never ends in a hard cut. + rangeKm: 200, + fadeKm: 150, + fadeSpanKm: 60, + // 'auto' = side of the road taken from location (lat/lon): left in + // the UK, Japan, Australia, India, Southern Africa, ... and right + // everywhere else. Set 'left' or 'right' to force it. + trafficSide: 'auto', + dashM: 620, // mean vehicle spacing on motorways + // Traffic spread around the motorway network (gaussian): peak on the + // motorway itself, thinning with distance to the nearest one. + // spreadKm = sigma, spreadFloor = share that far side roads keep. + spreadKm: 45, + spreadFloor: 0.30, + speed: 1.0, // flow speed multiplier + density: 1.0, // vehicle density multiplier + brightness: 1.3 // vehicle light intensity + }, + // Headlights and house lights do not ignite at once: every single + // light gets its own sun-altitude threshold between offDeg (nothing + // lit) and onDeg (everything lit), drawn per vehicle / per city block. + // offDeg +5 deg is roughly 30 min before sunset here, onDeg -6 deg is + // civil twilight. In the morning the same thresholds switch the lights + // off again as the sun climbs. + lights: { + offDeg: 5, + onDeg: -6, + softDeg: 1.2 + }, + // Physical lighting: direct beam from a Kasten-Young air mass with + // Rayleigh + ozone + aerosol extinction, diffuse skylight and moonlight + // from the same model. terrainLighting: 'palette' switches back to the + // old hand-painted hillshade. turbidity scales the aerosol load, + // moonGain the moonlight strength, nightFloor the darkest skylight + // (airglow + starlight), albedoTint is the theme's share on surfaces. + atmosphere: { + terrainLighting: 'physical', + turbidity: 1.0, + // moonlight strength; scales the direct term AND the diffuse sky + // lift, both with the illuminated fraction (new moon -> none) + moonGain: 0.60, + // stylised starlight + airglow floor on a dark night (real + // integrated starlight is ~1e-6 of daylight and would be invisible) + starGain: 0.18, + // extra share on the ground. The product is the starlit floor and + // must stay below the skylight at sunset, otherwise the night would + // be brighter than the moment of sunset. Moonlight (moonGain) is + // what lifts a night above that floor. + starGainGround: 0.4, + nightFloor: 0.10, + // cloud albedo in physical mode: theme hue kept, brightness comes + // from the light model (the night palette alone would double-darken) + cloudBrightness: 1.0, + // moving cloud patches darken the ground beneath them (the terrain + // samples the same cloud field as the sky pass) + cloudShadows: true, + cloudShadowDensity: 1.8, // optical depth through the deck + cloudShadowFloor: 0.06, // darkest shade under a thick cloud + cloudShadowAmbient: 0.65, // share of the skylight a cloud also blocks + // ridges occlude the sun for the ground behind them (march over the + // same height field the mesh was built from) + terrainShadows: true, + // brightness a fully shadowed slope keeps (0 = pitch black, 1 = off) + terrainShadowFloor: 0.42, + // how much of the ground haze applies to the cloud deck (clouds sit + // above most of the air that causes it) + cloudHaze: 0.22, + albedoGain: 1.0, + // share of the daylight column that the distance fog / horizon takes, + // and the diffuse skylight share on surfaces + skyGain: 0.45, + // Aerial perspective in physical mode: the terrain is attenuated by + // exp(-tau) and the scattered light of the same air column is added. + // airGain scales the Rayleigh airlight (blue-biased), hazeResidual + // is how much of the surface survives at the full fog distance + // (0.3 = distant ground keeps 30 % of its own colour). + airGain: 0.55, + hazeResidual: 0.30 + }, + // Star colour: every catalogue star gets its real blackbody tint, mixed + // towards white by this factor. 0 = pure white points, 1 = full colour. + stars: { + tint: 0.10, // 90 % white + 10 % of the star's real colour + brightness: 1.6 // overall glow of the catalogue points + }, // Day simulation: true = a full 24 h cycle runs in cycleSeconds // (sun rises east, culminates south, sets west). // false = real sun/moon positions via SunCalc. @@ -190,16 +317,23 @@ window.CONFIG = { // that time of day (moving objects, clouds, cars and the weather // sim keep running unaffected); null/omitted = the clock advances. day: { + // simulation: true runs a full 24 h cycle in cycleSeconds. + // 60 s = one simulated day per minute of wall time (one simulated + // hour every 2.5 s). The analog clock and the sun/moon tables follow + // the simulated date. simulation: true, - cycleSeconds: 20, - fixedTime: '20:30' + cycleSeconds: 60, + fixedTime: null }, // Cloud deck in world units (1 unit ~ 12.9 km at zoom 11; flight // band 15-35 km = y ~ 1.16-2.70). Keep the top below the lowest // flight altitude. clouds: { top: 0.9, - bottom: -0.3 + bottom: -0.3, + // per weather level (0 = clear, 1 = overcast); overrides the built-in + // sunny 0.20 / cloudy 0.70 / stormy 0.80 / night 0.45 + density: { stormy: 0.80 } }, }; diff --git a/default.conf b/default.conf index a536007..e8e5027 100644 --- a/default.conf +++ b/default.conf @@ -5,9 +5,17 @@ server { index background.html; location / { + add_header Cache-Control "no-store, must-revalidate" always; try_files $uri $uri/ =404; } + # Page, config and scripts must never come out of a stale browser + # cache: without this a changed CONFIG (new location, layer toggles) + # keeps rendering the previous setup until a hard reload. + location ~* \.(js|html)$ { + add_header Cache-Control "no-store, must-revalidate" always; + } + # The background page runs as an iframe on other origins; a stray # favicon request must not log 404s. location = /favicon.ico { @@ -55,6 +63,83 @@ server { add_header Access-Control-Allow-Origin "*" always; } + # CORS proxy for Overpass (road, place and airport queries). The public + # mirrors answer cross-origin POSTs without Access-Control-Allow-Origin + # (or refuse outright), so the app talks to same-origin paths here and + # nginx forwards with a browser-like request. Overpass timeouts are long: + # a busy instance needs more than the default 60 s. + # URL format: /overpass//interpreter (mirror: api | coffee | mail) + location ^~ /overpass/api/ { + # Host in a variable so the resolver directive applies: with a literal + # host nginx resolves at config time and tried AAAA records, which the + # container cannot reach (101 Network unreachable) and which then + # disabled the upstream for a while. + resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s; + set $overpass_api_host "overpass-api.de"; + proxy_pass https://$overpass_api_host/api/; + proxy_ssl_server_name on; + proxy_set_header Host overpass-api.de; + proxy_set_header Origin "https://overpass-api.de"; + proxy_set_header Referer "https://overpass-api.de/api/interpreter"; + proxy_set_header User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + proxy_connect_timeout 15s; proxy_send_timeout 120s; proxy_read_timeout 120s; + # A cell that answered once is kept for 30 days and served even while + # the mirror is down (stale), so a flaky instance cannot undo progress + # the browser already paid for. + proxy_cache overpass; + proxy_cache_key "$scheme$host$request_uri$request_body"; + proxy_cache_methods POST; + proxy_cache_valid 200 30d; + proxy_cache_lock on; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cache $upstream_cache_status always; + add_header Access-Control-Allow-Origin "*" always; + } + location ^~ /overpass/coffee/ { + resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s; + set $overpass_coffee_host "overpass.private.coffee"; + proxy_pass https://$overpass_coffee_host/api/; + proxy_ssl_server_name on; + proxy_set_header Host overpass.private.coffee; + proxy_set_header Origin "https://overpass.private.coffee"; + proxy_set_header Referer "https://overpass.private.coffee/api/interpreter"; + proxy_set_header User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + proxy_connect_timeout 15s; proxy_send_timeout 120s; proxy_read_timeout 120s; + # A cell that answered once is kept for 30 days and served even while + # the mirror is down (stale), so a flaky instance cannot undo progress + # the browser already paid for. + proxy_cache overpass; + proxy_cache_key "$scheme$host$request_uri$request_body"; + proxy_cache_methods POST; + proxy_cache_valid 200 30d; + proxy_cache_lock on; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cache $upstream_cache_status always; + add_header Access-Control-Allow-Origin "*" always; + } + location ^~ /overpass/mail/ { + resolver 1.1.1.1 8.8.8.8 ipv6=off valid=300s; + set $overpass_mail_host "maps.mail.ru"; + proxy_pass https://$overpass_mail_host/osm/tools/overpass/api/; + proxy_ssl_server_name on; + proxy_set_header Host maps.mail.ru; + proxy_set_header Origin "https://maps.mail.ru"; + proxy_set_header Referer "https://maps.mail.ru/osm/tools/overpass/api/interpreter"; + proxy_set_header User-Agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + proxy_connect_timeout 15s; proxy_send_timeout 120s; proxy_read_timeout 120s; + # A cell that answered once is kept for 30 days and served even while + # the mirror is down (stale), so a flaky instance cannot undo progress + # the browser already paid for. + proxy_cache overpass; + proxy_cache_key "$scheme$host$request_uri$request_body"; + proxy_cache_methods POST; + proxy_cache_valid 200 30d; + proxy_cache_lock on; + proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; + add_header X-Cache $upstream_cache_status always; + add_header Access-Control-Allow-Origin "*" always; + } + # CORS preflight handler location ~* \.(png|jpg)$ { if ($request_method = 'OPTIONS') { diff --git a/overpass-cache.conf b/overpass-cache.conf new file mode 100644 index 0000000..50f7168 --- /dev/null +++ b/overpass-cache.conf @@ -0,0 +1 @@ +proxy_cache_path /var/cache/nginx/overpass levels=1:2 keys_zone=overpass:20m max_size=2g inactive=30d use_temp_path=off; diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index f7d3ae7..ce304b9 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -80,6 +80,17 @@ var FRAG_SRC = [ 'uniform float uStarLon;', // sidereal rotation offset (radians) 'uniform vec3 uPole;', // north celestial pole in world coords 'uniform int uMode;', // 0 = sky+stars, 1 = clouds (transparent over mesh) + '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 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 // 3D hash and value noise 'float hash3(vec3 p) {', 'p = fract(p * vec3(0.1031, 0.1030, 0.0973));', @@ -94,6 +105,13 @@ var FRAG_SRC = [ '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);', @@ -105,30 +123,39 @@ var FRAG_SRC = [ '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 < 3; i++) {', + '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;', + '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. - 'float cloudDensity(vec3 p, float baseThresh) {', + // '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 cloudDensity(vec3 p, float baseThresh, float soft) {', 'float n = fbm3(p * 0.35 + vec3(0.0, 0.0, uTime * 0.06));', 'float d = fbm3(p * 1.4 + vec3(uTime * 0.04, 7.3, uTime * 0.03));', - 'n = n + d * 0.20;', + 'n = n + d * mix(0.20, 0.0, soft);', // Cloud layer between uCloudBot and uCloudTop, densest in the // middle (bell curve). 'float h = clamp((p.y - uCloudBot) / (uCloudTop - uCloudBot), 0.0, 1.0);', 'float profile = 4.0 * h * (1.0 - h); // bell curve', 'profile = max(profile, 0.0);', - 'return smoothstep(baseThresh, baseThresh + 0.20, n) * profile;', + 'return smoothstep(baseThresh, baseThresh + 0.30 + soft * 0.25, n) * profile;', '}', // Star field: project ray onto a far sphere, hash-grid for // positions. Visible above the horizon at night with a small @@ -190,7 +217,8 @@ var FRAG_SRC = [ 'col *= (1.0 - nightFactor * 0.55);', // ---- real star catalog (Yale BSC5 baked into a texture) ---- // world -> equatorial: pole tilted by the observer's latitude - 'if (uSkyOn > 0.5 && nightFactor > 0.02 && rd.y > -0.015) {', + '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;', @@ -201,7 +229,10 @@ var FRAG_SRC = [ 'float v = 0.5 - asin(clamp(ydec, -1.0, 1.0)) * 0.31830989;', 'vec3 st = texture2D(uSkyTex, vec2(u, v)).rgb;', 'float twk = 0.88 + 0.12 * sin(uTime * 0.7 + dot(rd, vec3(91.7, 43.3, 17.1)));', - 'col += st * nightFactor * skyMask * twk * 1.45;', + // 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;', '}', // ---- Sun disc ------------------------------------------------ // angular radius ~0.27 deg -> chord length ~0.0047 @@ -233,6 +264,17 @@ var FRAG_SRC = [ // faint procedural fill for stars below the catalog limit 'float stars = starField(rd) * skyMask * nightFactor;', 'col += stars * uLight * 0.85;', + // ---- 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.03, 0.20, rd.y);', + 'float cosTA = dot(rd, uSunDir);', + 'float phaseA = 0.5 + 0.5 * cosTA * cosTA;', + 'col = mix(col, uAirColor * phaseA * 1.25, lowBand * 0.92);', + '}', 'gl_FragColor = vec4(col, 1.0);', 'return;', '}', @@ -262,20 +304,63 @@ var FRAG_SRC = [ // terrain mesh from the previous pass shows through transparent // cloud pixels. 'float baseThresh = mix(0.72, 0.40, uDensity);', - 'float t = (ro.y > uCloudTop && rd.y < -0.001) ? (ro.y - uCloudTop) / -rd.y : 0.0;', - 't += hash21(gl_FragCoord.xy + uTime) * 0.85;', - 'float stepSize = 0.85;', + // March the deck in HEIGHT, not along the ray. Sampling a slab along + // the ray gives every view angle its own set of sample heights, and + // those sets line up across the screen as bands - exactly the stacked + // slabs this looked like. Walking the layer from its own top to its + // own bottom and weighting each step by its path length is independent + // of the view angle, and grazing rays get the long path they really + // have. + 'float tA = (abs(rd.y) < 0.0005) ? 0.0 : (uCloudBot - ro.y) / rd.y;', + 'float tB = (abs(rd.y) < 0.0005) ? 14.0 : (uCloudTop - ro.y) / rd.y;', + 'float tEnter = max(0.0, min(tA, tB));', + 'float tExit = max(tA, tB);', + 'if (tTerrain > 0.0) tExit = min(tExit, tTerrain);', + // A grazing ray would sample a path of well over a thousand km with + // only 24 steps - coarse blobs along the horizon. Past this length + // the deck is inside the haze anyway, so the march stops there. + 'tExit = min(tExit, tEnter + 18.0);', + 'float stepSize = max(0.0, (tExit - tEnter) / 24.0);', + 'float t = tEnter + stepSize * (0.5 + ign(gl_FragCoord.xy));', 'vec3 cloudSum = vec3(0.0);', 'float trans = 1.0;', - 'for (int i = 0; i < 18; i++) {', + 'for (int i = 0; i < 24; i++) {', 'vec3 p = ro + rd * t;', + 'p.y += (ign(gl_FragCoord.xy + vec2(float(i) * 7.13, float(i) * 3.71)) - 0.5) * stepSize;', 'if (p.y > uCloudTop || p.y < uCloudBot - 0.1) break;', 'if (tTerrain > 0.0 && t >= tTerrain) break;', - 'float d = cloudDensity(p, baseThresh);', + 'float cSoft = smoothstep(7.0, 20.0, t);', + 'float d = cloudDensity(p, baseThresh, cSoft);', 'if (d > 0.005) {', 'float lit = clamp((p.y - uCloudBot) / ((uCloudTop - uCloudBot) * 1.167), 0.0, 1.0);', - 'vec3 cloudCol = mix(uCloudShadow, uCloud, lit);', - 'cloudCol = mix(cloudCol, uLight, max(0.15, uDayProgress) * 0.35 * lit);', + 'vec3 cloudCol;', + 'if (uPhysical > 0.5) {', + // The night palette colours are already darkened for + // night, and multiplying them by the night light would + // double-darken into invisibility. Physical mode + // therefore uses a brightness-normalised albedo (theme + // hue kept, brightness from the light model) and lets + // the vertical gradient act as a shading ramp. + 'vec3 albC = uCloudAlbedo * mix(0.78, 1.0, lit);', + // Self-shading: probe the density towards the sun, so + // thick cloud interior stays dark and thin edges light up. + 'float thick = cloudDensity(p + uSunDir * 2.2, baseThresh, cSoft);', + 'float selfLit = clamp(1.0 - thick * 1.3, 0.12, 1.0);', + // Forward scattering - clouds brighten towards the sun + // (silver lining) instead of a flat day/night ramp. + 'float cosT = dot(rd, -uSunDir);', + 'float phase = 0.30 + 0.70 * pow(max(0.0, cosT), 2.5);', + 'vec3 direct = albC * uSunColor * phase * uSunInt * selfLit;', + // A cloud deck is a bright, thick scatterer: it catches + // the whole diffuse column (skylight, starlight) and + // stands clearly above the dark sky at night. + 'vec3 ambC = albC * max(vec3(uSkyAmb), uStarAmb * vec3(0.75, 0.85, 1.0)) * 1.7;', + 'vec3 moonC = albC * uMoonColor * (0.35 + 0.65 * max(0.0, dot(rd, uMoonDir))) * uMoonInt * 3.0;', + 'cloudCol = direct + ambC + moonC;', + '} else {', + 'cloudCol = mix(uCloudShadow, uCloud, lit);', + 'cloudCol = mix(cloudCol, uLight, max(0.15, uDayProgress) * 0.35 * lit);', + '}', 'float alpha = clamp(d * stepSize * 0.9, 0.0, 1.0);', 'cloudSum += cloudCol * alpha * trans;', 'trans *= (1.0 - alpha);', @@ -285,6 +370,23 @@ var FRAG_SRC = [ '}', 'float cloudAlpha = 1.0 - trans;', 'vec3 cloudColor = (cloudAlpha > 0.001) ? cloudSum / cloudAlpha : vec3(0.0);', + // Aerial perspective for the deck too: a distant cloud is seen + // through its own air column, so it dissolves into the same airlight + // the ground fades into. Without it the far clouds keep full contrast + // and the horizon reads as stacked bands. + // A cloud at 12 km looks through far less air than the ground at the + // horizon, so this uses its own much smaller factor, a desaturated + // haze colour (not the saturated blue) and a hard cap: distant cloud + // loses contrast, it does not turn blue. + 'if (uPhysical > 0.5 && cloudAlpha > 0.001) {', + 'float cTau = uAirDensity * min(tExit, 60.0) * uCloudHaze;', + 'float cHaze = min(0.55, 1.0 - exp(-cTau));', + 'vec3 airAvg = vec3(dot(uAirColor, vec3(0.3333)));', + 'vec3 hazeCol = mix(uAirColor, airAvg, 0.55);', + 'float cosTA = dot(rd, uSunDir);', + 'float phaseA = 0.5 + 0.5 * cosTA * cosTA;', + 'cloudColor = mix(cloudColor, hazeCol * phaseA, cHaze);', + '}', 'gl_FragColor = vec4(cloudColor, cloudAlpha);', '}' ].join('\n'); @@ -305,16 +407,93 @@ var TERRAIN_VERT_SRC = [ 'varying vec2 vUV;', 'varying float vY;', 'varying vec3 vViewPos;', + 'varying vec3 vWorld;', 'void main() {', 'vNormal = aNormal;', 'vUV = aUV;', 'vY = aPos.y;', + 'vWorld = aPos;', 'vec4 vp = uView * vec4(aPos, 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 cloudDensity(vec3 p, float baseThresh, float soft) {', + 'float n = fbm3(p * 0.35 + vec3(0.0, 0.0, uTime * 0.06));', + 'float d = fbm3(p * 1.4 + vec3(uTime * 0.04, 7.3, uTime * 0.03));', + 'n = n + d * mix(0.20, 0.0, soft);', + 'float h = clamp((p.y - 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.30 + soft * 0.25, n) * profile;', + '}', +].join('\n'); + var TERRAIN_FRAG_SRC = [ 'precision highp float;', 'uniform sampler2D uGround;', @@ -326,28 +505,114 @@ var TERRAIN_FRAG_SRC = [ 'uniform vec3 uBg;', 'uniform vec3 uSky;', '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 uPhysical;', // 1 = physical lighting, 0 = palette hillshade + '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 float uSunAlt;', // sun altitude, for the light switching + 'uniform float uLightHi;', // above this altitude no house light is on + 'uniform float uLightLo;', // below this altitude every house light is on + 'uniform float uLightSoft;', + '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 uEdgeFadeTiles;', 'uniform float uFogDist;', 'uniform float uNightFactor;', - 'uniform float uNightLights;', // 1 = VIIRS city-light overlay active - 'uniform sampler2D uNight;', // VIIRS night-lights layer (same layout as uGround) + 'uniform float uNightDim;', // how far the day map is dimmed at night '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;', + CLOUD_SAMPLER_GLSL, 'void main() {', 'vec2 gUv = vUV;', // Edge fade: beyond the canvas the texture is clamped to the // edge pixel, so we fade toward uBg over the last 5%. - 'float perpFade = smoothstep(-0.08, 0.05, gUv.x) * smoothstep(1.08, 0.95, gUv.x);', - 'float longFade = smoothstep(-0.08, 0.05, gUv.y) * smoothstep(1.08, 0.95, gUv.y);', - 'float edgeFade = perpFade * longFade;', + // 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));', 'vec3 col = texture2D(uGround, gUv).rgb;', - // ---- Hillshade ------------------------------------------------- + '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. + 'if (uPhysical > 0.5) {', + 'vec3 alb = texture2D(uGround, gUv).rgb * 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. + 'float tBot = max(0.0, (uCloudBot - vY) / uSunDir.y);', + 'float tTop = (uCloudTop - vY) / uSunDir.y;', + '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(7.0, 20.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;', + '} else {', + // ---- Hillshade (palette model) --------------------------------- // Classic shaded-relief: strong lambert term for sun-facing vs // shadow-facing slopes, plus a sky-ambient term (upward normals // collect more skylight), plus a subtle warm/cool slope tint. - 'vec3 n = normalize(vNormal);', 'float lam = dot(n, uSunDir);', 'float relief = 0.62 + 0.58 * lam;', // shadowed slopes keep a little bounce light instead of going black @@ -365,36 +630,40 @@ var TERRAIN_FRAG_SRC = [ // ---- Day/night cross-fade -------------------------------------- // Starts exactly at sunset and blends over ~the following hour of // simulated time (uNightFactor: 0 at alt 0, 1 at -0.4 rad, which - // the sun covers in ~1 h at 48 deg N). City lights from the VIIRS - // layer rise out of the dimmed day map. + // the sun covers in ~1 h at 48 deg N). 'if (uNightFactor > 0.002) {', - 'vec3 lights = vec3(0.0);', - 'if (uNightLights > 0.5) {', - // uNight is pre-processed offline per tile: luma threshold + - // heavy blur, colour already multiplied by the glow mask and - // composited onto black in the canvas - only city lights - // remain. Just warm-tint and boost it a bit. - 'lights = texture2D(uNight, gUv).rgb;', - 'lights = min(vec3(1.0), lights * vec3(1.35, 1.15, 0.95) * 1.35);', - '}', - // with city lights on, the day map may go dark (the lights carry - // the scene); without them keep it visibly brighter - 'vec3 nightBase = col * mix(0.34, 0.28, uNightLights);', - 'col = mix(col, nightBase + lights, uNightFactor);', + // uNightDim is CONFIG.terrain.nightDim - how far to dim the day + // map at full night (0 = pitch black, 1 = unchanged). + 'vec3 nightBase = col * uNightDim;', + 'col = mix(col, nightBase, uNightFactor);', + '}', '}', // ---- Distance fog ---------------------------------------------- // Real view-space distance (uFogDist is in tiles, matching the // world-unit scale of the mesh), not a height proxy. 'float dist = length(vViewPos);', - 'float fogAmount = clamp(dist / max(uFogDist, 0.001), 0.0, 1.0);', - 'fogAmount = pow(fogAmount, 0.8);', - // lowlands hold more haze than exposed peaks - 'fogAmount *= 0.75 + 0.25 * smoothstep(0.4, 0.0, vY);', - // horizon colour = EXACTLY what the sky pass paints at/below the - // horizon (mix(uBg,uSky,0.5) with the same night darkening), so - // the terrain's outer rim dissolves into the sky without a line. - 'vec3 horizonCol = mix(uBg, uSky, 0.5) * (1.0 - uNightFactor * 0.55);', - 'col = mix(col, horizonCol, fogAmount);', + 'vec3 horizonCol = (uPhysical > 0.5) ? uHorizonColor', + ' : mix(uBg, uSky, 0.5) * (1.0 - uNightFactor * 0.55);', + // ---- 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. + 'if (uPhysical > 0.5) {', + 'float tau = uAirDensity * dist * (0.75 + 0.25 * smoothstep(0.4, 0.0, vY));', + 'float trans = exp(-tau);', + 'vec3 vd = normalize(vWorld - uCamPos);', + 'float cosT = dot(vd, uSunDir);', + 'float rphase = 0.5 + 0.5 * cosT * cosT;', + 'col = col * trans + uAirColor * rphase * (1.0 - trans);', + '} else {', + 'float fogAmount = clamp(dist / max(uFogDist, 0.001), 0.0, 1.0);', + 'fogAmount = pow(fogAmount, 0.8);', + 'fogAmount *= 0.75 + 0.25 * smoothstep(0.4, 0.0, vY);', + 'col = mix(col, horizonCol, fogAmount);', + '}', // Edge fade to the same horizon colour 'col = mix(horizonCol, col, edgeFade);', // Wireframe overlay: when CONFIG.terrain.wireframe is set the @@ -463,6 +732,17 @@ function init(canvas) { var uSkyOn = gl.getUniformLocation(prog, 'uSkyOn'); var uStarLon = gl.getUniformLocation(prog, 'uStarLon'); var uPole = gl.getUniformLocation(prog, 'uPole'); + 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 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 @@ -493,14 +773,42 @@ function init(canvas) { var terrainUsky = terrainProg ? gl.getUniformLocation(terrainProg, 'uSky') : null; var terrainUfogdist = terrainProg ? gl.getUniformLocation(terrainProg, 'uFogDist') : null; var terrainUnight = terrainProg ? gl.getUniformLocation(terrainProg, 'uNightFactor') : null; - var terrainUnightlights = terrainProg ? gl.getUniformLocation(terrainProg, 'uNight') : null; - var terrainUnightsw = terrainProg ? gl.getUniformLocation(terrainProg, 'uNightLights') : null; + + var terrainUnightdim = terrainProg ? gl.getUniformLocation(terrainProg, 'uNightDim') : 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 terrainUphysical = terrainProg ? gl.getUniformLocation(terrainProg, 'uPhysical') : null; + var terrainUsunAlt = terrainProg ? gl.getUniformLocation(terrainProg, 'uSunAlt') : null; + var terrainUlightHi = terrainProg ? gl.getUniformLocation(terrainProg, 'uLightHi') : null; + var terrainUlightLo = terrainProg ? gl.getUniformLocation(terrainProg, 'uLightLo') : null; + var terrainUlightSoft = terrainProg ? gl.getUniformLocation(terrainProg, 'uLightSoft') : 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 terrainUedgeFade = terrainProg ? gl.getUniformLocation(terrainProg, 'uEdgeFadeTiles') : 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. night lights before the first tile, or nightLights: false) @@ -529,6 +837,10 @@ function init(canvas) { uCloudTop: uCloudTop, uCloudBot: uCloudBot, uElev: uElev, uElevOn: uElevOn, uElevScale: uElevScale, uSunDir: uSunDir, uMoonDir: uMoonDir, uMode: uMode, + uSunColor: uSunColor, uSunInt: uSunInt, uMoonColor: uMoonColor, + uMoonInt: uMoonInt, uSkyAmb: uSkyAmb, uStarAmb: uStarAmb, + uPhysical: uPhysical, uAirColor: uAirColor, uCloudAlbedo: uCloudAlbedo, + uAirDensity: uAirDensity, uCloudHaze: uCloudHaze, uSkyTex: uSkyTex, uSkyOn: uSkyOn, uStarLon: uStarLon, uPole: uPole, // terrain program terrain: { @@ -539,10 +851,27 @@ function init(canvas) { uBg: terrainUbg, uSky: terrainUsky, uFogDist: terrainUfogdist, uNightFactor: terrainUnight, uGroundCenter: terrainUgroundcenter, uGroundSize: terrainUgroundsize, uCorridorRot: terrainUcorridor, uCloudsOn: terrainUcloudsOn, - uNight: terrainUnightlights, - uNightLightsOn: terrainUnightsw, + + uNightDim: terrainUnightdim, uElevScale: terrainUelevScale, - uWireColor: terrainUwireColor + uWireColor: terrainUwireColor, + uSunColor: terrainUsunColor, uSunInt: terrainUsunInt, + uMoonColor: terrainUmoonColor, uMoonInt: terrainUmoonInt, + uSkyAmb: terrainUskyAmb, uAlbedoTint: terrainUalbedoTint, + uHorizonColor: terrainUhorizon, uPhysical: terrainUphysical, + uSunAlt: terrainUsunAlt, uLightHi: terrainUlightHi, + uLightLo: terrainUlightLo, uLightSoft: terrainUlightSoft, + uAirColor: terrainUairColor, uAirDensity: terrainUairDensity, + uCamPos: terrainUcampos, uEdgeFadeTiles: terrainUedgeFade, + 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). @@ -646,6 +975,11 @@ CLOUD_BOT = Math.min(CLOUD_BOT, CLOUD_TOP - 0.4); // 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 TERRAIN_EXAG = (typeof TERRAIN_CFG.exaggeration === 'number' && TERRAIN_CFG.exaggeration > 0) ? TERRAIN_CFG.exaggeration : 8; // metres -> world units, including the vertical exaggeration @@ -973,8 +1307,6 @@ function drawTileImage(img, canvasX, canvasY, tileX, tileY) { var pad = Math.max(3, Math.round(T * 0.047)); groundCtx.drawImage(img, canvasX, canvasY, T, T); groundLoadedTiles[tileX + '_' + tileY] = true; - // keep the night-lights layer in lockstep with the street mosaic - placeNightTile(tileX, tileY, canvasX, canvasY); // Debug tile overlays (CONFIG.tiles.debug): border + N/S + x/y labels. if (DEBUG_TILES) { @@ -1139,8 +1471,6 @@ function uploadGroundTexture() { groundGl.texImage2D(groundGl.TEXTURE_2D, 0, groundGl.RGBA, groundGl.RGBA, groundGl.UNSIGNED_BYTE, groundCanvas); if (IS_WEBGL2) groundGl.generateMipmap(groundGl.TEXTURE_2D); } - // the night canvas is small (8 MB) - safe to upload together - if (nightTextureDirty) uploadNightTexture(); groundLastUpload = performance.now(); groundTextureDirty = false; } @@ -1193,30 +1523,12 @@ function recenterGroundMosaic(centerTileX, centerTileY, dLng, dPerp) { groundCtx.drawImage(tmp, Math.round(dPerp) * GROUND_TILE_SIZE, -Math.round(dLng) * GROUND_TILE_SIZE); - // shift the night layer identically (4x smaller pixel scale) - if (nightCtx) { - var tmpn = document.createElement('canvas'); - tmpn.width = nightCanvas.width; - tmpn.height = nightCanvas.height; - tmpn.getContext('2d').drawImage(nightCanvas, 0, 0); - nightCtx.fillStyle = '#000'; - nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height); - nightCtx.drawImage(tmpn, - Math.round(dPerp) * NIGHT_UNIT_PX, - -Math.round(dLng) * NIGHT_UNIT_PX); - nightTextureDirty = true; - } } else { // Wipe: already-loaded tiles would sit at wrong canvas positions // relative to the new centre. Drop them all and redraw. groundLoadedTiles = {}; groundCtx.fillStyle = '#7c9aa1'; groundCtx.fillRect(0, 0, groundCanvas.width, groundCanvas.height); - if (nightCtx) { - nightCtx.fillStyle = '#000'; - nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height); - nightTextureDirty = true; - } } // Upload the shifted/wiped canvas right away: the shader already // uses the new centre, so the texture must match it even while the @@ -1508,6 +1820,19 @@ function buildTerrainMesh(gl, state) { } 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) = 8 floats per vertex. var cols = gw - 1, rows = gh - 1; @@ -1587,209 +1912,19 @@ function buildTerrainMesh(gl, state) { state.terrain.wireVertCount = (cols - 1) * rows * 2 + cols * (rows - 1) * 2; } terrainSample = { h: h, gw: gw, gh: gh, s: s, halfX: halfX, halfY: halfY }; - buildCarwayVBO(gl); - buildCityVBO(gl); + // 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 lights layer (NASA GIBS VIIRS "City Lights" composite) ------ -// Dark ground with glowing cities for the night: z8 webmercator JPEGs -// from the static VIIRS 2012 Day/Night Band composite (750 m sensor - -// more zoom than z8 would just upscale). Blitted into its own low-res -// canvas in the SAME layout as the street mosaic (same tile indices), -// so the terrain shader samples it with the same gUv. CORS is open -// (Access-Control-Allow-Origin: *), cached permanently in IndexedDB. -var NIGHT_Z = 8; -var NIGHT_TILES_PER = 1 << (11 - NIGHT_Z); // z11 tiles per night tile -var NIGHT_UNIT_PX = 256 / NIGHT_TILES_PER; // night canvas px per z11 tile -var NIGHT_URL = 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/' + - 'VIIRS_CityLights_2012/default/GoogleMapsCompatible_Level8/'; +// ---- 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). var NIGHT_CFG = (window.CONFIG && window.CONFIG.terrain) || {}; -var NIGHT_ON = NIGHT_CFG.nightLights !== false; -var nightCanvas = null, nightCtx = null, nightTexture = null, nightGl = null; -var nightTextureDirty = false; - -function initNightSystem(glCtx) { - if (!NIGHT_ON || nightCanvas) return; - nightGl = glCtx; - nightCanvas = document.createElement('canvas'); - nightCanvas.width = groundTilesX() * NIGHT_UNIT_PX; - nightCanvas.height = groundTilesY() * NIGHT_UNIT_PX; - nightCtx = nightCanvas.getContext('2d'); - nightCtx.fillStyle = '#000'; - nightCtx.fillRect(0, 0, nightCanvas.width, nightCanvas.height); - nightTexture = nightGl.createTexture(); - nightGl.bindTexture(nightGl.TEXTURE_2D, nightTexture); - nightGl.texImage2D(nightGl.TEXTURE_2D, 0, nightGl.RGBA, nightGl.RGBA, - nightGl.UNSIGNED_BYTE, nightCanvas); - nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_WRAP_S, nightGl.CLAMP_TO_EDGE); - nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_WRAP_T, nightGl.CLAMP_TO_EDGE); - nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MAG_FILTER, nightGl.LINEAR); - if (IS_WEBGL2) { - nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MIN_FILTER, nightGl.LINEAR_MIPMAP_LINEAR); - nightGl.generateMipmap(nightGl.TEXTURE_2D); - if (anisoExt) nightGl.texParameterf(nightGl.TEXTURE_2D, anisoExt.TEXTURE_MAX_ANISOTROPY_EXT, ANISO_MAX); - } else { - nightGl.texParameteri(nightGl.TEXTURE_2D, nightGl.TEXTURE_MIN_FILTER, nightGl.LINEAR); - } -} - -function uploadNightTexture() { - if (!nightGl || !nightTexture) return; - nightGl.bindTexture(nightGl.TEXTURE_2D, nightTexture); - nightGl.texImage2D(nightGl.TEXTURE_2D, 0, nightGl.RGBA, nightGl.RGBA, - nightGl.UNSIGNED_BYTE, nightCanvas); - if (IS_WEBGL2) nightGl.generateMipmap(nightGl.TEXTURE_2D); - nightTextureDirty = false; -} - -function loadNightTile(nx, ny) { - var id = 'night/' + NIGHT_Z + '/' + nx + '/' + ny; - return openTileDB().then(function (db) { - return tileDbGet(db, id).then(function (rec) { - if (rec && rec.blob) { - return decodeToBitmap(rec.blob).catch(function () { - return fetchFresh(db); - }); - } - return fetchFresh(db); - }); - }); - function fetchFresh(db) { - // GIBS WMTS REST path: {TileMatrix}/{TileRow}/{TileCol} - row(y) first! - return fetch(NIGHT_URL + NIGHT_Z + '/' + ny + '/' + nx + '.jpg') - .then(function (r) { - if (!r.ok) throw new Error('night http ' + r.status); - return r.blob(); - }) - .then(function (blob) { - if (db) tileDbPut(db, { id: id, blob: blob, savedAt: Date.now() }); - return decodeToBitmap(blob); - }) - .catch(function (e) { - console.warn('[clouds3d] night tile failed z8 ' + nx + '/' + ny + - ': ' + (e && e.message || e)); - return null; - }); - } -} - -// Post-process one raw 256x256 night tile into a pure "glow" tile: -// luma -> threshold curve (only real city lights survive, dim albedo -// and sensor noise are cut) -> very strong blur of mask and colour -// (downscale to 32 and bilinear back) -> colour x mask as alpha. -// Resolved value: processed PNG Blob, or null for lightless tiles. -function processNightTile(img) { - var S = 256, DS = 32; - var src = document.createElement('canvas'); src.width = src.height = S; - var sx = src.getContext('2d', { willReadFrequently: true }); - sx.drawImage(img, 0, 0); - var d; - try { d = sx.getImageData(0, 0, S, S); } catch (e) { return Promise.resolve(null); } - var px = d.data, i, o; - var mask = new Float32Array(S * S); - var maxL = 0; - for (i = 0; i < S * S; i++) { - o = i * 4; - var l = (0.299 * px[o] + 0.587 * px[o + 1] + 0.114 * px[o + 2]) / 255; - if (l > maxL) maxL = l; - mask[i] = l; - } - if (maxL < 0.22) return Promise.resolve(null); // no lights here - // smoothstep threshold on the normalised luma: below 32 % of the - // tile's brightest pixel = nothing, above 78 % = full light - for (i = 0; i < S * S; i++) { - var t = (mask[i] / maxL - 0.32) / (0.78 - 0.32); - t = Math.max(0, Math.min(1, t)); - mask[i] = t * t * (3 - 2 * t); - } - // encode mask as grey image, blur hard via 32px downscale - var mCanvas = document.createElement('canvas'); mCanvas.width = mCanvas.height = S; - var mc = mCanvas.getContext('2d'); - var md = mc.createImageData(S, S); - for (i = 0; i < S * S; i++) { - var v = (mask[i] * 255) | 0; o = i * 4; - md.data[o] = v; md.data[o + 1] = v; md.data[o + 2] = v; md.data[o + 3] = 255; - } - mc.putImageData(md, 0, 0); - var small = document.createElement('canvas'); small.width = small.height = DS; - var smc = small.getContext('2d'); - smc.imageSmoothingEnabled = true; smc.imageSmoothingQuality = 'high'; - smc.drawImage(mCanvas, 0, 0, DS, DS); - var mb = document.createElement('canvas'); mb.width = mb.height = S; - var mbc = mb.getContext('2d', { willReadFrequently: true }); - mbc.imageSmoothingEnabled = true; mbc.imageSmoothingQuality = 'high'; - mbc.drawImage(small, 0, 0, S, S); - var mBlur = mbc.getImageData(0, 0, S, S).data; - // blur the colour the same way, then multiply it by the blurred mask - var cs = document.createElement('canvas'); cs.width = cs.height = DS; - var csc = cs.getContext('2d'); - csc.imageSmoothingEnabled = true; csc.imageSmoothingQuality = 'high'; - csc.drawImage(src, 0, 0, DS, DS); - var cb = document.createElement('canvas'); cb.width = cb.height = S; - var cbc = cb.getContext('2d', { willReadFrequently: true }); - cbc.imageSmoothingEnabled = true; cbc.imageSmoothingQuality = 'high'; - cbc.drawImage(cs, 0, 0, S, S); - var out = cbc.getImageData(0, 0, S, S); - var od = out.data; - for (i = 0; i < S * S; i++) { - o = i * 4; - var a = mBlur[o] / 255; - od[o] = (od[o] * a) | 0; - od[o + 1] = (od[o + 1] * a) | 0; - od[o + 2] = (od[o + 2] * a) | 0; - od[o + 3] = mBlur[o]; // alpha = glow strength - } - var oc = document.createElement('canvas'); oc.width = oc.height = S; - oc.getContext('2d').putImageData(out, 0, 0); - return new Promise(function (resolve) { - oc.toBlob(function (b) { resolve(b || null); }, 'image/png'); - }); -} - -// Get the GLOW-processed bitmap of a z8 tile. Processed PNGs are cached -// under their own id ('nightg/...'); raw tiles under 'night/...'. Lightless -// tiles are cached as an empty marker so they never re-process. -function getProcessedNightTile(nx, ny) { - var rawId = 'night/' + NIGHT_Z + '/' + nx + '/' + ny; - var procId = 'nightg/' + NIGHT_Z + '/' + nx + '/' + ny; - return openTileDB().then(function (db) { - return tileDbGet(db, procId).then(function (rec) { - if (rec && rec.empty) return null; // processed: no lights - if (rec && rec.blob) { - return decodeToBitmap(rec.blob).catch(function () { return null; }); - } - return loadNightTile(nx, ny).then(function (img) { - if (!img) return null; - return processNightTile(img).then(function (blob) { - if (blob) { - tileDbPut(db, { id: procId, blob: blob, savedAt: Date.now() }); - return decodeToBitmap(blob); - } - tileDbPut(db, { id: procId, empty: 1, savedAt: Date.now() }); - return null; - }); - }); - }); - }); -} - -// Draw the night-lights slice of one street tile at the tile's canvas -// position (scaled to the low-res night canvas). -function placeNightTile(tileX, tileY, canvasX, canvasY) { - if (!nightCtx) return; - var nx = Math.floor(tileX / NIGHT_TILES_PER); - var ny = Math.floor(tileY / NIGHT_TILES_PER); - var sx = (tileX - nx * NIGHT_TILES_PER) * NIGHT_UNIT_PX; - var sy = (tileY - ny * NIGHT_TILES_PER) * NIGHT_UNIT_PX; - getProcessedNightTile(nx, ny).then(function (img) { - if (!img) return; - nightCtx.drawImage(img, sx, sy, NIGHT_UNIT_PX, NIGHT_UNIT_PX, - Math.round(canvasX * NIGHT_UNIT_PX / GROUND_TILE_SIZE), - Math.round(canvasY * NIGHT_UNIT_PX / GROUND_TILE_SIZE), - NIGHT_UNIT_PX, NIGHT_UNIT_PX); - nightTextureDirty = true; - }); -} +var NIGHT_DIM = (typeof NIGHT_CFG.nightDim === 'number') ? + Math.max(0, Math.min(1, NIGHT_CFG.nightDim)) : 0.22; // ---- Motorway car-lights layer ---------------------------------------- // Motorway/trunk roads (Autobahnen) from OSM via Overpass API, fetched at @@ -1801,19 +1936,137 @@ function placeNightTile(tileX, tileY, canvasX, canvasY) { // 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; +var ATMO_NIGHT_FLOOR = (typeof ATMO.nightFloor === 'number') ? Math.max(0.01, ATMO.nightFloor) : 0.05; +var ATMO_TERRAIN_PHYS = ATMO.terrainLighting !== 'palette'; +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; + +// ---- 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; +// 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 * (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)]; +} +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]; +} +// 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; +} +function skyTwilight(altRad) { + if (altRad > 0) return 1; + return Math.max(ATMO_NIGHT_FLOOR, Math.pow(2, altRad * 57.2957795 / 8.0)); +} 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) { +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), - q: '[out:json][timeout:50];way["highway"~"' + tagRx + '"](' + + 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;' @@ -1823,22 +2076,100 @@ function carCells(idBase, tagRx, s, w, n, e, latStep, lonStep) { return out; } var CAR_MOTORWAY_RX = '^(motorway|motorway_link|trunk|trunk_link)$'; -var CAR_SECONDARY_RX = '^(secondary|secondary_link)$'; -var CAR_QUERIES = [ - { id: 'geo/mw_w', q: '[out:json][timeout:50];way["highway"~"' + CAR_MOTORWAY_RX + '"](46.8,9.4,50.1,13.4);out geom;' } -].concat( +// "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), - // Bundesstrassen in a fine 0.2 deg raster: every cell is a tiny fast - // query, so transient instance load (504s) only ever costs one cell - carCells('geo/sec', CAR_SECONDARY_RX, 47.5, 12.1, 49.5, 14.8, 0.2, 0.2) + // 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) ); -var CAR_ENDPOINTS = [ - 'https://maps.mail.ru/osm/tools/overpass/api/interpreter', - 'https://overpass-api.de/api/interpreter' - // kumi.systems removed: dead upstream (HTTP 500, no CORS headers) +// 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://overpass.kumi.systems/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); + console.log('[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 @@ -1854,12 +2185,27 @@ function isValidOverpass(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; } +} +// Fingerprint checks only need "is this record usable at all" - a full +// JSON.parse of every cached cell just to hash its metadata is what made +// even a warm cache cycle expensive. +function cheapOverpass(t) { + return !!t && t.length > 64 && t.indexOf('"elements"') >= 0; +} function _opPump() { var now = Date.now(); - while (_opActive < 2 && _opQueue.length && now - _opLast >= 900) { + while (_opActive < 1 && _opQueue.length && now - _opLast >= 2500) { _opActive++; _opLast = now; _opQueue.shift()(); } - if (_opQueue.length) setTimeout(_opPump, Math.max(50, 950 - (Date.now() - _opLast))); + if (_opQueue.length) setTimeout(_opPump, Math.max(50, 2550 - (Date.now() - _opLast))); } function opGate() { return new Promise(function (go) { @@ -1869,13 +2215,23 @@ function opGate() { _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 = CAR_ENDPOINTS[((attempt || 0) + (startIdx || 0)) % CAR_ENDPOINTS.length]; + var ep = opPick(startIdx, attempt); var ctl = null, timer = null; - try { ctl = new AbortController(); timer = setTimeout(function () { ctl.abort(); }, 30000); } catch (e) {} + 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' }, @@ -1883,7 +2239,12 @@ function opQuery(q, startIdx, attempt) { signal: ctl ? ctl.signal : undefined }).then(function (resp) { if (timer) clearTimeout(timer); - if (resp.status !== 200) return null; + 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 @@ -1891,6 +2252,7 @@ function opQuery(q, startIdx, attempt) { }); }, function () { if (timer) clearTimeout(timer); + opCool(ep, 45000); return null; }).then(function (txt) { release(); @@ -1905,29 +2267,119 @@ function opQuery(q, startIdx, attempt) { } // full retry ladder over all mirrors, 2.5 s pause between failed attempts function opTry(q, startIdx) { - var MAX = CAR_ENDPOINTS.length * 3; // three full mirror rounds + 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, 2500 + Math.random() * 2000); }) + 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 -var CAR_W = 0.005; // ribbon half width (world units) +// 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 = 0.05, CAR_M_SPEED = 0.22, CAR_M_DENS = 0.85; -var CAR_B_DASH = 0.14, CAR_B_SPEED = 0.10, CAR_B_DENS = 0.50; -var carGeo = null; // { motorway: [polys], secondary: [polys] } +var 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; @@ -1992,16 +2444,25 @@ function fetchMotorways() { carFetching = true; var fresh = 0; // cells that needed a real fetch this cycle var fpParts = []; // "id:savedAt" of every raw cell -> fp() - var groups = { motorway: [], secondary: [] }; - function parseWays(txt) { - var data; - try { data = JSON.parse(txt); } catch (e) { return; } + var doneCells = 0, totalCells = CAR_QUERIES.length; + console.log('[clouds3d] road cells: starting 0/' + totalCells); + function progCell(cached) { + doneCells++; + if (doneCells % 10 === 0 || doneCells === totalCells) { + console.log('[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' - : (/^secondary/.test(hw) ? 'secondary' : null); + : (/^(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) }; }); @@ -2016,86 +2477,86 @@ function fetchMotorways() { var tl = Math.hypot(t0.x - t1.x, t0.z - t1.z) || 1; rp.headD = { x: (h1.x - h0.x) / hl, z: (h1.z - h0.z) / hl }; // away from body rp.tailD = { x: (t0.x - t1.x) / tl, z: (t0.z - t1.z) / tl }; - groups[key].push(rp); + carRaw[key].push(rp); } }); } - // stitch the raw ways into continuous chains, rebuild ribbons and - // (optionally on cycle completion) persist the chains for fast loads - function rebuildChains(persist) { - var mCh = carMergeChains(groups.motorway); - var bCh = carMergeChains(groups.secondary); - carRawCounts = [groups.motorway.length, groups.secondary.length]; - carGeo = { motorway: mCh, secondary: bCh }; - buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl)); - if (!persist) return; - var em = encodeChains(mCh), eb = encodeChains(bCh); - openTileDB().then(function (db) { - if (!db) return; - tileDbPut(db, { id: 'geo/roadchains_v1', fp: cellsFp(fpParts), - mCnts: em.cnts, mData: em.data, - bCnts: eb.cnts, bData: eb.data, savedAt: Date.now() }); - }); - } - // rebuild ribbons shortly after each arriving cell - the lights grow - // in region by region instead of appearing after the whole chain - var rebuildT = null; - function scheduleRebuild() { - if (rebuildT) return; - rebuildT = setTimeout(function () { - rebuildT = null; - rebuildChains(false); - }, 1500); - } function loadCell(entry) { return openTileDB().then(function (db) { return tileDbGet(db, entry.id).then(function (rec) { // validate stored JSON - a poisoned cache record (HTML error // page saved by an older build) must trigger a refetch - if (rec && rec.json && isValidOverpass(rec.json)) { - entry._cached = true; - fpParts.push(entry.id + ':' + rec.savedAt); - return rec.json; + if (rec && rec.json) { + var cached = parseOverpass(rec.json); + if (cached) { + entry._cached = true; + fpParts.push(entry.id + ':' + rec.savedAt); + progCell(true); + return cached; + } } return opTry(entry.q, entry.start).then(function (txt) { - if (txt && db) { + var obj = parseOverpass(txt); + if (!obj) return null; + if (db) { var ts = Date.now(); fpParts.push(entry.id + ':' + ts); tileDbPut(db, { id: entry.id, json: txt, savedAt: ts }); + progCell(false); } - return txt; + return obj; }); }); - }).then(function (txt) { - if (txt) { - parseWays(txt); + }).then(function (obj) { + if (obj) { + parseWays(obj); if (!entry._cached) fresh++; - if (!entry._cached || !carGeo) scheduleRebuild(); + // 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 -> restore chains straight to the GPU. - // Fingerprint check reads record metadata only, no JSON parsing. + // 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 tileDbGet(db, 'geo/roadchains_v1').then(function (rec) { - if (!rec || !rec.mCnts || !rec.fp) return false; - var ok = true, parts = []; + 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; + var newest = 0, missing = 0, total = CAR_QUERIES.length; return runPool(CAR_QUERIES, 8, function (e) { - if (!ok) return Promise.resolve(); return tileDbGet(db, e.id).then(function (r) { - if (!r || !isValidOverpass(r.json)) ok = false; - else parts.push(e.id + ':' + r.savedAt); + if (r && cheapOverpass(r.json)) { if (r.savedAt > newest) newest = r.savedAt; } + else missing++; }); }).then(function () { - if (!ok || cellsFp(parts) !== rec.fp) return false; - carGeo = { motorway: decodeChains(rec.mCnts, rec.mData), - secondary: decodeChains(rec.bCnts, rec.bData) }; - buildCarwayVBO(elevGl || (clouds3dState && clouds3dState.gl)); - console.log('[clouds3d] car layer: ' + carGeo.motorway.length + - ' motorway + ' + carGeo.secondary.length + ' chains (from cache)'); + // 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)) { + console.log('[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); + console.log('[clouds3d] car layer: ' + carGeo.motorway.cnts.length + + ' motorway + ' + carGeo.secondary.cnts.length + ' chains (from cache' + + (missing ? ', ' + missing + ' cells unavailable' : '') + ')'); return true; }); }); @@ -2106,10 +2567,12 @@ function fetchMotorways() { return loadCell(entry); }).then(function () { carFetching = false; - rebuildChains(true); - console.log('[clouds3d] car layer: ' + groups.motorway.length + - ' motorway + ' + groups.secondary.length + ' secondary polylines -> longest chain ~' + - Math.round(carMaxChainKm) + ' km'); + carPersistFp = geoFp(fpParts); + carCycleFp = carPersistFp; + kickCarBuild(true); + console.log('[clouds3d] car layer: ' + carRaw.motorway.length + + ' motorway + ' + carRaw.secondary.length + + ' secondary polylines queued (' + fresh + ' cells re-fetched)'); }); }); } @@ -2121,7 +2584,7 @@ function fetchMotorways() { // population tag, where present, scales all three). Static - unlike the // moving car lights - and faded in with the same sunset ramp as them. // Data (c) OpenStreetMap contributors, ODbL. -var cityGeo = null; // [{x, z, rank, pop}] +var 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 }; @@ -2139,6 +2602,14 @@ function fetchSettlements() { 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) { + console.log('[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). @@ -2163,20 +2634,11 @@ function fetchSettlements() { ci++; } } + totalCells = cells.length; + console.log('[clouds3d] place cells: starting 0/' + totalCells); var list = []; var seenB = {}; // bucket -> index in list: dedupe node/way/rel of one place - var rebuildT = null; - function scheduleRebuild() { - if (rebuildT) return; - rebuildT = setTimeout(function () { - rebuildT = null; - cityGeo = list.slice(); - buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl)); - }, 1500); - } - function parse(txt) { - var data; - try { data = JSON.parse(txt); } catch (e) { return; } + function parse(data) { (data.elements || []).forEach(function (el) { if (!el.tags) return; var rank = PLACE_RANK[el.tags.place]; @@ -2202,32 +2664,48 @@ function fetchSettlements() { 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 && isValidOverpass(rec.json)) { - entry._cached = true; - fpParts.push(entry.id + ':' + rec.savedAt); - return rec.json; + if (rec && rec.json) { + var cached = parseOverpass(rec.json); + if (cached) { + entry._cached = true; + fpParts.push(entry.id + ':' + rec.savedAt); + progCell(true); + return cached; + } } return opTry(entry.q, entry.start).then(function (txt) { - if (txt && db) { + var obj = parseOverpass(txt); + if (!obj) return null; + if (db) { var ts = Date.now(); fpParts.push(entry.id + ':' + ts); tileDbPut(db, { id: entry.id, json: txt, savedAt: ts }); + progCell(false); } - return txt; + return obj; }); }); - }).then(function (txt) { - if (txt) { + }).then(function (obj) { + if (obj) { var before = list.length; - parse(txt); + parse(obj); if (list.length > before) console.log('[clouds3d] place cell ' + entry.id + ': +' + (list.length - before) + ' places (total ' + list.length + ')'); if (!entry._cached) fresh++; - if (!entry._cached || !cityGeo) scheduleRebuild(); + // 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); }); @@ -2241,14 +2719,15 @@ function fetchSettlements() { return runPool(cells, 8, function (e) { if (!ok) return Promise.resolve(); return tileDbGet(db, e.id).then(function (r) { - if (!r || !isValidOverpass(r.json)) ok = false; + if (!r || !cheapOverpass(r.json)) ok = false; else parts.push(e.id + ':' + r.savedAt); }); }).then(function () { - if (!ok || cellsFp(parts) !== rec.fp) return false; - cityGeo = decodePlaces(rec.cnt, rec.data); - buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl)); - console.log('[clouds3d] settlements: ' + cityGeo.length + ' places (from cache)'); + if (!ok || geoFp(parts) !== rec.fp) return false; + cityGeo = b64ToF32(rec.data); + kickCityBuild(); + if (carGeo) kickCarBuild(false); + console.log('[clouds3d] settlements: ' + rec.cnt + ' places (from cache)'); return true; }); }); @@ -2258,15 +2737,14 @@ function fetchSettlements() { return loadCell(entry); }).then(function () { cityFetching = false; - cityGeo = list.slice(); - buildCityVBO(elevGl || (clouds3dState && clouds3dState.gl)); + publish(); openTileDB().then(function (db) { if (!db) return; - tileDbPut(db, { id: 'geo/placecache_v1', fp: cellsFp(fpParts), - cnt: list.length, data: encodePlaces(list), savedAt: Date.now() }); + tileDbPut(db, { id: 'geo/placecache_v1', fp: geoFp(fpParts), + cnt: list.length, data: f32ToB64(packPlaces(list)), savedAt: Date.now() }); }); - console.log('[clouds3d] settlements: ' + list.length + ' places -> ' + cityCount + ' lights' + - (cityCount ? '' : ' (terrain pending)')); + console.log('[clouds3d] settlements: ' + list.length + ' places queued for build' + + (terrainSample ? '' : ' (terrain pending)')); }); }); } @@ -2278,117 +2756,273 @@ var CAR_VERT_SRC = [ 'uniform mat4 uProj;', 'uniform mat4 uView;', 'uniform vec3 uCamPos;', + '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;', - 'float dir = aMeta.y >= 0.0 ? 1.0 : -1.0;', 'vec3 toCam = uCamPos - aPos;', 'vDist = length(toCam);', - // NOTE the minus: the flow equation moves a pulse toward DECREASING - // arc length, i.e. actual travel = -tangent for side +1. Without - // the flip, approaching cars would show taillights (red). - 'vApproach = -dot(vec3(aMeta.z, 0.0, aMeta.w) * dir, normalize(toCam));', - 'gl_Position = uProj * uView * vec4(aPos, 1.0);', + // 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 = aPos + perp * (vHw - uHalfW);', + 'gl_Position = uProj * uView * vec4(wp, 1.0);', '}' ].join('\n'); var CAR_FRAG_SRC = [ 'precision highp float;', - 'uniform float uTime;', 'uniform float uNight;', // brightness ramp (sunset/dusk, same as night lights)', 'uniform float uDens;', // global vehicle density (0..1), ramps 0->max->0', - 'uniform float uDash;', // mean slot spacing (world units)', - 'uniform float uSpeed;', // slots per second (frame flow)', + '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;', - 'float hash1(float s) {', - 'return fract(sin(s * 12.9898) * 43758.5453);', - '}', + '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;', - // far distance: fade the lights out smoothly toward ~150 km - 'float df = 1.0 - smoothstep(7.0, 12.0, vDist);', + 'float df = 1.0 - smoothstep(uFadeA, uFadeB, vDist);', 'if (df <= 0.0) discard;', - // distance-driven cross-section: widen the bright plateau as the - // ribbon shrinks below a pixel (kills lateral shimmer). ~0.3 at - // >6.5 units (~85 km), flat near the camera. - 'float grow = clamp((vDist - 3.0) * 0.09, 0.0, 0.3);', - 'float body = 1.0 - smoothstep(0.5 + grow, 1.0, abs(vSide));', - 'float dir = vSide >= 0.0 ? 1.0 : -1.0;', - // cars are slotted pulses; hash1(s) jitters each slot's position - // (varying spacing) and a per-slot sine wobble makes every vehicle - // crawl or overtake a bit (varying speeds). No trails - points only. - // Each slot also has a per-vehicle LIFE CYCLE: it switches on with - // a 5 s fade-in, drives for a random 1-20 min, fades out and rests - // for a random ~0.5-30 min before its next trip (on/off ramps in - // spirit) - the traffic field never sits on a fixed pattern. - 'float flow = vLen / uDash + dir * uTime * uSpeed;', - // pulse width grows with distance (~1.5 px floor at the fade edge) - // so distant cars read as slightly larger, steady lights instead - // of one-pixel flicker - 'float slot = 0.11 + clamp((vDist - 2.5) * 0.05, 0.0, 0.4);', - 'float n = floor(flow);', - 'float inten = 0.0;', - 'for (int k = -2; k <= 2; k++) {', - 'float s = n + float(k);', - 'float vis = step(hash1(s + 17.3), dens);', - 'if (vis > 0.0) {', - 'float life = 60.0 + 1140.0 * hash1(s * 0.371 + 9.4);', - 'float rest = 30.0 + 1770.0 * hash1(s * 0.617 + 3.2);', - 'float per = life + rest;', - 'float age = mod(uTime + 3607.0 * hash1(s * 0.913 + 7.7), per);', - 'float on = smoothstep(0.0, 5.0, age) * (1.0 - smoothstep(life - 5.0, life, age));', - 'if (on > 0.001) {', - 'float off = 0.05 + hash1(s) * 0.8;', - 'float w = 0.25 * sin(uTime * uSpeed * (1.5 + hash1(s + 7.1) * 4.0) + hash1(s + 3.7) * 6.283);', - 'float d = abs(flow - (s + off + w));', - 'inten = max(inten, on * max(0.0, 1.0 - d / slot));', - '}', - '}', - '}', - // beyond ~55 km merge the pulses into a steady traffic glow: - // a constant reads as a line of lights, individual slots would - // pop as they cross pixel boundaries - 'float far = smoothstep(4.0, 6.5, vDist);', - 'float steady = clamp(dens * 0.55, 0.0, 0.55);', - 'inten = max(inten * (1.0 - 0.75 * far), steady * far);', - 'if (inten <= 0.001) discard;', - 'inten = pow(inten, 1.8);', - // approaching the camera -> white headlights, driving away -> red', - 'vec3 col = mix(vec3(1.0, 0.12, 0.08), vec3(1.0, 0.96, 0.88),', - 'smoothstep(-0.1, 0.1, vApproach));', - 'gl_FragColor = vec4(col * inten * body * uNight * df, 1.0);', + // 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 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 toCam = uCamPos - base;', + '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(base + 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() { - var ts = terrainSample; - var BIG = 1e9; +function motorwayDistField(chains, ts) { + var BIG = 1e9, i, j, k, d; var f = new Float32Array(ts.gw * ts.gh); f.fill(BIG); - carGeo.motorway.forEach(function (pts) { + for (var p = 0; p < chains.length; p++) { + var pts = chains[p]; for (var q = 0; q < pts.length; q++) { - var i = Math.round((pts[q].x + ts.halfX) / ts.s); - var j = Math.round((ts.halfY - pts[q].z) / ts.s); + 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 (var j = 0; j < ts.gh; j++) { - for (var i = 0; i < ts.gw; i++) { - var k = j * ts.gw + i, d = f[k]; + } + 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); @@ -2398,16 +3032,16 @@ function motorwayDistField() { f[k] = d; } } - for (var j2 = ts.gh - 1; j2 >= 0; j2--) { - for (var i2 = ts.gw - 1; i2 >= 0; i2--) { - var k2 = j2 * ts.gw + i2, d2 = f[k2]; - if (i2 < ts.gw - 1) d2 = Math.min(d2, f[k2 + 1] + 1); - if (j2 < ts.gh - 1) { - d2 = Math.min(d2, f[k2 + ts.gw] + 1); - if (i2 > 0) d2 = Math.min(d2, f[k2 + ts.gw - 1] + 1.41); - if (i2 < ts.gw - 1) d2 = Math.min(d2, f[k2 + ts.gw + 1] + 1.41); + 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[k2] = Math.min(f[k2], d2); + f[k] = Math.min(f[k], d); } } return f; @@ -2415,27 +3049,38 @@ function motorwayDistField() { // Chain OSM road segments into continuous routes by GLOBAL best-first // stitching. Junctions are found PROXIMITY-based (not exact-node): many -// German motorways end each carriageway at its own node a few metres -// apart (bridges!), so exact-key matching severed chains at every -// overpass. A candidate join exists when two way-ends lie within ~130 m -// along the heading AND within ~32 m laterally (that lateral test is -// what keeps the two carriageways of a dual road from joining each -// other). Candidates are scored by directional continuity and processed -// best-first, so a mainline continuation always claims a junction before -// any ramp or angled fork can. Chains cap at ~580 km. Result: the A1 -// Munich -> Vienna reads as ONE ribbon a light can traverse end to end. -var carMaxChainKm = 0; -var carRawCounts = [0, 0]; -var carJoinCount = 0; -function carMergeChains(polys) { +// 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) - // two stitching tiers: TIGHT seams (same carriageway - bridge splits, - // shared nodes) claim junctions first; only still-open ends get the - // LOOSE pass (big interchange nodes where the mainline is cut ~150-300 - // m apart with a small lateral jog). Lateral tolerance is what keeps - // parallel opposite carriageways from pairing with each other. + var 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 } @@ -2443,11 +3088,11 @@ function carMergeChains(polys) { var i, j, k; var n = polys.length; var wayLen = new Float64Array(n); - // endpoint records: position + unit vector pointing AWAY from the way var eps = []; - var grid = {}; + var 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); @@ -2467,45 +3112,51 @@ function carMergeChains(polys) { for (k = 0; k < 2; k++) { var r = recs[k]; var ck = cell(r.x, r.z); - (grid[ck] || (grid[ck] = [])).push(r); + var b0 = grid.get(ck); + if (!b0) { b0 = []; grid.set(ck, b0); } + b0.push(r); eps.push(r); } } - // proximity candidate joins, per tier - var pairGeom = {}; + // 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 = A.w < B.w ? A.w + ':' + A.e + '|' + B.w + ':' + B.e - : B.w + ':' + B.e + '|' + A.w + ':' + A.e; - if (pairGeom[pk]) return pairGeom[pk]; + var 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; - var g = { s: score - Math.hypot(gpx, gpz) * 5, dot: score, - along: along, lat: Math.hypot(latx, latz) }; - pairGeom[pk] = g; + 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 = {}; + 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[(gx + ox) + '_' + (gz + 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 = A2.w < B2.w ? A2.w + ':' + A2.e + '|' + B2.w + ':' + B2.e - : B2.w + ':' + B2.e + '|' + A2.w + ':' + A2.e; - if (seenPair[pk2]) continue; - seenPair[pk2] = 1; + var 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 @@ -2573,75 +3224,565 @@ function carMergeChains(polys) { for (i = 0; i < n; i++) { if (!started[i] && polys[i] && polys[i].length > 1) out.push(polys[i]); } - if (n) { carMaxChainKm = maxD * KM_PER_TILE; carJoinCount = JOINED; } // empty input must not reset the previous call's stats - return out; + return { out: out, n: n, joins: JOINED, + maxChainKm: n ? maxD * cfg.KM_PER_TILE : 0 }; } -function buildCarwayVBO(gl) { - if (!CAR_ON || !gl || !carGeo || !terrainSample) return; - var ts = terrainSample; - var limX = groundTilesX() / 2 + 1, limZ = groundTilesY() / 2 + 1; - var distField = (carGeo.secondary.length && carGeo.motorway.length) - ? motorwayDistField() : null; - +// 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 - function densScale(x, z) { - if (!distField) return 1; + // 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 dTiles = distField[j * ts.gw + i] * ts.s; - var t = Math.max(0, Math.min(1, (dTiles - 4) / 12)); - t = t * t * (3 - 2 * t); - return 1 - 0.9 * t; + var v = settleField[j * ts.gw + i]; + return v > 1 ? 1 : v; } - - function build(polys, withField) { - var verts = []; - // pos(3) + meta(len, side, tangent 2) + dens = 8 floats - function push(x, y, z, len, side, tx, tz, dens) { - verts.push(x, y, z, len, side, tx, tz, dens); + // 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); + } } - for (var li = 0; li < polys.length; li++) { + // 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]; - for (var i = 0; i + 1 < pts.length; i++) { + // 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 (Math.abs(a.x) > limX || Math.abs(a.z) > limZ || - Math.abs(b.x) > limX || Math.abs(b.z) > limZ) continue; + 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 = carYAt(a.x, a.z), yb = carYAt(b.x, b.z); - var dens = withField ? densScale((a.x + b.x) / 2, (a.z + b.z) / 2) : 1; - push(a.x + nx, ya, a.z + nz, a.d, 1, tx, tz, dens); - push(a.x - nx, ya, a.z - nz, a.d, -1, tx, tz, dens); - push(b.x + nx, yb, b.z + nz, b.d, 1, tx, tz, dens); - push(a.x - nx, ya, a.z - nz, a.d, -1, tx, tz, dens); - push(b.x + nx, yb, b.z + nz, b.d, 1, tx, tz, dens); - push(b.x - nx, yb, b.z - nz, b.d, -1, tx, tz, dens); + 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; + return { verts: verts, count: vi / 8, len: totalLen, + pts: pArr.subarray(0, pi), pCount: pn * 2 }; } - - function upload(old, verts) { - if (old) gl.deleteBuffer(old); - if (verts.length < 16) return { vbo: null, count: 0 }; - var vbo = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, vbo); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW); - return { vbo: vbo, count: verts.length / 8 }; + 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 + }; +} +`; - // After deleting the old car VBO, any still-enabled vertex attrib slot - // points at a destroyed buffer -> the sky/cloud full-screen draws warn - // "no buffer is bound to enabled attribute". Re-anchor every enabled - // slot to the persistent full-screen triangle buffer until the passes - // that use them run again. - function sanitizeAttribSlots() { - var safe = clouds3dState && clouds3dState.buf; - if (!safe) return; +var 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) { + console.log('[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() }); + }); +} + +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); + console.log('[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'); + console.log('[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)) { @@ -2650,20 +3791,6 @@ function buildCarwayVBO(gl) { } gl.bindBuffer(gl.ARRAY_BUFFER, safe); } - - // carGeo already carries MERGED chains: fetchMotorways stitches the - // raw ways once per data change and caches the chains (geo/roadchains) - var chainsM = carGeo.motorway; - var chainsB = carGeo.secondary; - var m = upload(carVboM, build(chainsM, false)); - carVboM = m.vbo; carCountM = m.count; - var bn = upload(carVboB, build(chainsB, true)); - carVboB = bn.vbo; carCountB = bn.count; - window.__carDbg = { motorwayIn: carRawCounts[0], secondaryIn: carRawCounts[1], - motorwayChains: chainsM.length, secondaryChains: chainsB.length, - maxChainKm: Math.round(carMaxChainKm * 10) / 10 }; - sanitizeAttribSlots(); - if (!carProg && (carCountM || carCountB)) { carProg = makeProgram(gl, CAR_VERT_SRC, CAR_FRAG_SRC); if (!carProg) return; @@ -2679,12 +3806,92 @@ function buildCarwayVBO(gl) { time: gl.getUniformLocation(carProg, 'uTime'), night: gl.getUniformLocation(carProg, 'uNight'), dens: gl.getUniformLocation(carProg, 'uDens'), - dash: gl.getUniformLocation(carProg, 'uDash'), - speed: gl.getUniformLocation(carProg, 'uSpeed') + 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 + console.log('[clouds3d] city build skipped - ' + + (!gl ? 'no gl context' : '') + + (!gl || terrainSample ? '' : ', no elevation sample yet') + + (cityGeo ? '' : ', no place nodes fetched yet')); + done(); return; + } + console.log('[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); + console.log('[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'), + 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 @@ -2695,9 +3902,12 @@ var CITY_VERT_SRC = [ 'uniform float uScale;', // px scale (viewport height / 600)', 'varying float vBri;', 'varying float vDist;', + 'varying float vSeed;', 'void main() {', 'vDist = length(uCamPos - aPos);', '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(aPos, 1.0);', '}' @@ -2706,8 +3916,13 @@ var CITY_VERT_SRC = [ 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;', @@ -2716,15 +3931,15 @@ var CITY_FRAG_SRC = [ 'float core = smoothstep(1.0, 0.0, d);', 'core = core * core * (1.4 - core * 0.4);', // hot center, soft edge', 'if (core <= 0.0) discard;', - 'gl_FragColor = vec4(vec3(1.0, 0.80, 0.45) * core * vBri * uNight * df, 1.0);', + '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 -function cityRand(seed) { - var x = Math.sin(seed) * 43758.5453; - return x - Math.floor(x); -} +// 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) @@ -2759,6 +3974,15 @@ function cellsFp(parts) { 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) @@ -2772,111 +3996,6 @@ function b64ToU8(b64) { } function f32ToB64(f) { return u8ToB64(new Uint8Array(f.buffer, f.byteOffset, f.byteLength)); } function b64ToF32(b64) { return new Float32Array(b64ToU8(b64).buffer); } -function encodeChains(chains) { - var cnts = [], total = 0, i, j; - for (i = 0; i < chains.length; i++) { cnts.push(chains[i].length); total += chains[i].length; } - var f = new Float32Array(total * 3), k = 0; - for (i = 0; i < chains.length; i++) - for (j = 0; j < chains[i].length; j++) { - var p = chains[i][j]; - f[k++] = p.x; f[k++] = p.z; f[k++] = p.d; - } - return { cnts: cnts, data: f32ToB64(f) }; -} -function decodeChains(cnts, b64) { - var f = b64ToF32(b64), out = [], k = 0; - for (var i = 0; i < cnts.length; i++) { - var arr = []; - for (var j = 0; j < cnts[i]; j++) { arr.push({ x: f[k], z: f[k + 1], d: f[k + 2] }); k += 3; } - out.push(arr); - } - return out; -} -function encodePlaces(list) { - var f = new Float32Array(list.length * 4); - for (var i = 0; i < list.length; i++) { - f[i * 4] = list[i].x; f[i * 4 + 1] = list[i].z; - f[i * 4 + 2] = list[i].rank; f[i * 4 + 3] = list[i].pop; - } - return f32ToB64(f); -} -function decodePlaces(cnt, b64) { - var f = b64ToF32(b64), out = []; - for (var i = 0; i < cnt; i++) - out.push({ x: f[i * 4], z: f[i * 4 + 1], rank: Math.round(f[i * 4 + 2]), pop: f[i * 4 + 3] }); - return out; -} - -// same guard as the car rebuild: after deleting the old city VBO every -// still-enabled attrib slot must point at a live buffer, or the next -// full-screen draw warns "no buffer is bound to enabled attribute". -function sanitizeCityAttribSlots(gl) { - var safe = clouds3dState && clouds3dState.buf; - if (!safe) return; - var maxA = gl.getParameter(gl.MAX_VERTEX_ATTRIBS); - for (var i = 0; i < maxA; i++) { - if (gl.getVertexAttrib(i, gl.VERTEX_ATTRIB_ARRAY_ENABLED)) { - gl.vertexAttribPointer(i, 1, gl.FLOAT, false, 0, 0); - } - } - gl.bindBuffer(gl.ARRAY_BUFFER, safe); -} - -function buildCityVBO(gl) { - if (!CITY_ON || !gl || !cityGeo || !terrainSample) return; - var limX = groundTilesX() / 2 + 1, limZ = groundTilesY() / 2 + 1; - var verts = []; // pos(3) + size + bri = 5 floats - for (var s = 0; s < cityGeo.length; s++) { - var p = cityGeo[s]; - if (Math.abs(p.x) > limX || Math.abs(p.z) > limZ) continue; - var cls = CITY_CLASS[p.rank]; - var scale = p.pop > 0 ? Math.min(4, 0.6 + Math.sqrt(p.pop / 250000)) : 1; - var n = Math.max(1, Math.round(cls.n * scale)); - var spread = cls.r * (0.7 + 0.5 * scale); - var bri = cls.b * Math.min(1.4, 0.8 + 0.12 * scale); - var sz = cls.sz * Math.min(1.7, 0.9 + 0.13 * scale); - for (var k = 0; k < n; k++) { - var h1 = cityRand(s * 91.7 + k * 13.31 + 0.5); - var h2 = cityRand(s * 71.3 + k * 17.19 + 2.1); - var h3 = cityRand(s * 39.1 + k * 5.77 + 4.9); - var ang = h1 * 6.2831853; - var rad = spread * Math.sqrt(h2); - var x = p.x + Math.cos(ang) * rad; - var z = p.z + Math.sin(ang) * rad; - var y = carYAt(x, z) + 0.003; - // per-dot variation so the cluster reads as a town, not a blob - verts.push(x, y, z, sz * (0.6 + 0.8 * h3), bri * (0.55 + 0.7 * h3)); - } - } - var _bT = performance.now(); - if (cityVbo) gl.deleteBuffer(cityVbo); - cityVbo = null; cityCount = 0; - if (verts.length < 5) { sanitizeCityAttribSlots(gl); return; } - var vbo = gl.createBuffer(); - gl.bindBuffer(gl.ARRAY_BUFFER, vbo); - gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW); - cityVbo = vbo; cityCount = verts.length / 5; - // debug counter (also used by headless smoke tests) - window.__cityDbg = { places: cityGeo ? cityGeo.length : 0, lights: cityCount, - buildMs: +(performance.now() - _bT).toFixed(1) }; - sanitizeCityAttribSlots(gl); - if (!cityProg) { - cityProg = makeProgram(gl, CITY_VERT_SRC, CITY_FRAG_SRC); - if (!cityProg) return; - cityAttribs = { - pos: gl.getAttribLocation(cityProg, 'aPos'), - size: gl.getAttribLocation(cityProg, 'aSize'), - bri: gl.getAttribLocation(cityProg, 'aBri') - }; - cityUniforms = { - proj: gl.getUniformLocation(cityProg, 'uProj'), - view: gl.getUniformLocation(cityProg, 'uView'), - camPos: gl.getUniformLocation(cityProg, 'uCamPos'), - scale: gl.getUniformLocation(cityProg, 'uScale'), - night: gl.getUniformLocation(cityProg, 'uNight') - }; - } -} // ---- Aircraft traffic --------------------------------------------------- // Civil aviation between the real aerodromes of the region (OSM @@ -2912,8 +4031,21 @@ 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); +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; @@ -3110,6 +4242,9 @@ var FLT_VERT_SRC = [ 'uniform mat4 uView;', 'uniform vec3 uCamPos;', '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;', @@ -3117,7 +4252,10 @@ var FLT_VERT_SRC = [ 'vDist = length(uCamPos - aPos);', 'vBri = aBlink.x;', 'vPhase = aBlink.y;', - 'gl_PointSize = clamp(aBlink.z * uScale * 2.6, 3.0, 6.0);', + // 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(aPos, 1.0);', '}' ].join('\n'); @@ -3126,6 +4264,9 @@ 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;', @@ -3134,27 +4275,31 @@ var FLT_FRAG_SRC = [ 'if (df <= 0.0) discard;', 'vec2 pc = gl_PointCoord - 0.5;', 'float d = length(pc) * 2.0;', - // small bright disk with a faint halo -> reads as a light even at 3px - 'float core = max(exp(-d * d * 9.0), (1.0 - smoothstep(0.3, 0.55, d)) * 0.5);', - 'if (core <= 0.01) discard;', - // white strobe: fast double-flash every ~1.1 s - 'float t = fract(uTime * 0.9 + vPhase);', - 'float p1 = max(0.0, 1.0 - t * 8.0);', - 'float p2 = max(0.0, 1.0 - abs(t - 0.11) * 8.0);', - 'float flash = p1 * p1 + 0.7 * p2 * p2;', + // 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.7 + vec3(1.0, 0.12, 0.06) * red;', + '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 * core * a, 1.0);', + 'gl_FragColor = vec4(col * g * a, 1.0);', '}' ].join('\n'); -// ---- Daytime airliner model pass --------------------------------------- -// Low-poly jet (vendor/plane3d.js, CC0-derived) for the SAME traffic: by -// day the aircraft are sun-shaded silhouettes in a fixed stylized scale - -// nose-to-tail exactly half the contrail width, so jet and trail always -// read as one object at any distance. +// ---- 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;', @@ -3182,12 +4327,24 @@ var PLANE_VERT_SRC = [ 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);', - 'float l = 0.22 + 0.78 * max(0.0, dot(normalize(vN), uSun));', - 'gl_FragColor = vec4(alb * l, 1.0);', + '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'); @@ -3222,29 +4379,49 @@ var CT_VERT_SRC = [ 'attribute float aP;', // per-trail noise phase 'uniform mat4 uProj;', 'uniform mat4 uView;', + 'uniform vec3 uCamPos;', '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;', + 'vWorld = aPos;', 'gl_Position = uProj * uView * vec4(aPos, 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 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 @@ -3252,7 +4429,18 @@ var CT_FRAG_SRC = [ 'if (edge <= 0.0) discard;', 'float a = vS * pow(vU, 1.15) * 1.15 * dens * edge;', 'if (a <= 0.004) discard;', - 'gl_FragColor = vec4(0.95, 0.96, 1.0, a);', + // 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;', + 'gl_FragColor = vec4(vec3(0.95, 0.96, 1.0) * light, a);', '}' ].join('\n'); @@ -3276,17 +4464,26 @@ function decodePlaneVerts() { // band (low-frequency by nature) stays a baked equirect texture. var skyTexCanvas = null, skyTexture = null, skyTexGl = null; var skyPts = null; // { prog, vbo, count, a0, a1, u: {...} } -var STAR_PH = 2.9; // phase so the plausible constellations sit high at night +var 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 [1.00, 0.72, 0.52]; - if (k < 5000) return [1.00, 0.86, 0.70]; - if (k < 6300) return [1.00, 0.97, 0.92]; - if (k < 8000) return [0.92, 0.95, 1.00]; - return [0.80, 0.87, 1.00]; + 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 = [ @@ -3298,8 +4495,12 @@ var SKYPTS_VERT = [ 'uniform float uSizeScale;', 'varying vec4 vC;', 'varying float vAlt;', + 'varying float vSeed;', '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);', @@ -3307,7 +4508,7 @@ var SKYPTS_VERT = [ // finite radius (inside the far plane): w=0 would put z_ndc just // outside [-1,1] and clip every single point away 'gl_Position = uMVP * vec4(rp * 88.0, 1.0);', - 'gl_PointSize = clamp((7.2 - aSd.w) * 0.60 * uSizeScale, 0.8, 10.0);', + 'gl_PointSize = clamp((7.2 - aSd.w) * 0.60 * uSizeScale, 1.4, 9.0);', 'vC = aScol;', '}' ].join('\n'); @@ -3316,19 +4517,23 @@ 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;', 'void main() {', 'vec2 pc = gl_PointCoord - 0.5;', 'float d = length(pc) * 2.0;', - 'float disc = 1.0 - smoothstep(0.45, 1.0, d);', - 'float glow = 0.22 * exp(-d * 2.8);', - 'float twk = 0.96 + 0.04 * sin(uTime * 1.6 + vC.w * 33.0 + vAlt * 451.7 + vC.r * 180.3);', + // 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;', - 'gl_FragColor = vec4(vC.rgb * (disc + glow) * a * 1.35, 1.0);', + 'gl_FragColor = vec4(vC.rgb * g * a * uBright, 1.0);', '}' ].join('\n'); @@ -3393,7 +4598,7 @@ function buildSkyTexture(stars) { // 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; + 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]; @@ -3403,17 +4608,29 @@ function buildSkyTexture(stars) { var dy = sLat * sd + cLat * cd * sr; var dz = cLat * sd - sLat * cd * sr; var rgb = kelvinToRGB(k); - // desaturate strongly towards white (tints were too strong) - rgb[0] = 0.25 * rgb[0] + 0.75; - rgb[1] = 0.25 * rgb[1] + 0.75; - rgb[2] = 0.25 * rgb[2] + 0.75; + // 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.6 so even 6.5-mag stars glow + // per-star brightness: floor at 0.7 so even 6.5-mag stars glow // clearly; doubles as the twinkle phase seed - var b = 0.6 + 0.4 * Math.min(1, Math.pow(2.512, -mag * 0.7) * 1.3); + var b = 0.7 + 0.3 * Math.min(1, Math.pow(2.512, -mag * 0.7) * 1.3); data[di++] = b; } + console.log('[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(); @@ -3429,7 +4646,8 @@ function buildSkyTexture(stars) { lon: gl.getUniformLocation(prog, 'uLon'), sizeScale: gl.getUniformLocation(prog, 'uSizeScale'), night: gl.getUniformLocation(prog, 'uNight'), - time: gl.getUniformLocation(prog, 'uTime') + time: gl.getUniformLocation(prog, 'uTime'), + bright: gl.getUniformLocation(prog, 'uBright') } }; } @@ -3485,13 +4703,15 @@ function initGroundSystem(glCtx) { } else { groundGl.texParameteri(groundGl.TEXTURE_2D, groundGl.TEXTURE_MIN_FILTER, groundGl.LINEAR); } - // Kick off initial fetch + // 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); - initNightSystem(glCtx); - fetchMotorways(); 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) @@ -3512,9 +4732,18 @@ function hex(rgb) { var WEATHER_DENSITY = { sunny: 0.20, cloudy: 0.70, - stormy: 1.00, + // 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)); + } +} window.clouds3dRender = function(target) { if (!target || typeof target !== 'object') { @@ -3529,6 +4758,8 @@ window.clouds3dRender = function(target) { 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'; @@ -3550,14 +4781,18 @@ window.clouds3dRender = function(target) { var gl = s.gl; // Only touch canvas.width/height when it actually changes: assigning // it every frame reallocates the backbuffer (visible as jank). - // CONFIG.renderScale (0.3..1) renders the 3D at lower internal - // resolution and upscales via CSS if the shader is GPU-bound. + // 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.3, Math.min(1, rs)); - var rw = Math.round(cw * rs); - var rh = Math.round(ch * rs); + 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); @@ -3589,13 +4824,19 @@ window.clouds3dRender = function(target) { 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]); + gl.uniform1f(s.uPhysical, ATMO_TERRAIN_PHYS ? 1.0 : 0.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); - gl.uniform1f(s.uStarLon, -((window.__currentDayProgress || 0) * 6.2831853 + STAR_PH)); + // 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); @@ -3631,6 +4872,41 @@ window.clouds3dRender = function(target) { 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 _star = starAmbient(sunAlt); + var _mBeam = beamColor(_moonAlt), _mL = beamLuma(_moonAlt); + var _moonUp = Math.max(0, Math.sin(_moonAlt)); + var _moonI = ATMO_MOON_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 * _moonFrac * _moonUp * 0.9; + var _amb = skyAmbient(sunAlt) + _moonDiff; + var _air = airlightColor(sunAlt, _moonAlt, _moonFrac); + gl.uniform3f(s.uAirColor, _air[0], _air[1], _air[2]); + 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.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; @@ -3760,7 +5036,9 @@ window.clouds3dRender = function(target) { // ---- Catalogue star points (crisp, additive) ---- // Drawn right after the sky gradient, before the terrain: the depth // test of the mesh pass hides them below the horizon automatically. - var starNight = 1 - Math.max(0, Math.min(1, (sunAlt + 0.20) / 0.25)); + // 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]; @@ -3774,13 +5052,24 @@ window.clouds3dRender = function(target) { gl.enableVertexAttribArray(skyPts.a0); gl.vertexAttribPointer(skyPts.a0, 4, gl.FLOAT, false, 32, 0); gl.enableVertexAttribArray(skyPts.a1); - gl.vertexAttribPointer(skyPts.a1, 4, gl.FLOAT, false, 32, 12); + // 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); - gl.uniform1f(skyPts.u.lon, -((window.__currentDayProgress || 0) * 6.2831853 + STAR_PH)); + // 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); 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); @@ -3806,17 +5095,55 @@ window.clouds3dRender = function(target) { gl.uniformMatrix4fv(terr.uView, false, view); gl.uniformMatrix4fv(terr.uProj, false, proj); gl.uniform1i(terr.uGround, 1); - // Night lights on texture unit 3 (black until the layer exists) - gl.activeTexture(gl.TEXTURE3); - gl.bindTexture(gl.TEXTURE_2D, nightTexture || s.blackTex); - gl.uniform1i(terr.uNight, 3); - gl.uniform1f(terr.uNightLightsOn, NIGHT_ON ? 1.0 : 0.0); + gl.uniform1f(terr.uNightDim, NIGHT_DIM); 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.uniform1f(terr.uPhysical, ATMO_TERRAIN_PHYS ? 1.0 : 0.0); + gl.uniform1f(terr.uSunAlt, sunAlt); + gl.uniform1f(terr.uLightHi, LIGHT_HI); + gl.uniform1f(terr.uLightLo, LIGHT_LO); + gl.uniform1f(terr.uLightSoft, LIGHT_SOFT); + 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.uEdgeFadeTiles, EDGE_FADE_TILES); gl.uniform3fv(terr.uLight, hex(pal.light)); gl.uniform3fv(terr.uBg, hex(pal.bg)); gl.uniform3fv(terr.uSky, hex(pal.sky)); @@ -3871,7 +5198,9 @@ window.clouds3dRender = function(target) { // the sunset ramp; vehicle DENSITY ramps 0 -> max -> 0 with the depth // of night, and secondary roads additionally thin out by distance // from the A-net (per-vertex aDens, smooth - never a hard cut). - var carNight = Math.max(0, Math.min(1, -(window.__currentSunAlt || 0) / 0.4)); + // 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 @@ -3906,11 +5235,18 @@ window.clouds3dRender = function(target) { gl.uniform3f(carUniforms.camPos, camX, camY, camZ); 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, dash, speed, maxDens) { + var carDrawGroup = function (vbo, count, maxDens) { if (!vbo || !count) return; gl.bindBuffer(gl.ARRAY_BUFFER, vbo); gl.enableVertexAttribArray(carAttribs.pos); @@ -3919,13 +5255,97 @@ window.clouds3dRender = function(target) { gl.vertexAttribPointer(carAttribs.meta, 4, gl.FLOAT, false, 32, 12); gl.enableVertexAttribArray(carAttribs.dens); gl.vertexAttribPointer(carAttribs.dens, 1, gl.FLOAT, false, 32, 28); - gl.uniform1f(carUniforms.dash, dash); - gl.uniform1f(carUniforms.speed, speed); gl.uniform1f(carUniforms.dens, maxDens * tCurve * rush); gl.drawArrays(gl.TRIANGLES, 0, count); }; - carDrawGroup(carVboM, carCountM, CAR_M_DASH, CAR_M_SPEED, CAR_M_DENS); - carDrawGroup(carVboB, carCountB, CAR_B_DASH, CAR_B_SPEED, CAR_B_DENS); + 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'), + 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.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); } @@ -3940,6 +5360,10 @@ window.clouds3dRender = function(target) { gl.uniformMatrix4fv(cityUniforms.view, false, view); gl.uniform3f(cityUniforms.camPos, camX, camY, camZ); gl.uniform1f(cityUniforms.night, Math.min(1, carNight * 1.35)); + gl.uniform1f(cityUniforms.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); @@ -3976,7 +5400,7 @@ window.clouds3dRender = function(target) { fltBuf[k] = fl.x; fltBuf[k + 1] = yF; fltBuf[k + 2] = fl.z; fltBuf[k + 3] = f.big; fltBuf[k + 4] = f.phase; - fltBuf[k + 5] = 1.0; + 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; @@ -4001,7 +5425,13 @@ window.clouds3dRender = function(target) { camPos: gl.getUniformLocation(fltProg, 'uCamPos'), scale: gl.getUniformLocation(fltProg, 'uScale'), time: gl.getUniformLocation(fltProg, 'uTime'), - night: gl.getUniformLocation(fltProg, 'uNight') + 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') }; } } @@ -4013,6 +5443,12 @@ window.clouds3dRender = function(target) { 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); @@ -4026,11 +5462,11 @@ window.clouds3dRender = function(target) { gl.depthMask(true); } } - // -- day: silhouettes -- + // -- 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 && dayf > 0.02) { + if (planeN) { if (!planeVbo) { planeVbo = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, planeVbo); @@ -4051,7 +5487,10 @@ window.clouds3dRender = function(target) { origin: gl.getUniformLocation(pp, 'uOrigin'), orit: gl.getUniformLocation(pp, 'uOrit'), s: gl.getUniformLocation(pp, 'uS'), - sun: gl.getUniformLocation(pp, 'uSun') + sun: gl.getUniformLocation(pp, 'uSun'), + light: gl.getUniformLocation(pp, 'uLight'), + moon: gl.getUniformLocation(pp, 'uMoon'), + moonAmt: gl.getUniformLocation(pp, 'uMoonAmt') }; } } @@ -4061,6 +5500,13 @@ window.clouds3dRender = function(target) { gl.uniformMatrix4fv(planeU.proj, false, proj); gl.uniformMatrix4fv(planeU.view, false, view); 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); @@ -4072,8 +5518,11 @@ window.clouds3dRender = function(target) { gl.depthMask(true); // opaque little solids gl.disable(gl.BLEND); gl.disable(gl.CULL_FACE); - var S0 = CT_HALF_W * 0.5; // model is unit-extent: tip-to-tail - // = CT_HALF_W = half the contrail width + // 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; for (var dj = 0; dj < fltCount; dj++) { var briJ = fltBuf[dj * 6 + 3]; if (briJ <= 0) continue; @@ -4090,7 +5539,7 @@ window.clouds3dRender = function(target) { if (stj.s < cln) pitch = 0.16; else if (stj.s > stj.leg.len - cln) pitch = -0.16; } - // fixed physical size: nose-to-tail = half the trail width + // 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); @@ -4128,10 +5577,15 @@ window.clouds3dRender = function(target) { fltLegAt(stc.leg, stc.s - tl * 0.5, _cs); var cdx = _cs.x - camX, cdy = stc.y - camY, cdz = _cs.z - camZ; var distc = Math.sqrt(cdx * cdx + cdy * cdy + cdz * cdz); - // gentle distance thinning only - var dfc = 1 - (distc - 16) / 10; + // 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; - var strg = gt * dayf * dfc; + // 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 * 0.34); + 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; @@ -4170,13 +5624,29 @@ window.clouds3dRender = function(target) { s: gl.getAttribLocation(cpm, 'aS'), p: gl.getAttribLocation(cpm, 'aP') }; ctU = { proj: gl.getUniformLocation(cpm, 'uProj'), - view: gl.getUniformLocation(cpm, 'uView') }; + view: gl.getUniformLocation(cpm, 'uView'), + camPos: gl.getUniformLocation(cpm, 'uCamPos'), + 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') }; } } 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.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.enableVertexAttribArray(ctA.pos); gl.vertexAttribPointer(ctA.pos, 3, gl.FLOAT, false, 28, 0); gl.enableVertexAttribArray(ctA.u);