Unity3D 游戏开发之内存优化

项目的性能优化主要围绕CPU、GPU和内存三大方面进行。

无论是游戏还是VR应用,内存管理都是其研发阶段的重中之重。

然而,在我们测评过的大量项目中,90%以上的项目都存在不同程度的内存使用问题。就目前基于Unity引擎开发的移动游戏和移动VR游戏而言,内存的开销无外乎以下三大部分:1.资源内存占用;2.引擎模块自身内存占用;3.托管堆内存占用。

如果您的项目存在内存问题,一定逃不出以上三种情况。今天,我们就这三种情况逐一进行解释。

资源内存占用

在一个较为复杂的大中型项目中,资源的内存占用往往占据了总体内存的70%以上。因此,资源使用是否恰当直接决定了项目的内存占用情况。一般来说,一款游戏项目的资源主要可分为如下几种:纹理(Texture)、网格(Mesh)、动画片段(AnimationClip)、音频片段(AudioClip)、材质(Material)、着色器(Shader)、字体资源(Font)以及文本资源(Text Asset)等等。其中,纹理、网格、动画片段和音频片段则是最容易造成较大内存开销的资源。

一、纹理

纹理资源可以说是几乎所有游戏项目中占据最大内存开销的资源。一个6万面片的场景,网格资源最大才不过10MB,但一个2048x2048的纹理,可能直接就达到16MB。因此,项目中纹理资源的使用是否得当会极大地影响项目的内存占用。 那么,纹理资源在使用时应该注意哪些地方呢?

(1) 纹理格式

纹理格式是研发团队最需要关注的纹理属性。因为它不仅影响着纹理的内存占用,同时还决定了纹理的加载效率。一般来说,我们建议开发团队尽可能根据硬件的种类选择硬件支持的纹理格式,比如Android平台的ETC、iOS平台的PVRTC、Windows PC上的DXT等等。因此,我们在UWA测评报告中,将纹理格式进行详细罗列,以便开发团队进行快速查找,一步定位。

 

在使用硬件支持的纹理格式时,你可能会遇到以下几个问题:

  • 色阶问题 由于ETC、PVRTC等格式均为有损压缩,因此,当纹理色差范围跨度较大时,均不可避免地造成不同程度的“阶梯”状的色阶问题。因此,很多研发团队使用RGBA32/ARGB32格式来实现更好的效果。但是,这种做法将造成很大的内存占用。比如,同样一张1024x1024的纹理,如果不开启Mipmap,并且为PVRTC格式,则其内存占用为512KB,而如果转换为RGBA32位,则很可能占用达到4MB。所以,研发团队在使用RGBA32或ARGB32格式的纹理时,一定要慎重考虑,更为明智的选择是尽量减少纹理的色差范围,使其尽可能使用硬件支持的压缩格式进行储存。
  • ETC1 不支持透明通道问题 在Android平台上,对于使用OpenGL ES 2.0的设备,其纹理格式仅能支持ETC1格式,该格式有个较为严重的问题,即不支持Alpha透明通道,使得透明贴图无法直接通过ETC1格式来进行储存。对此,我们建议研发团队将透明贴图尽可能分拆成两张,即一张RGB24位纹理记录原始纹理的颜色部分和一张Alpha8纹理记录原始纹理的透明通道部分。然后,将这两张贴图分别转化为ETC1格式的纹理,并通过特定的Shader来进行渲染,从而来达到支持透明贴图的效果。该种方法不仅可以极大程度上逼近RGBA透明贴图的渲染效果,同时还可以降低纹理的内存占用,是我们非常推荐的使用方式。

当然,目前已经有越来越多的设备支持了OpenGL ES 3.0,这样Android平台上你可以进一步使用ETC2甚至ASTC,这些纹理格式均为支持透明通道且压缩比更为理想的纹理格式。如果你的游戏适合人群为中高端设备用户,那么不妨直接使用这两种格式来作为纹理的主要存储格式。

(2)纹理尺寸

一般来说,纹理尺寸越大,则内存占用越大。所以,尽可能降低纹理尺寸,如果512x512的纹理对于显示效果已经够用,那么就不要使用1024x1024的纹理,因为后者的内存占用是前者的四倍。因此,我们在UWA测评报告中,将纹理的尺寸进行详细展示,以便开发团队进行快速检测。

