上一篇介绍了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 SafeListReaderGameEffect(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, SafeListGameEffect mUnusedEffectList new(); protected Dictionary string, ListGameEffect 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继续解决特效播放状态和业务生命周期。业务层只需要表达特效的路径、位置、所属对象和持续时间粒子停止、拖尾清理、对象复用及最终释放都由框架统一处理。