Unity性能调优手册2基础:硬件,渲染,数据,Unity如何工作,C#基础,算法和计算复杂度

2023-11-19 08:49:28 浏览数 (1)

翻译自https://github.com/CyberAgentGameEntertainment/UnityPerformanceTuningBible/

性能调优需要对整个应用程序进行检查和修改。因此,有效的性能调整需要广泛的知识,从硬件到3D渲染再到Unity机制。因此,本章总结了执行性能调优所需的基本知识

硬件

计算机硬件由五个主要设备组成:输入设备、输出设备、存储设备、计算设备和控制设备。这些被称为计算机的五大设备。本节总结了这些硬件设备的基本知识,它们对性能调优很重要。

SoC

计算机是由各种设备组成的。典型的设备包括用于控制和计算的cpu、用于图形计算的gpu和用于处理音频和视频数字数据的dsp。在大多数台式个人电脑和其他设备中,它们作为独立的集成电路,它们组合在一起形成计算机。另一方面,在智能手机中,这些设备是在单个芯片上实现的,以减少尺寸和功耗。这被称为system-on-a-chip片上系统(SoC)。

iPhone, Android和SoC

智能手机中使用的SoC因型号而异。

例如,iPhone使用的是苹果公司设计的a系列SoC。这个系列是由字母“A”和数字(如A15)组合而成的,随着版本的升级,数字会越来越大。

相比之下,许多安卓设备使用的是一种名为骁龙的SoC。这个SoC是由一家名为高通的公司制造的,被命名为Snapdragon 8 Gen 1或Snapdragon 888。

此外,iphone是苹果公司生产的,而安卓则是由各种各样的制造商生产的。出于这个原因,Android有多种选择

在调优性能时,重要的是要了解设备的SoC和它有什么规格。

Tips

骁龙的命名一直是字符串“Snapdragon”和三位数字的组合。

这些数字是有意义的:800是旗舰机型,用于所谓的高端设备。号码越低,性能和价格就越低,400就是所谓的低端手机。

即使是400的设备,性能也会随着更新的发布日期而提高,所以很难做出一个笼统的说法,但基本上,数字越高,性能越高。

此外,它在2021年宣布,命名惯例将在未来更改为类似Snapdragon 8 Gen 1的东西,因为这种命名惯例很快就会用完。

在调优性能时,记住这些命名约定非常有用,因为它们可以用作确定设备性能的指标

CPU

Central Processing Unit中央处理器中央处理器是计算机的大脑,不仅负责执行程序,而且还负责与计算机的各种硬件部件进行接口。在实际调优性能时,了解CPU中执行了哪些处理以及它具有哪些特征是很有用的,因此我们将从性能的角度对其进行解释。

CPU的基本知识

决定程序执行速度的不仅仅是简单的运算能力,还有它执行复杂程序步骤的速度。例如,在一个程序中有四个算术运算,但也有分支操作。对于CPU来说,在执行程序之前,它不知道下一条指令将被调用。因此,CPU的硬件被设计成能够快速连续处理各种指令。

CPU内部的指令流称为管道,在处理指令的同时预测管道中的下一条指令。如果没有预测到下一条指令,则会发生称为管道失速的暂停,并重置管道。大多数摊位是由分支引起的。尽管分支本身在某种程度上预测了结果,但仍然可能犯错误。尽管在不记住内部结构的情况下也可以进行性能调优,但了解这些内容将有助于您在编写代码时更加了解如何避免循环分支。

CPU计算能力

CPU的计算能力由时钟频率(Hz)和内核数决定。时钟频率表示CPU每秒可以运行多少次。因此,时钟频率越高,程序执行速度越快。

另一方面,内核的数量决定了CPU的并行计算能力。核心是CPU运行的基本单元,当有多个核心时,它被称为多核。最初,只有单核,但在单核的情况下,为了运行多个程序,要交替运行的程序被切换。这被称为上下文切换,它的成本非常高。如果您习惯使用智能手机,您可能会认为总是有一个应用程序(进程)在运行,但实际上有许多不同的进程并行运行

近年来,非对称核(big.LITTLE) cpu已成为多核处理器(尤其是智能手机)的主流。非对称核是指同时具有高性能核和节能核的cpu。

非对称核的优点是,通常只有省电的核被用来节省电池电量,当性能需要时,比如在游戏中,核可以切换。但是需要注意的是,最大并行性能会因为核数的减少而降低,因此不能仅用核数来判断非对称核的性能

一个程序能否用完多个核还取决于该程序的并行处理描述。例如,在某些情况下,游戏引擎通过在单独的线程中运行来简化物理引擎,或者通过Unity的JobSystem使用并行处理等。由于游戏主循环本身的操作不能并行化,所以即使使用多个核心,核心本身的更高性能也是有利的。因此,拥有一个高性能内核本身是有利的,即使它是多核的。

CPU缓存

CPU和主存在物理上相距很远,并且需要一小部分时间(延迟)来访问。因此,当试图在程序执行期间访问存储在主存中的数据时,这个距离成为一个主要的性能瓶颈。为了解决这个延迟问题,在CPU内部安装了一个缓存内存。高速缓存主要是将一部分数据存储在主存中,以便硬件程序可以快速访问它们需要的数据。cache有L1、L2、L3三种类型。数字越小,速度越快,但容量越小。数字越小,缓存速度越快,但容量越小。因此,CPU缓存不能存储所有数据,只能存储最近处理的数据。

因此,提高程序性能的关键是如何有效地将数据放入缓存中。由于缓存不能由程序自由控制,因此数据的局部性很重要。在游戏引擎中,很难通过数据局部性来管理内存,但有些机制,如Unity的JobSystem,可以通过增强数据局部性来实现内存放置。

GPU

cpu专门执行程序,GPU(图形处理单元)是专门用于图像处理和图形渲染的硬件。

GPU基础知识

gpu是专门用于图形处理的,所以它的结构与cpu有很大的不同,它是为了并行处理大量的简单计算而设计的。例如,如果要将图像转换为黑白,CPU必须从内存中读取某些坐标的RGB值,将其转换为灰度,然后逐像素返回到内存中。由于这种过程不涉及任何分支,并且每个像素的计算不依赖于其他像素的结果,因此很容易并行执行每个像素的计算。

因此,gpu可以实现并行处理,对大量数据进行高速的相同操作,从而实现图形处理的高速。特别是图形处理需要大量的浮点运算,而gpu特别擅长浮点运算。出于这个原因,通常使用称为FLOPS的性能指数,它衡量每秒的浮点操作次数。由于仅从计算能力方面难以理解性能,因此还使用了一个称为填充率的指标,该指标表示每秒可以绘制多少像素。

GPU运算容量

GPU硬件的特点是包含整数和浮点算术单元的大量内核(数十到数千个)。为了部署大量的核心,运行cpu所必需的复杂程序所需的单元已经被消除,因为它们不再需要。此外,与cpu一样,它们运行时的时钟频率越高,可以执行的操作就越多每秒执行的数。

GPU内存

当然,gpu也需要内存空间作为临时存储来处理数据。通常,这个区域是专用于GPU的,不像主存。因此,要执行任何类型的处理,数据必须从主存储器传输到GPU存储器。处理后,数据以相反的顺序返回主存。请注意,如果要传输的数据量很大,例如传输多个高分辨率纹理,则传输需要花费时间并成为处理瓶颈。

然而,在移动设备中,主内存通常是在CPU和GPU,而不是专用于GPU。虽然这样做的优点是可以动态改变GPU的内存容量,但缺点是需要在CPU和GPU之间共享传输带宽。在这种情况下,数据仍然必须在CPU和GPU内存区域之间传输。

Tips

GPGPU

gpu可以以很高的速度对大量数据进行并行运算,这是cpu所不擅长的。GPGPU(General Purpose GPU) GPGPU,通用GPU。特别是gpu用于AI等机器学习和区块链等计算处理的情况很多,导致gpu需求大幅增加,导致价格上涨等影响。GPGPU也可以在Unity中使用一个叫做Compute Shader的功能。

