做了一个网页天气可视化

搜索"网页天气效果",你大概率会找到两类东西:一类是纯 CSS 写的下雨动画,十几行代码,@keyframes 让 div 从上往下飘;另一类是"调用天气 API 展示温度"的教程,跟视觉效果没半点关系。

真正意义上的"沉浸式天气可视化"------雨滴打到界面元素上溅射、雪花堆积在导航栏、镜头光斑随太阳位置偏移------这类东西,中文社区几乎是空白。英文社区也好不到哪去,CodePen 上那些酷炫的效果基本不开源,或者用了 WebGL 库,拿过来改也费劲。

所以我干脆自己做了一个。

项目用 Next.js + Canvas 2D + CSS 实现,支持晴天、雨天、雪天、阴天、雾天五种天气,每种都有一套可以实时调节的参数面板------雨量、风力、温度、雷暴概率、能见度,拖滑块即时生效。还接了 Open-Meteo 的免费 API,开启自动模式后会读取你的浏览器定位,展示你当前位置的真实天气。

在线体验:https://weather.anhejin.cn

开源地址:https://github.com/greywen/web-weather


Canvas、CSS、WebGL,选哪个

这是个经常被过度讨论的问题。

我的答案是:Canvas 2D 做粒子效果,CSS 做雾气和云层,WebGL 完全没用到。

不是说 WebGL 不好,是杀鸡用牛刀。雨滴最多也就两三百个粒子,雪花上限我设了五百个,Canvas 2D 跑起来 60fps 没什么压力。WebGL 的优势在几万个粒子以上,引入 Three.js 或者 raw WebGL 反而增加了整个项目的复杂度,调试也麻烦。

CSS 适合处理"大范围、有纹理感"的东西。雾气那层我用 CSS 做了烟雾纹理飘动,配合 backdrop-filter: blur() 做整体模糊感,效果比 Canvas 画出来的要自然很多。云层也是纯 CSS 动画,用 Web Animations API 做速度控制,这样可以根据风力参数实时改变云的移动速度,不用每帧重新计算。

Canvas 和 CSS 混用,有一个麻烦点:层叠顺序。Canvas 是一个 DOM 元素,CSS overlay 是另外几个 div,你得管好谁在谁上面,不然会出现 fog blur 把 Canvas 的 rain 也模糊掉这种情况。我在 WeatherProvider 里专门处理了这个,用 z-index 把各层分开,Canvas 在底,CSS fog 在上,控制面板最顶。


雨天:从一条线到溅射粒子

最早的版本,雨滴就是一条线------ctx.moveTo 到 ctx.lineTo,简单粗暴。后来改成了梯形,上窄下宽,模拟真实水滴下落时被空气拉扁的形态:

复制代码
// 梯形雨滴:上窄下宽const topHalfWidth = 0.3;const bottomHalfWidth = 1.2;ctx.moveTo(tx - topHalfWidth, ty);ctx.lineTo(tx + topHalfWidth, ty);ctx.lineTo(bx + bottomHalfWidth, by);  // bx = tx + windOffsetctx.lineTo(bx - bottomHalfWidth, by);

风力通过 windOffset 让梯形底部偏移,雨滴看起来是斜着落的,比单纯的线条有质感多了。

数据结构上,雨滴没有用 class,而是用了 SoA(Struct of Arrays)布局------所有 x 坐标放一个 Float32Array,所有 y 坐标放另一个,速度、长度、透明度也各自一个数组。这样做的好处是内存连续访问,CPU 缓存命中率高,几百个粒子循环更新的时候比逐个对象访问快不少。绘制的时候按透明度分三档批量 ctx.fill(),一次 beginPath 画一批,减少 draw call。

溅射粒子也做了类似的处理------用一个固定大小的 Float32Array 对象池,移除死亡粒子时用 swap-and-pop(把末尾元素换到当前位置),O(1) 移除,不用 splice。画的时候统一一个 fillStyle,所有溅射点一次 fill 搞定。这个效果加进去之后整个场景的"物理感"一下子就上来了。

雷暴是另一个让我花了不少时间的东西。闪电要有分叉,不然看起来就是一条直线,完全没感觉。我用了递归算法:

