一点心得


  • 首页

  • 归档

Unity游戏的图形渲染优化(译)

发表于 2017-12-03

原文Optimizing graphics rendering in Unity games。这里对这篇文章的粗略翻译,当作自己的笔记。

介绍

在这篇文章中我们将会学习Unity在渲染一帧的时候其内部的发生的事情,在渲染的时候会发生什么类型的性能问题同时我们怎样去处理这些性能问题。

在阅读这篇文章之前,必须明白没有一个同样的尺度来提高渲染的性能问题。在我们游戏中影响性能问题的原因有很多同时非常依赖游戏运行的硬件和操作系统。通过调查,实验和细心的分析实验结果来解决性能问题是我们最重要的方式。

这篇文章包含的信息主要是大部分常见的性能问题的修改建议和一些更深层次的阅读链接。我们的游戏中可能也存在这其中的一个问题或者一些问题或者都没有包含。但是这篇文章仍然将帮助我们理解我们自己的问题同时给予我们去搜索我们问题的知识和线索。

渲染的简介

在我们开始介绍之前,让我们快速简单看下Unity渲染一帧时其内部发生了什么。理解事件流和正确的术语将会帮助我们 理解,调查和解决我们的性能问题。

注:在这篇文章中,我们将会使用”对象(object)”术语来表示一个我们游戏的渲染的对象。任何渲染组件的GameObject将会被统称为对象。

在最基本的层面上,渲染可以描述为一下几步:

  • CPU计算出必须渲染的对象和决定怎么去绘制。
  • CPU发送指令到GPU。
  • GPU根据CPU的指令来确定绘制的内容。

现在让我们来更近一步看看究竟发生了什么。这篇文章后面将会详细的介绍上面的每一个步骤。但是现在让我们先来熟悉下文中使用的词语和理解CPU和GPU在渲染中扮演的不同角色。

描述渲染的词语通常用渲染管线(rendering pipeline),记住这是一个非常有用的情景。高效的渲染都是关于保持更高信息的传送能力。

对于每一个渲染的帧,CPU会做如下的工作:

  • CPU会检查场景中的每一个对象来决定这个对象是否需要被渲染。只有当这个对象满足特定的条件的时候才会被渲染;例如,它的包围盒的一些部分在相机视锥体内。一个对象如果将不会被渲染我们称之为这个对象被裁剪了(culled)。关于相机的视锥体和视锥体裁剪的更多信息可以看着这篇文章。
  • CPU搜集每一个将会被渲染的对象的信息然后将这些数据分类放入叫做draw calls命令中。一个draw call包含了一个mesh和这个mesh怎样被渲染的数据;例如,这个mesh用哪一张贴图。基于这些情况,共享设置的图像会合并到一些相同的draw call中。合并不同对象的数据到同一个draw call中我们称之为batching。
  • CPU为每次draw call创建一个叫batch的数据包。batchs有可能包含了draw calls之外的数据,但是这种情况不像常见的一个性能问题,因此我们不会在这篇文章里面来讨论。

对于每个draw call包含的batchs,CPU现在必须要入如下的工作:

  • CPU可以向GPU发送一个命令来改变一些被统称为渲染状态的变量。这个命令叫做SetPass call。SetPass call告诉GPU哪个设置被用来渲染下一个mesh。只有当上一个mesh渲染完成下一个mesh准备渲染的时候需要改变渲染状态的时候才需要发送SetPass call命令。
  • CPU发送draw call命令到GPU。draw call会指示GPU使用上一次设置好的SetPass call去渲染制定的mesh。
  • 基于这些情况,batchs的数据被渲染可能需要不止一个pass。一个pass是一段着色器的代码,使用一个新的pass需要改变渲染状态。对于一个batchs中的每个pass,CPU必须发送一个新的SetPass call命令同时必须再次发送draw call命令。这个pass才会生效。

