今天给 Argos 做了一个“段位晋级彩纸特效”。

听起来是个很小的 UI 动画:用户 token 用量触发段位变化时,全屏弹一下彩纸,提示“黄金 III”之类的晋级结果。但真做起来,我发现这类效果很容易翻车。粒子太少,不像庆祝;粒子太多,像噪音。彩带想做飘逸,结果像屏幕上画了几条乱线。加爆点,本意是增强“爆炸感”,实际像廉价烟花。加光晕,本意是高级,实际会脏。

这篇文章复盘这次踩坑。重点不是 SwiftUI 代码,而是把粒子系统里那些概念讲清楚:particle、emitter、confetti、streamer、glint、ambient sparkles、bloom、burst、motion field、lifetime、distribution。以后再做类似 UI 特效,至少知道自己在调什么。

一句话总结

UI 里的庆祝特效,本质是一个小型 particle system。好看的关键不是“粒子越多越好”,而是让粒子的出生位置、生命周期、运动方向、密度分布、视觉层级服务同一个动势。


这次到底做了什么

Argos 里有一个段位系统:用户每天使用 Claude Code / Codex 的 token 会累计,达到某个 milestone 时触发段位变化。

我想要的效果是:

  1. 段位变化时出现全屏庆祝反馈。
  2. 中间有一个段位 toast,例如“黄金 III”。
  3. 屏幕上有彩纸、亮片、星芒,形成“晋级”的瞬间感。
  4. 只在调试环境有“测试彩纸”按钮,正式逻辑仍由真实段位变化触发。

最终通过的版本是:

元素最终保留原因
Confetti rectangles语义最清晰,一眼就是庆祝
Circular chips补充面积和层次,不抢主视觉
Diamond glints增加高光和精致感
Small cross sparkles少量点缀,强化“闪一下”
Streamers / 彩带方向、曲线、交叉都很难调,容易显脏
Burst / 爆点在这个场景里太像烟花,不符合 macOS 工具气质
Heavy bloom / 大光晕容易污染画面,让文字和桌面背景变糊

这其实是一个很典型的 UI 粒子系统取舍:不是把所有效果都加上,而是删到只剩能稳定表达语义的元素


Particle:粒子不是“点”,而是一个小演员

很多人听到 particle 会下意识理解成“点”。但在 UI 动画里,一个 particle 更像一个小演员,它至少有这些属性:

属性含义彩纸特效里的例子
position当前坐标彩纸当前在屏幕哪里
velocity速度往左上、右上、下落还是漂移
acceleration加速度重力、阻尼、回弹
lifetime生命周期0.8s 出现,3.5s 消失
size尺寸小彩纸、大亮片
rotation旋转彩纸翻转角度
opacity透明度出现、停留、淡出
color颜色高饱和彩纸色、湖水青、晨光金
shape形状矩形、圆形、菱形、星芒

所以“加一点彩纸”不是加几个随机矩形,而是定义一组小对象:它们什么时候出生、从哪里出生、往哪里走、多久消失、怎么旋转、颜色如何分布。

这次一开始怪异,根因就是我把粒子看成“画面装饰”,而不是一个系统。粒子的运动方向、密度和语义没对齐,就会出现“从上往下落”“一坨挤在一起”“不够满”“没有爆炸感”这些问题。


Emitter:发射器决定第一眼的动势

Emitter 是粒子系统里的发射器。它决定粒子从哪里出生。

常见 emitter 有几种:

Emitter 类型视觉效果适用场景
Point emitter从一个点喷出烟花、爆炸、魔法点
Line emitter从一条线喷出雨、雪、瀑布、顶部落彩纸
Area emitter从一个区域出现环境漂浮、空间尘埃
Edge emitter从屏幕边缘进入庆祝彩纸、舞台入场
Multi-emitter多个发射源全屏庆祝、游戏技能

这次最大的教训是:发射器位置会强烈决定用户对动画的理解

最开始用户反馈“怎么是从上往下落的”,这说明粒子的 emitter 或 velocity 让人联想到“掉落”,而不是“庆祝喷发”。彩纸如果主要从顶部进入,语义就偏“下雨”;如果从左右两侧或底部斜向上出现,语义才接近“庆祝”。

