bookmark_border[译文]5分钟系列—快速理解Multithreading(多线程)

原文链接:A gentle introduction to multithreading

前言

随着计算机硬件 ( hardware ) 的更新迭代以及操作系统 ( operating systems ) 的智能化,使得现代计算机能够同时处理更多任务;从而让我们的应用程序执行速度更快,响应时间更短

因此编写软件的时候,我们可以利用并发处理越来越多的事情;但凡事都有代价,这要求我们对并发的实现原理要有更深入的了解。让我们用 threads (线程)来开启并发的魔法之旅吧

Processes and threads ( 进程与线程 ) : 字如其意

目前主流的操作系统都能同时运行多个程序,这也是为什么你能用 Chrome 浏览器看这篇文章的同时,还能用网易云音乐听歌。因为每个程序都运行在一个所谓 进程 的环境中;操作系统为每个进程提供不同的硬件支持 ( 包括 CPU,内存,显存,输入输出等)

让操作系统同一时间执行多个任务,并不是只有启动多个进程这一个办法。每个进程可以在其内部同时开启多个 线程 用来并发执行任务。你可以把线程看作是进程的一部分,每个进程启动的时候,至少会开启一个线程,一般称这个线程为:主线程。程序员可以根据实际需求,开启或者停止其他线程用来执行任务;我们经常说的多线程,指的就是在一个进程中,开启多个线程

我们用来听歌的网易云音乐,就是个多线程程序。主线程用来渲染UI界面,子线程1 用来播放音乐,子线程2 用来读取歌词并显示等。你可以通俗的把操作系统看成是进程的容器 ( 其实更像是管理员 ) ,进程是线程的容器,示例图如下:

进程和线程的区别

操作系统为每个进程分配一块独立的内存空间;默认情况下,进程之间不共享内存空间 ( 也就是不能相互访问 ) ,比如:Chrome浏览器无法访问网易云音乐的运行内存,反之亦然。除非使用进程通讯技术 inter-process communication (IPC)

与进程不同,线程能够共享父进程的内存空间。比如: 网易云音乐的UI界面线程可以访问音乐播放线程的内容,反之亦然。同时,线程占用资源更少,创建/切换 速度更快,也有人将线程称为:  lightweight processes (轻量级进程)

因此,相对启动多进程(通讯不方便,更消耗资源),启用多线程才是同时处理多任务的最佳实现,而且程序能运行的更快

多线程用处

为什么需要多线程?因为并发执行任务能大大节省时间提高效率。举个不太恰当的例子:比如你下载一部电影,如果单线程下载,需要1小时;但是多线程下载,就可以根据线程数去下载不同的片段,多任务同时进行,那么就可以很快下完

多线程太完美了,真的就这么简单吗?以下几个问题你要好好考虑:

  • 不是所有程序都要用到多线程。如果你的程序本身就需要顺序执行;或者频繁等待用户输入,那就没有必要多线程
  • 线程不是越多越好。要根据具体任务的类型思考和设计,是否增加线程
  • 多线程并不保证真正做到并发执行。这还要取决硬件是否支持

最后一点请注意,并不是所有硬件设备都支持同一时刻执行多任务,只不过操作系统让它看起来像,这个后面会介绍

我们先把 并发(Concurrency) 看作是多任务看起来是同时执行;而 并行(Parallelism) 是看作是同一时刻处理多任务。举个简单的例子来理解并发和并行

并发:吃饭的时候,来了电话;停止吃饭,接电话

并行:吃饭的时候,来了电话;边吃饭边打电话(并行也不一定高效,吃饭的时候说话对方有可能听不清楚,导致打电话时长增加)

并发(Concurrency),并行(Parallelism)

CPU是程序被执行的地方,它由很多部分组成,其中最核心的部分称之为:Core (核心),每个核心同一时刻只能执行一个操作。硬件设计如此,因此操作系统为了能最大化利用核心,发明了很多先进技术,使得用户能够运行多个进程,其中最重要的技术就是:抢占式多任务处理机制。抢占是指中断当前任务,切换到另一个任务,稍后再切回当前任务继续执行

因此,如果你的硬件是单核CPU,那么操作系统的工作就是将单核的算力分配到不同的进程或线程上,这些进程或者线程会被轮询执行;这样就会给我们造成一个假象:同时多个程序被执行 或者一个程序同时执行多个任务(多线程)。虽然这满足了并发,但实际上并未真正并行

如今CPU早就多核心,每个核心同一时刻都能进行一个操作,这也意味着真正的 并行 是可能的。操作系统会自动检测CPU的核心数量,并为每个核心配置进程/线程。一个进程/线程运行在哪个CPU核心对我们来说是完全透明的,而且一旦所有核心都很忙,那么抢占式多任务处理就会启动。这也是为什么计算机能同时开启的进程数量远超CPU核心数的原因

对于单核CPU多线程有意义吗?

在单核CPU上,真正实现并行是不可能的。不过,多线程依然是有意义的,因为抢占式任务处理的存在,它可以使得应用程序保持响应,即使其中一个线程执行缓慢或阻塞的任务

举个例子:假如你的应用要从磁盘读取某个大文件,此时如果是单线程,那么整个应用会未响应状态。如果用多线程,那么就可以线程A去读取文件,线程B刷新UI界面(实时观测读取该文件的进度)

多线程,多问题

依上文可知,线程会共享其父进程的内存空间;这就使得同个进程内的多线程之间可以很方便的进行数据交互。多个线程从同一块内存地址中读取数据没有任何问题,但是一旦某个线程进行写操作,而其他线程进行读操作时就容易出现如下问题:

  • Data Race(数据竞争):线程A 对一块内存地址的内容形成写操作,而 线程B 又同时读取这块内存地址存储的值,那么就有可能读到的是脏数据
  • Race Condition(竞争条件):线程执行的顺序是不可控的,而我们程序又希望按照指定的顺序执行。比如:我们要求 线程B 只能在 线程A 完成写入操作后再读取数据

如果一段代码,很多线程同时调用它而且不会有 数据竞争 和 竞争条件 ,那么我们称这段代码 thread-safe(线程安全)

数据竞争的根本原因

CPU内核一次只能执行一条机器指令。因其不可分割性,我们称这种指令为 原子指令(它不能被分解成更多步骤的操作)。希腊单词 atom(ἄτομος;atomos)的意思是不可切割的

因不可分割的特性,原子操作天然的线程安全:当 线程A 的写操作具备原子性时,其他线程在 写操作指令 完成之前则无法执行 读操作指令;反之亦然,因此不会发生数据竞争

可问题就是,大部分的操作指令都不是原子指令。即使是像 i++ 这样简单赋值也是由多个原子指令组成 (可以思考以下,为什么它不是原子操作,面试官很喜欢问的一个问题),从而使赋值本身作为一个整体是非原子的。因此,如果一个线程B 读取 i 而线程A 执行赋值,就会触发数据竞争

竞争条件的根本原因

抢占式多任务处理机制使得操作系统能够完全掌控和管理线程:它可以根据调度算法启动、暂停和中止线程;作为程序的开发者,你并不能控制线程的执行时间和顺序。实际上,你并不能保证如下代码会按照指定的顺序执行(一般是指从上到下)

writer_thread.start()
reader_thread.start()

