☰
基于Flutter自绘的轻量粒子系统:爆炸特效与得分飞字实战
2026/10/17 14:00:02 网站建设 项目流程

做游戏最绕不开的两个反馈表现,一个是打击感,一个是得分反馈。打击感靠画面震动、特效、音效堆出来,得分反馈靠的就是飞字和飘分。之前在某跨平台系统上做一个休闲小游戏,到了表现层优化阶段,发现手头现成的特效库要么太重,要么和自绘渲染管线配合不顺畅,就决定自己手搓一个轻量粒子系统。正好借这个机会,把 Flutter 自定义绘制和 OpenHarmony 的跨端适配都走了一遍,最后做出来的简易爆炸效果和得分飞字,虽然不算华丽,但胜在干净、可控、性能稳,正好把整套粒子系统的骨架搭起来了。

这篇东西适合谁看?如果你已经用 Flutter 写过一点界面,想在游戏或互动场景里加一些自绘特效,又不想直接引一个大而全的粒子引擎,那这篇的拆解思路和代码骨架应该能帮你省不少时间。我尽量把设计取舍、参数调优和踩过的坑都写明白,不光是贴代码,更把这些操作背后的为什么讲清楚。

1. 项目概述与整体设计思路

1.1 为什么从粒子系统切入游戏表现层

粒子系统在游戏开发里是个很经典的基础模块。几乎所有动态视觉反馈都能拆成粒子的组合:爆炸是粒子高速扩散、烟雾是粒子缓慢上飘、得分飞字是粒子上浮淡出、连击提示也可以做成一簇特殊粒子。把这个系统搭好,后面再做其他特效只是换参数的事,不用重复造轮子。

我选这个方向还有个现实原因:当时在 OpenHarmony 设备上跑 Flutter 应用,发现很多现成的 Flutter 特效插件在这个环境上多多少少有些水土不服,要么是用了不兼容的底层 API,要么是性能表现一般。而粒子系统用 Flutter 自带的 CustomPaint 就能实现,依赖极少,跨端适配也相对省心。与其到处找库,不如把基础能力握在自己手里。

从游戏开发进阶的角度看,粒子系统是绕不开的一个分水岭。单纯做界面布局、状态管理,和做有表现力的游戏化交互,中间隔着的就是这类动态效果的自研能力。学会自己控制粒子的生成、运动、消亡和控制渲染开销,才算真正摸到了游戏表现层的边。

1.2 技术方案选型:自绘 CustomPaint 还是外部粒子引擎

技术选型的时候我纠结过一阵子。市面上有现成的粒子引擎,功能丰富,粒子数几千上万都不怕,内置发射器、重力场、碰撞检测这些高级功能。但对这个项目来说,它有几个问题:

第一,体积问题。一个游戏小项目引一个大型粒子库,包体积和启动开销都不划算。第二,可控性问题。现成引擎的参数体系是为通用场景设计的,真要到某一个特效上做精细调优,反而是背着一大堆用不上的概念在操作。第三,也是最重要的,适配问题。在 OpenHarmony 的 Flutter 环境里,第三方库的底层实现不一定都做了充分兼容,出了问题排查成本很高。

所以最终方案回到了 Flutter 自带的 CustomPaint + CustomPainter,自己管理粒子对象、生命周期、绘制逻辑。这个方案有几个明显优势:

  • 不引额外依赖,纯 Flutter 标准能力,跨端表现一致
  • 绘制逻辑完全在自己手里,100 个粒子和 300 个粒子的行为差异自己说了算
  • 可以方便地和游戏逻辑层做事件对接,比如点击位置触发爆炸、得分回调触发飞字

方案也有代价:高级效果得自己写。比如粒子碰撞、涡流扰动、纹理贴图这些,都得后续一点点加。但对爆炸和飞字这两个场景来说,这是很合理的选择——杀鸡不用牛刀,核心逻辑自己掌控。