Memory

基本上,所有数据都保存在主存中,因为CPU当时只保存计算所需的数据。由于不可能使用比物理容量更多的内存,如果使用太多,则无法分配内存,并且操作系统会强制进程终止。这通常被称为OOM(输出)内存不足)这通常被称为OOM,即内存不足杀手。截至2022年,大多数智能手机都配备了4- 8gb的内存容量。即便如此,您也应该注意不要使用过多的内存。

此外,如上所述,由于内存与CPU是分离的,因此性能本身将取决于是否使用内存感知实现。

在本节中,我们将解释程序和内存之间的关系,以便执行性能敏感的实现。

存储硬件

虽然由于物理距离的原因,将主存储器放在SoC内部是有利的,但内存不包括在SoC中。这是有原因的,例如,如果安装的内存量包含在SoC中,则不能从设备更改到设备。但是,如果主存很慢,它将明显影响程序的执行速度,因此使用相对较快的总线来连接SoC和内存。智能手机中常用的内存和总线标准是LPDDR,即LPDDR标准。LPDDR有好几代,但理论传输速率都是几Gbps。当然,并不总是有可能达到理论性能,但在游戏开发中,这很少是一个瓶颈,所以没有必要意识到这一点。

内存和操作系统

在一个操作系统中,有许多进程同时运行,主要是系统进程和用户进程。系统进程在操作系统的运行中起着重要的作用,它们中的大多数作为服务驻留在操作系统中,并且不管用户是否有意继续运行。另一方面,用户进程是由用户启动的进程,不是操作系统运行所必需的。

智能手机上的应用程序有两种显示状态:前台(最显眼)和后台(隐藏)。通常,当一个特定的应用程序在前台时,其他应用程序在后台。当应用程序在后台运行时,进程处于挂起状态,以方便返回进程,内存保持原样。但是,当整个系统使用的内存不足时,根据操作系统确定的优先级顺序杀死进程。此时,最有可能被关闭的是在后台使用大量内存的用户应用程序(≒games)。换句话说,使用大量内存的游戏在被移到后台时更有可能被杀死,从而导致用户在返回游戏并不得不从头开始时产生更糟糕的体验。

如果在它尝试分配内存时没有其他进程可以终止,它将自己终止。在某些情况下,例如iOS,它是受控制的,因此单个进程不能使用超过一定百分比的物理内存。因此,可以分配的内存量是有限制的。到2022年,拥有3GB内存的iOS设备的限制将是1.3~1.4GB,所以这可能是制作游戏的上限。

内存交换

实际上,有许多不同的硬件设备,其中一些具有非常小的物理内存容量。为了在这些终端上运行尽可能多的进程,操作系统尝试以各种方式保护虚拟内存容量。这是内存交换。

内存交换中使用的一种方法是内存压缩。物理容量通过压缩和存储在内存中来节省,主要是在一段时间内不会被访问的内存。但是,由于压缩和解压缩成本的原因,不会对正在使用的区域进行压缩,而是对已经进入后台的应用程序进行压缩。

另一种技术是节省未使用内存的存储。在有充足存储空间的硬件上,比如PC,它不是终止进程来释放内存,而是尝试通过将未使用的内存保存到存储器中来释放物理内存。这样做的优点是可以确保比内存压缩更大的内存,但由于存储比内存慢,所以没有使用它,因此存在很强的性能限制,并且对于智能手机来说不太现实,因为智能手机一开始就具有较小的存储大小。

堆和栈Stack and Heap

堆栈和堆您可能至少听过一次“堆栈”和“堆”这两个术语。堆栈实际上是一个专用的固定区域,与程序的操作密切相关。当调用函数时,将为参数和局部变量分配堆栈,当函数返回到原始函数时,将释放堆栈并累积返回值。换句话说,当在下一个函数中调用下一个函数时,当前函数的信息保持原样,并将下一个函数加载到内存中。这样就实现了函数调用机制。堆栈内存取决于体系结构,但由于容量本身非常小(1 MB),因此仅存储有限数量的数据。

另一方面,堆是一个可以在程序中自由使用的内存区域。只要程序需要,它就可以发出内存分配指令(C语言中的malloc)来分配和使用大量数据。当然,当程序使用完内存后,它需要释放内存(free)。在c#中,内存分配和释放是在运行时自动执行的,因此实现者不需要显式地执行这些操作。

由于操作系统不知道何时以及需要多少内存,因此它在需要时从空闲空间中分配内存。如果在尝试分配内存时不能连续地分配内存,则假定内存不足。“连续”这个关键词很重要。通常,重复的内存分配和释放会导致内存碎片的发生。当内存被分片时,即使总空闲空间足够,也可能没有连续的空闲空间。在这种情况下,操作系统将首先尝试对堆进行Heap扩展。换句话说,它为进程分配新的内存,从而确保了连续的空间。然而,由于整个系统的内存有限,如果没有更多的内存可以分配,操作系统将终止进程。

在比较堆栈和堆时,内存分配性能有明显的差异。这是因为函数所需的堆栈内存量是在编译时确定的,因此已经分配了内存区域,而堆在执行之前不知道所需的内存量,因此堆通过每次在空闲区域中搜索来分配内存。这就是堆慢而栈快的原因。

Tips

Stack Overflow Error栈溢出错误

当由于对函数的递归调用而耗尽堆栈内存时,会发生堆栈溢出错误。iOS/Android的默认堆栈大小为1MB,因此当递归调用的大小增加时,更有可能发生此错误。一般来说,可以通过更改算法来防止此错误不会导致递归调用,或者通过更改不允许递归调用变得太深的算法。

译者增加部分

unity中监听内存报警API,Application.lowMemory,在回调中执行GC.Collect,与Resources.UnloadUnusedAssets()

存储

在实际进行调优时,您可能会注意到读取文件通常需要很长时间。读取文件意味着从存储文件的存储器中读取数据并将其写入内存,以便程序可以对其进行处理。了解实际发生的情况在调优时非常有用。

首先,典型的硬件体系结构将为持久数据提供专用存储。存储的特点是容量大,并且能够在没有电源(非易失性)的情况下持久化数据。利用这个特性,大量的资产以及应用程序本身的程序都存储在存储中,并且从存储中加载并在启动时执行。

Tips

RAM 与 ROM

ROM实际上指的是只读存储器

然而,从几个角度来看,与程序执行周期相比,读写该存储的过程非常缓慢。

•与CPU的物理距离大于与内存的物理距离,导致时延大,读写速度慢。

•有很多浪费,因为读取是在块单元中完成的,包括命令数据及其周围。

•顺序读写速度快,随机读写速度慢

随机读/写速度慢这一事实尤为重要。首先,顺序读/写和随机读/写是顺序的,当一个文件按照从文件开头开始的顺序读/写时。但是,当读取/写入单个文件的多个部分或同时读取/写入多个小文件时,它是随机的。如果你读/写一个文件的多个部分,或者读/写多个小文件,它将是随机的。重要的是要注意,即使在同一目录中读/写多个文件,它们也可能不是连续的物理位置,所以如果它们在物理上相距很远,它们将被随机化

Tips

从存储器中读取的过程

当从存储器中读取文件时,省略了细节,但该过程大致遵循以下流程。

1.程序命令存储控制器从存储器中读取文件的区域。2.存储控制器接收命令3.并计算数据所在物理上要读取的面积。4.读取数据5.将数据写入内存。

根据硬件和体系结构的不同,也可能有更多的层,例如控制器。没有必要准确地记住它们,但要注意,与从内存中读取相比,有更多的硬件处理步骤。

