ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity Mesh内存优化:Read/Write开关与性能调优实战

Unity Mesh内存优化:Read/Write开关与性能调优实战 1. 从一次线上事故说起Mesh内存为什么会失控项目上线第三周测试同学反馈角色在切换场景时偶发卡顿帧率从稳定的60帧掉到20帧以下而且设备发热明显。抓了Profiler一看Mesh相关的内存占用在场景切换后不降反升累计涨了将近80MB。排查到最后问题出在几个带Read/Write Enabled勾选的静态模型上——它们被MeshCollider引用而美术同学在导入设置里默认开了读写权限导致每个Mesh在CPU侧都保留了一份完整副本卸载场景时这份副本没有被正确释放。这个坑其实很典型。Unity里Mesh的内存开销从来不是单一维度的事情它牵扯到导入设置、运行时API调用、碰撞体引用、蒙皮网格更新、资源卸载策略等一整条链路。很多人只盯着Profiler里的Mesh曲线却忽略了Read/Write这个开关才是决定内存翻倍与否的关键闸门。这篇内容就把我这些年踩过的坑、验证过的结论、以及可以直接抄的配置方案整理出来适合正在做性能优化、或者被Mesh内存问题困扰的Unity开发者参考。不管你是刚接触Unity的新手还是已经做过几个项目的老手只要涉及3D资源管理这些细节都绕不开。2. Mesh内存到底由什么构成2.1 显存侧与内存侧的双份开销Unity里一个Mesh对象的内存占用本质上分两块GPU侧显存和CPU侧托管内存。GPU侧那份是必须的顶点数据、索引数据要传给显卡渲染这部分大小由顶点数量、顶点属性位置、法线、UV、切线、颜色等和索引格式决定。CPU侧那份则取决于Read/Write Enabled是否开启——开启后Unity会在托管堆或原生内存里保留一份完整的Mesh数据副本供脚本通过Mesh.vertices、Mesh.triangles等API读取和修改。很多人误以为Read/Write只是“能不能改”的问题实际上它首先是个内存问题。一个10万顶点的模型如果顶点属性包含位置、法线、UV、切线每个顶点大约占用32到48字节加上索引数据单份Mesh数据可能在4MB到6MB之间。开启读写后CPU侧再存一份总内存直接翻倍。场景里如果有几十个这样的模型几百MB的额外开销就出来了。2.2 顶点属性对内存的放大效应顶点属性不是免费的。位置float312字节法线float312字节UVfloat28字节切线float416字节颜色float416字节。一个顶点如果全属性拉满就是64字节。10万顶点就是6.4MB这还只是单份。如果模型还带了多套UV、骨骼权重、骨骼索引占用会进一步上升。实际项目里常见的优化手段是压缩顶点属性。比如法线和切线用Mesh Compression选项压缩UV用半精度颜色用Color32。这些在导入设置里都能配但要注意压缩会带来精度损失对于需要高精度法线计算的模型比如法线贴图密集的角色压缩过度会导致光照瑕疵。2.3 索引格式的隐性成本索引数据容易被忽略。Unity默认会根据顶点数量自动选择16位或32位索引。顶点数小于65535时用16位每个索引2字节超过则用32位每个索引4字节。一个20万顶点的模型索引数量通常是顶点数的1.5到3倍取决于三角形共享程度32位索引下索引数据可能达到2.4MB到4.8MB。如果这个模型还开了读写CPU侧再存一份开销就很可观了。注意索引格式在导入后是只读的运行时无法更改。如果发现某个模型索引占用过大只能回到DCC工具里拆分模型或优化拓扑。3. Read/Write开关的真实作用与代价3.1 开关背后的内存分配逻辑Read/Write Enabled这个选项在模型导入设置里控制的是Unity是否在CPU可访问的内存里保留Mesh数据。关闭时Mesh数据上传到GPU后CPU侧的那份会被释放或者根本不存在脚本调用Mesh.vertices会直接报错或返回空数组。开启时Unity会在原生内存里分配一块区域存放Mesh数据脚本可以随时读写。这里有个细节Unity 2019之后的版本开启读写后Mesh数据存放在原生内存而非托管堆所以不会直接体现在GC Alloc里但会体现在Profiler的Mesh内存分类中。很多人用Profiler看托管堆没涨就以为没问题实际上原生内存已经悄悄涨上去了。3.2 什么时候必须开读写不是所有Mesh都需要开读写。以下场景通常必须开启运行时需要修改顶点位置比如顶点动画、地形变形、布料模拟需要从Mesh读取数据做碰撞检测或射线检测部分MeshCollider场景需要动态合并Mesh或生成新的Mesh数据使用Mesh.SetVertices、Mesh.SetTriangles等API动态构建网格反过来以下场景可以关闭纯静态场景模型不需要运行时修改只用于渲染、不参与任何脚本逻辑的模型已经用MeshCollider烘焙了碰撞数据、不需要运行时读取的模型3.3 关闭读写后的性能收益实测我在一个中型场景里做过对比测试场景包含约120个静态模型总顶点数约180万。全部开启读写时Profiler显示Mesh内存占用约210MB关闭读写后降到约105MB直接省了一半。帧率方面关闭读写后场景切换时的卡顿明显减少因为卸载时不需要释放CPU侧的那份数据卸载耗时从平均180ms降到90ms左右。这个收益在移动端更明显。移动设备内存带宽有限CPU侧多一份Mesh数据不仅占内存还会增加内存带宽压力间接影响帧率。所以移动项目里除非确实需要运行时修改否则一律关闭读写。4. MeshCollider与SkinnedMesh的特殊处理4.1 MeshCollider的读写依赖MeshCollider是个容易踩坑的组件。它有两种模式Convex和Non-Convex。Convex模式下Unity会基于Mesh生成一个凸包碰撞体这个过程需要读取Mesh数据所以如果Mesh没开读写Convex模式可能无法正常工作取决于Unity版本和平台。Non-Convex模式下Unity直接使用Mesh的三角形数据做碰撞检测同样需要访问Mesh数据。但这里有个优化技巧烘焙碰撞数据。在导入设置里勾选Generate Colliders或者在场景里手动烘焙MeshColliderUnity会把碰撞数据单独存一份之后运行时就不需要再读取原始Mesh了。这时候即使关闭读写MeshCollider也能正常工作。烘焙后的碰撞数据通常比原始Mesh小很多因为只保留了几何信息去掉了UV、法线、切线等渲染属性。4.2 SkinnedMesh的读写与更新开销SkinnedMeshRenderer比普通MeshRenderer复杂得多。蒙皮网格在运行时需要根据骨骼动画更新顶点位置这个更新过程在CPU侧完成除非用GPU Skinning。如果SkinnedMesh开了读写CPU侧保留Mesh数据更新时直接修改这份数据再上传GPU如果关闭读写Unity会用另一种机制通常是双缓冲或直接写入GPU来更新。实测下来SkinnedMesh开启读写的开销主要体现在两方面一是内存翻倍二是每次骨骼更新时CPU要写入Mesh数据增加CPU负担。对于骨骼数量多、顶点数高的角色这个开销很可观。优化方案是如果角色不需要运行时读取Mesh数据比如不需要做顶点级别的碰撞检测可以关闭读写让Unity用更高效的路径更新蒙皮。4.3 动态合并Mesh时的读写策略动态合并Mesh比如把多个小物件合并成一个大Mesh以减少Draw Call时通常需要开启读写因为要用CombineMeshesAPI。但合并完成后如果不再需要修改可以调用Mesh.UploadMeshData(true)把Mesh数据标记为“只读”并释放CPU侧副本。这个API很多人不知道但它能有效降低合并后的内存占用。// 合并Mesh后释放CPU侧数据 Mesh combinedMesh new Mesh(); combinedMesh.CombineMeshes(combineInstances); combinedMesh.UploadMeshData(true); // true表示标记为只读并释放CPU副本注意UploadMeshData(true)之后Mesh就不可再修改了调用Mesh.vertices会报错。所以只在确定不再修改时调用。5. 实操从导入设置到运行时卸载的完整链路5.1 导入设置里的关键选项模型导入设置里和Mesh内存相关的选项主要有这几个选项作用建议Read/Write Enabled是否保留CPU侧Mesh数据默认关闭按需开启Mesh Compression压缩顶点数据静态模型可开Medium角色模型慎用Optimize Mesh优化顶点顺序和索引建议开启Generate Colliders自动生成碰撞体需要碰撞的模型开启Index Format索引格式自动即可除非有特殊需求Read/Write Enabled默认是关闭的但很多美术同学在导入时习惯性勾选导致内存浪费。建议在项目规范里明确除非程序明确要求否则一律不勾。5.2 运行时动态加载与卸载运行时通过Resources.Load或AssetBundle加载Mesh时读写状态由导入设置决定。加载后如果发现某个Mesh不需要读写可以在运行时调用Mesh.UploadMeshData(true)释放CPU副本。但要注意这个操作不可逆且只对动态加载的Mesh有效。卸载方面Resources.UnloadUnusedAssets会释放没有被引用的Mesh但前提是Mesh没有被任何活跃对象引用。如果Mesh被MeshCollider引用即使MeshRenderer已经销毁Mesh也不会被卸载。所以销毁对象时要确保MeshCollider也被销毁或者提前烘焙碰撞数据。5.3 用Profiler定位Mesh内存问题Profiler的Memory模块里Mesh分类会显示当前所有Mesh的内存占用。点击具体Mesh可以看到它的顶点数、索引数、是否可读写等信息。如果发现某个Mesh占用异常大先检查它的顶点属性是否过多、是否开了读写、是否被多个对象引用。另一个工具是Memory Profiler包它能抓取内存快照看到每个Mesh的详细信息和引用链。对于复杂场景Memory Profiler比Profiler更直观。5.4 一个完整的优化案例之前做过一个开放世界场景里面有大量植被和建筑。优化前Mesh内存占用约450MB优化后降到约180MB。具体做法把所有静态模型的Read/Write Enabled关闭省了约150MB。对需要碰撞的模型烘焙MeshCollider数据关闭读写后碰撞依然正常。对植被模型开启Mesh Compression顶点数据压缩约40%。对动态合并的Mesh调用UploadMeshData(true)释放CPU副本。检查SkinnedMesh关闭不需要读写的角色Mesh读写权限。这套组合拳下来内存降了六成帧率也稳定了不少。6. 常见问题与排查技巧实录6.1 关闭读写后MeshCollider失效怎么办这是最常见的问题。关闭读写后MeshCollider如果没烘焙碰撞数据会报错或碰撞失效。解决办法有两个一是在导入设置里勾选Generate Colliders让Unity自动烘焙二是在场景里手动烘焙然后关闭读写。烘焙后的碰撞数据会存在Mesh资产里不依赖原始Mesh的读写状态。6.2 为什么Profiler里Mesh内存没降有时候关闭读写后Profiler里Mesh内存没明显变化。原因可能是Mesh被多个对象引用关闭读写只影响新加载的实例或者Mesh已经被UploadMeshData标记为只读但之前加载的实例还保留着CPU副本又或者Profiler显示的是GPU侧内存CPU侧内存在其他分类里。建议用Memory Profiler抓快照看具体是哪个Mesh、哪份数据在占内存。6.3 SkinnedMesh关闭读写后动画异常部分Unity版本里SkinnedMesh关闭读写后如果用了某些自定义Shader或GPU Skinning方案可能导致动画更新异常。这时候需要检查Shader是否依赖CPU侧Mesh数据或者改用MeshRenderer加顶点动画的方案。实测Unity 2021之后的版本标准SkinnedMeshRenderer关闭读写后动画正常但自定义方案要单独验证。6.4 动态合并Mesh后内存反而涨了动态合并时原始Mesh和合并后的Mesh会同时存在如果原始Mesh没释放内存会翻倍。解决办法是合并后立即销毁原始Mesh或者用UploadMeshData(true)释放合并Mesh的CPU副本。另外合并后的Mesh如果顶点数超过65535索引会变成32位索引数据翻倍这个也要注意。6.5 常见问题速查表问题可能原因解决办法关闭读写后碰撞失效MeshCollider未烘焙烘焙碰撞数据或开启读写Mesh内存没降引用未释放或Profiler分类问题用Memory Profiler抓快照SkinnedMesh动画异常Shader依赖CPU数据检查Shader或改用GPU Skinning合并后内存涨原始Mesh未释放销毁原始Mesh或UploadMeshData移动端卡顿CPU侧Mesh数据占带宽关闭读写压缩顶点属性6.6 几个容易忽略的细节Mesh.UploadMeshData(true)之后Mesh的isReadable会变成false但vertexCount等只读属性依然可访问。Resources.UnloadUnusedAssets不会卸载被MeshCollider引用的Mesh除非碰撞数据已烘焙。AssetBundle卸载时如果Mesh被场景对象引用不会立即释放需要先销毁引用对象。不同Unity版本对Read/Write的处理有差异升级版本后建议重新验证。7. 一些实战中的个人体会我在实际项目里养成了一个习惯每次导入新模型先看Read/Write Enabled是否被勾选除非程序明确说需要否则一律关掉。这个习惯帮我省下了大量内存尤其是在移动端项目里效果立竿见影。另一个体会是MeshCollider能烘焙就烘焙不要依赖运行时读取Mesh数据烘焙后的碰撞数据更小、更稳定也不受读写开关影响。还有一点SkinnedMesh的读写开关要单独评估。角色模型通常顶点数高、骨骼多开启读写的内存和CPU开销都很大。如果不需要运行时读取顶点数据关闭读写是更优选择。但关闭后要验证动画是否正常特别是用了自定义Shader或GPU Skinning的项目。最后分享一个小技巧在Profiler里给Mesh内存设个阈值超过就报警。这样能在开发早期发现内存问题避免上线前才发现Mesh内存失控。这个阈值可以根据项目目标设备的内存预算来定比如移动端可以设成50MBPC端可以设成200MB。
返回列表