鸿蒙ArkTS粒子特效:帧循环、物理规则与性能优化全攻略


一、设计理念:画面由「成千上万个点」构成
前四个应用画的是「确定的形状」(笔迹、柱体、表针);粒子特效反过来——画面由大量微小元素(粒子)的集合构成,每个粒子有自己的位置、速度、生命,整屏的视觉效果来自粒子的群体行为。
本应用两种模式:
| 模式 | 效果 | 粒子行为 |
|---|---|---|
| 🌌 星空漫游 | 星星漂移 + 闪烁,触摸生成星团 | 慢速漂移 + 透明度正弦闪烁 |
| 🎆 烟花绽放 | 火箭升空自动爆炸,触摸引爆 | 重力升空 + 径向爆炸 + 阻尼衰减 |
技术核心:粒子数据结构、帧循环(更新 + 绘制)、随机发射、拖影效果、粒子上限保护。
为什么第五个应用选粒子特效
粒子特效是 Canvas 绘制系列的「最终关卡」,选择它收官有三个理由:
- 集大成:粒子用到了前面所有应用的技能——路径(arc)、样式(globalAlpha)、动画(帧循环)、性能(上限保护)。它把 25 篇的知识点全部串起来;
- 性能极限:前面四个应用的元素量都在百级以内,粒子上千——这是第一次真正考验「每帧全量重绘」的性能底线;
- 视觉震撼:粒子是「低成本高视觉回报」的典型——几十行代码就能做出星空、烟花,最能体现 Canvas 的创造力。
粒子系统 vs 传统绘制的本质差异
| 维度 | 传统绘制(前 4 应用) | 粒子系统(本应用) |
|---|---|---|
| 元素数量 | 几十~几百 | 数百~上千 |
| 元素关系 | 独立、静态 | 群体、动态 |
| 数据来源 | 用户/业务数据 | 程序计算(物理规则) |
| 画面形成 | 逐个画完即固定 | 每帧重新计算位置 |
| 生命周期 | 持久 | 出生→运动→消亡 |
粒子系统的精髓是「用简单规则 + 大量个体,涌现出复杂整体」——没有人为设计整体形状,画面是群体行为的自然结果。这种「涌现式」思维与前面「设计式」绘制形成鲜明对比,是计算机图形学的核心思想之一。
二、粒子模型:位置、速度、生命、颜色
interface Particle {
x: number; // 位置(画布坐标)
y: number;
vx: number; // 速度(每帧位移)
vy: number;
life: number; // 剩余生命(帧数)
maxLife: number; // 出生时生命(用于渐隐比例)
size: number; // 半径
color: string; // 颜色
kind: string; // 'star' | 'rocket' | 'spark'
}
四个字段组解释一切:
- 位置 (x, y) + 速度 (vx, vy):每帧
x += vx; y += vy——粒子按速度移动,这就是「漂移」; - 生命 (life, maxLife):每帧
life--,归零即死;life/maxLife是「剩余比例」,用于渐隐透明度——生命值统一了「存在时长」与「淡出效果」; - 大小 (size):粒子半径,星星小、火花稍大;
- kind:粒子行为的运行时类型——同是这 7 个字段,star 慢漂、rocket 受重力、spark 阻尼衰减,行为差异由 kind 决定。
为什么不用继承:star/rocket/spark 的行为差异只是 update 里的几行分支,字段完全同构。用一个扁平结构 + kind 分支,比三个子类更简单直接——粒子是「同构数据的差异行为」,不是多态的教科书案例。
2.1 粒子字段的「设计推演」
如果你要自己设计粒子模型,可以从需求反推每个字段的必要性:
| 需求 | 需要的字段 | 理由 |
|---|---|---|
| 粒子要动 | x/y + vx/vy | 位置 + 速度是最小运动模型 |
| 粒子会消失 | life/maxLife | 剩余生命 + 出生生命(渐隐比例) |
| 粒子大小不一 | size | 半径 |
| 粒子颜色不同 | color | 星空单色、烟花多色 |
| 行为不同 | kind | 三种物理规则的运行时分发 |
每个字段都能追溯到一条需求——这是「数据模型先行」的证明。如果某个字段没有对应需求(比如 rotation 旋转),就不该出现在模型里;将来需要旋转再加,模型保持最小。
2.2 为什么 kind 用字符串而不是枚举
ArkTS 里 kind 可以用 'star' | 'rocket' | 'spark' 字面量联合(编译期检查),本应用用 string 是为了接口简洁。对比:
- 字面量联合:编译期检查拼写错误,但接口定义稍繁琐;
- string:灵活但运行时才报错。
两者都可接受,工程上建议字面量联合(防手误);本应用为了代码可读性用了 string + 注释说明取值。类型安全 vs 简洁的取舍没有绝对答案,取决于团队规范。
2.3 速度与位置的「积分」关系
粒子运动本质是数值积分:
每帧:位置 += 速度 (一阶积分)
速度 += 加速度 (重力、阻尼都在这一层)
- 匀速运动(star):速度不变,只积分位置;
- 匀加速(rocket):加速度固定(重力),速度线性增长;
- 阻尼(spark):速度乘系数,指数衰减。
**「位置由速度积分、速度由加速度积分」**是粒子物理的统一框架——三种 kind 只是加速度规则不同。理解这个框架,加新的粒子行为(如风、磁力)只需定义新的加速度规则。
三、帧循环:更新与绘制的严格分离
private startLoop(): void {
if (this.loopTimer !== -1) {
return; // 防重入
}
this.loopTimer = setInterval(() => {
this.update(); // 第一步:所有粒子按规则动一步
this.draw(); // 第二步:把当前状态画到画布
}, 33); // ~30fps
}
帧循环 = 「更新 + 绘制」两段式,每 33ms(约 30fps)执行一轮:
update():遍历粒子,按 kind 规则更新位置/速度/生命,剔除死亡粒子
draw():清屏(拖影)→ 逐粒子绘制
为什么两段分离:update 是「世界演进」、draw 是「画面快照」——先让整个世界走一步,再把世界画出来,中间不插入任何绘制,避免「画到一半世界变了」的撕裂。游戏引擎的经典主循环(tick → render)在 Canvas 里就是这两行。
四、更新规则:三种 kind 三种物理
if (p.kind === 'rocket') {
p.vy += 0.06; // 重力:速度每帧 +0.06(向下加速)
p.x += p.vx;
p.y += p.vy;
p.life--;
if (p.life <= 0) {
this.explode(p.x, p.y, null); // 寿命尽 → 爆炸
continue; // 火箭死亡
}
} else if (p.kind === 'spark') {
p.vx *= 0.985; // 阻尼:速度每帧衰减
p.vy = p.vy * 0.985 + 0.02; // 阻尼 + 轻微重力
p.x += p.vx;
p.y += p.vy;
p.life--;
} else {
p.x += p.vx; // 星星:匀速漂移
p.y += p.vy;
p.life--;
}
三种「物理」:
| kind | 物理模型 | 效果 |
|---|---|---|
| rocket | 重力累加(vy += 0.06) | 上升减速 → 顶点爆炸 |
| spark | 阻尼衰减(×0.985)+ 轻重力 | 火花四溅后缓缓坠落 |
| star | 匀速直线 | 星星安静漂移 |
阻尼 ×0.985:速度每帧保留 98.5%,指数衰减——火花喷出后「由快到慢」,是烟花「洒落」感的来源。乘性衰减是粒子系统的通用减速手段(比线性减速更自然)。
五、死亡与回收:数组过滤
const alive: Particle[] = [];
for (const p of this.particles) {
// …更新…
if (p.life > 0) {
alive.push(p);
}
}
this.particles = alive;
更新中死亡、更新后过滤:爆炸时火箭 continue(跳过存活判断直接死),普通粒子生命归零则不进新数组——一轮更新结束,粒子数组只含活体。数组重建比原地 splice 更不易出错(遍历中删元素是经典 bug 源)。
5.1 粒子「生命周期」的完整阶段
每个粒子从出生到消亡经历四个阶段,理解它就能看懂 update 的全部逻辑:
出生(发射/爆炸时创建) → 活跃(每帧更新位置/速度) → 濒死(life 接近 0,alpha 渐隐) → 死亡(life = 0,被过滤)
| 阶段 | 数据状态 | 视觉表现 |
|---|---|---|
| 出生 | life = maxLife | 完全可见 |
| 活跃 | life 递减 | 正常运动 |
| 濒死 | life/maxLife 接近 0 | 渐隐(alpha 变小) |
| 死亡 | life = 0 | 被数组过滤,消失 |
「濒死」阶段是粒子系统的精妙之处——死亡不是瞬间的,而是渐进的。生命值同时驱动「何时死」和「多透明」,粒子在消失前有一段优雅的淡出,观感远胜「啪地消失」。
5.2 为什么「更新中死亡」用 continue 而不是标记
火箭爆炸的代码里 continue 直接跳过存活判断——为什么火箭不需要走「过滤」流程?
因为火箭的「死亡」不是自然消亡,而是转化为爆炸:explode() 已经生成了 70 个火花粒子,火箭本身不再有存在意义。用 continue 跳过本粒子剩余的更新逻辑(包括存活判断),直接进入下一轮循环——死亡时的「转化动作」与「移除」一次完成,不需要额外的标记位。
对比普通粒子的死亡(生命归零后被动过滤),火箭的死亡是「主动转化」。两种死亡方式各有适用场景,理解它们的区别就能写出更清晰的 update。
六、性能保护:粒子上限
if (this.particles.length > MAX_PARTICLES) {
this.particles.splice(0, this.particles.length - MAX_PARTICLES);
}
上限 800:触摸连点引爆时粒子数可能暴涨(每次爆炸 +70),不设上限会拖垮绘制帧率甚至内存。丢弃最老的粒子——新粒子优先保留,视觉上「旧的让位给新的」,观感无损。
为什么 800:30fps × 800 粒子 = 每帧 800 次 arc + fill,现代设备轻松;再高会掉帧,再低烟花不够密。上限是「保帧率」的显式契约。
七、文章小结
本节完成了粒子系统的设计:粒子模型(位置/速度/生命/大小/颜色/kind)→ 帧循环(update + draw 两段式)→ 三种物理(重力/阻尼/匀速)→ 死亡过滤 → 上限保护。核心方法论是「粒子 = 同构数据的差异行为」与「每帧:先更新世界,再画世界」。
下一节实现星空漫游:初始撒星、漂移闪烁与触摸星团。
八、补充:FAQ
Q1:为什么用 setInterval 33ms 而不是 16ms(60fps)?
粒子场景每帧要画几百个圆,60fps 需要每帧 ≤16ms,压力大且耗电;30fps(33ms)视觉上已足够流畅(粒子运动缓慢),省一半 CPU。帧率 = 画面复杂度与功耗的平衡点,30fps 是粒子特效的甜点值。
Q2:为什么粒子用数组而不是 Map/对象池?
本应用粒子数最多 800,数组遍历 + 过滤的开销可忽略。对象池(复用死亡对象)能减少 GC 压力,但引入复杂度;800 粒子的规模不值得。优化要配得上规模。
Q3:生命值用「帧数」而不是「秒」?
帧数简单直接:life-- 即时间流逝。若想与真实时间挂钩(比如爆炸后 2 秒消失),把 life 换成「剩余毫秒」,update 里减去帧间隔——本应用从简用帧数,效果等效。
九、深入:粒子系统与前四个应用的本质差异
粒子特效是系列的「最终关卡」,它的技术内核与前面四个应用有本质不同,理解这些差异才能看懂 5-2~5-4 的实现。
9.1 从「几十个元素」到「几百上千个元素」
| 应用 | 元素数量 | 绘制方式 |
|---|---|---|
| 签名板 | <100 笔 | 逐笔路径 |
| 涂鸦画板 | <1000 笔 | 逐笔重放 |
| 数据图表 | <30 个图形 | 逐元素绘制 |
| 时钟仪表 | ~80 个命令 | 逐元素绘制 |
| 粒子特效 | 800 粒子 | 逐粒子 arc |
粒子应用第一次把「每帧遍历所有元素」的量级推到百位数以上。虽然 800 在现代设备上依然轻松,但量级的变化带来了思维的转变:从「每个元素值得精心设计」到「每个粒子必须廉价绘制」——arc + fill 是你能用的最便宜的绘制原语之一。
9.2 从「记录型」到「计算型」的数据
前四个应用的粒子数据都是「记录」:笔迹是用户画的历史、图表是业务数据、时钟是系统时间。粒子应用的数据是「计算」:每个粒子的位置由速度积分而来、速度由物理规则(重力/阻尼)演化而来——数据不是被记录的,而是被算出来的。
这带来一个关键推论:粒子数据本身没有「保存价值」。你不会把某一帧的 800 个粒子序列化保存,因为下一帧它们就变了。粒子的「数据生命周期」只有一帧——它存在的意义就是「让这一帧的画面正确」。
9.3 从「事件驱动」到「持续帧循环」
| 应用 | 驱动方式 | 每帧做什么 |
|---|---|---|
| 签名/涂鸦 | 触摸事件 | 改数据 + 重绘 |
| 图表 | 切 tab/点选 | 重绘当前图 |
| 时钟 | 250ms 计时器 | 重绘表盘 |
| 粒子 | 33ms 帧循环 | update 世界 + draw 世界 |
粒子是第一个「update 与 draw 分离」的应用——前面四个应用的「重绘」直接画数据,粒子的「重绘」前要先让数据演进一帧。这个「先更新世界,再画世界」的模式是游戏引擎的主循环,也是粒子应用与前面所有应用的架构分水岭。
9.4 为什么粒子用「数组」而不是「类 + 继承」
面向对象新手可能觉得 star/rocket/spark 应该是三个类。本应用用一个扁平接口 + kind 分支,理由:
- 字段完全同构:三个类型都是「位置/速度/生命/大小/颜色」,只是 update 行为不同;
- 分支代价极低:update 里一个 if/else 链,远小于三个类的继承层级;
- 数组处理统一:遍历、过滤、上限裁剪都是「按 Particle[] 处理」,不需要类型分发。
判断是否该用继承的标准:字段和行为的差异是否足够大。粒子系统的差异只是「几行物理公式」,用扁平结构 + kind 分支是更优解——这也是真实游戏引擎里 ECS(实体组件系统)的思路雏形。
十、设计决策复盘
| 决策点 | 选择 | 理由 |
|---|---|---|
| 数据结构 | 扁平接口 + kind | 字段同构、分支廉价 |
| 帧率 | 33ms(30fps) | 慢速运动够流畅,省一半 CPU |
| 更新策略 | update/draw 分离 | 避免「画到一半世界变了」 |
| 死亡处理 | 数组重建过滤 | 比 splice 不易错 |
| 上限保护 | 800 + 丢最老 | 守住帧率契约 |
「update/draw 分离」和「上限保护」是本应用最核心的两个架构决策——前者保证画面一致性,后者保证性能底线。它们在 5-2~5-4 里反复出现,是理解粒子应用的钥匙。
十一、动手练习
- 改粒子数:把 MAX_PARTICLES 改成 200 / 3000,观察帧率与画面密度的关系,找到你设备的性能临界点;
- 改帧率:把 33ms 改成 16ms / 66ms,观察运动平滑度的变化,验证「慢速运动低帧率够用」;
- 改阻尼系数:把 0.985 改成 0.99 / 0.9,观察火花「飘得远」与「快速停住」的差异;
- 改重力系数:把火箭重力 0.06 改成 0.02 / 0.15,观察火箭「飞得高」与「飞不起来」的变化;
- 数组重建实验:把死亡过滤改成「遍历中 splice」,观察偶发的「漏粒子」或「错位」——验证「遍历中删元素是经典 bug 源」。
十二、本篇知识地图
把 5-1 的概念整理成「粒子应用的总览图」,作为 5-2~5-5 的导航:
┌───────────────────────────────────────────────┐
│ 粒子模型(7 字段) │
│ x/y 位置 · vx/vy 速度 · life/maxLife 生命 │
│ size 大小 · color 颜色 · kind 类型 │
├───────────────────────────────────────────────┤
│ 帧循环(33ms) │
│ update:位置+=速度,速度+=加速度,life--,过滤 │
│ draw:拖影清屏 → 逐粒子 alpha 绘制 │
├───────────────────────────────────────────────┤
│ 三种物理规则(kind 分发) │
│ star 匀速 · rocket 重力 · spark 阻尼+轻重力 │
├───────────────────────────────────────────────┤
│ 性能契约 │
│ MAX 800 · 30fps · 拖影 rgba 0.35 │
└───────────────────────────────────────────────┘
四层结构从「数据定义」到「世界演进」到「行为规则」到「性能底线」,正好对应 5-2(星空)、5-3(烟花)、5-4(性能)、5-5(收尾)四篇的内容。带着这张地图进入后续篇章,每个实现细节都能在模型里找到位置。
星空漫游:随机撒星、漂移闪烁与触摸星团
一、目标
星空模式要呈现:漫天星星缓缓漂移 + 每颗星独自闪烁 + 触摸生成一小团新星。技术点:随机撒星、漂移速度、正弦闪烁、触摸生成。
1.1 星空是最简单的「氛围粒子」
粒子系统的第一个模式选「星空」是刻意的:星星的物理最简单(匀速直线),视觉要求最高(宁静、自然、有氛围)——它让学习者先掌握「粒子如何出生、如何运动、如何消亡」的完整循环,再进入烟花的复杂物理(重力 + 爆炸)。先简单物理 + 高视觉要求,再复杂物理 + 高视觉回报,是粒子学习的合理梯度。
1.2 星空效果的三层构成
星空不是「一堆点」,而是三个视觉层叠加:
| 层 | 内容 | 实现 |
|---|---|---|
| 运动层 | 星星漂移 | 匀速直线 + 区间速度 |
| 生命层 | 闪烁 + 消亡 | sin 相位 + 渐隐 |
| 交互层 | 触摸星团 | 局部随机生成 |
三层互不干扰:运动由速度决定、闪烁由生命相位决定、触摸只负责「注入新粒子」。这种「分层解耦」是粒子系统的核心组织方式——每个视觉元素只依赖自己的数据源,新增效果只需加一层。
1.3 与烟花模式的「同一套引擎」
星空和烟花共享同一个帧循环、同一个 Particle 接口、同一套 update/draw 架构——区别只在:
- 出生方式:星空是「初始撒 50 颗 + 触摸补团」,烟花是「自动发射 + 触摸引爆」;
- 物理规则:星星匀速,火箭重力、火花阻尼;
- 绘制方式:星星闪烁 alpha,火花渐隐 alpha。
同一引擎、两套剧本——这正是 5-1 篇「粒子 = 同构数据的差异行为」的具体化。切换模式时只需要替换「出生函数」和「update 分支」,帧循环完全不动。
二、初始撒星:50 颗随机星
private seedStars(count: number): void {
for (let i = 0; i < count; i++) {
const p: Particle = {
x: Math.random() * this.canvasW, // 随机横坐标
y: Math.random() * this.canvasH, // 随机纵坐标
vx: -0.15 - Math.random() * 0.2, // 向左慢漂(负值)
vy: -0.05 - Math.random() * 0.1, // 向上微漂
life: 200 + Math.random() * 200,
maxLife: 400,
size: 0.8 + Math.random() * 1.8, // 大小不一
color: '#E2E8F0',
kind: 'star'
};
this.particles.push(p);
}
this.particleCount = this.particles.length;
}
「自然感」来自随机范围:
- 位置全画布随机:
random() × 宽/高——星空要「铺满」,不能集中; - 速度带区间:
-0.15 - random()*0.2是 [-0.35, -0.15]——所有星都向左上漂,但快慢不同(同一方向、不同速率,像透过车窗看远方星星,有视差感); - 大小带区间:
0.8 + random()*1.8→ [0.8, 2.6]——大小不一才有层次; - 生命带区间:
200 + random()*200→ [200, 400] 帧——星星不是永生的,寿命不同导致淡出时间错开,避免「集体消失」的整齐感。
随机范围的学问:全区间随机是「噪声」,区间随机(最小值 + 随机余量)是「有组织的自然」。星空、雪景、树叶这类自然效果全靠区间随机。
三、漂移:匀速直线 + 边界消失
} else {
p.x += p.vx; // 星星:匀速直线
p.y += p.vy;
p.life--;
}
漂移是帧循环的最小物理:每帧位置 += 速度。星星向左上缓慢移动,寿命耗尽逐渐淡出(见绘制层 alpha 逻辑)。
为什么不循环出现(出界从另一边回来):星空是「一次性布景」,星星漂出边界就让它自然消失——生命周期天然把「边界管理」交给 life 处理,无需坐标越界判断。简单场景用生命值管理寿命,胜过边界重定位。
四、闪烁:正弦函数的呼吸感
if (p.kind === 'star') {
ctx.globalAlpha = 0.3 + 0.7 * Math.abs(Math.sin(p.life * 0.06));
}
闪烁公式拆解:
sin(p.life * 0.06):生命值随时间递减,sin 在 [-1, 1] 间振荡——相位随生命变化 = 亮度随时间变化;Math.abs(...):取绝对值把 [-1,1] 折叠成 [0,1],负半周也变亮——避免亮度跌到负数,且频率翻倍;0.3 + 0.7 × ...:把 [0,1] 映射到 [0.3, 1.0]——亮度下限 0.3,星星永不「全灭」,始终是星星。
为什么用 sin 不用 random:random 是离散跳变(闪得像坏灯泡),sin 是连续振荡(呼吸感)——正弦是「平滑周期变化」的万能工具,不只亮度,波浪、心跳、加载动画全是它。
为什么用生命值当相位:每颗星的 life 起点不同(200~400),相位天然错开——无需额外计时器,每颗星自带独立相位,这正是「每颗星独自闪烁」的来源。
五、触摸星团:局部爆发
} else {
// 星空模式:生成一小团星
for (let i = 0; i < 25; i++) {
const p: Particle = {
x: t.x + (Math.random() - 0.5) * 30, // 触摸点 ±15vp
y: t.y + (Math.random() - 0.5) * 30,
vx: (Math.random() - 0.5) * 1.2, // 向四周轻微散开
vy: (Math.random() - 0.5) * 1.2,
life: 120 + Math.random() * 120,
maxLife: 240,
size: 0.8 + Math.random() * 1.6,
color: '#E2E8F0',
kind: 'star'
};
this.particles.push(p);
}
}
星团 = 触摸点为中心的随机分布:
- 位置以触摸点为中心:
t.x + (random-0.5)*30——±15vp 的散落范围,成一团; - 速度向四周:
(random-0.5)*1.2——每颗星往随机方向缓慢散开,团逐渐「化开」; - 寿命较短:120~240 帧(比初始星短)——用户点的星团是「即兴烟火」,不常驻。
注意触摸坐标:t.x/t.y 相对画布左上角,与粒子坐标同系——触摸坐标直接可用,无需换算(与签名板同款约定)。
六、绘制:拖影与闪烁的叠加
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)'; // 半透明清屏(拖影)
ctx.fillRect(0, 0, this.canvasW, this.canvasH);
for (const p of this.particles) {
if (p.kind === 'star') {
ctx.globalAlpha = 0.3 + 0.7 * Math.abs(Math.sin(p.life * 0.06));
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.fill();
}
拖影原理:不清屏而是「盖一层 35% 不透明的背景色」——旧帧只淡化 65% 而非消失,星星移动时留下淡淡尾迹。半透明清屏 = 时间上的模糊,是粒子视觉的高级感来源。
alpha 逐粒子设置:每颗星画前设置自己的 globalAlpha,画完恢复 1——globalAlpha 是全局状态,用后必须复位(与合成模式、setLineDash 同纪律)。
6.1 星空绘制管线的「全量重绘」代价
星空模式每帧的绘制量:
- 拖影清屏:1 次全画布 fillRect;
- 逐星绘制:N 颗星 ×(设置 alpha + arc + fill)≈ N × 3 个命令;
- 50 颗初始星 + 触摸星团 ≈ 百级命令/帧。
这个量级在 30fps 下毫无压力(微秒级)。但如果星星数涨到 5000+,每帧 15000 个命令就会逼近帧预算——这就是 5-4 篇「粒子上限」要解决的问题。星空的绘制量是「可预测的线性增长」,上限保护让它永远可控。
6.2 为什么星空用「画圆」而不是「画点」
Canvas 画「点」有三种方式:
| 方式 | 实现 | 效果 | 开销 |
|---|---|---|---|
| arc + fill | 真圆 | 可设半径、边缘平滑 | 中 |
| fillRect 小方块 | 2×2 矩形 | 远看像点,近看发方 | 低 |
| fillText 字符 | 画「·」 | 依赖字体 | 中 |
本应用用 arc + fill——星星需要大小不一(0.8~2.6vp)且平滑,arc 最合适。若星星数量极大(万级),可降级为 fillRect(更快的像素填充),是 5-4 篇性能优化的选项之一。
6.3 拖影的「时间积分」直觉
拖影(半透明清屏)本质是对时间做低通滤波:
当前帧画面 = 上一帧画面 × 65% + 本帧粒子 × 100%
旧帧只保留 65%,逐帧衰减(0.65^n),星星的尾迹以指数方式淡出。这个「时间上的模糊」让运动轨迹可见——拖影 = 时间的可视化。alpha 越小衰减越快、拖影越短;越大拖影越长。0.35 是「能看见轨迹但不过度糊」的平衡点。
七、文章小结
本节实现了星空模式:随机撒星(区间随机:位置铺满/速度带差/大小不一/寿命错开)→ 匀速漂移(帧循环最简物理)→ 正弦闪烁(sin 折叠映射 + 生命作相位)→ 触摸星团(中心分布 + 散开速度)→ 拖影绘制(半透明清屏)。核心技巧是「区间随机制造自然感」与「sin + 生命相位 = 独立闪烁」。
下一节实现烟花绽放:火箭升空、爆炸迸发与阻尼衰减。
八、补充:FAQ
Q1:星星漂出边界就消失,会不会很快变空?
会缓慢变少,但触摸随时能补星团,切模式也会重新撒 50 颗。若想要「永恒星空」,可让星星出界后重生到对侧(边界回卷),是 5-4 篇的性能讨论之外的一个小扩展。
Q2:闪烁频率可以调吗?
可以。sin(p.life * 0.06) 的 0.06 是角速度——调大闪烁更快、调小更慢。0.06 × 每帧 33ms ≈ 每秒 3.2 次呼吸,是「宁静星空」的合适频率。
Q3:为什么星团不加颜色变化?
星空保持单色(E2E8F0 冷白)更真实宁静;色彩留给烟花模式(FIRE_COLORS 六色板),两模式各司其职,观感对比更强。
九、深入:星空模式的「氛围感」从何而来
星空模式的效果看起来「宁静美好」,但它的代码只有几十行。拆解「氛围感」的来源,你能学到通用视觉设计的技巧。
9.1 四个「自然感」的来源
| 来源 | 实现 | 效果 |
|---|---|---|
| 区间随机 | 位置/速度/大小/寿命都带区间 | 避免「整齐划一」的机械感 |
| 慢速漂移 | 速度 0.15~0.35 | 宁静而非急躁 |
| 独立闪烁 | sin + 生命相位 | 每颗星有自己的呼吸节奏 |
| 半透明拖影 | rgba 0.35 清屏 | 星星移动留下柔和余辉 |
「自然感 = 有组织的随机 + 克制的运动 + 独立的生命」——这三个要素不仅适用于星空,任何「模拟自然」的粒子效果(雪花、萤火虫、气泡)都靠它们。
9.2 为什么星星向左上漂而不是随机方向
星空模式所有星星都向左上漂(vx 负、vy 负),这是刻意的设计:
- 方向一致性:所有星星同一方向 = 「镜头在移动」的视差感——像透过车窗看星空;
- 方向差异在速度:快慢不同(0.15~0.35 区间)让近处星「走得快」、远处星「走得慢」——视差 = 同方向不同速度;
- 屏幕利用率:向左上漂意味着新星从右下补入(触摸生成),画面持续有「新内容进入」。
如果随机方向,星星会互相穿过、画面显得杂乱——星空需要的是「秩序感」而非「混乱感」。
9.3 sin 闪烁的「频率设计」
sin(p.life * 0.06) 的频率由系数 0.06 决定:
闪烁周期 = 2π / 0.06 ≈ 105 帧 ≈ 3.5 秒
即每颗星约 3.5 秒完成一次「亮-暗-亮」的呼吸。为什么这个频率合适?
- 太快(系数 0.3):星星「闪个不停」,像坏灯泡,刺眼;
- 太慢(系数 0.01):几乎看不出闪烁,星空「死寂」;
- 3.5 秒一次:肉眼可感知但不打扰,是「宁静星空」的甜点值。
视觉参数的选择 = 感知实验的结论——0.06 不是算出来的,是「试出来」的。这再次说明绘制参数需要反复调优。
9.4 星空模式的「边界哲学」
星星漂出边界就消失(不重生),这与许多粒子 Demo 的「边界回卷」不同。两种策略的对比:
| 策略 | 实现 | 适用 |
|---|---|---|
| 自然消亡(本应用) | 寿命耗尽即死 | 一次性布景、触摸补充 |
| 边界回卷 | 出界从对侧重生 | 永恒星空、漫游背景 |
本应用选「自然消亡」是因为:星空模式有触摸补充 + 切模式重置,不需要永恒感;且「寿命管理」比「边界判断」更简单(一行 life-- 完事)。先选最简方案,需要时再升级是贯穿系列的工程原则。
十、动手练习
- 方向实验:把星星速度改成向右下(vx 正、vy 正),观察「镜头反向移动」的视觉差异;
- 视差实验:把速度区间拉大(0.05~0.8),观察远近星的「视差感」增强;
- 频率实验:把 0.06 改成 0.15 / 0.02,感受闪烁快慢对「宁静感」的破坏与恢复;
- 亮度下限实验:把
0.3 + 0.7×改成0.8 + 0.2×,观察星星「永远很亮」失去呼吸感; - 边界回卷实验:给星星加「出界重生到对侧」逻辑,观察永恒星空的效果,对比两种策略的取舍。
十一、本篇知识点串联
| 知识点 | 实现 | 一句话理解 |
|---|---|---|
| 区间随机 | min + random×range | 有组织的自然感 |
| 匀速漂移 | 位置 += 速度 | 帧循环最简物理 |
| 正弦闪烁 | sin + abs + 映射 | 平滑周期变化 |
| 生命作相位 | sin(life × 系数) | 每颗星独立闪烁 |
| 触摸星团 | 中心 ± 随机 | 局部爆发 |
| 拖影清屏 | rgba 半透明 fillRect | 时间上的模糊 |
下一节实现烟花绽放:火箭升空、爆炸迸发与阻尼衰减。
十二、常见错误排查指南
星空模式代码不长,但运行时容易踩这几类坑:
12.1 「星星不动」或「画面静止」
排查顺序:
- 确认
startLoop()被调用(aboutToAppear 里)——漏调则没有帧循环; - 确认
loopTimer初始为 -1(防重入守卫不误拦); - 确认 update 里真的执行了
p.x += p.vx——如果只 push 粒子不更新,星星当然不动; - 确认没有被误暂停(toggleRun 把 running 置 false 且清了计时器)。
12.2 「星星不闪烁」或「亮度恒定」
排查:
- 确认 star 分支设置了
ctx.globalAlpha(星星没有自己的 alpha 就会用上一个粒子的值); - 确认
sin(p.life * 0.06)的 life 确实在递减(如果忘了life--,sin 相位恒定,亮度不变); - 确认 alpha 用后复位为 1(否则下一帧所有粒子都继承上一个星星的 alpha)。
12.3 「拖影变脏」或「画面越来越糊」
排查:
- 确认拖影色
rgba(15, 23, 42, 0.35)的 RGB 与背景色#0F172A一致——不一致会残留色差; - 确认「清空/切模式」用的是 clearRect 彻底清屏而非拖影清屏——残留旧帧会让「清空」后还有暗影。
12.4 「触摸没反应」或「星团不出现」
排查:
- 确认 Canvas 绑定了 onTouch(漏绑则触摸不触发);
- 确认 handleTouch 里 mode === 0 分支正确(星空模式下才生成星团);
- 确认粒子被 push 进 particles 数组(如果 push 了但 update 立即把它们过滤掉,可能是 life 初始为 0)。
12.5 「粒子数暴涨卡顿」
排查:
- 确认触摸只响应 Down(Move 会高频触发);
- 确认 MAX_PARTICLES 上限保护在 update 末尾执行(粒子数超限立即裁剪);
- 用标题栏的 particleCount 观察粒子数是否被限制在 800 以内。
十三、本篇核心结论一句话
星空模式的全部秘密是两句话:「区间随机制造自然感」(位置铺满、速度带差、大小不一、寿命错开,让星星看起来「天然」)与 「sin + 生命相位 = 独立闪烁」(用每颗星自己的生命值做正弦相位,无需额外计时器,每颗星就拥有独立的呼吸节奏)。加上拖影的半透明清屏,几十行代码就渲染出了「宁静星空」的氛围——这是粒子系统「用简单规则涌现复杂效果」的第一个实例,也是烟花模式的前奏。
烟花绽放:火箭升空、爆炸迸发与阻尼衰减
一、目标
烟花模式的完整链条:火箭从底部自动升空(约每秒一枚)→ 升到顶点爆炸 → 70 粒火花向四周迸发 → 阻尼衰减 + 重力,火花缓缓洒落。触摸画布任意位置 = 立即引爆。
1.1 烟花是最复杂的「物理粒子」
与星空(匀速直线)相比,烟花的物理复杂度显著提升:
| 阶段 | 物理模型 | 对应星空 |
|---|---|---|
| 火箭升空 | 抛体运动(重力) | 无(星空无重力) |
| 爆炸迸发 | 径向速度(极坐标) | 触摸星团(近似) |
| 火花洒落 | 阻尼 + 重力 | 无(星空只漂移) |
烟花把「重力」「阻尼」「极坐标」三个物理概念集于一身,是粒子系统的「物理集大成者」。学会烟花,你就掌握了粒子物理的绝大部分手段——之后加任何效果(雪花、泡泡、火焰)都只是这些手段的组合。
1.2 烟花的三段式生命周期
一个完整的烟花经历三个阶段,每个阶段由不同的粒子类型承载:
阶段一:火箭(kind='rocket')—— 升空,重力对抗初速度
阶段二:爆炸(火箭 life=0 触发 explode)—— 生成 70 粒火花
阶段三:火花(kind='spark')—— 阻尼衰减 + 重力洒落
每个阶段是上一个阶段的「产物」:火箭死亡 → 生成火花。这种「粒子生成粒子」的链式结构是粒子系统的核心模式——一个粒子的消亡可以触发一批新粒子的诞生(烟花、爆炸、分裂都是它)。
1.3 与触摸交互的结合
烟花的「触摸引爆」与火箭自动爆炸复用同一个 explode 函数——用户的手与自动逻辑走同一条代码路径,这是「交互 = 注入」思想的体现:触摸不是打断系统,而是往系统里加一个「即时爆炸」事件。
二、自动发射:帧计数器
if (this.mode === 1) {
this.autoCount++;
if (this.autoCount % 30 === 0) { // 30 帧 ≈ 1 秒
this.launchRocket();
}
}
帧计数器替代独立计时器:在已有帧循环里数帧,每 30 帧发射一枚——不新增计时器,用「帧计数取模」制造周期事件,与粒子世界同一时间轴,天然同步。
三、火箭:重力加速度
private launchRocket(): void {
const x = 30 + Math.random() * (this.canvasW - 60); // 随机横坐标
const p: Particle = {
x: x, y: this.canvasH, // 从底部出发
vx: (Math.random() - 0.5) * 0.8, // 轻微水平偏移
vy: -(3 + Math.random() * 2.5), // 向上初速度(负值)
life: 60,
maxLife: 60,
size: 2.5,
color: '#FDE047', // 亮黄火箭
kind: 'rocket'
};
this.particles.push(p);
}
火箭更新的重力模型:
p.vy += 0.06; // 每帧重力加速度 +0.06(向下)
p.x += p.vx;
p.y += p.vy;
p.life--;
if (p.life <= 0) {
this.explode(p.x, p.y, null); // 寿命尽 → 爆炸
continue;
}
重力 = 速度每帧变化:vy 初值 -3~-5.5(向上),每帧 +0.06 逐渐归零再转正——火箭「上升渐慢 → 顶点悬停 → 下落」。初速度抵消重力的抛物线,与真实弹道同构。
爆炸时机 = 生命耗尽:设定 life=60(约 2 秒),火箭飞到一半高时爆炸;爆炸在火箭当前坐标进行(p.x, p.y 已是更新后的位置),位置自然随随机 x 而变化——每枚烟花在屏幕不同位置绽放。
四、爆炸:70 粒径向火花
private explode(x: number, y: number, fixedColor: string | null): void {
const n = 70;
const base = fixedColor ?? FIRE_COLORS[Math.floor(Math.random() * FIRE_COLORS.length)];
for (let i = 0; i < n; i++) {
const angle = Math.random() * Math.PI * 2; // 全方向随机角度
const speed = 1 + Math.random() * 4.5; // 随机速度
const p: Particle = {
x: x, y: y,
vx: Math.cos(angle) * speed, // 极坐标 → 直角速度
vy: Math.sin(angle) * speed,
life: 40 + Math.random() * 40,
maxLife: 80,
size: 1.5 + Math.random() * 1.8,
color: base,
kind: 'spark'
};
this.particles.push(p);
}
}
径向迸发的数学:vx = cos(angle)×speed, vy = sin(angle)×speed——极坐标(角度 + 速度)→ 直角坐标(vx, vy)。角度随机取全圆 [0, 2π],速度随机 [1, 5.5],火花向所有方向喷出,且快慢不一——爆炸的「球形」就出来了。
整团同色:爆炸 70 粒火花用同一个随机色(六色板抽一),每枚烟花是单色圆球——比每粒乱色更「像烟花」(真实烟花就是单色球)。随机在「爆炸级」而非「粒子级」,这是美学决策。
五、火花:阻尼 + 重力
p.vx *= 0.985; // 速度每帧衰减 1.5%
p.vy = p.vy * 0.985 + 0.02; // 衰减 + 轻重力
p.x += p.vx;
p.y += p.vy;
p.life--;
阻尼与重力的分工:
- 阻尼(×0.985):火花飞出后逐渐减速——模拟空气阻力,火花「洒开→停住」;
- 重力(+0.02):火花最终向下坠落——模拟地心引力,火花「停留后下坠」。
两者叠加的效果:爆炸瞬间火花四射 → 速度衰减、轨迹弯曲 → 顶部火花「停一下再落」、底部火花直接坠地——这是烟花最迷人的「开伞」过程,全由两个系数(0.985 与 0.02)调制。
调参直觉:阻尼越接近 1(如 0.99)火花飘得越远越久;重力越大火花坠得越快。0.985 / 0.02 是「洒落感」与「坠落感」的平衡点。
六、触摸引爆:explode 的复用
if (this.mode === 1) {
this.explode(t.x, t.y, null); // 点哪炸哪
}
触摸引爆 = 直接调用 explode——爆炸逻辑与火箭触发的爆炸完全同一函数,只是位置来自手指而非火箭。一次实现、两处复用(火箭爆炸 + 触摸爆炸),这就是把「爆炸」抽象成独立函数的收益。
七、绘制:按生命渐隐
} else {
ctx.globalAlpha = Math.max(0, p.life / p.maxLife); // 剩余生命比例
}
火花渐隐:life/maxLife 从 1 渐减到 0——火花越接近消亡越透明,消失在背景里而非「啪地消失」。生命值同时驱动位置(存在性)与视觉(透明度),粒子系统的自洽之美。
7.1 火箭的「可见性」设计
火箭本身是一个 2.5vp 的亮黄点,在深色背景上清晰可见。但它运动速度快(vy 初始 -3~-5.5),如果不做任何处理会显得「一晃而过」。本应用靠两件事弥补:
- 拖影:半透明清屏让火箭升空的轨迹留下淡痕,视觉上「有尾迹」;
- 帧间隔小:33ms 一帧,火箭每帧位移约 0.1~0.2vp(vy 初始值的 1/33),运动平滑。
如果要更「像火箭」,可以每帧在火箭尾部发射小粒子(尾焰),但粒子量会翻倍(每秒 30 帧 × 每帧 2 颗 = 每分钟 3600 颗尾焰粒子,撞上 800 上限)。本应用用拖影模拟尾迹,是「性价比」的选择。
7.2 烟花绘制的「alpha 策略」对比
星空用 sin 闪烁 alpha,烟花用生命比例渐隐 alpha——两种策略对比:
| 模式 | alpha 公式 | 效果 |
|---|---|---|
| 星空 | 0.3 + 0.7×|sin(life×0.06)| | 周期性呼吸 |
| 烟花 | max(0, life/maxLife) | 单调递减渐隐 |
- 星空是「循环」效果(闪烁永不停),需要周期性 alpha;
- 烟花是「一次性」效果(绽放后消亡),需要单调衰减 alpha。
alpha 策略与粒子生命周期匹配——循环粒子用振荡,一次性粒子用递减。这个「alpha 驱动模式」的直觉可以推广到任何「生命周期可视化」场景。
7.3 火箭与火花共用 draw 路径
draw() 里火箭和火花都走同一个「arc + fill」绘制,只是 alpha 分支不同:
ctx.globalAlpha = p.kind === 'star'
? 0.3 + 0.7 * Math.abs(Math.sin(p.life * 0.06))
: Math.max(0, p.life / p.maxLife);
- star 走闪烁分支;
- rocket 和 spark 都走渐隐分支(火箭其实也用渐隐,虽然 life=60 只存在 2 秒,渐隐不显著)。
一个 alpha 表达式、三种粒子的视觉效果——绘制层的统一让新增粒子类型只需要在 update 里加规则,draw 几乎不用改。
八、文章小结
本节实现了烟花模式:帧计数自动发射 → 重力火箭(vy 累加 + 寿命耗尽爆炸)→ 径向迸发(极坐标转直角速度 + 爆炸级随机色)→ 阻尼衰减 + 重力洒落 → 触摸直接复用 explode。核心链条是「发射 → 升空 → 爆炸 → 洒落」四个阶段,分别由四种物理规则驱动,全部收在粒子模型的 7 个字段里。
下一节处理触摸交互与性能优化:帧率控制、粒子上限与拖影的取舍。
九、补充:FAQ
Q1:火箭为什么不画尾焰?
本应用从简——火箭是 2.5vp 的黄点,尾焰需要额外每帧发射尾随粒子(尾部火花),粒子量翻倍。真实产品会加,这里用「拖影」间接模拟了尾迹效果,性价比更高。
Q2:爆炸半径受什么控制?speed = 1 + random()*4.5 的上限 5.5 决定火花飞多远(5.5 × 阻尼积分 ≈ 上百像素)。调大 speed 上限烟花炸得更开,配合画布 420 高度,5.5 是「铺满不溢出」的合适值。
Q3:为什么火花也有重力,火箭也有?
物理一致:世界共享同一套重力概念。区别只在系数——火箭重力 0.06(明显,升空对抗)、火花重力 0.02(轻微,只影响坠落轨迹)。同一概念、不同强度,比两套物理模型更易维护。
十、深入:烟花的「弹道物理」拆解
烟花是五个应用里「物理规则最丰富」的效果,它的三个运动阶段对应三种物理模型,逐个拆解。
10.1 火箭:抛体运动的离散化
火箭的运动是标准的抛体运动(初速度 + 恒定重力),用离散帧模拟:
每帧:vy += 0.06(重力加速度)
y += vy(位置积分)
- 初始 vy = -3~-5.5(向上),重力每帧 +0.06 抵消向上的速度;
- 约 50~90 帧后 vy 归零(顶点),之后 vy 变正(下落);
- 但火箭在 vy 归零前(life=60)就爆炸了——爆炸点在上升段,让烟花在空中绽放而非落地。
这个「爆炸时机早于顶点」的设计很关键:如果等火箭自然落回,爆炸就发生在地面附近,毫无观赏性。用寿命控制爆炸时机,比用高度判断更简单——life = 60 是「飞行约 2 秒后爆炸」的简化表达。
10.2 火花:阻尼 + 重力的「开伞」过程
火花是烟花最迷人的部分,它的运动由两个系数调制:
| 系数 | 作用 | 效果 |
|---|---|---|
| 阻尼 0.985 | 速度每帧衰减 1.5% | 火花「由快转慢」 |
| 重力 0.02 | 每帧向下加速 0.02 | 火花最终坠落 |
阻尼让火花在爆炸瞬间「迸发」(初速度 1~5.5),随后速度指数衰减——轨迹从直线变成弧线;重力让弧线向下弯——火花「洒开 → 减速 → 坠落」。阻尼决定「开伞」的蓬松度,重力决定「收伞」的坠落感,两者配合就是烟花最经典的「球形渐散」画面。
10.3 为什么阻尼用乘法而不是减法
阻尼有两种实现:
乘性(本应用):v *= 0.985 → 指数衰减,快→慢,永不为负
减性: v -= 0.02 → 线性衰减,可能变负(反向运动)
乘性衰减的优越性:速度按比例缩小,永不越过零点(不会「反向飞行」);且衰减率与速度成正比(快的时候减速快,慢的时候减速慢),符合空气阻力的物理直觉。减性衰减需要额外判断「速度是否已经归零」,实现繁琐还容易出错。粒子系统的减速一律用乘性阻尼,这是惯例。
10.4 爆炸的「随机分层」
explode 里有三个随机量,各自的分层语义值得梳理:
| 随机量 | 范围 | 控制什么 |
|---|---|---|
| 角度 | [0, 2π) | 方向(全向均匀) |
| 速度 | [1, 5.5] | 距离(远近不一) |
| 寿命 | [40, 80] | 消散时间(先后不一) |
- 角度随机保证火花覆盖整个圆(球形爆炸);
- 速度随机让火花有的飞得远、有的近(蓬松感);
- 寿命随机让火花有的先消失、有的后消失(自然消散)。
三个随机量分别控制「方向」「距离」「时间」——这是「随机分层的艺术」:不随机则机械,全随机则混乱,分维度随机则自然。
十一、动手练习
- 初速度实验:把火箭 vy 从
-(3 + random×2.5)改成-(5 + random×3),观察火箭飞得更高、爆炸点更高; - 阻尼实验:把 0.985 改成 0.97 / 0.999,观察火花「快速收拢」与「长时间飘散」的差异;
- 火花数实验:把 explode 的 n 从 70 改成 30 / 150,观察爆炸的密度与帧率的关系;
- 颜色实验:往 FIRE_COLORS 加金色
#FDE047和银色#C0C0C0,观察双色烟花的观感; - 爆炸时机实验:把火箭 life 从 60 改成 30 / 100,观察爆炸发生在「低空」与「高空」的效果差异。
十二、本篇知识点串联
| 知识点 | 实现 | 一句话理解 |
|---|---|---|
| 帧计数发射 | autoCount % 30 | 周期事件不新增计时器 |
| 重力 | vy += 0.06 | 速度每帧变化 |
| 抛体运动 | 初速度 + 重力积分 | 上升减速 → 顶点 → 下落 |
| 径向迸发 | cos/sin × speed | 极坐标转直角速度 |
| 阻尼 | v ×= 0.985 | 指数衰减,永不为负 |
| 爆炸级随机色 | 整团同色 | 随机在爆炸级而非粒子级 |
下一节处理触摸交互与性能优化:帧率控制、粒子上限与拖影的取舍。
十三、常见错误排查指南
烟花模式是粒子系统里最容易出「物理诡异」的模式,附排查思路:
13.1 「火箭不升空」或「原地爆炸」
排查:
- 确认火箭初速度 vy 是负值(向上)——若写成正值会「向下射」然后爆炸;
- 确认 update 里 rocket 分支的
p.vy += 0.06与p.y += p.vy顺序正确——先加速度后积分位置; - 确认
launchRocket在 autoCount % 30 时被调用(帧计数逻辑正确)。
13.2 「爆炸不出火花」或「火花静止」
排查:
- 确认
explode里循环生成 70 个火花并 push 进 particles; - 确认火花初速度非零(
cos(angle)×speed中 speed 最小 1,不会为 0); - 确认 update 里 spark 分支的
p.x += p.vx执行——如果 update 只更新 rocket 分支,火花不会动。
13.3 「火花乱飞」或「飞出屏幕」
排查:
- 阻尼系数 0.985 是否正确——若写成 1 则无阻尼,火花一直飞;
- 初速度上限 5.5 是否被调大(调大后火花飞得更远);
- 火花是否有上限保护(MAX_PARTICLES 800)——连点引爆可能瞬间超限。
13.4 「爆炸位置不对」或「在屏幕底部爆炸」
排查:
- 确认
explode(p.x, p.y, null)用的是更新后的坐标(p.y 已包含当帧位移); - 确认火箭 life 初始值合理(60 ≈ 2 秒)——太小会在底部爆炸;
- 确认 canvasH 正确(若为 0 则火箭从顶部「掉下来」)。
13.5 「触摸引爆没反应」
排查:
- 确认 handleTouch 里 mode === 1 分支调用了 explode;
- 确认触摸坐标 t.x/t.y 与粒子坐标同系(都相对画布左上角);
- 确认 Canvas 的 onTouch 绑定正确。
十四、本篇核心结论一句话
烟花的全部秘密是「三段生命周期、三种物理规则、一次函数复用」:火箭用重力对抗初速度升空,寿命耗尽在上升段触发爆炸;explode 用极坐标(角度 + 速度)生成 70 粒径向火花,整团取一个随机色;火花用乘性阻尼(×0.985)加轻重力(+0.02)洒落渐隐。触摸引爆与自动爆炸复用同一个 explode——一条代码路径、两种触发方式。掌握烟花,你就掌握了粒子物理的全部核心手段,任何自然效果(雪花、火焰、泡泡)都能在此基础上组合实现。
触摸交互与性能优化:帧率控制、粒子上限与拖影取舍
一、本篇目标
粒子场景是五个应用里最吃性能的:每帧几百次 arc + fill,还要保证触摸响应流畅。本节讲三件事:触摸引爆的交互设计、帧率与功耗的控制、粒子上限与拖影的取舍。
二、触摸交互:Down 即响应
private handleTouch(e: TouchEvent): void {
if (e.type !== TouchType.Down) {
return; // 只响应按下
}
const t = e.touches[0];
if (!t) {
return;
}
if (this.mode === 1) {
this.explode(t.x, t.y, null); // 烟花:点哪炸哪
} else {
// 星空:生成 25 颗星团(5-2 篇已述)
}
}
为什么只响应 Down 不响应 Move:粒子场景的触摸语义是「点一下炸一下」,Move 会高频触发(60~120Hz),每帧引爆一次会造成粒子瞬间爆满、帧率雪崩。Down 触发 + 粒子生命周期自然演进,交互与动画解耦。
触摸不打断帧循环:引爆只是往 particles 数组追加粒子,下一次 update/draw 自然处理——交互是「注入」而非「接管」,帧循环对此无感知,这是粒子架构对交互最友好的地方。
触摸频控的必要性:连续快速点按(每秒 5 次)→ 每次 +70 粒子 → 3 秒即达上限 800。上限保护(5-1 篇)在此时显身手:丢弃最老粒子,画面依旧饱满,不会卡死。
三、帧率控制:30fps 的取舍
this.loopTimer = setInterval(() => {
this.update();
this.draw();
}, 33); // ~30fps
30fps 够不够? 粒子运动缓慢(星星漂移、火花洒落),30fps 视觉连续;60fps 每帧 16ms,800 粒子 × (arc + fill) 压力翻倍,且移动端功耗显著上升。粒子场景的「流畅」取决于运动的平滑度而非帧率数字——慢速运动 30fps 与 60fps 观感几乎无差。
帧率与复杂度的契约:帧间隔 33ms 意味着 update + draw 必须在 33ms 内完成——一旦粒子数超标导致单帧超时,帧率自动下降(丢帧)而非崩溃。粒子上限正是为了守住这个 33ms 契约。
进阶(可扩展):按粒子数动态调帧率——粒子 < 300 用 50ms 节流,> 500 用 33ms;或按系统负载降级(把星星 size 减半、拖影透明度调大)。本应用固定 33ms + 上限保护,已足够稳。
四、暂停与恢复:驱动源开关
private toggleRun(): void {
this.running = !this.running;
if (this.running) {
this.startLoop(); // 继续:重建循环(防重入守卫放行)
} else {
if (this.loopTimer !== -1) {
clearInterval(this.loopTimer); // 暂停:杀掉驱动源
this.loopTimer = -1;
}
}
}
暂停语义与时钟应用一致:杀掉计时器 = 世界静止;恢复 = 世界继续演进。粒子数据(位置/速度/生命)原样保留——暂停不破坏世界状态,只是停止时间推进。
防重入的微妙之处:暂停把 timer 置 -1,恢复时 startLoop 的守卫(timer !== -1)放行创建新循环——「清理后置 -1」是防重入守卫能反复工作的前提,两处必须配套。
五、粒子上限:内存与帧率的双保险
const MAX_PARTICLES: number = 800;
if (this.particles.length > MAX_PARTICLES) {
this.particles.splice(0, this.particles.length - MAX_PARTICLES);
}
上限解决的三个问题:
| 问题 | 无上限的后果 | 上限方案 |
|---|---|---|
| 内存 | 持续触摸,数组无限膨胀 | 800 封顶 |
| 帧率 | 粒子越多单帧越慢,最终卡死 | 上限守住 33ms 契约 |
| 拖影 | 粒子重叠发白,画面变噪 | 丢弃最老,新粒子优先 |
为什么「丢弃最老」:粒子数组按出生顺序排列,最老的 = 生命快耗尽的 = 视觉上即将消失的——先丢视觉上无关紧要的,用户几乎感知不到。
触发时机:在 update 末尾(每帧检查)——不引入额外判断成本,数组超长立刻收缩。
六、拖影的取舍:rgba 半透明清屏
// draw() 里:不清屏,盖半透明背景
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
ctx.fillRect(0, 0, this.canvasW, this.canvasH);
拖影带来的:火箭尾迹、火花光晕、星星余辉——粒子效果的「氛围感」一半来自拖影。
拖影付出的:每帧多一次全画布 fillRect(宽 94% × 420vp 的填充,约毫秒级);且拖影会让粒子「虚化」,快速运动物体显得模糊。
本应用的取舍:30fps 下拖影 fillRect 开销可忽略,而视觉收益巨大——保留。若做 60fps 高密度粒子(数千粒),建议改 clearRect 全清(放弃拖影)换取帧率,或用小尺寸离屏画布做拖影。
拖影与背景色的一致性:拖影色 rgba(15,23,42,0.35) 的 RGB 必须等于画布背景 #0F172A——否则旧帧淡出后留下「色差残影」,画面越叠越脏。拖影色的 RGB 恒等于背景色,只有 alpha 决定拖影长度。
七、清屏与重绘的正确姿势
// 日常帧:拖影清屏(draw)
// 清屏/切模式:彻底清(redraw)
private redraw(): void {
this.ctx.clearRect(0, 0, this.canvasW, this.canvasH);
this.ctx.fillStyle = '#0F172A';
this.ctx.fillRect(0, 0, this.canvasW, this.canvasH);
// …重绘当前粒子…
}
两种清屏的语义差异:拖影清屏(rgba)用于连续帧(保留旧帧残影);彻底清屏(clearRect + 纯色)用于场景重置(清空/切模式/onReady)——残留的拖影会让「清空」后画面仍留有暗影,必须彻底清。
八、文章小结
本节完成了交互与性能闭环:触摸 Down 即引爆(注入不接管 + 频控依赖上限)→ 30fps 帧率契约(上限守 33ms)→ 暂停/恢复(驱动源开关 + 置 -1 配套)→ 粒子上限(内存帧率双保险 + 丢最老)→ 拖影取舍(rgba 与背景同 RGB)。核心方法论是「帧率、内存、视觉三者用显式契约(上限/间隔/拖影参数)互相制衡」。
下一节给出粒子特效画布完整代码与运行效果,同时为整个 Canvas 五应用系列收尾。
九、补充:FAQ
Q1:为什么爆炸用 Down 而不是 Up(松手引爆)?
Down 响应快、直觉强(「点下去就炸」);Up 会有按下到松手的延迟感。爆炸类特效一律 Down。
Q2:拖影长度怎么调?
alpha 0.35 越小拖影越短(残影消失快),越大越长。0.35 是「明显但不糊」的值;星空模式可调大(0.4)增加氛围,烟花模式调小(0.3)避免火花糊成一团。
Q3:粒子系统还能怎么优化?
真实项目可用「对象池」(复用死亡粒子对象减少 GC)、「离屏 Canvas」(把静态背景预渲染成一张图)、「降采样」(远小粒子用 fillRect 2×2 代替 arc)。本应用 800 粒子的规模都用不上,优化留给值得的规模。
十、深入:粒子性能的「三层瓶颈」
粒子应用的性能问题可以分成三个层次,优化时要按层次逐一排查:
10.1 数据层:对象数量
瓶颈:粒子数组的长度。
表现:遍历 + 过滤 + 绘制的时间与粒子数线性相关。
手段:上限保护(本应用)、对象池复用、死亡即移除。
10.2 绘制层:命令数量
瓶颈:每帧的绘制命令数。
表现:每个粒子的 arc + fill 都是独立命令,800 粒子 = 2400+ 命令。
手段:合并绘制(同颜色粒子批量画)、降级绘制(fillRect 代替 arc)、离屏缓存静态部分。
10.3 合成层:帧率与清屏
瓶颈:帧率 × 每帧合成成本。
表现:33ms 一帧时,单帧超时会「丢帧」而非「减速」。
手段:动态帧率、拖影与全清的取舍、低分辨率渲染。
优化顺序:先砍数量(上限),再减命令(合并),最后调帧率(动态)——从「源头」到「末端」逐层优化,每层都有明确的指标可观察。
10.4 本应用性能画像
| 指标 | 数值 | 说明 |
|---|---|---|
| 粒子上限 | 800 | MAX_PARTICLES |
| 帧率 | 30fps(33ms) | setInterval 间隔 |
| 每帧命令 | ~2500(800 粒子 × 3) | arc + alpha + fill |
| 单帧预算 | 33ms | 超时丢帧 |
800 粒子 × 30fps 在现代设备上 <5ms/帧,预算充裕——本应用的上限其实「很保守」,留足了余量。这也是刻意的:性能优化要「留白」,不要贴着极限跑,否则任何波动都会导致卡顿。
十一、进阶:三种「拖影」实现对比
拖影是粒子特效的高级感来源,实现方式有三种,各有取舍:
| 方案 | 实现 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 半透明清屏(本应用) | rgba fillRect 全屏 | 简单、效果自然 | 每帧多一次全屏填充 | 30fps、千级粒子 |
| 逐帧复制 | 保存上帧像素再叠加 | 拖影可控 | 内存占用高 | 小画布 |
| 粒子轨迹 | 记录每个粒子的历史位置画线 | 精确轨迹 | 数据量大 | 星轨效果 |
本应用选半透明清屏,因为它「一行代码、效果达标」——它把「拖影长度」抽象成一个 alpha 参数(0.35),调起来最直观。选择拖影方案时,先问「拖影要表现什么」:氛围(半透明清屏)、精确轨迹(粒子轨迹)、还是可控长度(逐帧复制)。
十二、动手练习
- 上限实验:把 MAX_PARTICLES 改成 200 / 3000,用粒子计数观察触顶行为,感受上限保护的价值;
- 帧率实验:把 33ms 改成 16ms / 66ms,对比流畅度与耗电(看 Profiler 的 CPU 占用);
- 拖影长度实验:把 rgba alpha 从 0.35 改成 0.1 / 0.7,观察拖影从「几乎无」到「长尾」的变化;
- 降级绘制实验:把星星的 arc + fill 改成 fillRect 2×2,对比视觉与帧率(粒子数调大后差异更明显);
- 动态帧率实验:按粒子数切换帧率(<300 用 50ms,>500 用 33ms),观察「负载自适应」的效果。
十三、本篇知识点串联
| 知识点 | 实现 | 一句话理解 |
|---|---|---|
| Down 引爆 | 只响应按下 | 避免 Move 高频触发 |
| 注入式交互 | 只追加粒子 | 帧循环无感知 |
| 30fps 契约 | 33ms 间隔 | 慢速运动够流畅 |
| 粒子上限 | 800 + 丢最老 | 内存帧率双保险 |
| 拖影取舍 | rgba 与背景同 RGB | 时间上的模糊 |
| 双清屏语义 | 拖影 vs clearRect | 连续帧 vs 场景重置 |
下一节给出粒子特效画布完整代码与运行效果,同时为整个 Canvas 五应用系列收尾。
粒子特效画布完整代码与运行效果
一、代码全景
完整源码见 Canvas_2d/5_粒子特效/ParticleCanvas.ets,结构:
import { promptAction } from '@kit.ArkUI';
interface Particle {
x: number; y: number; // 位置
vx: number; vy: number; // 速度
life: number; maxLife: number;// 生命
size: number; // 半径
color: string; // 颜色
kind: string; // 'star' | 'rocket' | 'spark'
}
const FIRE_COLORS: string[] = ['#3B82F6', '#EF4444', '#F59E0B', '#10B981', '#8B5CF6', '#EC4899'];
const MAX_PARTICLES: number = 800;
@Entry
@Component
struct ParticleCanvas {
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private canvasW: number = 0;
private canvasH: number = 0;
private particles: Particle[] = [];
private loopTimer: number = -1;
private autoCount: number = 0;
@State mode: number = 0; // 0 星空 / 1 烟花
@State running: boolean = true;
@State particleCount: number = 0;
@State modeLabel: string = '🌌 星空漫游';
aboutToAppear(): void { /* seedStars(50) + startLoop */ }
aboutToDisappear(): void { /* 清 loopTimer */ }
build() { /* 标题栏 + 画布 + 操作栏 + 提示 */ }
private startLoop(): void { /* 33ms 帧循环 */ }
private toggleRun(): void { /* 暂停/继续 */ }
private update(): void { /* 三种物理 + 死亡过滤 + 自动发射 + 上限 */ }
private launchRocket(): void { /* 底部火箭 */ }
private explode(x: number, y: number, fixedColor: string | null): void { /* 70 粒径向火花 */ }
private seedStars(count: number): void { /* 初始撒星 */ }
private draw(): void { /* 拖影清屏 + 逐粒子 alpha 绘制 */ }
private redraw(): void { /* 彻底清屏重绘 */ }
private handleTouch(e: TouchEvent): void { /* Down 引爆/星团 */ }
private switchMode(): void { /* 模式切换 + 重置粒子 */ }
private clearAll(): void { /* 清空粒子 */ }
}
二、关键代码逐段回放
2.1 帧循环(更新 + 绘制)
this.loopTimer = setInterval(() => {
this.update(); // 世界走一步
this.draw(); // 画出世界
}, 33);
2.2 火箭重力 + 寿命爆炸
p.vy += 0.06;
p.x += p.vx;
p.y += p.vy;
p.life--;
if (p.life <= 0) {
this.explode(p.x, p.y, null);
continue;
}
2.3 径向迸发
const angle = Math.random() * Math.PI * 2;
const speed = 1 + Math.random() * 4.5;
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
2.4 拖影 + 闪烁
ctx.fillStyle = 'rgba(15, 23, 42, 0.35)';
ctx.fillRect(0, 0, this.canvasW, this.canvasH);
for (const p of this.particles) {
ctx.globalAlpha = p.kind === 'star'
? 0.3 + 0.7 * Math.abs(Math.sin(p.life * 0.06))
: Math.max(0, p.life / p.maxLife);
// arc + fill
}
ctx.globalAlpha = 1;
三、运行效果
| 操作 | 效果 |
|---|---|
| 进入页面 | 星空模式,50 颗星缓慢向左上漂移,各自闪烁 |
| 触摸星空画布 | 触摸点迸出一团 25 颗新星,向四周散开 |
| 切「🎆 烟花模式」 | 约每秒一枚黄火箭从底部升空,顶点爆炸成 70 粒彩色火花 |
| 触摸烟花画布 | 手指位置立即爆炸,无需等火箭 |
| 连续快速触摸 | 粒子数到 800 后自动丢弃最老粒子,画面仍饱满不卡 |
| 点「⏸ 暂停」 | 画面定格(粒子不再运动),按钮变「▶️ 继续」 |
| 点「🧹 清屏」 | 粒子全部清空,画布回到纯色背景 |
| 观察标题栏 | 实时显示当前模式与粒子总数 |
边界验证:暂停时触摸 → 粒子数增加但画面静止(注入但未演进,恢复后一起动);切模式 → 粒子清空并重建(星空撒 50 星);页面退出 → 计时器被清理,无泄漏。
四、五个应用能力全景
至此 Canvas 五应用全部完成,回顾每应用沉淀的 Canvas 能力:
| 应用 | 核心 API | 沉淀能力 |
|---|---|---|
| 1 手写签名板 | quadraticCurveTo / getPixelMap | 路径平滑、像素导出 |
| 2 涂鸦画板 | globalCompositeOperation | 合成模式、双栈历史 |
| 3 数据图表 | createLinearGradient / setLineDash / arc | 坐标系、渐变、动画、命中检测 |
| 4 动态时钟 | cos/sin 极坐标 / setInterval | 圆形绘制、定时刷新 |
| 5 粒子特效 | globalAlpha / 帧循环 | 粒子系统、性能控制 |
五应用串起一条完整的学习路径:基础路径(1)→ 合成与历史(2)→ 坐标与数据可视化(3)→ 极坐标与动画(4)→ 粒子与性能(5),CanvasRenderingContext2D 的核心 API 全部覆盖。
五、可扩展方向
- 更多粒子行为:雪花(飘落旋转)、萤火虫(随机徘徊)、星轨(长拖影);
- 声音反馈:爆炸瞬间震动(vibrator)+ 音效;
- 离屏渲染:把静态背景(夜空渐变)预渲染成位图,减少每帧填充;
- 重力交互:倾斜传感器驱动重力方向,粒子随风/倾斜飘落。
六、系列总结
完成 HarmonyOS 自定义 Canvas(CanvasRenderingContext2D)从入门到实战的完整闭环:
- 画布基础:RenderingContextSettings 抗锯齿、Canvas 组件、onReady 时机;
- 绘制体系:路径、样式、渐变、合成、变换、文字;
- 数据驱动:画面 = 数据的投影,重绘 = 投影的重放;
- 交互体系:触摸坐标、命中检测、状态 + 重绘;
- 动画体系:setInterval 帧循环、进度驱动、生命周期清理;
- 性能体系:粒子上限、帧率契约、拖影取舍、资源 release。
更多推荐


所有评论(0)