Mipmap旨在有效降低渲染带宽的压力,提升游戏的渲染效率。但是,开启Mipmap会将纹理内存提升1.33倍。对于具有较大纵深感的3D游戏来说,3D场景模型和角色我们一般是建议开启Mipmap功能的,但是在我们的测评项目中,经常会发现部分UI纹理也开启了Mipmap功能。这其实就没有必要的,绝大多数UI均是渲染在屏幕最上层,开启Mipmap并不会提升渲染效率,反倒会增加无谓的内存占用。因此,建议研发团队在UWA的测评报告中通过Mipmap一项进行排序,详细检测开启Mipmap功能的资源是否为UI资源。

(4) Read & Write

一般情况下,纹理资源的“Read & Write”功能在Unity引擎中是默认关闭的。但是,我们仍然在项目深度优化时发现了不少项目的纹理资源会开启该选项。对此,我们建议研发团队密切关注纹理资源中该选项的使用,因为开启该选项将会使纹理内存增大一倍。

 

 

二、网格

网格资源在较为复杂的游戏中,往往占据较高的内存。对于网格资源来说,它在使用时应该注意哪些方面呢?

(1) Normal、Color和Tangent

在我们深度优化过的大量项目中,Mesh资源的数据中经常会含有大量的Color数据、Normal数据和Tangent数据。这些数据的存在将大幅度增加Mesh资源的文件体积和内存占用。其中,Color数据和Normal数据主要为3DMax、Maya等建模软件导出时设置所生成,而Tangent一般为导入引擎时生成。

更为麻烦的是,如果项目对Mesh进行Draw Call Batching操作的话,那么将很有可能进一步增大总体内存的占用。比如,100个Mesh进行拼合,其中99个Mesh均没有Color、Tangent等属性,剩下一个则包含有Color、Normal和Tangent属性,那么Mesh拼合后,CombinedMesh中将为每个Mesh来添加上此三个顶点属性,进而造成很大的内存开销。正因如此,我们在UWA测评报告中为每个Mesh展示了其Normal、Color和Tangent属性的具体使用情况,研发团队可以直接针对每种属性进行排序查看,直接定位出现冗余数据的资源。

 

 

一般来说这些数据主要为Shader所用,来生成较为酷炫的效果。所以,建议研发团队针对项目中的网格资源进行详细检测,查看该模型的渲染Shader中是否需要这些数据进行渲染。

限于篇幅,我们今天只针对纹理和网格资源进行详细介绍,对于动画片段、音频片段等其他资源,建议您直接通过UWA测评报告中进行查看。同时,我们会在后续的资源专题中进行详细讲解,敬请期待。

引擎模块自身占用

引擎自身中存在内存开销的部分纷繁复杂,可以说是由巨量的“微小”内存所累积起来的,比如GameObject及其各种Component(最大量的Component应该算是Transform了)、ParticleSystem、MonoScript以及各种各样的模块Manager(SceneManager、CanvasManager、PersistentManager等)...

一般情况下,上面所指出的引擎各组成部分的内存开销均比较小,真正占据较大内存开销的是这两处:WebStream 和 SerializedFile。其绝大部分的内存分配则是由AssetBundle加载资源所致。简单言之,当您使用new WWW或CreateFromMemory来加载AssetBundle时,Unity引擎会加载原始数据到内存中并对其进行解压,而WebStream的大小则是AssetBundle原始文件大小 + 解压后的数据大小 + DecompressionBuffer(0.5MB)。同时,由于Unity 5.3版本之前的AssetBundle文件为LZMA压缩,其压缩比类似于Zip(20%-25%),所以对于一个1MB的原始AssetBundle文件,其加载后WebStream的大小则可能是5~6MB,因此,当项目中存在通过new WWW加载多个AssetBundle文件,且AssetBundle又无法及时释放时,WebStream的内存可能会很大,这是研发团队需要时刻关注的。

 

 作者:张鑫 链接:https://zhuanlan.zhihu.com/p/21913770 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

对于SerializedFile,则是当你使用LoadFromCacheOrDownload、CreateFromFile或new WWW本地AssetBundle文件时产生的序列化文件。