此外,典型的存储通过在4KB左右的块中写入单个文件来实现性能和空间效率。这些块在物理上不一定是连续的,即使它们是单个文件。文件物理分布的状态称为碎片(fragmentation),消除碎片的操作称为碎片整理(defragment)。硬盘是pc的主要设备,而碎片化常常是硬盘的一个问题,随着闪存的出现,这个问题几乎消失了。虽然我们不需要意识到智能手机中的文件碎片,但在考虑pc时,意识到这一点很重要。

Tips

个人电脑和智能手机的存储类型

在PC领域,hdd和ssd是最常见的存储类型;你可能以前没有见过hdd,但它们是以磁盘形式记录的媒体,就像cd一样,磁头在磁盘上移动以读取磁性。因此,它是一个结构上很大的设备,并且由于涉及到物理移动而具有高延迟。近年来,固态硬盘开始流行起来。与hdd不同,ssd不产生物理移动,因此提供高速性能,但另一方面,它们对读/写周期(寿命)的数量有限制,因此当频繁的读/写周期发生时,它们变得不可用。虽然智能手机与固态硬盘不同,但它们使用的是一种名为NAND的闪存。

最后,智能手机的实际读写速度有多快?到2022年,读取速度估计约为100mb /s。如果要读取一个10mb的文件,即使在理想条件下,读取整个文件也需要100 ms。此外,如果要读取多个小文件,将发生随机访问,使读取过程更加缓慢。因此,最好知道读取一个文件实际上要花很长时间。至于各个终端的具体性能,您可以参考*1,这是一个收集基准测试结果的站点。最后,总结一下,在读写文件时,了解以下几点是一个好主意

•存储器的读/写速度出奇地慢,不要期望与内存相同的速度

•尽可能减少要同时读/写的文件数量(例如,分配时间,将文件合并到单个文件中等)

译者增加部分

可以使用分帧加载Assetbundle

Rendering渲染

在游戏中,渲染工作负载通常会对性能产生负面影响。因此,关于呈现的知识对于性能调优至关重要。因此,该渲染部分总结了渲染的基本原理。

渲染管线

在计算机图形学中,对三维模型的顶点坐标、灯光的坐标和颜色等数据进行一系列处理,最终输出要输出到屏幕上每个像素的颜色。这种处理机制被称为呈现管道。

渲染管道从CPU向GPU发送必要的数据开始。这些数据包括要渲染的3D模型顶点的坐标、灯光的坐标、物体的材质信息、相机信息等等。

此时,发送的数据是3D模型顶点的坐标、相机坐标、方向、视角等,每一个都是单独的数据。GPU对这些信息进行编译,并计算出物体在被摄像头观看时出现在屏幕上的位置。这个过程叫做坐标变换。

一旦确定了物体在屏幕上的位置,下一步就是确定物体的颜色。然后GPU通过询问“当光线照射时,屏幕上相应的像素将是什么颜色”来计算对象的颜色

在上述过程中,“物体将出现在屏幕上的位置”由顶点着色器决定,“屏幕上每个像素对应的区域的颜色”由一个名为片段着色器的程序计算,“屏幕上每个像素对应的部分将是什么颜色”由一个名为片段着色器的程序计算。

这些着色器可以自由编写。因此,在顶点着色器和片段着色器中编写繁重的处理将增加处理负载。

此外,顶点着色器处理3D模型中的顶点数量,所以顶点越多,处理负载就越大。片段着色器会随着渲染像素的增加而增加处理负荷。

Tips

实际渲染管道

在实际的渲染管道中,除了顶点着色器和片段着色器之外,还有许多其他的过程,但由于本文档的目的是理解性能调优所需的概念,我们将只给出一个简短的描述。

译者增加部分

【腾讯文档】渲染

https://docs.qq.com/doc/DWmpjSVBNWlpTZnFx

半透明与overdraw

渲染时,对象的透明度是一个重要的问题。例如,考虑两个物体在从相机观看时部分重叠。

首先,考虑这两个对象都不透明的情况。在这种情况下,首先绘制相机前面的物体。这样,在绘制后面的对象时,对象中由于与前面的对象重叠而不可见的部分不需要处理。这意味着片段着色器操作可以在此区域跳过,从而优化处理负载

另一方面,如果两个对象都是半透明的,那么如果后面的对象无法通过前面的对象看到,即使它与前面的对象重叠,也是不自然的。

在这种情况下,从相机看到的后面的物体开始绘制过程,重叠区域的颜色与已经绘制的颜色混合。

与不透明渲染不同,半透明渲染需要渲染重叠的对象。如果有两个半透明的对象填充整个屏幕,则整个屏幕将被处理两次。因此,在彼此的顶部绘制半透明的物体称为透支(overdraw)。过多的透支会给GPU带来沉重的处理负荷,导致性能下降,所以在绘制半透明对象时,有必要设置适当的规则。

Tips

前向渲染forward rendering

有几种方法可以实现呈现管道。其中,本节中的描述假定为前向渲染。有些点可能部分不适用于其他呈现渲染,如延迟渲染deferred rendering。

译者增加部分

【腾讯文档】前向渲染和延迟渲染

https://docs.qq.com/doc/DWnRBeFNSR0lGeFh2

Draw calls, set-pass calls, and batching

渲染不仅需要GPU的处理负载,还需要CPU的处理负载。

如上所述,当渲染对象时,CPU向GPU发送命令进行绘制。这被称为绘制调用DrawCall,执行的次数与要渲染的对象的数量一样多。

此时,如果纹理或其他信息与之前绘制调用中渲染的对象不同,则CPU将纹理或其他信息设置给GPU。这是使用set-pass calls调用完成的,是一个相对繁重的过程。由于此过程是在CPU的渲染线程上完成的,因此它是CPU的处理负载,并且过多会影响性能。

Unity有一个减少绘制调用的功能,称为绘制调用批处理draw call batching,以减少绘制调用。这是一种机制,即具有相同纹理和其他信息(即相同材质)的对象网格在cpu端预先处理并通过单个绘制调用进行组合。Dynamic batching动态批处理和合并网格是提前创建的。Static batching静态批处理预先创建一个组合网格。

也有一个Scriptable Render Pipeline可编程渲染管道,也有一个SRP Batcher批处理机制。使用这种机制,set-pass calls可以合并成一个单独的调用,即使网格和材质是不同的,只要着色器变体是相同的。这种机制并没有减少DrawCall的数量,但它确实减少了set-pass calls,因为这些调用的处理成本最高

Tips

GPU Instancing

一个与批处理有类似效果的特性是GPU实例化。这个函数使用GPU的能力,在一个单一的绘制调用或设置路径调用中绘制具有相同网格的对象。

译者增加部分

【腾讯文档】静态、动态合批与GPUInstancing

https://docs.qq.com/doc/DWm1Ib25MZEFHQW9y

数据

游戏使用各种各样的数据,包括图像、3D模型、音频和动画。了解如何将这些数据表示为数字数据对于计算内存和存储容量以及正确配置压缩等设置非常重要。本节总结了基本的数据表示方法。

bit位与byte字节

计算机所能表示的最小单位是位。一个位可以表示单个二进制数字所能表示的数据范围,即0和1的组合

  1. 这只能表示简单的信息,例如打开和关闭开关。

如果我们使用两位,我们可以表示二进制数的两位所能表示的范围,换句话说,就是四种组合。由于有四种组合,因此可以表示,例如,按下了哪个键:上、下、左或右。

同样,8位可以表示8位二进制数字的范围,即2种方式^ 8位数字= 256种方式。此时,似乎可以表达各种各样的信息。这些8位以1字节为单位表示。换句话说,一个字节是一个单位,可以表达256个不同数量的信息。

还有一些单位表示更大的数字,例如千字节(KB)表示1,024字节,兆字节(MB)表示1,024KB

译者增加部分

1byte = 8bit

1kb = 1024byte

1m = 1024kb

常见位运算使用

https://docs.qq.com/s/gLZfMXgJ5-y3jazFdHsriq

Image

图像数据表示为一组像素。例如,一个8 × 8像素的图像由总共8 × 8 = 64个像素组成。