后来我尝试让彩带从左右两边飞出,但又出现了新问题:左右彩带在中间交叉、起点过于集中、曲线不丝滑。这说明 emitter 不是只看一个点,还要看发射区域分布

更准确地说,emitter 要调这几个参数:

参数作用
origin发射源位置
spread发射范围
angle初速度方向
variance随机扰动
rate单位时间生成多少粒子
burst count瞬间生成多少粒子

最终我放弃彩带后,保留了更稳定的 area/edge 混合:彩纸从不同区域出现,星芒覆盖更大的屏幕区域,但不再让连续线条承担主视觉。


Confetti:彩纸是最稳的庆祝语义

Confetti 就是彩纸。它是庆祝动画里最稳定、最不需要解释的元素。

UI 里常见的 confetti 粒子通常是:

形状视觉特点
矩形条最像真实彩纸
小方块更像游戏奖励
圆片像亮片或纸屑
菱形更有闪光感
短弧片更像翻转中的纸片

这次最终保留的是亮色矩形彩纸。原因很简单:它的语义最强。

彩纸要好看,核心不是形状复杂,而是三件事:

  1. 颜色要亮:庆祝粒子不能太灰,否则像灰尘。
  2. 尺寸要有层次:全是同一尺寸会机械。
  3. 旋转要变化:不旋转就像静态贴纸。

我一开始颜色不够亮,用户直接指出“彩纸色彩要亮”。这很关键。UI 主体可以克制,但庆祝粒子本身必须高饱和,否则它的情绪表达不成立。

这里有一个设计边界:背景和主 UI 需要低饱和,粒子可以高饱和。如果整个界面都高饱和,会廉价;但庆祝粒子高饱和,是语义需要。


Streamer:彩带为什么最容易翻车

Streamer 是长彩带、飘带。它的问题比彩纸复杂很多。

彩纸是离散粒子,单个粒子不好看也不会毁掉整体。彩带是连续曲线,一条不自然就非常显眼。

这次彩带翻车主要有几个原因:

问题根因
太高lift 参数过大,曲线飞到屏幕顶部
太长reach 过大,线条穿过视觉中心
挤在一起emitter 起点过于集中
全是并行控制点变化不足,曲线缺少角度差
交叉很尬左右两侧没有限定各自活动区域
不丝滑Bézier 控制点没有形成自然切线

彩带要自然,至少要同时满足:

  1. 起点分布不能重合。
  2. 曲线控制点要有连续的切线方向。
  3. 同侧可以有交叉,但交叉角度要自然。
  4. 左右两侧如果语义是“各自飞出”,就不能互相穿越中心。
  5. 长度、弧度、透明度要一起衰减。

这就不是“加几条曲线”了,而是在调一个小型 motion field。

最后我判断:在这个产品场景里,彩带收益不够高。Argos 是一个 macOS 工具,不是游戏结算页。彩带一旦做不好,负收益很明显。所以最终删掉。

这个决策很重要:粒子系统不是越复杂越高级,删掉不稳定元素也是工程能力


Glint:星芒负责“精致”,不要负责“热闹”

Glint 是闪光、星芒。它通常是菱形、四角星、十字星,出现在高光点附近。

它适合做:

  1. 金属反光。
  2. 奖励高光。
  3. 魔法感。
  4. “刚刚发生了好事”的瞬间提示。

但 glint 不能太多。太多会变成噪点,也会像廉价贴纸。

这次最终保留了两种 glint:

类型作用
Diamond glints菱形闪光,提供精致感
Cross sparkles十字闪光,提供瞬间感

它们不是主角,主角仍然是 confetti。glint 的职责是让画面从“只有纸片”变成“有奖励感”。

一个实用规则:glint 的 opacity 应该随 pulse 变化,而不是常亮。常亮的星芒像贴图;一闪一灭才像反光。


Ambient Sparkles:环境闪光不是爆点

