在这里插入图片描述
在这里插入图片描述

写这个 demo 的时候是晚上十一点半。我坐在窗边,屏幕上是一个灰扑扑的 Canvas 画布,里面下着一场我亲手写出来的雨。窗外也下着雨。那一刻我忽然觉得,程序员最浪漫的瞬间大概就是这样——你在代码里造了一个世界,然后发现现实世界跟你撞了。

说正事。我一直在用 ArkTS 刷鸿蒙的 Canvas 2D 应用,前面刚做完贪吃蛇(那篇改天细说),全是逻辑、碰撞、方向控制,太"硬"了。我寻思换个口味,做个纯观赏性的东西——天气粒子动画。就是那种打开 App 就能看到下雨、下雪、打雷的屏保级效果。目标就一个:好看

然后我就发现,好看这件事,比我想象中难多了。

第一步:一个接口,装下所有天气

一开始我天真地想,五种天气(晴、多云、雨、雪、雷雨)嘛,那就写五套粒子类,各画各的。写到第二种我就放弃了——雨和雪明明长得很像,都是"一堆点从天上掉下来",凭什么要写两遍?

于是收敛成一个接口:

interface Particle {
  x: number;
  y: number;
  vx: number;       // 水平速度
  vy: number;       // 垂直速度
  size: number;     // 尺寸
  alpha: number;    // 透明度
  life: number;     // 剩余寿命(帧)
  maxLife: number;  // 总寿命
  type: string;     // 'rain' | 'snow' | 'sun' | 'cloud' | 'lightning'
}

位置、速度、尺寸、透明度、寿命,这五样东西是所有粒子的共同语言。至于你是雨还是雪,靠一个 type 标记,画的时候分一下流就行。

这就是"引擎 + 配置"的思路:引擎只写一遍,天气的差异全部塞进生成参数里。后来我想加个雾,只需要多写一个 spawn 方法和一个绘制分支,引擎一个字都不用动。这话现在说起来轻巧,当时我可是写废了两版才悟出来的。

晴:看起来最简单,其实最坑

晴天的粒子是"漂浮光斑",就是那种阳光里能看到的小光点。代码也真简单,慢速随机游走:

vx: (Math.random() - 0.5) * 0.3,
vy: (Math.random() - 0.5) * 0.3,
size: 6 + Math.random() * 10,
alpha: 0.15 + Math.random() * 0.3,

我第一版把透明度写成了 0.8,结果屏幕上飘着一堆"金色葡萄干",哪是阳光啊,简直是梵高的星空喝多了。调小到 0.15~0.45 之后,光斑才终于有了那种"若有若无"的呼吸感。

有个细节我到现在都觉得妙:晴天的粒子只有 40 个。为什么?因为稀疏本身就有"宁静感"。粒子数不是越多越好,粒子数 = 视觉密度,密度要配合天气的情绪。这句话是我调了半小时参数之后得出的血泪结论。

雨:物理课代表

雨丝是最讲究物理的。真实世界的雨有重力(越落越快)、有风(斜着下)。我全写进速度向量里:

vx: -1.5,                   // 风往左吹
vy: 9 + Math.random() * 4,  // 高速下落
size: 1.5,

vy 取 9~13,配合 16ms 一帧,这个速度落在屏幕上刚刚好——再快就是"子弹雨",再慢就是"漏水的天花板"。风给了个固定值 -1.5,于是整场雨都朝一个方向斜,看着特别"真实",因为真下雨时风就是同一个方向吹的。

这里踩了一个大坑,值得单独说。我最初写的时候,所有雨粒子出生点都是 y = -20(屏幕顶上方),结果开场第一秒,屏幕下半部分是空的——雨是从天上"下"下来的,可天上还没来得及下呢,画面就尴尬了。

解决办法是给生成函数加一个 initial 参数:

y: initial ? Math.random() * h : -20,

首次生成时让雨粒子均匀撒满全屏(相当于这场雨"已经下了一会儿"),运行中再新生的粒子才从顶部往下落。开局即完整画面,这个思路后来被我用在所有天气上。

雪:慢,才是灵魂

雪跟雨最大的区别就一个字:慢。

vy: 1.2 + Math.random() * 0.8,        // 雨的六分之一
vx: (Math.random() - 0.5) * 0.8,      // 左右摇摆

vy 只有 1.2~2,雨是 9~13,慢了差不多六倍。而且雪的横风是随机的——每片雪花在 ±0.4 之间晃,就有了那种"飘"的感觉,不是"砸"下来。

