简介:这份资源聚焦Unity开发中粒子特效与UI层级冲突的常见难题,面向需要处理特效穿插、Mask裁剪与合批优化的中高级开发者。内容围绕BakeMesh方案展开,对比RenderTexture与多Canvas两种传统思路的优劣,并给出可直接运行的Demo工程,在特效父物体上挂载UIParticle脚本即可让粒子像UGUI一样调整层级排序并接受Mask处理。压缩包共84个文件,约758KB,包含15个cs脚本、4个asmdef程序集定义、3个unity场景、2个prefab、3个mat材质、1个shader及rendertexture等,另附6个md说明文档与示例贴图,覆盖运行时脚本、编辑器扩展、着色器与演示场景等模块。目前已有782人学习下载,适合希望快速掌握粒子与UI层级融合方案、减少多摄像机与合批开销的Unity开发者参考实践。
1. 从一次烘焙翻车说起:BakeMesh 粒子层级问题到底卡在哪
如果你做过 Unity 粒子特效的烘焙,大概率遇到过这种场景:场景里粒子系统跑得好好的,一旦执行 BakeMesh,出来的网格要么层级错乱、要么粒子位置整体偏移,甚至某些子发射器的粒子直接消失。这不是玄学,是粒子系统层级结构和 BakeMesh 的坐标系处理方式没对齐导致的。BakeMesh 本质上是把粒子系统的模拟结果“快照”成静态 Mesh,它依赖 ParticleSystemRenderer 的本地空间变换,而粒子系统的 Simulation Space 又分 Local 和 World 两种模式,一旦父子层级嵌套超过两层,烘焙结果的顶点坐标就会叠加错误的变换矩阵。这份文档资料围绕的就是这个具体问题:粒子层级嵌套下 BakeMesh 的坐标偏移、子发射器丢失、烘焙后 Mesh 朝向翻转三类典型故障,适合已经能跑通基础烘焙流程、但在复杂层级下反复翻车的 Unity 特效向开发者。下面按“问题定位 → 参数修正 → 脚本实现 → 避坑排查”的顺序拆开讲。
2. 粒子层级与 BakeMesh 的坐标系原理:为什么嵌套两层就偏了
2.1 ParticleSystem 的 Simulation Space 与层级变换的叠加关系
要搞清楚 BakeMesh 为什么在层级下出问题,得先理清 Unity 粒子系统的坐标计算链路。每个 ParticleSystem 有一个 Simulation Space 选项,取值 Local 或 World。当设为 Local 时,粒子的模拟坐标是相对于该 ParticleSystem 所在 GameObject 的本地空间;设为 World 时,粒子坐标直接在世界空间计算,不受父级 Transform 影响。
问题出在 BakeMesh 的调用时机。ParticleSystemRenderer.BakeMesh(Mesh mesh, bool useTransform) 这个方法有两个重载,第二个参数 useTransform 决定了烘焙时是否把 ParticleSystemRenderer 的 Transform 变换应用到顶点上。默认不传这个参数时,useTransform 为 false,烘焙出的 Mesh 顶点是粒子系统的本地坐标。如果你的粒子系统嵌套在父级下,父级又有位移或旋转,那烘焙出来的 Mesh 放到场景里就会偏移——因为父级的变换没有被计入。
更麻烦的是多层嵌套。假设层级是 Root → Container → ParticleSystem,Container 有旋转,ParticleSystem 的 Simulation Space 设为 Local。此时粒子的模拟坐标基于 ParticleSystem 自身的本地空间,但 ParticleSystem 的世界位置又受 Container 和 Root 两级变换影响。BakeMesh 在不传 useTransform 的情况下,只取粒子本地坐标,父级变换全部丢失。传了 useTransform=true,它会把 ParticleSystemRenderer 自身的 Transform 应用上去,但 Container 和 Root 的变换仍然不在计算范围内。这就是嵌套两层以上偏移的根因。
常见做法是:烘焙前先把粒子系统的 Simulation Space 统一设为 World,让粒子坐标直接落在世界空间,这样 BakeMesh 出来的顶点就不依赖父级变换了。但这样做有个副作用——粒子在模拟过程中如果父级有动画,World 模式下粒子不会跟随父级移动,视觉表现会变。所以选型上要权衡:静态特效烘焙用 World 模式最省事,动态跟随的特效得在烘焙前手动把父级变换“压平”。
2.2 BakeMesh 的 useTransform 参数与矩阵压平策略
理解了坐标系叠加关系后,解决思路就清晰了:要么在烘焙前把层级变换压平,要么在烘焙后手动修正顶点坐标。两种方案各有适用场景。
压平策略的核心是:在调用 BakeMesh 之前,计算从 ParticleSystem 到目标父级的所有变换矩阵的乘积,然后把这个矩阵应用到烘焙后的 Mesh 顶点上。Unity 的 Matrix4x4 提供了 MultiplyPoint3x4 方法,可以高效地做顶点变换。具体来说,你需要拿到 ParticleSystem 所在 Transform 的 localToWorldMatrix,再拿到你希望烘焙结果所处的目标空间的 worldToLocalMatrix,两者相乘得到变换矩阵。
// 计算从粒子系统本地空间到目标空间的变换矩阵 Matrix4x4 particleLocalToWorld = particleSystem.transform.localToWorldMatrix; Matrix4x4 targetWorldToLocal = targetParent.transform.worldToLocalMatrix; Matrix4x4 finalMatrix = targetWorldToLocal * particleLocalToWorld; // 烘焙后修正顶点 Mesh bakedMesh = new Mesh(); particleSystemRenderer.BakeMesh(bakedMesh, false); Vector3[] vertices = bakedMesh.vertices; for (int i = 0; i < vertices.Length; i++) { vertices[i] = finalMatrix.MultiplyPoint3x4(vertices[i]); } bakedMesh.vertices = vertices; bakedMesh.RecalculateBounds();这段代码的逻辑是:先用 BakeMesh 拿到粒子本地坐标的 Mesh,然后逐顶点应用变换矩阵,把顶点从粒子本地空间转到目标父级空间。参数说明:particleSystem.transform.localToWorldMatrix 包含了从粒子系统到世界空间的所有父级变换;targetParent.transform.worldToLocalMatrix 是目标空间的逆变换;两者相乘的顺序不能反,先 localToWorld 再 worldToLocal 才是正确的空间转换链。RecalculateBounds 必须调用,否则 Mesh 的包围盒还是旧值,渲染时会被视锥体剔除。
这个方案的好处是不依赖 Simulation Space 的设置,Local 和 World 模式都能用。代价是顶点数多的时候逐顶点循环有性能开销,不过烘焙本身是离线操作,这点开销可以接受。我一般会在烘焙脚本里加一个判断:如果粒子系统层级深度超过 2,就强制走矩阵压平路径,否则直接用 useTransform=true 就够了。
3. 子发射器与多 Renderer 场景:BakeMesh 只烘一个怎么办
3.1 Sub Emitter 的粒子归属与烘焙遗漏
粒子系统里 Sub Emitter 是个高频使用的功能——主粒子死亡时触发子发射器,做出爆炸碎片、拖尾消散之类的效果。但 BakeMesh 只作用于调用它的那个 ParticleSystemRenderer,Sub Emitter 的粒子属于另一个 ParticleSystem,不会被一起烘焙。很多人第一次遇到这个问题时会懵:明明场景里粒子都在,烘出来只剩主粒子。
解决方式有两种。第一种是遍历层级下所有 ParticleSystem,逐个 BakeMesh 然后合并 Mesh。合并时要注意每个子发射器的变换矩阵不同,得分别做空间转换再合并顶点和三角形索引。第二种是用 ParticleSystemRenderer 的 BakeTrailsMesh 和 BakeMesh 组合,但这只适用于 Trails 模块,Sub Emitter 还是得单独处理。
// 遍历层级下所有粒子系统并合并烘焙结果 List<CombineInstance> combineInstances = new List<CombineInstance>(); ParticleSystem[] allParticles = root.GetComponentsInChildren<ParticleSystem>(); foreach (var ps in allParticles) { var renderer = ps.GetComponent<ParticleSystemRenderer>(); if (renderer == null) continue; Mesh tempMesh = new Mesh(); renderer.BakeMesh(tempMesh, false); // 对每个子系统的顶点做空间变换 Matrix4x4 matrix = targetParent.worldToLocalMatrix * ps.transform.localToWorldMatrix; Vector3[] verts = tempMesh.vertices; for (int i = 0; i < verts.Length; i++) verts[i] = matrix.MultiplyPoint3x4(verts[i]); tempMesh.vertices = verts; tempMesh.RecalculateBounds(); combineInstances.Add(new CombineInstance { mesh = tempMesh, transform = Matrix4x4.identity }); } Mesh finalMesh = new Mesh(); finalMesh.CombineMeshes(combineInstances.ToArray(), true, false);这段代码的关键点:GetComponentsInChildren 会拿到所有层级的 ParticleSystem,包括 Sub Emitter 和独立的子物体粒子。每个子系统的顶点都要单独做空间变换,因为它们的 Transform 不同。CombineMeshes 的第三个参数 mergeSubMeshes 设为 false,保留每个子系统的子网格结构,方便后续按材质分组。如果设为 true,所有顶点会合并到一个子网格里,材质数量多的时候会出问题。
参数上有个细节:CombineInstance 的 transform 设为 identity,因为顶点已经在循环里做过空间变换了,如果再传变换矩阵会二次叠加。这个坑我踩过,烘出来的 Mesh 直接飞到天边。
3.2 多 Renderer 材质分组与子网格保留
当一个粒子系统有多个 ParticleSystemRenderer 时(比如一个粒子系统同时用 Mesh 和 Trail 两种渲染模式),BakeMesh 只会烘焙当前 Renderer 对应的粒子数据。Trail 需要用 BakeTrailsMesh 单独处理。这两种 Mesh 的顶点结构和三角形索引格式不同,合并时不能直接 CombineMeshes,得先统一成标准 Mesh 格式。
常见做法是:先分别烘焙 Mesh 和 Trails,然后检查两者的顶点属性(位置、法线、UV、颜色),把缺失的属性补零对齐,再用 CombineMeshes 合并。材质分组通过 subMeshCount 控制,每个 Renderer 对应一个子网格,最终 Mesh 的 materials 数组按顺序赋值。
注意:BakeTrailsMesh 在 Unity 2021 之前有顶点数上限,超过 65535 个顶点会截断。如果 Trail 粒子多,建议分批烘焙再合并。
材质分组这块还有个容易忽略的点:粒子系统的 Renderer 可能用了 MaterialPropertyBlock 来覆盖材质属性,BakeMesh 不会保留这些覆盖。烘焙后的 Mesh 需要手动把 MaterialPropertyBlock 里的属性应用到新材质上,否则颜色、贴图偏移这些会丢。我一般会在烘焙脚本里加一步:读取源 Renderer 的 MaterialPropertyBlock,把关键属性(_MainTex_ST、_Color 等)复制到烘焙 Mesh 对应的材质实例上。
4. 避坑排查:BakeMesh 粒子层级问题的五条血泪记录
4.1 烘焙后 Mesh 整体偏移一个父级位移
现象:烘焙出来的 Mesh 放到场景里,位置比预期偏移了父级物体的位移量。原因:BakeMesh 的 useTransform 参数没传或传了 false,父级变换没被计入。解决:要么传 useTransform=true,要么在烘焙后手动应用 localToWorldMatrix。如果层级超过两层,useTransform=true 也不够,必须走矩阵压平。
4.2 子发射器粒子全部丢失
现象:主粒子烘焙正常,Sub Emitter 的粒子一个都没出来。原因:BakeMesh 只作用于调用它的那个 ParticleSystemRenderer,Sub Emitter 是独立的 ParticleSystem。解决:用 GetComponentsInChildren 遍历所有粒子系统,逐个烘焙后合并。注意每个子系统的变换矩阵不同,要分别做空间转换。
4.3 烘焙后 Mesh 朝向翻转或镜像
现象:烘焙出来的 Mesh 和场景里看到的粒子朝向相反,或者左右镜像。原因:粒子系统的 Simulation Space 设为 World 时,BakeMesh 的顶点坐标已经是世界空间,如果再传 useTransform=true 会二次应用变换。另外,某些粒子系统的 Render Alignment 设为 View 或 Facing 时,烘焙结果的朝向依赖相机位置,离线烘焙时相机不存在,朝向会 fallback 到默认值。解决:烘焙前把 Simulation Space 统一设为 Local,Render Alignment 设为 Local 或 World,避免依赖相机。
4.4 顶点数超限导致 Mesh 截断
现象:粒子数量多的时候,烘焙出来的 Mesh 只有一部分粒子。原因:Unity 的 Mesh 顶点索引是 16 位时上限 65535,超过后截断。解决:在 Player Settings 里把 Mesh 的 Index Format 设为 32 位,或者分批烘焙再合并。分批时注意每批的顶点数留余量,因为合并后还会增加。
4.5 烘焙后材质丢失或颜色异常
现象:Mesh 烘出来了,但材质变成默认粉色,或者颜色和场景里不一致。原因:BakeMesh 不保留 MaterialPropertyBlock 的覆盖属性,也不自动创建材质实例。解决:烘焙后手动读取源 Renderer 的 sharedMaterial 和 MaterialPropertyBlock,创建材质实例并应用属性。颜色异常还要检查粒子系统的 Color over Lifetime 模块,烘焙时粒子颜色是当前帧的采样值,如果烘焙时机不对,颜色可能取到生命周期末尾的透明值。
5. 进阶技巧:用 Editor 脚本一键烘焙带层级的粒子系统
把前面的逻辑串起来,写一个 Editor 扩展,支持一键烘焙选中粒子系统的所有层级,自动处理矩阵压平和子发射器合并。这个脚本我用了两年多,迭代了七八个版本,现在基本能覆盖 90% 的粒子烘焙场景。
[MenuItem("Tools/Bake Particle System Hierarchy")] static void BakeParticleHierarchy() { GameObject selected = Selection.activeGameObject; if (selected == null) return; ParticleSystem[] systems = selected.GetComponentsInChildren<ParticleSystem>(); if (systems.Length == 0) return; List<CombineInstance> combines = new List<CombineInstance>(); List<Material> materials = new List<Material>(); foreach (var ps in systems) { var renderer = ps.GetComponent<ParticleSystemRenderer>(); if (renderer == null || renderer.sharedMaterial == null) continue; Mesh temp = new Mesh(); // 根据层级深度决定是否用 useTransform bool useTransform = ps.transform.parent == selected.transform; renderer.BakeMesh(temp, useTransform); // 如果用了 useTransform=false,手动压平矩阵 if (!useTransform) { Matrix4x4 m = selected.transform.worldToLocalMatrix * ps.transform.localToWorldMatrix; Vector3[] v = temp.vertices; for (int i = 0; i < v.Length; i++) v[i] = m.MultiplyPoint3x4(v[i]); temp.vertices = v; temp.RecalculateBounds(); } combines.Add(new CombineInstance { mesh = temp, transform = Matrix4x4.identity }); materials.Add(renderer.sharedMaterial); } Mesh final = new Mesh(); final.CombineMeshes(combines.ToArray(), false, false); final.RecalculateNormals(); // 保存 Mesh 资产 string path = AssetDatabase.GenerateUniqueAssetPath("Assets/BakedParticleMesh.asset"); AssetDatabase.CreateAsset(final, path); AssetDatabase.SaveAssets(); // 创建带 MeshRenderer 的 GameObject GameObject go = new GameObject("BakedParticle"); go.transform.SetParent(selected.transform.parent); go.transform.localPosition = Vector3.zero; go.transform.localRotation = Quaternion.identity; var mf = go.AddComponent<MeshFilter>(); mf.sharedMesh = final; var mr = go.AddComponent<MeshRenderer>(); mr.sharedMaterials = materials.ToArray(); Debug.Log($"烘焙完成:{systems.Length} 个粒子系统,{final.vertexCount} 个顶点"); }脚本逻辑说明:首先拿到选中物体下所有 ParticleSystem,然后逐个烘焙。关键判断在 useTransform 那行——如果粒子系统的父级就是选中物体本身,说明层级只有一层,直接用 useTransform=true 让 BakeMesh 处理变换;如果层级更深,useTransform=false 然后手动压平矩阵。合并时 mergeSubMeshes 设为 false,保留每个子系统的子网格,材质按顺序对应。最后把 Mesh 存成资产文件,创建新的 GameObject 挂上 MeshFilter 和 MeshRenderer。
参数调整建议:如果你的粒子系统用了 Trails 模块,需要在循环里额外调用 BakeTrailsMesh,然后把 Trail Mesh 和普通 Mesh 分别合并到不同的子网格。材质方面,如果多个粒子系统共用同一个材质,可以在 materials 列表里去重,减少 Draw Call。顶点数超过 65535 时,在创建 Mesh 后设置final.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32。
验证方法:烘焙完成后,把生成的 BakedParticle 物体和原始粒子系统并排放在场景里,对比顶点位置和材质表现。如果位置有偏差,检查矩阵乘法的顺序;如果材质不对,检查 MaterialPropertyBlock 是否被忽略;如果子发射器粒子缺失,检查 GetComponentsInChildren 是否包含了未激活的物体(默认不包含,需要传 includeInactive=true)。
从那以后我每次烘焙粒子之前,都会先跑一遍层级检查脚本,确认 Simulation Space 和 Render Alignment 的设置,再执行烘焙。这个习惯帮我省了至少几十次返工。希望帮到你。
本文还有配套的精品资源,点击获取