Ambient sparkles 可以翻译成环境闪光、氛围闪点。它和 burst 不一样。

概念特点观感
Burst从中心或局部突然爆开强刺激、像烟花
Ambient sparkles分散在画面中轻微闪烁氛围、精致、空间感

这次我试过加爆点,但用户反馈“不好看”。原因是爆点会把语义带向“烟花爆炸”,而 Argos 的晋级反馈更像“系统奖励”,不应该过度娱乐化。

Ambient sparkles 更适合这个场景:它们不抢段位 toast,不制造视觉中心,只是在背景层增加一点“庆祝正在发生”的环境反馈。

实现时要注意:

  1. 不要集中在一个点。
  2. 不要全部同步闪。
  3. 不要持续太久。
  4. 不要比主彩纸更亮。

Ambient sparkles 的价值是“补空气感”,不是“制造高潮”。


Bloom / Glow:光晕是调味料,不是主菜

Bloom 是光晕、泛光。Glow 也是发光,但在 UI 语境里更泛一点。Bloom 通常指亮部向周围扩散,让画面有曝光感。

光晕很容易让效果看起来“高级”,也很容易让画面变脏。

这次我对光晕的判断是:

光效适合程度原因
小面积 highlight能增强奖励感
粒子边缘轻微 glow可以让粒子更亮
大面积 bloom会污染桌面背景和文字
中央强光爆点会抢段位 toast

尤其在 macOS 上,全屏 overlay 下面是真实桌面和窗口。如果光晕太重,用户看到的是“脏”和“糊”,不是高级。

所以最后只保留轻量星芒和亮片描边,没有做大面积 bloom。

我的经验是:工具型产品里的 bloom,要像盐,不能像汤底


Burst:爆点为什么不一定有“爆炸感”

Burst 是瞬间爆发。很多庆祝动画都会从一个中心点喷出大量粒子,形成爆炸感。

但 burst 有一个问题:它非常依赖上下文。

在游戏里,burst 很自然,因为奖励、击杀、升级本来就是强反馈。在 macOS menu bar 工具里,burst 太强就会显得浮夸。用户这次明确说“彩纸特效不要加爆点不好看”,这个判断是对的。

Burst 适合:

  1. 游戏结算。
  2. 支付成功。
  3. 大型 milestone。
  4. 首次完成关键任务。

Burst 不适合:

  1. 高频状态变化。
  2. 工具型产品里的轻量反馈。
  3. 已经有 toast 的场景。
  4. 背景复杂、可读性重要的场景。

段位晋级当然是好事,但它不是游戏胜利。Argos 的整体调性是克制的 macOS 工具,所以 burst 被删掉是合理的。


Motion Field:自然感来自“方向场”,不是 random

很多粒子系统不好看,是因为用了太多随机数。

随机数只能避免重复,不能自动产生自然感。自然感来自 motion field:一套整体一致的运动方向场。

这次我踩过的坑是:

  1. 每条彩带都有随机控制点,但整体没有一致方向。
  2. 左右彩带有交叉,但交叉没有空间逻辑。
  3. 起点随机,但集中在相近区域,实际观感还是一坨。

也就是说,random 只是 variation,不是 composition。

一个更靠谱的思路是:

1
2
先定义整体动势:从两侧 / 中下区域进入,向中上区域扩散,再缓慢下落淡出
再给每个粒子加小随机:角度 ±10°,速度 ±20%,尺寸 ±30%,delay 分层

如果反过来,先给每个粒子随机,再希望整体自然,通常会失败。


Distribution:满不满,取决于分布,不只取决于数量

用户后来反馈“感觉不够满”。第一反应容易是加数量。但只加数量会导致两个问题:

  1. 原来空的地方还是空。
  2. 原来挤的地方更挤。

所以要区分两个概念:

概念解决什么
Count粒子数量
Distribution粒子分布

这次最终我同时做了两件事:

  1. 提高粒子数量:彩纸、亮片、星芒都增加。
  2. 扩大覆盖区域:横向更接近全屏,纵向从中下区域扩到中上/中下区域。