对于WebStream和SerializedFile,你需要关注以下两点:

  • 是否存在AssetBundle没有被清理干净的情况。开发团队可以通过Unity Profiler直接查看其使用具体的使用情况,并确定Take Sample时AssetBundle的存在是否合理;
  • 对于占用WebStream较大的AssetBundle文件(如UI Atlas相关的AssetBundle文件等),建议使用LoadFromCacheOrDownLoad或CreateFromFile来进行替换,即将解压后的AssetBundle数据存储于本地Cache中进行使用。这种做法非常适合于内存特别吃紧的项目,即通过本地的磁盘空间来换取内存空间。

注意:关于AssetBundle的详细管理机制,建议查看我们之前的AssetBundle技术文章。

托管堆内存占用

对于目前绝大多数基于Unity引擎开发的项目而言,其托管堆内存是由Mono分配和管理的。“托管” 的本意是Mono可以自动地改变堆的大小来适应你所需要的内存,并且适时地调用垃圾回收(Garbage Collection)操作来释放已经不需要的内存,从而降低开发人员在代码内存管理方面的门槛。

但是这并不意味着研发团队可以在代码中肆无忌惮地开辟托管堆内存,因为目前Unity所使用的Mono版本存在一个很严重的问题,即:Mono的堆内存一旦分配,就不会返还给系统。这意味着Mono的堆内存是只升不降的。举个例子,项目运行时,在场景A中开辟了60MB的托管堆内存,而到下一场景B时,只需要使用20MB的托管堆内存,那么Mono中将会存在40MB空闲的堆内存,且不会返还给系统。这是我们非常不愿意看到的现象,因为对于游戏(特别是移动游戏)来说,内存的占用可谓是寸土寸金的,让Mono毫无必要地锁住大量的内存,是一件非常浪费的事情。所以,我们在UWA测评报告中,为研发团队统计了测试过程中累积的函数堆内存分配量,大家只需要通过查看堆内存分配Top10的函数,即可快速对其底层代码实现进行查看,定位是否有分配不必要堆内存的代码存在。

 

 作者:张鑫 链接:https://zhuanlan.zhihu.com/p/21913770 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

读到这里,你可能会产生这样的疑问:我知道了哪些函数的堆内存分配大了,但是我该如何去进一步定位不必要的堆内存呢?

这是我们经常遇到的问题,所以在我们的深度项目优化服务中,我们都会直接进驻到项目团队,现场查看项目代码并对问题代码进行定位。在经过了大量的深度检测后,我们发现用户不必要的堆内存分配主要来自于以下几个方面:

  • 高频率地 New Class/Container/Array等。研发团队切记不要在Update、FixUpdate或较高调用频率的函数中开辟堆内存,这会对你的项目内存和性能均造成非常大的伤害。做个简单的计算,假设你的项目中某一函数每一帧只分配100B的堆内存,帧率是1秒30帧,那么1秒钟游戏的堆内存分配则是3KB,1分钟的堆内存分配就是180KB,10分钟后就已经分配了1.8MB。如果你有10个这样的函数,那么10分钟后,堆内存的分配就是18MB,这期间,它可能会造成Mono的堆内存峰值升高,同时又可能引起了多次GC的调用。在我们的测评项目中,一个函数在10分钟内分配上百MB的情况比比皆是,有时候甚至会分配上GB的堆内存。
  • Log输出。我们发现在大量的项目中,仍然存在大量Log输出的情况。建议研发团队对自身Log的输出进行严格的控制,仅保留关键Log,以避免不必要的堆内存分配。对此,我们在UWA测评报告中对Log的输出进行了详细的检测,不仅提供详细的性能开销,同时占用Log输出的调用路径。这样,研发团队可直接通过报告定位和控制Log的输出。

 

 UIPanel.LateUpdate。这是NGUI中CPU和堆内存开销最大的函数。它本身只是一个函数,但NGUI的大量使用使它逐渐成为了一个不可忽视规则。该函数的堆内存分配和自身CPU开销,其根源上是一致的,即是由UI网格的重建造成。因此,其对应的优化方法是直接查看CPU篇中的UI模块讲解。

作者:张鑫 链接:https://zhuanlan.zhihu.com/p/21913770 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