在这种情况下,每个像素都有自己的颜色数据。那么颜色是如何在数字数据中表现出来的呢?

首先,颜色是由四个元素组合而成的:红(red)、绿(green)、蓝(blue)和透明度(Alpha)。这些通道称为通道,每个通道由首字母RGBA表示。

在常用的颜色表示方法True Color中,每个RGBA值用256步表示。如前一节所述,256步意味着8位。换句话说,真彩色可以用4通道× 8位= 32位信息来表示。

因此,例如,一个8 × 8像素的真彩色图像具有8像素× 8像素× 4通道× 8位= 2,048位= 256字节。对于1024 × 1024像素的真彩色图像,其信息内容为1024像素× 1024像素× 4通道×

8位= 33,554,432位= 4,194,304字节= 4,096千字节= 4兆字节。

译者增加部分

unity取色板中颜色值为0-255

在这里插入图片描述

图像压缩

在实践中,图像通常用作压缩数据。

压缩是通过设计一种存储数据的方法来减少数据量的过程。例如,假设有五个像素彼此相邻,颜色相同。在这种情况下,与其每个像素有五个颜色信息,不如有一个颜色信息和一行有五个像素的信息,这样可以减少信息量。

实际上,还有许多更复杂的压缩方法。

作为一个具体的例子,让我们介绍一种典型的移动压缩格式——ASTC。应用ASTC6x6格式,1024x1024纹理从4兆字节压缩到约0.46兆字节。换句话说,结果是容量被压缩到不到原始大小的八分之一,我们可以认识到压缩的重要性。

作为参考,下面描述了主要用于移动设备的ASTC格式的压缩比。

在Unity中,可以使用纹理导入设置为每个平台指定各种压缩方法。因此,通常导入未压缩的图像并根据导入设置应用压缩以生成要使用的最终纹理。

Tips

gpu和压缩格式

当然,根据某个规则压缩的图像必须根据该规则进行解压缩。这个解压缩在运行时完成。为了最小化这个处理负载,使用GPU支持的压缩格式是很重要的。ASTC是一个典型的压缩格式

支持移动设备上的gpu。

译者增加部分

【腾讯文档】unity图片压缩格式

https://docs.qq.com/doc/DWlBMQUdrUHBCaEZV

Mesh

在3DCG中,一个三维形状是通过连接多个三角形来表示的3 d空间。这个三角形的集合叫做网格。

三角形可以表示为中三个点的坐标信息3 d空间。这些点中的每一个被称为一个顶点,它的坐标被称为顶点坐标,它们的坐标被称为顶点坐标。每个网格的所有顶点信息都存储在一个数组中。

由于顶点信息存储在单个数组中,我们需要额外的信息来指示哪些顶点将被组合成一个三角形。这被称为顶点索引,并表示为int类型的数组,该数组表示顶点信息数组的索引。

纹理和照明对象需要额外的信息。例如,映射纹理需要UV坐标。光照还需要顶点颜色、法线和切线等信息

下表总结了主要顶点信息和每个顶点的信息量。

确定顶点的数量和顶点信息的类型是很重要的。因为网格数据随着顶点数量的增加和单个顶点处理的信息量的增加而增长,所以需要提前进行基础知识的学习。

译者增加部分

unity 中cube由24个顶点构成

【腾讯文档】cube24顶点

https://docs.qq.com/doc/DWnVEa0VNQlhna2RT

对模型用不到Tangent属性可以不导入

【腾讯文档】资源导入标准

https://docs.qq.com/doc/DWmxoSmNmeVZ0ZXpz

关键帧动画

游戏在许多领域使用动画,如UI动画和3D模型运动。关键帧动画是实现动画的最常见方法之一。

关键帧动画由一组表示特定时间(关键帧)值的数据组成。关键帧之间的值是通过插值获得的,可以当作平滑的连续数据来处理。

除了时间和价值,关键帧还有其他信息,比如切线和它们的权重。利用这些方法进行插值计算,可以用更少的数据实现更复杂的动画。

在关键帧动画中,关键帧越多,动画就越复杂。然而,数据量也随着关键帧的数量而增加。出于这个原因,关键帧的数量应该适当设置

有一些方法可以通过减少关键帧的数量来压缩数据量,同时保持曲线尽可能相似。在Unity中,关键帧可以在模型导入设置中减少,如下图所示

译者增加部分

【腾讯文档】AnimationClip内存优化

https://docs.qq.com/doc/DWldCSHdPR2tFQ3pS

Unity如何工作

理解Unity引擎的工作原理对于调整你的游戏非常重要。本节解释了你应该知道的Unity的操作原理。

二进制与Runtime

首先,这一节解释了Unity实际是如何工作的以及运行时是如何工作的。

C#与Runtime

当开发者在Unity中创造游戏时,他们会使用c#去编程行为。c#是一种编译语言,因为在Unity中开发游戏时它经常被编译(构建)。

然而,c#与传统的C和其他语言的不同之处在于,它不是一种可以在机器上自行编译和执行的机器语言,而是一种中间语言;后面IL转换为的可执行代码IL被称为“可执行代码”。由于转换为IL的可执行代码本身不能执行,因此在将其转换为机器语言时使用。

中断一次IL的原因是,一旦转换为机器语言,二进制文件只能在单个平台上执行。使用IL,任何平台都可以通过简单地为该平台准备运行时来运行,从而消除了为每个平台准备二进制文件的需要。因此,Unity的基本原理是将编译源代码获得的IL在相应环境的运行时上执行,从而实现多平台兼容性。

Tips

检查IL代码

通常很少看到的IL代码对于了解诸如内存分配和执行速度之类的性能非常重要。例如,对于同一个foreach循环,数组和列表乍一看会输出不同的IL代码,数组是性能更好的代码。您还可能发现意外的隐藏堆分配。为了了解c#和IL代码之间的对应关系,建议定期检查您编写的c#代码的IL转换结果。您可以在诸如Visual Studio或Rider之类的ide中查看IL代码,但IL代码本身是一种难以理解的语言,因为它是一种称为汇编的低级语言。在这种情况下,你可以使用一个名为SharpLab *2的web服务来检查c# ->IL→c#,反之亦然,这样更容易理解IL代码。

*2 https://sharplab.io/

IL2CPP

如上所述,Unity基本上将c#编译成IL代码并在运行时运行,但从2015年左右开始,一些环境开始出现问题。这是对运行在iOS和Android上的应用程序的64位支持。如上所述,c#需要在每个环境中运行一个运行时来执行IL代码。事实上,在那之前,Unity实际上是一个长期存在的OSS实现。Mono . NET Framework的OSS实现,Unity自己修改了它以供自己使用。换句话说,为了使Unity兼容64位,有必要使分叉的Mono兼容64位。当然,这将需要大量的工作,所以Unity决定使用IL2CPP。Unity通过开发一种名为IL2CPP的技术来克服这一挑战。

IL2CPP顾名思义就是IL到CPP,一种将IL代码转换为c 代码的技术。由于c 是一种高度通用的语言,在任何开发环境中都是本地支持的,因此一旦输出到c 代码,它就可以在每个开发工具链中编译成机器语言。因此,64位支持是工具链的工作,Unity不需要处理它。与c#不同,c 代码在构建时被编译成机器语言,从而消除了在运行时将其转换为机器语言的需要,并提高了性能。

尽管c 代码的缺点是需要花很长时间来构建,但IL2CPP技术已经成为Unity的基石,一举解决64位兼容性和性能问题。

译者增加部分

【腾讯文档】Mono,IL,Unity跨平台,托管,IL2CPP

https://docs.qq.com/doc/DWnZGZHRmTGV4cmdC

Unity Runtime

顺便说一下,尽管Unity允许开发者用c#编程游戏,但Unity本身的运行时(即引擎)并不是在c#中运行的。源代码本身是用c 编写的,称为播放器的部分是预先构建的,可以在每个环境中运行。Unity用c 编写引擎有几个可能的原因

