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

一、设计理念:画面由「成千上万个点」构成

前四个应用画的是「确定的形状」(笔迹、柱体、表针);粒子特效反过来——画面由大量微小元素(粒子)的集合构成,每个粒子有自己的位置、速度、生命,整屏的视觉效果来自粒子的群体行为

本应用两种模式:

模式 效果 粒子行为
🌌 星空漫游 星星漂移 + 闪烁,触摸生成星团 慢速漂移 + 透明度正弦闪烁
🎆 烟花绽放 火箭升空自动爆炸,触摸引爆 重力升空 + 径向爆炸 + 阻尼衰减

技术核心:粒子数据结构、帧循环(更新 + 绘制)、随机发射、拖影效果、粒子上限保护。

为什么第五个应用选粒子特效

粒子特效是 Canvas 绘制系列的「最终关卡」,选择它收官有三个理由:

  1. 集大成:粒子用到了前面所有应用的技能——路径(arc)、样式(globalAlpha)、动画(帧循环)、性能(上限保护)。它把 25 篇的知识点全部串起来;
  2. 性能极限:前面四个应用的元素量都在百级以内,粒子上千——这是第一次真正考验「每帧全量重绘」的性能底线;
  3. 视觉震撼:粒子是「低成本高视觉回报」的典型——几十行代码就能做出星空、烟花,最能体现 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'
}

四个字段组解释一切

  1. 位置 (x, y) + 速度 (vx, vy):每帧 x += vx; y += vy——粒子按速度移动,这就是「漂移」;
  2. 生命 (life, maxLife):每帧 life--,归零即死;life/maxLife 是「剩余比例」,用于渐隐透明度——生命值统一了「存在时长」与「淡出效果」
  3. 大小 (size):粒子半径,星星小、火花稍大;
  4. 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 分支,理由:

  1. 字段完全同构:三个类型都是「位置/速度/生命/大小/颜色」,只是 update 行为不同;
  2. 分支代价极低:update 里一个 if/else 链,远小于三个类的继承层级;
  3. 数组处理统一:遍历、过滤、上限裁剪都是「按 Particle[] 处理」,不需要类型分发。

判断是否该用继承的标准:字段和行为的差异是否足够大。粒子系统的差异只是「几行物理公式」,用扁平结构 + kind 分支是更优解——这也是真实游戏引擎里 ECS(实体组件系统)的思路雏形。

十、设计决策复盘

决策点 选择 理由
数据结构 扁平接口 + kind 字段同构、分支廉价
帧率 33ms(30fps) 慢速运动够流畅,省一半 CPU
更新策略 update/draw 分离 避免「画到一半世界变了」
死亡处理 数组重建过滤 比 splice 不易错
上限保护 800 + 丢最老 守住帧率契约

「update/draw 分离」和「上限保护」是本应用最核心的两个架构决策——前者保证画面一致性,后者保证性能底线。它们在 5-2~5-4 里反复出现,是理解粒子应用的钥匙。

十一、动手练习

  1. 改粒子数:把 MAX_PARTICLES 改成 200 / 3000,观察帧率与画面密度的关系,找到你设备的性能临界点;
  2. 改帧率:把 33ms 改成 16ms / 66ms,观察运动平滑度的变化,验证「慢速运动低帧率够用」;
  3. 改阻尼系数:把 0.985 改成 0.99 / 0.9,观察火花「飘得远」与「快速停住」的差异;
  4. 改重力系数:把火箭重力 0.06 改成 0.02 / 0.15,观察火箭「飞得高」与「飞不起来」的变化;
  5. 数组重建实验:把死亡过滤改成「遍历中 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;
}

「自然感」来自随机范围

  1. 位置全画布随机random() × 宽/高——星空要「铺满」,不能集中;
  2. 速度带区间-0.15 - random()*0.2 是 [-0.35, -0.15]——所有星都向左上漂,但快慢不同(同一方向、不同速率,像透过车窗看远方星星,有视差感);
  3. 大小带区间0.8 + random()*1.8 → [0.8, 2.6]——大小不一才有层次;
  4. 生命带区间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);
  }
}

星团 = 触摸点为中心的随机分布

  1. 位置以触摸点为中心t.x + (random-0.5)*30——±15vp 的散落范围,成一团;
  2. 速度向四周(random-0.5)*1.2——每颗星往随机方向缓慢散开,团逐渐「化开」;
  3. 寿命较短: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 负),这是刻意的设计:

  1. 方向一致性:所有星星同一方向 = 「镜头在移动」的视差感——像透过车窗看星空;
  2. 方向差异在速度:快慢不同(0.15~0.35 区间)让近处星「走得快」、远处星「走得慢」——视差 = 同方向不同速度
  3. 屏幕利用率:向左上漂意味着新星从右下补入(触摸生成),画面持续有「新内容进入」。