关于代码堆内存分配的注意点还有很多,比如String连接、部分引擎API(GetComponent)的使用等等,这些已经是老生常谈了,鉴于篇幅限制不在此处多作介绍,大家感兴趣可以Google自行搜索。后续也会有专门的代码效率专题讲解,敬请关注。

UWA测评的内存标准

在大家使用过UWA之后,对于UWA推荐的内存标准值提出了很大的疑惑。在这里,我们也分享下UWA内存标准的制定规则。

(1)150MB的总体内存标准主要由以下两个因素得出:

  • 经过了大量的项目优化后总结而得。其实,对于目前市场主流的Unity游戏来说,其内存占用主要集中在120~200MB。同时,顾及到iPhone4和512MB/768MB等低端Android机型,其应用的自身总体内存占用不可超过200MB(iPhone4的安全线应该在180MB左右),所以我们将Reserved Total设定在150MB,这是Unity引擎的自身内存分配,以保证App在使用到的系统库后,其OS中的整体内存也在200MB以下。
  • 某些渠道对Android游戏的PSS内存进行了严格的限制。一般要求游戏的PSS内存在200MB以下。这是我们将Reserved Total内存设定在150MB的另外一个重要原因。

(2)当总体内存设定为150MB后,我们进一步对其具体分配进行了设定。但需要说明的是,这里的内存分配其实并没有严格的公式来进行论证,仅是我们在大量的项目优化工作中提炼出的经验值。目前,项目较为合理的内存分配如下:

  • 纹理资源: 50 MB
  • 网格资源: 20 MB
  • 动画片段: 15 MB
  • 音频片段: 15 MB
  • Mono堆内存: 40 MB
  • 其他: 10 MB

需要指出的是,150MB中并没有涵盖较为复杂的字体文件(比如微软雅黑)以及Text Asset,这些需要根据游戏需求而定。

(3)目前的UWA内存标准是较为苛刻的,对于中高端设备而言,其内容允许量其实要比150MB要大得多。但我们坚持认为,在研发过程中,一个严苛的标准对于一个项目来说是一件好事。至少,它可以为大家提个醒,让大家时刻关注自己的问题。据我们了解,目前的三到五线城市,其低端手机的覆盖率还是相当高的。同时,对于中高端移动设备,我们仍在不断试验和研究中。我们希望在不久的将来可以做到针对各种不同档次的机型都给出一个更为合理的推荐值,从而让大家更为简单地对内存进行管理。

以上所说的是游戏项目中主要的内存分配情况,希望读到此处的你,可以更加了解Unity项目的内存开销和潜在问题,并对自己的项目进行更有针对性的检测。

 

有三个更为重要的地方需要研发团队关注:内存泄露、Mono无效堆内存开销和资源冗余。这几乎是所有团队在研发过程中都会遇到的问题。今天我们就来详细说一说这些问题的解决方案。

作者:张鑫 链接:https://zhuanlan.zhihu.com/p/21913837 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

内存泄露

内存泄露是开发人员在项目研发过程中最常见也最不愿遇到的问题。就目前来看,大家对于判断项目是否存在内存泄露仍然存在一些误区:

  • 误区一 我的项目进出场景前后内存回落不一致,比如进入场景后,内存增加40MB,出来后下降30MB,仍有10MB内存没有返回给系统,即说明内存存在泄露情况。
  • 误区二 我的项目在进出场景前后,Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落(出场景后的PSS值高于进场景前的PSS值),即说明内存存在泄露情况。

以上是我们遇到的开发团队反馈给我们的典型问题。相信大多数开发团队都会遇到类似的情况。在此有必要说明一下,以上两种情况均不能表明内存存在泄漏问题。即便内存在一段时间始终保持增长的趋势,也不能简单地判定其存在内存泄露。因为造成内存不能完全回落的情况有很多,比如资源加载后常驻内存以备后续使用、Mono堆内存的只升不降等等,这些均可造成内存无法完全回落。一般来说,我们推荐的判断内存是否泄漏的方法如下:

一、检查资源的使用情况,特别是纹理、网格等资源的使用