•获得快速和节省内存的性能

•支持尽可能多的平台

•保护引擎(黑盒子)的知识产权

由于开发人员编写的c#代码在c#中运行,Unity需要两个部分:引擎部分(本机运行)和用户代码部分(在c#运行时运行)。引擎和用户代码通过在执行过程中根据需要交换数据来工作。例如,当GameObject.transform是从c#中调用的,所有游戏执行状态(如场景状态)都是在引擎中管理的,所以首先要进行本地调用以访问本地区域中的内存数据,然后将值返回给c#。需要注意的是,内存不是在c#和本机之间共享的,所以c#需要的数据每次都在c#端分配。API调用也很昂贵,会发生本机调用,因此需要一种不需要频繁调用的缓存值的优化技术。

因此,在开发Unity时,有必要在一定程度上意识到不可见的引擎部分。出于这个原因,查看原生Unity引擎和c#之间接口的源代码是个好主意。幸运的是,Unity已经在GitHub *3上提供了c#部分的源代码,所以你可以看到它主要是本机调用,这非常有帮助。我建议在必要时使用它。

*3 https://github.com/Unity-Technologies/UnityCsReference

Asset实体

正如前一节所解释的,因为Unity引擎是本机运行的,所以它基本上没有c#方面的数据。对于资产的处理也是如此:在原生区域中加载资产,并且只将引用返回给c#,或者复制并返回数据。因此,加载资源有两种主要方式:通过指定路径在Unity引擎端加载它们,或者通过将原始数据(如字节数组)直接传递给引擎。如果指定了路径,c#端不会消耗内存,因为它是在本机区域加载的。然而,如果像字节数组这样的数据是从c#端加载和处理的,并传递给c#端,那么c#端和本机端都会双重消耗内存。

此外,由于资产实体是在本机,调查多重资产负载和泄漏的难度增加。这是因为开发人员主要关注c#方面的分析和调试。单独理解c#端执行状态是很困难的,有必要将其与引擎端执行状态进行比较分析。原生区域的分析依赖于Unity提供的API,这限制了可用的工具。我们将在本文档中介绍使用各种工具进行分析的方法,但如果你知道c#和本机之间的区别,就会更容易理解。

译者增加部分

不要使用AssetBundle.LoadFromMemory

【腾讯文档】AssetBundle的原理及最佳实践

https://docs.qq.com/doc/DWlhFU2xqWElxb21n

Threads线程

线程是程序执行的一个单位,处理通常通过在单个进程中创建多个线程来进行。由于CPU的单个核心一次只能处理一个线程,因此它在执行程序时可以在线程之间高速切换以处理多个线程。这叫做上下文切换上下文切换。上下文切换会产生开销,因此如果频繁发生上下文切换,则会降低处理效率

当程序被执行时,底层主线程被创建,程序根据需要从主线程创建和管理其他线程。Unity的游戏循环被设计成在单一线程上运行,所以用户编写的脚本基本上会在主线程上运行。相反,试图从其他线程调用Unity api,对于大多数api将导致错误

译者增加部分

如何从其他线程调用UnityAPi

https://cloud.tencent.com/developer/article/2317090

常见网络传输在其他线程接收数据,只是把数据塞入队列里。Unity中Update再对消息队列进行分发

如果从主线程创建另一个线程来执行进程,则不知道该线程何时执行以及何时完成。因此,线程间同步处理的手段是使用信号机制在线程间同步处理。当一个线程正在等待另一个线程完成进程时,可以通过接收来自该线程的信号来释放它。这种信号等待也在Unity中使用,可以在分析期间观察到,但重要的是要注意,它只是在等待另一个进程,正如名称WaitFor~所暗示的那样。

Unity内部线程

然而,如果每个进程都在主线程中运行,整个程序将花费很长时间来处理。如果存在多个繁重的进程,并且它们不相互依赖,那么如果可以通过在一定程度上同步进程来完成并行处理,则可以缩短程序执行时间。为了达到这样的速度,在游戏引擎中使用了许多并行进程。其中一个是渲染线程其中一个是渲染线程。顾名思义,它是一个专门用于渲染的线程,负责将主线程计算出的帧绘制信息作为图形命令发送给GPU。

主线程和渲染线程像管道一样运行,所以渲染线程开始计算下一帧,而渲染线程正在处理它。然而,如果渲染线程处理一帧的时间越来越长,即使下一帧的计算完成,主线程也无法开始绘制下一帧,主线程将不得不等待。在游戏开发中,如果主线程或渲染线程过重,FPS就会下降

可并行的用户处理线程

此外,还有许多计算任务可以并行执行,例如物理引擎和震动,这是游戏所独有的。为了在主线程之外执行这样的计算,Unity使用工作线程(Worker thread)来在主线程之外执行这样的计算。工作线程执行通过JobSystem生成的计算任务。如果您可以通过使用JobSystem来减少主线程上的处理负载,那么您应该积极地使用它。当然,您也可以在不使用JobSystem的情况下生成自己的线程。

虽然线程对性能调优很有用,但我们建议您不要在不熟悉时使用它们,因为使用太多线程可能会降低性能并增加处理的复杂性。

Game Loop游戏循环

常见的游戏引擎,包括Unity,使用游戏循环(玩家循环),这是引擎的常规过程。描述循环的简单方法大致如下

  1. 处理来自控制器的输入,如键盘,鼠标,触摸显示器等。
  2. 计算在一帧时间内应该进行的游戏状态 3.渲染新的游戏状态 4.等待下一帧取决于目标FPS 这个循环被重复以将游戏作为视频输出到GPU。如果单帧内的处理时间更长,那么FPS当然会下降。 Unity中游戏循环 Unity中的游戏循环在官方Unity参考*4中有说明,你可能至少看过一次。 *4 https://docs.unity3d.com/ja/current/Manual/ExecutionOrder.html

这张图表严格地显示了MonoBehaviour中事件的执行顺序,这与游戏引擎游戏循环*5不同,但对于开发者应该知道的游戏循环来说已经足够了。特别重要的是事件Awake, OnEnable, Start, fixeduupdate, Update, LateUpdate, OnDisable, OnDestroy和各种协同程序的定时。错误的执行顺序或事件计时可能导致意外的内存泄漏或额外的计算。因此,您应该了解重要事件调用计时的性质以及同一事件内的执行顺序。

*5 https://tsubakit1.hateblo.jp/entry/2018/04/17/233000

在物理计算中存在一些特定问题,例如,如果物体在正常游戏循环中以相同的间隔执行,则它们会滑过而不会被检测为碰撞。出于这个原因,物理例程通常以不同于游戏循环的间隔循环,以便它们以高频率执行。但是,如果循环以非常快的间隔运行,则有可能与游戏主循环的更新过程发生冲突,因此有必要在一定程度上同步这些过程。因此,如果物理操作比必要的更重,则要注意物理操作可能会影响画框过程的可能性,或者如果画框过程更重,则物理操作可能会延迟并滑过

译者增加部分

unity中常见错误,动态加脚本A,然后执行A.Func,因为参数依赖A.Start,导致报错

【腾讯文档】AddComponent后新脚本生命周期执行顺序

https://docs.qq.com/doc/DWkNraUNzcW1mdGhv

GameObject

如上所述,由于Unity引擎本身是本机运行的,所以c#中的Unity API在很大程度上也是调用内部本机API的接口。GameObject和MonoBehaviour也是如此,它们定义了附加到其上的组件,这些组件将始终具有来自c#端的本地引用。然而,如果本地端管理数据,并且在c#端也有对它们的引用,那么在销毁它们的时候就会很不方便。这是因为当数据在本机端被销毁时,未经许可不能删除来自c#的引用。

事实上,清单2.1检查被破坏的GameObject是否为null,但true是在日志中输出的。这对于标准的c#行为来说是不自然的,因为_gameObject没有被赋值为null,所以仍然应该有一个对GameObject类型实例的引用。