复制代码
functioncreateBolt(  x1: number, y1: number,  x2: number, y2: number,  depth: number) {if (depth === 0) return;const midX = (x1 + x2) / 2 + (Math.random() - 0.5) * 80;const midY = (y1 + y2) / 2 + (Math.random() - 0.5) * 20;  segments.push({ x1, y1, x2: midX, y2: midY });  segments.push({ x1: midX, y1: midY, x2, y2 });if (Math.random() > 0.5) {createBolt(midX, midY, midX + 60, midY + 80, depth - 1);  }createBolt(x1, y1, midX, midY, depth);createBolt(midX, midY, x2, y2, depth);}

depth 控制分叉深度,我最多允许一层分支,再深下去视觉上反而乱。闪烁效果是每帧用 Math.random() > 0.8 随机跳过绘制,模拟真实闪电的频闪感。


积雪系统

雪花本身没什么特别的,飘落轨迹加点正弦波模拟摇摆就行。有意思的是积雪。

雪花落到导航栏上不会消失,而是堆积起来。SnowPile 系统记录每个雪花的落点,雪花越来越多,堆积层越来越厚。

然后是融化。温度参数控制融化速率,温度高于零度时,堆积的雪从边缘开始缩减。这里我没有做真实的物理模拟,就是每帧从 pile 列表里随机移除一定数量的点,配合渲染时按照 x 坐标排序画出轮廓,看起来是从两侧融化。

低温结冰是另一个细节。温度低于 -5°C 时,积雪颜色从白色渐变成冰蓝色,整个堆积层上面会覆盖一层半透明的白色渐变,模拟冻硬的质感。颜色插值用的是:

复制代码
const iceRatio = Math.min(1, (-temp - 5) / 15);const r = Math.round(220 - iceRatio * 60);const g = Math.round(235 - iceRatio * 20);const b = Math.round(255);ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;

效果出来之后比我预想的要好很多。看着雪一点点堆上去,然后调高温度,边缘开始融化,这个动态过程比静态截图有意思多了。

积雪数量我设了 500 个点的上限。超过就停止新增,不然老设备上跑几分钟之后帧率会掉得很惨。


晴天:拿 Canvas 画镜头光斑

晴天是所有天气里视觉层次最多的,因为光效要叠很多层。

太阳本体是一个 radialGradient,从中心的亮白往外渐变到橙黄再到透明。外层加一圈辉光,半径更大,透明度更低,模拟大气散射。

镜头光斑(lens flare)是我专门花时间研究的东西。真实相机里,光斑是光穿过镜片折射产生的,位置和主光源成镜像关系------光源在右上,光斑出现在左下,而且距离和亮度都有规律。

我用了一个 distRatio 数组定义每个光斑相对于屏幕中心的偏移比例,然后根据太阳位置动态计算:

复制代码
flares.forEach((flare) => {const fx = screenCenterX + (sunX - screenCenterX) * flare.distRatio;const fy = screenCenterY + (sunY - screenCenterY) * flare.distRatio;const gradient = ctx.createRadialGradient(fx, fy, 0, fx, fy, flare.radius);  gradient.addColorStop(0, `rgba(255,255,200,${flare.alpha})`);  gradient.addColorStop(1, 'rgba(255,255,200,0)');  ctx.fillStyle = gradient;  ctx.fill();});

distRatio 是负数时光斑在太阳的对侧,正数时在同侧。实际效果是太阳移动时,光斑会跟着漂移,整个画面有一种"正在用相机拍太阳"的感觉。

导航栏上还有一条反光条,单独用 clip 限定绘制区域,只在导航栏表面画一条弧形高光。这种细节多了,画面就显得精致很多。


昼夜系统我用了一个 0 到 24 的时间滑块,控制整体背景亮度和太阳/月亮的位置。凌晨三点是最暗的,正午最亮。亮度变化用的是 globalAlpha 叠一层半透明黑色蒙版,配合颜色色调偏移。夜晚的雨和雪也会相应变暗,不然会有种白天效果贴在夜空上的割裂感。


雾天:两套方案拼出来的

纯 Canvas 画雾不够自然,纯 CSS 做不出"你在雾里"的层次感,所以我把两个混在一起用。

Canvas 层画了 25 个大型 FogPuff。早期版本每个雾团每帧都 createRadialGradient 生成渐变,25 个雾团就是 25 次渐变创建,性能开销不小。后来改成了离屏 Canvas 预渲染:启动时在一个 256x256 的离屏 canvas 上画好渐变纹理,运行时用 drawImage + globalAlpha 控制透明度,不再每帧创建渐变对象。这一改雾天的帧率直接稳了。