在我们进行过的项目深度优化过程中,资源泄漏是内存泄露的主要表现形式,其具体原因是用户对加载后的资源进行了储存(比如放到Container中),但在场景切换时并没有将其Remove或Clear,从而无论是引擎本身还是手动调用Resources.UnloadUnusedAssets等相关API均无法对其进行卸载,进而造成了资源泄露。对于这种情况的排查相当困难,这是因为项目中的资源量过于巨大,泄露资源往往很难定位。因此,我们在UWA测评报告中对项目中的每个资源都进行了详细的监控,并通过“生命周期”这一衡量指标让大家可以清楚地了解到每个资源在项目运行过程中的使用范围。

 

这样,大家可以通过资源的“生命周期”属性来快速查看有哪些资源是“常驻”内存的,并且判断该资源是“预加载”资源还是“泄露”资源。

同时,项目中所使用的总资源数量往往是成百上千的,让大家逐个资源检查过来是一件很费力的事情。所以,我们推出了资源的“场景比较”功能。建议大家通过以下两种方式进行资源比较,以便更快地找到存在“泄露”问题的资源

  • 同种类型场景或同一场景进行比较

一般来说,同种场景或同一场景的资源使用应该是较为固定的,比如游戏项目中的主城场景或主界面场景。通过比较不同时刻同一场景的资源信息,可以快速帮你找到其资源使用的差异情况。这样,你只需判断这些“差异”资源的存在是否合理,即可快速判定是否存在资源泄露,已经具体的泄露资源。

  • 不同类型场景进行比较

除一些常驻资源外,不同类型的场景,其资源使用是完全不同的。比如,游戏中主城和战斗副本的资源,除少部分常驻内存的资源外,二者使用的绝大部分资源应该是不一致的。所以,通过比较两种不同类型的场景,你可以直接查看比较结果中的“共同资源”,并判断其是否确实为预先设定好的常驻资源。如果不是,则它很可能是“泄露”资源,需要你进一步查看项目的资源管理是否存在漏洞。

 

二、通过Profiler来检测WebStream或SerializedFile的使用情况

AssetBundle的管理不当也会造成一定的内存泄露,即上一场景中使用的AssetBundle在场景切换时没有被卸载掉,而被带入到了下一场场景中。对于这种情况,建议直接通过Profiler Memory中的Take Sample来对其进行检测,通过直接查看WebStream或SerializedFile中的AssetBundle名称,即可判断是否存在“泄露”情况。

 

三、通过Android PSS/iOS Instrument反馈的App线程内存来查看

承接上述“误区二”中的说法,“Unity Profiler中内存回落正常,但Android的PSS数值并没有完全回落”是有可能的,这是因为Unity Profiler反馈的是引擎的真实分配的物理内存,而PSS中记录的则包括系统的部分缓存。一般情况下,Android或iOS并不会及时将所有App卸载数据进行清理,为了保证下次使用时的流畅性,OS会将部分数据放入到缓存,待自身内存不足时,OS Kernel会启动类似LowMemoryKiller的机制来查询缓存甚至杀死一些进程来释放内存。因此,并不能通过一两次的PSS内存没有完全回落来说明内存泄露问题。

我们推荐的测试方式是在两个场景之间来回不停切换,比如主城和战斗副本间。理论上来说,多次切换同样的场景,如果Profiler中显示的Unity内存回落正常,那么其PSS/Instrument的内存数值波动范围也是趋于稳定的,但如果出现了PSS/Instrument内存持续增长的情况,则需要大家注意了。这可能有两种可能:

  • Unity引擎自身的内存泄露问题。这种概率很小,之前仅在少数版本中出现过。

  • 第三方插件在使用时出现了内存泄露。这种概率较大,因为Profiler仅能对Unity自身的内存进行监控,而无法检测到第三方库的内存分配情况。因此,在出现上述内存问题时,建议大家先对自身使用的第三方库进行排查。

无效的Mono堆内存开销

目前,Unity所使用的Mono版本中存在一个较大的问题,即内存一旦分配,则不会再返回给系统。这就衍生出另外一个问题—— 无效的Mono堆内存。它是Mono所分配的堆内存,但却没有被真正利用上,因此称之为“无效”。那么,如何查看我的项目中是否存在较大量的“无效堆内存”呢