运行多次,你就会发现有时 writer 线程先启动,有时 reader 线程先启动。如果你的程序需要保证先执行写操作再执行读操作,那么你肯定会遇到竞争条件

这种行为被称为不确定性:结果每次都会改变,你无法预测。调试受竞争条件影响的程序非常烦人,因为产生的bug是非必现的,而且有可能在你的计算机运行不会出问题,而在别人的计算机上就会有问题

多线程与并发

多线程编程,数据竞争 和 竞争条件 是经常会出现的问题。容纳多个并发线程的技术称之为: concurrency control (并发控制),操作系统或者一些编程语言为我们提供了一些解决方案,比较通用的是如下几种:

  • synchronization 同步: 确保同一时刻仅有一个线程在使用资源。将代码的特定部分作上标记,这样多个并发线程就不会同时执行这段代码,也不会让共享数据变得混乱
  • atomic operations  原子操作: 借助操作系统提供特殊指令,将非原子操作(如:赋值操作)转化为原子操作 ( 比如C#的 Interlocked.Increment )
  • immutable data 不可变数据: 当数据被标记为不可变(ReadOnly),只允许线程从中读取数据,而不允许线程改变数据内容

以上三种只是比较常规的做法,C# 内关于多线程的解决,还有一些其他方案,比如:监视器(Monitor)、互斥锁(lock)、读写锁(ReadWriteLock)、互斥(Mutex)、信号量(Semaphore)、事件(AutoResetEvent/ManualResetEvent)等

bookmark_border[译文]5分钟系列-快速理解Hash Table(哈希表)

原文链接 : What is a Hash Table?

什么是哈希表?

哈希表是用来存储同种类型 键/值对 的数据结构,它使得存储在哈希表内的 键/值对 能被非常高效的查找到。最典型的哈希表就是衣柜内的抽屉 :假如 第一格存放袜子,第二格存放T恤,第三格存放内衣。那么要找某一双袜子,你只要去第一格就能快速找到。 key(键)就是抽屉索引,value (值) 就是抽屉内存放的衣物

了解哈希表有什么用处?

  • 你可以大概了解哈希映射(hash map)、关联数组( associative array )、字典 ( dictionary ) 等这些常用的数据结构如何实现
  • 可以明白哪些数据比较适合用哈希表来存储

5分钟带你走入哈希表

假如我们现在要存储用户信息,同时还可以根据名字查找到对应用户的信息

用户信息

我们大可以将这三个用户存在一个数组内,查找的时候遍历数组对比名字,就能找到。看起来没什么问题,假如有成千上万个用户呢?那最差情况是不是得遍历所有用户挨个对比名字才能找到我们想要的用户信息,这显然效率低下

创建哈希表

为了能使用哈希表,存储的对象必须要有一个 唯一 的东西当作key,我们称它为:ID,假设用户的名字作为 ID 。哈希表的工作原理是将对象存储在对应的桶(buckets)内

一个哈希表选择多少个桶,各种编程语言内的集合类存储结构对桶的数量都有着不同的考量,这是个很大的话题,本文暂且就用4个桶来阐述哈希表的基本原理

每个用户该存储在哪个桶内呢?这是很关键的一步。你当然可以随便放入一个桶内,但下次拿的时候,你能想起来你放在哪个桶内吗?所以,此时需要一种算法,能根据 ID 迅速找到对应的桶号( 这就是 Hash Function 哈希函数 )

假设 a= 0, b=1, c=2 依次类推(不区分大小写),Ada = 0+3+0 = 3 ,那么 用户ada 就放入3号桶

同理,当我们需要获取 Ada 的相关信息的时候,就可以根据相同的算法,快速定位到 3 号桶,从而拿到他/她的信息 。让我们来存放第二个用户:Grace

哦吼,29超过了我们最大的桶编号,咋个办 ? 一个比较通用的办法是将 29 与桶的数量进行求余运算

一群小朋友玩躲猫猫的游戏,但是一开始谁最先找呢?就用到 “点兵点将 骑马打仗 有钱喝酒 没钱滚蛋 ”这个顺口溜,小伙伴站一排,然后通过这个顺口溜,一个字一个人,从头到尾数,人数不够就又从头来,点到 谁是 “蛋” 字,谁就开始找,这就是最原始的”求余”

29 % 4 = 1(也可以表示为:29 mod 4 = 1),因此用户 Grace 放入1号桶内,结果如下:

冲突

一个好的哈希函数的终极目标就是让每一个桶内只存放一个用户信息,让我们来存放用户 Tim 试试

结果显示,用户 Tim 应该放入 3 号桶,但是 3 号桶已经存储了 Ada 的相关信息,这就是所谓冲突

针对冲突,常用的方法有如下方法:

  • 发现当前桶已经存储了信息,就存入下一号桶内,如果下一号桶也存储了信息,就继续下去直到有空的桶。上文 3 号桶已经存储了信息,那么我们就找下一号桶,因为没有4号桶,所以就跳到0号桶存入
  • 每个桶是一个数组/链表,可以存储多个对象,求余结果一样,放到同一个桶内
  • 增加桶的数量,这样就能大大降低冲突的概率

因此,哈希表最重要的是哈希函数,一个好的哈希函数能让信息均匀的存入每个桶内(但是冲突永远无法避免,只能减少概率)。最坏的哈希函数导致的结果可能是所有的对象都放在同一个桶内,这就跟直接放入数组内没有什么区别了

关于哈希表的更详细信息,可以参考我翻译的数据结构系列文章:数据结构

课外延申

bookmark_border[译文]5分钟系列-快速理解Huffman Coding(霍夫曼编码)

原文链接What is Huffman Coding?

什么是霍夫曼编码 ( Huffman Coding )

霍夫曼编码是很多压缩算法的基础,比如著名的 DEFLATE (常用的图片格式 png 就用到了 DEFLATE ) 和 Gzip

为什么要了解霍夫曼编码?

有没有偶然的瞬间,或是通勤途中的地铁上,抑或是入眠前的思绪畅游,脑海中有如下疑问:

  • 如果做到无损压缩数据?
  • 为什么同一个文件,不同的压缩算法会有不结果(压缩率,压缩/解压时间)?
  • Gzip 是如何工作的?

5 分钟带你走入哈夫曼编码

压缩

假设我们想压缩一段字符串 (哈夫曼编码可以压缩任意数据,本文只是讲解基本原理,选用字符串最容易理解)

通常一段文本中,有些字符出现的频率会比另外一些字符更高;而哈夫曼编码就正是利用了这一点,对这段文本中出现的全部字符重新编码,让出现频率更高的字符占用更少的空间从而达到压缩的目的

就用 Yoda 大师的经典名言 “do or do not” 来当作示例,这句话一共 12 个字符。按照计算机默认编码格式 (关于编码格式,你可以参考UTF百科),每个英文字符占用 8 比特 (bit) , 一共占用96比特 ;那么采用霍夫曼编码以后一共占用多少比特呢?

首先,我们得先构建哈夫曼树。出现频率最高的字符,就距离树的根节点最近。依次类推,下图就是字符串 “do or do not” 的哈夫曼树

最常见的字符 ‘o’和 ‘ ‘ (空格) 距离根节点只有 2 步,而最不常见的 t 则距离根节点有 3 步。哈夫曼编码最神奇的事情来了,我们存储的不再是字符本身,而且存储从根节点到达它的路径。具体什么意思呢?

我们从根节点开始,然后沿着树像要编码的字符前进。如果走了左侧路径,则标记为 0,走了右侧路径,我们则标记为 1

因此,字符 d 的编码为:’100′ ,而字符 d 的默认编码是:’ 01100100 ‘,整个字符串编码以后的结果如下:

最终需要消耗 96 比特的字符串,采用哈夫曼编码以后,只需要 29 字节,足足压缩了2/3

解压

怎么解压呢?就是照着存储路径,依次从哈夫曼树拿到该路径对应的真实字符

聪明的你是不是早已想到,如果我只把压缩后的数据发给别人,别人没有对应的哈夫曼树,就没法解压。是的,大概来说有 3 种办法:

  • 将哈夫曼树和压缩后的文本一起发给对方,就可以根据你发的哈夫曼树来解压
  • 可以双方都同意同一颗已知的哈夫曼树,压缩和解压就可以都用它
  • 发送足够的信息,对方可以根据这些信息构建出哈夫曼树从而达到解压的目的(Gzip的工作方式),但是请注意:同样的信息,也有可能构建出不同的哈夫曼树,因此发送的信息要确保构建的哈夫曼树一致

课堂疑问

  • 上面的示例,为什么哈夫曼树是 4 层 ?所有的哈夫曼树都是 4 层吗?
  • 一大段 中文文本 和 英文文本 ,压缩比例会一样吗 ?

课外延申

bookmark_border[原创]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

bookmark_border[原创]C#中Dispose和 Finalize

要讨论这两个方法,首先要知道C#的 GC,因为有了 GC,所以开发者才能专注于应用程序的功能;然而 GC 也有局限性,它只能释放托管内存中的对象;像文件句柄,网络套接字,数据库链接这种非托管资源,GC 就无法帮你释放了。如果不释放这些资源,就会导致应用程序一直引用它们,而其他应用程序就无法对它们进行操作(比如:当你打开Word 文件,然后删除它的时候就会弹出:操作无法完成,因为文件已经在 Microsoft Office Word中打开)

C# 内的 Dispose() 和 Finalize() 方法,就是释放对象中非托管资源的方法。如果你想在自定义的类里面实现 Dispose() 方法,那么就得引用 IDisposable 接口;你可能见过使用 using 的代码,它其实就是try/finally格式的语法糖,在finally内会主动调用 Dispose() 方法,所以当你遇到非托管资源的操作,using是个很安全的做法。当然,你也可以在使用完该对象以后,主动调用它的 Dispose() 方法

using(FileStream file = new FileStream("path", FileMode.Open, FileAccess.Read))   
{  
    //Do something with file   
}  

finailze() 是 object 类的内置方法;注意:你查看 object 类源码的时候,并不能查找到 finalize 方法,它是析构方法” ~Object() “编译产生的。如下图,析构方法 ~FinalizeTest() 编译成IL代码以后,就变成了 Finalize():

源码和IL代码

那么问题来了,既然有 Dispose() 方法,为什么还需要 Finalize() 方法呢? 因为有时候持有非托管资源的变量可能被用在很多地方,我们无法显示判断在何时调用 Dispose() 方法释放该资源。只有在 GC 的时候,我们判断引用该非托管资源的变量已经不被任何其他对象引用,这时候才是合适的释放时机。然而GC过程是内部实现的,我们无法在 GC 过程中手动调用 Dispose() 方法,所以 Finalize() 方法的用处就来了,GC 会自动调用 Finalize() 方法,我们只要把释放非托管内存的代码写在 Finalize() 方法中,就能释放非托管内存了

那么 Finalize() 是怎么被调用的呢? 当程序运行以后,GC会收集实现了 Finalize() 方法的对象,并把他们放入一个队列。当某个对象可以释放内存的时候:GC会把它所持有的非托管内存释放,并从队列中移除;但请注意,该对象在托管堆中并未释放。只有在下一次 GC 的时候,才会从托管堆中释放该对象

至此,我们已经了解了 Dispose() 和 Finalize() 方法,那是不是可以让他们两完美组合呢?既可以让用户手动调用,也可以做个托底,让GC自动调用。肯定可以的,代码如下:

using System;
public class FinailzeTest : IDisposable
{
    //GC自动调用
    ~FinailzeTest() { Dispose(false); }

    //用户手动调用
    public void Dispose() { Dispose(true); }

    protected virtual void Dispose(bool disposing)
    {
        //释放托管资源 
        if (disposing) { managedObject = null; }
        //释放非托管资源
        unManagedObjectDispose();
        //告诉GC, 把该类对象从列表中移除, 到时候不再执行Finalize()方法
        if (disposing)
        {
            GC.SuppressFinalize(this);
        }
    }
}

如果用户没有主动调用 Dispose() ,未能及时释放托管和非托管资源,那么在垃圾回收时,会执行Finalize(),释放非托管资源(注意:非托管资源并未被释放)
;如果用户主动调用了 Dispose(),就能及时释放了托管和非托管资源,而且垃圾回收的时候不会执行 Finalize() 方法,提高了性能

好了,关于 Dispose() 和 Finalize() 方法,就写到这,以后有新发现,再更新!

bookmark_border[原创]何为利率市场化?

何为利率

说到利率,首先就要清楚两个概念:货币的时间价值和利息。现实生活中,一般来说现在的100块钱比一年后的100块钱更有价值,因为可以把现在拥有的100块钱存进银行,一年后从银行取出来的货币总额将大于100块钱。两者的差额就是通常所说的利息。对于这种现象,金融理论用”货币的时间价值”进行概括。所谓货币的时间价值(Time Value of Money),就是指同等金额的货币其现在的价值要大于其未来的价值,利息就是货币时间价值的体现

为什么货币具有时间价值? 西方经济学的解释:人类本性中,存在着不耐心等待而现在就想要消费的欲望。如果货币的所有者将其持有的货币进行投资(存银行也是一种投资)或者借给他人,他就必须牺牲当前的消费,对此,他会要求对其当前消费的推迟给予一定的补偿,补偿金额的多少与当前消费推迟的时间长短同向变动。因此货币的时间价值来源于对当前消费推迟的时间补偿

我们知道利息是借贷关系中资金借入方支付给资金贷出方的报酬。由于本金数量会对利息总额产生重要影响,这使得我们无法通过比较利息额来衡量货币时间价值的高低,因此,我们要引入一个新的概念来具体衡量货币时间价值——利率。它是指借贷期满的利息总额与贷出本金总额的比例。按照大白话来理解利率:就是借钱贵不贵,利率高,借钱的代价就高,利率低,借钱的代价就低

知道了利率我们就可以开始讨论各种名头的利率了,最重要的一个当属国之重器:基准利率(Benchmark Interest Rate),是在多种利率并存的条件下起决定作用的利率,其他利率会随其变动而发生相应的变化。我国的基准利率是:央行对商业银行及其他金融机构的存贷款利率,又称法定利率。可能你还听过上海银行间同业拆借利率(SHIBOR),它就是我国货币市场的基准利率,后面会多次谈到它

利率管制

为什么要搞利率市场化? 要回答这个问题,得先搞清楚两个问题:1. 什么是利率市场化? 2. 利率以前为什么没有市场化?

利率市场化是指金融机构在货币市场经营融资的利率由市场供求来决定。它包括利率决定、利率传导、利率结构和利率管理的市场化。即是货币当局将利率的决定权交给市场,由市场主体自主决定利率,货币当局则通过运用货币政策工具,间接影响和决定市场利率,以达到货币政策目标

利率之前为什么没有市场化呢?建国初期,出于对当时国际国内政治、经济等因素的全面考虑,我国领导人高瞻远瞩的选择了优先发展重工业的”赶超战略“。而重工业作为资本密集型产业,具有三个主要的特征:建设周期长、投资规模大、大部分设备需要从国外引进。而当时我国资金短缺、外汇短缺、资金动员能力弱。所以如果依靠市场机制来配置资源,是不可能把资金引入重工业部门的。为了解决这一问题,就不得不做出一些政策来降低市场机制的作用,而利率管制,就是这些政策里面重要的一个

当时的做法是中央银行进行利率管制,制定较低的存款利率和贷款利率。居民以较低的存款利率在银行存钱,银行以较低的利率优先向重工业相关的企业提供资金,助其快速工业化。实践证明,当时这种做法效果是不错的,在短时间内按照国家意志建立了重工业体系,为后续的经济腾飞打下了坚实的工业基础。但是随着我国经济的发展,这样做的弊端也逐渐显现

第一,因为银行的贷款完全是以政策为导向,不是按照科学的信贷风控来决策的,所以不良贷款率会较高;其次就是很多银行未训练出真正意义的经营管理能力,尤其是风险控制、客户服务,以及最重要的定价能力; 第二,因为产业部门市场化程度提高,银行再向其提供低利率资金,就会助长过度投资、过度杠杆; 第三,过低利率不能覆盖借款人风险,所以银行不愿意把信贷投放给中小企业(后两个问题以及这两个问题衍生出的问题,至今仍困扰着我国)

利率双轨

在中国经济市场化过程中,产品市场上同一产品存在计划垄断性定价和市场定价两种价格,称为”价格双轨制”。与之相仿,在金融市场中,也存在受管制的存贷款利率和完全由市场供求决定的市场化利率,这便是“利率双轨制

  随着市场化进程的加快,产品市场的价格已完成并轨,实现了由市场供求决定价格。金融市场的改革则相对滞后,所以利率双轨制问题仍然存在。银行根据央行公布的存贷款基准利率去和客户定价;央行通过政策工具调节货币市场利率。贷款利率与货币市场利率各行其是

在利率两轨制下,不管央行怎么调节货币市场利率,都不会明显影响存贷款利率。比如,政策当局想降低企业融资成本时,央行通过货币政策工具引导货币市场利率下行,但贷款利率却基本不动。因此,最理想的办法是实现利率两轨并一轨,实现从货币市场利率向存贷款利率的有效传导

利率体系

要明白利率的有效传导,就要了解我国目前的利率体系。我国的利率体系可分为:央行利率、金融市场利率和商业银行存贷款利率三个层次(如下图)。目前利率的双轨制问题是,央行的利率体系能影响金融市场利率。但目前我国社会融资的主要模式仍然是“间接融资”(也就是商业银行直接给企业放贷),而商业银行的贷款利率则是由被管制的”存贷款利率”决定,所以央行的利率体系并不能影响到商业银行的存贷款利率

为了应对双轨制利率,央行得采用两套不同的对策来应对银行存贷款市场与货币债券资金市场的问题

利率传导

针对所以央行的利率体系并不能影响到商业银行的存贷款利率问题,2019年8月人民银行宣布改革完善LPR形成机制推出了贷款市场报价利率(Loan Prime Rate, LPR),LPR的报价是在MLF利率基础上加点形成的。共有18家银行每月根据MLF等市场利率报出LPR,去掉最高值和最低之后形成的价格就是每月公布的LPR利率。央行可以调整MLF利率来影响LPR利率,而商业银行的贷款利率则是在LPR基础上加点,因此新的传导利率则打通。通过LPR利率,我们可以预知,MLF利率以后可能成为新的基准利率,至少它有这个潜力

货币市场有SHIBOR作为基准利率,而商业银行利率体系有LPR作为贷款基准利率。因此央行调节金融市场的可用货币政策工具越来越多,也越来越灵活。其实理想化的利率传导应该如下图:

① 首先,央行制定政策利率,即投放基础货币的各种工具(再贷款、逆回购、MLF、SLF等)的利率

② 然后,政策利率影响货币市场利率。比如SLF作为利率走廊上限,能够稳定市场利率

③ 货币市场利率会影响银行存款利率,因为理想模型中,如果市场利率足够低,银行就没必要更高成本去拉存款

④ 银行综合了各种负债成本之后,再考虑其他因素,与客户确定贷款利率

我们发现上述整个传导过程中,我们用不到存贷款基准利率,也用不到LPR……换言之,这两个基准利率似乎并不是未来成熟模式下的必需品(只是最后签合同时可以选它们为锚,也可以不选它们)

一二点,央行制定策略,没啥问题。利率传导卡在第三点,银行可以从货币市场获取同业负债,也可以从存款市场获取存款,理论上两种获取货币的代价应该一致,如果差别太大,比如货币市场利率过低,而存款利率过高,那么就没必要继续高息揽存。但现实中,即使货币市场利率再低,高息揽存现象依然屡禁不绝,背后是什么原因?因为我们实施了很多专门针对存款的考核,比如存贷比、同业负债不得超1/3等之类的,这就导致了银行无论如何也必须拉存款

第四点,银行内部只有建立了完善的FTP系统,才能实现负债成本向贷款利率的有效传导。否则,货币市场利率、存款利率变动之后,贷款也不会跟着及时变动。FTP是利率传导的真正最后一公里

第三点不可能一下子要求放开所有要求,而第四点才是最关键的,银行内部的FTP才是利率传导的真正最后一公里,也是利率并轨的最大阻碍,因此,正如央行自己说的,利率并轨工作非一日之功,需稳妥推进。

总结

目前我国利率市场化,已到了最后一公里。但这一公里才是整个利率市场化最难啃的骨头,一些经济学家给出的建议是:

一、建立一套完整的中央银行利率调整框架。一是优化货币政策的目标,突出价格稳定目标;而是进一步完善货币政策决策机能;三是健全货币政策操作系统;四是建立利率走廊机制

二、强化金融市场基准利率培育,健全市场基准利率体系。一方面进一步优化Shibor报价生成机制。另一方面,加速研究完善LPR报价质量考核评估指标体系

三、完善金融机构的定价能力,提高微观经济主体的利率敏感性

四、加速推进配套改革

bookmark_border[译文]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代码,降低它的复杂度,可以提升性能

bookmark_border[原创]静态批处理、动态批处理、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 入门精要

bookmark_border[译文]Unity性能优化—脚本最佳实践

原文地址: Fixing Performance Problems – 2019.3

引言

当游戏在移动设备上运行的时候,游戏的每一帧可能需要CPU执行上百万个指令;为了保持平稳的帧率,CPU必须在规定时间内执行完相应的指令,一旦CPU无法及时执行所有指令,我们游戏就会变的卡顿

很多问题会导致 CPU 需要执行密集指令,比如:为渲染做准备工作、进行超复杂的物理运算或者太多的动画回调;本文主要探讨的是:我们编写的脚本导致的 CPU 性能问题。我们将学习脚本是如何转换成 CPU 指令,哪些代码会让 CPU 执行过多的指令,以及如何修复这些代码

诊断代码中的问题

CPU 超负荷运行的直接现象就是游戏不够流畅(帧频不稳)。然而其他性能问题也可能导致类似的现象。如果我们游戏有类似症状,我们首先想到的是要利用Unity 的 Profiler 来分析是不是因为 CPU 超负荷运行引起的;如果是那么进一步分析到底因为代码问题引起的;还是因为物理计算或者动画太多引起的(如何使用Profiler分析性能问题,请移步到:Diagnosing Performance Problems tutorial.

Unity构建和运行游戏

     为什么我们的代码可能造成性能问题呢 ? 我们首先需要知道当构建游戏的时候发生了什么事情,了解幕后发生的事情有助于我们提高游戏性能。构建游戏的时候,Unity 会把游戏运行需要的所有东西打包到对应设备能运行的程序内。众所周知,CPU 只能执行机器代码(原生代码:native code),不能执行一些高级语言编写的代码(比如: C#)。第一步:Unity 会将我们编写的高级语言转换成其他语言;这个转换过程我们称之为 编译( compiling ) , Unity 会把我们写的 C# 代码编译成CIL(译者注:这一步是通过 Mono 实现的),CIL 是一种很容易编译成不同原生代码的通用中间语言。第二步: CIL 然后被编译为目标设备对应的原生代码; 它发生在我们构建游戏时(静态编译,AOT )或代码运行之前(即时编译,JIT )

源代码与编译代码之间的关系

代码在未编译之前称为”源代码”,源代码直接决定了编译后的原生代码的结构和内容,结构良好且高效的源代码将生成结构良好且高效的已编译代码

有些 CPU 指令的执行时间比其他指令长。比如: 计算平方根比计算两个数相乘耗时更长。实际上 CPU 执行这两个指令的时间都非常短暂,但是它们之间相对来说还是有快慢之分

一些在源代码中看起来非常简单的操作,编译之后的原生代码会非常复杂。比如将一个元素插入列表,执行此操作所需要的指令要比按索引从数组中访问元素多得多

理解了以上两点,即便你对一些原生代码底层如果工作不是很熟悉,但是可以通过源代码和编译以后的代码之间的联系编写高性能的源代码

Unity引擎代码与脚本代码在运行时通讯

用 C# 编写的脚本代码与Unity引擎核心代码的运行方式略有不同。Unity 引擎的内核代码是用C++编写的,并且已经被编译成原生代码。这个编译后的引擎代码包含在我们安装的Unity程序中

源代码被编译成 CIL 以后,称为托管代码。当托管代码被编译成原生代码时,它与托管运行时集成。托管运行时负责内存管理和安全检查,以确保代码中的错误只是会导致异常,而不会造成设备崩溃

当数据从托管代码传递回引擎代码时,CPU 可能需要将数据从托管运行时使用的格式转换为引擎代码所需的格式,这种转换称为编组(marshlling)。托管代码和引擎代码之间的任何单个调用的开销都不是很大,但是我们必须知道这里是有开销的

代码性能不佳的原因

现在我们已经了解了 Unity 构建和运行游戏时代码会发生什么,我们也知道,我们的代码之所以性能很低,那是因为它们导致了 CPU 执行了密集指令

第一种可能是我们的代码很浪费或者结构很差。常见的例子可能是:当一个函数只需要被调用一次时,它却被重复调无数次

第二种可能是我们的代码看起来结构良好,但是对其他代码进行不必要的大量调用。这方面的一个例子可能是导致托管代码和引擎代码之间不必要的重复通讯从而造成 CPU 开销

第三种可能是我们的代码的没有任何问题,但是在不需要调用它的时候它依然被调用(一个例子是:怪物自动寻敌代码,当主角离怪物足够远的时候,这段代码应该停止执行)

最后一种可能是我们的代码需求太高了。这个我们只能重新设计游戏以降低它对性能的要求。实现这种优化超出了本文的范围,因为它非常依赖于游戏本身,但是阅读这篇文章并考虑如何使我们的游戏尽可能地拥有高性能仍然会对我们有好处

提高代码性能

一旦确定我们游戏中的性能问题是由于代码造成的,我们就应该仔细想想怎么解决这些问题。优化臃肿的函数似乎是个好的开始,当然也有可能这个函数为了实现这个功能已经是最优写法了,再去改动需要花费的代价太大(有可能导致功能异常或者带来新的bug)。我们可以在一个被数百个游戏对象所引用的脚本中创造一个小的效率节约,那么这就会给我来很可观的性能提升。此外,可能为了提高 CPU 的性能,会导致更多的内存消耗或者将工作量分担给了 GPU

尽量将代码移出循环

循环是个老生常谈的低效率代码之一,嵌套循环更甚。如果它们是被包含在Update 这类每一帧都会被调用的函数中,那么效率更低。在下面的示例中,无论条件是否满足,for 循环每一帧都被执行,这是非常糟糕的代码

void Update()
{
	for(int i = 0; i < myArray.Length; i++)
	{
		if(exampleBool)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

一个小小的改变,代码只在满足条件的情况下才会循环,情况就会好很多

void Update()
{
	if(exampleBool)
	{
		for(int i = 0; i < myArray.Length; i++)
		{
			ExampleFunction(myArray[i]);
		}
	}
}

这是一个简单的例子,我们应该认真检查代码中的循环结构,尤其是被频繁调用的循环。Update() 是每一帧都会被调用的函数,将代码移出 Update() 在需要时才运行,这是提高性能的一种好办法,而且也是最容易实现的办法之一

只有情况发生改变时才调用代码

让我们来看一个非常简单的例子(只在状态发生变化时才调用它)。在下面的代码中,在 Update() 中调用 DisplayScore() 。事实上分数的值不可能每一帧都发生变化,这意味着,我们可以将它移出 Update()

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
}

void Update()
{
	DisplayScore(score);
}

通过一个简单的更改,我们现在确保只有在 Score 的值发生改变时才调用DisplayScore()

private int score;

public void IncrementScore(int incrementBy)
{
	score += incrementBy;
	DisplayScore(score);
}

每隔X帧执行代码

如果代码需要频繁运行并且不能被事件触发,那其实也并不意味着每一帧都需要调用,可以每隔X帧调用一次

void Update()
{
	ExampleExpensiveFunction();
}

我们每3帧调用一次这段代码就行了,在下面的代码中,我们使用模运算来确保该函数每隔3帧才会调用一次

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
}

这种技术的另一个好处是很容易在不同的帧之间分散开销很大的代码,从而避免峰值。在下面的示例中,两个性能消耗很大的代码就可以错帧执行,非常好的方法

private int interval = 3;

void Update()
{
	if(Time.frameCount % interval == 0)
	{
		ExampleExpensiveFunction();
	}
	else if(Time.frameCount % 1 == 1)
	{
		AnotherExampleExpensiveFunction();
	}
}

缓存变量

我们通常调用 GetComponent() 来访问组件。在下面的示例中,我们在Update() 中调用GetComponent() 来获取 Renderer 组件,并将它传给ExampleFunction() 使用。这段代码可以工作,但是它的效率很低

void Update()
{
	Renderer myRenderer = GetComponent<Renderer>();
	ExampleFunction(myRenderer);
}

下面的代码只调用 GetComponent() 一次,因为结果被变量myRenderer缓存。变量可以在 Update() 中重用,而无需调用 GetComponent() 重新获取

private Renderer myRenderer;

void Start()
{
	myRenderer = GetComponent<Renderer>();
}

void Update()
{
	ExampleFunction(myRenderer);
}


对于频繁调用返回结果的函数的情况,我们应该检查代码,最好通过使用缓存来降低这些调用

使用正确的数据结构

为了正确地决定使用哪种数据结构,我们需要了解不同数据结构的优缺点,并仔细考虑我们希望代码做什么。我们可能有数千个元素需要每一帧中迭代一次,或者我们可能有少量的元素需要频繁地添加和删除。这些不同的问题最好由不同的数据结构来解决。 如果数据结构对你来说是一个新的知识领域,可以参考:数据结构

尽量减少垃圾回收的影响

垃圾回收是 Unity 管理内存的一部分。我们的代码使用内存的方式决定了垃圾收集的频率和 CPU 成本,因此理解垃圾回收是如何工作的非常重要。在下一篇文章中,我们将深入讨论垃圾回收,并提供几种不同的策略来最小化垃圾收集的影响

使用对象池

实例化和销毁一个对象通常比停用和重新激活一个对象花费更多。如果对象包含启动代码,例如在 Awake() 或 Start() 函数中调用GetComponent(),则更是如此。如果我们需要生成和处理多个相同对象,比如射击游戏中的子弹,那么使用对象池就非常有必要

对象池是一种技术,它不是创建和销毁对象的实例,而是临时停用对象,然后根据需要回收和重新激活。尽管对象池作为一种管理内存使用的技术广为人知,但作为一种减少过度使用CPU的技术,它也很有用。Unity教程上有关于在Unity中实现对象池的应用的内容

避免昂贵的Unity API的调用

有时,我们的代码对其他函数或 api 的调用可能会出乎意料性能消耗。因为有些看起来像变量的东西可能包含额外的代码、触发事件或从托管代码调用引擎代码的访问器(accessor)

在这一节中,我们将看一些 Unity API 调用的例子,这些调用的代价比看起来的要高。这些例子展示了成本的不同潜在原因,建议的解决方案可以应用于其他类似的情况

很遗憾,我无法列出哪些 Unity API 你绝对禁止调用。因为每个 API 有时候你必须使用,而且合理使用的时候开销并不会很大。在任何情况下,我们都必须仔细分析我们的游戏,找出导致开销昂贵的原因,并仔细考虑如何以最适合我们游戏的方式解决问题。

SendMessage()

SendMessage() 和 BroadcastMessage() 是非常灵活的函数,它们几乎不需要了解项目的结构,而且实现起来非常快。因此,这些函数对于原型或初学者来说非常有用。然而,它们是非常耗费性能的 API。这是因为这些函数使用了反射

建议仅将 SendMessage() 和 BroadcastMessage()用于快速出Demo的时期,并尽可能使用其他函数代替。例如,如果我们知道要在哪个组件上调用某个函数,我们应该直接引用该组件并直接调用该函数;如果我们不知道在哪个组件上调用该函数,那么可以考虑使用事件或委托

Find()

Find() 和相关的函数功能强大,但是开销也很大。这些函数需要 Unity 遍历内存中的每个 GameObject 和组件。这意味着在小项目中不需要特别关注效率,但是随着项目复杂性增加,它们的使用成本会越来越高

Transform

设置 transform 位置或旋转将导致内部 OnTransformChanged 事件传播到该 transform 的所有子节点。这意味着设置一个 transform 位置和旋转值相对比较昂贵,特别是在有许多子元素的转换中

为了限制这些内部事件的数量,我们应该避免设置这些属性的值。例如,我们不应该单独设置 transform 的 x,y,z的值。而是将 x,y,z的值放入 vector3中,然后将 vector3的值赋给 transform,这只会导致一个 OnTransformChanged 事件

如果代码经常使用Transform.position,应该用Transform.localPosition替换。这将导致更少的CPU指令,并可能最终提高性能。如果我们经常使用Transform.position,我们应该在可能的地方缓存它

Update()

Update(),LateUpdate() 和其他事件函数看起来像简单的函数,但是它们有隐藏的开销。每次调用这些函数,都需要在引擎代码和托管代码之间进行通信。除此之外,Unity在调用这些函数之前还进行了一些安全检查。安全检查确保GameObject处于有效状态,没有被销毁等等

因此,空 Update() 调用可能特别浪费。你可能猜想,因为函数是空的,并且我们的代码不包含对它的直接调用,所以空函数不会运行。事实并非如此:在幕后,即使 Update() 函数的主体为空,这些安全检查和本机调用仍然会发生。为了避免浪费CPU时间,我们应该确保项目中不包含空的Update()调用(译者注:可以写个脚本,在打包的时候执行该脚本,检测到了空Update()函数,自动删除它)

关于Update(),这篇文章带来了深入的探讨:10000 Update() calls

Vector2 和 Vector3

我们知道一些操作会导致比其他操作更多的CPU指令。向量数学操作就是一个例子:它们比浮点或整数操作更复杂。尽管两次这样的计算所花费的时间实际差异很小,但是在足够大的范围内,这样的操作可能影响性能

在数学运算中使用 Unity 的 Vector2 和 Vector3 结构是很常见和方便的,特别是在处理 transform 时。如果我们在 Update() 中的嵌套循环对大量游戏对象执行这些操作,我们很可能会给 CPU 带来不必要的工作。在这些情况下,我们可以通过执行int或float计算来节省性能

在文章的前面,我们了解到执行平方根计算所需的 CPU 指令比简单乘法所需的CPU指令要多的多。Vector2.Magnitude 和Vector3.Magnitude 就是一个例子,因为它们都涉及平方根计算。如果我们的游戏广泛而频繁地使用 magnitude 或 Distance,那么我们就有可能使用Vector2.sqrMagnitude和Vector3.sqrMagnitude代替来避免相对昂贵的平方根计算

Camera.main

Camera.main是一个方便的 Unity API ,它返回对第一个被标记为“Main Camera” 的已激活相机的引用。这是另一个类似于变量但实际上是访问器(accessor)的例子。在这种情况下,访问器在幕后调用类似Find()的函数。因此Camera.main 面临着与 Find() 相同的问题:它搜索内存中的所有游戏对象和组件,并且使用起来非常耗费性能。为了避免这种潜在的昂贵调用,我们应该缓存Camera.main的结果,或者避免使用它,并手动管理对相机的引用

其他的Unity API调用和进一步优化

我们已经考虑了一些 Unity API 调用的常见例子,它们可能会带来意想不到的代价,并且了解这种代价背后的不同原因。然而,这并不是提高Unity API调用效率的全部方法。这里有一篇关于Unity性能优化的文章,它包含了许多我们可能会发现有用的其他Unity API优化

只在需要运行时运行代码

鲁迅说过:”最快的代码是没有运行的代码”。通常,解决性能问题的最有效方法不是使用高级技术而是删除不需要的代码

裁剪

Unity 包含代码来检查对象是否在摄像机视锥体内。如果它们不在摄像机的视锥体内,则渲染这些对象相关的代码不会运行。这里的术语是视锥体剔除

我们可以对脚本中的代码采取类似的方法。如果我们有与对象的可视状态相关的代码,当玩家看不到该对象时,我们可能不需要执行此代码。在有许多对象的复杂场景中,这可以节省大量的性能

在下面的简化示例代码中,我们有一个巡逻敌人的示例。每次调用Update()时,控制这个敌人的脚本都会调用两个示例函数:一个与移动敌人有关,一个与它的可视状态有关

void Update()
{
	UpdateTransformPosition();
	UpdateAnimations();
}

在下面的代码中,我们现在检查敌人的渲染器是否在任何摄像机的视锥体内。与敌人的可视状态相关的代码仅在敌人可见时才运行

private Renderer myRenderer;

void Start()
{
    myRenderer = GetComponent<Renderer>();
}

void Update()
{
    UpdateTransformPosition();

    if (myRenderer.isVisible)
    {
        UpateAnimations();
    }
}

在玩家看不到的情况下禁用代码有几种方法。如果我们知道我们的场景中的某些对象在游戏中的某个特定点是不可见的,我们可以手动禁用它们。当我们不确定并且需要计算可见性时,我们可以使用粗略的计算(例如,检查玩家背后的对象)、OnBecameInvisible() 和 OnBecameVisible() 等函数,或者更详细的raycast。最好的实现在很大程度上取决于我们的游戏,而实验和分析是必不可少的。

LOD

LOD 是另一种常见的渲染优化技术。最接近玩家的物体使用更详细的网格和纹理以完全保真的方式渲染。远处的物体使用较少的细节网格和纹理。我们的代码也可以使用类似的方法。例如,我们可能有一个敌人的AI脚本决定了它的行为。这种行为的一部分可能涉及昂贵的操作。以确定它可以看到和听到什么,以及它应该如何对输入作出反应。我们可以使用一个详细级别的系统来启用和禁用这些昂贵的操作

总结

我们已经了解了在Unity游戏构建和运行时我们编写的代码会发生什么变化,为什么我们的代码会导致性能问题,以及如何最小化昂贵代码对游戏的影响。我们已经了解了代码中导致性能问题的一些常见原因,并考虑了一些不同的解决方案。使用这些知识和分析工具,我们现在应该能够诊断、理解和修复与游戏中的代码相关的性能问题

bookmark_border[译文] JavaScript 之执行上下文,函数调用栈,事件循环

原文地址:The JavaScript Execution Context, Call-stack & Event Loop

给你一段 JavaScript 代码,你可能会很快知道这段代码运行后的结果。但若我问你,JavaScript 引擎是如何执行代码并给出结果的? 或者问你 click handler, AJAX call 等回调函数是如何被触发的?你是不是一时语塞了

理解 执行上下文( Execution Context ) 、调用栈( Call-stack )和 事件循环( Event Loop )这三个概念并知道他们是如何协作的,你就能回答以上两个问题

执行上下文 ( Execution Context )

var a = 4;
function foo(num) {
    return num * num;
}
var b = foo(a);

很简单直白的一段代码,我们将数字 42 赋值给变量 a , 声明 foo() 方法,把变量 a 当作参数传给 foo() 方法并调用,最后把函数执行的结果赋值给变量 b 。如果我问 b 的值是多少?你会马上说出答案。那么 JavaScript 是如何得到最终结果的呢? 让我们来一起探讨这个问题吧

针对以上代码,JS 引擎要做的第一件事情就是生成运行这段代码的 执行上下文。引擎在执行代码的时候有着精确的步骤,在生成执行上下文的时候,分两个阶段,分别是:生成阶段(  creational phase )和执行阶段(  execution phase )

首先,在代码执行之前,一个全局的执行上下文( Global Execution Context )就被生成了。在生成阶段,引擎会做以下一些事情:

  • 创建一个全局实例对象,在浏览器中叫做 window , 在Node中叫做 global
  • 创建一个this对象 , this指向上一步创建的全局实例对象
  • 设置用于存储变量和函数引用的内存堆
  • 将函数声明存储在上一个的内存堆中,并将执行上下文中的每个变量赋值为undefined

在生成阶段,引擎会保存变量 a ,b 以及函数 foo 。 变量a 和 b 会被赋值为 undefined

当生成阶段结束以后,引擎会跳到执行阶段。在执行阶段,代码会从上往逐行执行。在这个阶段,变量会被赋予它真正的值,并且函数也会被调用

此时如果你的代码内没有函数被调用,那么存储到这一步就结束了。然而,每一个你调用的函数,引擎都会创建一个 函数执行上下文,这个上下文和上面我们介绍全局执行上下文基本一致,但是此时 global object的对象不是 window ,而是 arguments , 它指向外部传入到该函数的全部参数的引用

回到我们上文的代码示例,在执行阶段,引擎首先去拿已声明变量 a ,然后将42赋值给它,然后回执行到给变量 b 赋值,注意,这一行有一个方法调用,所以引擎会创建一个新的函数运行上下文,然后重复上面的步骤(不过,此时有个argumenrts 对象被创建)

但引擎是如何跟踪所有这些执行上下文呢 ? 特别是在有多个嵌套函数调用的情况下 ? 它如何知道哪一个是活动的,或者哪一个已经完全执行了? 这就要用到我们即将介绍的下一个概念:函数调用栈

函数调用栈(Call-Stack)

调用堆栈是一种数据结构,用于跟踪和管理 JavaScript 的函数执行。 它的工作是存储在代码执行过程中创建的所有执行上下文,并记录我们实际处于哪个执行上下文以及那些仍然留在堆栈上的执行上下文 。 当您调用一个函数时,引擎将该函数推到堆栈的顶部,然后创建一个执行上下文。从上面对执行上下文的研究中,我们知道这个上下文要么是全局上下文,要么是函数执行上下文

每当一个函数执行完后,函数调用栈会弹出这个函数,然后移动到下一个函数,直到栈上没有函数为止,这种顺序称为后进先出LIFO(Last in First Out)

当调用一个函数时,将创建一个堆栈帧。这是内存中存储参数和变量的位置(还记得我们在上面讨论的内存堆吗?)当函数返回(隐式或显式)时,该内存将被清除,整个上下文将从调用堆栈中弹出

执行上下文从栈中弹出一个接一个,他们与每一个创建一个完整的执行堆栈帧当我们抛出错误时,我们得到了所谓的堆栈跟踪,这就是听起来——跟踪所有的执行上下文从错误到我们已通过的所有上下文。

如果拥有的帧数超过了堆栈的设计容纳量,也可能会破坏调用堆栈。这可能发生在递归调用一个函数时,没有某种退出条件,或者我确信我们都在某个时间点做过——当运行一个无限的for循环时。

请看如下代码块:

function thirdFunc() {
    console.log("Greetings from thirdFunc()");
  }
  
  function secondFunc() {
    thirdFunc();
    console.log("Greetings from secondFunc()");
  }
  
  function firstFunc() {
    secondFunc();
    console.log("Greetings from firstFunc()");
  }
  
  firstFunc();

灵魂拷问,我们是怎么知道最后输出结果的呢?

当我们运行这段代码的时候,引擎首先启用函数调用栈,将main() 和 global() 函数压入函数调用栈 , 这就是执行JS代码的主线程,我们上文讲到的执行上下文会先进入 创建上下文阶段,然后再进入 执行上下文 阶段。 我们在前一节中描述的执行上下文将首先进入创建阶段,然后调用执行阶段。在此阶段,当引擎到达对firstFunc()的调用时,调用堆栈将再次被引用,firstFunc()的函数执行上下文将推入main()顶部的调用堆栈(下面的步骤2)

现在,引擎开始执行 firstFunc() 因为它在函数调用栈里最上面。引擎会创建一个私有执行上下文用和私有内存用来存储 变量,参数,函数声明等(作用域与这个息息相关)

firstFunc() 函数的第一行就是调用 secondFunc() ,此时,引擎会重新引用函数调用栈把 函数 secondFunc() 压入到最顶端,然后执行上面的过程。 在 secondFunc() 函数的第一行,又是调用 thirdFunc(),那么就重复上面的过程

当引擎开始执行 thirdFunc() 以后,这个函数内没有方法调用了,我们仅仅打印了一个字符串 ” Greetings from thirdFunc() “(注意:console.log()其实也是一个方法,但是我们为了简化模型,暂时把它只当作一行执行语句)。 因为函数中没有更多的语句,执行完毕以后它会隐式返回(译者注:其实所有函数都有返回,如果你没有明确它返回的值,引擎会默认返回 undefined ) ,此时,调用栈会弹出thirdFunc() (步骤4),弹出以后,secondFunc() 处于栈顶,引擎会执行 secondFunc()内剩余的语句,打印出” Greetings from secondFunc() ” , 同样执行完以后,调用栈弹出secondFunc(),如此反复,一旦调用栈为空,程序就停止运行

调用堆栈的性质反映了这样一个事实:JavaScript本质上是单线程的,一次只能运行一个执行上下文。这意味着在执行一个函数时,引擎不能同时运行另一个上下文。它还意味着,每次将一个函数推入调用堆栈时,它都会成为活动的执行上下文,并从调用它的任何函数中获取控制流,直到显式(使用return语句)或隐式(当所有指令都已执行时)返回。

如果故事到这里就结束,那么JavaScript就不可能被用于各种应用,更别提有用户输入,资源请求,API调用这些异步操作了。因为它是单线程呀,那么意味着,当我在加载一张图片的时候,是不是我就不能输入用户名和密码?当然不是,那么JavaScript是如何实现异步的呢?那就得介绍我们鼎鼎大名的事件循环机制了(event loop)

事件循环机制(The Event Loop)

从上文可知,JavaScript是单线程模式, 它从我们代码的顶部开始,向下工作,根据需要创建新的执行上下文,并将它们推入和弹出调用堆栈。 如果有一个阻塞函数需要很长时间才能执行,那么在该函数位于调用堆栈顶部期间,浏览器将无法执行任何操作。不能发生新的执行上下文或代码执行。这意味着即使是像滚动和按钮单击事件这样的用户输入也不能工作。

相反,当我们有一个可能需要很长时间才能完成的函数时,我们通常会提供一个回调函数。这个函数封装了我们希望在稍后的阻塞操作(e。网络呼叫)已经被解决。这允许我们将控制权返回给JS引擎,并将其余的执行推迟到调用堆栈被清除之后。这就是JavaScript中的异步概念。

调整一下代码,看看异步是怎么执行的:

function thirdFunc() {
  setTimeout(function() {
    console.log("Greetings from thirdFunc()");
  }, 5000);
}

function secondFunc() {
  thirdFunc();
  console.log("Greetings from secondFunc()");
}

function firstFunc() {
  secondFunc();
  console.log("Greetings from firstFunc()");
}

firstFunc();

在上面的代码中,执行与前面的示例一样开始。但是,当引擎到达第三个函数时,它不是立即将消息记录到控制台,而是调用setTimeout(),这是浏览器环境提供给我们的一个API。这个函数接受一个“回调”函数,该函数将存储在一个我们尚未讨论的结构中,称为回调队列。然后thirdFunc()将完成它的执行,依次将控制权返回给secondFunc()和firstFunc()。最后,在至少5秒之后(后面会详细介绍),来自thirdFunc()的消息将被记录到控制台。

在JavaScript中,我们实现代码异步执行的机制是通过环境api (Node和浏览器都提供某些api,向我们公开底层特性)、回调队列和事件循环来实现的。 并发性(或它的假象)是通过这些额外的机制实现的。

正如我们所说的,调用堆栈用于跟踪当前执行的函数上下文,回调队列跟踪需要在以后运行的任何执行上下文。例如传递给setTimeout函数或节点异步任务的回调。在调用代码时,事件循环会定期检查调用堆栈是否为空。一旦调用堆栈在我们的代码中运行了所有的执行上下文,事件循环将获取进入回调队列的第一个函数,并将其放在要执行的调用堆栈上。然后再次重复该过程,不断地检查调用堆栈和回调队列,并在调用堆栈为空时将函数从回调队列传递到调用堆栈。

还记得我们说过setTimeout回调将从调用setTimeout开始“至少”运行5秒吗?这是因为setTimeout并不只是在超时完成时将其代码插入调用堆栈,它必须将其传递给回调队列,然后等待事件循环在调用堆栈为空时将其放入调用堆栈。只要调用堆栈中还有项,就不会运行setTimeout回调。让我们仔细看看这个

我们的代码按上面的方式运行,直到到达第三个函数为止,此时setTimeout被调用,从调用堆栈中取出并开始倒计时。我们的代码继续到secondFunc和firstFunc和console。依次记录他们的消息。与此同时,setTimeout几乎立即(在0秒内)完成了它的倒计时,但是没有办法让它的回调直接进入调用堆栈。相反,当它完成倒计时时,它将回调传递给回调队列。事件循环不断检查调用堆栈,但在此期间secondFunc和firstFunc占用了调用堆栈上的空间。直到这两个函数完成执行并清空调用堆栈后,事件循环才接受传递给setTimeout的回调函数,并将其放在要执行的调用堆栈上

这就是为什么有时您会发现使用0调用setTimeout的模式是一种延迟执行传递给它的回调中的代码的方法。我们只是想确保所有其他同步代码在setTimeout回调中的代码之前运行。

还需要注意的是,“回调”是由另一个函数调用的函数,但是我们在上面讨论的回调,例如传递给setTimeout的回调是“异步回调”。区别在于,异步回调被传递到回调队列,等待被(通过事件循环)放到调用堆栈上,以便稍后执行

至此,我们已经介绍了JavaScript代码执行的主要概念,以及JavaScript引擎如何处理异步代码。我们已经看到JS引擎是单线程的,只能同步执行代码。我们还看到了在不阻塞执行线程的情况下实现异步代码的机制。我们还可以更好地理解函数执行的顺序和围绕这个过程的规则。

这些概念很容易理解,但是值得花时间真正掌握它们,因为它们是深入了解JavaScript的基础。不仅仅是var a = 2语法,而是一个完整的视图,关于JavaScript采用这种语法并运行时到底发生了什么。这些概念还可以作为更好地理解其他概念(如作用域和闭包)的基础。