如果随机方向,星星会互相穿过、画面显得杂乱——星空需要的是「秩序感」而非「混乱感」

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-- 完事)。先选最简方案,需要时再升级是贯穿系列的工程原则。

十、动手练习

  1. 方向实验:把星星速度改成向右下(vx 正、vy 正),观察「镜头反向移动」的视觉差异;
  2. 视差实验:把速度区间拉大(0.05~0.8),观察远近星的「视差感」增强;
  3. 频率实验:把 0.06 改成 0.15 / 0.02,感受闪烁快慢对「宁静感」的破坏与恢复;
  4. 亮度下限实验:把 0.3 + 0.7× 改成 0.8 + 0.2×,观察星星「永远很亮」失去呼吸感;
  5. 边界回卷实验:给星星加「出界重生到对侧」逻辑,观察永恒星空的效果,对比两种策略的取舍。

十一、本篇知识点串联

知识点 实现 一句话理解
区间随机 min + random×range 有组织的自然感
匀速漂移 位置 += 速度 帧循环最简物理
正弦闪烁 sin + abs + 映射 平滑周期变化
生命作相位 sin(life × 系数) 每颗星独立闪烁
触摸星团 中心 ± 随机 局部爆发
拖影清屏 rgba 半透明 fillRect 时间上的模糊

下一节实现烟花绽放:火箭升空、爆炸迸发与阻尼衰减。

十二、常见错误排查指南

星空模式代码不长,但运行时容易踩这几类坑:

12.1 「星星不动」或「画面静止」

排查顺序:

  1. 确认 startLoop() 被调用(aboutToAppear 里)——漏调则没有帧循环;
  2. 确认 loopTimer 初始为 -1(防重入守卫不误拦);
  3. 确认 update 里真的执行了 p.x += p.vx——如果只 push 粒子不更新,星星当然不动;
  4. 确认没有被误暂停(toggleRun 把 running 置 false 且清了计时器)。
12.2 「星星不闪烁」或「亮度恒定」

排查:

  1. 确认 star 分支设置了 ctx.globalAlpha(星星没有自己的 alpha 就会用上一个粒子的值);
  2. 确认 sin(p.life * 0.06) 的 life 确实在递减(如果忘了 life--,sin 相位恒定,亮度不变);
  3. 确认 alpha 用后复位为 1(否则下一帧所有粒子都继承上一个星星的 alpha)。
12.3 「拖影变脏」或「画面越来越糊」

排查:

  1. 确认拖影色 rgba(15, 23, 42, 0.35) 的 RGB 与背景色 #0F172A 一致——不一致会残留色差;
  2. 确认「清空/切模式」用的是 clearRect 彻底清屏而非拖影清屏——残留旧帧会让「清空」后还有暗影。
12.4 「触摸没反应」或「星团不出现」

排查:

  1. 确认 Canvas 绑定了 onTouch(漏绑则触摸不触发);
  2. 确认 handleTouch 里 mode === 0 分支正确(星空模式下才生成星团);
  3. 确认粒子被 push 进 particles 数组(如果 push 了但 update 立即把它们过滤掉,可能是 life 初始为 0)。
12.5 「粒子数暴涨卡顿」

排查:

  1. 确认触摸只响应 Down(Move 会高频触发);
  2. 确认 MAX_PARTICLES 上限保护在 update 末尾执行(粒子数超限立即裁剪);
  3. 用标题栏的 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),如果不做任何处理会显得「一晃而过」。本应用靠两件事弥补:

  1. 拖影:半透明清屏让火箭升空的轨迹留下淡痕,视觉上「有尾迹」;
  2. 帧间隔小: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] 消散时间(先后不一)
  • 角度随机保证火花覆盖整个圆(球形爆炸);
  • 速度随机让火花有的飞得远、有的近(蓬松感);
  • 寿命随机让火花有的先消失、有的后消失(自然消散)。

三个随机量分别控制「方向」「距离」「时间」——这是「随机分层的艺术」:不随机则机械,全随机则混乱,分维度随机则自然。