同时,GPU会做如下的工作:

  • GPU按照CPU发送的顺序来处理CPU发送的任务。
  • 如果当前的任务是SetPass call,GPU就更新渲染状态。
  • 如果当前的任务是draw call, GPU就渲染这个draw call的mesh。这个是分阶段进行的,每个阶段单独定义在着色器代码中。这部分渲染比较复杂我们不会详细的讨论,但是这部分对于我们去理解顶点着色器(vertex shader)这部分代码和片段着色器(fragment shader)这部分代码非常有用。顶点着色器是用来告诉GPU怎么去处理mesh的顶点数据的代码,片段着色器使用来怎样去处理每个单独像素代码。
  • GPU重复处理从CPU发送过来的任务,直到所有的任务都处理完成。

现在我们理解了当Unity渲染一帧时其内部发生了什么,接下来让我们思考下当渲染发生的时候会随之发生什么问题。

阅读全文 »

Unity游戏内存回收的优化(译)

发表于 2017-11-18

原文Optimizing garbage collection in Unity games。这里对这篇文章的粗略翻译,当作自己的笔记。

Garbage Collector在这里被翻译成名词GC。对于Garbage Collection这里翻译成GC内存回收。

内存垃圾: 代码中销毁了(disposed)但是GC还没有清理的内存。

Unity的托管内存的简单介绍

为了理解GC(本文GC指代Garbage Collector)在内存分配和回收时是怎样工作的,我们必须首先了解Unity的引擎代码和我们自己编写的脚本是怎么利用内存来工作的。

Unity引擎代码运行时管理内存的方式叫做手动管理内存(manual memory management)。也就是说引擎代码必须显示的声明内存是怎么使用的。手动管理内存没有用到GC,这部分内容本文不做介绍。

当运行我们自己编写的脚本时Unity管理这部分内存的方式叫做自动内存管理(automatic memory management).这意味这我们自己编写的代码不需要很详细的去告诉Unity怎样去管理这部分内存。Unity都帮我们做好了。

简而言之,Unity的自动管理内存工作方式如下:

  • Unity有两个可以访问的内存池:栈(stack)和堆 (heap)(也被叫做托管堆。栈内存被用在短期存储一些小片段的数据,堆则用来存储一些长期并且比较大的片段数据)。
  • 当一个变量创建好了,Unity会在栈或者堆中申请一个内存块。
  • 只要这个创建的变量在作用范围之内(我们的代码一直可以访问到它),为它分配的相应的那块内存就一直是保持可用状态。我们把这块内存成为已经被分配的(allocated)。我们把在栈中分配内存的变量叫做栈上的对象,把内存分配在堆上的变量成为堆上的对象。
  • 当这个变量已经脱离了它的作用区域,相应的这个变量所分配的内存也就不再需要了,这块内存随之就会把返回给分配时的那个内存池。我们称这块内存叫做已回收(deallocated)。栈上的内存一旦离开了作用区域就会立即被回收。如果是堆上的内存即便是它的引用已经超出了作用区域也不会被立即回收,它还会保持它的分配状态。
  • GC会标记并且回收堆上没有被使用的内存。它会周期性的对堆上的内存进行清理。

现在我们了解了内存使用的流程,让我们来更深入的来了解栈和堆上的内存的分配和释放。

阅读全文 »

Quaternion的插值分析及总结

发表于 2017-08-30

下面的内容是阅读3D数学基础后结合自己的理解的总结

Unity中应用

我们接触到的地方就是Unity的Transform组件。Transform组件维护了四元数实现方位角位的变换。但是我们也看到Transform组件提供了eulerAgnles属性,按理说里面也实现了欧拉角的,但事实并不是这样。接下来详细分析下

首先看看类似与这种操作,我们对一个GameObject实现旋转的时候,可以直接对其transform组件的eulerAngles属性赋值需要旋转的角度即可。

1
transform.eulerAngles = Vector3.zero;