我第一版偷懒,直接抄了雨的速度然后改了颜色,结果下的是"白色冰雹",从屏幕上方五秒砸到下方,气势汹汹。后来把速度调慢、加摇摆、寿命给足(400~600 帧),才终于有了"窗外的雪"的味道。

顺带一提,真实雪花是六角形的,我在深度扩展里写了个 save/translate/rotate 六分支画法,但正文里还是用白色圆点——教学嘛,圆点足够表达"飘落"这个核心,六角形是锦上添花。真做成产品,我肯定会换六角形,还会加景深模糊。

闪电:用 2% 的概率骗过眼睛

雷雨模式是重头戏,也是我最得意的一个设计。闪电我没有单独写什么事件系统,它就是一个稀有粒子

const isFlash = Math.random() < 0.02;   // 2% 概率
life: isFlash ? 8 : 200,                // 闪电只有 8 帧寿命

每生成一个粒子有 2% 概率是闪电,闪电寿命只有 8 帧(约 0.13 秒)。画面上的效果就是:雨下着下着,突然全屏闪白一下,一条黄色锯齿线一闪而过——然后一切照旧。

第一次跑通的时候我盯着屏幕等闪电,等了大概两秒,突然"咔嚓"一下,我整个人从椅子上弹起来了。那一下的成就感,比写完整个贪吃蛇都大。

锯齿线画起来也简单,五段折线,每段横向随机偏移:

for (let i = 1; i <= 5; i++) {
  this.ctx.lineTo(p.x + (Math.random() - 0.5) * 30, p.y + i * 12);
}

每次重绘锯齿都不一样,所以闪电的形状一直在变,正好就是"闪烁"的效果。用低概率 + 短寿命模拟稀有事件——这个思路我后来想,其实很多游戏里的随机事件都可以这么干,比写一个独立的状态机省心太多了。

那些"看起来没写但必须写"的代码

有几句代码,乍看没啥,少了要出大事。

第一句:切换模式时清空粒子。 如果不清空,你从雨切到雪,屏幕上是半场雨丝和半场雪花混在一起下,像什么混搭灾难现场。所以 switchMode 里必须 this.particles = [],清掉旧的,立刻生成新天气的首波粒子,再重启循环。清空和重建要放在同一个同步代码块里——这样中间不会有渲染的机会,用户根本看不到空白帧。这个细节我 debug 的时候才想明白。

第二句:页面销毁时停掉帧循环。

aboutToDisappear(): void {
  this.stopLoop();
}

这个坑我栽得最狠。有几天我发现 App 用久了莫名发热,电量哗哗掉。排查半天——返回首页之后,帧循环还在后台跑着!每 16ms 更新一次粒子、画一次画布,页面都没了它还在吭哧吭哧干活。CPU 白烧,还可能去操作已经销毁的 canvas 直接报错。加上这句 stopLoop() 之后,世界清净了。

第三句:粒子数的实时显示。 @State countText 每帧更新成 粒子数:xxx。这行代码的初衷是调试——我想看看粒子数到底稳不稳。结果它成了整个页面最有"生命感"的部分:雨模式下数字在 180 附近波动(出界重生的时序有随机性),切到多云变成 12。用户能看到粒子系统在"呼吸",这比任何说明文案都有说服力。

说点参数之外的

我算过一笔账:雷雨模式 220 个粒子 × 60fps ≈ 每秒 13200 次绘制,Canvas 2D 扛得住,一点不卡。但是粒子数要是上千(比如星空屏保要 300+ 星星),就得想优化了:背景渐变别每帧重画(离屏 Canvas 缓存一份)、只清变化区域、实在不行降帧率到 30fps。先看帧率再优化,不卡就别动——这是我被"过早优化"坑过之后学会的。

关于帧循环,我用的 setInterval(..., 16),约等于 60fps。严格来说 requestAnimationFrame 才是正解,但 rAF 在 ArkTS 的 Canvas 组件上要绑定生命周期,教学场景里 16ms 的 interval 更直观。做产品的话,我会换 rAF。

最后

这篇写完了,窗外雨也停了。这个 demo 的神奇之处在于,把"引擎 + 配置"想明白之后,雨、雪、烟花、星空、萤火虫,全都能在这一个骨架里长出来——改参数就是新世界。我甚至怀疑,做游戏的人看到这个会不会觉得太基础——但我确实是从这里开始,才真正理解"粒子系统"这个东西到底是怎么回事。

代码在 entry/src/main/ets/pages/canvas_2d/WeatherParticlePage.ets,完整源码和逐段解读在系列的前三篇里。你要是也写了天气粒子,欢迎告诉我你踩了什么坑——毕竟,程序员之间的友谊,都是靠交换 bug 建立的。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