[原创]Unity面试经验分享

面试的这一个月时间,拿了几家大厂的offer(包含自己最心仪的公司),即将去新的工作岗位,所以总结一下这一个月的面经,希望对读到这篇文章的你,有些许帮助

letcode 肯定要刷一刷,这属于送分题。你不刷,到时候问你就只能挠头,刷完以后,你再临场发挥就有思路了。哪怕面试官问的题,你没刷到,你也不至于犯憷。最起码,如果是数组那么左右指针试试?

刷题也有技巧,不是照着 letcode 来一遍,力扣探索页 就有很多大厂高频算题,你可以把微软、腾讯、字节、还有初级中级的题都刷刷;30分钟以内想不出来,建议直接找答案(我是用 js 刷的,vscode + js 简直不要太爽)

下面就是一些我被问到,我觉得可以跟大家分享的问题,具体是哪个厂的面试官问题,就不方便说了(以下答案,是我自己的理解,如有错误还请告知,以免误人)

问:A*算法了解吗?大概说说

A*是比较经典的寻路算法,它的重点就是启发函数:总代价 = 当前点到起点的代价+ 当前点到终点的代价(代价可以是距离或者权重等)。算法的核心是两个列表,开启列表和关闭列表,未走过的节点放入开启列表,已走过的节点放入关闭列表。每个节点的核心元素:当前点的总代价,当前点的父节点

问:C# 字典的具体实现讲讲?跟哈希表有什么区别?

字典的实现,主要是两个数组;桶数组以及对象数组。对象是个结构体(hashcode,同哈希值的下一个对象的下标索引,key,value);如果 new 字典的时候传入了字典大小,那么会初始化,否则要等到添加对象的时候初始化

ADD:有个 freeCount和freeList来标记,当前对象数组,是否有空位置。如果有,优先放入空位置。然后判断哈希值,如果对应的桶内已经有值,那么头插法;桶的索引值变成当前新添加对象所在对象数组的索引,而它的next就是桶原来的索引值

REMOVE:最巧妙的地方,删除以后,该对象的next值等于freeList,然后新的freeList等于这个被删的对象的索引,这样的话,根据freeList就能找到所有被删以后的空位置

扩展:注意,字典没有容量因子的说法,一定要对象数组满了才会扩容。而且它扩容是当前长度*2,然后选中大于这个值的素数,之所以用素数,是减少哈希冲突。当哈希冲突次数超过100以后,才会执行重哈希

问:谈谈 Unity 的 GC

标记阶段:收集器从根对象开始进行遍历,对从根对象 ( 正在运行的局部变量、静态变量、重写 Finalize 方法的变量、正在调用的函数传递的参数 ) 可以访问到的对象的同步索引块标标记为可达

清除阶段:收集器会对堆内存从头到尾进行线性的遍历,如果发现某个对象没有标记则将其回收(回收之前,会先执行它的 Finalize() 方法,如果重写了话)

缺点:容易造成内存碎片化

2019.3增加了增量GC优点:避免出现GC时CPU峰值,导致掉帧或者卡顿 ; 缺点:1. 因为是分帧执行遍历,所以得采用写屏障(可能导致bug),2. GC总时常变长 

CG触发:1.向托管堆申请连续内存块,但是托管堆中的连续内存块大小不足2.GC 自动触发3.手动调用GC

CG优化: Cache变量,使用对象池,闭包,匿名函数和协程会有托管堆内存分配,配置表拆分,字符串,手动调用GC

问: Editor 和真机环境下,profiler 看内存是否有差异?为什么?

有差异,一般有以下原因:

  • 一些引擎API在Editor和真机环境下,实现原理不一致。比如加载ab.LoadFromFile, Editor下会加载整个AB,而真机只加载AB头部;GetComponent在Editor会造成GC Alloc, 真机不会
  • 我们自己写的或者第三方插件代码实现不一致
  • 编辑器可能缓存了某些资源(比如你选中某个纹理,该纹理会一直在内存中)

问:谈谈资源卸载?

非托管资源,手动卸载;托管资源,清空引用

  • 1. 文件,网络套接字,数据库连接等采用Dispose + Finalize() 释放 ;
  • 2. 资源(纹理,网格, ab中LoadAsset() 等) 用Resources.UnloadAsset(obj) 来卸载
  • 3. AssetBundle内存 (非托管内存 ),如果有指向该资源的变量( 假设名为:ab),则可使用  ab.Unload ( false/true ) 来卸载
  • 4. 最后调用Resources.UnloadUnusedAssets()来释放(此方法非常耗时)

问为什么说 MOBA 要用帧同步? 帧同步用什么网络协议?以及为什么?

moba类游戏小兵多玩家少且固定;对实时性、流畅性 、公平性要求较高。用帧同步的优势是回放制作方便,流量消耗少,制作离线战斗方便 ( 新手战斗 ),而难度则如下:

  • 1. 保证一致性
  • 2. 开发难度比较高,经常会有以主角为主类的想法。也有可能会直接拿玩家的位置等属性作为参数等的情况 ( 一定要注意逻辑代码和表现分离 )
  • 3. 流畅性(性能,网络)

一般来说,都会采用UDP协议,因为帧同步方案,会有高频次的上报和下发逻辑帧信息,所以对网络要求很高 而它的优点协议简单,包头小。缺点是无序,不可靠,所以需要额外信息来保证传输数据的可靠性。因此,我们需要在传输的数据上增加一个包头。用于确保数据的可靠、有序主要使用两种策略来决定是否需要重传数据包 : 超时重传、快速重传