CSS 层做纹理烟雾,用 mix-blend-mode: screen 叠加,透明烟雾纹理在画面上慢慢飘,这一层给雾补充细节纹理。最外层再加一个 backdrop-filter: blur() 的 div,模糊整个背景,加强"能见度低"的感觉。最后一圈 vignette 渐变压暗四周边缘,强化沉浸感。

能见度参数控制的是 CSS blur 的半径和 Canvas 雾团的透明度,两个联动,所以调一个滑块,三层效果同时变化。


天气切换动画

这个设计上花了一点心思。直接切换太生硬,fade out 再 fade in 又太慢,我最后用的是两层 Canvas + 两层 CSS overlay 同时淡入淡出。

新天气的 Canvas 从透明度 0 淡入,旧天气的 Canvas 从 1 淡出,两者交叠,过渡时间 800ms。缓动用的是 easeInOut:

复制代码
consteaseInOut = (t: number) =>  t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;

CSS 层同步处理,fog 和 cloud 的 overlay 也跟着淡入淡出。这样整个切换过程不会出现"底层露出来"的闪烁。


性能:踩过的坑和后来的优化

React 里用 Canvas 动画有一个经典陷阱:状态变化触发重渲染,useEffect 依赖项更新,动画循环被取消再重启,粒子全部重置。

我的解法是 configRef 模式------把天气配置存在 useRef 里,不用 useState。动画循环每帧直接读 configRef.current,不会订阅 React 状态变化。用户拖动滑块时,只更新 ref,不触发重渲染,动画连续不断。

复制代码
const configRef = useRef(defaultConfig);// 控制面板更新consthandleParamChange = (key: string, value: number) => {  configRef.current = { ...configRef.current, [key]: value };// 不 setState,不触发重渲染};// 动画循环里constanimate = () => {const cfg = configRef.current;updateParticles(cfg.windSpeed, cfg.rainRate);requestAnimationFrame(animate);};

这个模式在 Canvas + React 的场景里几乎是必须的,但网上教程很少提到。很多人遇到"拖滑块动画卡一下"的问题,根本原因就在这里。

后面又做了一轮性能优化,主要是三件事:

一是把雨滴和溅射粒子从 class 实例改成 SoA + Float32Array。原来几百个 new RainDrop() 每个都是独立对象,GC 压力大,内存也不连续。改成 SoA 之后所有同类属性挨着存,循环跑起来对缓存友好很多。溅射粒子用固定大小的对象池,spawn 的时候写入下一个空位,死亡时 swap-and-pop 移除,整个生命周期零分配。

二是批量绘制。之前每个雨滴单独 beginPath + stroke,改成按透明度分三档,同一档的雨滴合进一个 path 一次 fill。溅射粒子更简单,统一颜色直接一把画完。Canvas 2D 的瓶颈很多时候不在像素量,而在 draw call 次数------state change 越少越快。

三是雾气的离屏 Canvas。25 个雾团每帧 createRadialGradient 太浪费了,改成启动时预渲染一张 256x256 的渐变纹理,运行时 drawImage 缩放绘制,用 globalAlpha 控制浓淡。这个改动对雾天帧率的提升最明显。


自动模式

我接了 Open-Meteo,完全免费,不需要 API key,直接 GET 请求就行。先用浏览器 navigator.geolocation 拿坐标,然后把经纬度传给 Open-Meteo,拿回 WMO 天气代码,再映射到我自己的五种天气类型。每十分钟刷新一次天气,每分钟更新一次时间,时间影响昼夜状态。

WMO 代码挺繁琐的,61-65 是不同强度的雨,71-77 是雪,各有各的范围,写映射表的时候对着文档抄了好一会儿。


做完这个项目之后,我把它放在家里俩个屏幕电脑上面上跑了一周。开着自动模式,放在第二屏当动态壁纸,效果意外地好。上海冬天那几天大雾,界面里真的飘着雾,雪花粒子跑起来有点废内存,但没到不能接受的程度。

代码写得不算优雅,1000 多行的 Canvas 组件本来应该拆开,但懒得动了,能跑就行。

有感兴趣的可以拿去改,或者告诉我哪里可以做得更好。