做Unity性能优化做到一定程度,一定会碰到Mesh的内存问题。尤其是移动端项目,一进场景内存就往上飙,定位半天发现不是贴图,而是美术丢过来的那些模型——它们每个文件看着都不大,但进内存之后个个都像“隐形胖子”。这时候最常被问到的就是那个勾选框:Import Settings里的Read/Write Enabled。这个开关到底该不该勾?勾了多占多少内存?不勾为什么有的功能会报错?今天就把这笔账彻底算清楚。
先说一下结论:Read/Write这个开关不是“勾了兼容性更好”的白名单,它本质上是“要不要在CPU侧常驻一份可编辑的网格数据”。大多数纯渲染的网格根本不需要这份数据,开着就是白花钱;而少数功能(碰撞、动态修改、网格合并)又确实依赖它。理解清楚背后的内存模型,才知道什么时候勾、什么时候关、关了之后怎么把数据再找回来。
1. Read&Write 开关到底在管什么?
1.1 一份网格,两份数据
一个Mesh在运行时会“住”在两个地方:CPU内存和GPU显存。CPU侧是顶点坐标、法线、切线、UV、顶点色、骨骼权重、三角形索引这些数组,脚本可以随时读写;GPU侧则是一份打包好的Vertex Buffer和Index Buffer,只有渲染管线能高效访问。Read/Write Enabled控制的就是CPU侧那部分数据在网格上传到GPU之后“留不留、允不允许访问”。
很多人在网上查资料,看到的说法是“开启Read/Write会多占一份内存”,但没解释为什么会多占一份。实际上,引擎在加载模型时会先把几何数据解析到CPU内存中,然后第一次渲染时把这些数据上传到GPU。如果Read/Write是开启的,上传之后CPU侧的数据会被继续保留,因为脚本随时可能通过mesh.vertices之类的接口去读、去改;如果Read/Write是关闭的,数据上传完,CPU侧那份就可以标记释放,GC和系统内存管理器会把这块空间回收掉。说白了,一份网格到底占一份内存还是两份内存,就差在这一个开关上。
这里可以打个比方:CPU侧相当于打印前的源文档,GPU侧相当于最终打印出来的成品。Read/Write开着,就是源文档一直保存在手边,想改随时改;关掉,就是打印完就把源文档销毁,只留成品。成品不能随意改动,但如果你永远不改,留着源文档就是纯粹的浪费。
1.2 从导入到上屏,网格数据经历了什么
一个模型从导入到出现在屏幕上,内存变化大致分三个阶段。
第一阶段是导入和加载。Unity把FBX、OBJ等源文件解析成引擎内部的Mesh数据,这时候数据必定在CPU侧。第二阶段是首次渲染。当场景里第一个需要绘制该网格的Renderer出现,mesh数据会被上传到GPU,Shader、阴影、后处理基本都是吃GPU侧的数据。第三阶段是释放。如果Read/Write关闭,CPU侧数据在此之后释放;如果开启,则会一直在内存里躺到资源被卸载。
这个流程里有一个容易被忽略的API:UploadMeshData。对于代码动态创建的Mesh,CPU侧数据默认是保留的,即使你没勾任何开关也一样——因为代码创建的Mesh在创建那一刻天然就是可读的。如果你确定这个Mesh后续不会被修改,可以在上传之后调用mesh.UploadMeshData(true),让Unity把它标记为“上传给GPU之后CPU侧不再保留”。这个API和导入设置的Read/Write开关本质上是同一种机制的两端:一个是导入阶段决定,一个是运行时手动决定。
另外,SharedMesh和mesh的区别也得提一下。MeshFilter.sharedMesh是共享资源,改了会影响所有引用它的对象;mesh是实例副本,会复制一份数据并额外占用内存。很多人为了“保险”在代码里写mesh(而不是sharedMesh),结果每个物体都复制一份网格数据,内存直接翻好几倍,这个坑和Read/Write叠加起来,移动端很容易爆。
1.3 这个开关不止影响内存,还影响加载速度
除了内存占用,Read/Write开启还会让加载流程变重。关闭Read/Write的Mesh,加载时可以走快速路径,不需要额外分配CPU侧的副本结构,反序列化和导入花的时间也更短。尤其是AssetBundle场景下,一个含几十个网格的Bundle,开启Read/Write的Mesh在加载时CPU耗时和瞬时分配都会明显增高。但它换来的是“随时可以从CPU访问顶点数据”的能力,所以这不是一个可以无脑统一处理的问题,而是一道根据用途做的选择题。
2. 什么时候必须开,什么时候可以关
2.1 必须开启的场景:别为了省内存牺牲功能
我给项目做内存审计时,第一步就是把模型按用途分类,凡是命中下面这些场景的,Read/Write必须开,省这个内存会出功能事故。
第一类是运行时读取网格数据。脚本里调用mesh.vertices、mesh.normals、mesh.triangles、mesh.uv这类属性时,网格必须是可读的。这类需求常见于顶点动画、变形工具、编辑器扩展、运行时解算、射线检测辅助逻辑等。第二类是动态修改网格。水面波纹、布料模拟、破碎、角色受击变形、草地摆动,这些都要在CPU侧改顶点坐标后再上传,不开Read/Write连改的机会都没有。第三类是MeshCollider。Unity的碰撞体烘焙(Cooking)需要读取网格的三角形数据来构建物理形状,官方文档明确说MeshCollider使用的是可读的Mesh数据,不可读的网格是无法生成碰撞体的,运行时动态给GameObject加MeshCollider也一样。
第四类是网格合并与运行时Mesh处理。Mesh.CombineMeshes、减面、抽取顶点、生成LOD这些操作都需要读取源网格的顶点缓冲和索引缓冲,源网格不开Read/Write就出不来数据。第五类是Navigation导航烘焙。NavMesh的烘焙过程需要遍历场景中所有参与烘焙的网格三角形,烘焙工具和运行时NavMeshSurface Builder同样要求网格可读。顺便说一句,BlendShape(混合变形)如果用动画驱动但脚本不读,其实可以关;但如果美术有工具或运行时逻辑需要读取BlendShape的权重帧数据,那就得开。
2.2 可以放心关的场景:绝大多数静态渲染
反过来,下面这些场景通通可以关:纯静态渲染的模型,比如石头、建筑、植被、静态道具;只用来显示、不参与物理碰撞的装饰物;用简单碰撞体(Box、Sphere、Capsule)替代MeshCollider的物件;不需要动态修改的UI元素、特效网格、低模占位;以及所有“渲染完就完事”的网格。
很多人会担心:关了Read/Write,阴影、描边、后处理、GPU Instancing会不会出问题?不会。这些功能读取的都是GPU侧的顶点和索引数据,和CPU侧那份没有关系。也就是说,只要脚本和物理系统不回头读CPU数据,关掉就安全。
2.3 想用又不想一直占内存的中间方案
如果你遇到的情况是“只有特定时机需要读一次网格数据”,那不必长期开着Read/Write硬扛。我常用的做法有三种。
第一种是在编辑阶段做预处理。比如运行时需要的高精度碰撞数据、顶点权重、UV分布等,在编辑器里用脚本读一次,序列化到ScriptableObject或者二进制Asset里,运行时直接从序列化数据读,模型本身的Read/Write可以关掉。第二种是用MeshDataArray这类只读接口临时获取数据,用完就释放,不需要网格整体保持可读状态。第三种是运行时临时切换:确实需要读的时候让美术开启Read/Write并重新导入,业务逻辑跑完再关掉重新导入。这个方法适合编辑器工具和离线管线,不适合正式包。
经验:凡是能在编辑器阶段解决的数据需求,就不要放到运行时。运行时读Mesh数据无论用的是老API还是新API,都有分配和遍历成本,能省就省。
3. 内存占用到底差多少?先算清楚这笔账
3.1 用公式估算一个Mesh占多少内存
要回答“勾上多占多少内存”,得先会估算Mesh的内存大小。一个Mesh的CPU侧内存主要由顶点数据和索引数据组成。公式大概是:
网格内存 ≈ 顶点数 × 每顶点字节数 + 索引数 × 索引字节数
每顶点字节数取决于Mesh包含哪些顶点属性。常见的属性大小:Position是12字节(3个float),Normal是12字节,Tangent是16字节(4个float),UV0是8字节,UV1、UV2、UV3每个也是8字节,Color一般是16字节,骨骼权重(BlendWeight)16字节,骨骼索引16字节。一个带Position、Normal、Tangent、UV0、UV1的5属性角色模型,每顶点就是12+12+16+8+8=56字节。
我们以真实的角色模型举例:15000个顶点、20000个三角形。顶点部分15000×56字节等于840KB,索引部分20000×3×2字节等于120KB(顶点数没超过65535,用2字节索引就够了),CPU侧合计约1MB。一个场景如果有100个这种量级的模型,开和关的差距就是100MB量级,这个数字对移动端来说非常可观。
如果模型里有BlendShape、多UV、顶点色、骨骼权重,每顶点字节数还会再往上走。极端情况下一个带3个BlendShape、4套UV、骨骼绑定完整的写实角色,每顶点可能超过120字节,同样顶点数,CPU侧能多占一倍内存。这还没算GPU侧,因为GPU侧无论如何都要存一份,Read/Write影响的主要是CPU侧那部分。
3.2 用Profiler和Memory Profiler实测
算完理论值,实际操作时怎么验证?在编辑器里打开Window > Analysis > Profiler,切到Memory面板,Simple视图下会有一个Mesh分类,显示当前场景加载的Mesh数量和内存占用。Memory Profiler Package更精细,打开Window > Analysis > Memory Profiler拍一张快照,能直接看到每个Mesh对象的详细信息、引用来源和Native内存占用。
实测方法很简单:准备一个测试场景,导入一批模型,分别用Read/Write开启和关闭的状态打两个包或两次进入Play Mode,对比Profiler里Mesh分类的数据。我做过一组测试:20个中高模角色加30个道具,全开时Mesh常驻内存约45MB,全关后降到15MB左右,差距接近30MB。这在真机上就是“会不会触发系统低内存警告”的分水岭,尤其是4GB内存的老手机,30MB能决定游戏是流畅运行还是被杀后台。
3.3 别只看总量,还要看峰值和加载瞬时
常驻内存是大家最容易关注的数字,但峰值内存和加载瞬时分配同样受这个开关影响。如果一批模型都是Read/Write开启的,那么在加载瞬间,除了磁盘IO和反序列化,引擎还会为每个Mesh分配完整的CPU数据缓冲区;关闭Read/Write后,这个缓冲区的生命周期大幅缩短,加载过程中的内存峰值能降不少。在AssetBundle加载、切场景、进入战斗这些关键时刻,内存峰值往往比常驻内存更危险,很多移动端闪退就是峰值超限导致的。所以优化这个开关,不仅省常驻内存,还能让加载过程更平稳。
4. 实操落地:从导入设置到运行时释放
4.1 模型导入设置与批量管理
最简单的操作就是在Project窗口选中模型资源,在Inspector的Model页签里勾选或取消Read/Write Enabled,然后点Apply。但如果项目里有几十上百个模型,一个一个改不现实,我建议用两条腿走路。
第一条腿是批量选择。在Project窗口可以多选Mesh资源,然后在Inspector统一修改Read/Write后Apply,Unity会一次性重新导入所有选中资源。第二条腿是写一个AssetPostprocessor,在模型导入时按路径或命名规则自动设置。我们团队的标准是:默认关,只在特定路径(比如Collider/、Dynamic/)或者文件名带_rd标签的模型开。脚本大概是这样的:
#if UNITY_EDITOR using UnityEditor; public class ModelImportDefaultSettings : AssetPostprocessor { private void OnPreprocessModel() { ModelImporter importer = assetImporter as ModelImporter; if (importer == null) return; // 路径中包含 Dynamic 或 Collider 的模型需要可读 bool needReadable = assetPath.Contains("/Dynamic/") || assetPath.Contains("/Collider/") || assetPath.EndsWith("_rd.fbx", System.StringComparison.OrdinalIgnoreCase); importer.isReadable = needReadable; } } #endif这样美术新加的模型只要按目录规范放,导入时就会自动带正确的开关,比事后人工巡检省心得多。需要注意的是,修改导入设置会触发资源重新导入,项目大、模型多的时候要安排好时间,别在大家提交代码的高峰期批量改。
4.2 代码动态创建Mesh的正确姿势
运行时动态生成网格是重灾区。最常见的写法是这样:
Mesh mesh = new Mesh(); mesh.vertices = vertexArray; mesh.uv = uvArray; mesh.triangles = indexArray; GetComponent<MeshFilter>().mesh = mesh;这种写法默认创建的Mesh是CPU可读的,数据会一直留着。如果这个网格还要在运行时改(比如水体、旗帜),那留着是必须的;但如果只是生成一个静态网格显示一次,那就可以在赋值后调用mesh.UploadMeshData(true),让Unity上传到GPU并释放CPU侧数据。注意调用之后就不能再访问vertices、triangles这些属性了,否则会报错。
对于需要频繁更新顶点的高性能场景(比如粒子网格、分块地形更新),别用mesh.vertices = xxx这种老接口,它在内部会整体重建缓冲区,开销非常大。推荐用Mesh.SetVertexBufferParams配SetVertexBufferData,配合NativeArray直接上传,减少GC和数组拷贝。如果不确定自己的Unity版本支不支持,先看项目里的Package Manager是否包含Unity.Collections相关包,2020.1之后这些接口都已稳定。
另外,如果只是纯渲染用的动态网格,可以走“创建后立即上传,之后不再读”的路线:把Mesh读写的生命周期控制在最小范围内,内存和性能都能受益。我在实际项目中见过很多“动态生成一堆网格但从不修改,还留着CPU副本”的代码,统计下来一个场景里多了几十MB,非常可惜。
4.3 Resources、AssetBundle与Addressables中的Mesh管理
Read/Write开关只能控制Mesh本身的数据驻留,控制不了资源生命周期。用Resources.Load加载的Mesh,如果不显式调用Resources.UnloadAsset或Resources.UnloadUnusedAssets,即便你用完之后不再引用它,资源依然会留在内存里。AssetBundle加载的Mesh跟着Bundle走,Bundle卸载(Bundle.Unload(true))时才会释放;Addressables加载的Mesh则要配套调用Addressables.Release。
这里常见的一个误区是:觉得“没有对象引用这个Mesh了,内存就会自动释放”。Unity不是Java那种纯引用计数自动回收,资源对象有引擎层的引用和缓存,得通过卸载接口才能真正释放。我做内存优化时,会同时做两件事:排查所有从Resources和Bundle加载Mesh的代码,确认它们在生命周期结束时正确卸载;再把所有Mesh的Read/Write统一关掉。两个动作加起来,内存曲线才会真正掉下来。
另外,场景中同一个Mesh被很多物体引用时,Unity会复用同一份资源数据,不会重复拷贝。但如果你用Mesh.Instantiate或者代码里new Mesh再赋值,就会真的复制一份。所以静态共享网格尽量用sharedMesh,不要为了修改临时数据而复制整个网格。
5. 常见问题与排查技巧实录
5.1 “Mesh can’t be accessed”报错怎么破
运行时如果遇到这类报错,说明脚本试图访问不可读的网格数据。这种报错的完整提示通常写着“Mesh can't be accessed because it does not have read access. Set the Mesh Read/Write Enabled setting on the Import Settings to allow accessing the mesh data from scripts.”,看起来像天书,翻译过来就是:你关了Read/Write,但代码用了一个需要CPU数据的接口。
排查思路分三步:先定位是哪一行代码触发了报错,看它用的是不是mesh.vertices、mesh.normals、mesh.triangles这类读取接口;再定位这个mesh来自哪个资源,检查它的Import Settings或动态创建代码;最后根据业务决定走哪条路——要么给这个资源开Read/Write,要么把读取逻辑改成MeshDataArray这类只读方案,或者干脆在编辑器阶段把数据预烘焙出去,运行时不再直接读。
注意:在编辑器下打开Read/Write能解决报错,但正式包里这个开关会一直占用内存。如果你只是临时调试,记得排查后把开关关回去,别留着“反正能用就不关了”的坏习惯。
5.2 MeshCollider与NavMesh烘焙:这个开关千万别关错
MeshCollider的坑我踩过好几次。场景里放了一个精细的模型作为碰撞体,MeshCollider组件挂上去,编辑器里一切正常,打包后物理检测偶尔失效或者完全不触发,查了半天,最后发现是模型资源的Read/Write被关闭了。原因是烘焙碰撞体需要读取三角形数据,没有可读网格,引擎无法在运行时重建物理形状。
NavMesh烘焙也一样。如果你用NavMeshSurface或者菜单里的Bake,烘焙后模型关了Read/Write重新导入,已经生成的NavMesh数据不会受影响,但烘焙过程本身必须能读到网格。所以实践中我的建议是:烘焙类需求可以临时开Read/Write,烘焙完成后关掉并重新导入资源,运行时不再对烘焙结果产生额外负担。如果是运行时动态烘焙NavMesh,那烘焙期间必须保证相关模型的Read/Write是开的,烘焙完成后如果确定不再修改,再想办法释放。
5.3 合并网格后内存反而更高?CombineMeshes的正确用法
Mesh.CombineMeshes可以把多个网格合成一个,但它的实现机制是读取所有子Mesh的顶点和索引数据,然后在新Mesh里重建。如果源网格Read/Write都关着,Combine直接报错或返回空数据;如果开着,合并过程中会为每个子Mesh临时分配数据,合并完成后如果不释放源网格,内存反而会比合并前更高。
正确做法是:参与合并的Mesh确保Read/Write开启;合并完成后,把合并结果Mesh交给渲染,再逐一清理掉临时创建的合并数据。如果合并结果是静态的,最后调用UploadMeshData(true)释放CPU侧数据。不要在每帧重复执行CombineMeshes,合并成本很高,要么提前在编辑器阶段做好,要么缓存合并结果。另外要注意,合并后的总顶点数超过65535时,要设置合理的索引格式,否则Unity会自动回退到32位索引,内存占用会翻一倍。
5.4 怎么批量揪出项目中忘记关的Read/Write
如果项目已经上线,又想判断内存问题是不是Read/Write引起的,最直接的办法是用编辑器脚本扫一遍所有模型的导入设置。下面这个脚本会把项目中所有开启了Read/Write的模型按路径列出来,方便你逐个判断:
#if UNITY_EDITOR using UnityEditor; using UnityEngine; public static class MeshReadWriteScanner { [MenuItem("Tools/Query Mesh ReadWrite")] public static void QueryAll() { string[] guids = AssetDatabase.FindAssets("t:Model", new[] { "Assets" }); int count = 0; foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); ModelImporter importer = AssetImporter.GetAtPath(path) as ModelImporter; if (importer != null && importer.isReadable) { Debug.Log($"开启Read/Write的模型: {path}", AssetDatabase.LoadAssetAtPath<Object>(path)); count++; } } Debug.Log($"统计完成,共 {count} 个模型开启Read/Write"); } } #endif结合Memory Profiler的快照,你能看到开启Read/Write的Mesh具体占了多少Native内存。经验上,一个项目如果Mesh相关的Native内存占比接近甚至超过总内存的15%,优先查这个开关列表,大概率能找到一堆根本不需要可读的模型。
6. 最后分享一点个人经验
我自己在移动端项目里做过两轮Read/Write专项优化,第一轮只是把明显不需要开的模型关了,常驻内存降了大概20MB;第二轮配合AssetPostprocessor默认关开关、编辑器阶段预处理碰撞和动态数据,又把内存峰值压了30%以上。后来团队里形成了一条不成文的规定:美术和程序在提审前都会跑一遍脚本看Read/Write统计,超过10个非必要开启就会被驳回。
如果你不想用这么硬性的流程,至少记住一点:Read/Write是“按需开”的开关,不是“默认开”的保险。遇到功能需要网格数据时,先想想这个数据能不能在编辑器阶段算好、能不能用只读接口、能不能用完后立刻释放;最后一步才考虑长期开着。这个顺序想清楚了,Mesh内存的大头基本就控制住了。
做优化这么久,最深的体会是“内存是不会凭空消失的,只会从一个地方转移到另一个地方”。Read/Write这个开关不是黑魔法,它只是告诉你:那份CPU侧的数据,你到底还要不要。想清楚这个问题,每个Mesh该勾还是不该勾,答案其实很明确。