代码语言:javascript复制
public class DestroyTest : UnityEngine.MonoBehaviour
{
    private UnityEngine.GameObject _gameObject;
    private void Start()
    {
        _gameObject = new UnityEngine.GameObject("test");
        StartCoroutine(DelayedDestroy());
    }
    System.Collections.IEnumerator DelayedDestroy()
    {
        // cache WaitForSeconds to reuse
        var waitOneSecond = new UnityEngine.WaitForSeconds(1f);
        yield return waitOneSecond;
        Destroy(_gameObject);
        yield return waitOneSecond;
        // _gameObject is not null, but result is true
        UnityEngine.Debug.Log(_gameObject == null);
    }
}

这是因为Unity的c#侧机制控制对销毁数据的访问。事实上,如果你参考UnityEngine的源代码*6。对象在Unity的c#实现部分,你将看到以下内容

代码语言:javascript复制
// Excerpt.
public static bool operator==(Object x, Object y) {
    return CompareBaseObjects(x, y);
}
static bool CompareBaseObjects(UnityEngine.Object lhs,
UnityEngine.Object rhs)
{
    bool lhsNull = ((object)lhs) == null;
    bool rhsNull = ((object)rhs) == null;
    if (rhsNull && lhsNull) return true;
    if (rhsNull) return !IsNativeObjectAlive(lhs);
    if (lhsNull) return !IsNativeObjectAlive(rhs);
    return lhs.m_InstanceID == rhs.m_InstanceID;
}
static bool IsNativeObjectAlive(UnityEngine.Object o)
{
    if (o.GetCachedPtr() != IntPtr.Zero)
    return true;
    if (o is MonoBehaviour || o is ScriptableObject)
    return false;
    return DoesObjectWithInstanceIDExist(o.GetInstanceID());
}

*6 https://github.com/Unity-Technologies/UnityCsReference/blob/c84064be69f20dcf21ebe4a7bbc176d48e2f289c/Runtime/Export/Scripting/UnityEngineObject.bindings.cs

总而言之,对被销毁实例进行null比较是正确的,因为当进行null比较时,将检查本机端以查看数据是否存在。这导致非空的GameObject实例表现得好像它们部分为空。虽然这个特性乍一看很方便,但它也有一个非常麻烦的方面。这是因为_gameObject实际上不是null,这会导致内存泄漏。单个_gameObject的内存泄漏是很明显的,但是如果你在组件中引用了大量数据,例如master,它将导致巨大的内存泄漏,因为引用仍然是c#并且不受垃圾收集的影响。为了避免这种情况,你需要采取一些措施,比如将null赋值给_gameObject。

AssetBundle

智能手机游戏受到应用大小的限制,并不是所有的资源都可以包含在应用中。因此,为了下载所需的资源,Unity有一个名为AssetBundle的机制,它可以打包多个资源并动态加载它们。乍一看,这似乎很容易处理,但在大型项目中,它需要仔细设计并很好地理解内存和AssetBundle,因为如果设计不当,内存可能会浪费在意想不到的地方。因此,本节将从调优的角度描述您需要了解的有关AssetBundle的信息。

AssetBundle的压缩设置

AssetBundle在构建时默认是LZMA压缩的。通过将BuildAssetBundleOptions更改为UncompressedAssetBund le,可以将其更改为未压缩,并通过更改为ChunkBasedCompression将其更改为LZ4压缩。这些设置之间的差异如表2.4所示

换句话说,未压缩有利于最快的加载时间,但其致命的大文件大小使其基本上无法使用,以避免浪费智能手机上的存储空间。另一方面,LZMA具有最小的文件大小,但由于算法问题,存在解压缩缓慢和部分解压缩的缺点。LZ4是一种压缩设置,它在速度和文件大小之间提供了良好的平衡,正如名称ChunkBasedCompression所暗示的那样,可以部分解压缩,因此可以部分加载,而不必像LZMA那样解压缩整个文件

AssetBundle也有Caching.compressionEnabled,它在终端缓存中缓存时改变压缩设置。换句话说,通过使用LZMA进行交付并在终端上转换为LZ4,可以最小化下载大小,并且在实际使用时可以享受LZ4的好处。但是,在终端端进行重压缩意味着终端端的CPU处理成本要高得多,并且会暂时浪费内存和存储空间。

AssetBundle依赖和复制

如果一个资产依赖于多个资产,那么在将其转换为AssetBundle时必须小心。例如,如果材料A和材料B依赖于纹理C,并且你为材料A和B创建了一个AssetBundle而没有为纹理创建一个AssetBundle,那么由两个AssetBundle生成的每个AssetBundle将包含纹理C,这将导致重复和浪费。这在空间使用方面是一种浪费。当然,这在空间使用方面是浪费的,但它也浪费内存,因为当两种材料加载到内存中时,纹理是分别实例化的。

为了避免在多个AssetBundle中拥有相同的资源,纹理C应该是一个独立的AssetBundle,它依赖于材料的AssetBundle或材质A, B和纹理C在一个单一的AssetBundle。材质A, B和材质C必须制作成一个单一的AssetBundle。

【腾讯文档】YooAsset零冗余构建

https://docs.qq.com/doc/DWmdrWWtzWFdHYmZu

从AssetBundle加载的资产的标识

从AssetBundle加载资源的一个重要属性是,只要加载AssetBundle时,无论加载多少次,都会返回相同资源的相同实例。这表明Unity内部管理加载的资产,并且AssetBundle和资产在Unity中绑定在一起。通过使用这个属性,我们可以将资产的缓存留给Unity,而无需在游戏端为它们创建缓存机制。

但是请注意,在AssetBundle.Unload(false)处卸载的资源将会变成一个不同的实例,即使从相同的地方再次加载相同的资源AssetBundle如图2.35所示。这是因为在卸载时,AssetBundle与资产没有链接,并且资产的管理处于浮动状态。

销毁从AssetBundle加载的资源

当使用AssetBundle. unload (true)卸载AssetBundle时,加载的资源会被完全丢弃,所以没有内存问题。然而,当使用AssetBundle.Unload(false)时,资产不会被丢弃,除非在适当的时候调用资产卸载指令。因此,在使用后者时,有必要调用Resources.UnloadUnusedAssets

适当地卸载,以便在切换场景时销毁资产等。如果引用仍然存在,即使调用Resources.UnloadUnusedAssets,它将不会被释放。注意,当使用Addressable时,AssetBundle.Unload(true)会在内部调用。

C#基础

本节描述c#的语言规范和程序执行行为,这对性能调优至关重要。

栈与堆Stack and Heap

“堆栈和堆”介绍了堆栈和堆作为程序执行期间内存管理方法的存在。堆栈由操作系统管理,而堆由程序管理。换句话说,了解堆内存的管理方式可以实现内存感知实现。由于管理堆内存的机制在很大程度上取决于程序起源的源代码的语言规范,我们将解释c#中的堆内存管理。

堆内存是在必要时分配的,在使用完后必须释放。如果不释放内存,就会发生内存泄漏,应用程序使用的内存区域会扩大,最终导致崩溃。然而,c#没有显式的内存释放过程。. NET运行时环境,c#程序在其中执行,堆内存由运行时自动管理,已经用完的内存在适当的时候释放。由于这个原因,堆内存被称为托管堆,也被称为托管堆内存。

在堆栈上分配的内存与函数的生命周期相匹配,因此只需要在函数结束时释放内存。在堆上分配的堆内存很可能在函数的生命周期之后仍然存在,这意味着只有在函数结束使用堆内存时才会使用堆内存。这意味着在不同的时间需要和使用堆内存,因此需要一种机制来自动有效地使用堆内存。细节将在下一节中介绍。垃圾收集

事实上,Unity的Alloc是一个专有术语,指的是分配给由垃圾收集管理的堆内存的内存。因此,减少GC。Alloc将动态减少分配的堆内存量。

