上一篇介绍了PrefabPoolManager,它负责复用 Prefab 实例。但游戏特效除了实例创建,还需要处理粒子播放、拖尾清理、生存时间、时间缩放和所属对象销毁等问题。
MyFramework 使用EffectManager在PrefabPoolManager之上继续封装,统一管理 3D 特效的创建、播放、回收和释放。
项目地址:
https://github.com/ZHOURUIH/MyFramework
一、为什么不能只使用 PrefabPoolManager
通过对象池获得一个特效 Prefab 后,业务仍然需要处理:
找到所有 ParticleSystem 找到所有 TrailRenderer 开始或停止粒子 清除上次残留的拖尾 计算特效生存时间 播放结束后回收 处理暂停和时间缩放 所属角色销毁时回收特效如果每种技能都重复实现这些逻辑,特效代码很容易散落到角色、技能和场景管理器中。
EffectManager将一个特效封装为:
GameEffect ├── GameObject ├── ParticleSystem列表 ├── TrailRenderer列表 ├── Animator列表 ├── 生存时间 ├── 播放状态 └── 对象池信息业务层只需要决定:
播放什么特效 播放到什么位置 是否跟随某个对象 持续多长时间二、创建一个临时特效
先定义特效分类:
public static class EFFECT_TAG { public const int BATTLE = 1; public const int SCENE = 2; public const int UI = 3; }在指定位置创建一个持续一秒的受击特效:
GameEffect effect = mEffectManager.createEffect( "Effect/Battle/Hit.prefab", null, null, EFFECT_TAG.BATTLE, true, true, hitPosition, 1.0f);参数分别表示:
资源路径 挂接的框架对象 GameObject父节点 特效Tag 创建后是否激活 是否通过移动到远处隐藏 初始位置 生存时间资源路径相对于:
Assets/GameResources并且必须带文件后缀。
如果只是播放一次,不需要保存返回值。生存时间结束后,EffectManager会自动将它回收到特效池。
三、GameEffect 如何管理粒子
GameEffect.setObject()会缓存 Prefab 中的相关组件:
public override void setObject(GameObject obj) { base.setObject(obj); mParticleSystems.Clear(); mTrailRenderers.Clear(); mEffectAnimators.Clear(); if (mTransform == null) { return; } mTransform.GetComponentsInChildren( mParticleSystems); mTransform.GetComponentsInChildren( mTrailRenderers); mTransform.GetComponentsInChildren( mEffectAnimators); foreach (ParticleSystem particle in mParticleSystems) { var main = particle.main; main.cullingMode = ParticleSystemCullingMode.Pause; } mTrailRenderers.For( trail => trail.autodestruct = false); }这些组件只在构建GameEffect时查找一次,后续复用时直接使用缓存列表。
播放特效:
effect.play();停止特效:
effect.stop();停止时会统一处理:
停止全部粒子 清空粒子内容 清除TrailRenderer拖尾 恢复默认时间缩放设置这样重新播放对象池中的特效时,不会显示上一次残留的拖尾。
四、生存时间如何自动销毁特效
创建特效时可以设置:
effect.setLifeTime(1.0f);GameEffect.update()中会更新计时器:
if (tickTimerOnce( ref mLifeTimer, elapsedTime)) { mIsDead = true; }EffectManager.update()会遍历正在使用的特效:
using var a = new SafeListReader<GameEffect>(mEffectList); foreach (GameEffect effect in a.mReadList) { if (effect.isActiveInHierarchy()) { effect.update(elapsedTime); } if (!effect.isDead()) { continue; } if (effect.isInEffectPool()) { destroyEffectInPool(effect); } else { destroyEffectNoPool(effect); } }特效只需要将自己标记为死亡,最终采用哪种方式回收,由EffectManager统一决定。
五、EffectManager 提供了两层复用
createEffect()并不是直接从PrefabPoolManager获取对象。
它会先尝试从专用的GameEffectPool获取已经构建完成的特效:
GameEffect effect = mGameEffectPool.getOneEffect( parent, nameWithPath, pos, moveToHide, active, lifeTime); if (effect != null) { return mEffectList.add(effect); } return createEffectNoPool( nameWithPath, attachedParent, parent, tag, active, moveToHide, pos, lifeTime, true);这里存在两层对象池:
PrefabPoolManager 复用GameObject实例 GameEffectPool 复用已经完成组件扫描和初始化的GameEffect只使用PrefabPoolManager时,重新获取实例后还需要创建GameEffect,并重新收集所有粒子、拖尾和 Animator。
GameEffectPool会保留完整的GameEffect,再次播放时可以直接设置位置和生存时间。
因此,常用临时特效优先使用:
createEffect(...)只有不希望进入专用特效池时,才直接使用:
createEffectNoPool(...)这里的NoPool指的是不进入GameEffectPool,底层 GameObject 仍然来自PrefabPoolManager。
六、特效池内部如何管理对象
GameEffectPool按照资源路径分别保存使用中和未使用特效:
protected Dictionary< string, SafeList<GameEffect>> mUnusedEffectList = new(); protected Dictionary< string, List<GameEffect>> mInusedEffectList = new();回收特效时:
public void unuseEffect(GameEffect effect) { mInusedEffectList .get(effect.getFilePath()) ?.Remove(effect); mUnusedEffectList .getOrAddNew(effect.getFilePath()) .add(effect); effect.setUnuseTime(DateTime.Now); }下次播放同一路径的特效时,优先取出未使用实例:
GameEffect effect = effectList.removeAt( effectList.count() - 1); effect.setLifeTime(lifeTime); effect.setDead(false);默认超过 60 秒没有再次使用的缓存特效会被真正销毁:
mEffectManager.setUnuseMaxTime(60);所以特效池并不会永久保留所有曾经播放过的特效。
七、异步创建要关联对象生命周期
角色刚释放技能时开始加载特效,但加载完成前角色可能已经死亡或离开场景。
这种情况应使用安全异步接口:
mEffectManager.createEffectAsyncSafe( "Effect/Battle/Buff.prefab", character, character, true, (effect) => { effect.setPosition( character.getPosition()); }, EFFECT_TAG.BATTLE, true, -1.0f);这里的两个character分别表示:
relatedObject 用于检查异步加载期间对象是否仍然有效 attachedParent 记录特效挂接在哪个框架对象上加载完成后,如果relatedObject已经被销毁或从对象池重新分配,特效不会继续交给旧回调。
如果attachedParent被销毁,EffectManager也会自动销毁挂接到它身上的所有特效。
内部会为挂接对象注册销毁回调:
attachedParent.addDestroyCallback( mObjectDestroyCallback); mEffectAttachList .getOrAddListPersist(attachedParent) .Add(effect);这样角色销毁时,不会遗留仍在播放的 Buff 或技能特效。
八、高频简单粒子使用 QuickEffect
大量角色同时受击时,可能需要在一帧内播放很多相同的火花粒子。
如果每次都获取一个完整 Prefab 实例,即使使用对象池,也要维护大量 GameObject。
这类特效可以使用:
mEffectManager .playEffectAsyncAtPositionQuick( "Effect/Battle/HitSpark.prefab", hitPosition, EFFECT_TAG.BATTLE);QuickEffect不会为每次播放创建独立节点,而是复用同一个粒子系统,并调用:
item.Emit( emission.GetBurst(j).maxCount);粒子模拟空间会被设置为世界空间,因此同一套粒子系统可以在不同位置连续发射。
它适合:
非循环粒子 不带拖尾 简单Burst发射 同一特效高频出现在多个位置不适合:
循环特效 TrailRenderer拖尾 需要独立停止的特效 依赖复杂粒子模块的特效QuickEffect是针对特定高频场景的优化方式,并不是普通特效的默认替代方案。
九、手动销毁和批量清理
需要提前停止一个特效:
mEffectManager.destroyEffect( ref mBuffEffect);对于来自GameEffectPool的特效,默认只是回收。
要求真正销毁:
mEffectManager.destroyEffect( ref mBuffEffect, true);按 Tag 批量销毁:
mEffectManager.destroyAllEffectWithTag( EFFECT_TAG.BATTLE);适合在退出战斗或切换场景时清理整类特效。
不要直接调用:
Object.Destroy( effect.getGameObject());否则会绕过EffectManager、GameEffectPool和PrefabPoolManager保存的状态。
十、总结
MyFramework 的特效管理分为三种方式:
createEffect 复用完整GameEffect,适合大多数临时特效 createEffectNoPool 只复用Prefab实例,不缓存完整GameEffect QuickEffect 复用一个粒子系统在多个位置快速发射完整生命周期是:
加载Prefab ↓ 创建或复用GameEffect ↓ 缓存粒子、拖尾和Animator ↓ 播放并更新生存时间 ↓ 到期或主动销毁 ↓ 回到GameEffectPool ↓ 长时间不用后真正释放PrefabPoolManager解决的是 Prefab 实例复用,而EffectManager继续解决特效播放状态和业务生命周期。
业务层只需要表达特效的路径、位置、所属对象和持续时间,粒子停止、拖尾清理、对象复用及最终释放都由框架统一处理。