16 KiB
41 · VFX 架构与 URP 2D 光照指南
命名空间
BaseGames.VFX
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core(GlobalObjectPool)·BaseGames.Combat(HitFxType)· URP 2D Renderer · Feel(MMF_Player)
关联 07_FeedbackSystem(MMF_Player 集成)· 04_CombatSystem §2(HitFxType)· 27_PerformanceBudgetGuide §5(VFX 性能预算)
目录
- 系统总览
- VFX 分类与生命周期策略
- VFXPool — 粒子对象池
- HitFX — 命中特效路由
- 伤害闪白(HurtFlash)
- 形态切换调色板变换(PaletteSwap)
- URP 2D 光照架构
- 后处理 Volume 分区管理
- 区域 Shader 效果(Environmental Shader)
- VFX 性能约束
- 编辑器友好设计
1. 系统总览
VFX 架构回答两类问题:
- 何时、如何生成粒子特效,且不产生 GC spike
- 如何通过光照/后处理塑造区域氛围,不破坏像素风格统一性
VFX 系统职责:
├─ VFXPool → 粒子对象池(复用 ParticleSystem 实例)
├─ VFXCatalogSO → 命中类型 → VFX Prefab 的映射字典
├─ HitFXSpawner → 监听战斗命中事件,从 VFXPool 取粒子播放
├─ HurtFlashController → SpriteRenderer Material 属性块闪白
├─ PaletteSwapSystem → 形态切换时玩家精灵调色板替换
├─ LightLayerManager → 区域进出时切换 Light Layer 配置
└─ PostProcessManager → Volume Blend Weight 平滑过渡
零耦合原则:HitFXSpawner 监听 OnHitConfirmed 事件频道,不直接与战斗系统耦合。
2. VFX 分类与生命周期策略
| 分类 | 示例 | 生命周期策略 | 说明 |
|---|---|---|---|
| 命中特效 | 刀击火花、魔法爆炸 | 对象池(VFXPool) | 频繁触发,必须池化 |
| 状态特效 | 持续燃烧、中毒气泡 | Prefab 跟随目标 Transform | 随目标销毁,不需要池 |
| 环境特效 | 灰尘、落叶、水泡 | 场景内预放置,常驻播放 | 不产生运行时 Instantiate |
| 能力特效 | 冲刺残影、双跳光圈 | 对象池(VFXPool) | 中等频率,建议池化 |
| 死亡特效 | 敌人爆裂、Boss 死亡烟花 | Addressables.InstantiateAsync | 低频但复杂,不值得池化 |
| UI 特效 | 升级闪光、货币飞入 | Timeline Signal / MMF_Player | 在 UI Canvas 子层用 UI Particle |
原则:帧内可能多次触发(命中、脚步等)→ 池化;每关卡最多触发数次 → Addressables 按需加载。
3. VFXPool — 粒子对象池
VFXPool 是 GlobalObjectPool(见 13_ProjectileSystem §6)的上层封装,针对 ParticleSystem 定制:
namespace BaseGames.VFX
{
/// <summary>
/// ParticleSystem 专用对象池。
/// 粒子播放完成后自动回池,无需调用方手动归还。
/// </summary>
public class VFXPool : MonoBehaviour
{
// 每种 VFX Prefab 对应独立的子池
private Dictionary<AssetReferenceGameObject, Queue<ParticleSystem>> _pools = new();
/// <summary>在世界坐标 <paramref name="position"/> 播放一次特效。</summary>
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
Vector3 position, Quaternion rotation = default)
{
var ps = await GetOrCreate(vfxRef);
ps.transform.SetPositionAndRotation(position, rotation);
ps.gameObject.SetActive(true);
ps.Play();
// 自动回池:等待粒子系统完成
await UniTask.WaitUntil(() => !ps.IsAlive(true));
ps.gameObject.SetActive(false);
_pools[vfxRef].Enqueue(ps);
}
async UniTask<ParticleSystem> GetOrCreate(AssetReferenceGameObject vfxRef)
{
if (_pools.TryGetValue(vfxRef, out var queue) && queue.Count > 0)
return queue.Dequeue();
var go = await Addressables.InstantiateAsync(vfxRef, transform).Task;
return go.GetComponent<ParticleSystem>();
}
}
}
预热(Pool Warm-up)
在 GameManager.OnGameplayStarted 时预热高频 VFX:
// VFXCatalogSO 中声明需要预热的 VFX 及预热数量
[System.Serializable]
public struct VFXWarmupEntry
{
public AssetReferenceGameObject vfxRef;
public int warmupCount; // 默认建议 3–5
}
4. HitFX — 命中特效路由
HitFXSpawner 监听 OnHitConfirmed 频道,根据 DamageInfo.HitFxType 路由到对应 VFX:
VFXCatalogSO
[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
public class VFXCatalogSO : ScriptableObject
{
[Serializable]
public struct Entry
{
public HitFxType hitFxType;
public AssetReferenceGameObject vfxPrefabRef;
}
[SerializeField] Entry[] _entries;
private Dictionary<HitFxType, AssetReferenceGameObject> _map;
public void Initialize()
{
_map = new();
foreach (var e in _entries) _map[e.hitFxType] = e.vfxPrefabRef;
}
public bool TryGet(HitFxType type, out AssetReferenceGameObject vfxRef)
=> _map.TryGetValue(type, out vfxRef);
}
HitFxType(与 04_CombatSystem §2 对应)
| 枚举值 | 特效 | 建议粒子数 |
|---|---|---|
Spark |
金属碰撞火花 | ≤ 8 |
Blood |
敌人受击血迹 | ≤ 12 |
Magic |
法术命中光环 | ≤ 15 |
Crit |
暴击大爆炸 | ≤ 20 |
Void |
深渊伤害涟漪 | ≤ 10 |
Heal |
治疗绿光粒子 | ≤ 8 |
Parry |
弹反白光爆散 | ≤ 20 |
5. 伤害闪白(HurtFlash)
受击闪白通过 Material Property Block 实现,不修改 Material 本身(避免额外 Draw Call):
namespace BaseGames.VFX
{
/// <summary>
/// 挂在 SpriteRenderer 同 GameObject 上。
/// 受击时将 Sprite 替换为纯白色一帧,随后恢复原始颜色。
/// </summary>
public class HurtFlashController : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] float _flashDuration = 0.08f; // 秒
[SerializeField] Color _flashColor = Color.white;
MaterialPropertyBlock _mpb;
static readonly int _colorId = Shader.PropertyToID("_Color");
void Awake() => _mpb = new MaterialPropertyBlock();
public async UniTaskVoid Flash(CancellationToken ct = default)
{
_renderer.GetPropertyBlock(_mpb);
_mpb.SetColor(_colorId, _flashColor);
_renderer.SetPropertyBlock(_mpb);
await UniTask.Delay(
TimeSpan.FromSeconds(_flashDuration),
cancellationToken: ct);
_mpb.SetColor(_colorId, Color.white); // 恢复原始颜色乘数
_renderer.SetPropertyBlock(_mpb);
}
}
}
触发路径:HurtBox.ReceiveDamage → 发布 OnEntityHurt 频道 → HurtFlashController.Flash()
6. 形态切换调色板变换(PaletteSwap)
玩家在天魂 / 地魂 / 命魂形态间切换时,Sprite 颜色方案改变,使用 Shader Graph 中的 Palette Swap 节点实现,避免维护多套 Sprite 资产:
Shader Graph 实现要点
PaletteSwap.shadergraph 关键节点:
Sample Texture 2D (Main Sprite)
→ UV 映射到 PaletteTexture(64×1 横条,每列一个色阶)
→ 输出最终颜色
PaletteTextureSO:
每个形态一个 64×1 Texture(或 Sprite Sheet 一行):
Row 0 (天魂): 蓝白色调
Row 1 (地魂): 赤褐色调
Row 2 (命魂): 翠绿色调
运行时切换
public class PlayerPaletteSwap : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] PaletteSwapCatalogSO _catalog; // FormId → Texture2D 映射
static readonly int _paletteTex = Shader.PropertyToID("_PaletteTexture");
MaterialPropertyBlock _mpb;
void Awake() => _mpb = new();
// 监听 OnFormSwitched 事件频道调用
public void OnFormChanged(string formId)
{
if (!_catalog.TryGet(formId, out var tex)) return;
_renderer.GetPropertyBlock(_mpb);
_mpb.SetTexture(_paletteTex, tex);
_renderer.SetPropertyBlock(_mpb);
}
}
7. URP 2D 光照架构
光照层(Light Layer)规范
URP 2D Renderer 支持最多 8 个光照层,分配如下:
| Light Layer | 名称 | 用途 |
|---|---|---|
| 0 | Default |
全局环境光(所有对象默认受光) |
| 1 | Environment |
蜡烛、火把、发光水晶等环境光源 |
| 2 | PlayerLight |
玩家携带光源(如灵力发光) |
| 3 | EnemyLight |
Boss 爆炸闪光、特殊敌人发光体 |
| 4 | FXOnly |
仅特效 ParticleSystem 受此层光照 |
| 5–7 | 预留 | 未来扩展 |
规则:地形 Tilemap 仅勾选 Layer 0;粒子特效 Renderer 勾选 Layer 0 + 4;敌人勾选 Layer 0 + 3。
Global Light 2D 配置
每个区域(Region)的 Persistent 常驻场景中维护一个 Global Light 2D:
| 区域 | 颜色 | 强度 | 说明 |
|---|---|---|---|
| Forest(扎根森林) | #C8E8D0(淡绿) |
0.8 | 明亮、生机盎然 |
| Cave(腐蚀洞穴) | #1A0A2E(深紫) |
0.2 | 黑暗、压抑 |
| Ruins(坍塌废墟) | #3D3028(暖褐) |
0.5 | 残阳余晖 |
| Abyss(深渊裂隙) | #000820(极暗蓝) |
0.1 | 近乎全黑 |
| Core(核心熔炉) | #4A1800(暗红橙) |
0.6 | 熔岩红光 |
切换策略:进入新区域时,RegionLightController(监听 OnRegionEntered 频道)用 DOTween 在 1.5 秒内插值 GlobalLight2D.color 和 intensity,避免突兀切换。
Shadow Caster 2D 规范
- 地形 Tilemap:挂
ShadowCaster2D(Composite),usesPathPoints = true,自动生成阴影形状 - 动态物件(敌人/玩家):不挂
ShadowCaster2D(2D 精灵角色使用 Normal Map 而非实时阴影) - 环境道具(箱子、柱子):可选挂
ShadowCaster2D,仅在 P1 关卡填充时添加
8. 后处理 Volume 分区管理
Volume 结构
Persistent 场景:
[PostProcess]
├── Volume_Default Priority=0 Weight=1.0(始终生效基础 Profile)
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
├── Volume_Death Priority=20 Weight=0(玩家死亡时瞬间 blend 到 1.0)
└── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
Profile 参数速查
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|---|---|---|---|---|
| Default | Intensity 0.3 | 正常 | 0.2 | 关闭 |
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
| Death | 0 | 去饱和度 -80% | 0.7(黑色) | 0.8 |
| Victory | 0.8(白色) | 亮度 +0.4 | 0 | 0 |
PostProcessManager
public class PostProcessManager : MonoBehaviour
{
[SerializeField] Volume _underwater;
[SerializeField] Volume _bossArena;
[SerializeField] Volume _death;
[SerializeField] Volume _victory;
// 通过 DOTween 平滑 blend Weight
public void BlendTo(PostProcessState state, float duration = 0.4f)
{
Volume target = state switch
{
PostProcessState.Underwater => _underwater,
PostProcessState.BossArena => _bossArena,
PostProcessState.Death => _death,
PostProcessState.Victory => _victory,
_ => null
};
// 先关闭所有非默认 Volume
foreach (var v in new[] { _underwater, _bossArena, _death, _victory })
DOTween.To(() => v.weight, x => v.weight = x, 0f, duration);
if (target != null)
DOTween.To(() => target.weight, x => target.weight = x, 1f, duration)
.SetAutoKill(true) // Tween 完成后自动回池,禁止保留无用 Tween 对象
.SetLink(gameObject); // 绑定到容器 GameObject,对象销毁时自动 Kill
}
}
> **DOTween 全局规范**:
> - 所有 `DOTween.To()` 调用均**必须**链接 `.SetAutoKill(true)`(默认开启时可省略,关闭时**必须**显式填写)
> - 挂在 MonoBehaviour 上的 Tween 必须链接 `.SetLink(gameObject)` 或在 `OnDestroy` 中手动 `DOTween.Kill(target)`
> - 禁止全局 `DOTween.KillAll()`(会杀死所有 Tween,包括 Feel 内部的 Tween)
> - Tween 序列(Sequence)使用后调用 `.SetAutoKill(true).Play()` 启动,不保留引用即丢弃
9. 区域 Shader 效果(Environmental Shader)
像素风 Metroidvania 常见区域 Shader 效果规范:
| 效果 | Shader 方案 | 挂载位置 |
|---|---|---|
| 水体流动 | WaterFlow.shadergraph(UV 偏移 + 正弦波扰动) |
Tilemap_Liquid 的 Material |
| 岩浆涌动 | LavaFlow.shadergraph(橙红色 + 亮度闪烁) |
Tilemap_Lava Material |
| 发光水晶 | Emission.shadergraph(自发光强度受 sin(time) 调制) |
水晶 Sprite Material |
| 死亡区域迷雾 | FogEffect.shadergraph(Noise 纹理 + Alpha) |
Tilemap_Background Overlay |
| 伤害闪白 | HurtFlash(_Color 属性块,见 §5) |
角色/敌人 Sprite Material |
| 调色板替换 | PaletteSwap.shadergraph(见 §6) |
玩家 Sprite Material |
禁止在游戏逻辑中直接 Material.SetXxx(),一律通过 MaterialPropertyBlock 修改,避免 Material 实例化产生 GC。
10. VFX 性能约束
参见 27_PerformanceBudgetGuide §5;以下为 VFX 专项补充。
| 约束项 | 限制 | 备注 |
|---|---|---|
| 同屏活跃粒子总数 | ≤ 500 | 超过时 Profile 会看到 GPU 帧时上升 |
| 单次命中特效粒子数 | ≤ 20 | HitFxType 表格已约束 |
| 同屏 Light 2D 数量 | ≤ 16 | 超过时触发 URP 2D 光照降级 |
| Shadow Caster 2D 数量 | ≤ 32 | Composite 模式下 1 Tilemap = 1 |
| 新增 VFX Prefab | 必须通过 VFXPool 统一管理 | PR Review 清单检查项 |
Material.SetXxx() 直接调用 |
禁止 | 一律用 MaterialPropertyBlock |
Instantiate(ParticleSystem) 运行时调用 |
禁止 | 一律从 VFXPool.Play() 取 |
11. 编辑器友好设计
VFXCatalog Custom Inspector
VFXCatalogSO Inspector:
┌─ [命中类型 → VFX 映射]
│ HitFxType | VFX Prefab Ref | 预热数量
│ ─────────────────────────────────────────────
│ Spark | [vfx_hit_spark] | 5
│ Blood | [vfx_hit_blood] | 5
│ Magic | [vfx_hit_magic] | 3
│ ...
└─ [▶ 全部预热] ← Editor Only 按钮(调用 WarmupAll)
Scene View Gizmos
LiquidZone光照区域:半透明色块填充(颜色对应液体类型)VFXPool当前各子池使用率:在 Debug Overlay 中显示(见 09_EditorExtensions §4)
快速创建工具
菜单 → BaseGames/VFX/Create HitFX Prefab → 自动创建包含正确 Layer、Sort Order、Auto Stop 配置的 ParticleSystem Prefab 模板,需设计师填充 Particle Shape / Color Over Lifetime 即可。
文档版本 1.0 · 2025