Garbage Collection

在c#内存管理中,对未使用内存的搜索和释放称为垃圾收集,简称“GC”。垃圾收集器是循环执行的。然而,执行的确切时间取决于算法。它同时搜索堆上的所有对象,并删除所有已经存在的对象解引用。换句话说,取消引用的对象被删除,从而释放内存空间。

有各种各样的垃圾收集器算法,但Unity默认使用Boehm GC算法。Boehm GC算法的特点是“非分代”和“不可压缩”。“非特定于生成”意味着每次运行垃圾收集时都必须立即扫描整个堆。这会降低性能,因为搜索区域会随着堆的扩展而扩展。“未压缩”意味着对象不会在内存中移动以关闭对象之间的间隙。这意味着往往会出现碎片(在内存中产生小间隙),并且托管堆往往会扩展。

每个进程都需要耗费大量的计算资源,并且会同步停止所有其他进程,从而导致所谓的“Stop the World”进程在游戏中运行时中断。

从Unity 2018.3开始,GCMode可以指定,也可以暂时禁用。

GarbageCollector.GCMode = GarbageCollector.Mode.Disabled;

当然,如果GC。在禁用期间执行Alloc,堆空间将被扩展和消耗,最终导致应用程序崩溃,因为它无法重新分配。由于内存使用很容易增加,因此有必要实现该函数,以便GC.Alloc在禁用期间根本不执行,而且实现成本也很高,因此实际使用受到限制。(例如,只禁用射击游戏的射击部分)

此外,从Unity 2019开始可以选择增量GC。使用增量GC,垃圾收集处理现在跨帧执行,现在可以减少大的峰值。然而,对于那些必须最大化能量同时减少每帧处理时间的游戏,有必要实现一种避免GC发生的实现。在需要的时候分配。

Tips

应该如何

由于游戏中存在大量代码,如果在完成所有功能的实现后才执行性能调整,你将经常遇到无法避免GC.Alloc的设计/实现。如果您在编码之前的初始设计阶段就意识到它发生在哪里,那么返工的成本就可以减少,并且总开发效率趋于提高。

理想的执行流程是首先创建一个强调速度的原型,以验证游戏的感觉和核心。然后,当进入下一个生产阶段时,设计将再次被审查和重构。在这个重组阶段,致力于消除GC.Alloc将是有益的。在某些情况下,为了加快开发过程,可能需要降低代码的可读性,所以如果我们从原型开始,开发速度也会降低。

译者增加部分

GF框架中大量使用引用池,对象池。避免GC的同时,但是撑大了占用内存

结构体

在c#中,有两种类型的复合类型定义:类和结构。基本前提是类是引用类型,结构体是值类型。引用在MSDN的“在类和结构之间选择”*7中,我们将回顾每一个的特征,选择它们的标准,以及它们的使用注释。

*7https://docs.microsoft.com/en-us/dotnet/standard/design-guidelines/choosingbetween-class-and-struct

内存分配位置的差异

引用类型和值类型之间的第一个区别是它们分配内存的方式不同。虽然有些不精确,但可以安全地认识到以下内容。引用类型在内存的堆区域中分配,并受到垃圾收集的影响。值类型是在内存的堆栈区域中分配的,不受垃圾收集的影响。值类型的分配和回收通常比引用类型的成本要低。

但是,在引用类型的字段中声明的值类型和静态变量是在堆区域中分配的。注意,定义为结构的变量不一定分配给堆栈区域。

处理数组

值类型的数组是内联分配的,数组元素是值类型的实体(实例)。另一方面,在引用类型的数组中,数组元素是按照引用类型实体的引用(地址)排列的。因此,值类型数组的分配和回收比引用类型数组的成本要低得多。此外,在大多数情况下,值类型数组的优点是大大提高了引用的局部性(空间局部性),这使得CPU缓存命中概率更高,有利于更快的处理。

值拷贝

在引用类型的赋值(分配)中,引用(地址)被复制。另一方面,在值类型赋值(分配)中,整个值被复制。32位环境下地址大小为4字节,64位环境下地址大小为8字节。因此,较大的引用类型赋值比大于地址大小的值类型赋值的开销要小。

此外,就使用方法进行数据交换(参数和返回值)而言,引用类型按值传递引用(地址),而值类型按值传递实例本身。

代码语言:javascript复制
private void HogeMethod(MyStruct myStruct, MyClass myClass){...}

例如,在这个方法中,MyStruct的整个值被复制。这意味着随着MyStruct的大小增加,复制成本也会增加。另一方面,MyClass方法只将对MyClass的引用作为值复制,因此即使MyClass的大小增加,复制成本也将保持不变,因为它只针对地址大小。由于复制成本的增加与处理负载直接相关,因此必须根据要处理的数据的大小做出适当的选择。

Immutability不可变

对引用类型的实例所做的更改将影响引用同一实例的其他位置。另一方面,value(第2章基础类型)实例的副本是在通过value传递时创建的。如果修改了值类型的实例,自然不会影响该实例的副本。副本不是由程序员显式创建的,而是在传递参数或返回值时隐式创建的。作为一名程序员,您可能至少经历过一次这样的错误:您认为您正在更改一个值,但实际上您只是针对副本设置了值,这不是您想要做的。建议值类型是不可变的,因为可变的值类型可能会让许多程序员感到困惑。

Tips

按引用传递

一个常见的错误应用是“引用类型总是通过引用传递”,但是正如前面提到的,引用(地址)复制是基本的,引用传递是在使用ref/in/out参数修饰符时完成的。

代码语言:javascript复制
private void HogeMethod(ref MyClass myClass){...}

由于引用(地址)是在引用类型值传递中复制的,因此替换实例不会影响原始实例,但引用传递允许替换原始实例。

代码语言:javascript复制
private void HogeMethod(ref MyClass myClass)
{
    // The original instance passed by argument is rewritten.
    myClass = new MyClass();
}

Boxing装箱

装箱是将值类型转换为对象类型或将值类型转换为接口类型的过程。盒子是一个对象,它被分配在堆上,并受制于垃圾收集。因此,过多的装箱和拆箱将导致GC.Alloc。相反,当转换引用类型时,不会发生这种装箱

代码语言:javascript复制
int num = 0;
object obj = num; // Boxed
num = (int) obj; // Unboxing

我们永远不会使用如此明显和无意义的装箱,但是当它们在方法中使用时呢?

代码语言:javascript复制
private void HogeMethod(object data){ ... }
// Abbreviation
int num = 0;
HogeMethod(num); // Boxing with arguments

像这样的情况是存在的,在传递函数参数时发生了无意识的装箱拆箱。

与简单的赋值操作相比,装箱和拆箱操作是一个繁琐的过程。当装箱值类型被装箱时,必须分配和构造新的实例。此外,虽然没有装箱那么繁琐,但拆箱所需的铸造也非常繁琐。

译者增加部分

【腾讯文档】C#值类型,引用类型,堆,栈

https://docs.qq.com/doc/DWkZEZUhDbUV5VGdT

【腾讯文档】C#装箱拆箱步骤

https://docs.qq.com/doc/DWkdocVVqQ2RIT256

选择类和结构的标准

•应考虑结构的条件:

-当该类型的实例通常很小且有效期较短时

-当该类型经常嵌入到其他对象中时

•避免结构的条件:除非该类型具有以下所有特征

-当它在逻辑上表示单个值时,如基本类型(int, d - double等)

—实例大小小于16字节

-它是不可变的。

-不需要经常装箱

有许多类型不符合上述选择标准,但被定义为结构。像Vector4和Quaternion这样的类型,它们经常被使用在Unity中使用的基础,被定义为结构体,但不少于16字节。请检查如何有效地处理这些问题,如果复制成本正在增加,请选择包含变通方法的方法。在某些情况下,可以考虑自己创建具有相同功能的优化版本。

