做了一个网页天气可视化 2
上一篇写完之后,本想先停一停的。结果项目一开着就关不掉了。白天调太阳光斑,晚上改雷声,挂在副屏上跑着跑着,又开始觉得"这里还差一点,那里也还能再补"。于是它从一个"网页天气可视化 demo",慢慢长成了一个我真的会开着用的东西。
如果说第一篇写的是"怎么把雨、雪、雾这些视觉效果做出来",那第二篇更像是后续更新记录:我把这个项目继续往"长期运行、可交互、可沉浸"的方向推了一大截。
这次主要更新了这些:
- • 声音系统全面重做------雷声、雨声、风声都有了,而且风向会影响左右声道
- • 新增两种极端天气------冰雹和沙尘暴,从粒子到音效全套做完
- • 世界地图选点------点哪看哪的天气,带 24 小时和 7 天预报时间线
- • 沉浸模式、控制面板重做、深色主题,以及一轮继续往下抠的性能优化
开源地址:https://github.com/greywen/web-weather
在线体验:https://weather.anhejin.cn
从"有画面"到"有气氛"------声音系统是怎么做的
第一版项目其实是静音的。
视觉上已经有雨、有雾、有闪电,看着挺像回事,但一旦全屏挂到副屏上,就总觉得少了点什么。不是少一个特效,而是少了"环境"。真实天气打动人的地方,从来不只是你看到了什么,还包括你听到了什么。
雷声合成:七层叠加
最开始我也想过偷懒,直接找几段雨声、雷声 MP3 循环播放就完了。但这么做有两个问题:重复感太强,控制粒度不够。尤其雷声这种东西,一旦你听到第三次完全一样的波形,沉浸感会瞬间消失。
所以最后还是走了更麻烦的路:用 Web Audio API 合成。雷声被拆成了七个阶段,每个阶段都是独立的音频节点链路:
// ═══ 1. THE CRACK --- 闪电回击瞬间的尖锐爆点 + 短弧尾 ═══playBuf(snapBuf, now, v * 1.2, { hpFreq: 1800, bpFreq: 4800, distAmount: 0.45, pan: mainPan });playBuf(arcTailBuf, now + 0.003, v * 0.42, { hpFreq: 900, bpFreq: 2600, pan: mainPan });// ═══ 2. SUB-BASS BOOM --- 正弦波下扫,胸口被震一下的感觉 ═══playBuf(bassBuf, now + 0.005, v * 1.2, { lpFreq: 120 });// ═══ 3-4. RE-STRIKE --- 分支闪电的二次、三次回击 ═══playBuf(rs1Buf, now + 0.045 + random, v * 0.68, { pan: mainPan + offset });playBuf(rs2Buf, rs1Time + 0.03 + random, v * 0.4, { pan: -mainPan + offset });// ═══ 5. ROLLING RUMBLE --- 远处滚动的低频隆隆声 ═══playBuf(rumbleBuf, now + 0.08, v * 0.55, { lpFreq: 250 });// ═══ 6-7. 延迟重击 + 远方余震 ═══playBuf(bass2Buf, rs1Time + 0.01, v * 0.7, { lpFreq: 90 });playBuf(distRumbleBuf, now + 0.5 + random, v * 0.25, { lpFreq: 150, pan: random });
这七层不是随便叠的。snap buffer 是采样级别逐个样本手搓的脉冲序列,模拟电弧击穿空气的尖锐感;crack 段用了高通滤波 + 稀疏脉冲 + tanh 饱和削波,避免听起来像廉价游戏音效;sub-bass 是一个从 80Hz 向下扫到 25Hz 的正弦波,加了二次和半频谐波,给整个雷声一个"下坠感"的低频冲击:
// Sub-bass: 频率下扫合成let phase = 0;for (let i = 0; i < len; i++) { const t = i / sr; const freq = 80 * Math.exp(-t * 0.8) + 25; // 80Hz → 25Hz 指数衰减 phase += (2 * Math.PI * freq) / sr; const envelope = Math.exp(-t * 1.2); d[i] = (Math.sin(phase) * 0.7 + Math.sin(phase * 2) * 0.2 + Math.sin(phase * 0.5) * 0.3) * envelope * Math.tanh(envelope * 2);}
rumble 则是布朗噪声做的滚动尾音,双声道各自加了不同的随机漂移和不规则幅度调制,模拟声音在山谷或云层间反射的那种不均匀滚动感。
最后面还挂了两级 delay-feedback network 做回响------一个短延迟(180-330ms)模拟近处反射,一个长延迟(400-700ms)模拟远处山体回声,都经过低通滤波让每次反馈越来越闷。整条总线再过一个 compressor,threshold -18dB,ratio 12:1,把瞬态炸峰压住。
这一整套做下来,效果最明显的不是"更真实",而是"更耐听"。项目挂一小时,你不会因为那几段音频反复循环而烦躁。而且因为 snap / rumble buffer 各预生成了多个变体缓存在内存里,每次雷声都会从缓存里随机取,所以同样是"打雷",每次听起来都不完全一样。
环境声:雨声、风声、雪声的分层处理
雷声是一次性事件音效,但环境声(雨、风、雪)是持续背景音。这块也不是一段 MP3 循环那么简单。
每种天气的环境声都由多个噪声层构成。拿雨声举例:
function createRainAmbient(ctx: AudioContext, dest: AudioNode): AmbientSet { // Layer 1: 雨体 --- 带通滤波白噪声, 中心频率 3kHz, 模拟雨滴密集打击声 const body = createFilteredNoise(ctx, 'bandpass', 3000, 0.8); // Layer 2: 低频轰鸣 --- 低通 400Hz, 模拟大雨时整体环境的闷响 const rumble = createFilteredNoise(ctx, 'lowpass', 400, 0.6); return { layers: [body, rumble], panner };}
风声两层------600Hz 带通做主体风声,2kHz 窄带通做高频呼啸。雪天则只有一层极低音量的 800Hz 低通噪声,营造那种"世界突然安静下来"的感觉。
这些层的音量不是固定的,而是根据天气参数实时联动的。particleCount 控制雨量,speed 控制下落速度,两个参数组合影响雨声音量和滤波器频率:
const rainFactor = particleRatio * 0.6 + speedRatio * 0.4;fadeTo(rain.layers[0].gain, 0.25 * rainFactor + 0.05, fadeTime);// 雨速越快,滤波频率越高,声音越 "沙沙" 而不是 "哗哗"rain.layers[0].filter.frequency.setTargetAtTime( 2500 + speedRatio * 1500, ctx.currentTime, 0.5);
风控制了左右声道
这是声音系统里我最得意的一个小细节。
所有环境声层都挂了 StereoPannerNode,而 panner 的 pan 值直接由风力滑块(config.wind)驱动:
// 风从右边吹 → 声音偏右声道;风从左边吹 → 声音偏左rain.panner.pan.setTargetAtTime( Math.max(-1, Math.min(1, config.wind / 3)), ctx.currentTime, 0.3);
效果很简单但是非常直觉:你把风力滑块往右拖,雨声就渐渐偏向右耳;往左拖,声音往左耳移动。戴耳机的时候尤其明显,整个"风从哪边来"的方向感一下就有了。
雷声也做了类似的处理。每次触发雷声时,主 crack 的 pan 位置是随机的(±0.25 范围),后面的 re-strike 会在主位置基础上偏移,最后的远方余震 pan 范围更大(±0.4),模拟闪电在天空中不同位置击中的空间感。
当然,浏览器也不会这么轻易放过你。Web Audio 最大的经典问题就是 autoplay policy,不点一下页面,大多数浏览器根本不让你出声。所以我最后把整个 AudioContext 的初始化挂在用户首次交互上------你第一次点页面,才开始创建上下文、预生成 buffer、启动噪声源。
新天气:冰雹------从天上砸下来的不只是圈圈
做完雨雪雾之后,总觉得天气种类还是太"温和"了。真实世界里的极端天气------冰雹、沙尘暴------才是能把氛围感拉满的东西。
冰雹粒子:预渲染纹理 + 加速下落 + 弹跳碎裂
冰雹不是缩小版的雨滴。它是硬的、不规则的、有重量感的。
粒子形状用 6 边不规则多边形模拟冰块的棱角感,但重点不在这里------重点是这些形状全部预渲染成了离屏 Canvas 纹理。一共 6 种尺寸,每种都带径向渐变(模拟冰的半透明质感)和一个小椭圆高光(模拟镜面反射):
// 预渲染冰雹纹理:径向渐变 + 高光const grad = tctx.createRadialGradient(cx - size * 0.2, cy - size * 0.2, size * 0.1, cx, cy, size);grad.addColorStop(0, 'rgba(240, 248, 255, 0.85)'); // 中心亮白grad.addColorStop(0.4, 'rgba(200, 220, 245, 0.65)'); // 冰蓝过渡grad.addColorStop(1, 'rgba(160, 190, 220, 0.35)'); // 边缘半透明// 镜面高光tctx.ellipse(cx - size * 0.15, cy - size * 0.2, size * 0.25, size * 0.15, -0.3, 0, Math.PI * 2);tctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
运行时直接 drawImage 贴图,不用每帧重新创建渐变。150 颗冰雹一起画,开销和画 150 个矩形差不多。
下落不是匀速的。冰雹有加速度(每帧 speed += 0.18),速度上限很高(14-20),加上旋转(每颗粒子有独立的旋转角速度),视觉上就是"从高处狠砸下来"的感觉,而不是"缓缓飘落"。
最有意思的是砸到地面之后的反应。冰雹落地会同时触发两个子系统:
弹跳碎片(hailBounce) :每颗冰雹落地时溅射 2-3 个三角形碎片,向上弹起然后受重力回落,存活时间不到半秒。用 120 个预分配的对象池管理,spawn 时写入下一个空槽,死亡时 swap-and-pop,零分配。
地面碎冰(groundIce) :碎片落地后不会消失,而是留在地面堆积。500 个碎冰块,9 种预渲染纹理(3 种形状 × 3 种大小),每块都有独立的生命周期。这里有个细节------冰雹天气的背景是有雨的(雨量较小),而雨量会影响碎冰的融化速率:
const rainAmount = configRef.current.particleCount;const baseMelt = 0.0004;const melt = baseMelt + rainAmount * 0.0002; // 雨越大,冰融化越快
所以你会看到:冰雹砸下来,碎冰堆积在地面,同时背景有小雨,碎冰在雨中慢慢消融。如果碎冰池满了,新碎冰会替换掉生命值最低的那块------这个策略保证了地面碎冰数量稳定,不会无限增长。
音效方面,冰雹用的是预录制的 hail.mp3 循环播放(这个用合成反而不自然),音量跟冰雹数量联动。背景同时叠加了雨声和风声层,整体听起来就是一场混杂着噼噼啪啪冰粒声的暴风雨。
新天气:沙尘暴------三层粒子 + 碎屑系统
沙尘暴是这次新增的两种天气里视觉层次最丰富的一个。不是因为它最难做,而是因为真实沙尘暴的"信息量"比别的天气大:有细沙、有碎屑、有地面扬尘、有整体色调偏移,全部叠在一起才像回事。
沙粒层:350 颗不规则多边形
沙粒不是圆形,是 3-6 边的随机多边形。启动时预生成 12 种形状模板,运行时每颗粒子随机索引一种。三组颜色------暖沙 rgb(180,155,100)、深沙 rgb(160,135,85)、亮沙 rgb(195,170,120)------按 colorShift 分配,避免所有粒子都是一个色。
移动轨迹不是直线。水平方向跟随风速和风向,垂直方向有正弦波摆动(wobble),每颗粒子的摆动频率和幅度都不一样:
this.x[i] += (this.speed[i] + absWind * 2) * speedMult * dir;this.y[i] += Math.sin(now * 0.0006 + this.wobble[i]) * this.wobbleAmp[i];this.rotation[i] += this.rotSpeed[i] * speedMult;
碎屑层:会翻滚的树枝、树叶和小石子
沙尘暴不只是沙子。空气里还夹杂着被风卷起来的杂物。
我定义了 5 种碎屑类型------树枝(twig)、树叶(leaf)、小石子(rock)、塑料片(scrap)、泥块(clump)------每种都有独立的手绘形状。树枝是几段折线,树叶是贝塞尔曲线填充,石子是不规则多边形,塑料片是带弯曲的长条。最多 25 个碎屑,这个数量不能多,否则画面会太乱。
地面扬尘:贴地滚动的圆形尘团
靠近地面的部分有一层贴地滚动的沙团(groundSand),60 个半透明圆形,从一侧滚到另一侧后淡出消失。这一层给整个画面底部一种"沙子在脚下翻涌"的感觉。
整体色调:全局沙色覆盖
最上面还盖了一层全屏的半透明沙色蒙版,密度跟 sandDensity 参数联动。密度高的时候,画面上下各有一条沙色渐变带,模拟近地面和高空能见度降低的效果:
// 全画面沙色蒙版ctx.fillStyle = `rgba(180, 140, 70, ${sandDensity * 0.25})`;ctx.fillRect(0, 0, width, height);// 密度 >= 0.3 时,顶部和底部加重的沙尘带if (sandDensity >= 0.3) { const topGrad = ctx.createLinearGradient(0, 0, 0, height * 0.25); topGrad.addColorStop(0, `rgba(160, 120, 50, ${sandDensity * 0.3})`); topGrad.addColorStop(1, 'transparent'); // ...}
音效方面,沙尘暴用了 sandstorm.mp3 循环样本,音量跟密度和风速双重联动。同时风声层也会自动拉满,毕竟没有大风就不会有沙尘暴。
世界地图:点哪儿看哪儿的天气
第一版的自动模式只能读浏览器定位,看自己所在城市的天气。这次加了世界地图------地图上任意点一下,就能看那个位置的实时天气。
为什么不做城市选择器
一开始我考虑过做一个下拉列表选城市。但很快就放弃了。城市列表要维护,数据不全的城市定位不准,而且一个列表最多放几百个城市名。
地图直接点击就不一样了:经纬度是连续的,你可以点撒哈拉沙漠中间、可以点南极大陆、可以点太平洋正中央。有没有城市不重要,Open-Meteo 只要经纬度就能返回天气数据。
天气预报时间线
既然已经有了位置,顺手就把预报数据也用上了。Open-Meteo 的同一个 API 请求里可以带 hourly 和 daily 参数,额外返回 24 小时逐小时预报和 7 天逐日预报。
这些数据渲染成了一个可折叠的时间线面板。小时预报是横向滚动的卡片列表,每张卡片显示时间、天气图标、温度和降水量,当前小时会高亮。日预报是纵向列表,每行显示星期、图标、最低/最高温度,中间有一条渐变的温度范围条。
这一块交互上没什么特别的,但有一个细节:温度单位可以在摄氏度和华氏度之间切换,切换后整个时间线的温度显示会即时刷新。
所以现在你可以这样玩:点开地图,搜 "Reykjavik",看冰岛现在在下什么;然后切到 "Dubai",看沙漠里的天气;再切 "Tokyo",看东京的梅雨季------每个地方的天气参数完全不同,Canvas 里的效果也跟着变。某种意义上这不再只是一个"天气可视化",而是一个"天气体验器"。
沉浸模式,本质上是在认真对待"副屏动态壁纸"这个场景
上一篇文章最后我提过,我会把这个项目开在家里第二块屏幕上跑。后来我发现,如果真把它当"桌面氛围层"来用,那界面逻辑就不能还是一个普通演示页的思路。
你不可能让一个侧边栏一直糊在画面上。控制面板、GitHub 按钮、语言切换,全都摆在那里,再好的天气效果也会被 UI 打断。
所以这次我单独做了一个沉浸模式。
进入后会直接请求浏览器全屏,主面板隐藏,只在左上角保留一个很小的状态 badge,显示当前天气和自动/手动状态。想调参数的时候,再点开面板;不调的时候,整个页面尽量只剩天气本身。
代码上倒不复杂,核心就是 Fullscreen API:
if (enterImmersive) { document.documentElement.requestFullscreen?.();} else if (document.fullscreenElement) { document.exitFullscreen?.();}
真正要处理的是边界情况。比如用户按 Escape 退出全屏,UI 状态不能还傻傻地以为自己在 immersive mode 里,不然按钮状态和真实页面状态会不同步。所以我又监听了 fullscreenchange,把 React 状态反向同步回来。
这种东西写起来没技术含量,但不补的话体验会很糟。很多 demo 之所以始终是 demo,不是因为效果不够酷,而是因为它们没认真处理这些"你真的会拿来用吗"的问题。
性能这次没大改架构,但继续把一些不值当的开销砍掉了
第一篇里我已经写过 SoA、对象池、批量绘制这些优化,这次主要是继续清理一些"肉眼不一定看得到,但设备会很诚实地告诉你它难受"的地方。
冰雹的预渲染纹理是这次最典型的优化案例。150 颗冰雹,每颗是不规则多边形 + 径向渐变 + 高光椭圆,如果每帧现画,就是 150 次 createRadialGradient + 150 次多边形路径构建 + 150 次椭圆绘制。改成 6 张预渲染纹理之后,运行时就是 150 次 drawImage,开销直接降了一个数量级。
沙尘暴也是类似思路。12 种沙粒形状预生成顶点数组,运行时只做平移、旋转、填充。碎屑的 5 种类型各自有固定的绘制路径,不用每帧重新计算形状。
雷声的 buffer 缓存也值得一提。buildLightningSnapBuffer、buildSubBassBuffer、buildRumbleBuffer 这些函数调一次要遍历几万个采样点做数学运算,如果每次打雷都重新算,延迟会非常明显。现在所有 buffer 在第一次触发时生成并缓存,后续直接从 cache 里取,snaps 缓存 3 个变体、bass 缓存 2 个、rumble 缓存 2 个,共 7 段 buffer 覆盖所有组合。
雷电的视觉绘制还是上次的双层线条方案------粗淡外轮廓 + 细亮芯线。单纯画亮线不难,难的是闪的时候别把 GPU/CPU 一起拖慢,shadowBlur 是绝对不碰的。
这类优化的共同点是:它们不会让截图变得更惊艳,但会决定项目能不能在一台普通电脑上安静地跑一整天。
这次更新让我更确定一件事:网页特效项目最难的不是"做",而是"收"
如果只是为了发个视频,很多东西都可以往上堆。
多一层 glow,多一层 noise,多一个模式,多一个按钮,看起来好像都在"增强项目"。但当你真的把它当成一个可以打开、切换、悬挂、长期运行的网页应用去看,问题就变了。
你得考虑:
- • 冰雹的碎冰堆积到 500 块之后怎么办
- • 沙尘暴的三层粒子系统会不会把低端设备拖垮
- • 切到东京的天气数据后,声音系统的音量跳变会不会吓到人
- • 地图组件差不多 400 行代码,SSR 不能渲染怎么处理
- • 用户在赤道选了一个点之后想切回自己的定位,状态怎么恢复
说白了,越往后做,这个项目就越不像一个"特效合集",反而越来越像一个小型系统。视觉、状态、交互、性能、音频、地理数据,最后都缠在一起。
也是因为这个,我现在反而不太在意它是不是"代码优雅"。它当然还能继续拆、继续抽、继续模块化,但对我来说,这一轮更新最重要的收获不是文件结构更漂亮,而是这个项目终于有了明确的使用场景------打开它,选一个地方,听着那里的天气,挂上一整天。
接下来如果我还继续做,可能会往两个方向走。
一个方向是继续补环境细节------雨天玻璃挂水、远处城市灯光在雾里的扩散、更自然的云层分层,这些一直在脑子里转但还没动手的东西。另一个方向则是工程侧的收拾,把一个越来越膨胀的 Canvas 组件拆得更清楚一点,毕竟 WeatherCanvas.tsx 已经一千好几百行了,再加新天气类型就不太好维护了。
当然,也可能我又突然去做别的坑了。
如果你对这类"网页里的环境模拟"也有兴趣,欢迎直接拿项目改,或者告诉我还有哪些天气细节值得往里塞。