跳到正文

用 Canvas 绘制一场会呼吸的暴风雨

1 分钟阅读

从一条斜线到完整的天气系统——记录为博客背景实现真实感雨景、闪电与粒子流场水面的全过程,涵盖分形闪电、双层雨滴、四种湍流模式、轨迹尾迹渲染与雨势联动设计。

博客背景里那场雨和那道闪电,不是直接用的库。从第一条斜线画下来,到现在这套有风、有涟漪、有粒子流场水面的天气系统,大概经历了十几轮迭代。这篇文章把值得记录的技术细节整理出来。

整体架构

最终实现的动画由三个部分构成:

  • Lightning.svelte — 分形闪电:路径生成、多级分支、回击时序、大气辉光渲染
  • Rain.svelte — 雨滴(双层景深)、粒子流场水面、涟漪环、落地溅点、风向变化
  • stores/weather.ts — 共享雨势强度,是两个组件之间唯一的通信桥梁

全部基于 Canvas 2D API,不依赖任何动画库。两个 canvas 层叠在页面最底层,CSS dark:invert 处理深色模式适配。


闪电系统

分形路径:fBm 中点位移

真实的闪电看起来像随机折线,实际上是分形结构。用分形布朗运动(fBm)中点位移法生成:

function subdivide(pts: Point[], scale: number): Point[] {
  const out: Point[] = [pts[0]];
  for (let i = 0; i < pts.length - 1; i++) {
    const a = pts[i], b = pts[i + 1];
    const len = Math.hypot(b.x - a.x, b.y - a.y);
    // 垂直于线段方向的单位向量
    const nx = -(b.y - a.y) / len;
    const ny =  (b.x - a.x) / len;
    // 随机位移,幅度正比于当前线段长度
    const disp = (Math.random() - 0.5) * len * scale;
    out.push({ x: (a.x + b.x) / 2 + nx * disp,
               y: (a.y + b.y) / 2 + ny * disp }, b);
  }
  return out;
}

function fBmPath(x0, y0, x1, y1, depth = 9): Point[] {
  let pts = [{ x: x0, y: y0 }, { x: x1, y: y1 }];
  for (let d = 0; d < depth; d++)
    pts = subdivide(pts, 0.70 * 0.62 ** d);
  return pts;
}

关键在于 scale = 0.70 × 0.62^d:每次细分,位移幅度以 0.62 的比率衰减(接近黄金比例的倒数)。9 次迭代后路径有 2^9 = 512 个点,看起来和真实闪电几乎没有区别。

分支树:四级层级结构

主干生成后,递归挂载分支:

主干(level 0)
  └─ 8–13 条一级分支(level 1,偏转 48°–86°)
       └─ 每条 2–5 条二级分支(level 2,偏转 40°–76°)
            └─ 每条 1–3 条三级分支(level 3)
                 └─ 50% 概率的四级分支(level 4)

每一级比父级短 28%–47%,细分深度也随级别递减(9→7→5→4→3),保证越细的分支越平滑。每条分支的起点从父路径上随机采样,方向继承父路径局部切线再加随机偏转:

function spawnBranch(parent: Point[], t: number, devDeg: number, len: number, depth: number) {
  const i  = Math.floor(t * parent.length);
  // 用前后 5 个点估计局部切线方向
  const pa = Math.atan2(parent[i+5].y - parent[i-5].y,
                        parent[i+5].x - parent[i-5].x);
  const ang = pa + sign * devDeg * Math.PI / 180;
  return fBmPath(parent[i].x, parent[i].y,
                 parent[i].x + Math.cos(ang) * len,
                 parent[i].y + Math.sin(ang) * len, depth);
}

时序:先导放电 + 回击

真实闪电分两个阶段:先导(电荷从云端向地面缓慢传导)和回击(沿先导通道瞬间放电,产生强光)。

// 先导阶段:80–230 ms,闪电已经"在那里"但极暗
if (age < bolt.leader) return 0.05 + 0.01 * Math.random();