译者增加部分

xlua 传递Vector3优化

https://zhuanlan.zhihu.com/p/650103418

xlua值传递无GC

https://zhuanlan.zhihu.com/p/579440465

算法和计算复杂度

游戏编程使用多种算法。根据算法的创建方式不同,计算结果可能是相同的,但由于计算过程的差异,性能可能会有很大差异。例如,您将需要一个度量来分别评估标准c#算法的效率和算法实现的效率。作为测量这些的指南,使用了一种称为计算复杂性的度量。

关于计算复杂度

计算复杂度是衡量算法计算效率的指标,可以细分为时间复杂度和面积复杂度,前者衡量的是时间效率,后者衡量的是内存效率。计算复杂度的顺序为isO符号(朗道符号)。由于计算机科学和数学定义不是这里的本质,如果您感兴趣,请参考其他书籍。本文将计算量视为时间计算量。

常用的主要计算量为O(1) O(1)、O(2)、O(3)、O(4)O(n)和O (n2),其中O(n2)O(n log n)括号中N表示数据的个数。如果您想象某个进程的处理次数取决于数据的数量,就很容易理解了。要比较计算复杂度方面的性能,请参见O(1)<O(log n) <O (n) <O(n log n) <O (n^2)<O (n^3)结果如下。表2.5数据数与计算步数的对比及图2.36以对数方式显示的对比图如下表所示。O(1)被排除,因为它不依赖于数据的数量。O(log n)即使有10000个样本也有13个计算步骤,即使有1000万个样本也有23个计算步骤,这表明它非常优越。

为了说明每个计算量,我们将列出一些代码示例。首先,让我们看一下下面的代码示例。o(1)表示与数据数量无关的计算量。

代码语言:javascript复制
private int GetValue(int[] array)
{
    // Assume that array is an array containing some integer value.
    var value = array[0];
    return value;
}

接下来,我们调用o (n)代码示例。

代码语言:javascript复制
private bool HasOne(int[] array, int n)
{
    // Assume that array has length=n and contains some integer value
    for (var i = 0; i < n;   i)
    {
        var value = array[i];
        if (value == 1)
        {
            return true;
        }
    }
}

这里是一个包含整数值的数组,如果存在1,则进程只返回true。如果偶然array1中的第一个发现之初,这个过程可能会在最快的时间完成,但是如果没有1数组,这个过程将return1或数组的末尾第一次,这个过程将return1年底首次发现,循环会一直到最后,因为循环一直到最后。这种最坏的情况称为o (n),您可以想象计算量随着数组长度而增加

让我们看一个O(n^2)的例子。

代码语言:javascript复制
private bool HasSameValue(int[] array1, int[] array2, int n)
{
    // Assume array1 and array2 have length=n and contain some integer value.
    for (var i = 0; i < n;   i)
    {
        var value1 = array1[i];
        for (var j = 0; j < n;   j)
        {
            var value2 = array2[j];
            if (value1 == value2)
            {
                return true;
            }
        }
    }
    return false;
}

这只是一个方法,如果两个数组中的任何一个在双循环中包含相同的值,则返回true。最坏的情况是,它们都是不匹配的情况。所以出现计算复杂度为o(n^2)

Tips

顺便说一句,在计算复杂性的概念中,只使用阶数最大的项。如果我们创建一个方法,将上面示例中的三个方法中的每一个都执行一次,我们将得到最大顺序的最大O(n^2 n 1))

还需要注意的是,当数据量足够大时,计算量只是一个指导,并不一定与实际测量时间相关联。当数据量很小时,0 (n^5)可能不是问题,即使它看起来像一个巨大的计算量,例如。因此,建议以计算量为参考,测量处理时间,看是否适合在合理的范围内,每次都要考虑到数据的数量。

基本集合和数据结构

c#提供了具有各种数据结构的集合类。本节将介绍最常用的方法作为示例,并根据主要方法的计算时间说明在什么情况下应该使用它们。

这里描述的集合类的方法复杂性可以在MSDN。在选择最合适的集合类时,检查MSDN更安全。

List

这是最常用的List。数据结构是一个数组。当数据的顺序很重要,或者当数据经常通过索引检索或更新时,它是有效的。另一方面,如果有很多元素的插入和删除,最好避免使用List,因为它需要大量的计算,由于需要复制后的指数被操纵。

此外,当Add操作超过该容量时,会扩展分配给阵列的内存。当扩展内存时,分配的容量是当前容量的两倍,因此建议将Add与o(1)一起使用,以适当的初始值使用它,这样就可以在不引起扩展的情况下使用它。

LinkedList

LinkedList的数据结构是一个链表。链表是一种基本的数据结构,其中每个节点都有对下一个节点的引用。c#的LinkedList < T>是一个双向链表,因此每个链表都有对其前后节点的引用。LinkedList具有强大的添加和删除元素的功能,但不擅长访问数组中的特定元素。当您想要创建一个临时保存需要频繁添加或删除的数据的进程时,它是合适的。

Queue

Queue是一个实现FIFO(先进先出)方法的集合类。它用于实现所谓的队列,例如,用于管理输入操作。在Queue中,使用了一个圆形数组。使用Dequeue将第一个元素添加到末尾,并删除第一个元素,同时使用。当超出容量时,执行扩容操作。Peek是取出顶部元素而不删除它的操作。从计算可以看出,Enqueue和Dequeue可以用来保持高性能,但它们不适合遍历这样的操作。TrimExcess是一种减少容量的方法,但从性能调优的角度来看,可以使用它,以便首先不增加或减少容量,从而进一步利用Queue<它的优势。

Stack

Stack是一个集合类,实现后进先出(LIFO)方法:后进先出。Stack被实现为一个数组。第一个元素用Pop添加,第一个元素用Pop删除。Peek是取出第一个元素而不删除它的操作。一个常见的用法是在实现屏幕转换时,其中转换目的地的场景信息存储在Push中,当按下后退按钮时,通过Pop检索场景信息。与Queue一样,仅使用Push和Pop for Stack就可以获得高性能。注意不要搜索元素,并注意增加或减少容量。

Dictionary<TKey, TValue>

虽然到目前为止引入的集合都是按语义顺序排列的,是专门化可索引性的集合类。数据结构被实现为哈希表(一种关联数组)。该结构类似于字典,其中键具有相应的值(在字典的情况下,单词是键,描述是值)。Dictionary<TKey, TValue>缺点是消耗更多内存,但查找O(1)的速度更快。对于不需要枚举或遍历的情况,以及强调引用值的情况,它非常有用。另外,一定要预先设置容量。

译者增加部分

c#数据结构源码

https://docs.qq.com/s/CdO9984zuGtuWKGAFnIFEa

降低计算量的方法

除了到目前为止介绍的集合之外,还有其他各种可用的集合。当然,也可以只使用List(array)来实现相同的过程,但是通过选择更适合的集合类,可以优化计算量。通过简单地实现了解计算量的方法,可以避免繁重的处理。作为优化代码的一种方法,您可能希望检查方法的计算复杂性,并查看是否可以将其降低到小于预期。

Tips

设计方法:记忆,用空间换时间,即占用内存换降低复杂度

假设您有一个计算复杂度非常高的方法(ComplexMethod),需要进行复杂的计算。然而,有时不可能减少计算量。在这种情况下,可以使用一种称为记忆的技术。

让我们假设当给定参数时,ComplexMethod唯一地返回相应的结果。首先,第一次传递参数时,要经过一个复杂的过程。计算完成后,将参数和结果放入Dictionary<TKey, TValue>和缓存。第二次以及随后的次数,我们首先检查它们是否被缓存,如果是,我们只返回结果并退出。这样,无论第一次的计算量有多高,第二次及以后的第二次计算量都减少了O(1)。如果事先知道可以传递的参数数量,则可以在比赛前完成计算并缓存,从而有效地在比赛前返回o(1)并缓存它们。

0 人点赞