- WEATHER_DENSITY bumped: sunny 0.20->0.55, cloudy 0.70->0.85, night 0.45->0.65. Stormy stays at 1.0 (already maxed). Even 'sunny' now has visible cloud structure, not just clear sky. - clouds3d: add a second fBm layer at 4x frequency for fine detail, blended at 18% into the base. Combined with the tighter smoothstep (0.24 -> 0.18) the cloud silhouettes have more interesting edges instead of soft blob shapes. - Same density bump applied to clouds2d so swapping renderers doesn't change the visual weather. - config.renderer: clouds2d -> clouds3d (user request)
281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
/* Texture-based 2D cloud renderer.
|
|
*
|
|
* Inspired by fizzer's tiny ShaderToy cloud shader: instead of
|
|
* computing fBm in-shader with hash functions, sample a noise
|
|
* texture (4 lookups = 4 octaves) and weight the results. This
|
|
* trades a tiny amount of texture memory for a large drop in ALU
|
|
* per pixel — significant on low-end integrated GPUs.
|
|
*
|
|
* Texture is loaded from window.__noiseDataUri (a data URI baked
|
|
* from noise.png by the build). Falls back to /noise.png if the
|
|
* URI is unavailable. Theme-agnostic: parent owns colour via
|
|
* __currentPalette.
|
|
*/
|
|
|
|
(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('clouds2d: 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('clouds2d: 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');
|
|
|
|
// 4-octave fBm by sampling a noise texture. The texture is REPEAT-
|
|
// wrapped so we can scroll through it endlessly. Scrolling uses a
|
|
// hash-based per-pixel jitter so the regular tiling pattern doesn't
|
|
// cause horizon flicker (same fix as clouds3d).
|
|
var FRAG_SRC = [
|
|
'precision highp float;',
|
|
'uniform vec2 uResolution;',
|
|
'uniform float uTime;',
|
|
'uniform sampler2D uNoise;',
|
|
'uniform vec3 uSky;',
|
|
'uniform vec3 uCloud;',
|
|
'uniform vec3 uCloudShadow;',
|
|
'uniform vec3 uLight;',
|
|
'uniform vec3 uBg;',
|
|
'uniform float uSpeed;',
|
|
'uniform float uDensity;',
|
|
'uniform float uDayProgress;',
|
|
'uniform float uSunAlt;',
|
|
'float hash21(vec2 p) {',
|
|
'p = fract(p * vec2(123.34, 456.21));',
|
|
'p += dot(p, p + 45.32);',
|
|
'return fract(p.x * p.y);',
|
|
'}',
|
|
// 4-octave texture fBm. Each octave doubles frequency and halves
|
|
// amplitude (1, 0.5, 0.25, 0.125). Total normalised by 1/0.9375.
|
|
'float fbm(vec2 p) {',
|
|
'float total = 0.0;',
|
|
'total += 1.0 * texture2D(uNoise, p * 1.0).r;',
|
|
'total += 0.5 * texture2D(uNoise, p * 2.0).r;',
|
|
'total += 0.25 * texture2D(uNoise, p * 4.0).r;',
|
|
'total += 0.125 * texture2D(uNoise, p * 8.0).r;',
|
|
'return total / 1.875;',
|
|
'}',
|
|
'void main() {',
|
|
'vec2 uv = gl_FragCoord.xy / uResolution.xy;',
|
|
// Scroll the noise field horizontally with time, plus a small
|
|
// vertical drift for slow cloud movement. Multiply by uv's
|
|
// aspect so clouds don't squash on wide displays.
|
|
'float aspect = uResolution.x / uResolution.y;',
|
|
'vec2 p = vec2(',
|
|
'(uv.x - 0.5) * aspect * 3.0 + uTime * 0.04 * uSpeed,',
|
|
'uv.y * 2.5 + uTime * 0.012 * uSpeed',
|
|
');',
|
|
// Per-pixel jitter to break the regular tiling pattern that
|
|
// would otherwise flicker at the cloud horizon.
|
|
'p += (hash21(gl_FragCoord.xy + uTime) - 0.5) * 0.015;',
|
|
'float n = fbm(p);',
|
|
// Density threshold widens with weather (sunny = sparse,
|
|
// stormy = overcast).
|
|
'float thresh = mix(0.70, 0.35, uDensity);',
|
|
'float cloud = smoothstep(thresh, thresh + 0.18, n);',
|
|
// Sky gradient: bg below, sky above
|
|
'vec3 sky = mix(uBg, uSky, smoothstep(0.0, 0.6, uv.y));',
|
|
// Cloud body: shadow on the bottom, lit on the top
|
|
'vec3 cloudCol = mix(uCloudShadow, uCloud, smoothstep(0.0, 0.7, uv.y));',
|
|
// Light tint on the lit side (always slightly, so even at
|
|
// night the cloud edge catches some illumination)
|
|
'cloudCol = mix(cloudCol, uLight, cloud * uv.y * 0.45 * max(0.15, uDayProgress));',
|
|
// Composite
|
|
'vec3 col = mix(sky, cloudCol, cloud);',
|
|
// Night darkening, driven by sun altitude (not uDayProgress
|
|
// which is a 24h clock progress, not "is it night")
|
|
'float nightFactor = smoothstep(0.05, -0.15, uSunAlt);',
|
|
'col = mix(col, mix(uBg, col, 0.4), nightFactor * 0.45);',
|
|
'gl_FragColor = vec4(col, 1.0);',
|
|
'}'
|
|
].join('\n');
|
|
|
|
function hex(rgb) {
|
|
return [((rgb >> 16) & 255) / 255,
|
|
((rgb >> 8) & 255) / 255,
|
|
(rgb & 255) / 255];
|
|
}
|
|
|
|
var WEATHER_DENSITY = {
|
|
sunny: 0.55,
|
|
cloudy: 0.85,
|
|
stormy: 1.00,
|
|
night: 0.65
|
|
};
|
|
|
|
// Load the noise image and upload it as a REPEAT-wrapped texture.
|
|
// Priority: window.__noiseDataUri (if it has real data, not
|
|
// PLACEHOLDER), else fetch /noise.png from the server.
|
|
function loadNoiseTexture(gl) {
|
|
return new Promise(function(resolve, reject) {
|
|
var inline = window.__noiseDataUri;
|
|
var hasInline = inline && inline.indexOf('PLACEHOLDER') < 0;
|
|
var src = hasInline ? inline : 'noise.png';
|
|
var img = new Image();
|
|
img.onload = function() {
|
|
var tex = gl.createTexture();
|
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
resolve(tex);
|
|
};
|
|
img.onerror = function() {
|
|
reject(new Error('clouds2d: failed to load noise image: ' + src));
|
|
};
|
|
img.src = src;
|
|
});
|
|
}
|
|
|
|
window.clouds2dRender = function(target) {
|
|
if (!target || typeof target !== 'object') {
|
|
console.error('clouds2d: invalid target');
|
|
return;
|
|
}
|
|
var canvas = target.__clouds2dCanvas;
|
|
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.__clouds2dCanvas = canvas;
|
|
}
|
|
|
|
var gl = canvas.__gl;
|
|
if (!gl) {
|
|
gl = canvas.getContext('webgl', { antialias: false, alpha: false });
|
|
if (!gl) {
|
|
console.error('clouds2d: WebGL not available');
|
|
return;
|
|
}
|
|
canvas.__gl = gl;
|
|
}
|
|
|
|
if (!canvas.__state) {
|
|
// Compile shaders synchronously, load texture asynchronously.
|
|
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 prog = makeProgram(gl, VERT_SRC, FRAG_SRC);
|
|
if (!prog) return;
|
|
canvas.__state = {
|
|
gl: gl, prog: prog, buf: buf,
|
|
aPos: gl.getAttribLocation(prog, 'aPos'),
|
|
uRes: gl.getUniformLocation(prog, 'uResolution'),
|
|
uTime: gl.getUniformLocation(prog, 'uTime'),
|
|
uNoise: gl.getUniformLocation(prog, 'uNoise'),
|
|
uSky: gl.getUniformLocation(prog, 'uSky'),
|
|
uCloud: gl.getUniformLocation(prog, 'uCloud'),
|
|
uCloudShadow: gl.getUniformLocation(prog, 'uCloudShadow'),
|
|
uLight: gl.getUniformLocation(prog, 'uLight'),
|
|
uBg: gl.getUniformLocation(prog, 'uBg'),
|
|
uSpeed: gl.getUniformLocation(prog, 'uSpeed'),
|
|
uDensity: gl.getUniformLocation(prog, 'uDensity'),
|
|
uDayProgress: gl.getUniformLocation(prog, 'uDayProgress'),
|
|
uSunAlt: gl.getUniformLocation(prog, 'uSunAlt')
|
|
};
|
|
canvas.__noiseReady = false;
|
|
loadNoiseTexture(gl).then(function(tex) {
|
|
canvas.__noise = tex;
|
|
canvas.__noiseReady = true;
|
|
}).catch(function(err) {
|
|
console.error(err);
|
|
});
|
|
}
|
|
|
|
var s = canvas.__state;
|
|
canvas.width = target.clientWidth || window.innerWidth;
|
|
canvas.height = target.clientHeight || window.innerHeight;
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
|
|
// Texture not loaded yet — just clear, frame after will draw.
|
|
if (!canvas.__noiseReady) {
|
|
gl.clearColor(0, 0, 0, 1);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
return;
|
|
}
|
|
|
|
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.activeTexture(gl.TEXTURE0);
|
|
gl.bindTexture(gl.TEXTURE_2D, canvas.__noise);
|
|
gl.uniform1i(s.uNoise, 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;
|
|
var sunAlt = window.__currentSunAlt;
|
|
|
|
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.uniform1f(s.uSunAlt, (typeof sunAlt === 'number') ? sunAlt : 0);
|
|
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
};
|
|
|
|
})(); |