// 回击阶段:每次回击是一个指数衰减的光脉冲
let peak = 0;
for (const s of bolt.strokes) {
  const sa = age - s;
  if (sa >= 0) peak = Math.max(peak, Math.exp(-sa / 55));
}
return peak;

每道闪电有 2–5 次回击,间隔 28–108 ms,这就是闪电”闪烁”的原因——不是一次曝光,而是多次快速重复放电。

渲染:五层大气辉光

最终的视觉效果来自多层叠加,从外到内:

超远晕(σ=120px,α×0.07)— 大气散射,极淡的远场辉光
远晕  (σ=85px,α×0.22) — 电离气体的光晕
中晕  (σ=35px,α×0.70) — 发光等离子体通道
内通道(σ=12px,α×0.90) — 电弧紧邻区域
核心线(lineWidth 2px)  — 闪电本身的高温细丝

shadowBlur 是 Canvas 2D 的内置高斯模糊,由 GPU 执行。用它模拟辉光的代价极低,但视觉效果几乎和真实的大气散射一致。分支级别越高(越细),辉光层越少、线宽越细,天然形成”细枝不发光”的视觉层次。


雨滴系统

双层景深

单层雨滴没有真实感——现实中的雨有层次,近处的大而快,远处的细而慢。维护两个独立数组:

const drops:   Drop[] = []; // 前景 160 滴,角度随风变化(5°–28°)
const bgDrops: Drop[] = []; // 背景  70 滴,固定近垂直角度(7°),极淡

背景层先画,前景层后画,天然形成遮挡关系。两层角度差带来肉眼可感的纵深。

雨线在水面的裁剪

雨落到水面就消失,不会穿进水里。每帧绘制时把终点限制在水面线以上:

const drawY = Math.min(d.y, waterY);
// 沿线段方向插值出水面处的交点
ctx.moveTo(d.x - (d.y - drawY) * sinA / cosA, drawY);
ctx.lineTo(d.x - d.len * sinA, d.y - d.len * cosA);

当雨线尖端超过水面时,沿 (-sinA, -cosA) 方向插值裁出新端点——视觉上雨线”打入”水面的瞬间就消失,随即激起涟漪。


水面系统:粒子流场

水面是整套系统里迭代最多的部分。最终放弃了”扫描线 + y 轴位移”的方案,改用粒子流场:每个粒子沿速度场积分一段短路径,留下带尾迹的弯曲笔画,整体形成流动的湍流感。

四种湍流模式

每次页面加载随机选一种,形态各不相同:

① 叠加正弦(Sine)

多道正弦波叠加出水平主流加竖向扰动:

const u = 1.2 + 0.8 * (
  Math.sin(0.022 * y + t * 1.5) +
  0.5 * Math.sin(0.044 * y + t * 2.2 + x * 0.012)
);
const v = 0.6 * (Math.sin(0.028 * x + t * 1.8) + 0.5 * Math.cos(0.055 * x + t * 1.2));

结果是规律的层流,粒子轨迹整齐地随波浪起伏,像平静水面的水流纹。

② 分形布朗运动(fBm)

用两个独立 Perlin 噪声场分别驱动 x 和 y 速度:

vx = fbm(nx * 3,       ny * 2,       t * 0.30, 4) * 2.8;
vy = fbm(nx * 3 + 5.2, ny * 2 + 3.7, t * 0.30, 4) * 1.1;

两个 fbm 采样点偏移了 (5.2, 3.7) 以保证统计独立。4 倍频叠加产生有机的湍流感——粒子轨迹忽左忽右,没有明显规律,类似水面被风搅乱的样子。

③ Curl 噪声

从标量势场 ψ 的梯度旋转生成无散度速度场,v⃗ = ∇×ψ = (∂ψ/∂y, −∂ψ/∂x)

function curlVel(nx, ny, t): [number, number] {
  const dpdy = (perlin(nx, ny + ε, t) - perlin(nx, ny - ε, t)) / (2ε);
  const dpdx = (perlin(nx + ε, ny, t) - perlin(nx - ε, ny, t)) / (2ε);
  return [dpdy, -dpdx];
}