超时重传:发送数据包在一定的时间内没有收到相应的ACK,等待一定的时间,就会重新发送。这个等待时间被称为RTO,即重传超时时间

快速重传:明确丢了哪个包,那么不用等超时,直接快速重传

问:帧同步有遇到过不同步的问题吗?怎么解决?

原则就是:就是保证客户端的输入一致,计算一致

  1. 主要的点就是随机数(服务器发随机种子)和浮点数运算(采用定点数)
  2. 战斗逻辑不要使用一些排序不固定的容器,比如:Dictionary
  3. 逻辑层不要使用协程(协程是根据Unity的Update去驱动,而不是我们自己的帧逻辑)
  4. 第三方插件的某些实现导致不同步(比如:Update不受帧控制,导致某些计算两个客户端不是在同一个逻辑帧,就会导致异常)
  5. 逻辑层和表现层一定不能交叉调用,表现层根据Update去轮询逻辑层的帧

问:说说深度缓冲(z-buffer)与w缓冲(w-buffer)

z-buffer 保存的是经过投影变换后的 z 坐标,投影后物体会产生近大远小的效果,所以距离眼睛比较近的地方,z 坐标的分辨率比较大,而远处的分辨率则比较小。所以投影后的 z 坐标在其值域上,对于离开眼睛的物理距离变化来说,不是线性变化的(即非均匀分布),这样的一个好处是近处的物体得到了较高的深度分辨率,但是远处物体的深度判断可能会出错

w-buffer 保存的是经过投影变换后的 w 坐标,而 w 坐标通常跟世界坐标系中的 z 坐标成正比,所以变换到投影空间中之后,其值依然是线性分布的,这样无论远处还是近处的物体,都有相同的深度分辨率,这是它的优点,当然,缺点就是不能用较高的深度分辨率来表现近处的物体

问:什么是法线贴图,缺点是什么?为什么法线贴图一般都是偏蓝色?

法线贴图是简单的视觉欺骗,一旦凹凸太明显的模型,使用了法线贴图,太靠近的时候,就穿帮了。法线贴图主要适用于凹凸不太明显,细节很多,需要表现实时光照效果,不会太靠近观察的物体的情况

生成法线贴图,一般都是采取纹理的灰度图。根据两个像素间的灰度差,形成U,V两个向量,然后两个向量的叉积就是法线的方向。在切线坐标系里,定义顺序是Tangent、Binormal、Normal。也就是说,Normal处于z这个方向。而对于一个三角形而言,绝大多数时候,法线值都是垂直于这个面的。显而易见,法线贴图的法线值大多数时候是接近于(0,0,1)的,当然是接近于蓝色了

问:为什么 png,jpg 不能直接用,而要采用etc,astc格式?

jpg , png 是针对硬盘的压缩格式,使用的时候需要CPU解压。最大的问题他们都是基于整张图片的压缩,像素与像素之间在解码过程中存在依赖关系,无法实现单个像素级别的解析;而且,png,jpg 解码以后都是 RGBA 的纹理格式,无法减少显存的占用率。而 etc 和 astc 这种是针对图形接口而设计的压缩格式,不需要 CPU 解压而 GPU 可以直接采样

问:AOP , IOC 懂吗?简单说说

 AOP:与传统OOP对比,面向切面,传统的OOP开发中代码逻辑是自上而下的,在自上而下的过程中会产生一些横切性的问题,这些横切性的问题和我们主业务逻辑关系不大,会散落在代码的各个地方,造成难以维护,AOP的思想就是把业务逻辑和横切的问题进行分离,从而达到解耦的目的,是代码的重用性和开发效率提高

IOC :主要是针对接口,抽象编程,而不是具体实现。要做到绝对的依赖注入,就要配置文件,使用反射,针对接口,外部dll实现功能(这一块,可以去B站找找相关的视频,那个老师废话很多,但是得耐心看下去)

问:UGUI  优化

Canvas负责将其子节点的UI元素的网格合并,并生成相应的渲染命令发送到Unity的图形管道,当UI发生了变化,它就要执行一次Batch给GPU进行渲染。Canvas只影响其子节点,但是不会影响子Canvas

重建:

  1. 重新计算一个Layout组件子节点的适当位置,或者可能的大小
  2. Graphic发生了变化,比如:大小,旋转,文字的变化,图片的修改等,都会引起Rebuild

优化:

  1. 动静分离
  2. 少对Canvas增加、删除、显示、隐藏;少对Canvas进行颜色,材质,纹理等的改变
  3. 预加载UI模块
  4. 不直接关闭,设置UI界面为其他的layer
  5. 不需要进行事件接收的组件,取消勾选Raycaster Target
  6. 不适用富文本的Text,取消勾选Rich Text,不使用Best Fit
  7. 用TextMashPro 代替OutLine,shadow组件

问:讲讲UGUI的Draw Call

UGUI 主要是根据 depth 来判断 Draw Call 数量(还有纹理,材质,shader等)

  1. 按照 Hierarchy 节点顺序,从上往下进行深度分析(深度优先)
  2. 且没有任何其他渲染元素与它相交深度 = 0
  3. 有其他元素跟它相交,则找到相交渲染元素的最大深度的渲染元素,判断是否能够与它合批,如果可以,则它等于最大深度,否则深度= 最大深度+ 1
  4. 按照 Hierarchy 顺序来的对深度进行排序,然后根据材质的 Instance ID,纹理的 Instance ID 排序

