diff --git a/config.js b/config.js index be1a793..6cbe3cb 100644 --- a/config.js +++ b/config.js @@ -147,6 +147,58 @@ window.CONFIG = { // emergencyVehicles: { colorVariation: 0.25, ledColor: [0.02, 0.18, 1.0], halogenColor: [0.70, 0.82, 1.0] } }, + // ------------------------------------------------------------------ + // Meteor showers + // ------------------------------------------------------------------ + // Known meteor showers are rendered as short night-sky streaks whose + // backward extension points toward the shower radiant. The debug shower + // is a temporary test shower for a fixed local start time. + meteorShowers: { + enabled: true, + brightness: 1.0, + maxActive: 12, + rate: 0.25, // meteors per second across all active showers + speedDegPerSec: 14, // apparent angular speed + lengthDeg: 18, // trail length in degrees + trailPoints: 14, // point sprites per trail + pointSize: 10, // base point size in pixels + nightOnly: true, + colors: [ + [1.00, 0.96, 0.88], + [0.72, 1.00, 0.86], + [1.00, 0.72, 0.42] + ], + debug: { + enabled: true, + name: 'Debug Shower', + startAt: '2026-10-13T07:00:00', + durationMin: 10, + rate: 1.2, + maxActive: 18, + radiant: { raH: 3.2, decDeg: 58 }, + lengthDeg: 22, + speedDegPerSec: 18, + brightness: 2.4, + nightOnly: false + }, + showers: [ + { name: 'Quadrantids', startMonth: 1, startDay: 1, peakMonth: 1, peakDay: 3, endMonth: 1, endDay: 15, zhr: 110, raH: 15.4, decDeg: 49 }, + { name: 'Lyrids', startMonth: 4, startDay: 16, peakMonth: 4, peakDay: 22, endMonth: 4, endDay: 25, zhr: 18, raH: 18.1, decDeg: 34 }, + { name: 'Eta Aquariids', startMonth: 4, startDay: 19, peakMonth: 5, peakDay: 6, endMonth: 5, endDay: 28, zhr: 50, raH: 22.5, decDeg: -1 }, + { name: 'Delta Aquariids', startMonth: 7, startDay: 15, peakMonth: 7, peakDay: 30, endMonth: 8, endDay: 18, zhr: 25, raH: 22.8, decDeg: -16 }, + { name: 'Perseids', startMonth: 7, startDay: 17, peakMonth: 8, peakDay: 12, endMonth: 8, endDay: 24, zhr: 100, raH: 3.2, decDeg: 58 }, + { name: 'Draconids', startMonth: 10, startDay: 6, peakMonth: 10, peakDay: 8, endMonth: 10, endDay: 10, zhr: 10, raH: 17.5, decDeg: 54 }, + { name: 'Orionids', startMonth: 10, startDay: 2, peakMonth: 10, peakDay: 21, endMonth: 11, endDay: 7, zhr: 20, raH: 6.4, decDeg: 15 }, + { name: 'Taurids', startMonth: 10, startDay: 10, peakMonth: 11, peakDay: 5, endMonth: 12, endDay: 10, zhr: 5, raH: 3.9, decDeg: 21 }, + { name: 'Leonids', startMonth: 11, startDay: 6, peakMonth: 11, peakDay: 17, endMonth: 11, endDay: 30, zhr: 15, raH: 10.2, decDeg: 22 }, + { name: 'Geminids', startMonth: 12, startDay: 4, peakMonth: 12, peakDay: 14, endMonth: 12, endDay: 17, zhr: 120, raH: 7.5, decDeg: 33 }, + { name: 'Ursids', startMonth: 12, startDay: 17, peakMonth: 12, peakDay: 22, endMonth: 12, endDay: 26, zhr: 10, raH: 14.5, decDeg: 75 } + ] + // meteorShowers: { enabled: false } + // meteorShowers: { debug: { enabled: false } } + // meteorShowers: { debug: { startAt: '2026-10-13T07:00:00', durationMin: 10, rate: 2.0 } } + }, + // ------------------------------------------------------------------ // Weather effects // ------------------------------------------------------------------ diff --git a/vendor/clouds3d.js b/vendor/clouds3d.js index 2bd7615..325a3a0 100644 --- a/vendor/clouds3d.js +++ b/vendor/clouds3d.js @@ -6255,6 +6255,300 @@ function starSidereal() { var _skyLat = ((window.CONFIG && window.CONFIG.location && window.CONFIG.location.lat) || 51.5074) * Math.PI / 180; var SKY_SIN_LAT = Math.sin(_skyLat), SKY_COS_LAT = Math.cos(_skyLat); +var METEOR_CFG = (window.CONFIG && window.CONFIG.meteorShowers) || {}; +var METEORS_ON = METEOR_CFG.enabled !== false; +var meteorProg = null, meteorVbo = null, meteorUniforms = null, meteorCount = 0; +var meteorList = [], meteorSpawnAcc = 0, meteorLastT = null, meteorAllowDay = false; +var METEOR_BRIGHT = (typeof METEOR_CFG.brightness === 'number') ? Math.max(0, METEOR_CFG.brightness) : 1.0; +var METEOR_MAX_ACTIVE = (typeof METEOR_CFG.maxActive === 'number') ? Math.max(1, Math.min(80, METEOR_CFG.maxActive)) : 12; +var METEOR_RATE = (typeof METEOR_CFG.rate === 'number') ? Math.max(0, Math.min(8, METEOR_CFG.rate)) : 0.25; +var METEOR_SPEED = (typeof METEOR_CFG.speedDegPerSec === 'number') ? Math.max(1, Math.min(80, METEOR_CFG.speedDegPerSec)) : 14; +var METEOR_LENGTH = (typeof METEOR_CFG.lengthDeg === 'number') ? Math.max(2, Math.min(90, METEOR_CFG.lengthDeg)) : 18; +var METEOR_TRAIL_POINTS = (typeof METEOR_CFG.trailPoints === 'number') ? Math.max(2, Math.min(40, Math.round(METEOR_CFG.trailPoints))) : 14; +var METEOR_POINT_SIZE = (typeof METEOR_CFG.pointSize === 'number') ? Math.max(1, Math.min(40, METEOR_CFG.pointSize)) : 10; +var METEOR_NIGHT_ONLY = METEOR_CFG.nightOnly !== false; +var METEOR_COLORS = Array.isArray(METEOR_CFG.colors) && METEOR_CFG.colors.length ? METEOR_CFG.colors : [ + [1.00, 0.96, 0.88], [0.72, 1.00, 0.86], [1.00, 0.72, 0.42] +]; +var METEOR_DEBUG = METEOR_CFG.debug || {}; +var METEOR_SHOWERS = Array.isArray(METEOR_CFG.showers) ? METEOR_CFG.showers : []; + +var METEOR_VERT = [ + 'attribute vec4 aMpos;', + 'attribute vec4 aMcol;', + 'uniform mat4 uMVP;', + 'uniform float uSizeScale;', + 'varying vec4 vC;', + 'varying float vSeed;', + 'void main() {', + 'gl_Position = uMVP * vec4(aMpos.xyz, 1.0);', + 'gl_PointSize = clamp(aMcol.w * uSizeScale, 1.0, 34.0);', + 'vC = aMcol;', + 'vSeed = aMpos.w;', + '}' +].join('\n'); +var METEOR_FRAG = [ + 'precision mediump float;', + 'varying vec4 vC;', + 'varying float vSeed;', + 'void main() {', + 'vec2 pc = gl_PointCoord - 0.5;', + 'float d = length(pc) * 2.0;', + 'float g = exp(-d * d * 3.1);', + 'float flicker = 0.88 + 0.12 * sin(vSeed * 628.0);', + 'float a = vC.w * g * flicker;', + 'gl_FragColor = vec4(vC.rgb * a, 1.0);', + '}' +].join('\n'); + +function meteorNum(v, def, min, max) { + return (typeof v === 'number' && isFinite(v)) ? Math.max(min, Math.min(max, v)) : def; +} +function meteorDot(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } +function meteorCross(a, b) { + return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +} +function meteorNorm(a) { + var l = Math.sqrt(meteorDot(a, a)) || 1; + return [a[0] / l, a[1] / l, a[2] / l]; +} +function meteorLocalDir(raH, decDeg, lst) { + var ra = (raH || 0) * 15 * DEG; + var dec = (decDeg || 0) * DEG; + var H = lst - ra; + var cd = Math.cos(dec), sd = Math.sin(dec); + var sh = Math.sin(H), ch = Math.cos(H); + return [cd * sh, SKY_SIN_LAT * sd + SKY_COS_LAT * cd * ch, SKY_COS_LAT * sd - SKY_SIN_LAT * cd * ch]; +} +function meteorActiveOnDate(s, date) { + var key = (date.getMonth() + 1) * 100 + date.getDate(); + var start = s.startMonth * 100 + s.startDay; + var end = s.endMonth * 100 + s.endDay; + if (start <= end) return key >= start && key <= end; + return key >= start || key <= end; +} +function meteorDayDiffToPeak(date, peakMonth, peakDay) { + var y = date.getFullYear(); + var cur = Date.UTC(y, date.getMonth(), date.getDate()); + var best = Infinity; + for (var yy = y - 1; yy <= y + 1; yy++) { + var peak = Date.UTC(yy, peakMonth - 1, peakDay); + var diff = (cur - peak) / 86400000; + if (Math.abs(diff) < Math.abs(best)) best = diff; + } + return best; +} +function meteorShowerIntensity(s, date) { + if (!meteorActiveOnDate(s, date)) return 0; + var diff = meteorDayDiffToPeak(date, s.peakMonth, s.peakDay); + var sigma = 4.0; + var g = Math.exp(-0.5 * (diff / sigma) * (diff / sigma)); + var zhr = (typeof s.zhr === 'number') ? s.zhr : 10; + return Math.max(0, Math.min(1, g * Math.min(1.2, zhr / 100))); +} +function meteorDebugActive(sceneDate, realDate) { + if (!METEOR_DEBUG.enabled) return null; + var start = METEOR_DEBUG.startAt ? new Date(METEOR_DEBUG.startAt) : null; + var dur = (typeof METEOR_DEBUG.durationMin === 'number') ? METEOR_DEBUG.durationMin * 60000 : 600000; + var s = start ? start.getTime() : 0; + var e = start ? s + dur : Infinity; + var t1 = sceneDate ? sceneDate.getTime() : NaN; + var t2 = realDate ? realDate.getTime() : NaN; + var active = !start || (t1 >= s && t1 <= e) || (t2 >= s && t2 <= e); + if (!active) return null; + var rad = METEOR_DEBUG.radiant || {}; + return { + name: METEOR_DEBUG.name || 'Debug Shower', + intensity: 1, + raH: (typeof rad.raH === 'number') ? rad.raH : 3.2, + decDeg: (typeof rad.decDeg === 'number') ? rad.decDeg : 58, + rate: meteorNum(METEOR_DEBUG.rate, 1.2, 0, 8), + maxActive: (typeof METEOR_DEBUG.maxActive === 'number') ? Math.max(1, Math.min(80, Math.round(METEOR_DEBUG.maxActive))) : 18, + lengthDeg: meteorNum(METEOR_DEBUG.lengthDeg, 22, 2, 90), + speedDegPerSec: meteorNum(METEOR_DEBUG.speedDegPerSec, 18, 1, 80), + brightness: meteorNum(METEOR_DEBUG.brightness, 2.4, 0, 8), + nightOnly: METEOR_DEBUG.nightOnly === true, + debug: true + }; +} +function meteorActiveShowers(sceneDate, realDate) { + var out = []; + var dbg = meteorDebugActive(sceneDate, realDate); + if (dbg) out.push(dbg); + for (var i = 0; i < METEOR_SHOWERS.length; i++) { + var s = METEOR_SHOWERS[i]; + var intensity = meteorShowerIntensity(s, sceneDate || realDate || new Date()); + if (intensity < 0.02) continue; + out.push({ + name: s.name || 'Shower', + intensity: intensity, + raH: s.raH, + decDeg: s.decDeg, + rate: METEOR_RATE * intensity, + maxActive: METEOR_MAX_ACTIVE, + lengthDeg: METEOR_LENGTH, + speedDegPerSec: METEOR_SPEED, + brightness: METEOR_BRIGHT * intensity, + nightOnly: METEOR_NIGHT_ONLY, + debug: false + }); + } + return out; +} +function meteorSpawn(shower, now) { + var lst = starSidereal(); + if (lst === null) lst = ((window.__currentDayProgress || 0) * 6.2831853); + var R = meteorLocalDir(shower.raH, shower.decDeg, lst); + if (R[1] < -0.25) return null; + var helper = (Math.abs(R[1]) < 0.8) ? [0, 1, 0] : [1, 0, 0]; + var u = meteorNorm(meteorCross(R, helper)); + var v = meteorNorm(meteorCross(R, u)); + var P = null; + for (var attempt = 0; attempt < 10; attempt++) { + var phi = Math.random() * 6.2831853; + var theta = 0.12 + Math.random() * 1.15; + var ct = Math.cos(theta), st = Math.sin(theta); + P = meteorNorm([ + R[0] * ct + u[0] * st * Math.cos(phi) + v[0] * st * Math.sin(phi), + R[1] * ct + u[1] * st * Math.cos(phi) + v[1] * st * Math.sin(phi), + R[2] * ct + u[2] * st * Math.cos(phi) + v[2] * st * Math.sin(phi) + ]); + if (P[1] > 0.06) break; + } + if (!P || P[1] <= 0.06) return null; + var radial = meteorDot(P, R); + var dir = meteorNorm([P[0] - R[0] * radial, P[1] - R[1] * radial, P[2] - R[2] * radial]); + if (Math.abs(dir[0]) + Math.abs(dir[1]) + Math.abs(dir[2]) < 1e-4) dir = u; + var angularLen = shower.lengthDeg * DEG * (0.7 + Math.random() * 0.6); + var speed = shower.speedDegPerSec * DEG * (0.7 + Math.random() * 0.6); + var seed = Math.random(); + var color = METEOR_COLORS[Math.floor(seed * METEOR_COLORS.length) % METEOR_COLORS.length]; + return { + start: now, + lifetime: angularLen / speed, + P: P, + dir: dir, + angularLen: angularLen, + speed: speed, + seed: seed, + color: color, + brightness: shower.brightness, + shower: shower.name + }; +} +function updateMeteorShower(gl, sceneDate, realDate, now) { + if (!gl || !METEORS_ON) return; + if (meteorLastT === null) meteorLastT = now; + var elapsed = Math.max(0, Math.min(0.25, now - meteorLastT)); + meteorLastT = now; + var active = meteorActiveShowers(sceneDate, realDate); + var maxActive = METEOR_MAX_ACTIVE; + meteorAllowDay = !METEOR_NIGHT_ONLY; + for (var i = 0; i < active.length; i++) { + if (active[i].maxActive > maxActive) maxActive = active[i].maxActive; + if (active[i].nightOnly === false) meteorAllowDay = true; + } + for (var j = meteorList.length - 1; j >= 0; j--) { + if (now - meteorList[j].start > meteorList[j].lifetime) meteorList.splice(j, 1); + } + if (active.length && meteorList.length < maxActive) { + var totalRate = 0; + for (var k = 0; k < active.length; k++) totalRate += active[k].rate * active[k].intensity; + meteorSpawnAcc += elapsed * totalRate; + while (meteorSpawnAcc >= 1 && meteorList.length < maxActive) { + meteorSpawnAcc -= 1; + var r = Math.random() * totalRate; + var chosen = active[0]; + for (var a = 0; a < active.length; a++) { + r -= active[a].rate * active[a].intensity; + if (r <= 0) { chosen = active[a]; break; } + } + var m = meteorSpawn(chosen, now); + if (m) meteorList.push(m); + } + } + var maxPoints = maxActive * METEOR_TRAIL_POINTS; + var data = new Float32Array(maxPoints * 8); + var di = 0, count = 0; + for (var mi = 0; mi < meteorList.length; mi++) { + var mm = meteorList[mi]; + var age = now - mm.start; + if (age < 0) continue; + var progress = age / mm.lifetime; + if (progress > 1) continue; + var headAngle = age * mm.speed; + var trailLen = Math.min(headAngle, mm.angularLen); + for (var p = 0; p < METEOR_TRAIL_POINTS; p++) { + var s = (METEOR_TRAIL_POINTS === 1) ? 0 : p / (METEOR_TRAIL_POINTS - 1); + var angle = headAngle - s * trailLen; + var ca = Math.cos(angle), sa = Math.sin(angle); + var x = mm.P[0] * ca + mm.dir[0] * sa; + var y = mm.P[1] * ca + mm.dir[1] * sa; + var z = mm.P[2] * ca + mm.dir[2] * sa; + var fade = Math.pow(1 - s, 1.35) * Math.pow(1 - progress, 0.35); + var intensity = fade * mm.brightness * (0.72 + 0.28 * Math.sin(mm.seed * 628.0 + age * 7.0)); + if (intensity <= 0.01) continue; + data[di++] = x * 88; data[di++] = y * 88; data[di++] = z * 88; data[di++] = mm.seed; + data[di++] = mm.color[0]; data[di++] = mm.color[1]; data[di++] = mm.color[2]; + data[di++] = intensity * METEOR_POINT_SIZE; + count++; + } + } + if (!meteorVbo) meteorVbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, meteorVbo); + gl.bufferData(gl.ARRAY_BUFFER, data.subarray(0, di), gl.STREAM_DRAW); + meteorCount = count; + window.__meteorDbg = { + enabled: METEORS_ON, + active: active.map(function (x) { return { name: x.name, intensity: +x.intensity.toFixed(2), rate: +x.rate.toFixed(2), debug: !!x.debug }; }), + meteors: meteorList.length, + points: count, + spawnAcc: +meteorSpawnAcc.toFixed(2), + allowDay: meteorAllowDay + }; +} +function ensureMeteorProgram(gl) { + if (meteorProg) return true; + var prog = makeProgram(gl, METEOR_VERT, METEOR_FRAG); + if (!prog) return false; + meteorProg = prog; + meteorUniforms = { + mvp: gl.getUniformLocation(prog, 'uMVP'), + sizeScale: gl.getUniformLocation(prog, 'uSizeScale'), + a0: gl.getAttribLocation(prog, 'aMpos'), + a1: gl.getAttribLocation(prog, 'aMcol') + }; + return true; +} +function drawMeteorShower(gl, proj, view, sunAlt) { + if (!meteorCount || !meteorVbo || !ensureMeteorProgram(gl)) return; + var night = 1 - Math.max(0, Math.min(1, (sunAlt + 0.05) / 0.25)); + if (METEOR_NIGHT_ONLY && !meteorAllowDay && night < 0.02) return; + var viewR = new Float32Array(16); + viewR[0] = view[0]; viewR[1] = view[1]; viewR[2] = view[2]; + viewR[4] = view[4]; viewR[5] = view[5]; viewR[6] = view[6]; + viewR[8] = view[8]; viewR[9] = view[9]; viewR[10] = view[10]; + viewR[12] = 0; viewR[13] = 0; viewR[14] = 0; viewR[15] = 1; + var mvp = new Float32Array(16); + mat4Multiply(mvp, proj, viewR); + gl.useProgram(meteorProg); + gl.bindBuffer(gl.ARRAY_BUFFER, meteorVbo); + gl.enableVertexAttribArray(meteorUniforms.a0); + gl.vertexAttribPointer(meteorUniforms.a0, 4, gl.FLOAT, false, 32, 0); + gl.enableVertexAttribArray(meteorUniforms.a1); + gl.vertexAttribPointer(meteorUniforms.a1, 4, gl.FLOAT, false, 32, 16); + gl.uniformMatrix4fv(meteorUniforms.mvp, false, mvp); + gl.uniform1f(meteorUniforms.sizeScale, canvas.height / 900); + gl.disable(gl.DEPTH_TEST); + gl.depthMask(false); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); + gl.drawArrays(gl.POINTS, 0, meteorCount); + gl.disable(gl.BLEND); + gl.enable(gl.DEPTH_TEST); + gl.depthMask(true); +} + function kelvinToRGB(k) { // crude blackbody tint if (k < 3400) return [0.98, 0.84, 0.70]; @@ -7262,6 +7556,11 @@ window.clouds3dRender = function(target) { gl.depthMask(true); } + if (METEORS_ON) { + updateMeteorShower(gl, window.__currentSceneDate || new Date(), new Date(), now); + drawMeteorShower(gl, proj, view, sunAlt); + } + // ---- Terrain mesh pass (real 3D heightfield) --------------------- // The mesh VBO is built once by buildTerrainMesh() after the // elevation grid finishes loading. Depth test+write on so it