From 45e35982daf75c40fafb0bc25114a0c2b8c5b3a7 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Mon, 12 Oct 2026 21:00:23 +0200 Subject: [PATCH] clouds: advect cloud field with Open-Meteo wind --- background.html | 33 +++++++++++++++++++++++++++++---- config.js | 11 ++++++++++- vendor/clouds3d.js | 45 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/background.html b/background.html index 96aabf9..1eeacb3 100644 --- a/background.html +++ b/background.html @@ -2133,8 +2133,8 @@ window.weatherThemes = { function fetchWeather() { var url = 'https://api.open-meteo.com/v1/forecast?latitude=' + WEATHER_LAT + '&longitude=' + WEATHER_LON + - '¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m' + - '&hourly=temperature_2m,weather_code' + + '¤t=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m' + + '&hourly=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m' + '&daily=weather_code,temperature_2m_max,temperature_2m_min' + '&timezone=Europe/Berlin&forecast_days=5'; @@ -2147,6 +2147,10 @@ window.weatherThemes = { window.BackgroundCache.put('meta/weather', { json: data }); } currentWeatherCode = data.current.weather_code; + window.__currentWind = { + speedKmh: Number(data.current.wind_speed_10m || 0), + dirDeg: Number(data.current.wind_direction_10m || 0) + }; // while the weather sim runs it owns the theme - a live // poll must not override the configured test weather if (!__weatherSimActive) @@ -2163,6 +2167,10 @@ window.weatherThemes = { weatherData = rec.json; window.__weatherStale = true; currentWeatherCode = rec.json.current.weather_code; + window.__currentWind = { + speedKmh: Number(rec.json.current.wind_speed_10m || 0), + dirDeg: Number(rec.json.current.wind_direction_10m || 0) + }; if (!__weatherSimActive) setWeatherTheme(getWeatherThemeName(currentWeatherCode)); updateWeatherDisplay(); @@ -2220,6 +2228,12 @@ window.weatherThemes = { var curIdx = findHourlyIndex(eff, hourly.time); curTemp = hourly.temperature_2m[curIdx]; curCode = hourly.weather_code[curIdx]; + if (hourly.wind_speed_10m && hourly.wind_direction_10m) { + window.__currentWind = { + speedKmh: Number(hourly.wind_speed_10m[curIdx] || 0), + dirDeg: Number(hourly.wind_direction_10m[curIdx] || 0) + }; + } if (!__weatherSimActive && curCode !== currentWeatherCode) { currentWeatherCode = curCode; setWeatherTheme(getWeatherThemeName(curCode)); @@ -2231,9 +2245,20 @@ window.weatherThemes = { var tempDisplay = currentUnits === 'imperial' ? Math.round(curTemp * 9 / 5 + 32) + '\u00b0F' : Math.round(curTemp) + '\u00b0C'; + var curWindSpeed = current.wind_speed_10m; + var curWindDir = current.wind_direction_10m; + if (SIM_ACTIVE && hourly.wind_speed_10m && hourly.wind_direction_10m) { + var curIdx = findHourlyIndex(eff, hourly.time); + curWindSpeed = hourly.wind_speed_10m[curIdx]; + curWindDir = hourly.wind_direction_10m[curIdx]; + } + window.__currentWind = { + speedKmh: Number(curWindSpeed || 0), + dirDeg: Number(curWindDir || 0) + }; var windDisplay = currentUnits === 'imperial' - ? Math.round(current.wind_speed_10m * 0.621371) + ' mph' - : Math.round(current.wind_speed_10m) + ' km/h'; + ? Math.round(curWindSpeed * 0.621371) + ' mph' + : Math.round(curWindSpeed) + ' km/h'; var currentHtml = '
' + weatherSVG(curCode) + '
' + '
' + tempDisplay + '' + ((window.CONFIG && window.CONFIG.location && (window.CONFIG.location.label || (window.CONFIG.location.lat.toFixed(2) + ',' + window.CONFIG.location.lon.toFixed(2)))) || '') + '
' + diff --git a/config.js b/config.js index 055b47d..38f0043 100644 --- a/config.js +++ b/config.js @@ -152,7 +152,16 @@ window.CONFIG = { // only ever reports sunny/cloudy/stormy, so the deck keeps that // coverage through the night as well - 'night' is used by the weather // simulation (weatherSim: true) alone. - density: { sunny: 0.30, cloudy: 0.70, stormy: 1.00, night: 0.45 } + density: { sunny: 0.30, cloudy: 0.70, stormy: 1.00, night: 0.45 }, + // Cloud motion from Open-Meteo wind. Open-Meteo reports wind FROM + // the reported compass direction; clouds advect toward the opposite + // bearing. speedScale exaggerates real advection so the motion is + // visible at the world scale (1 unit ~ 12.9 km). + wind: { + speedScale: 80, + minSpeed: 0.004, + maxSpeed: 0.08 + } // density: { sunny: 0.05, cloudy: 0.9, stormy: 1.0, night: 0.2 } }, diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index efa5181..71e12bf 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -60,6 +60,7 @@ var FRAG_SRC = [ 'precision highp float;', 'uniform vec2 uResolution;', 'uniform float uTime;', + 'uniform vec3 uWind;', 'uniform sampler2D uGround;', 'uniform vec2 uGroundCenter;', // current mosaic centre (world units) 'uniform vec2 uGroundSize;', // mosaic side extents (canvas tiles: W x H) @@ -203,13 +204,13 @@ var FRAG_SRC = [ '}', 'float cloudDensity(vec3 p, float baseThresh, float soft) {', 'float py = p.y + earthDrop(p.xz);', - 'vec3 cp = vec3(p.x, py, p.z);', - 'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.05));', + 'vec3 cp = vec3(p.x, py, p.z) + uWind * uTime;', + 'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.02));', // The detail octave weighs at most 0.20 and fades to zero with // distance; past soft = 0.95 it contributes under 1 %, so the whole // 4-octave lookup is skipped for the (numerous) far samples. 'if (soft < 0.75) {', - 'float d = fbm3(cp * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));', + 'float d = fbm3(cp * 1.80 + vec3(uTime * 0.015, 7.3, uTime * 0.012));', 'n = n + d * mix(0.22, 0.0, soft / 0.75);', '}', // Cloud layer between uCloudBot and uCloudTop, densest in the @@ -397,10 +398,10 @@ var FRAG_SRC = [ 'return vec2((-b - sq) / (2.0 * a), (-b + sq) / (2.0 * a));', '}', 'float cloudDensitySpherical(vec3 p, float alt, float baseThresh, float soft) {', - 'vec3 q = vec3(p.x, alt, p.z);', - 'float n = fbm3(q * 0.22 + vec3(0.0, 0.0, uTime * 0.05));', + 'vec3 q = vec3(p.x, alt, p.z) + uWind * uTime;', + 'float n = fbm3(q * 0.22 + vec3(0.0, 0.0, uTime * 0.02));', 'if (soft < 0.75) {', - 'float d = fbm3(q * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));', + 'float d = fbm3(q * 1.80 + vec3(uTime * 0.015, 7.3, uTime * 0.012));', 'n = n + d * mix(0.22, 0.0, soft / 0.75);', '}', 'float h = clamp((alt - uCloudBot) / max(0.01, uCloudTop - uCloudBot), 0.0, 1.0);', @@ -852,13 +853,13 @@ var CLOUD_SAMPLER_GLSL = [ '}', 'float cloudDensity(vec3 p, float baseThresh, float soft) {', 'float py = p.y + earthDrop(p.xz);', - 'vec3 cp = vec3(p.x, py, p.z);', - 'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.05));', + 'vec3 cp = vec3(p.x, py, p.z) + uWind * uTime;', + 'float n = fbm3(cp * 0.22 + vec3(0.0, 0.0, uTime * 0.02));', // The detail octave weighs at most 0.20 and fades to zero with // distance; past soft = 0.95 it contributes under 1 %, so the whole // 4-octave lookup is skipped for the (numerous) far samples. 'if (soft < 0.75) {', - 'float d = fbm3(cp * 1.80 + vec3(uTime * 0.045, 7.3, uTime * 0.035));', + 'float d = fbm3(cp * 1.80 + vec3(uTime * 0.015, 7.3, uTime * 0.012));', 'n = n + d * mix(0.22, 0.0, soft / 0.75);', '}', 'float h = clamp((py - uCloudBot) / (uCloudTop - uCloudBot), 0.0, 1.0);', @@ -885,6 +886,7 @@ var TERRAIN_FRAG_SRC = [ 'uniform vec3 uHorizonColor;', // fog colour taken from the same light model 'uniform float uStarAmb;', // diffuse starlight + airglow (night only) 'uniform float uTime;', // must match the sky pass: same cloud field + 'uniform vec3 uWind;', // cloud advection vector, world units / s 'uniform float uCloudBot;', 'uniform float uCloudTop;', 'uniform float uDensity;', // cloud coverage, same as the sky pass @@ -1087,6 +1089,7 @@ function init(canvas) { var aPos = gl.getAttribLocation(prog, 'aPos'); var uRes = gl.getUniformLocation(prog, 'uResolution'); var uTime = gl.getUniformLocation(prog, 'uTime'); + var uWind = gl.getUniformLocation(prog, 'uWind'); var uGround = gl.getUniformLocation(prog, 'uGround'); var uGroundCenter = gl.getUniformLocation(prog, 'uGroundCenter'); var uGroundSize = gl.getUniformLocation(prog, 'uGroundSize'); @@ -1223,6 +1226,7 @@ function init(canvas) { var terrainUFarPlane = terrainProg ? gl.getUniformLocation(terrainProg, 'uFarPlane') : null; var terrainUstarAmb = terrainProg ? gl.getUniformLocation(terrainProg, 'uStarAmb') : null; var terrainUtime = terrainProg ? gl.getUniformLocation(terrainProg, 'uTime') : null; + var terrainUwind = terrainProg ? gl.getUniformLocation(terrainProg, 'uWind') : 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; @@ -1250,7 +1254,7 @@ function init(canvas) { return { gl: gl, prog: prog, buf: buf, aPos: aPos, blackTex: blackTex, - uRes: uRes, uTime: uTime, uGround: uGround, + uRes: uRes, uTime: uTime, uWind: uWind, uGround: uGround, uGroundCenter: uGroundCenter, uGroundSize: uGroundSize, uCorridorRot: uCorridorRot, uViewProjInv: uViewProjInv, @@ -1314,7 +1318,7 @@ function init(canvas) { uHazeMaxAlpha: terrainUHazeMaxAlpha, uHazeColor: terrainUHazeColor, uDistancePassOn: terrainUDistancePass, uFarPlane: terrainUFarPlane, uStarAmb: terrainUstarAmb, - uTime: terrainUtime, uCloudBot: terrainUcloudbot, + uTime: terrainUtime, uWind: terrainUwind, uCloudBot: terrainUcloudbot, uCloudTop: terrainUcloudtop, uDensity: terrainUdensity, uCloudShadowOn: terrainUcloudshadow, uCloudShadowDensity: terrainUcsDens, @@ -1435,6 +1439,22 @@ if (window.CONFIG && window.CONFIG.clouds) { CLOUD_TOP = Math.max(0.2, Math.min(CLOUD_TOP, 2.0)); CLOUD_BOT = Math.min(CLOUD_BOT, CLOUD_TOP - 0.4); var CLOUDS_ON = !(window.CONFIG && window.CONFIG.clouds && window.CONFIG.clouds.enabled === false); +var CLOUD_WIND_CFG = (window.CONFIG && window.CONFIG.clouds && window.CONFIG.clouds.wind) || {}; +var CLOUD_WIND_SCALE = (typeof CLOUD_WIND_CFG.speedScale === 'number') ? Math.max(0, CLOUD_WIND_CFG.speedScale) : 80; +var CLOUD_WIND_MIN = (typeof CLOUD_WIND_CFG.minSpeed === 'number') ? Math.max(0, CLOUD_WIND_CFG.minSpeed) : 0.004; +var CLOUD_WIND_MAX = (typeof CLOUD_WIND_CFG.maxSpeed === 'number') ? Math.max(0, CLOUD_WIND_CFG.maxSpeed) : 0.08; +function cloudWindVector() { + var w = window.__currentWind || {}; + var speedKmh = Number(w.speedKmh || 0); + var dirDeg = Number(w.dirDeg || 0); + var speed = speedKmh * CLOUD_WIND_SCALE / (KM_PER_TILE * 3600); + speed = Math.max(CLOUD_WIND_MIN, Math.min(CLOUD_WIND_MAX, speed)); + // Open-Meteo wind_direction_10m is the compass bearing the wind blows + // FROM. Clouds advect toward the opposite bearing. World +X = east, + // +Z = north, bearing 0 = north and clockwise. + var rad = (dirDeg + 180) * Math.PI / 180; + return [Math.sin(rad) * speed, 0.0, Math.cos(rad) * speed]; +} // Terrain elevation grid (CONFIG.terrain). Heights come from Mapzen/ // TILEZEN "terrarium" tiles (Copernicus DEM) at ELEV_Z, fetched once @@ -6130,6 +6150,8 @@ window.clouds3dRender = function(target) { gl.uniform2f(s.uRes, canvas.width, canvas.height); var now = performance.now() / 1000; gl.uniform1f(s.uTime, now); + var _cloudWind = cloudWindVector(); + gl.uniform3f(s.uWind, _cloudWind[0], _cloudWind[1], _cloudWind[2]); var pal = window.__currentPalette; if (!pal) { @@ -6485,6 +6507,7 @@ window.clouds3dRender = function(target) { // 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.uniform3f(terr.uWind, _cloudWind[0], _cloudWind[1], _cloudWind[2]); gl.uniform1f(terr.uCloudBot, CLOUD_BOT); gl.uniform1f(terr.uCloudTop, CLOUD_TOP); gl.uniform1f(terr.uDensity, density);