ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity Mesh内存优化:Read/Write开关与MeshCollider避坑指南

Unity Mesh内存优化:Read/Write开关与MeshCollider避坑指南 1. 从一次线上事故说起Mesh内存为什么会失控去年我们团队做的一个开放世界手游测试阶段一切正常上线后第二天开始陆续收到低端机闪退的反馈。排查了三天最后定位到的问题让我印象很深一个场景里同时加载了十几个高精度建筑模型每个模型的Mesh都勾选了Read/Write Enabled导致这些Mesh在CPU侧和GPU侧各存了一份完整副本内存直接翻倍。低端机本来内存就紧张再加上AssetBundle卸载时因为CPU侧还有引用导致资源无法释放最终OOM。这件事之后我把项目里所有Mesh的Read/Write开关全部梳理了一遍也顺带把MeshCollider和SkinnedMesh相关的内存问题做了一次系统性排查。这篇文章就是那次排查的完整总结从原理到实操从参数计算到避坑经验尽量讲透。如果你正在做Unity项目尤其是移动端或者内存敏感的平台Mesh内存管理是绕不过去的坎。这篇文章适合有一定Unity基础的开发者也适合刚接触性能优化的朋友我会尽量用大白话把底层逻辑讲清楚。2. Mesh内存的底层逻辑CPU侧和GPU侧到底存了什么2.1 一个Mesh在内存里到底占多大很多人算Mesh内存的时候只看顶点数实际上Mesh的内存占用由多个部分组成。一个标准的Mesh包含以下数据顶点位置数组Vertex Positions每个顶点一个Vector3占12字节法线数组Normals每个顶点一个Vector3占12字节UV数组UV0每个顶点一个Vector2占8字节切线数组Tangents每个顶点一个Vector4占16字节顶点颜色Colors每个顶点一个Color32占4字节骨骼权重BoneWeights如果是SkinnedMesh每个顶点可能占最多32字节索引数组Indices每个三角形3个索引索引格式可能是16位或32位拿一个典型的角色模型举例5000个顶点10000个三角形带法线、UV、切线、骨骼权重。粗略算一下数据项单顶点/索引大小数量小计顶点位置12字节500060KB法线12字节500060KBUV08字节500040KB切线16字节500080KB骨骼权重32字节5000160KB索引16位2字节3000060KB合计约460KB这只是一个角色。如果场景里有50个这样的角色光Mesh数据就是23MB。如果这些Mesh还勾了Read/WriteCPU侧再存一份就是46MB。对于内存预算只有几百MB的移动端来说这个数字非常恐怖。2.2 Read/Write开关到底做了什么Unity的Mesh有一个Read/Write Enabled选项在Inspector里可以看到。这个选项的本质是是否在CPU可访问的内存中保留一份Mesh数据的副本。不勾选的时候Mesh数据上传到GPU后CPU侧的副本会被释放或者说从一开始就不保留。这时候你在代码里访问mesh.vertices、mesh.normals这些属性会报错或者返回空数组。勾选之后CPU侧保留完整副本你可以随时读取和修改顶点数据但代价是内存翻倍。这里有个容易误解的点很多人以为不勾选Read/Write就不能用MeshCollider。实际上MeshCollider需要访问Mesh的三角形数据来构建碰撞体但这个访问是在构建阶段完成的构建完成后就不需要CPU侧的Mesh数据了。所以MeshCollider并不强制要求Read/Write开启这一点后面会详细说。2.3 为什么Unity要这样设计这个设计的逻辑其实很清晰GPU渲染只需要显存里的数据CPU侧的数据在渲染阶段是多余的。对于静态的、不需要在运行时修改的Mesh保留CPU副本纯属浪费。但Unity没法自动判断你后续会不会修改Mesh所以给了你一个手动开关。从引擎架构的角度看Unity的Mesh数据流是这样的导入模型时Mesh数据存在磁盘上.asset文件或AssetBundle里加载时数据从磁盘读到CPU内存上传到GPU后如果Read/Write关闭CPU侧内存被释放如果Read/Write开启CPU侧内存保留关键在第3步和第4步之间。关闭Read/Write的Mesh在UploadMeshData之后CPU侧的内存会被标记为可回收。但这里有个坑Unity的Mesh内存回收并不是即时的它依赖于GC和资源卸载流程。如果你在AssetBundle卸载时还有代码引用着这个Mesh即使Read/Write关闭了CPU侧内存也可能不会被释放。3. Read/Write开关的实操判断什么时候该开什么时候该关3.1 必须开启Read/Write的场景有些操作没有Read/Write是根本做不了的这些场景你必须开启运行时修改顶点比如做顶点动画、地形变形、布料模拟你需要读写mesh.vertices程序化生成Mesh用代码构建Mesh时需要先设置vertices再RecalculateNormals这个过程需要CPU访问Mesh合并CombineMeshes操作需要读取多个Mesh的顶点数据Mesh切割/破碎运行时切割需要读取和重写顶点数据导出Mesh数据比如把Mesh导出为OBJ或其他格式这些场景有一个共同点需要在运行时从CPU侧读取或修改Mesh数据。如果你的项目里没有这些需求那Read/Write就应该关掉。3.2 可以安全关闭Read/Write的场景以下场景关闭Read/Write是完全安全的纯静态场景模型建筑、地形、道具加载后不需要修改角色模型如果角色不需要运行时修改Mesh换装用SkinnedMeshRenderer的Bones或者Mesh替换不需要Read/WriteUI用的MeshUnity UI的Mesh是动态生成的但通常不需要你手动读写粒子系统粒子用的Mesh通常是静态的这里要特别说一个常见误区换装系统。很多换装系统是通过替换Mesh来实现的比如把身体的Mesh换成另一套。这种操作不需要Read/Write因为你是替换整个Mesh引用不是修改顶点数据。但如果你做的是同一个Mesh修改部分顶点来实现换装比如把衣服的顶点位置改一下那就需要Read/Write。3.3 一个实用的判断流程我在项目里总结了一个简单的判断流程每次拿到一个新模型按这个流程走一遍这个Mesh在运行时会被代码读取顶点数据吗会→开不会→下一步这个Mesh在运行时会被代码修改顶点数据吗会→开不会→下一步这个Mesh会被CombineMeshes合并吗会→开不会→下一步这个Mesh需要被MeshCollider使用吗需要→看情况后面详说不需要→关按这个流程走下来项目中90%以上的Mesh都应该关闭Read/Write。4. MeshCollider与Read/Write的恩怨情仇4.1 MeshCollider到底需不需要Read/Write这是被问得最多的问题之一。答案是不需要。MeshCollider在构建碰撞体时会从Mesh中提取三角形数据构建完成后碰撞体有自己的数据结构通常是一棵BVH树不再依赖原始的Mesh数据。所以即使Read/Write关闭MeshCollider也能正常工作。但这里有一个例外如果MeshCollider的Mesh在运行时被修改了你需要调用MeshCollider.sharedMesh重新赋值来更新碰撞体。这个操作需要访问Mesh数据所以如果要做运行时更新就需要Read/Write。4.2 MeshCollider的内存开销MeshCollider本身的内存开销也不小。一个复杂的MeshCollider其BVH树的内存占用可能比原始Mesh还大。而且MeshCollider的构建过程很耗时如果在运行时动态构建会造成明显的卡顿。我在项目里的做法是静态场景的MeshCollider在编辑器里烘焙好随场景一起加载动态物体的MeshCollider尽量用BoxCollider、SphereCollider等简单碰撞体替代必须用MeshCollider的动态物体用简化后的低模Mesh不要用渲染用的高模4.3 一个真实的优化案例我们项目里有一个可破坏的场景物件原始方案是用高模Mesh做MeshCollider破坏时实时切割Mesh并重建Collider。这个方案在PC上跑没问题但在移动端上每次破坏都会造成200ms以上的卡顿。后来我们改成了渲染用高模Collider用低模面数是高模的1/10破坏时只切割低模Collider高模通过Shader做视觉上的破碎效果。这样Read/Write只需要在低模上开启高模可以关闭。内存节省了大约40%卡顿也降到了可接受的范围。5. SkinnedMesh的特殊处理骨骼、权重与内存5.1 SkinnedMesh的内存构成SkinnedMesh比普通Mesh多了一些数据骨骼权重BoneWeights每个顶点最多4个骨骼影响每个影响包含骨骼索引和权重绑定姿势Bindposes每个骨骼一个Matrix4x4占64字节骨骼层级数据骨骼的Transform数据一个典型的角色如果有60根骨骼5000个顶点骨骼权重部分就是5000 * 32 160KB绑定姿势是60 * 64 3.84KB。这些数据在Read/Write关闭时CPU侧同样会被释放。5.2 SkinnedMeshRenderer的更新模式SkinnedMeshRenderer有一个Update When Offscreen选项这个选项和Read/Write没有直接关系但和内存、性能密切相关。勾选Update When Offscreen即使角色在屏幕外骨骼动画也会更新。这会导致CPU持续计算但好处是角色重新进入屏幕时不会出现姿势跳变。不勾选角色在屏幕外时停止更新节省CPU。但重新进入屏幕时可能会有一次性的计算峰值。我的经验是主角和近距离角色勾选远处NPC不勾选。这样在保证视觉效果的同时节省了大量CPU。5.3 SkinnedMesh的Read/Write策略SkinnedMesh的Read/Write策略和普通Mesh类似但有几个特殊点如果用了Mesh Baker之类的插件做SkinnedMesh合并需要Read/Write如果用了GPU Skinning不需要Read/Write因为顶点变换在GPU上完成如果用了Bone-based的换装不需要Read/Write因为换的是骨骼的Transform不是Mesh数据我们项目里所有角色的Read/Write都是关闭的换装通过替换SkinnedMeshRenderer的bones和sharedMesh来实现。唯一开启Read/Write的是一个做布料模拟的特殊角色因为布料模拟需要每帧读取和修改顶点位置。6. 实操如何批量检测和优化项目中的Mesh内存6.1 用编辑器脚本批量检测手动一个个检查Mesh的Read/Write状态是不现实的。我写了一个编辑器脚本可以批量扫描项目中的所有Meshusing UnityEngine; using UnityEditor; using System.Collections.Generic; public class MeshReadWriteScanner : EditorWindow { [MenuItem(Tools/Mesh ReadWrite Scanner)] static void Scan() { string[] guids AssetDatabase.FindAssets(t:Model); int totalCount 0; int rwCount 0; long totalMem 0; long rwMem 0; foreach (string guid in guids) { string path AssetDatabase.GUIDToAssetPath(guid); Object[] assets AssetDatabase.LoadAllAssetsAtPath(path); foreach (Object obj in assets) { Mesh mesh obj as Mesh; if (mesh null) continue; totalCount; long mem Profiler.GetRuntimeMemorySizeLong(mesh); totalMem mem; if (mesh.isReadable) { rwCount; rwMem mem; } } } Debug.Log($Total Meshes: {totalCount}, ReadWrite On: {rwCount}); Debug.Log($Total Memory: {totalMem / 1024 / 1024}MB, RW Memory: {rwMem / 1024 / 1024}MB); } }这个脚本会扫描项目中所有模型文件统计Mesh数量和内存占用以及其中开启了Read/Write的部分。跑一遍就能知道项目里有多少Mesh是不必要地开启了Read/Write。6.2 用Memory Profiler做运行时分析编辑器脚本只能看资产层面的情况运行时的情况需要用Memory Profiler。Unity的Memory Profiler包可以抓取运行时的内存快照看到每个Mesh的实际内存占用和引用关系。具体操作步骤安装Memory Profiler包Window Package Manager Memory Profiler在Profiler窗口里抓取一次快照在Memory Profiler里筛选Mesh类型查看每个Mesh的Size和Referenced By重点看两个东西Size实际内存占用和Referenced By谁在引用这个Mesh。如果发现某个Mesh的Size比预期大很多很可能就是Read/Write导致的。6.3 批量修改Read/Write状态检测出来之后批量修改也是个麻烦事。Unity的Model Importer设置里可以控制Read/Write但需要逐个修改。我写了一个批量修改的脚本using UnityEngine; using UnityEditor; public class MeshReadWriteBatchSetter : EditorWindow { [MenuItem(Tools/Batch Disable ReadWrite)] static void DisableReadWrite() { string[] guids AssetDatabase.FindAssets(t:Model); int changed 0; foreach (string guid in guids) { string path AssetDatabase.GUIDToAssetPath(guid); ModelImporter importer AssetImporter.GetAtPath(path) as ModelImporter; if (importer null) continue; if (importer.isReadable) { importer.isReadable false; importer.SaveAndReimport(); changed; } } Debug.Log($Changed {changed} models.); } }注意批量修改前一定要先备份项目或者用版本控制做好提交。SaveAndReimport会触发重新导入如果模型很多这个过程可能很慢。6.4 运行时动态控制Read/Write有些Mesh可能只在特定情况下需要Read/Write比如只在某个关卡里需要修改顶点。这种情况下可以在运行时动态控制// 需要读取时先确保CPU数据存在 mesh.UploadMeshData(false); // false表示保留CPU副本 // 读取或修改顶点数据 Vector3[] vertices mesh.vertices; // ... 修改 ... // 修改完成后重新上传并释放CPU副本 mesh.UploadMeshData(true); // true表示上传后释放CPU副本UploadMeshData(true)会把Mesh数据上传到GPU并释放CPU侧副本相当于动态关闭Read/Write。这个方法在需要临时修改Mesh的场景里非常有用。7. 常见问题与排查技巧实录7.1 关闭Read/Write后报错怎么办最常见的报错是Not allowed to access vertices on mesh xxx (isReadable is false)。这个报错说明你的代码在访问一个没有开启Read/Write的Mesh。解决方法有两种如果确实需要访问在Model Importer里开启Read/Write或者用UploadMeshData(false)动态开启如果不需要访问检查代码逻辑看看是不是有冗余的顶点读取操作我遇到过一个情况一个第三方插件在初始化时会读取所有Mesh的顶点数来做统计。这个操作其实不需要Read/Write因为mesh.vertexCount这个属性即使Read/Write关闭也能正常返回。但插件用的是mesh.vertices.Length这就触发了报错。后来我们把插件里的mesh.vertices.Length改成了mesh.vertexCount问题解决。7.2 Mesh内存没有按预期释放有时候你关闭了Read/Write但Memory Profiler里看Mesh内存并没有减少。可能的原因有AssetBundle引用未释放Mesh还在被AssetBundle引用即使Read/Write关闭CPU侧内存也不会释放Resources.UnloadUnusedAssets未调用Unity的未使用资源卸载不是自动的需要手动调用代码中还有引用某个静态变量或单例还持有Mesh的引用排查方法是在Memory Profiler里查看Mesh的Referenced By找到所有引用路径逐一排查。7.3 常见问题速查表问题现象可能原因解决方法访问vertices报错Read/Write未开启开启Read/Write或改用vertexCountMesh内存翻倍Read/Write开启且不需要关闭Read/Write关闭后内存未释放AssetBundle引用未释放卸载AssetBundle后调用UnloadUnusedAssetsMeshCollider不工作Mesh被修改后未更新Collider重新赋值sharedMeshSkinnedMesh卡顿Update When Offscreen开启远处角色关闭此选项换装后Mesh内存增长每次换装都创建新Mesh用对象池复用Mesh7.4 几个容易踩的坑坑一以为关闭Read/Write就一定能省内存。实际上如果Mesh被AssetBundle引用即使Read/Write关闭CPU侧内存也可能不会立即释放。需要确保AssetBundle正确卸载。坑二在编辑器里关闭Read/Write但运行时又动态开启了。有些代码会在运行时调用UploadMeshData(false)这会导致CPU侧内存重新分配。如果这个操作很频繁内存会反复分配和释放造成GC压力。坑三MeshCollider的sharedMesh赋值。如果你在运行时修改了MeshCollider的sharedMesh即使新Mesh的Read/Write关闭Unity也会在赋值时读取Mesh数据来构建碰撞体。这个操作是同步的会造成卡顿。坑四SkinnedMesh的BakeMesh。SkinnedMeshRenderer.BakeMesh()会创建一个新的Mesh这个新Mesh默认是Read/Write开启的。如果频繁调用BakeMesh会产生大量临时Mesh造成内存泄漏。记得手动销毁这些临时Mesh。8. 一个完整的优化实例从200MB到80MB最后分享一个我们项目里的实际优化案例。这是一个中型的移动端RPG优化前Mesh相关的内存占用大约是200MB优化后降到了80MB左右。优化前的状况所有模型都开启了Read/Write场景里有大量高模建筑每个都有MeshCollider角色换装系统每次换装都创建新的SkinnedMesh没有做AssetBundle的及时卸载优化步骤批量关闭Read/Write用脚本扫描所有模型除了少数需要运行时修改的全部关闭。这一步节省了大约60MB。简化MeshCollider把场景建筑的MeshCollider换成简化后的低模面数降到原来的1/5。这一步节省了大约30MB。换装系统改用对象池不再每次创建新Mesh而是复用已有的Mesh只修改骨骼和材质。这一步节省了大约20MB。优化AssetBundle卸载在场景切换时及时卸载不再使用的AssetBundle并调用Resources.UnloadUnusedAssets。这一步节省了大约10MB。整个优化过程花了两周左右其中大部分时间花在测试和验证上。优化后的版本在低端机上再也没有出现过OOM闪退。提示优化Mesh内存不是一劳永逸的事情。每次添加新模型、新功能都需要重新检查Read/Write状态。建议把Mesh内存检查加入到项目的CI流程里每次构建时自动扫描并报告异常。9. 一些工具和插件的选择建议在Mesh优化这块有几个工具和插件值得推荐Unity Memory Profiler官方出品必装。可以看运行时的Mesh内存详情。Mesh Baker做Mesh合并和材质合并的利器但注意合并后的Mesh通常需要Read/Write。Simplygon做Mesh简化的专业工具可以生成高质量的LOD和Collider Mesh。Asset Hunter可以扫描项目中未使用的资源帮助清理冗余Mesh。选择工具的原则是先搞清楚需求再选工具。如果只是关闭Read/Write编辑器脚本就够了不需要额外插件。如果需要做复杂的Mesh合并和简化再考虑专业工具。我在实际使用中发现很多团队的问题不是工具不够好而是没有建立起规范的检查流程。工具再好如果没人用、没人检查问题还是会反复出现。所以比起工具更重要的是把Mesh内存检查变成团队开发流程的一部分。
返回列表