这才让画面变“满”。如果只加 count,不改 distribution,只会更乱。

一个实用判断:如果你觉得画面“不够满”,先看热力图,不要先加粒子。


Lifetime:动画太快,本质是生命周期太短

用户一开始还反馈“太快了”。这通常不是单个粒子速度的问题,而是 lifetime 的问题。

Lifetime 包含:

  1. 出生 delay。
  2. 可见时间。
  3. fade out 时间。
  4. 整体 overlay 持续时间。

如果所有粒子几乎同时出现、同时消失,即使速度不快,也会显得“啪一下没了”。如果 delay 分层,粒子批次进入,动画会更有呼吸。

这次我把整体展示时间拉长,并让不同粒子有不同 delay。这样用户看到的是一段庆祝过程,而不是一帧贴图。


Toast:中心信息不能和粒子抢主视觉

这个特效里还有一个中心段位 toast。

Toast 的任务是告诉用户:你升到了哪个段位。粒子的任务是表达情绪。二者不能互相抢。

我中间也犯过一个错误:段位 badge 设计太突兀,颜色和粒子不搭,又不够明显。后来调整为更克制的青山湖风格,用深绿/湖水青承载,标题变大,减少花哨颜色。

这里的原则是:

  1. Toast 负责信息。
  2. 粒子负责情绪。
  3. Toast 颜色要稳定,粒子颜色可以跳。
  4. Toast 不要跟粒子一样花。

如果中心信息也花,背景粒子也花,用户就不知道该看哪里。


Canvas 实现:为什么不用一堆 SwiftUI View

这次最终用 SwiftUI Canvas 画粒子,而不是 ForEach 渲染一堆 View。

原因很直接:

方案优点问题
多个 SwiftUI View写起来直观大量 View diff,动画状态复杂
Canvas单层绘制,适合粒子要自己管理坐标、时间、路径

粒子系统本质上是每帧根据时间计算位置,然后画出来。Canvas 更贴近这个模型。

大致结构是:

1
2
3
4
5
6
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in
ConfettiCanvas(
elapsed: timeline.date.timeIntervalSince(startDate),
frozen: false
)
}

elapsed 是整个系统的时钟。每个粒子根据 elapsed - delay 算自己的局部时间,再算 position、rotation、opacity。

这个模型比给每个粒子挂一堆 .animation 更可控。


这次最后留下的系统结构

最终版本可以理解成 3 层:

层级元素作用
主粒子层Confetti rectangles庆祝语义
辅助粒子层Circular chips增加密度和面积
高光层Diamond glints + cross sparkles精致感、瞬间感

被删掉的是:

元素删除原因
Streamers曲线难自然,抢画面
Burst太像烟花,不符合产品气质
Heavy bloom污染背景,可读性差

这个结构其实很朴素,但稳定。

很多 UI 特效最后不是赢在复杂,而是赢在“每个元素都知道自己负责什么”。


我的判断框架

以后再做类似粒子特效,我会按这个顺序判断:

问题优先调什么
不像庆祝emitter 方向、颜色亮度、confetti 形状
太乱删除连续元素,降低 bloom,减少同步运动
不够满distribution 先于 count
太快lifetime、delay、fade out
不自然motion field 先于 random
主信息不明显toast 层级、粒子避让、颜色对比
廉价少用爆点和大光晕,控制高饱和面积

一句话:先定语义,再定动势,最后才调随机参数


要不要用粒子系统

我的判断是:值得用,但要克制。

适合用的场景:

  1. 用户完成一个阶段性目标。
  2. 系统需要表达奖励。
  3. 状态变化低频且重要。
  4. 动画不会打断用户工作流。

不适合用的场景:

  1. 高频反馈。
  2. 错误提示。
  3. 信息密集页面。
  4. 用户正在专注输入或阅读。

Argos 这次段位晋级适合用粒子系统,因为它是低频、正向、阶段性的反馈。但最终也证明了一点:工具型产品里的庆祝动画,不应该朝游戏化一路狂奔。真正合适的方向是:有情绪,但不喧宾夺主

这也是我对这次迭代最大的收获。