【OpenHarmony/HarmonyOS】Canvas 粒子特效实战:爆炸、生命周期、对象池与批量绘制
【OpenHarmony/HarmonyOS】Canvas 粒子特效实战:爆炸、生命周期、对象池与批量绘制
坦克被击毁、砖墙被打碎、玩家重生,如果画面只是“对象突然消失或出现”,规则虽然成立,动作反馈却会很生硬。粒子系统的价值,就是用短暂、轻量的小对象把一次离散事件变成一段可感知的过程。本篇结合 ArkTS + Canvas 项目,完整分析粒子的初始化、运动、衰减、回收、对象池和渲染优化,并说明哪些优化真正有效、哪些地方仍需用数据验证。💥
一、粒子系统在游戏循环中的位置
粒子不是一个独立页面组件,而是游戏世界的一类短生命周期实体。它与坦克、子弹的区别在于数量多、创建频繁、单个价值低、死亡速度快,因此管理策略也不同。
flowchart LR
A[命中/爆炸/重生事件] --> B[spawnEffect]
B --> C[从 ObjectPool 获取 Particle]
C --> D[init 重置位置速度寿命]
D --> E[加入 particles 数组]
E --> F[每帧 update]
F --> G{寿命结束?}
G -- 否 --> H[Canvas 绘制]
G -- 是 --> I[归还对象池]
I --> J[交换删除活动数组元素]
| 阶段 | 输入 | 输出 | 高频程度 |
|---|---|---|---|
| 生成 | 事件位置、颜色、数量、大小 | 一批活动粒子 | 瞬时突发 |
| 更新 | deltaTime |
新位置、新速度、新寿命 | 每帧 |
| 绘制 | 粒子状态、摄像机变换 | 圆点和透明度 | 每帧 |
| 回收 | isDead |
返回池、移出数组 | 每帧可能发生 |
性能优化应该优先放在每帧和批量路径,而不是只优化偶尔执行一次的配置代码。
二、最小粒子模型包含哪些状态
项目中的 Particle 只保存运动和绘制所需数据:
export class Particle {
position: Vector2;
velocity: Vector2;
color: string;
life: number;
maxLife: number;
size: number;
isDead: boolean = false;
constructor() {
this.position = new Vector2(0, 0);
this.velocity = new Vector2(0, 0);
this.color = '#FFFFFF';
this.life = 0;
this.maxLife = 0;
this.size = 0;
}
}
life 是当前剩余寿命,maxLife 是初始寿命。之所以保留两份,是因为透明度需要计算 life / maxLife。只保存剩余时间虽然也能判断死亡,却无法知道当前处于完整生命周期的哪个百分比。
模型没有保存 Canvas 上下文、音频管理器或游戏引擎引用。这一点很健康:粒子可以被对象池独立创建,更新逻辑也容易单测。颜色和大小直接存成最终绘制值,则避免每帧根据“爆炸类型”重复查配置。
三、init:对象池模式下真正的构造函数
使用对象池后,JavaScript/ArkTS 的 constructor() 只在池扩容时运行。每次复用必须依赖 init() 把旧状态完全覆盖:
init(
x: number,
y: number,
color: string,
speed: number,
life: number,
size: number
): void {
this.position.x = x;
this.position.y = y;
const angle = Math.random() * Math.PI * 2;
this.velocity.x = Math.cos(angle) * speed;
this.velocity.y = Math.sin(angle) * speed;
this.color = color;
this.life = life;
this.maxLife = life;
this.size = size;
this.isDead = false;
}
随机角度覆盖 [0, 2π),再通过 cos、sin 得到速度分量,使粒子向四周均匀散开。这里传入的是标量速度,因此每个粒子的初速度模长相同;实际 spawnEffect() 又对速度随机化,让爆炸边缘不至于形成过于整齐的圆环。
对象池最常见的错误是漏重置字段。例如上一次粒子已经死亡,若忘记把 isDead 改回 false,新粒子会在加入数组后立刻被回收。当前 ObjectPool 的 reset 回调和 Particle.init() 都设置了该字段,虽然略有重复,但形成了双重保护。更重要的是位置、速度、寿命、颜色和大小也全部覆盖,没有残留旧爆炸的数据。
四、运动与衰减:用 deltaTime 保持速度稳定
粒子更新逻辑分三步:减少寿命、按速度移动、施加阻力。
update(deltaTime: number): void {
this.life -= deltaTime;
if (this.life <= 0) {
this.isDead = true;
}
this.position.x += this.velocity.x * deltaTime;
this.position.y += this.velocity.y * deltaTime;
this.velocity.x *= 0.95;
this.velocity.y *= 0.95;
}
位置使用 velocity * deltaTime,因此速度的含义是“每秒移动多少世界单位”。如果设备从 120 FPS 降到 60 FPS,单帧 deltaTime 变大,但一秒内累计距离仍近似一致。
阻力 0.95 却是按帧相乘,而不是按时间计算。固定时间步长下问题不大,因为逻辑更新频率稳定;如果完全使用可变步长,同样一秒内更新 120 次会比 60 次衰减得更多。时间一致的形式可以写为:
const dampingPerSecond = 0.05;
const damping = Math.pow(dampingPerSecond, deltaTime);
this.velocity.x *= damping;
this.velocity.y *= damping;
这是演进示例。具体参数必须通过视觉效果调试,不能直接把 0.05 当成当前项目的等价值。
另一个边界是:寿命在本帧变为零后,代码仍会执行一次移动。随后绘制会因为 isDead 跳过,视觉上没有问题。如果追求最少运算,可以在设置死亡后立即 return,但分支收益要用性能分析确认。
五、一次爆炸如何创建不同粒子
引擎使用统一 spawnEffect() 处理墙体、坦克、命中和重生等反馈:
private spawnEffect(
x: number,
y: number,
color: string,
count: number = 20,
size: number = 3,
playSound: boolean = true
): void {
if (playSound) {
AudioManager.getInstance().playSound('explosion');
VibrationManager.getInstance().trigger('heavy');
}
for (let i = 0; i < count; i++) {
const speed = 50 + Math.random() * 100;
const life = 0.5 + Math.random() * 0.5;
const particle = this.particlePool.get();
particle.init(x, y, color, speed, life, size);
this.particles.push(particle);
}
}
每个粒子的速度处于 50~150,寿命处于 0.5~1.0 秒。随机速度形成不同扩散半径,随机寿命让粒子不会同一帧整齐消失。
参数化让同一机制服务多个场景:
| 场景 | 数量/大小倾向 | 颜色 | 是否播放本机反馈 |
|---|---|---|---|
| 子弹命中 | 少而小 | 白色火花 | 玩家相关命中才播放 |
| 敌方坦克爆炸 | 中等 | 金橙色 | 玩家击毁时播放 |
| 玩家死亡 | 多而大 | 玩家绿色 | 播放并重振动 |
| 砖墙破坏 | 中等偏小 | 棕色碎屑 | 玩家子弹触发时播放 |
| 重生 | 少量 | 坦克本色 | 仅本机玩家播放 |
这比为每种事件写一个粒子类更符合 YAGNI:当前差异主要是参数,而不是完全不同的运动规律。
六、为什么需要对象池 ♻️
假设一次爆炸产生 50 个粒子,连续战斗每秒发生数次爆炸。若每次都 new Particle(),短时间内会制造大量很快失效的对象。垃圾回收何时发生不可控,一旦恰好落在战斗高峰,就可能形成可感知卡顿。
项目的通用对象池结构很短:
export class ObjectPool<T> {
private pool: T[] = [];
constructor(
private createFn: () => T,
private resetFn: (obj: T) => void,
initialSize: number = 20
) {
for (let i = 0; i < initialSize; i++) {
this.pool.push(this.createFn());
}
}
get(): T {
if (this.pool.length > 0) {
const obj = this.pool.pop()!;
this.resetFn(obj);
return obj;
}
return this.createFn();
}
release(obj: T): void {
this.pool.push(obj);
}
}
池初始准备 20 个对象,不足时仍允许创建,因此它是弹性池,不会因为容量不足丢弃特效。爆炸结束后对象回池,下次优先复用。
对象池不是零成本:它让对象长期存活,增加常驻内存;还要求严格重置所有字段。如果粒子数量很少、平台垃圾回收足够稳定,池可能没有明显收益。正确做法是记录活动粒子峰值、GC 暂停和帧时间,再决定初始容量,而不是认为“对象池一定更快”。
七、活动数组回收:倒序扫描 + 交换删除
游戏循环无论是否处于 playing 都先更新粒子,使暂停或结算中的已有特效能够自然消失:
for (let i = this.particles.length - 1; i >= 0; i--) {
const particle = this.particles[i];
particle.update(deltaTime);
if (particle.isDead) {
this.particlePool.release(particle);
const lastIndex = this.particles.length - 1;
if (i !== lastIndex) {
this.particles[i] = this.particles[lastIndex];
}
this.particles.pop();
}
}
倒序遍历防止删除后索引前移导致漏更新。交换删除避免 splice() 移动数组中所有后续元素。粒子没有稳定顺序要求,调换绘制先后通常不可见,因此可以用 O(1) 删除换取更稳定的更新成本。
需要保证同一个对象只释放一次。当前对象被移出活动数组后不会再次遍历,符合这一约束。若未来引入异步特效或多个容器,共享对象池时需要增加调试期状态,例如 poolState: 'active' | 'pooled',防止双重归还。
八、透明度就是最简单的生命周期可视化
单个 Particle.draw() 使用寿命比例控制透明度:
draw(ctx: CanvasRenderingContext2D): void {
if (this.isDead) return;
ctx.save();
ctx.globalAlpha = this.life / this.maxLife;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.position.x, this.position.y,
this.size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
出生时 life / maxLife = 1,死亡前接近 0,因此粒子自然淡出。线性淡出计算便宜,且没有额外状态。还可以使用 ratio * ratio 让前半段保持明亮、末段快速消失,或根据比例同步缩小半径。
必须保证 maxLife > 0。当前所有活动粒子都经过 init(),传入的寿命至少为 0.5,因此除数安全。如果开放外部配置,应在初始化时钳制到最小正数。
九、减少 Canvas 状态切换
逐粒子调用 save()/restore() 最稳妥,却会在几百个粒子时产生大量上下文栈操作。项目在引擎中提供了更直接的绘制路径:
private drawParticlesOptimized(): void {
if (this.particles.length === 0) return;
for (const particle of this.particles) {
if (particle.isDead) continue;
this.context.globalAlpha = Math.max(
0, particle.life / particle.maxLife
);
this.context.fillStyle = particle.color;
this.context.beginPath();
this.context.arc(
particle.position.x,
particle.position.y,
particle.size,
0,
Math.PI * 2
);
this.context.fill();
}
this.context.globalAlpha = 1.0;
}
优化点是取消每粒子的 save()/restore(),在循环结束后显式恢复 globalAlpha。fillStyle 会保留为最后一个粒子的颜色,但后续绘制如果总是显式设置自己的颜色,就不会出错。
这段函数名称包含 “Optimized”,但每个粒子仍分别 beginPath() 和 fill(),还不能算真正按颜色批处理。可进一步按颜色分组:同色粒子加入同一个 Path,最后一次填充。不过不同透明度不能在同一批次中轻易表达,需要先把透明度量化为若干档,或接受状态切换。优化总是伴随视觉或复杂度取舍。
十、渲染顺序决定特效位于谁上方
项目世界层的大致绘制顺序是:地形、墙体、传送门、晶石、道具、坦克、子弹、粒子,最后退出摄像机变换并绘制 HUD。
这使爆炸粒子覆盖在坦克和子弹上方,同时仍受世界摄像机影响。HUD 不受爆炸遮挡,也不会跟随摄像机移动。若把粒子放到 context.restore() 之后,它们的世界坐标就会被当成屏幕坐标,在摄像机移动时出现漂移。
粒子绘制不做可见区裁剪。因为寿命短且数量通常有限,这个选择可能足够。大地图中如果远处 AI 也大量爆炸,可以在绘制前用摄像机矩形加边距判断,屏幕外粒子仍更新但不绘制。
十一、暂停、结算与生命周期的语义问题
当前更新函数先更新粒子,之后才执行:
if (this.gameState !== 'playing') return;
因此只要游戏循环仍运行,非 playing 状态下粒子也会继续衰减。它可能是刻意设计:结算弹窗出现时,最后一次爆炸能够播放完。但对于“暂停”语义,玩家通常预期整个世界冻结。
可以把粒子分为两类:
- 世界粒子:受游戏暂停和时间缩放影响;
- UI 粒子:使用真实时间,在暂停菜单和结算层中继续播放。
当前项目没有这层区分。若未来加入慢动作、暂停截图或回放,需要明确粒子使用哪一种时钟。
页面离开或重新初始化时还要清理活动数组,并决定是否把对象逐个还池。直接 particles = [] 会让这些对象等待垃圾回收,而不是回到池中。数量不大时影响有限;严谨的 clearEffects() 应遍历 release 后再清空数组。
十二、常见误区与真实改进方向 ⚠️
1. 不要用粒子数量代替视觉设计
从 20 个增加到 200 个不一定更好,只会提高覆盖率、填充率和更新成本。颜色、速度分布、寿命曲线与大小层次往往比数量更重要。
2. 不要伪造性能结论
代码用了对象池和交换删除,可以说明它们减少分配与数组搬移,但不能在没有真机数据时宣称“性能提升 80%”。应通过帧时间 P50/P95、活动对象峰值和 GC 次数证明收益。
3. 音效和粒子不应强绑定
spawnEffect() 提供 playSound 参数,已经允许远端或 AI 事件只画粒子而不轰炸本机音频。继续演进时可以传入反馈级别,而不是不断添加布尔参数。
4. 粒子池也需要容量策略
弹性池会保留历史峰值。如果一次极端事件产生数千粒子,之后池可能一直占用这些对象。可以设置最大回收量,多余对象直接放弃,由垃圾回收处理。
十三、测试与性能验证清单
| 检查项 | 验证方法 | 通过标准 |
|---|---|---|
| 初始化完整性 | 同一对象连续 init 两次 | 第二次无任何旧字段残留 |
| 寿命结束 | 用固定 deltaTime 更新 |
到期后 isDead=true |
| 透明度 | 检查生命比例 | 始终处于 0~1 |
| 对象复用 | 生成、死亡、再次生成 | 能取回对象且状态正确 |
| 删除安全 | 同帧多个粒子死亡 | 不漏删、不越界、不双重 release |
| 暂停语义 | 切换非 playing 状态 | 行为符合产品定义 |
| Canvas 状态 | 粒子后绘制 HUD | HUD 不继承透明度 |
| 峰值压力 | 连续触发大爆炸 | 记录帧时间,不凭感觉下结论 |
属性测试还可以随机生成不同 speed、life 和 deltaTime,验证位置始终为有限数值,不出现 NaN,寿命最终一定结束。
十四、总结 ✨
项目的粒子系统规模不大,却包含了一条完整的实时实体管线:事件触发生成、init() 重置池对象、deltaTime 推进运动、寿命比例控制淡出、倒序交换删除活动项、对象归还池、渲染层减少 Canvas 状态切换。
真正值得带走的工程经验有三点。第一,对象池模式下 init() 必须覆盖所有可变字段;第二,高频数组删除可以用无序交换换取稳定成本;第三,任何“性能优化”都要区分代码意图和实测结果。再进一步,还应处理按帧阻力、暂停时钟、池容量上限和可见区裁剪。这样一套小型粒子系统,才既有表现力,也能在 HarmonyOS 设备上长期稳定运行。🚀
推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI Canvas 游戏开发 粒子系统 性能优化 对象池

更多推荐


所有评论(0)