优化点:

  1. 一个Canvas组建下的元素才会合批,不同Canvas不会合批
  2. 有时候会为了合并层级,我们需要给Text垫高层级
  3. 一个节点的RectTransform的值不会影响合批,Image和rawImage如果引用了相同的texture,可以合批

问: Lua GC 的垃圾回收机制?

lua使用的是经典的标记清扫算法;Lua所有类型的对象都统一为Tvalue;所有动态分配的对象串连成一个链表(或多个);Lua里的注册表,主线程等,这些根集对象再去引用其他对象,由此展开成对象的关系结构

Lua的垃圾回收周期共分为四个阶段:标记、整理、清扫、收尾
标记阶段:Lua会首先将根集合中的对象标记为活跃,然后将可以通过根节点访问到的对象也标记为活跃
整理阶段:Lua会遍历所有的userdata,找出未被标记且有__gc元方法的userdata,将它们标记为活跃,并放入单独的列表中。再根据所有的弱引用table,删除那些未被标记为活跃的key或者value
清扫阶段:Lua遍历所有对象,如果当前对象未被标记,就收集它,否则清除它的标记
收尾阶段:根据上面生成的userdata列表来调用终结函数(类似C#的析构函数)

问:Lua 如何实现协程?

线程:抢占式多任务机制,是一个相对独立的、可调度的执行单元,是系统独立调度和分配CPU的基本单位。它由操作系统来决定执行哪个任务,在运行过程中需要调度,休眠挂起,上下文切换等系统开销,而且最关键还要使用同步机制保证多线程的运行结果正确

协程:协作式多任务机制,协程之间通过函数调用来完成一个既定的任务。它由程序自己决定执行哪个任务,只涉及到控制权的交换(通过resume-yield),同一时刻只有一个协程在运行,而且无法外部停止。通俗来说,协程就是可以用同步的方式,写出异步的代码

协程(Coroutine)拥有4种状态:

  • 运行(running)如果在协程的函数中调用status,传入协程自身的句柄,那么执行到这里的时候才会返回运行状态
  • 挂起(suspended)调用了yeild或还没开始运行,那么就是挂起状态
  • 正常(normal)如果协程A重启协程B时,协程A处于的状态为正常状态
  • 停止(dead)如果一个协程发生错误结束,或正常终止。那么就处于死亡状态(不可以再重启)

Lua的协程是一种非对称式协程,又或叫半协程,因为它提供了两种传递程序控制权的操作:1. 重启调用协程,通过coroutine.resume实现;2. 挂起协程并将程序控制权返回给协程的调用者,即通过coroutine.yield实现。对称式协程,只有一种传递程序控制权的操作,即将控制权直接传递给指定的协程

协程(Coroutine)具有两个非常重要的特性:1. 私有数据在协程间断式运行期间一直有效;2. 协程每次yield后让出控制权,下次被resume后从停止点开始继续执行

问:lua中的闭包懂吗?说说看

闭包主要由2个元素组成;1. 函数原型:一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令 2.上下文环境:在Lua里主要是Upvalues和env

  1. Upvalues是在函数闭包生成的时候(运行到function时)绑定的
  2. Upvalues在闭包还没关闭前(即函数返回前),是对栈的引用,这样做的目的是可以在函数里修改对应的值从而修改Upvalues的值
  3. 闭包关闭后(即函数退出后),Upvalues不再是指针,而是值

问:类(class)和结构(struct)的区别是什么?它们对性能有影响吗?在自定义类型时,您如何选择是类还是结构?

Class是引用类型,Struct是值类型。Struct不可以被继承,但是可以Override它基类的方法;也可以实现接口。比如:如果想用Struct当作字典的key,但是又想避免有装箱和拆箱等操作,则可以实现接口IEquatable的Equals方法,则可以避免装箱和拆箱操作,以下是一些详细的区别

  1. 值类型对象未装箱和已装箱两种形式,而引用类型总是处于已装箱形式
  2. 值类型从System.ValueType派生,重写了Equals和GetHashCode,由于默认的实现存在性能问题,所以在自定义值类型的时候,如果有用到,应重写这两个方法
  3. 自定义的值类型不应该有虚方法
  4. 引用类型的变量的值是堆上的一个对象的地址,值类型包含的是值
  5. 引用类型赋值是复制内存地址,而值类型赋值则是逐一字段复制
  6. 未装箱的值类型,不在堆上分配内存。而引用类型在堆上分配内存
  7. 因为未装箱的值类型,没有同步索引块,所以不支持多线程同步
  8. 虽然未装箱的值类型,没有类型对象指针,但是仍可以调用由类型继承或重写的虚方法(比如:Equals,GetHashCode或ToString),但是请注意:调用这些方法可能会产生装箱。具体如下:
  9. 如果结构体重写了虚方法,并且虚方法内没有内部调用父类的方法,那么结构体实例调用这个重写的虚方法就不会装箱
  10. 如果调用父类的非虚方法,那么一定会产生装箱,一定得需要一个类型对象指针,以定位父类类型的方法表才能执行该方法

以下情况适合值类型:

  1. 类型具有基元类型的行为,意思就是结构十分简单,属性没有可变类型
  2. 类型不会派生子类,也不从其他类继承
  3. 类型的实例较小 ( 因为值作为参数传递或者作为方法返回值的时候,会拷贝整个结构,对性能有损害,所以类型的实例大小是个重要考虑指标 )

关于值类型的装箱问题,发生装箱操作时,在内部发生的事情如下:

  1. 在托管堆中分配好内存,分配的内存量是值类型各个字段需要的内存量加上两个额外(类型对象指针和同步索引块)需要的内存量
  2. 将值类型的字段复制到新分配的内存中
  3. 返回对象的内存地址,现在值对象变成了引用类型

问:泛型的作用是什么?它有什么优势?它对性能有影响吗?

面向对象的好处是代码重用,对一个类来说,可以继承基类的所有能力,同时可以重写虚方法,或者添加一些新方法,就可以定制该类的行为。而泛型,则是支持另外一种形式的代码重用,即”算法重用”

泛型具有如下优势:1. 源代码保护  2. 类型安全(在编译期就能过滤不符合的类型数据)3. 更佳的性能 ( CLR不再需要执行装箱操作;也不需要类型转换,这对于提高代码运行速度很有帮助 )

问:抽象类和接口有什么区别?使用时有什么需要注意的吗?如何选择是定义一个抽象类,还是接口?什么是接口的“显式实现”?

接口:描述的是Can-do,是一类行为,而不是针对具体某个类。比如: IDispose,IEnumerable等都是描述一类行为

抽象类:为了继承而存在的,如果你定义了一个抽象类,却不去继承它,那就没有任何意义。抽象类不可用来实例化

拿个具体的事例,比如说动物Animal ,这是一个高度抽象的概念。说到动物,有些人想到的是狗,有些人想到的是猫。所以Animal这个类没办法具体实例化出来,应该设计为抽象类,不继承它就没有意义。那有些动物会飞,有些动物会游,这表示有些动物具有某些行为,应该设计成接口,比如:Ifly, Iswim等

在考虑是使用抽象类还是接口的时候,还要考虑易用性和后期版本控制。因为如果要修改接口(比如,添加新的方法),那么实现了该接口的所有类都必须实现这个新方法。所以改动很大 接口的显示实现,是指继承的接口的方法 与类本身的方法重名而采取的措施,就是将定义方法的那个接口的名称作为方法名的前缀(例如:IDispose.Dispose)。注意,在C#中定义一个显示接口方法时,不可以指定可访问性(比如:public或privtae),也不能标记为virtual,所以它不能被重写。这是因为显示实现的方法并非是该类类型的对象模型的一部分,它是将一个接口连接到一个类型上

问:谈谈 Unity 中的协程

协程其实是分帧执行的类似语法糖。协程方法,在编译以后,会生成一个对应名字的类,继承了IEnumerator<object>, IEnumerator, Idisposable 这三个接口。最主要实现了 MoveNext() 方法,Current 属性。而实现原理则是在MonoBehaviour 内的Update和LateUpdate方法之间,根据Current的值是否调用该类的MoveNext()方法

当MonoBehaviour被摧毁以后,协程也会被终止。但是enabled =false,协程会继续执行

协程的使用规范:需要的时候使用,用完就销毁。不要用对象池保存,因为协程会引用MonoBehaviour类(包括变量,方法等),所以如果不销毁,这些引用会一直存在,从而导致无法被GC。有点类似闭包,以及委托

问:说说关于 Unity 内存,显存,CPU 的优化

这一部分太庞大了,如有有需要可以邮件我,我会把整理过的文档发给你们。邮箱地址:lingzelove2008@163.com

[译文]Unity性能优化—图形渲染最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

本文我们将学习Unity在渲染每一帧幕后所发生的事情,以及有哪些问题会导致渲染卡顿,以及怎么解决这些问题

阅读本文之前,首先要明白对于提升渲染性能,没有一招鲜的办法。影响渲染性能的因素太多:包括游戏类型、设备硬件、操作系统等。重要的是我们通过观察,实践得到数据然后分析数据从而有针对性的解决问题。本文包含一些常见的渲染性能问题和解决办法以及一些扩展链接供你更深层次的了解其原理;当然,很有可能你们游戏中出现的性能问题,这篇文章并没有指出;不过,这篇文章你还是值得看看的,理解事物的本质对解决问题很有帮助

渲染简介

开始之前我们快速的过一遍,看看Unity渲染每一帧时发生了什么,理解整个流程以及每个过程发生的时机会对我们解决性能问题有帮助。渲染流程如下:

  • CPU(central processing unit)计算哪些物体需要渲染以及为这些物体设置渲染状态
  • CPU 发送图形渲染指令给 GPU(graphics processing unit)
  • GPU 渲染物体

“渲染管线(rendering pipeline)”通常用来描述渲染的过程;这十分贴切,高效的渲染就像车间的流水线,无停顿,高效率。渲染每一帧的过程中,CPU都会做如下工作:

  • 检测场景中的每一个物体是否需要渲染;一个物体被渲染需要符合一定的条件。比如:物体必须处于摄像机的可视范围内(注意:哪怕只有一部分处于可视范围,仍然需要渲染整个物体),不在范围内的则剔除,如果想了解更多关于物体剔除方面的内容,请查阅:Understanding the View Frustum
  • CPU收集和计算将要被渲染的物体的相关信息并发送命令给GPU(包括将网格和纹理等传送到显存,以及设置渲染状态,调用图形API),这个过程称之为:Draw Calls
  • CPU 给每一个 Draw Call 创建的数据包,称之为:Batch(批次),批次有时候会包含一些 Draw Call 以外的数据,但这些数据对性能没有什么影响,因此本文将不会讨论这些数据

每一个批次至少包含一个 Draw Call,CPU 针对批次会做如下事情:

  • CPU会发出更改指令,让 GPU 更改渲染状态,这个指令称之为:SetPass Call,SetPass Call 告诉 GPU 使用哪些设置去渲染网格。SetPass Call指令只有在渲染下一个网格的设置和渲染上一个网格的设置不一样时,才会发出
  • CPU通过 Draw Call 命令告诉 GPU,就按照上一次 SetPass Call 的渲染设置去渲染指定的网格
  • 有些情况下,一个批次可能需要不止一个pass,pass是一段shader代码,并且新的 pass 需要更改渲染状态,对于批次中的每个 pass ,CPU必须发送新的 SetPass Call 指令,然后再次发送 Draw Call 通知 GPU 按照新设置的渲染状态去渲染网格

同时,GPU也会做如下事情:

  • GPU 按照 CPU 发送到 Command buffer 内的指令顺序处理
  • 如果当前指令是 SetPass Call,那么GPU更新渲染状态
  • 如果当前指令是 Draw Call,那么GPU根据上一次设置的渲染状态来渲染网格。渲染网格的过程有很多阶段,这里就不一一阐述,不过你可以了解一下:顶点着色器(Vertex Shader)是用来处理网格顶点的,而片元着色器(Fagment Shader)是用来处理每一个像素的
  • 这个过程会重复执行,直到Command buffer内的指令都被执行完毕

大概了解了Unity渲染的简要流程,让我们来考虑渲染的过程中可能会出现的问题

渲染问题

关于渲染,最重要的一点是:每一帧之内,CPU和GPU都要按时完成自己的任务。他们中任何一个任务超时的话,那就会造成渲染问题。渲染问题一般有两个基本原因:1. CPU 约束,渲染过程中,CPU 为每一帧渲染准备数据花费的时间太长,导致渲染瓶颈 2. GPU 约束,渲染数量过于膨大,导致 GPU 渲染一帧需要花费的时间过长

有果必有因,在性能出问题的时候,找出导致性能问题的原因才是首要任务。针对不同的问题,我们才能给出不同的解决方案。修复性能问题,其实也是一项平衡性的工作,比如:牺牲内存用于提高 CPU 性能,牺牲游戏画质解决渲染瓶颈。我们会使用Unity自带的两个工具来定位问题:ProfilerFrame Debugger

CPU瓶颈

基本上,渲染每一帧的过程中,CPU 就干三件事儿:1. 决定渲染哪些物体 2. 为渲染准备好数据以及设置渲染状态 3.发送图形渲染API 。 这三类工作包含很多独立任务,这些任务可能是通过多线程完成的;当这些任务被分配到不同的线程执行时,我们称之为:多线程渲染

Unity的渲染过程中,有三类线程参与:主线程(main thread)、渲染线程(render thread)、辅助线程(worker threads)。主线程用于我们游戏中主要的 CPU 任务(也包括一些渲染任务),渲染线程主要是用来给 GPU 发送指令的,而辅助线程则用来处理单独的任务,比如:物体剔除和网格蒙皮计算。哪些任务执行在哪个线程,取决于我们游戏运行设备的硬件以及游戏的设置。比如:设备的CPU核心数量越多,我们就会线程越多的辅助线程。正是由于这个原因,在目标设备上进行性能分析十分有必要,在不同的设备上,我们的游戏表现可能有天壤之别

由于多线程渲染非常复杂而且非常依赖硬件条件,所以在尝试提升性能的时候,我们要理解是那些任务导致的CPU瓶颈。如果游戏卡顿是因为剔除物体是否在摄像机是窗内的线程超时,那么我们减少 Draw Call 对提升游戏体验,并没有什么卵用

注意:不是所有平台都支持多线程渲染,WebGL就不支持。在不支持多线程的平台,所有的任务都是在同一个线程中执行。如果在这种平台碰到CPU瓶颈问题,那么就试着去优化所有可能对CPU性能有帮助的点

Graphics Jobs

Project Settings ->Player->Other Setting->Rendering-> Graphics jobs 选项可以让Unity将那些本该由主线程处理的渲染任务分配到辅助线程中。在可以使用该功能的平台上,它将带来显著的性能提升(可以开启和关闭这个功能来做性能对比,该功能目前仍是预览版)

发送指令到GPU

发送命令到GPU这个过程,一般是CPU瓶颈的最常见原因。大多数平台这个任务是由渲染线程完成的,个别平台是在辅助线程(比如:PS4)

而最耗时的操作是 SetPass Call 指令,如果C PU 性能问题是因为发送指令到 GPU 引起的,那么减少 SetPass Calls 的数量通常是最有效的改善性能的办法。我们可以通过 Profiler 查看到 SetPass Call 和 批次的数量。多少 SetPass Call 会造成性能问题,这个和游戏运行设备的硬件有很大关系,在高端PC上可以发送的 SetPass Call 数量远大于移动设备

SetPass Call 数量以及对应批次数量取决很多因素,稍后会详细介绍。然而一般来说:

  • 减少批次数量 或者 让更多物体共用相同的渲染状态,通常会减少 SetPass Call 数量
  • 减少 SetPass Call 数量,通常能提升 CPU 性能

减少批次能提升 CPU 性能,即便减少批次并没有减少 SetPass Call 的数量。因为CPU 能够更有效的处理单个批次。一般来说有以下方式,能减少批次和 SetPass Call 的数量:

  • 减少需要渲染的物体数量,能减少批次和SetPass Call 的数量
  • 减少每个物体被渲染的次数,能减少 SetPass Call 的数量
  • 合并需要渲染的物体,可以减少渲染批次

减少需要渲染的物体数量

这是减少批次和SetPass Call最简单的方法了,有以下方法可以降低渲染物体的数量:

  • 直接减少场景中的可见物体数量。比如:场景中有很多人物需要渲染,我们可以选择性的只渲染一部分人
  • 使用摄像机的 Far Clipping Planes 属性来降低摄像机的绘制范围。这个属性表示距离摄像机多远的物体不再被渲染
  • 通过 Occlusion culling 技术来关闭被其他物体完全遮挡的物体,这样就不用渲染被遮挡的物体了。请注意:这个功能不适用于所有场景,但是在某些场景它确实带来很可观的性能提升。另外,我们可以通过手动关闭物体来实现自己的遮挡剔除。比如:如果我们场景中包含一些过场切换才会出现的物体,那么在过场播放之前或者结束以后,就应该手动隐藏他们以减少需要渲染的物体。手动剔除往往比Unity 提供的动态剔除更高效

减少每个物体渲染的次数

实时光,阴影,反射等效果可以极大的提高游戏的真实感;但其实这些效果都非常消耗性能,因为这些效果会导致物体被渲染多次

我们对游戏的渲染路径的设置,对这些功能的性能消耗也有实质性的影响。渲染路径:表示绘制场景的时候,渲染计算的执行顺序;不同的渲染路径最主要的区别是他们怎么处理实时光,阴影和反射。通常来说,如果我们的游戏运行在比较高端的设备上,并且运用了实时光,阴影和反射,那么延迟渲染(Deferred Rendering)是比较好的选择。向前渲染(Forward Rendering)适用于不使用以上功能的低端设备。实时光,阴影和反射是个比较复杂的功能,最好能研究相关主题充分了解实现原理,可参考:灯光和渲染-2019.3

不管选择哪种渲染路径,实时光,阴影,反射都会极大的影响性能,所以优化他们十分有必要:

  • 动态光照是个非常复杂的主题,它超出了本文的讨论范畴,你可以参考这篇文章去深入了解:Shadow troubleshooting
  • 动态光照非常消耗性能,当我们场景中包含很多静态物体时,我们可以利用烘焙技术去预先计算场景的光照,生成光照贴图,详细可以点击:Lighting
  • 如果我们想使用实时阴影,你可以通过这篇文章来提交性能:Shadow Cascades。文章介绍了阴影的设置,以及这些设置怎么影响性能
  • 反射探头创建真实的反射,但是会影响合批。因此我们最好最小化的使用它,反射探头的优化请参考:Reflection Probe performance

合批物体

一个批次可以包含多个物体的数据,为了能合并批次,物体必须满足如下条件:

  • 共享相同的材质
  • 一样的渲染状态(比如:纹理,shader等)

合并物体确实可以提高性能;同时我们也要分析,合并物体带来的性能提高会不会反而造成其他方面更大的性能消耗。关于合批可以参考我上一篇文章:静态批处理、动态批处理、GPU Instancing

纹理图集(Texture Atlasing),是把大量的小尺寸的纹理合并成一张大的纹理。它通常在2D游戏以及UI系统中使用,Unity内置了图集工具:Sprite Packer

我们也可以手动合并共享材质和纹理的网格(可通过编辑器直接操作,也可以在运行时调用API操作),但是我们必须意识到,有可能我们手动合并以后,本来会被剔除而无需渲染的物体,因为共享相同的网格而必须渲染,而这些物体可能还有光照,阴影等更加消耗性能的操作,所以要权衡利弊

在脚本中,我们必须小心使用Renderer.material,这个接口会拷贝材质,并返回拷贝后的引用。这样做也会破坏合批,因为它和其他物体不再有相同的材质引用了。如果我们一定要访问合批物体的材质,应该使用Renderer.shareMaterial

蒙皮网格

SkinnedMeshRenderers通常用在网格动画中,渲染蒙皮的任务通常在主线程或者单独的辅助线程(取决于游戏的设置以及目标设备硬件)。渲染蒙皮是个很消耗性能的动作,如果在Profiler中看到是渲染蒙皮对CPU造成性能瓶颈,这里有几个方法我们可以改进性能

  • 考虑是否真的有必要使用蒙皮网格渲染组件,如果物体并不需要运动,尽可能使用普通的网格渲染组件(MeshRenderer)
  • 如果我们只在某些时刻运动物体,我们应该用细节较少的网格,(SkinnedMeshRenderers组件有一个函数BakeMesh,可以用匹配的动作创建一个网格)
  • 关于蒙皮网格的优化,可以参考:Skinned Mesh Renderer。(蒙皮网格消耗是在每个顶点上,因此,使用顶点较少的模型以及减少骨骼数量也可以提高性能)
  • 在某些平台,蒙皮可以被GPU快速处理。如果目标设备GPU比较强,则可以对当前平台开启 GPU Skinning

GPU瓶颈

如果游戏是GPU性能限制,那么首先就得找到GPU瓶颈原因。一般GPU性能最常见的问题是填充率限制,尤其是移动平台。当然显存带宽和顶点处理也可能影响

填充率

填充率是指 GPU 在屏幕上每秒可以渲染的像素数量;如果我们游戏是因为填充率导致的GPU性能问题,那么意味着我们游戏每帧尝试绘制的像素数量超过了 GPU 的处理能力,检查是否填充率引起的GPU性能问题其实很简单:

  • 打开Profiler,注意GPU时间
  • 重新设置渲染分辨率 Screen.SetResolution(width,height,true)
  • 重新打开Profiler,如果GPU性能提升,那么大概率就是填充率的原因了

如果是填充率问题,那么我们有如下几个方法解决这个问题

  • 片元着色器是告诉 GPU 怎么去绘制每一个像素的shader代码,如果这段代码效率低,那么就容易发生性能问题,复杂的片元着色器是很常见的引起填充率问题的原因
  • 如果我们游戏使用了Unity内置的Shader,那么应该使用针对移动平台的 the mobile shaders
  • 如果游戏使用的是Unity的Standard Shader,那么要理解 Unity 编译这些shader是基于当前材质设置,只有那些当前使用的功能才会被编译。这意味着,移除 detail maps可以减少片元着色器的复杂度
  • 如果使用的是定制的shader,那么应该尽量优化它。优化shader是个很大的话题,可以参考:Optimizations
  • Overdraw 是指相同位置的像素被绘制了多次。一般发生在某个物体在其他物体之上。为了理解 Overdraw,我们必须理解 Unity 在场景中绘制物体的顺序。物体的 shader 决定了物体的绘制顺序,通常由 render queue 属性决定。最常见引起 Overdraw 的因素是透明材质,未优化的粒子以及重叠的UI元素。关于Overdraw优化,可以参考:Optimizing Unity UI

显存带宽(Memory bandwidth)

显存带宽是指GPU读写专用内存的速度,如果我们的游戏受限于显存带宽,通常意味着我们使用的纹理太大了,我们可以用如下方法检测是否是显存带宽问题:

  • 打开Profiler,并关注GPU各项数据
  • Project Settings->Quality->Texture Quality,设置纹理质量,降低当前平台的纹理质量
  • 重新打开Profiler,重新查看GPU各项数据。如果性能改善,那么就是显存带宽问题

如果是显存带宽问题,那么我们需要降低纹理的内存占用,针对不同游戏通常有不同的解决方案,这里我们提供几个优化纹理的方法

  • 纹理压缩技术可以同时极大的降低纹理在内存中的占用。Texture Import Setting讲述了纹理压缩格式和各种设置的详细信息
  • Mipmaps,多级渐远纹理是 Unity 对远处物体使用低分辨率纹理。Unity场景视图中的 The Mipmaps Draw Mode 允许我们查看哪些物体适用多级渐远纹理。其实,Mipmap最主要的目的是为了提高质量,如果没有mipmap 纹理很大,采样频率却很小的情况下,模型看去来质量会很差(相邻的两个屏幕像素采样的纹素差的很远,此时会大大降低缓存命中率)

顶点处理

顶点处理是指 GPU 处理网格中的每一个顶点,顶点处理的消耗主要受两个因素的影响:顶点数量以及操作每个顶点的复杂度。有一些方法可以优化这个:

  • 降低网格复杂度
  • 使用法线贴图模拟更高几何复杂度的网格,关于法线贴图可以参考:Normal map
  • 如果游戏未使用法线贴图,在网格导入的设置中,可以关闭顶点的切线,这可以降低每个顶点的数据量
  • LOD,当物体远离摄像机的时候,降低物体网格的复杂度的技术,可以有效的降低 GPU 需要渲染的顶点数量,并且不会影响视觉表现,具体细节请参考:LODGroup
  • 顶点着色器,是一段shder代码,降低它的复杂度,可以提升性能

[原创]静态批处理、动态批处理、GPU Instancing

这几个东西的原理是什么? 问过几个经验丰富的大佬,不知是不是这些问题太低端,他们要么不屑于回答,要么直接给个链接。反正就是别问,问就是只可意会,不可言传

没办法,只能去茫茫互联网寻觅答案;当疑惑被解开的时候,那感觉就像星期五下午,明知这个礼拜没得休息,而且晚上要加班。老板突然在群里说一句:最近大家幸苦了,项目进度不错,这周双休,今晚不加班!我特么,我好想反手给老板一………张过去的CD,纪念那时我们的爱情

要回答这个问题,首先得了解何为渲染流水线?渲染流水线的工作任务就是由一个三维场景出发,生成(或者说渲染)一张二维图像,而这个工作通常是由CPU和GPU共同完成的,本文主要讨论的就是渲染流水线中关于CPU的部分。在渲染物体之前,CPU需要为渲染这个物体做准备工作,主要包括如下三个步骤:

Data Transfer (把数据加载到显存中)

在程序开始运行之前所有渲染所需的数据都是保存在磁盘(HDD)上的。程序开始运行,为了CPU能够快速访问数据,需要先把数据载入到内存(RAM)。GPU和CPU一样,也有自己的可以快速访问的类内存结构,叫做显存(VRAM)。当需要渲染该物体时,网格和纹理等数据又被加载到显存中

Set Render States (设置渲染状态)

渲染状态(Render State),通俗的解释就是:这些状态定义了场景中的网格是怎么被渲染的。渲染状态包括 使用哪个顶点着色器/片段着色器、光源,材质,纹理等。在我们调用图形渲染API函数进行绘制之前我们需要设置这些状态值,这些状态值指导 GPU 如何渲染我们传递到显存的模型和纹理数据

Draw Call(调用图形渲染API)

在模型和纹理数据传输到显存,并且设置了正确的渲染状态之后,CPU需要调用渲染API函数通知GPU去执行绘制操作。这个步骤就是传说中的” Draw Call “。因为渲染状态已经通过其他API函数设置好了,所以 Draw Call 仅仅是发送命令给GPU,告诉GPU绘制哪些模型数据

CPU和GPU是并行工作的,他们之间通过命令缓冲区(Command Buffer)这个队列沟通。大概如下:

明白了 Draw Call,我们得了解另外一个概念:批次(Batch), 调用一次渲染API的绘制接口来向GPU提交使用相同渲染状态来渲染物体的行为称为一个渲染批次。从渲染API调用的角度来看,Batch和Draw call是一样的,但是在游戏引擎中他们的实际意义是不一样的,Batch一般指经过打包之后的 Draw call 。如下2种缓冲区命令,你就能明白他们的区别了

渲染状态A > 渲染A >渲染状态B > 渲染B > 渲染状态C > 渲染C Draw Call:3 Batch:3

渲染状态A > 渲染A > 渲染B > 渲染C Draw Call : 3 Batch:1

实际证明,从CPU到GPU最消耗性能的地方是切换渲染状态,而不是 Draw Call 这个操作。也因此,Unity引擎越来越淡化Draw Call,转而用Batchs作为Graphics的性能指标,同时这也是Unity进行批处理的理论依据

Static Batching(静态批处理)

静态批处理的工作原理是将静态游戏对象转换为世界空间,并为它们建立一个共享的顶点和索引缓冲区。 在后续的绘制过程中,根据Unity引擎渲染排序函数,将符合静态合批的对象,一次性提交整个合并模型的顶点数据,然后设置一次渲染状态,调用多次Draw call分别绘制每一个子模型 , 而多次 Draw call 调用之间并没有渲染状态的切换

优点:针对相同材质的静态GameObject,Unity使用静态合批能将相同渲染状态的对象同一批次添加到命令缓冲区供GPU去渲染,从而加速了渲染效率

缺点:会加大运行时的内存以及增加包体大小

为什么会增加包体呢? 答: 因为在Build项目的时候,Unity会把符合静态合批的对象生成一个大的合并网格,这个多出来的网格会导致包体增加

为什么会增加运行时内存呢? 答: 渲染一个对象,CPU会从硬盘拿到渲染这个物体的数据(网格,材质,贴图等)加载到内存中,然后传送到显存中,渲染完成后,自动删除这部分内存。渲染合批的对象时,因为是直接加载Build的时候生成的合并后的大的Mesh,所以运行时的内存就增加了。合并后的网格越大,则运行时增加的内存就越大

Unity还提供了一种灵活度很高的运行时静态合批方法我们可以在运行时调用StaticBatchingUtility.Combine实现将一些模型合并成一个完整模型。使用这种方法我们可以避免最终打包的应用体积增大,但是由于在运行时通过CPU做模型的合并,会到来一次性的运行时内存和CPU开销

Dynamic Batching (动态批处理)

动态批处理 专门为优化场景中共享同一材质的动态GameObject的渲染设计的。为了合并渲染批次, 动态批处理 每一帧都会有一些CPU性能消耗,如果我们开启了 动态批处理 ,Unity会自动地将所有符合条件的共享同一材质的动态GameObject在一个Draw call内绘制。动态批处理 的原理也很简单,在进行场景绘制之前将所有的共享同一材质的模型的顶点信息变换到世界空间中,然后通过一次Draw call绘制多个模型,达到合批的目的

优点:弥补了静态批处理不能处理相同材质的动态对象的不足

缺点:1. 会带来CPU性能消耗。它与静态批处理带来性能优化不一样,动态批处理是将多个对象通过一个Draw Call来渲染。那么就需要在渲染之前,将这些小模型的顶点从本地坐标系转换成世界坐标系,模型顶点变换的操作是由CPU完成的,所以这会带来CPU的性能消耗。计算的模型顶点数量不宜太多,否则CPU串行计算耗费的时间太长会造成场景渲染卡顿。2. 动态批处理有很多严格的限制条件,具体可以参考官网,这里就不一一列举

GPU Instancing

静态批处理和动态批处理,都有自己的优势和劣势。但这些都是在CPU端做的优化,实际上GPU的运行速度是很高的,一次性渲染20个对象和一次性渲染200个对象,几乎是一样的。以上两种提升渲染能力的方法都是针对Command buffer,那我们能不能另辟蹊径,针对GPU来做个优化。比如:一个Draw Call,绘制一个对象;那能不能告诉GPU,一个Draw Call ,给我绘制一百个对象

静态批处理的优先级要比 GPU Instancing 的优先级高,如果一个GameObject被标记为 static 物体并且在Build阶段成功地执行了静态合批,那么如果这个物体还要使用 Instancing Shader 渲染的话,Instancing会失效

动态批处理的优先级要低于 GPU Instancing ,如果一个GameObject使用Instancing渲染的话,那么对于它的Dynamic batching会失效

好了,希望本文也能为你解惑,重拾爱情

Unity3D性能优化——渲染篇

图形渲染及优化—Unity合批技术实践

Unity Shader 入门精要