feat: terrain-following cloud base

This commit is contained in:
Richard Zink committed 2026-10-13 14:16:34 +02:00
1 parent c02a198f3f
commit 3e17279499
2 files changed
+54 -6

No files matched your search

+8
View File
@@ -335,6 +335,14 @@ window.CONFIG = {
start: 0.35, // world units: clouds fade out very close to camera
end: 2.5 // full density from this distance onward
},
// Terrain-following cloud base. The DEM height grid is sampled in
// the cloud shader, so the cloud bottom rises locally to keep a
// clearance above the visible ground. 'bottom' remains the global
// floor for areas without terrain data.
groundFollow: {
enabled: true, // false = flat cloud deck
clearanceM: 50 // metres above terrain, exaggerated with terrain
},
// Coverage per weather level, 0 = clear, 1 = overcast. Only the
// levels listed here override the built-in defaults
// (sunny 0.20, cloudy 0.70, stormy 0.80, night 0.45). Live weather
+46 -6
View File
@@ -85,6 +85,10 @@ var FRAG_SRC = [
'uniform float uSunAlt;',
'uniform float uCloudTop;', // cloud deck upper edge (world Y)
'uniform float uCloudBot;', // cloud deck lower edge (world Y)
'uniform float uCloudGroundFollow;',
'uniform float uCloudClearanceUnits;',
'float terrElev(vec2 xz);',
'float cloudBaseY(vec2 xz);',
'uniform float uCloudNearFadeStart;',
'uniform float uCloudNearFadeEnd;',
'uniform sampler2D uElev;', // terrarium RGB height grid, same UV as uGround
@@ -230,9 +234,9 @@ var FRAG_SRC = [
'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
// middle (bell curve). The layer follows the Earth curvature.
'float h = clamp((py - uCloudBot) / (uCloudTop - uCloudBot), 0.0, 1.0);',
// Cloud layer between the terrain-following base and uCloudTop.
'float bot = cloudBaseY(p.xz);',
'float h = clamp((py - bot) / max(0.01, uCloudTop - bot), 0.0, 1.0);',
'float profile = 4.0 * h * (1.0 - h); // bell curve',
'profile = max(profile, 0.0);',
'return smoothstep(baseThresh, baseThresh + 0.34 + soft * 0.28, n) * profile;',
@@ -266,6 +270,14 @@ var FRAG_SRC = [
'vec3 t = texture2D(uElev, euv).rgb;',
'return (t.r * 256.0 + t.g + t.b / 256.0) * 255.0 - 32768.0;',
'}',
'float cloudBaseY(vec2 xz) {',
'float base = uCloudBot;',
'if (uCloudGroundFollow > 0.5 && uElevOn > 0.5) {',
'float terrainY = terrElev(xz) * uElevScale;',
'base = max(base, terrainY + uCloudClearanceUnits);',
'}',
'return min(base, uCloudTop - 0.05);',
'}',
'vec2 curvedLayerRoots(float layerY, vec3 ro, vec3 rd) {',
'float R = max(1.0, uEarthRadius);',
'float a = (uCurvatureOn > 0.5) ? dot(rd.xz, rd.xz) / (2.0 * R) : 0.0;',
@@ -421,7 +433,8 @@ var FRAG_SRC = [
'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);',
'float bot = cloudBaseY(p.xz);',
'float h = clamp((alt - bot) / max(0.01, uCloudTop - bot), 0.0, 1.0);',
'float profile = 4.0 * h * (1.0 - h);',
'profile = max(profile, 0.0);',
'return smoothstep(baseThresh, baseThresh + 0.34 + soft * 0.28, n) * profile;',
@@ -725,7 +738,8 @@ var FRAG_SRC = [
'float soft = smoothstep(4.0, 20.0, t);',
'float d = cloudDensitySpherical(p, alt, baseThresh, soft);',
'if (d > 0.02) {',
'float lit = clamp((alt - uCloudBot) / max(0.01, uCloudTop - uCloudBot), 0.0, 1.0);',
'float botHere = cloudBaseY(p.xz);',
'float lit = clamp((alt - botHere) / max(0.01, uCloudTop - botHere), 0.0, 1.0);',
'vec3 albC = uCloudAlbedo * mix(0.78, 1.0, lit);',
'float selfLit = clamp(1.0 - d * 0.8, 0.18, 1.0);',
'vec3 direct = albC * uSunColor * uSunInt * phase * sunUp * selfLit;',
@@ -834,6 +848,14 @@ var CLOUD_SAMPLER_GLSL = [
'vec3 t = texture2D(uElev, euv).rgb;',
'return (t.r * 256.0 + t.g + t.b / 256.0) * 255.0 - 32768.0;',
'}',
'float cloudBaseYT(vec2 xz) {',
'float base = uCloudBot;',
'if (uCloudGroundFollow > 0.5) {',
'float terrainY = terrElevT(xz) * uElevScale;',
'base = max(base, terrainY + uCloudClearanceUnits);',
'}',
'return min(base, uCloudTop - 0.05);',
'}',
// 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
@@ -887,7 +909,8 @@ var CLOUD_SAMPLER_GLSL = [
'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);',
'float bot = cloudBaseYT(p.xz);',
'float h = clamp((py - bot) / max(0.01, uCloudTop - bot), 0.0, 1.0);',
'float profile = 4.0 * h * (1.0 - h);',
'profile = max(profile, 0.0);',
'return smoothstep(baseThresh, baseThresh + 0.34 + soft * 0.28, n) * profile;',
@@ -914,6 +937,8 @@ var TERRAIN_FRAG_SRC = [
'uniform vec3 uWind;', // cloud advection vector, world units / s
'uniform float uCloudBot;',
'uniform float uCloudTop;',
'uniform float uCloudGroundFollow;',
'uniform float uCloudClearanceUnits;',
'uniform float uDensity;', // cloud coverage, same as the sky pass
'uniform float uCloudShadowOn;',
'uniform float uCloudShadowDensity;', // optical depth per density sample
@@ -1145,6 +1170,8 @@ function init(canvas) {
var uSunAlt = gl.getUniformLocation(prog, 'uSunAlt');
var uCloudTop = gl.getUniformLocation(prog, 'uCloudTop');
var uCloudBot = gl.getUniformLocation(prog, 'uCloudBot');
var uCloudGroundFollow = gl.getUniformLocation(prog, 'uCloudGroundFollow');
var uCloudClearanceUnits = gl.getUniformLocation(prog, 'uCloudClearanceUnits');
var uElev = gl.getUniformLocation(prog, 'uElev');
var uElevOn = gl.getUniformLocation(prog, 'uElevOn');
var uElevScale = gl.getUniformLocation(prog, 'uElevScale');
@@ -1268,6 +1295,8 @@ function init(canvas) {
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 terrainUcloudfollow = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudGroundFollow') : null;
var terrainUcloudclearance = terrainProg ? gl.getUniformLocation(terrainProg, 'uCloudClearanceUnits') : 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;
@@ -1307,6 +1336,7 @@ function init(canvas) {
uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress,
uSunAlt: uSunAlt,
uCloudTop: uCloudTop, uCloudBot: uCloudBot,
uCloudGroundFollow: uCloudGroundFollow, uCloudClearanceUnits: uCloudClearanceUnits,
uCloudNearFadeStart: gl.getUniformLocation(prog, 'uCloudNearFadeStart'),
uCloudNearFadeEnd: gl.getUniformLocation(prog, 'uCloudNearFadeEnd'),
uElev: uElev, uElevOn: uElevOn, uElevScale: uElevScale, uTerrainExag: uTerrainExag,
@@ -1372,6 +1402,7 @@ function init(canvas) {
uStarAmb: terrainUstarAmb,
uTime: terrainUtime, uWind: terrainUwind, uCloudBot: terrainUcloudbot,
uCloudTop: terrainUcloudtop, uDensity: terrainUdensity,
uCloudGroundFollow: terrainUcloudfollow, uCloudClearanceUnits: terrainUcloudclearance,
uCloudShadowOn: terrainUcloudshadow,
uCloudShadowDensity: terrainUcsDens,
uCloudShadowFloor: terrainUcsFloor,
@@ -1553,6 +1584,11 @@ var TERRAIN_EXAG = (typeof TERRAIN_CFG.exaggeration === 'number' &&
TERRAIN_CFG.exaggeration > 0) ? TERRAIN_CFG.exaggeration : 8;
// metres -> world units, including the vertical exaggeration
var ELEV_SCALE_UNITS = TERRAIN_EXAG / (KM_PER_TILE * 1000.0);
var CLOUD_GROUND_FOLLOW_CFG = (window.CONFIG && window.CONFIG.clouds && window.CONFIG.clouds.groundFollow) || {};
var CLOUD_GROUND_FOLLOW = CLOUD_GROUND_FOLLOW_CFG.enabled === true ? 1.0 : 0.0;
var CLOUD_CLEARANCE_M = (typeof CLOUD_GROUND_FOLLOW_CFG.clearanceM === 'number')
? Math.max(0, CLOUD_GROUND_FOLLOW_CFG.clearanceM) : 50;
var CLOUD_CLEARANCE_UNITS = CLOUD_CLEARANCE_M * ELEV_SCALE_UNITS;
var ORBIT_CFG = (window.CONFIG && window.CONFIG.orbit) || null;
var ORBIT_R = 0; // figure-8 half-diagonal (lobe tip reach) in tiles
@@ -7384,6 +7420,8 @@ window.clouds3dRender = function(target) {
gl.uniform1f(s.uCloudBot, CLOUD_BOT);
gl.uniform1f(s.uCloudNearFadeStart, CLOUD_NEAR_FADE_START);
gl.uniform1f(s.uCloudNearFadeEnd, CLOUD_NEAR_FADE_END);
gl.uniform1f(s.uCloudGroundFollow, CLOUD_GROUND_FOLLOW);
gl.uniform1f(s.uCloudClearanceUnits, CLOUD_CLEARANCE_UNITS);
// Ground mosaic uniforms + periodic re-fetch trigger.
// uGroundCenter is the world position (X, Z) of the centre tile's
@@ -7656,6 +7694,8 @@ window.clouds3dRender = function(target) {
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.uCloudGroundFollow, CLOUD_GROUND_FOLLOW);
gl.uniform1f(terr.uCloudClearanceUnits, CLOUD_CLEARANCE_UNITS);
gl.uniform1f(terr.uDensity, density);
gl.uniform1f(terr.uCloudShadowOn, ATMO_CLOUD_SHADOWS ? 1.0 : 0.0);
gl.uniform1f(terr.uCloudShadowDensity, ATMO_CS_DENSITY);