【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π),再通过 cossin 得到速度分量,使粒子向四周均匀散开。这里传入的是标量速度,因此每个粒子的初速度模长相同;实际 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(),在循环结束后显式恢复 globalAlphafillStyle 会保留为最后一个粒子的颜色,但后续绘制如果总是显式设置自己的颜色,就不会出错。

这段函数名称包含 “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 不继承透明度
峰值压力 连续触发大爆炸 记录帧时间,不凭感觉下结论

属性测试还可以随机生成不同 speedlifedeltaTime,验证位置始终为有限数值,不出现 NaN,寿命最终一定结束。

十四、总结 ✨

项目的粒子系统规模不大,却包含了一条完整的实时实体管线:事件触发生成、init() 重置池对象、deltaTime 推进运动、寿命比例控制淡出、倒序交换删除活动项、对象归还池、渲染层减少 Canvas 状态切换。

真正值得带走的工程经验有三点。第一,对象池模式下 init() 必须覆盖所有可变字段;第二,高频数组删除可以用无序交换换取稳定成本;第三,任何“性能优化”都要区分代码意图和实测结果。再进一步,还应处理按帧阻力、暂停时钟、池容量上限和可见区裁剪。这样一套小型粒子系统,才既有表现力,也能在 HarmonyOS 设备上长期稳定运行。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS ArkUI Canvas 游戏开发 粒子系统 性能优化 对象池

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