1. 从一次线上事故说起:Mesh内存为什么会失控
去年我们项目上线前做性能压测,场景里堆了大概两百多个带MeshCollider的物件,跑起来内存直接飙到1.8G,低端机频繁闪退。当时第一反应是贴图太大,查了半天发现贴图才占了两百多兆,真正的大头在Mesh上——准确地说,是那些被Read/Write Enabled打开、又被脚本无意中持有引用的Mesh副本。关掉这个开关之后,同样的场景内存直接掉到900M出头,帧率也稳了。这件事让我意识到,很多做Unity的人对Mesh内存的理解其实停留在“模型面数别太高”这个层面,而真正吃内存的往往是那些看不见的副本和引用。
这篇内容我想把Mesh内存和Read/Write开关这件事讲透。它适合谁看?如果你做过Unity项目、遇到过内存莫名上涨、或者面试时被问过“Mesh的Read/Write有什么用”,那这篇就是写给你的。我会从底层原理讲到实操配置,从MeshCollider讲到SkinnedMesh,把那些文档里一笔带过、但实际项目里能坑死人的细节都摊开说。核心关键词就几个:Unity、Mesh、Read/Write、MeshCollider、SkinnedMesh,围绕它们展开。
先说结论性的认知:Read/Write Enabled这个开关,本质上是决定Unity要不要在CPU侧保留一份Mesh顶点数据的可访问副本。打开它,你可以在运行时读写Mesh的vertices、normals、uv这些数组;关掉它,这些数据只存在于GPU显存里,CPU拿不到。听起来很简单对吧?但问题在于,这个开关背后牵扯的是内存的双份占用、物理系统的碰撞体烘焙、以及蒙皮网格的额外开销。下面我一块一块拆。
2. Read/Write开关到底动了什么:原理层面的拆解
2.1 打开与关闭时,Mesh数据存在哪里
要理解这个开关,得先知道一个Mesh在Unity里到底存了几份数据。当你从FBX导入一个模型,Unity会生成一个Mesh资产。这个Mesh的顶点、法线、UV、切线、颜色、骨骼权重等信息,默认情况下是上传到GPU的Vertex Buffer里,CPU侧只保留一份“元数据”(比如顶点数、子网格数、包围盒)。这时候如果你在代码里访问mesh.vertices,Unity会报错或者返回空数组,因为CPU手里根本没有原始数据。
一旦你勾上Read/Write Enabled,Unity会在CPU内存里额外保留一份完整的顶点数据副本。这份副本的存在意义是让你能随时读取和修改。比如你想做顶点动画、想动态合并Mesh、想用脚本改UV,都必须打开它。代价就是这份数据会一直占着内存,直到这个Mesh被卸载。
这里有个很容易被忽略的点:这份CPU副本是按Mesh资产走的,不是按实例走的。也就是说,如果你有一个Mesh被100个GameObject引用,打开Read/Write之后,CPU侧只有一份副本,不是100份。真正会产生多份副本的情况,是你在运行时通过mesh.vertices这类访问触发了Mesh的“实例化”——Unity会把共享的Mesh复制一份出来给你改,这份复制出来的才是个体独立的。
2.2 为什么访问vertices会触发Mesh复制
这是Unity一个非常经典的设计。假设场景里有10个物体共用同一个Mesh资产,你只改了其中一个的顶点,如果直接改共享资产,那10个物体全变了,这显然不对。所以Unity的做法是:当你第一次通过脚本访问某个MeshRenderer的mesh属性(注意不是sharedMesh)时,如果这个Mesh还被别人共享着,Unity就悄悄复制一份出来给你。这份复制出来的Mesh,带着完整的CPU顶点数据,内存占用直接翻倍甚至更多。
我见过最离谱的案例是:有人在Update里写mesh.vertices去读一个顶点位置,结果每帧都在触发复制判断,虽然Unity有优化不会每帧都复制,但只要这个Mesh被标记为可写且被脚本持有,那份CPU副本就一直存在。如果场景里有几百个这样的物体,每个都复制一份,内存不炸才怪。
提示:判断一个Mesh是否已经被实例化,可以看它的
hideFlags或者用Profiler的Memory模块看Mesh分类下的对象数量。如果数量远大于你预期的资产数,基本就是被脚本访问触发了复制。
2.3 Read/Write与MeshCollider的隐藏关联
MeshCollider是另一个重灾区。很多人不知道,MeshCollider在烘焙碰撞数据时,需要读取Mesh的顶点和三角形信息。如果Mesh没有打开Read/Write,Unity在运行时是无法为它构建MeshCollider的——至少在需要动态烘焙的情况下不行。
具体来说,分两种情况。如果MeshCollider在编辑期就已经烘焙好了(也就是你在编辑器里挂上去、场景保存时数据已经生成),那运行时不需要Read/Write也能用,因为碰撞数据已经序列化在场景里了。但如果你是运行时动态给一个物体加MeshCollider,或者动态修改Mesh之后再重新烘焙碰撞,那就必须打开Read/Write,否则会报“Mesh is not readable”的错误。
这里的内存账要算清楚:打开Read/Write,你多了一份CPU顶点数据;挂上MeshCollider,你又多了一份碰撞网格数据(通常是三角形索引和简化后的凸包)。两份加起来,一个中等复杂度的模型可能就多出几兆到几十兆。场景里如果有几百个这样的物件,数字就很可观了。
2.4 SkinnedMeshRenderer的特殊性
SkinnedMesh(蒙皮网格)比普通Mesh更复杂。它除了顶点数据,还有骨骼权重、绑定姿势矩阵、骨骼层级。SkinnedMeshRenderer在运行时需要CPU参与蒙皮计算(除非你用GPU Skinning),所以它对Read/Write的依赖和普通Mesh不太一样。
默认情况下,SkinnedMeshRenderer的Mesh即使不打开Read/Write,Unity也能做蒙皮,因为蒙皮计算是在GPU或者专门的蒙皮管线里做的。但如果你想在脚本里读取蒙皮后的顶点位置(比如做命中检测、做顶点特效),那就必须打开Read/Write。而且SkinnedMesh的CPU副本通常比静态Mesh更大,因为多了骨骼相关的数据。
另外,SkinnedMeshRenderer有一个BakeMesh方法,它可以把当前蒙皮后的结果烘焙成一个新的Mesh。这个操作会创建一个新的Mesh对象,如果你频繁调用而不释放,内存会持续增长。我见过有人在每帧调用BakeMesh做碰撞检测,结果内存一路涨到几个G。正确做法是复用同一个Mesh对象,或者用MeshCollider配合BakeMesh但严格控制频率。
3. 实操配置:怎么开、怎么关、怎么验证
3.1 在编辑器里正确设置Read/Write
打开Read/Write的入口在模型的导入设置里。选中FBX或者Mesh资产,在Inspector的Model页签下,找到Read/Write Enabled选项。注意,这个选项在较新版本的Unity里默认是关闭的,老版本可能默认打开。如果你不确定,批量选中所有模型资产,在Inspector里统一设置。
这里有个实操技巧:不要无脑全开,也不要无脑全关。正确的做法是按需开启。判断标准很简单:这个Mesh在运行时会不会被脚本读写?会不会被动态烘焙MeshCollider?如果都不会,就关掉。如果会,就打开。我一般会在项目初期全部关掉,然后遇到报错再逐个打开,这样能最大程度省内存。
对于SkinnedMesh,情况稍微特殊。如果你的角色不需要脚本读取顶点,只是正常播放动画,那Read/Write可以关掉。但如果你用了某些需要CPU访问顶点的插件(比如某些布料模拟、某些命中检测方案),那就得打开。建议在真机上用Profiler实测,看打开前后内存差多少,再决定。
3.2 运行时动态控制Mesh的可读性
Unity没有提供在运行时直接切换Read/Write的API,这个开关是导入设置,烘焙进资产里的。但你可以通过代码在运行时创建一个可读的Mesh副本。比如:
Mesh readableMesh = Instantiate(originalMesh); readableMesh.UploadMeshData(false); // false表示保留CPU副本UploadMeshData(true)会把Mesh标记为不可读,释放CPU副本;UploadMeshData(false)则保留。这个API在运行时动态管理Mesh内存时非常有用。比如你加载了一个大模型,做完顶点修改之后,如果不再需要读写,就调用UploadMeshData(true)把CPU副本释放掉。
但要注意,UploadMeshData(true)之后,这个Mesh就不能再被脚本访问顶点了,也不能再用于动态烘焙MeshCollider。所以调用时机要把握好,一般是在所有顶点操作完成、碰撞体也烘焙好之后。
3.3 用Profiler验证内存变化
验证Read/Write影响最直接的工具是Unity Profiler的Memory模块。具体操作:打开Profiler,切换到Memory页签,用Detailed模式,然后在场景里加载/卸载带Mesh的物体,观察Mesh分类下的内存变化。
我一般会做一组对比测试:同一个场景,第一次全部Mesh关闭Read/Write,记录Mesh内存;第二次全部打开,再记录。差值就是CPU副本的总大小。这个数字在移动端尤其重要,因为移动端内存本来就紧张。
另外,Profiler里还能看到Mesh对象的数量。如果数量异常多,说明有Mesh被实例化了。这时候可以用Resources.FindObjectsOfTypeAll<Mesh>()在编辑器里扫一遍,看看哪些Mesh是运行时生成的、哪些是资产副本。不过这个API在运行时开销很大,只建议在编辑器里做诊断用。
3.4 批量处理工具脚本
项目大了之后,手动一个个改导入设置不现实。我写过一个简单的编辑器脚本,批量扫描所有Mesh资产,按规则设置Read/Write:
[MenuItem("Tools/Mesh/Disable ReadWrite For Static Meshes")] static void DisableReadWriteForStaticMeshes() { string[] guids = AssetDatabase.FindAssets("t:Model"); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); ModelImporter importer = AssetImporter.GetAtPath(path) as ModelImporter; if (importer != null && !importer.isReadable) { importer.isReadable = false; importer.SaveAndReimport(); } } }这个脚本的逻辑是:找到所有模型资产,把isReadable设为false。实际项目中我会加更多判断,比如根据文件夹路径、根据模型是否包含动画、根据是否被特定脚本引用等。关键是要有一套明确的规则,而不是拍脑袋决定。
注意:批量修改导入设置会触发重新导入,大项目可能要等很久。建议在版本控制干净的时候做,做完提交一次,避免和其他人的改动冲突。
4. 内存账本:打开Read/Write到底多花多少内存
4.1 单个Mesh的内存计算公式
一个Mesh的CPU副本大小,粗略估算公式是:顶点数 × 每个顶点属性字节数。每个顶点通常包含位置(12字节)、法线(12字节)、UV(8字节)、切线(16字节),如果带颜色再加4字节,带骨骼权重再加若干字节。所以一个普通静态Mesh,每个顶点大概48到60字节。
举个例子:一个1万顶点的模型,CPU副本大约500KB到600KB。听起来不多,但如果你有500个这样的模型,就是250MB到300MB。移动端总共可能就2G内存,这一下就吃掉一大块。
SkinnedMesh更夸张,因为多了骨骼索引和权重。每个顶点可能多出8到16字节,而且SkinnedMesh通常顶点数更多。一个2万顶点的角色,CPU副本可能到1.5MB以上。如果场景里有几十个角色,数字就很吓人了。
4.2 MeshCollider的额外开销
MeshCollider的内存开销主要来自碰撞网格数据。Unity在烘焙MeshCollider时,会生成一份用于物理查询的加速结构(通常是BVH树)和三角形数据。这份数据的大小和三角形数量成正比,通常比顶点数据小一些,但也不容忽视。
更麻烦的是,如果MeshCollider是动态烘焙的,每次修改Mesh都要重新烘焙,会产生临时内存分配。如果频繁做这个操作,GC压力会很大。我建议动态MeshCollider尽量用简化后的碰撞网格,不要直接用渲染网格。Unity的MeshCollider.sharedMesh可以指定一个单独的、低面数的碰撞Mesh,这样既省内存又省物理计算。
4.3 实测数据对比
我在一个测试场景里放了100个相同的模型,每个模型5000顶点,分别测试四种组合:
| 配置 | Mesh内存 | 物理内存 | 总内存 |
|---|---|---|---|
| Read/Write关,无Collider | 约12MB | 0 | 约12MB |
| Read/Write开,无Collider | 约36MB | 0 | 约36MB |
| Read/Write关,有Collider | 约12MB | 约8MB | 约20MB |
| Read/Write开,有Collider | 约36MB | 约8MB | 约44MB |
可以看到,打开Read/Write让Mesh内存直接翻了三倍(因为除了GPU数据,CPU副本也占了一份,而且Unity内部还有一些管理开销)。加上Collider之后,总内存差距更明显。这还只是100个模型,实际项目里数量往往更多。
4.4 移动端的特殊考量
移动端的内存带宽和容量都比PC紧张,而且很多移动GPU对顶点数据的读取方式不同。在移动端,打开Read/Write的代价可能比PC更大,因为CPU和GPU共享内存(比如某些移动芯片架构),CPU副本会直接挤占GPU可用的内存空间。
我的经验是:移动端项目,除非万不得已,否则不要打开Read/Write。如果确实需要顶点操作,考虑用GPU端的方案(比如顶点着色器、Compute Shader)替代CPU端操作。如果必须用CPU,那就尽量缩小需要读写的Mesh范围,比如只对主角打开,场景静态物件全部关闭。
5. 常见问题与排查实录
5.1 报错“Mesh is not readable”怎么排查
这个报错通常出现在运行时给MeshCollider赋值、或者脚本访问Mesh顶点的时候。排查步骤:
- 确认报错的Mesh是哪个资产,看它的导入设置里Read/Write是否打开。
- 如果Mesh是运行时生成的(比如用代码创建的),检查创建时是否设置了
MarkDynamic或者是否调用了UploadMeshData。 - 如果Mesh是从AssetBundle加载的,检查打包时是否保留了Read/Write设置。AssetBundle里的Mesh可读性取决于打包时的设置,运行时改不了。
- 如果是SkinnedMesh,检查
BakeMesh的调用是否在Read/Write关闭的情况下进行。
我遇到过一次比较隐蔽的情况:Mesh本身打开了Read/Write,但被UploadMeshData(true)在某个时机关掉了,之后又去访问顶点,就报错了。这种要看代码里有没有动态释放CPU副本的逻辑。
5.2 内存持续上涨但找不到原因
如果Profiler里Mesh内存持续上涨,但场景里物体数量没变,大概率是Mesh被反复实例化了。常见触发点:
- 在Update里访问
mesh.vertices或mesh.normals。 - 每次调用
BakeMesh都new一个Mesh。 - 动态合并Mesh时没有释放旧的。
- 某些插件在后台偷偷复制Mesh。
排查方法:用Profiler的Memory模块,看Mesh对象的数量变化。如果数量在涨,就用Resources.FindObjectsOfTypeAll<Mesh>()在编辑器里列出所有Mesh,看哪些是重复的。另外,可以在代码里给Mesh的name加上标记,方便识别来源。
5.3 SkinnedMesh的BakeMesh内存泄漏
SkinnedMeshRenderer.BakeMesh是内存泄漏的高发区。这个API每次调用都会把当前蒙皮结果写入你传入的Mesh对象。如果你每次都传一个新的Mesh,就会不断创建新对象。正确做法是复用一个Mesh:
private Mesh bakedMesh; void Start() { bakedMesh = new Mesh(); } void Update() { skinnedMeshRenderer.BakeMesh(bakedMesh); // 使用bakedMesh做检测 }这样只有一个Mesh对象,不会持续增长。但要注意,BakeMesh本身有CPU开销,不要每帧对大量角色调用。如果只是做碰撞检测,可以考虑用简化的碰撞体代替。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 报错Mesh is not readable | Read/Write未打开 | 在导入设置中打开 |
| Mesh内存异常高 | 脚本访问触发实例化 | 改用sharedMesh,避免运行时访问vertices |
| 动态MeshCollider失效 | Mesh不可读 | 打开Read/Write或预烘焙碰撞 |
| SkinnedMesh内存涨 | BakeMesh未复用Mesh | 复用同一个Mesh对象 |
| AssetBundle加载后不可读 | 打包时未保留可读性 | 打包前检查导入设置 |
| 移动端内存紧张 | Read/Write开启过多 | 按需关闭,用GPU方案替代 |
5.5 几个容易踩的坑
第一个坑:以为sharedMesh和mesh没区别。sharedMesh是共享资产,改它会改所有引用者;mesh会触发实例化。读的时候用sharedMesh,写的时候才用mesh,而且要意识到写会带来内存开销。
第二个坑:在编辑器里测试正常,打包后报错。因为编辑器里Unity可能自动帮你处理了可读性,但打包后AssetBundle里的Mesh可读性取决于打包设置。一定要在真机上验证。
第三个坑:以为关掉Read/Write就一定能省内存。如果Mesh已经被实例化了,关掉导入设置不会影响已经实例化的副本。要在实例化之前就规划好。
第四个坑:SkinnedMesh的updateWhenOffscreen。这个选项如果打开,即使角色在屏幕外,Unity也会持续更新蒙皮,增加CPU和内存开销。如果角色经常在屏幕外,建议关掉,但要注意关掉后包围盒可能不准确,导致角色突然消失。
6. 进阶优化:从源头控制Mesh内存
6.1 模型导入阶段的减面与合并
最有效的Mesh内存优化,其实是在导入阶段就控制顶点数。很多美术给的模型面数远超实际需要,尤其是移动端项目。我一般会要求美术在导出前做减面,或者在Unity导入设置里用Mesh Compression选项压缩顶点数据。
Mesh Compression可以把顶点数据从Float32压缩到Float16甚至更低,直接减少CPU和GPU侧的内存占用。但压缩会带来精度损失,对于需要精确顶点操作的Mesh要慎用。一般静态场景物件可以开中等压缩,角色和需要读写的Mesh保持不压缩。
另外,多个小Mesh可以合并成一个大Mesh,减少Draw Call和Mesh对象数量。但合并后如果其中一个需要Read/Write,整个大Mesh都得打开,反而可能增加内存。所以合并要权衡。
6.2 LOD与Mesh内存的关系
LOD(Level of Detail)系统会根据距离切换不同精度的Mesh。高精度Mesh只在近处使用,远处用低精度版本。这对内存的影响是:如果所有LOD级别都常驻内存,总内存反而增加;但如果配合Addressables或AssetBundle做按需加载,就能有效控制。
我的做法是:LOD0(最高精度)如果不需要Read/Write就关掉;LOD1、LOD2通常面数少,即使打开Read/Write开销也不大,但一般也没必要打开。关键是确保远处物体的高精度Mesh被卸载,而不是一直占着内存。
6.3 用Addressables管理Mesh资产
Addressables可以按需加载和卸载Mesh资产。对于大场景,可以把Mesh分组,根据玩家位置动态加载。这样同一时间内存里只有当前区域需要的Mesh,而不是整个场景的。
但Addressables的卸载要注意引用计数。如果Mesh还被某个GameObject引用着,卸载不会真正释放。要确保卸载前销毁所有引用该Mesh的物体,或者用Resources.UnloadUnusedAssets强制清理。
6.4 GPU Skinning替代CPU蒙皮
对于SkinnedMesh,如果不需要CPU读取顶点,可以开启GPU Skinning。这样蒙皮计算在GPU做,CPU侧不需要保留顶点副本,Read/Write可以关掉。Unity的Player Settings里有GPU Skinning选项,打开后SkinnedMeshRenderer会用GPU计算。
GPU Skinning的代价是兼容性——老设备可能不支持。而且开了之后,BakeMesh的行为会变化,需要测试。但在支持的设备上,它能显著降低CPU开销和内存占用。
6.5 一个实际项目的优化案例
我之前做过一个开放世界项目,场景里有大量植被和建筑。初始版本Mesh内存占了1.2G。优化步骤:
- 批量关闭所有静态物件的Read/Write,内存降到800M。
- 把植被Mesh合并成几个大Mesh,减少对象数量,降到600M。
- 用Addressables做分块加载,同时只加载玩家周围3个区块,降到300M。
- 对角色开启GPU Skinning,关闭Read/Write,再降50M。
最终Mesh内存控制在250M左右,低端机也能跑。这个过程里,Read/Write的关闭贡献了最大的一块。所以别小看这个开关,它可能是你项目里最容易被忽视的内存大户。
6.6 监控与自动化
项目上线后,内存问题可能随时出现。建议在CI流程里加一个Mesh内存检查:打包后自动跑一个场景,用Profiler API采集Mesh内存数据,超过阈值就报警。这样能在早期发现问题,而不是等玩家反馈闪退。
另外,可以在游戏里加一个调试面板,实时显示Mesh数量和内存。我一般用Profiler.GetTotalAllocatedMemoryLong()配合Resources.FindObjectsOfTypeAll<Mesh>().Length做粗略监控。虽然不精确,但能快速发现异常。
7. 我个人在实际操作中的几点体会
做Unity这些年,Mesh内存这块踩过的坑比任何其他模块都多。最大的体会是:不要等到内存炸了才去查,要在项目初期就定好规范。比如明确规定哪些文件夹的模型默认关闭Read/Write,哪些需要打开;规定SkinnedMesh的BakeMesh必须复用对象;规定动态MeshCollider必须用简化网格。这些规范写进项目文档,比事后优化省事得多。
另一个体会是,Profiler要会用,但不能全信。Profiler里的Mesh内存有时候不包括AssetBundle里的、有时候不包括已经标记为卸载但还没GC的。最好结合Resources.UnloadUnusedAssets和GC.Collect做几次强制清理,再看稳定后的数值。
最后分享一个小技巧:如果你不确定某个Mesh要不要打开Read/Write,可以先关掉,然后在真机上跑一遍所有功能。如果没报错、没异常,就保持关闭。如果某个功能报错了,再针对性地打开。这样能保证只有真正需要的Mesh才付出内存代价。我靠这个方法,在一个中型项目里省下了将近400M的内存,效果立竿见影。