这个是transform的eulerAngles的实现,可以看到其实里面直接把欧拉角转换成了四元数来处理,所以欧拉角其实只是作为一个方法的表达形式,并没有作为Transform的成员变量之类的属性来维护 (实际上也不需要,因为有了四元数之后,欧拉角和四元数就可以实现相互的转换)

1
2
3
4
5
6
7
8
void Transform::SetLocalEulerAngles (const Vector3f& eulerAngles)
{
    ABORT_INVALID_VECTOR3 (eulerAngles, localEulerAngles, transform)
     
    SetLocalRotationSafe (EulerToQuaternion (eulerAngles * Deg2Rad (1)));
  
    ...
}

我们想对一个GameObject实现旋转还可以对其的transform组件的rotation属性以四元素的方式赋值,来实现旋转。

1
transform.rotation = Quaternion.identity;

对于rotation属性,其具体实现是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Transform::SetRotation (const Quaternionf& q)
{
    ...
    
    if (father != NULL)
        SetLocalRotation (Inverse (father->GetRotation ()) * q);
    else
        SetLocalRotation (q);
}

void Transform::SetLocalRotation (const Quaternionf& q)
{
    ABORT_INVALID_QUATERNION (q, localRotation, transform);
    m_LocalRotation = q;
    
   ...
}

可以看到标红色的那行,传入的四元数被赋值参数被赋值给了m_LocalRotation成员变量。

上面介绍了在Unity里面封装的Quaternion的方位与角位的情况。那么下面Quaternion里面常用到的一个叫做Lerp的方法。相应的这个方法有一个 对应的Slerp方法。那么这两个方法到底有什么区别呢?下面来看看。

线性插值和球面线性插值的原理

Lerp是用来求两个目标之前的差值,其表达式为Lerp(a, b, t)。a和b分别为起始和终点两个点,t为差值参数变量,范围在0到1之间。Lerp表示为标准的线性差值公式的话,其公式为:

1
Lerp(a, b, t) = a + (b - a) * t

代码实现着这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Quaternion LerpX (Quaternion a, Quaternion b, float t)
{
    Quaternion result;
 
    //线性的
    float k0 = 1.0f - t;
    float k1 = t;
 
    //插值
    result.w = a.w * k0 + b.w * k1;
    result.x = a.x * k0 + b.x * k1;
    result.y = a.y * k0 + b.y * k1;
    result.z = a.z * k0 + b.z * k1;
 
    return result;
}

Slerp的计算方式(里面数学公式编辑比较麻烦,干脆直接写下来上图片了):

得出了推导公式,那么代码实现就比较简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
 private Quaternion SlerpX(Quaternion a, Quaternion b, float t)
 {
        Quaternion result;
 
        //利用点乘计算两个四元素夹角的cos值
        //
        //   q1 * q2 = [w1 v1] * [w2 v2]
        //
        //           = [w1 (x1 y1 z1)] * [w2 (x2 y2 z2)]
        //
        //           = w1*w2 + x1*x2 + y1*y2 + z1*z2
 
        // 这个可以参考向量点积: 两个向量的点积等于两个向量的模乘以两个向量的夹角
        // a * b = ||a|| ||b|| cosθ
        // 如果 a和b是单位向量
        // 那么 a * b = cosθ
 
        //这里a,b是单位四元数
        //   q   = [cos(θ/2) sin(θ/2)n]
        // ||q|| = sqrt(cos(θ/2)^2 + (sin(θ/2)n)^2)
        // 如果n为单位向量,则:
        //       = sqrt(cos(θ/2)^2 + (sin(θ/2)^2)
        //       = 1
        float cosOmega = a.w * b.w + a.x * b.x + a.y * b.y + a.z * b.z;
 
        if (cosOmega < 0.0f)
        {
            result.w = -a.w;
            result.x = -a.x;
            result.y = -a.y;
            result.z = -a.z;
 
            cosOmega = -cosOmega;
        }
 
        float k0, k1;
 
        if (cosOmega > 0.9999f) //线性计算
        { //Unity中为0.95f
            k0 = 1.0f - t;
            k1 = t;
        }
        else //球面平滑的
        {
            // 用三角公式 sin^2(omega) + cos^2(omega) = 1求得
            float sinOmega = Mathf.Sqrt(1.0f - cosOmega * cosOmega);
 
            // tan(omega) = sin(omega) / cos(omega)
            // omega = atan(sin(omega) / cos(omega)
            float omega = Mathf.Atan2(sinOmega, cosOmega);
 
            // 计算 1 / sin(omega)
            float oneOverSinOmega = 1.0f / sinOmega;
 
            k0 = Mathf.Sin((1.0f - t) * omega) * oneOverSinOmega;
 
            k1 = Mathf.Sin(t * omega) * oneOverSinOmega;
        }
 
        //插值
        result.w = a.w * k0 + b.w * k1;
        result.x = a.x * k0 + b.x * k1;
        result.y = a.y * k0 + b.y * k1;
        result.z = a.z * k0 + b.z * k1;
 
        return result;
 }