无散度意味着速度场里没有源也没有汇——粒子不会无缘无故聚集或消散。流线永远不交叉,视觉上是连贯的弯曲河道,不同于 fBm 的随机扰动。

④ 涡旋场(Vortex)

用 Biot-Savart 定律叠加 7 个点涡的诱导速度,模拟流体涡旋:

function vortexVel(nx, ny, t): [number, number] {
  let vx = 0, vy = 0;
  for (const v of vortices) {
    const dx = nx - vx_pos, dy = ny - vy_pos;
    const k  = Γ / (2π × (+ ε));   // Biot-Savart 正则化核
    vx += k × (−dy);
    vy += k × dx;
  }
  return [Math.tanh(vx / 3), Math.tanh(vy / 3)];  // tanh 软截断防奇点发散
}

每个涡心缓慢绕轨道运动,粒子在多个涡的叠加场中画出复杂的螺旋轨迹。tanh 截断解决了 Biot-Savart 核在奇点处发散的问题——不加这个,粒子会在涡心附近瞬间飞出屏幕。

轨迹尾迹:历史位置 + 三层 alpha

粒子并不只是一个点,而是存储了最近 30 帧的历史位置,每帧渲染时分三层绘制:

// Layer 1: 全尾迹,极淡(ghost)
ctx.globalAlpha = base * 0.30;
ctx.beginPath();
ctx.moveTo(trail[0], trail[1]);
for (let i = 1; i < n; i++) ctx.lineTo(trail[i*2], trail[i*2+1]);
ctx.stroke();

// Layer 2: 前 60%,半透明
ctx.globalAlpha = base * 0.55;
ctx.moveTo(trail[mid*2], trail[mid*2+1]);
...

// Layer 3: 前 25%,接近不透明
ctx.globalAlpha = base;
...

// 头部亮点
ctx.globalAlpha = base * 1.4;
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();

三层叠加后,尾迹从尾部到头部自然过渡:极淡的残影 → 半透明流线 → 亮点。在深色模式 dark:invert 下,黑色笔画反转为白色,变成在暗背景上发光的粒子流。

流场随雨势变化

粒子数量和流速与 intensity 正相关:

const nParts  = Math.round(lerp(180, 420, intensity));  // 细雨少粒子,暴雨多粒子
const speedPx = lerp(0.6, 1.6, intensity);               // 细雨慢流,暴雨急流

细雨时水面安静,粒子稀少、缓慢漂移;暴雨时 420 个粒子高速流动,和头顶的雨势形成联动。


涟漪系统

雨滴落入水面时激起的涟漪,与粒子流场完全解耦——涟漪作为独立的同心椭圆环绘制,不影响粒子轨迹,避免产生”蝌蚪”形的视觉伪像。

分层展开

每个雨滴落点创建 1–3 圈涟漪,错开 130–200 ms 依次出现:

function splash(now, x, y) {
  const rings = 1 + Math.floor(Math.random() * lerp(1.5, 3.5, intensity));
  for (let i = 0; i < rings; i++) {
    ripples.push({
      x, y, r: 0,
      maxR: baseR * (1 + i * 0.50),   // 外圈更大
      born: now + i * lerp(200, 130, intensity),
    });
  }
}

每圈绘制时带一个内侧”影环”(半径 × 0.72),模拟真实涟漪的同心多圈结构:

// 主环
ctx.lineWidth   = lerp(1.2, 0.4, age);   // 新生粗,老化后细
ctx.globalAlpha = fade * 0.38;
ctx.ellipse(rp.x, rp.y, rp.r * 1.55, rp.r * 0.48, 0, 0, Math.PI * 2);

// 内影环(稍滞后,更淡)
const r2 = rp.r * 0.72;
ctx.lineWidth   = lerp(0.8, 0.25, age);
ctx.globalAlpha = fade * 0.22;
ctx.ellipse(rp.x, rp.y, r2 * 1.55, r2 * 0.48, 0, 0, Math.PI * 2);

