fellwing / atmos.js
CyberMax-tools's picture
Deploy fellwing
8f3ff28 verified
Raw History Blame Contribute Delete
7.55 kB
// Fellwing sky and air: one GLSL atmosphere shared by the sky dome, the terrain's aerial perspective, the sea and the
// clouds, so far mountains fade into exactly the sky behind them. Analytic single-scattering look (Rayleigh blue,
// Mie haze and sun glow, sunset reddening by air mass), Earth curvature for the horizon, and a time-of-day table.
// (c) 2026 CyberMax. All rights reserved.
export const ATMOS_GLSL = /* glsl */`
uniform vec3 uSun; // unit vector to the sun (world)
uniform float uHaze; // 0 = crystal clear, 1 = thick haze
uniform float uOvercast; // 0..1 grey sky
uniform float uNight; // 0 day .. 1 night
uniform vec3 uSunCol; // sun colour * intensity (linear)
const float R_EARTH = 6378137.0;
vec3 skyColor(vec3 dir) {
float sunH = uSun.y;
float h = max(dir.y, -0.02);
float mu = dot(dir, uSun);
// air mass along the view (more air near the horizon)
float am = 1.0 / (max(h, 0.0) + 0.05);
vec3 rayleigh = vec3(0.18, 0.42, 1.0);
vec3 zenith = mix(vec3(0.10, 0.32, 0.78), vec3(0.20, 0.44, 0.86), uHaze * 0.5);
vec3 horizon = mix(vec3(0.66, 0.78, 0.92), vec3(0.80, 0.84, 0.88), uHaze);
vec3 col = mix(horizon, zenith, pow(clamp(h, 0.0, 1.0), 0.45));
// sunset: horizon and the half of the sky around the sun go warm when the sun is low
float low = 1.0 - smoothstep(-0.06, 0.32, sunH);
vec3 warm = vec3(1.0, 0.48, 0.22);
float side = 0.5 + 0.5 * mu;
col = mix(col, mix(vec3(0.95, 0.62, 0.45), warm, side) * (0.55 + 0.45 * side), low * (1.0 - smoothstep(0.0, 0.5, h)) * 0.85);
col = mix(col, col * vec3(0.55, 0.62, 0.95), low * smoothstep(0.1, 0.7, h) * 0.5);
// Mie: haze glow around the sun
float mie = pow(max(mu, 0.0), 8.0) * 0.35 + pow(max(mu, 0.0), 64.0) * 0.6;
col += uSunCol * mie * (0.25 + uHaze * 0.6) * (1.0 - uOvercast * 0.8);
// daylight level from the sun's height
float day = smoothstep(-0.12, 0.18, sunH);
col *= mix(0.04, 1.0, day);
// overcast: flat grey that still brightens a little toward the sun
vec3 grey = vec3(0.62, 0.65, 0.70) * mix(0.05, 1.0, day) * (0.85 + 0.15 * max(mu, 0.0));
col = mix(col, grey, uOvercast);
// night: deep blue
col = mix(col, vec3(0.006, 0.010, 0.026) + vec3(0.02, 0.03, 0.06) * pow(1.0 - h, 3.0), uNight);
return col;
}
// Aerial perspective: blend a lit surface colour toward the sky along the view ray, with air density falling with height.
vec3 aerial(vec3 col, vec3 camPos, vec3 wpos, out float fogAmt) {
vec3 d = wpos - camPos;
float dist = length(d);
vec3 dir = d / max(dist, 1.0);
float hAvg = max(0.0, 0.5 * (camPos.y + wpos.y));
float dens = exp(-hAvg / 7000.0);
float beta = mix(1.0 / 95000.0, 1.0 / 16000.0, uHaze) * (1.0 + uOvercast * 0.8);
fogAmt = 1.0 - exp(-dist * beta * dens);
vec3 sky = skyColor(normalize(vec3(dir.x, max(dir.y, 0.0) * 0.6, dir.z)));
// sunlight scattered toward the eye (in-scatter) warms the haze toward the sun
float mu = max(dot(dir, uSun), 0.0);
sky += uSunCol * pow(mu, 6.0) * 0.18 * (1.0 - uOvercast);
// the haze is a little bluer and darker than the bright horizon itself
return mix(col, sky * vec3(0.92, 0.96, 1.02), fogAmt);
}
// Earth curvature relative to the camera (visual only): far terrain drops below the horizon like the real thing.
float curveDrop(vec3 wpos, vec3 camPos) {
vec2 d = wpos.xz - camPos.xz;
return dot(d, d) / (2.0 * R_EARTH);
}
`;
/**
* Times of day: sun elevation and azimuth (deg, azimuth 0 = north), sun colour/intensity, ambient, night amount.
* 'day' is free; the rest come with the Fun Pass.
*/
export const TIMES = {
day: { name: 'Midday', el: 38, az: 150, sun: [1.0, 0.97, 0.92], i: 3.0, amb: 1.0, night: 0, free: true },
morning: { name: 'Morning', el: 22, az: 110, sun: [1.0, 0.9, 0.78], i: 2.6, amb: 0.85, night: 0 },
golden: { name: 'Golden hour', el: 7, az: 255, sun: [1.0, 0.68, 0.40], i: 2.3, amb: 0.6, night: 0 },
dusk: { name: 'Dusk', el: -2.5, az: 275, sun: [1.0, 0.45, 0.25], i: 1.0, amb: 0.32, night: 0.25 },
night: { name: 'Night', el: -18, az: 300, sun: [0.55, 0.62, 0.85], i: 0.22, amb: 0.12, night: 1 },
live: { name: 'Live (local time)', el: 30, az: 180, sun: [1.0, 0.97, 0.92], i: 3.0, amb: 1.0, night: 0, live: true },
};
/** Light for any sun elevation: interpolates the presets above by elevation (so a real sun gets matching colours). */
export function lightFor(el) {
const K = [TIMES.night, TIMES.dusk, TIMES.golden, TIMES.morning, TIMES.day];
if (el <= K[0].el) return K[0];
if (el >= K[4].el) return { ...K[4], el };
let i = 0; while (el > K[i + 1].el) i++;
const a = K[i], b = K[i + 1], t = (el - a.el) / (b.el - a.el), m = (x, y) => x + (y - x) * t;
return { el, sun: a.sun.map((v, j) => m(v, b.sun[j])), i: m(a.i, b.i), amb: m(a.amb, b.amb), night: m(a.night, b.night) };
}
/** Weather presets. 'fair' is free. wind = [from deg, knots], turb 0..1. */
export const WEATHER = {
fair: { name: 'Fair', haze: 0.08, overcast: 0, clouds: 0.32, cloudAlt: 2600, wind: [270, 6], turb: 0.05, free: true },
clear: { name: 'Crystal clear', haze: 0.0, overcast: 0, clouds: 0, cloudAlt: 3000, wind: [0, 0], turb: 0 },
hazy: { name: 'Summer haze', haze: 0.7, overcast: 0, clouds: 0.18, cloudAlt: 3200, wind: [200, 8], turb: 0.12 },
overcast: { name: 'Overcast', haze: 0.45, overcast: 0.85, clouds: 0.92, cloudAlt: 1900, wind: [240, 14], turb: 0.3 },
windy: { name: 'Strong wind', haze: 0.25, overcast: 0.15, clouds: 0.45, cloudAlt: 2400, wind: [300, 28], turb: 0.65 },
};
/**
* The real sun for a place and moment (NOAA low-precision solar position; better than 0.5 deg until 2100).
* Returns { el, az } in degrees (azimuth 0 = north, clockwise). date: a JS Date (UTC instant).
*/
export function sunPosition(lat, lon, date) {
const D = Math.PI / 180;
const jd = date.getTime() / 864e5 + 2440587.5, n = jd - 2451545.0;
const L = (280.46 + 0.9856474 * n) % 360, g = ((357.528 + 0.9856003 * n) % 360) * D;
const lam = (L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2 * g)) * D, eps = (23.439 - 0.0000004 * n) * D;
const ra = Math.atan2(Math.cos(eps) * Math.sin(lam), Math.cos(lam)), dec = Math.asin(Math.sin(eps) * Math.sin(lam));
const gmst = (18.697374558 + 24.06570982441908 * n) % 24;
const ha = ((gmst * 15 + lon) * D - ra);
const la = lat * D;
const el = Math.asin(Math.sin(la) * Math.sin(dec) + Math.cos(la) * Math.cos(dec) * Math.cos(ha));
const az = Math.atan2(-Math.sin(ha), Math.tan(dec) * Math.cos(la) - Math.sin(la) * Math.cos(ha));
return { el: el / D, az: ((az / D) + 360) % 360 };
}
/** Today's UTC instant at a local mean solar time (hours) for a longitude. */
export function solarTime(lon, hours, day = new Date()) {
const d = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
return new Date(d + (hours - lon / 15) * 3600e3);
}
/** The sun's local solar times for each preset: noon, mid-morning, ~40 min before sunset, just after it, late night. */
export const SOLAR = { day: 12.5, morning: 8.75, golden: 'set-0.6', dusk: 'set+0.35', night: 23.5 };
/** Sunset solar hour for lat on a date (sun centre at -0.83 deg); 18 when the sun never sets or never rises. */
export function sunsetHour(lat, date = new Date()) {
const D = Math.PI / 180;
const doy = Math.floor((date - Date.UTC(date.getUTCFullYear(), 0, 0)) / 864e5);
const dec = -23.44 * Math.cos((360 / 365) * (doy + 10) * D) * D;
const c = (Math.sin(-0.83 * D) - Math.sin(lat * D) * Math.sin(dec)) / (Math.cos(lat * D) * Math.cos(dec));
if (c <= -1 || c >= 1) return 18;
return 12 + Math.acos(c) / D / 15;
}