两种方式比较: Lerp更少的计算量,Slerp更加平滑。实际测试下使用Slerp和Lerp的运动效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
private const int mMaxDotCount = 20;
private int mCurDotCount = 0;
private float mStep = 0.05f;
  
void Start ()
{
    StartCoroutine (Loop ());
}
  
IEnumerator Loop ()
{
    Quaternion slerpStartRoation = SlerpCapsule.rotation;
    Quaternion lerpStartRotation = LerpCapsule.rotation;
  
    while (mCurDotCount < mMaxDotCount) { //只计算mMaxDotCount次
  
        //平滑表现测试
        Quaternion newSlerpRotation = SlerpX (slerpStartRoation, Targeter.rotation, mStep);
        Quaternion newLerpRotation = LerpX (lerpStartRotation, Targeter.rotation, mStep);
  
        mStep += 0.05f;
  
        GameObject lerpDot = Instantiate (LerpDotTemplate);
  
        lerpDot.transform.SetParent (null);
        lerpDot.transform.localPosition = newLerpRotation.eulerAngles;
  
        GameObject slerpDot = Instantiate (SlerpDotTemplate);
  
        slerpDot.transform.SetParent (null);
        slerpDot.transform.localPosition = newSlerpRotation.eulerAngles;
  
        SlerpCapsule.rotation = newSlerpRotation;
        LerpCapsule.rotation = newLerpRotation;

    mCurDotCount++;
              
        yield return new WaitForSeconds (0.02f);
    }
}

通过一个个的小球,我把每次Slerp和Lerp的插值画出来,形成了一个轨迹(其中红色是Slerp的插值轨迹,白色的是Lerp的插值轨迹)

通过这个轨迹图片可以看得到(由于各个小球的Z周有一点差异,导致画面有一点透视效果),代码Slerp的红色小球的轨迹相邻的之间距离比较均匀,但是代表Lerp的白色的小球两球之前的距离由最开始逐渐变小然后到达中间之后又开始变大。分析之后可以归结到下面两张图片中(左图描述Lerp,右图描述Slerp)。Lerp求得的是四元数在圆上的弦上的等分,而Slerp求得的是四元数载圆上的圆弧的等分(论据的图是参考的这里)。

项目中的使用问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Quaternion _sphereOriginRotation;
private readonly float _rotationFactor = 0.2f;
 
void Start()
{
    _sphereOriginRotation = Sphere.rotation;
}
 
void Update()
{
    //这样用其实没有用上Slerp的球面线性差值的特性
    Sphere.rotation = Quaternion.Slerp(Sphere.rotation, Target.rotation, _rotationFactor);
 
    //上面这么写的话,其实和用Lerp得到的效果基本一样。但是Lerp的计算效率会高很多
    Sphere.rotation = Quaternion.Lerp(Sphere.rotation, Target.rotation, _rotationFactor);
         
    //要用到Slerp的球面差值的特性可以这样写,改变目标旋转的物体之前,先保存这个物体的Rotation
    Sphere.rotation = Quaternion.Slerp(_sphereOriginRotation, Target.rotation, _rotationFactor);
}
阅读全文 »