椭圆宽高比约 3.2:1,模拟水面的透视压扁。线宽随年龄从 1.2px 细化到 0.4px,配合 (1−age)^1.6 的 alpha 衰减,老化感自然。


雨势系统与联动

Waypoint 余弦插值

雨势用 waypoint 慢速插值,每段持续 10–180 分钟

function getIntensity(t: number): number {
  while (t >= wp1.t) {
    wp0 = wp1;
    wp1 = { t: wp0.t + (10 + Math.random() * 170) * 60, v: Math.random() };
  }
  const p = (t - wp0.t) / (wp1.t - wp0.t);
  return lerp(wp0.v, wp1.v, (1 - Math.cos(p * Math.PI)) / 2); // 余弦缓动
}

初始强度 Math.random() 保证每次刷新页面都是不同的天气状态。

强度驱动所有参数

intensity ∈ [0, 1] 是整个天气系统的总线:

参数细雨(0)暴雨(1)
流场粒子数180420
粒子流速0.6 px/帧1.6 px/帧
涟漪触发概率7%45%
涟漪环半径7–15 px18–38 px
涟漪寿命2800 ms1600 ms
雨滴速度280–430 px/s920–1200 px/s
闪电间隔20–45 s2–7 s

“涟漪寿命”反向:细雨时一个涟漪在平静水面缓慢扩散;暴雨时新涟漪不断覆盖旧的,寿命自然更短。

Weather Store:组件间通信

两个 canvas 组件通过一个极简 store 通信:

// stores/weather.ts
export const rainIntensity = writable(0.5);

// Rain.svelte — 每帧写入
rainIntensity.set(intensity);

// Lightning.svelte — 生成闪电时读取
const ri  = get(rainIntensity);         // 主动读,不订阅
const lo  = lerp(20000, 2000, ri);      // 细雨:最短 20s
const hi  = lerp(45000, 7000, ri);      // 暴雨:最长 7s
nextSpawn = now + lo + Math.random() * (hi - lo);

get() 而不是 subscribe():闪电生成是低频事件,主动读一次就够,不需要在每帧维护订阅回调。


几个取舍

为什么用粒子流场而不是扫描线? 扫描线 + y 轴位移画出的是”一组向下看的水波断面”,视觉上是平行的波浪线条。粒子流场里每个粒子沿速度场弯曲前进,轨迹本身就携带了流动方向的信息——这是扫描线完全无法表达的。Curl 噪声的无散度特性让流线永不交叉,Vortex 的螺旋涡核在扫描线体系下根本没有对应的表达形式。

为什么四种模式随机选一种而不是混合? 混合过的话,每种模式的个性就消失了。Sine 的层流感、Curl 的河道感、Vortex 的旋转感,都是在单一模式下才最纯粹。让每次访问得到不同的水面形态,也是一种内容的多样性。

涟漪为什么不影响粒子轨迹? 试过在粒子轨迹里叠加涟漪的 y 轴位移。问题是:轨迹只向头部方向变形,而老位置(尾部)不受影响,形成”头部跳一下、尾部继续向前”的形态——正好是蝌蚪的样子。涟漪作为独立视觉层绘制,反而更干净。

深色模式怎么处理? Canvas 只画黑色(strokeStyle = '#000'),CSS 对两个 canvas 加 dark:invert。浅色模式下黑线极淡,几乎隐于白底;深色模式下颜色反转为白线,在暗背景上清晰可见,粒子流场变成发光的粒子流,和闪电的辉光效果形成呼应。整个颜色适配只需一个 Tailwind 类,不需要在 JS 里判断主题。


打开这个博客,在雨势高的时候看,和雨势低的时候看,是两种完全不同的氛围。暴雨时闪电每隔几秒就劈下来,420 个粒子在涡旋场里高速旋转;细雨时天空安静,180 个粒子缓慢漂过,偶尔一圈涟漪从水面展开。每次刷新,还可能是完全不同的湍流形态。这是做静态动画很难得到的东西。

© 2026 chenzylab