十一、动手练习

  1. 初速度实验:把火箭 vy 从 -(3 + random×2.5) 改成 -(5 + random×3),观察火箭飞得更高、爆炸点更高;
  2. 阻尼实验:把 0.985 改成 0.97 / 0.999,观察火花「快速收拢」与「长时间飘散」的差异;
  3. 火花数实验:把 explode 的 n 从 70 改成 30 / 150,观察爆炸的密度与帧率的关系;
  4. 颜色实验:往 FIRE_COLORS 加金色 #FDE047 和银色 #C0C0C0,观察双色烟花的观感;
  5. 爆炸时机实验:把火箭 life 从 60 改成 30 / 100,观察爆炸发生在「低空」与「高空」的效果差异。

十二、本篇知识点串联

知识点 实现 一句话理解
帧计数发射 autoCount % 30 周期事件不新增计时器
重力 vy += 0.06 速度每帧变化
抛体运动 初速度 + 重力积分 上升减速 → 顶点 → 下落
径向迸发 cos/sin × speed 极坐标转直角速度
阻尼 v ×= 0.985 指数衰减,永不为负
爆炸级随机色 整团同色 随机在爆炸级而非粒子级

下一节处理触摸交互与性能优化:帧率控制、粒子上限与拖影的取舍。

十三、常见错误排查指南

烟花模式是粒子系统里最容易出「物理诡异」的模式,附排查思路:

13.1 「火箭不升空」或「原地爆炸」

排查:

  1. 确认火箭初速度 vy 是负值(向上)——若写成正值会「向下射」然后爆炸;
  2. 确认 update 里 rocket 分支的 p.vy += 0.06p.y += p.vy 顺序正确——先加速度后积分位置;
  3. 确认 launchRocket 在 autoCount % 30 时被调用(帧计数逻辑正确)。
13.2 「爆炸不出火花」或「火花静止」

排查:

  1. 确认 explode 里循环生成 70 个火花并 push 进 particles;
  2. 确认火花初速度非零(cos(angle)×speed 中 speed 最小 1,不会为 0);
  3. 确认 update 里 spark 分支的 p.x += p.vx 执行——如果 update 只更新 rocket 分支,火花不会动。
13.3 「火花乱飞」或「飞出屏幕」

排查:

  1. 阻尼系数 0.985 是否正确——若写成 1 则无阻尼,火花一直飞;
  2. 初速度上限 5.5 是否被调大(调大后火花飞得更远);
  3. 火花是否有上限保护(MAX_PARTICLES 800)——连点引爆可能瞬间超限。
13.4 「爆炸位置不对」或「在屏幕底部爆炸」

排查:

  1. 确认 explode(p.x, p.y, null) 用的是更新后的坐标(p.y 已包含当帧位移);
  2. 确认火箭 life 初始值合理(60 ≈ 2 秒)——太小会在底部爆炸;
  3. 确认 canvasH 正确(若为 0 则火箭从顶部「掉下来」)。
13.5 「触摸引爆没反应」

排查:

  1. 确认 handleTouch 里 mode === 1 分支调用了 explode;
  2. 确认触摸坐标 t.x/t.y 与粒子坐标同系(都相对画布左上角);
  3. 确认 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),调起来最直观。选择拖影方案时,先问「拖影要表现什么」:氛围(半透明清屏)、精确轨迹(粒子轨迹)、还是可控长度(逐帧复制)。

十二、动手练习

  1. 上限实验:把 MAX_PARTICLES 改成 200 / 3000,用粒子计数观察触顶行为,感受上限保护的价值;
  2. 帧率实验:把 33ms 改成 16ms / 66ms,对比流畅度与耗电(看 Profiler 的 CPU 占用);
  3. 拖影长度实验:把 rgba alpha 从 0.35 改成 0.1 / 0.7,观察拖影从「几乎无」到「长尾」的变化;
  4. 降级绘制实验:把星星的 arc + fill 改成 fillRect 2×2,对比视觉与帧率(粒子数调大后差异更明显);
  5. 动态帧率实验:按粒子数切换帧率(<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)从入门到实战的完整闭环:

  1. 画布基础:RenderingContextSettings 抗锯齿、Canvas 组件、onReady 时机;
  2. 绘制体系:路径、样式、渐变、合成、变换、文字;
  3. 数据驱动:画面 = 数据的投影,重绘 = 投影的重放;
  4. 交互体系:触摸坐标、命中检测、状态 + 重绘;
  5. 动画体系:setInterval 帧循环、进度驱动、生命周期清理;
  6. 性能体系:粒子上限、帧率契约、拖影取舍、资源 release。
Logo

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

更多推荐