Unity中shader代码的条件分支的性能问题

发表于 2017-08-20

优化方案

GPU对条件分支语句的执行性能比较敏感似乎是一个比较普遍的认知了,那么到底影响有多大呢?这里做了一次测试,测试针对Shader中的条件分支语句的性能情况。在有if语句和没有if语句时候的程序不同写法的:

针对if语句的优化方法,参考的这里, 目前没有找到更好的替换if语句的办法,希望有更好办法的同学评论里面提出来,非常感谢!

1
2
3
4
5
6
7
8
9
10
11
12
13
fixed4 col = tex2D(_MainTex, i.uv);
fixed4 col2 = tex2D(_SubTex, i.uv);
 
//1. if语句写法
if (col.r < 0.1)
{
	col.r = col2.r*0.1;
}
 
//2. 对if优化之后的写法
fixed r_factor = max(0, sign(col.r - 0.1));
col.r = r_factor * col.r + (1 - r_factor) * col2.r*0.1; 
 

为了放大测试效果,我在测试的shader里面添加了30个if语句,对应的在替换if语句计算的公式也添加了30个。在空场景上渲染500个14.4k面的模型,并且对每个模型的material更改了颜色,保证500个模型渲染不batching,没有关照及其他因素影响。

测试环境: macOS 10.12.6,  unity 5.6.3f1,  xcode 8.3.3, iOS 10.3.3,  iPhone7 测试的结果主要针对iphone7的设备,其他设备测试结果可能不同,和GPU的型号直接关系。

结果验证

测试场景

测试结果

  1. 没有if的测试情况
程序执行1min 程序执行5min
FPS: 11 FPS: 7
CPU: 94ms ~105ms CPU: 140ms  ~ 144ms
GPU: 65ms  ~  71ms GPU: 90ms  ~ 98ms

5min时刻的性能截图:

  1. 存在if语句的测试情况
程序执行1min 程序执行5min
FPS: 11 FPS: 7
CPU: 90ms ~ 98ms CPU: 138ms ~160ms
GPU: 66ms ~ 71ms GPU: 90ms  ~  101ms

5min时刻的性能截图:

总结: 通过测试发现两种写法的shader性能基本相近, 数值一直在波动,但是波动范围和数值频率差别比较微小, 没有哪种情况能够从数值上面看出绝对的优势。也就是相比使用if语句的代码,优化之后的代码并没有执行效率的明显提升,所以这里所谓的优化就不存在了。

阅读全文 »

Unity3D Sprite常用存储格式性能对比

发表于 2017-07-14

UI图片资源对于项目里面内存的占用还是包大小的比例都是很大一部分,这里对带Alpha的图片的几种常用格式做了下对比,便于找出最好的解决方案应用到项目中去

测试环境: Unity5.5.4f1, Window7, 魅蓝Note1

在网上找了8张512x512的PNG图片,分别测试了RGBA32、RGBA16和ETC1+Alpha(这种格式主要是考虑Android平台)三种格式在魅蓝Note1上的显示效果、AB的大小和加载时间(测试了5次求的平均值)。

格式 AB大小 加载时间
RGBA32 17.5MB 545ms
RGBA16 3.01MB 326ms
ETC1+Alpha 3.19MB 385ms

对于显示效果图,可以到这里下载

通过上面对比,可以看到ETC1+Alpha这种格式的显示效果和RGBA32的相差不是很大,但是AB的大小和加载时间确实和RGBA16的比较接近。至于RGBA16虽然AB的大小和加载速度很有优势,但是无奈显示效果和RGBA32相差太大。可以考虑使用这种格式应用到项目中来降低包大小和提高加载速度。

阅读全文 »
1 … 5 6 7 … 9
© 2025 yiliangduan@qq.com