2. 粒子系统核心架构设计

2.1 粒子类设计:位置、速度、生命周期的抽象

在动手写代码之前,先把粒子抽象成什么样想清楚,后面会省很多事情。我见过不少人一上来就写一个又大又全的粒子类,把所有可能的属性都塞进去,结果大部分字段在大多数场景里都用不上,还白白占内存。更好的做法是先定义最小公共接口,再针对具体效果做扩展。

我这里的粒子基类只保留了四个最基本的东西:位置、速度、生命值、存活状态。真要说的话,大小和颜色也是必须的,但这两个在不同特效里差异很大,更适合放到子类里去定义,基类里只留一个统一的绘制接口和更新接口。

abstract class Particle { Offset position; Offset velocity; double life; // 剩余生命,单位:秒 double maxLife; // 初始生命,用于计算生命比例 bool alive; Particle({ required this.position, required this.velocity, required this.life, }) : maxLife = life, alive = true; void update(double dt) { if (!alive) return; life -= dt; if (life <= 0) { alive = false; return; } position += velocity * dt; } void draw(Canvas canvas, Paint paint) {} double get lifeRatio => (life / maxLife).clamp(0.0, 1.0); }

这个抽象的精髓在于lifeRatio,它把生命周期归一化到 0 到 1,所有渐入渐出、缩放、颜色变化都基于这个比例计算。爆炸粒子从 1 降到 0 的过程,就是它从亮到暗、从大到小、从清晰到消散的过程。飞字粒子的透明度变化也基于同样的逻辑。

2.2 粒子管理器:统一驱动更新与绘制

有了粒子,还需要一个管理器来统一调度。它的职责很单纯:持有所有存活粒子的列表(用链表或类似结构,方便移除死粒子)、提供生成粒子的入口、每帧驱动所有粒子走一遍更新逻辑,再把存活的粒子交给 Painter 绘制。

这里有个细节值得注意:不要在遍历列表的时候直接删除元素。一边遍历一边删,要么用下标逆序遍历,要么先标记、遍历完再统一移除。我在早期版本里吃过这个亏,用正序遍历加removeAt,结果跳过了元素,一部分粒子提前被回收了。

管理器的更新入口由一个Ticker驱动。Flutter 里启动一个动画帧循环非常方便,不用自己写循环:

class ParticleSystemManager with ChangeNotifier { final List<Particle> _particles = []; Ticker? _ticker; void start() { _ticker ??= createTicker((elapsed) { final dt = _lastElapsed == null ? 1 / 60 : (elapsed - _lastElapsed!) / Duration.millisecondsPerSecond; _lastElapsed = elapsed; update(dt); notifyListeners(); }); _ticker!.start(); } void update(double dt) { for (var p in _particles) { p.update(dt); } _particles.removeWhere((p) => !p.alive); _particles.sort(_compareByLayer); // 飞字在顶层 } void spawn(Particle particle) { _particles.add(particle); } void draw(Canvas canvas, Size size) { for (var p in _particles) { p.draw(canvas, Paint()); } } }

注意ChangeNotifier的引入。粒子系统是独立于 Widget 之外的数据层,但它需要通知 UI 重绘。与其手动调setState,不如让粒子管理器继承ChangeNotifier,然后用ListenableBuilder或AnimatedBuilder包裹 CustomPaint,数据一更新就自动重绘。这样把 UI 刷新和业务逻辑解耦得很干净。

2.3 渲染层结构:Stack 叠加与 RepaintBoundary 隔离

渲染层的结构我用了最简单的方案:一个 Stack,底层放游戏内容,顶层放一个 IgnorePointer 包裹的 CustomPaint 作为粒子覆盖层。IgnorePointer很关键,因为粒子层要响应点击位置生成特效,但它本身不能拦截点击事件——点击还是要透传给下面的游戏内容区的。

class ParticleOverlay extends StatelessWidget { final ParticleSystemManager manager; const ParticleOverlay({super.key, required this.manager}); @override Widget build(BuildContext context) { return IgnorePointer( child: RepaintBoundary( child: ListenableBuilder( listenable: manager, builder: (context, _) { return CustomPaint( size: Size.infinite, painter: ParticlePainter(manager), ); }, ), ), ); } }

RepaintBoundary是性能安全的保证。它能隔离粒子层的重绘区域,避免粒子每帧刷新的时候把下面整个游戏界面也带着重绘一遍。没有这层隔离的话,粒子一多就会明显感到帧率下降,因为 CPU 和 GPU 的开销都被放大了。

这个结构搭好之后,整个系统就有了统一入口:游戏任何位置需要触发爆炸或者得分飞字,只需要拿到ParticleSystemManager的引用,调用对应的spawnExplosion或spawnScoreText方法就行,不需要关心渲染细节。这也是分层设计的好处——数据和行为统一走管理器,UI 只是被动的展示者。

3. 简易爆炸特效实现

3.1 爆炸粒子的数据模型:速度向量与颜色生命周期

爆炸的特效本质,是一群粒子从中心点向外高速扩散,同时经历颜色变化、尺寸衰减和透明度淡出。数据模型上我在粒子基类基础上做了扩展:增加了半径、初始颜色和结束颜色。

颜色渐变是爆炸视觉冲击力的重要来源。刚炸开的时候是亮白色,因为温度最高;然后快速过渡到橙红色,因为温度在下降;最后慢慢淡出消失在背景里。这个效果不用复杂算法,用Color.lerp结合生命比例就能做得很自然:

class ExplosionParticle extends Particle { double radius; Color startColor; Color endColor; @override void draw(Canvas canvas, Paint paint) { final t = lifeRatio; final color = Color.lerp(startColor, endColor, 1 - t)!; paint.color = color.withOpacity(t); // 生命越少越透明 paint.maskFilter = MaskFilter.blur(BlurStyle.normal, radius * 0.3); canvas.drawCircle(position, radius * (0.6 + 0.4 * t), paint); } }

这里t从 1 慢慢变成 0,Color.lerp(startColor, endColor, 1 - t)就是从 startColor 过渡到 endColor。透明度用t本身,刚开始完全不透明,快消失的时候趋近于完全透明。半径也做了处理,生命初期粒子略大,逐渐缩小,这样爆炸的消散感更强。

有个小细节:MaskFilter.blur会给粒子加一点柔和的边缘,避免一整圈硬边圆点看起来像撒了一地小米粒。但模糊效果是有额外渲染开销的,粒子数量多的时候要注意,后文性能优化部分我会专门再提这一点。

3.2 爆炸粒子初始化:随机方向与扇形分布

初始化的时候,关键是把粒子速度的分布做好。如果所有粒子速度都一模一样,爆炸出来就是规则的圆形扩散,很假。真实爆炸应该是各个方向不均匀的——有些粒子飞得远、有些飞得近、有些先冲出去、有些明显更慢。

代码里我用随机角度加随机速度的做法:

void spawnExplosion(Offset center, {int count = 80, double power = 400}) { for (int i = 0; i < count; i++) { final angle = Random().nextDouble() * 2 * math.pi; final speed = power * (0.3 + Random().nextDouble() * 0.7); final velocity = Offset(math.cos(angle), math.sin(angle)) * speed; final life = 0.5 + Random().nextDouble() * 0.4; _manager.spawn( ExplosionParticle( position: center, velocity: velocity, life: life, radius: 3 + Random().nextDouble() * 4, startColor: Colors.white, endColor: Colors.orange, ), ); } }

速度分布从 30% 到 100% 之间随机取,再配合不同粒子 0.5 到 0.9 秒不等的寿命,爆炸整体看上去就是跌宕起伏的,粒子纷纷扬扬往外飞,而不是整齐划一的圆环扩散。这里的经验是:所有参数都不要给固定值,尽量给一个范围,随机化才是自然感的第一来源。

爆炸位置如果发生在游戏场景里,最好再配合一次小范围震动。震动不在粒子系统内实现,而是在逻辑层感知爆炸事件的时候做一个屏幕偏移动画。粒子系统和屏幕震动解耦,反而更灵活。

3.3 爆炸事件对接:点击位置与逻辑回调

爆炸特效要能响应实际游戏逻辑。比如玩家点击得分点,就得分加爆炸;角色死亡,就在死亡位置爆一簇粒子。事件对接的核心是把逻辑层的位置坐标正确传递到粒子层。

这里最容易踩的坑是坐标系不一致。Flutter 里的GestureDetector返回的localPosition是相对于手势组件坐标系的,而粒子层可能覆盖在整个 Stack 上,坐标系原点不同。直接用的话爆炸位置会偏。解决办法是在入口做一次坐标转换:把粒子层 CustomPaint 的全局位置取出来,减去偏移量。

void onTapDown(TapDownDetails details) { final globalPos = details.globalPosition; final overlayLocal = overlayKey.currentContext.findRenderObject() as RenderBox; final localPos = overlayLocal.globalToLocal(globalPos); _manager.spawnExplosion(localPos); }

这样无论游戏场景怎么嵌套布局,粒子都能准确地炸在用户点击的那一点上。如果粒子层和手势组件在同一坐标系下,也可以直接用localPosition,但多一步转换成本很低,却能彻底规避坐标偏差问题。

逻辑回调和特效的配合也要注意先后顺序。先更新游戏逻辑状态,再触发特效;还是先触发特效再更新逻辑?我的习惯是先更新逻辑、后触发特效,这样特效的位置和状态都是最新值,避免出现粒子炸在错误位置或者分数已经变了特效却是旧数值的情况。

4. 得分飞字粒子实现

4.1 与爆炸粒子的本质区别:文字绘制与静态呈现

得分飞字和爆炸粒子看着差别很大,一个是文字标识,一个是颗粒特效,但在粒子系统框架下它们的本质是高度统一的:都是一群“有位置、有生命周期、会变化”的绘制对象。唯一不同的是爆炸粒子每帧画的是圆形,飞字粒子每帧画的是文字。

飞字粒子继承粒子基类,把绘制内容换成文字,更新逻辑简化一些——它不需要速度向量扩散,只需要垂直上浮和透明度变化。甚至可以复用基类的velocity字段,把速度的 y 轴设成负值,让它自然向上飘。

class ScoreTextParticle extends Particle { final String text; final double fontSize; final Color color; @override void update(double dt) { if (!alive) return; life -= dt; if (life <= 0) { alive = false; return; } // 先快后慢的上浮缓动 position = Offset(position.dx, position.dy - 60 * dt * lifeRatio); } @override void draw(Canvas canvas, Paint paint) { final tp = TextPainter( text: TextSpan( text: text, style: TextStyle( color: color.withOpacity(lifeRatio), fontSize: fontSize * (1.0 + (1 - lifeRatio) * 0.2), fontWeight: FontWeight.bold, ), ), textDirection: TextDirection.ltr, textAlign: TextAlign.center, )..layout(); tp.paint( canvas, Offset(position.dx - tp.width / 2, position.dy - tp.height / 2), ); } }

文字绘制用TextPainter是 Flutter 的标准做法。需要特别注意textDirection必须显式指定,否则在有些环境上可能不渲染文字或者抛异常。另外TextPainter在使用前要先调用layout(),否则paint不会画出任何内容,这是个经常被新手忽略的坑。

4.2 飞字动效参数:上浮距离、持续时长与缩放节奏

飞字动效讲究一个适度。飞得太快看不清楚内容,飞得太慢拖节奏;飞得太高飘出屏幕外,飞得太低显得局促。我反复试过几组参数,最后敲定了一套比较舒服的默认值:

参数数值说明
持续时长0.7 秒不长不短,刚好够扫一眼又不拖沓
上浮距离50~60 像素和字号匹配,不会飘太远
初始字号20~24在手机上适中,大屏可以调大
透明度变化前 60% 保持,后 40% 渐出先让玩家看清文字,再淡出
轻微放大最大 20%给飞字一点点“弹出来”的张力感

上浮距离我用的是缓动方式而不是固定速度:早期移动快、后期移动慢。这样飞字刚出现的时候有一种“冒出来”的冲劲,快要消失时又稳稳停住,视觉上更舒服。如果用固定速度匀速上浮,看起来就很僵硬,像字被线拉着跑。

飞字的缩放我做得很克制。只在最前面 20% 的生命周期内放大到 1.2 倍,后面快速收回。这样既有弹出来的瞬间感,又不会让字一直忽大忽小地闪烁。做特效的审美原则就是:动效越克制,高级感越强。爆炸可以狂野,但得分反馈要清楚利落。

4.3 批量飞字与多得分点并发处理

游戏里往往不止一个得分点。连击的时候可能同一帧冒出三个“+100”,多个敌人同时死亡时也可能出现五六簇飞字同时飘起。这种情况下飞字粒子系统要有两个能力:批量生成无视性能和语义冲突,多粒子并存不会互相覆盖。

批量生成只需要在入口做一个循环调用:

void spawnScoreTexts(List<ScoreInfo> scores) { for (final score in scores) { final randOffset = Offset( Random().nextDouble() * 30 - 15, Random().nextDouble() * 20 - 10, ); _manager.spawn( ScoreTextParticle( text: '+${score.value}', position: score.position + randOffset, life: 0.7, fontSize: 22, color: Colors.amber, ), ); } }

每个飞字的位置加一点随机偏移,避免同一得分点上的多个飞字完全重叠、互相遮挡看不清。偏移范围控制在 15 像素以内,不会让人觉得文字乱飘,但又足够错开层次。

多粒子并存这块,粒子管理器的_particles列表天然支持,只要每次spawn都是新增粒子对象就行。需要注意的点是绘制顺序:粒子管理器里最好维护一个简单的层级概念。爆炸粒子属于底层,飞字粒子属于顶层,这样飞字永远压在爆炸特效上面,不会被抢了视觉焦点。实现方式很简单,给粒子基类加一个layer字段,默认 0,飞字赋值 1,绘制前按 layer 排一次序即可。粒子数量少排序开销可以忽略。

5. 完整实操流程与参数调优记录

5.1 从零搭建 Flutter 粒子 Demo 的七个步骤

整个项目从零开始搭,大概经历七个步骤。把它们串一遍,方便你直接照做:

第一步,创建 Flutter 工程。如果你要跑 OpenHarmony,记得确保 Flutter 环境已适配到对应系统,具体在工程配置里选择目标设备即可。

第二步,定义粒子基类和管理器。把上面的Particle抽象类和ParticleSystemManager写进项目。这一步是整个系统的地基,后面所有特效类型都往里面挂。

第三步,实现爆炸粒子和飞字粒子两个子类。先不追求效果完美,能画出来、能动起来就行。跑通后再慢慢调参。

第四步,搭建粒子覆盖层。用Stack包裹游戏内容,顶层放IgnorePointer + RepaintBoundary + ListenableBuilder + CustomPaint的组件链。

第五步,把粒子管理器和游戏逻辑层对接。点击事件里解析坐标并调用spawnExplosion,得分回调里调用spawnScoreText。

第六步,启动粒子管理器。在页面初始化时调用manager.start(),页面销毁时调用manager.dispose(),避免资源泄漏。

第七步,调参。把爆炸粒子的速度、数量、颜色,飞字的时长、距离、字号逐个调到自己项目里最舒服的手感。

5.2 爆炸效果核心参数速查与调优心得

给你一张参数速查表,这是我反复试出来的实用区间,可以直接当起点用:

参数项默认区间调优方向
爆炸粒子数量60~120数量越多视觉越密,但超过 150 会明显影响低端机性能
初始速度200~500 px/s越大扩散越猛,适合大招特效
粒子寿命0.4~0.9 秒寿命越短越清脆,越长越有余韵
粒子半径3~7 px小半径显细腻,大半径更有冲击力
颜色过渡白→橙红也可以白→黄、红→黑,不同色调表达不同打击类型
透明度曲线线性衰减想强化爆发感可以改为前半段保持后半段骤降

调参的心得最重要的一点是:不要试图一次性调到位。我习惯把参数全部抽成常量,比如kExplosionCount、kExplosionPower,改一个就重启看一次,每次只变一个维度,这样能准确知道哪个参数影响的是什么观感。有一次我一次性调了数量和速度两个维度,结果视觉变化了也说不清是谁贡献的。这种“控制变量法”在参数调优里比什么都好使。

爆炸强烈的打击效果还有一个秘密配方:粒子分层。把粒子分成三层来生成,内圈速度快、数量少、粒子大;中圈速度中等、数量多;外圈速度慢、粒子小。三层叠加,观感一下子从一个稀薄的圆环变成有层次的火花柱,真实感提升非常明显。这个技巧我只在自己项目里反复实验过才琢磨明白,文档里很少见到。

5.3 飞字参数的 UI 动效平衡:字号、间距与节奏控制

飞字参数调的思路和爆炸不同。爆炸追求冲击力,飞字追求可读性和节奏感。字体太小看不清,太大又挡游戏画面;出现太快看不清,太慢又让玩家等。

我实测下来,在 1080P 级别的屏幕上,20~24 号字的飞字清晰度和存在感最好。如果是 4K 大屏,按屏幕宽度等比放大到 28~32 也不过分。飞字和爆炸效果之间的时间配合也很重要:得分飞字应该在爆炸开始后 0.1 秒左右出现,太早会被爆炸的光效盖住,太晚又显得得分和打击脱离,玩家会感觉迟疑。

飞字叠加时还要注意间距。如果大量飞字从同一得分点冒出,要确保它们最密的时候也不会糊成一团看不清。经验值是飞字之间的最小间距在 24 像素以上,随机偏移能保证这一点就够用。我之前做过一组密集连击测试,12 个飞字同屏,随机偏移后依然能逐个辨认,基本就是合格的。

6. 常见问题与排查技巧实录

6.1 粒子不显示的三种排查路径

做了这么多次特效,粒子不显示是最常见的故障,而且往往原因很低级。我总结了三类排查路径,按概率排序:

第一类:CustomPaint 没有尺寸。CustomPaint 的 child 或者 size 参数必须至少有一个有效值,否则画布尺寸为 0,什么都不会画出来。排查方法很简单:给 CustomPaint 包一层SizedBox.expand(),或者渲染层用size: Size.infinite。

第二类:粒子管理器没启动或者每帧没有触发刷新。确认Ticker在运行,ChangeNotifier有监听者。粒子更新了但 UI 不重绘,视觉上就是“没反应”。检查ListenableBuilder是否包裹了 CustomPaint,以及监听对象是否就是同一个 manager 实例。

第三类:粒子生命周期算错了。如果粒子的life初始值设置成负数,或者dt传入了 NaN/巨大值,粒子可能在第一帧就被判为死亡。我曾在 dt 计算上踩过坑:首帧_lastElapsed为空时直接取 1/60,否则elapsed差值会把整个生命周期归零。所以在管理器代码里我做了首帧保护,这段代码值得保留。

这三个路径排查完,粒子显示问题大概率能解决。如果还没显示,再检查一下spawn是否真的被调用到了——可以在spawn方法里加一行打印,确认入口确实有数据进来。

6.2 爆炸后残留残影或粒子卡死的处理方法

残影问题通常是粒子淡出逻辑不够彻底导致的。透明度的计算是color.withOpacity(t),理论上 t 到 0 时粒子应该完全透明。但如果在life <= 0之前就提前把透明度衰减到一个很低但非 0 的值,就会留下很淡的“幽灵残影”,特别是爆炸粒子数量多的时候,满屏都是细小的半透明圆点。

解决方法是把存活判定和淡出计算对齐:t <= 0.05就强制标记死亡,而不是等着完全归零。我一般会在 update 里做一个if (lifeRatio < 0.03) alive = false;的提前回收。既避免了残影,也给系统省出一点性能。

粒子卡死则可能是管理器里排序或删除逻辑出现问题。比如排序比较器有副作用,或者removeWhere里写错了条件。排查这类问题最直接的方式是给管理器加一个调试面板:实时显示当前存活粒子数,暂停时还能逐帧观察粒子的速度和位置。粒子数异常飙升或者长时间不变,一眼就能看出来。

6.3 低端机上掉帧的四个优化方向

粒子系统性能问题的核心就一句话:一帧内绘制的量太大。低端机上掉帧,按下面四个方向排查,基本能覆盖所有情况:

第一,减少粒子总数。爆炸粒子从 100 个减到 60 个,视觉差异其实有限,但性能直接省了 40%。我做了一个自适应机制:根据设备的屏幕分辨率和刷新率动态调整粒子总数,高刷高分辨率设备开满,低端设备自动降档。

第二,减少模糊效果。MaskFilter.blur很吃 GPU 性能,粒子一多帧率就会垮。可以只在关键特效上保留模糊,或者把模糊半径调小。如果追求性能极致,直接放弃 blur,用多层渐变色圆叠加模拟柔和边缘。

第三,重绘区域的隔离。确保 RepaintBoundary 包裹粒子层,避免重绘扩散到整个页面。如果不确定有没有生效,可以用 Flutter 的性能调试工具显示重绘边界,看到粒子层的独立边界框就说明隔离成功了。

第四,避免每帧创建新对象。粒子绘制里频繁创建 Paint、TextPainter 是很大的开销。Paint 可以复用一个实例(虽然在有状态变化时需要重置),TextPainter 建议在粒子生成时就 layout 好,绘制时直接复用结果对象。我在早期版本里飞字粒子每帧都新建一个 TextPainter 并 layout,粒子一多就开始掉帧,改成缓存之后就顺畅多了。

6.4 跨端渲染差异与字体渲染细节

Flutter 项目跑到 OpenHarmony 上,大部分渲染能力是统一的,但有几个细节要特别留意。第一是字体差异:OpenHarmony 系统默认字体和 Android 不完全一致,中文飞字的字形渲染效果可能不同,需要在文本样式里显式指定fontFamily,否则在不同设备上看会有细微差别。

第二是TextPainter的国际化行为。没有显式设置textDirection时,中英文混排的默认结果可能不一样。我所有飞字文本都强制指定TextDirection.ltr,规避了大多数坑。

第三是自定义绘制的纹理适配。如果在粒子系统里用了图片纹理而不是简单图形,要确认图片资源在 OpenHarmony 环境下的加载路径正确。我建议粒子初期都用纯 Canvas 图元,简单稳定,等全部功能稳定后再考虑加纹理贴图。颜色渐变、透明度这些基础能力已经足够撑起不错的视觉效果,没必要一开始就上纹理。

7. 性能优化进阶与后续扩展方向

7.1 粒子池化:避免 GC 卡顿的实用方案

粒子系统跑时间长了,会有一个非常隐蔽的性能问题:频繁生成和销毁粒子对象,导致 GC(垃圾回收)压力增大,表现为周期性掉帧。游戏挂机几分钟后突然卡一下,多半就是这种问题。

解法是粒子池化。预先分配一定数量的粒子对象,用的时候从池子里取,用完了归还池子,而不是直接让 GC 回收。对爆炸这种高频生成场景,效果立竿见影。

class ParticlePool<T extends Particle> { final List<T> _pool = []; final T Function() _factory; ParticlePool(this._factory); T obtain() { if (_pool.isNotEmpty) { return _pool.removeLast()..reset(); } return _factory(); } void release(T particle) { particle.alive = false; _pool.add(particle); } }

实现时要给粒子类加一个reset()方法,把所有字段恢复初始值。这样才能重复使用而不残留上一次的状态。池子的初始大小根据峰值粒子数来定,爆炸和飞字同时出现的峰值场景能覆盖到就行。

使用池化之后,粒子对象创建从“每帧新建”变成“缓存复用”,GC 频率显著下降。我实测挂机 30 分钟,卡顿次数从每分钟 2~3 次降低到几乎为 0。如果你的游戏场景也是高频触发特效的,池化是必做项,不是可选优化。

7.2 分层渲染与独立画布的最佳实践

粒子系统再往后发展,单一画布会撑不住。爆炸粒子、飞字粒子、背景飘花、前景烟雾,全都画在一层的话,每次任何一类粒子更新,所有粒子都要跟着重新绘制一遍,浪费巨大。

更优的做法是分层渲染:爆炸一层、飞字一层、漂浮层一层,每层是一个独立的 CustomPaint 和 RepaintBoundary。某一层粒子变化只触发该层重绘,其他层原样不动。代价是多几个画布对象,但对性能的收益是实打实的。

分层的时候要注意 z 轴顺序。飞字永远在最上层,爆炸在中层,背景烟雾在下层。层与层之间的混合模式也可以做文章:底层烟雾用BlendMode.plus叠加,会让整个场景有一种发光的高级的氛围感,而不是死板的简单覆盖。

我现在项目的标准结构就是三层画布叠在 Stack 里,每层一个管理器,互不干扰。代码组织起来也更清晰——爆炸管理器只管爆炸,飞字管理器只管飞字,不用在一个大数组里做各种类型判断。

7.3 从爆炸到烟火:粒子系统的自然扩展路径

这套粒子系统搭好之后,扩展空间其实很大。爆炸粒子改一下初始化参数,就是烟花:粒子先被发射到空中,到达指定高度后再二次爆炸。二次爆炸的实现只需要在粒子死亡时生成一组新的粒子,相当于触发一个“子粒子回调”。

过渡的效果,连续小爆炸的节奏,雨滴落地的粒子溅射,甚至金币翻转的粒子撒落,都可以在这一套骨架里派生出来。关键是抽象层设计得对——粒子基类只关心位置、速度和生命周期,具体绘制内容完全由子类决定,新特效只写子类就行,不用改任何管理器逻辑。

如果后面想做更高级的 GPU 粒子,可以考虑用 Flutter 的片段着色器,或者原生纹理贴图配合 BlendMode 混合来实现更加复杂的视觉效果。但作为第一版,纯 Canvas 方案已经足够覆盖绝大多数游戏的常规反馈需求。

我个人在实际操作中的体会是,粒子系统的核心不是抄代码,而是建立一套“参数直觉”。当你看到爆炸效果不满意,能立刻判断出是该调速度还是该调数量还是该调颜色曲线,而不是盲试参数;当你想做新特效,能快速判断出它需要哪种粒子类型、是否需要子粒子回调、应该放哪个渲染层——有了这种直觉,任何特效在你手里都只是配置问题,而不是技术问题。这篇粒子系统的骨架,希望能帮你尽快建立起属于自己的那套参数直觉。之后再做火焰、烟雾、碎片飞溅,都只是在这个基础之上再加一些细节罢了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询