Camera altitude is now driven by pow(sin,5) so the view stays at y=2.6 most of the time and only briefly dips to y=0.9 to skim the top of the cloud deck. Marching also skips the empty gap between camera and cloud ceiling when looking down from above.
270 lines
9.8 KiB
JavaScript
270 lines
9.8 KiB
JavaScript
/* Volumetric 3D cloud renderer.
|
|
* Raymarches a horizontal cloud layer with the camera flying
|
|
* slowly above and through the cloud deck. Density threshold
|
|
* comes from the weather theme, so 'sunny' gives breaks in the
|
|
* deck and 'stormy' puts you inside overcast most of the time.
|
|
*
|
|
* Reads palette + weather + speed from window globals (same
|
|
* contract as clouds2d.js, theme-agnostic).
|
|
*/
|
|
|
|
(function() {
|
|
|
|
function makeShader(gl, type, src) {
|
|
var s = gl.createShader(type);
|
|
gl.shaderSource(s, src);
|
|
gl.compileShader(s);
|
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
console.error('clouds3d: Shader compile error:', gl.getShaderInfoLog(s));
|
|
gl.deleteShader(s);
|
|
return null;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
function makeProgram(gl, vsSrc, fsSrc) {
|
|
var vs = makeShader(gl, gl.VERTEX_SHADER, vsSrc);
|
|
var fs = makeShader(gl, gl.FRAGMENT_SHADER, fsSrc);
|
|
if (!vs || !fs) return null;
|
|
var p = gl.createProgram();
|
|
gl.attachShader(p, vs);
|
|
gl.attachShader(p, fs);
|
|
gl.linkProgram(p);
|
|
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
|
|
console.error('clouds3d: Program link error:', gl.getProgramInfoLog(p));
|
|
gl.deleteProgram(p);
|
|
return null;
|
|
}
|
|
return p;
|
|
}
|
|
|
|
var VERT_SRC = [
|
|
'attribute vec2 aPos;',
|
|
'void main() { gl_Position = vec4(aPos, 0.0, 1.0); }'
|
|
].join('\n');
|
|
|
|
// Volumetric raymarch through a horizontal cloud layer.
|
|
// Cheap 3D value noise + 3-octave fbm. 18 raymarch steps.
|
|
// Tuned for low-end hardware: fewer steps + fewer noise samples
|
|
// per step keep the frame within budget on integrated GPUs.
|
|
// Camera path is sinusoidal so the view occasionally dips
|
|
// into the cloud deck for the "fly through the edges" feel.
|
|
var FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform vec2 uResolution;',
|
|
'uniform float uTime;',
|
|
'uniform vec3 uSky;',
|
|
'uniform vec3 uCloud;',
|
|
'uniform vec3 uCloudShadow;',
|
|
'uniform vec3 uLight;',
|
|
'uniform vec3 uBg;',
|
|
'uniform float uSpeed;',
|
|
'uniform float uDensity;',
|
|
'uniform float uDayProgress;',
|
|
// 3D hash and value noise
|
|
'float hash3(vec3 p) {',
|
|
'p = fract(p * vec3(0.1031, 0.1030, 0.0973));',
|
|
'p += dot(p, p.yxz + 33.33);',
|
|
'return fract((p.x + p.y) * p.z);',
|
|
'}',
|
|
'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);',
|
|
'}',
|
|
'float fbm3(vec3 p) {',
|
|
'float v = 0.0;',
|
|
'float a = 0.5;',
|
|
'for (int i = 0; i < 3; i++) {',
|
|
'v += a * noise3(p);',
|
|
'p = p * 2.03 + vec3(13.7, 7.1, 19.3);',
|
|
'a *= 0.5;',
|
|
'}',
|
|
'return v;',
|
|
'}',
|
|
// Density at a point in the cloud layer.
|
|
'float cloudDensity(vec3 p, float baseThresh) {',
|
|
'float n = fbm3(p * 0.35 + vec3(0.0, 0.0, uTime * 0.06));',
|
|
// Cloud layer thickness: between y=-0.3 (deck bottom)
|
|
// and y=1.8 (top), densest in the middle.
|
|
'float h = clamp((p.y + 0.3) / 2.1, 0.0, 1.0);',
|
|
'float profile = 4.0 * h * (1.0 - h); // bell curve',
|
|
'profile = max(profile, 0.0);',
|
|
'return smoothstep(baseThresh, baseThresh + 0.18, n) * profile;',
|
|
'}',
|
|
'void main() {',
|
|
'vec2 uv = (gl_FragCoord.xy / uResolution.xy) * 2.0 - 1.0;',
|
|
'uv.x *= uResolution.x / uResolution.y;',
|
|
// Camera path: slow drift in X, mostly above the cloud deck.
|
|
// pow(...,n) shapes the sin into short dips, so the view spends
|
|
// most of its time at cruising altitude and only briefly skims
|
|
// the top of the cloud layer.
|
|
'float drift = uTime * uSpeed * 0.45;',
|
|
'float diveT = pow(0.5 + 0.5 * sin(uTime * 0.10), 5.0);',
|
|
'float camY = mix(2.6, 0.9, diveT);',
|
|
'vec3 ro = vec3(drift, camY, drift * 0.7);',
|
|
// Ray direction: tilt down a bit more so the cloud deck fills
|
|
// the lower frame when we're above it.
|
|
'vec3 rd = normalize(vec3(uv.x * 0.55, -0.35 + uv.y * 0.30, 1.0));',
|
|
// Sky gradient based on ray Y (background).
|
|
'float skyT = clamp(rd.y * 0.7 + 0.5, 0.0, 1.0);',
|
|
'vec3 col = mix(uBg, uSky, skyT);',
|
|
// Density threshold: weather 'sunny' -> high (sparse clouds),
|
|
// 'stormy' -> low (overcast deck).
|
|
'float baseThresh = mix(0.72, 0.40, uDensity);',
|
|
// Skip the gap between camera and cloud top so we don't waste
|
|
// steps marching through clear sky. ray-Y = 2.0 = cloud ceiling.
|
|
'float t = (ro.y > 2.0 && rd.y < -0.001) ? (ro.y - 2.0) / -rd.y : 0.0;',
|
|
'float stepSize = 0.85;',
|
|
'float trans = 1.0;',
|
|
'for (int i = 0; i < 18; i++) {',
|
|
'vec3 p = ro + rd * t;',
|
|
'if (p.y > 2.0 || p.y < -0.4) break;',
|
|
'float d = cloudDensity(p, baseThresh);',
|
|
'if (d > 0.005) {',
|
|
// Cloud shading: bottom = shadow, top = lit
|
|
'float lit = clamp((p.y + 0.3) / 2.1, 0.0, 1.0);',
|
|
'vec3 cloudCol = mix(uCloudShadow, uCloud, lit);',
|
|
// Sun tint on the lit side, fades at night
|
|
'cloudCol = mix(cloudCol, uLight, uDayProgress * 0.35 * lit);',
|
|
'float alpha = clamp(d * stepSize * 0.9, 0.0, 1.0);',
|
|
'col = mix(col, cloudCol, alpha * trans);',
|
|
'trans *= (1.0 - alpha);',
|
|
'if (trans < 0.04) break;',
|
|
'}',
|
|
't += stepSize;',
|
|
'}',
|
|
// Night darkening
|
|
'col = mix(col, mix(uBg, col, 0.4), (1.0 - uDayProgress) * 0.55);',
|
|
'gl_FragColor = vec4(col, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
function init(canvas) {
|
|
var gl = canvas.getContext('webgl', { antialias: false, alpha: false });
|
|
if (!gl) {
|
|
console.error('clouds3d: WebGL not available');
|
|
return null;
|
|
}
|
|
var prog = makeProgram(gl, VERT_SRC, FRAG_SRC);
|
|
if (!prog) return null;
|
|
|
|
var buf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(gl.ARRAY_BUFFER,
|
|
new Float32Array([ -1,-1, 3,-1, -1,3 ]),
|
|
gl.STATIC_DRAW);
|
|
|
|
var aPos = gl.getAttribLocation(prog, 'aPos');
|
|
var uRes = gl.getUniformLocation(prog, 'uResolution');
|
|
var uTime = gl.getUniformLocation(prog, 'uTime');
|
|
var uSky = gl.getUniformLocation(prog, 'uSky');
|
|
var uCloud = gl.getUniformLocation(prog, 'uCloud');
|
|
var uCloudShadow = gl.getUniformLocation(prog, 'uCloudShadow');
|
|
var uLight = gl.getUniformLocation(prog, 'uLight');
|
|
var uBg = gl.getUniformLocation(prog, 'uBg');
|
|
var uSpeed = gl.getUniformLocation(prog, 'uSpeed');
|
|
var uDensity = gl.getUniformLocation(prog, 'uDensity');
|
|
var uDayProgress = gl.getUniformLocation(prog, 'uDayProgress');
|
|
|
|
return {
|
|
gl: gl, prog: prog, buf: buf, aPos: aPos,
|
|
uRes: uRes, uTime: uTime, uSky: uSky, uCloud: uCloud,
|
|
uCloudShadow: uCloudShadow, uLight: uLight, uBg: uBg,
|
|
uSpeed: uSpeed, uDensity: uDensity, uDayProgress: uDayProgress
|
|
};
|
|
}
|
|
|
|
function hex(rgb) {
|
|
return [((rgb >> 16) & 255) / 255,
|
|
((rgb >> 8) & 255) / 255,
|
|
(rgb & 255) / 255];
|
|
}
|
|
|
|
// Same density mapping as clouds2d so swapping renderers doesn't
|
|
// change the visual weather.
|
|
var WEATHER_DENSITY = {
|
|
sunny: 0.20,
|
|
cloudy: 0.70,
|
|
stormy: 1.00,
|
|
night: 0.45
|
|
};
|
|
|
|
window.clouds3dRender = function(target) {
|
|
if (!target || typeof target !== 'object') {
|
|
console.error('clouds3d: invalid target');
|
|
return;
|
|
}
|
|
var canvas = target.__clouds3dCanvas;
|
|
if (!canvas) {
|
|
canvas = document.createElement('canvas');
|
|
canvas.style.position = 'absolute';
|
|
canvas.style.top = '0';
|
|
canvas.style.left = '0';
|
|
canvas.style.width = '100%';
|
|
canvas.style.height = '100%';
|
|
while (target.firstChild) target.removeChild(target.firstChild);
|
|
target.appendChild(canvas);
|
|
target.style.position = 'relative';
|
|
target.__clouds3dCanvas = canvas;
|
|
}
|
|
|
|
if (!canvas.__state) {
|
|
canvas.__state = init(canvas);
|
|
if (!canvas.__state) return;
|
|
}
|
|
|
|
var s = canvas.__state;
|
|
var gl = s.gl;
|
|
canvas.width = target.clientWidth || window.innerWidth;
|
|
canvas.height = target.clientHeight || window.innerHeight;
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
|
|
gl.useProgram(s.prog);
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, s.buf);
|
|
gl.enableVertexAttribArray(s.aPos);
|
|
gl.vertexAttribPointer(s.aPos, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
gl.uniform2f(s.uRes, canvas.width, canvas.height);
|
|
gl.uniform1f(s.uTime, performance.now() / 1000);
|
|
|
|
var pal = window.__currentPalette;
|
|
if (!pal) {
|
|
gl.clearColor(0, 0, 0, 1);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
return;
|
|
}
|
|
|
|
var weather = window.__currentWeatherName;
|
|
var dayProgress = window.__currentDayProgress;
|
|
var themeSpeed = window.__currentThemeSpeed;
|
|
|
|
gl.uniform3fv(s.uSky, hex(pal.sky));
|
|
gl.uniform3fv(s.uCloud, hex(pal.cloud));
|
|
gl.uniform3fv(s.uCloudShadow, hex(
|
|
pal.cloudShadow !== undefined ? pal.cloudShadow : pal.sky));
|
|
gl.uniform3fv(s.uLight, hex(pal.light));
|
|
gl.uniform3fv(s.uBg, hex(pal.bg));
|
|
|
|
var density = (weather && WEATHER_DENSITY[weather] !== undefined)
|
|
? WEATHER_DENSITY[weather] : 0.5;
|
|
gl.uniform1f(s.uDensity, density);
|
|
|
|
var day = (typeof dayProgress === 'number')
|
|
? Math.max(0, Math.min(1, dayProgress)) : 0.5;
|
|
gl.uniform1f(s.uDayProgress, day);
|
|
|
|
var speed = (typeof themeSpeed === 'number')
|
|
? Math.max(0.1, Math.min(3, themeSpeed)) : 1.0;
|
|
gl.uniform1f(s.uSpeed, speed);
|
|
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
};
|
|
|
|
})(); |