在UWA测评报告中,我们提供了内存随项目运行的分配情况,如下图所示。其中,蓝线和紫线的分离情况,反映了无效堆内存的分配大小。比如,图中所选中时刻,蓝线的Reserved Total为当前项目所占据的总物理内存,而紫线的Used Total为当前项目所使用的总物理内存,这说明当前项目中的空闲内存为57.1MB(200.4-143.3),而这其中主要由两部分组成,空闲的Unity引擎内存和无效的Mono堆内存。其中,空闲的Unity内存为17.1MB(92.0-74.9),所以当前所选帧的无效Mono堆内存为40.0MB。并且,从图中可以看出,蓝线和紫线在运行过程中一直分得较开,这说明一直存在不小的Mono堆内存处于“无效”状态。这是一件很浪费的事情,特别是对于内存寸土寸金的移动设备而言。

 

 

那么,我们应该如何避免或减少过多“无效堆内存”的分配呢?我们推荐的做法如下:

  • 避免一次性堆内存的过大分配。Mono的堆内存也是“按需”逐步进行分配的。但如果一次性开辟过大堆内存,比如New一个较大Container、加载一个过大配置文件等,则势必会造成Mono的堆内存直接冲高,所以研发团队对堆内存的分配需要时刻注意;

  • 避免不必要的堆内存开销。UWA测评报告中将项目运行过程中堆内存分配Top10函数进行罗列,限于篇幅,我们不再此处进行一一赘述,研发团队可以直接查看之前一篇的内存优化相关文章。

资源冗余

在内存管理方面,还有一个大家必须关注的话题——资源冗余。在我们测评过的大量项目中,95%以上的项目均存在不同程度的资源冗余情况。所谓“资源冗余”,是指在某一时刻内存中存在两份甚至多份同样的资源。导致这种情况的出现主要有两种原因:

一、AssetBundle打包机制出现问题

同一份资源被打入到多份AssetBundle文件中。举个例子,同一张纹理被不同的NPC所使用,同时每个NPC被制作成独立的AssetBundle文件,那么在没有针对纹理进行依赖打包的前提下,就会出现该张纹理出现在不同的NPC AssetBundle文件中。当这些AssetBundle先后被加载到内存后,内存中即会出现纹理资源冗余的情况。对此,我们建议研发团队在发现资源冗余问题后,对相关AssetBundle的制作流程一定要进行检查。

同时,我们在UWA测评中为每个资源引入了一个衡量指标——“数量峰值”。它指的是同一资源在同一帧中出现的最大数量。如果大于1,则说明该资源很可能存在 “冗余资源”。大家可以通过这一列进行排序,即可立即查看项目中的资源冗余情况。

 

 

二、资源的实例化所致

在Unity引擎中,当我们修改了一些特定GameObject的资源属性时,引擎会为该GameObject自动实例化一份资源供其使用,比如Material、Mesh等。以Material为例,我们在研发时经常会有这样的做法:在角色被攻击时,改变其Material中的属性来得到特定的受击效果。这种做法则会导致引擎为特定的GameObject重新实例化一个Material,后缀会加上(instance)字样。其本身没有特别大的问题,但是当有改变Material属性需求的GameObject越来越多时(比如ARPG、MMORPG、MOBA等游戏类型),其内存中的冗余数量则会大量增长。如下图所示,随着游戏的进行,实例化的Material资源会增加到333个。虽然Material的内存占用不大,但是过多的冗余资源却为Resources.UnloadUnusedAssets API的调用效率增加了相当大的压力。

 

一般情况下,资源属性的改变情况都是固定的,并非随机出现。比如,假设GameObject受到攻击时,其Material属性改变随攻击类型的不同而有三种不同的参数设置。那么,对于这种需求,我们建议你直接制作三种不同的Material,在Runtime情况下通过代码直接替换对应GameObject的Material,而非改变其Material的属性。这样,你会发现,成百上千的instance Material在内存中消失了,取而代之的,则是这三个不同的Material资源。其中的益处,对于能够阅读到这里的你来说,应该已经不需要我多说了。:)

以上则是我们在内存优化工作中的经验和心得,希望它对你的项目研发有所帮助。优化永远没有统一的标准方案,只有最适合你项目的方案,希望大家可以活学活用,不要放过任何一处让你感觉“不对劲”的地方。最后提醒大家——“勿以善小而不为,勿以恶小而为之”,共勉!

转载自:https://zhuanlan.zhihu.com/p/21913837 来源:知乎

你可能感兴趣的:(unity3d)