做角色战斗特效时,最怕遇到“看起来像纸片”的问题。粒子系统喷火、爆裂、拖尾做久了,会发现它们天然缺少一种东西:模型本身的体量感和几何破碎感。比如角色死亡碎裂,用粒子去模拟碎片,永远没有直接把 Mesh 拆成真实三角形面片来得有冲击力。
这篇文章围绕Unity Mesh-Based VFX这套思路展开,重点讲清楚“基于 Mesh 的实时特效”到底怎么做。我们会先理解 Mesh 的数据结构,再拆解三个可运行案例:碎片爆裂、顶点冲击波、瞬移残影。每个案例都给出完整 C# 脚本和 Shader 片段,并补上性能优化与常见坑点。适合两类读者:一类是刚从粒子特效转向网格级特效的 Unity 开发者,另一类是希望把自研 VFX 模块工程化的技术负责人。
1. 为什么角色实时特效要选择 Mesh-Based VFX
1.1 粒子系统的能力边界
Unity 的 Particle System 很强大,能实现火焰、烟雾、闪电、暴风雪,也能拖出各种流光。但粒子本质上是“空间中的小型四边形网格”,它的每个粒子都是 Billboard 或者拉伸后的 Mesh,无法表达“模型本身被炸开”的连续性。
举个例子:角色死亡时如果希望模型碎裂成几百块带厚度的小碎片,粒子系统只能给出外观接近的贴图碎片,无法做到每一块碎片都来自角色原始 Mesh 的顶点位置。玩家在近距离观察时,这种“贴图感”很容易穿帮。
另一个问题是交互反馈。粒子与场景碰撞更多是物理粒子行为,而 Mesh 碎片的碰撞能让每一块碎片都有真实的碰撞盒,实现阻挡、弹跳、堆积。这在动作游戏、割草游戏、Roguelike 游戏里是刚需。
1.2 Mesh-Based VFX 的定位
Mesh-Based VFX 意思是:把 Mesh 本身作为特效的计算和渲染主体,通过修改顶点坐标、拆分子网格、切换材质参数、复制网格快照来产生视觉特效。
典型应用场景包括:
- 角色死亡碎裂,按三角面或连通块拆分模型。
- 受击时顶点向外膨胀或凹陷,产生冲击反馈。
- 冲刺、瞬移、斩杀时生成角色残影。
- 技能释放时角色模型进入“聚集/展开”状态。
- 敌人出场时地面模型向外翻涌。
这类效果无法单纯靠材质 Shader 实现,因为 Shader 只能操纵渲染阶段的顶点数据,无法持久化修改结果,更无法让每个碎片独立拥有物理运动。
1.3 常见方案对比
| 方案 | 表现力 | 性能开销 | 开发复杂度 | 适用场景 |
|---|---|---|---|---|
| 粒子系统 + 贴图 | 中等 | 低 | 低 | 火焰、烟雾、拖尾 |
| Shader 顶点动画 | 中等 | 低 | 中 | 水波、旗帜、轻微受击形变 |
| Mesh 拆分 + 刚体碎片 | 高 | 较高 | 高 | 死亡碎裂、场景破坏 |
| SkinnedMesh BakeMesh 残影 | 高 | 中 | 中 | 瞬移残影、动画快照 |
| Compute Shader 顶点计算 | 高 | 中 | 高 | 大面积顶点形变、水体 |
所以“Realistic Mesh VFX”不是某个固定插件的专有名词,而是一类实现思路。市面上不少特效插件本质上是把上面几套方案封装成了编辑器工具和运行时组件。下面我们从零开始实现其中最关键的技术点。
2. 环境准备与工程组织
2.1 编辑器与渲染管线
本文示例基于 Unity 2021.3 LTS 或 Unity 2022.3 LTS,使用 Built-in Render Pipeline 做最基础演示,Shading 部分采用 CGPROGRAM 写法。如果你用的是 URP 或 HDRP,Shader 需要换成 HLSL,并调整 RenderState 相关语法,但 C# 侧的 Mesh 操作逻辑是通用的。
安装 Unity 时建议勾选:
- Windows Build Support(根据目标平台选择)
- Android Build Support / iOS Build Support
- Visual Studio 编辑器集成
如果你的 Unity Hub 打开项目时报No valid Unity Editor license found. Please activate your license.,先登录 Unity 账号完成许可证激活,再重新打开项目。
2.2 示例工程目录结构
为了便于管理,建议按下面结构组织脚本和资源:
Assets/ ├── Scenes/ │ └── MeshVFXDemo.unity ├── Scripts/ │ ├── VFX/ │ │ ├── MeshExploder.cs │ │ ├── FragmentFlying.cs │ │ ├── MeshVertexPulse.cs │ │ ├── AfterImageGenerator.cs │ │ ├── AfterImagePool.cs │ │ └── AfterImageFade.cs │ └── Editor/ │ └── MeshVFXMenu.cs ├── Materials/ │ ├── M_Fragment.mat │ ├── M_AfterImage.mat │ └── M_ImpactWave.mat ├── Shaders/ │ ├── VFX_UnlitAlpha.shader │ └── VFX_VertexPulse.shader └── Art/ └── Character/这篇文章的运行场景会用一个带 MeshFilter 的立方体或胶囊体做测试。如果你要测试角色模型,角色通常带 SkinnedMeshRenderer,需要先用BakeMesh把当前骨骼动画帧烘焙成静态 Mesh。
3. 核心原理:Mesh 的数据链路与更新方式
3.1 顶点数组与三角形索引
Unity 的 Mesh 本质是三个数组的组合:
mesh.vertices; // Vector3[] 顶点坐标 mesh.triangles; // int[] 每三个索引构成一个三角形 mesh.normals; // Vector3[] 顶点法线 mesh.uv; // Vector2[] 贴图坐标triangles的长度必须是 3 的倍数。比如triangles[0] = 0,triangles[1] = 1,triangles[2] = 2,代表由第 0 号、1 号、2 号顶点组成一个三角形。修改顶点数组后,需要调用:
mesh.RecalculateNormals(); mesh.RecalculateBounds();RecalculateNormals负责重新计算法线,否则光照和背面剔除会出错;RecalculateBounds负责重新计算包围盒,否则阴影贴图、视锥剔除可能异常。
3.2 为什么不能直接修改 sharedMesh
MeshFilter.sharedMesh是资源引用,意味着它可能被多个对象共享。如果直接修改,会污染资源本身,影响场景里其他使用同一模型的角色。
正确做法是实例化后再修改:
MeshFilter mf = GetComponent<MeshFilter>(); Mesh instanceMesh = Instantiate(mf.sharedMesh); mf.sharedMesh = instanceMesh;这也是 Mesh VFX 插件最常见的源头坑:不克隆网格就直接改顶点,导致美术资源被永久破坏。
3.3 SkinnedMeshRenderer 与 BakeMesh
带骨骼动画的角色使用SkinnedMeshRenderer,它的顶点位置由骨骼驱动,每帧都在变化。如果要获得当前帧的几何快照,需要调用:
SkinnedMeshRenderer sr = GetComponent<SkinnedMeshRenderer>(); Mesh bakedMesh = new Mesh(); sr.BakeMesh(bakedMesh);BakeMesh 会把当前骨骼动画对应的顶点位置、法线、UV 写入一个普通 Mesh。这个 Mesh 可以交给 MeshFilter 显示,也可以用于残影、碎裂等操作。注意 BakeMesh 有性能开销,不要在 Update 里对每个角色每帧调用,高频场景下需要做间隔采样。
4. 实战案例一:角色死亡碎裂特效
这个案例是 Mesh VFX 里最典型的落地场景。核心思路:遍历原始 Mesh 的所有三角形,为每个三角形生成一个独立的小 Mesh,并为每个小 Mesh 分配一个 GameObject 与运动速度。
4.1 效果拆解
当角色死亡时:
- 读取角色的
MeshFilter.sharedMesh数据。 - 按三角形索引拆分。
- 每个碎片生成独立 Mesh 和 GameObject。
- 碎片从角色中心向外飞散,带随机旋转。
- 原角色隐藏。
下图是流程示意:
原始 Mesh ↓ 拆三角形 多个 Fragment Mesh ↓ 生成节点 碎片 GameObject(MeshFilter + MeshRenderer + 运动脚本) ↓ 播放 向外飞散 + 旋转 + 淡出/回收4.2 网格读取与碎片生成
新建脚本MeshExploder.cs,放到Assets/Scripts/VFX/下。
using UnityEngine; public class MeshExploder : MonoBehaviour { [Header("目标网格")] public MeshFilter meshFilter; [Header("碎片材质")] public Material fragmentMaterial; [Header("爆炸参数")] public float explosionForce = 8f; public float upwardBias = 2f; public float fragmentLife = 3f; public float spinSpeed = 240f; public bool hideOriginal = true; public void Explode() { if (meshFilter == null) meshFilter = GetComponent<MeshFilter>(); if (meshFilter == null || meshFilter.sharedMesh == null) return; Mesh sourceMesh = meshFilter.sharedMesh; Vector3[] vertices = sourceMesh.vertices; int[] triangles = sourceMesh.triangles; Vector3[] normals = sourceMesh.normals; Vector2[] uvs = sourceMesh.uv; if (triangles.Length == 0) return; Transform self = transform; for (int i = 0; i < triangles.Length; i += 3) { int i0 = triangles[i]; int i1 = triangles[i + 1]; int i2 = triangles[i + 2]; Mesh fragmentMesh = new Mesh(); fragmentMesh.name = "Fragment_" + i; fragmentMesh.vertices = new[] { vertices[i0], vertices[i1], vertices[i2] }; fragmentMesh.triangles = new[] { 0, 1, 2 }; if (normals != null && normals.Length > 0) fragmentMesh.normals = new[] { normals[i0], normals[i1], normals[i2] }; else fragmentMesh.RecalculateNormals(); if (uvs != null && uvs.Length > 0) fragmentMesh.uv = new[] { uvs[i0], uvs[i1], uvs[i2] }; fragmentMesh.RecalculateBounds(); GameObject fragmentObject = new GameObject("Fragment_" + i); fragmentObject.transform.SetParent(self); fragmentObject.transform.position = self.TransformPoint(vertices[i0]); fragmentObject.transform.rotation = self.rotation; MeshFilter mf = fragmentObject.AddComponent<MeshFilter>(); mf.sharedMesh = fragmentMesh; MeshRenderer mr = fragmentObject.AddComponent<MeshRenderer>(); mr.sharedMaterial = fragmentMaterial; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.On; mr.receiveShadows = false; Vector3 center = (vertices[i0] + vertices[i1] + vertices[i2]) / 3f; Vector3 worldCenter = self.TransformPoint(center); Vector3 direction = (worldCenter - self.position).normalized + Vector3.up * upwardBias; FragmentFlying flying = fragmentObject.AddComponent<FragmentFlying>(); flying.Setup(direction * explosionForce, Random.onUnitSphere * spinSpeed, fragmentLife); } if (hideOriginal) meshFilter.gameObject.SetActive(false); } }代码里有几个关键点:
sourceMesh是共享网格,我们只读取不修改,所以这一步不用Instantiate。- 每个三角形单独生成一个新的小 Mesh,称为“单三角形碎片”。美术上如果想要更厚的碎片,可以再对每个三角形做挤出。
- 碎片坐标先用局部坐标计算,再用
TransformPoint转换到世界空间,避免角色带缩放时出现位置错误。 - 碎片对象设置
SetParent(self),角色隐藏时碎片仍留在场景中。
4.3 碎片运动控制
为碎片对象添加FragmentFlying.cs,让它拥有简单的抛物线运动和自旋,不依赖物理系统。
using UnityEngine; public class FragmentFlying : MonoBehaviour { private Vector3 velocity; private Vector3 angularVelocity; private float lifeTime; private float elapsedTime; private Renderer targetRenderer; private MaterialPropertyBlock propertyBlock; private static readonly int AlphaProp = Shader.PropertyToID("_Alpha"); public void Setup(Vector3 initVelocity, Vector3 initAngularVelocity, float life) { velocity = initVelocity; angularVelocity = initAngularVelocity; lifeTime = life; elapsedTime = 0f; targetRenderer = GetComponent<Renderer>(); if (targetRenderer != null) { propertyBlock = new MaterialPropertyBlock(); } } private void Update() { elapsedTime += Time.deltaTime; float t = Mathf.Clamp01(elapsedTime / lifeTime); if (t >= 1f) { Destroy(gameObject); return; } Vector3 gravity = Physics.gravity * 0.5f; velocity += gravity * Time.deltaTime; transform.position += velocity * Time.deltaTime; transform.Rotate(angularVelocity * Time.deltaTime, Space.Self); if (targetRenderer != null && propertyBlock != null) { targetRenderer.GetPropertyBlock(propertyBlock); propertyBlock.SetFloat(AlphaProp, 1f - t); targetRenderer.SetPropertyBlock(propertyBlock); } } }这里没有使用Rigidbody,而是一套轻量模拟逻辑。这样做的原因有两点:
- 数百个碎片同时创建 Rigidbody 会带来物理引擎初始化压力。
- 大多数碎裂特效并不需要真实的碰撞反弹,只要“看着像在飞”即可。
如果你想保留碰撞,可以把velocity计算逻辑替换成Rigidbody.velocity,并让碎片进入一个Pool循环使用。
4.4 给碎片一个支持透明的 Shader
FragmentFlying通过MaterialPropertyBlock修改_Alpha参数做淡出。所以碎片材质对应的 Shader 必须暴露名为_Alpha的属性,并在片元着色器里乘到最终颜色上。
Shader "Custom/VFX/FragmentUnlit" { Properties { _MainTex ("Texture", 2D) = "white" {} _Color ("Color", Color) = (1,1,1,1) _Alpha ("Global Alpha", Range(0,1)) = 1 } SubShader { Tags { "RenderType"="Transparent" "Queue"="Transparent" } Blend SrcAlpha OneMinusSrcAlpha Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Alpha; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv) * _Color; col.a *= _Alpha; return col; } ENDCG } } }如果是 URP 工程,需要把CGPROGRAM替换为HLSLPROGRAM,并引入Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl,同时把UnityObjectToClipPos替换为TransformObjectToHClip。
4.5 运行与验证
在场景中创建一个 Cube,把MeshExploder.cs挂到 Cube 上,指定MeshFilter和碎片材质。运行后调用Explode()即可。
如果想在鼠标点击时触发,可以添加一个测试脚本:
using UnityEngine; public class ExplodeOnClick : MonoBehaviour { private MeshExploder exploder; private void Awake() { exploder = GetComponent<MeshExploder>(); } private void Update() { if (Input.GetMouseButtonDown(0)) { if (exploder != null) exploder.Explode(); } } }预期的效果是:Cube 瞬间碎成 12 个三角形碎片(Cube 有 12 个三角形),碎片向外飞散,1 秒后开始透明淡出,3 秒后销毁。
5. 实战案例二:顶点冲击波纹与受击反馈
5.1 实现思路
第二个案例适合做角色受击反馈:当攻击命中角色某个点时,角色模型被击中位置附近的顶点会向外推移,随后慢慢恢复。
实现思路是:
- 保存一份原始顶点坐标。
- 根据命中点与每个顶点的距离计算偏移量。
- 偏移量随时间衰减。
- 重新写入
mesh.vertices,调用RecalculateNormals和RecalculateBounds。
这里的关键不是代码本身,而是如何控制偏移衰减。
5.2 顶点冲击脚本
using UnityEngine; public class MeshVertexPulse : MonoBehaviour { public MeshFilter meshFilter; public float radius = 1.2f; public float strength = 0.4f; public float duration = 0.6f; public AnimationCurve falloffCurve = AnimationCurve.EaseInOut(0f, 1f, 1f, 0f); private Mesh instanceMesh; private Vector3[] originalVertices; private Vector3[] workVertices; private Vector3 hitWorldPosition; private float timer = -1f; private void Start() { if (meshFilter == null) meshFilter = GetComponent<MeshFilter>(); if (meshFilter == null) return; instanceMesh = Instantiate(meshFilter.sharedMesh); originalVertices = instanceMesh.vertices; workVertices = (Vector3[])originalVertices.Clone(); instanceMesh.MarkDynamic(); meshFilter.sharedMesh = instanceMesh; } public void TriggerPulse(Vector3 worldHit) { hitWorldPosition = worldHit; timer = 0f; } private void Update() { if (timer < 0f || timer >= duration) return; timer += Time.deltaTime; float wave = falloffCurve.Evaluate(Mathf.Clamp01(timer / duration)); Matrix4x4 localToWorld = transform.localToWorldMatrix; Matrix4x4 worldToLocal = transform.worldToLocalMatrix; for (int i = 0; i < workVertices.Length; i++) { Vector3 worldPos = localToWorld.MultiplyPoint3x4(originalVertices[i]); float dist = Vector3.Distance(worldPos, hitWorldPosition); float factor = Mathf.Clamp01(1f - dist / radius) * wave * strength; if (factor <= 0.0001f) { workVertices[i] = originalVertices[i]; continue; } Vector3 dir = (worldPos - hitWorldPosition).normalized; workVertices[i] = originalVertices[i] + worldToLocal.MultiplyVector(dir) * factor; } instanceMesh.vertices = workVertices; instanceMesh.RecalculateNormals(); instanceMesh.RecalculateBounds(); } private void Reset() { meshFilter = GetComponent<MeshFilter>(); falloffCurve = new AnimationCurve( new Keyframe(0f, 1f), new Keyframe(0.6f, 0.3f), new Keyframe(1f, 0f) ); } }这段代码需要注意的地方:
instanceMesh.MarkDynamic()告诉引擎这个网格会被频繁修改,让内部缓冲区走动态更新路径,减少 GC 与重建开销。RecalculateNormals在顶点数量很大时很耗时,真实项目中可以改为“受影响区域局部重算法线”,或者用 Shader 侧法线偏移来掩盖误差。- 如果角色带骨骼动画,不能直接用这个脚本,因为骨骼动画每帧都会重写顶点位置。这种情况建议把顶点冲击做成 Shader 变体,把冲击点传入 Shader,让顶点阶段计算偏移。
5.3 用 Shader 替代 C# 更新的场景
C# 更新 Mesh 的问题在于 CPU 到 GPU 的数据上传量非常大,顶点数过万时每帧修改会明显卡顿。更好的做法是把偏移逻辑写进顶点着色器:
Shader "Custom/VFX/VertexHitPulse" { Properties { _MainTex ("Texture", 2D) = "white" {} _HitPos ("Hit Position", Vector) = (0,0,0,0) _HitRadius ("Hit Radius", Float) = 2 _HitStrength ("Hit Strength", Float) = 0.4 _HitWave ("Hit Wave", Range(0,1)) = 1 } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _MainTex_ST; float4 _HitPos; float _HitRadius; float _HitStrength; float _HitWave; struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float3 normal : NORMAL; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; float dist = distance(worldPos, _HitPos.xyz); float factor = saturate(1 - dist / max(_HitRadius, 0.001)); factor *= _HitStrength * _HitWave; float3 dir = normalize(worldPos - _HitPos.xyz); worldPos += dir * factor; o.vertex = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { return tex2D(_MainTex, i.uv); } ENDCG } } }使用 Shader 方案后,C# 侧只需要把_HitPos、_HitWave传入材质或 MaterialPropertyBlock,不再每帧修改 Mesh。这是生产环境更推荐的做法。
6. 实战案例三:瞬移冲刺残影拖尾
6.1 残影的原理
残影的核心是“延迟多帧显示角色某个瞬间的网格快照”。常见做法:
- 每间隔一小段时间,调用
SkinnedMeshRenderer.BakeMesh。 - 把快照 Mesh 赋给一个带半透明材质的 GameObject。
- 该 GameObject 随帧数增加透明度,最后回收。
这个方案比简单的“复制角色模型”更省资源,因为快照 Mesh 只包含顶点、法线、UV,不需要骨骼、Animator、动画状态机。
6.2 网格快照与淡出脚本
创建AfterImagePool.cs管理残影对象池:
using System.Collections.Generic; using UnityEngine; public class AfterImagePool : MonoBehaviour { public GameObject prefab; public int poolSize = 16; private Queue<GameObject> pool = new Queue<GameObject>(); public void Prepare() { for (int i = 0; i < poolSize; i++) { GameObject go = Instantiate(prefab, transform); go.SetActive(false); pool.Enqueue(go); } } public GameObject Spawn() { if (pool.Count == 0) return null; GameObject go = pool.Dequeue(); go.SetActive(true); return go; } public void Despawn(GameObject go) { go.SetActive(false); pool.Enqueue(go); } }创建AfterImageFade.cs控制快照淡出:
using UnityEngine; public class AfterImageFade : MonoBehaviour { private float duration; private float elapsed; private AfterImagePool pool; private Renderer targetRenderer; private MaterialPropertyBlock block; private static readonly int AlphaProp = Shader.PropertyToID("_Alpha"); private void Awake() { targetRenderer = GetComponent<Renderer>(); block = new MaterialPropertyBlock(); } public void Play(float fadeDuration, AfterImagePool ownerPool) { duration = fadeDuration; pool = ownerPool; elapsed = 0f; } private void Update() { if (elapsed >= duration) { if (pool != null) pool.Despawn(gameObject); return; } elapsed += Time.deltaTime; float alpha = Mathf.Clamp01(1f - elapsed / duration); targetRenderer.GetPropertyBlock(block); block.SetFloat(AlphaProp, alpha); targetRenderer.SetPropertyBlock(block); } }再创建AfterImageGenerator.cs控制采样频率:
using UnityEngine; public class AfterImageGenerator : MonoBehaviour { public SkinnedMeshRenderer skinnedMeshRenderer; public Material ghostMaterial; public AfterImagePool pool; public float sampleInterval = 0.05f; public float fadeDuration = 0.5f; private float timer; private void Start() { if (skinnedMeshRenderer == null) skinnedMeshRenderer = GetComponent<SkinnedMeshRenderer>(); if (pool != null) pool.Prepare(); if (ghostMaterial == null && skinnedMeshRenderer != null) ghostMaterial = skinnedMeshRenderer.sharedMaterial; } private void Update() { timer += Time.deltaTime; if (timer >= sampleInterval) { timer = 0f; SpawnGhost(); } } private void SpawnGhost() { if (pool == null || skinnedMeshRenderer == null) return; GameObject ghost = pool.Spawn(); if (ghost == null) return; Mesh bakedMesh = new Mesh(); skinnedMeshRenderer.BakeMesh(bakedMesh); MeshFilter mf = ghost.GetComponent<MeshFilter>(); if (mf != null) mf.sharedMesh = bakedMesh; ghost.transform.SetPositionAndRotation( skinnedMeshRenderer.transform.position, skinnedMeshRenderer.transform.rotation ); AfterImageFade fade = ghost.GetComponent<AfterImageFade>(); if (fade != null) fade.Play(fadeDuration, pool); } }残影对象需要挂一个空 MeshFilter、MeshRenderer 和 AfterImageFade。Prefab 的 MeshFilter 可以在 Inspector 里留空,运行时赋值。
注意BakeMesh每帧只会拿当前骨骼姿势,所以采样间隔越小,残影越连贯,但 CPU 压力越大。建议在实际项目中让玩家只在冲刺、瞬移等短时技能期间开启生成器,而不是常驻 Update。
6.3 让残影贴合动画姿态的技巧
BakeMesh 产生的网格,顶点坐标是世界空间还是模型空间?这一点容易混淆。实际结果是:BakeMesh 产生的顶点坐标已经包含骨骼变换,但transform.position仍然参与最终变换。所以赋值给残影物体时,最好把残影物体放在世界原点,并把 BakeMesh 数据直接作为世界坐标渲染。
更稳妥的做法是:
ghost.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity);这样快照网格的顶点坐标与角色世界坐标一致,避免双重变换造成错位。如果角色有缩放或负数缩放,需要额外处理。
7. 性能优化与真机适配建议
7.1 认清更新 Mesh 的 CPU 开销
每帧修改mesh.vertices都会触发 CPU 到 GPU 的缓冲区上传。顶点数在 1000 以下时通常没问题;顶点数超过 3000 时,频繁更新可能会触发 GC 和渲染线程阻塞。
建议使用Mesh.MarkDynamic(),它让 Unity 知道这个网格会动态更新,从而选择更合适的内部缓冲区策略。此外,尽量只在需要的帧数内更新,而不是常驻每帧更新。
7.2 从主线程迁移到 Job System / Compute Shader
如果模型面数高,或者同时存在多个特效单位,需要把顶点计算从主线程挪走:
- C# Job System + NativeArray:适合 CPU 端并行计算顶点偏移。
- Compute Shader:适合 GPU 端并行计算顶点位置,然后把顶点数据通过
ComputeBuffer传回 Mesh。
示例思路如下,需要结合自身项目版本调整:
// 伪代码:Compute Shader 更新网格 ComputeBuffer vertexBuffer = new ComputeBuffer(vertexCount, sizeof(float) * 3); computeShader.SetBuffer(kernelIndex, "vertices", vertexBuffer); computeShader.Dispatch(kernelIndex, vertexCount / 64 + 1, 1, 1); vertexBuffer.GetData(vertexArray); mesh.vertices = vertexArray;这里注意:GPU 到 CPU 的回读(GetData)有同步开销,真机上需要测试。如果目标平台是 PC,回读速度通常可以接受;移动端要谨慎。
7.3 控制碎片数量与对象池
案例一里为每个三角形生成一个独立 GameObject,Draw Call 会非常高。工程化时建议:
- 用
Graphics.RenderMeshInstanced批量渲染相同材质、不同矩阵的碎片。 - 限制碎片数量:美术模型在特效阶段使用简化网格,面数控制在 500 到 2000 之间。
- 碎片生命周期结束后放入对象池,而不是直接 Destroy。
Graphics.RenderMeshInstanced是现代 Unity 大批量渲染碎片的首选方案,示例参见 Unity Scripting API 文档中的RenderParams。
8. 常见问题与排查清单
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 碎片没有光照,全是黑的 | 法线缺失或 RecalculateNormals 未调用 | 检查mesh.normals与材质 Shader 是否使用法线 |
| 字符隐藏后碎片位置错乱 | 使用了局部坐标但未转换世界坐标 | 用TransformPoint转换顶点中心位置 |
| 多个角色同时碎裂,互相影响 | 直接修改 sharedMesh | 先Instantiate(meshFilter.sharedMesh)再修改 |
| 模型阴影显示异常 | 顶点更新后未调用 RecalculateBounds | 每次更新顶点后重新计算包围盒 |
| 残影位置跑偏 | BakeMesh 顶点含世界坐标,又被残影 transform 叠加 | 残影物体放在原点,或直接使用快照坐标 |
| 移动端卡顿 | 每帧修改大量顶点 | 改用 Shader 顶点动画或 Compute Shader |
| 透明碎片深度错乱 | 未设置 Queue=Transparent,或未关闭深度写入 | Shader 标签设置透明队列,并按实际需求调整 ZWrite |
| URP 下 Shader 报错 | CGPROGRAM 与 URP 不兼容 | 改为 HLSLPROGRAM,使用 URP 库函数 |
排查顺序建议:
- 先固定复现场景,最小化模型。
- 关闭阴影、后处理、抗锯齿,确认是否渲染层问题。
- 查看 Profiler,确认是 CPU 还是 GPU 瓶颈。
- 检查顶点数量与 Draw Call 数量。
- 使用 Frame Debugger 逐 Pass 查看碎片绘制状态。
9. 工程落地最佳实践
9.1 把特效逻辑从角色业务中解耦
不要把 MeshExploder 直接写在角色死亡逻辑里。建议设计一个MeshVFXController,接收通用事件:
public interface IMeshVFXTarget { Transform EffectRoot { get; } Mesh GetCurrentMesh(); Material GetVFXMaterial(); }角色死亡时只调用MeshVFXController.PlayDeath(),由控制器决定是碎裂、聚合还是残影。这样换技能、换美术表现时不需要改动角色战斗逻辑。
9.2 预处理与资源规范
- 三角面碎裂前,建议先在编辑器里跑一遍“自动简化网格”工具,把模型面数压到可接受范围。
- 纹理不要再依赖逐碎片材质实例,尽量用图集 + 一张公共材质。
- 动态修改 Mesh 的脚本只允许在运行时操作实例化网格。
- 残影、碎片、冲击波效果都用对象池管理,禁止频繁 Instantiate/Destroy。
9.3 真机测试关注点
移动端真机测试前,打开 Profiler 检查三点:
- 每帧
Mesh.RecalculateBounds的耗时。 - BakeMesh 被调用的次数。
- 透明物体覆盖屏幕范围是否过大,导致 Overdraw 爆表。
10. 总结与进一步学习方向
本文从 Unity Mesh 的底层数据结构出发,解释了为什么粒子系统不足以实现“角色实时碎裂”和“残影”这类效果,并依次给出了三个可运行案例:三角形碎片爆炸、顶点冲击波纹、BakeMesh 残影。同时补充了从 C# 每帧更新到 Shader 顶点计算再到 Compute Shader 的优化路径,以及位移、法线、包围盒、对象池等工程细节。
下一步建议自己动手跑一遍最简单的 Cube 爆炸,然后替换成一个低模角色,把碎片数量控制在 500 块以内,再尝试加入Graphics.RenderMeshInstanced做批量渲染。跑通后再去研究 URP 下的 Shader 迁移,以及 Compute Shader 做大规模顶点形变。这几个方向都打通后,你也可以把文章里的模块封装成一套自己的 Mesh VFX 小插件。