图形显示原理简述

图形系统作为游戏引擎的核心系统之一,为了实现特定的渲染效果以及保证良好的性能,我们需要对这部分知识进行系统性的掌握。这里从计算机图形显示的整个流程出发来理解上屏,渲染的对象,效果呈现以及渲染流程等内容,这样比较容易和生活实践联系起来,有利于理解知识点在其应用领域中所处的位置,从而形成画面感。


图:计算机图形显示流程

我们从看得见的终端(显示屏幕)出发,来逐步剖析整个过程。

上屏机制

将图形渲染结果(Framebuffer)显示到物理显示屏(LCD)的过程。

GPU将渲染结果(Framebuffer)存储在显存/共享内存中,CPU通知LCD控制器获取图形数据交给LCD驱动器,并产生必须的LCD控制信号,从而控制和完成图形的显示,翻转,叠加,缩放等一系列复杂的图形显示功能。

Vsync与帧率

首先来体会下不同帧率的画面效果(如下图)。

一般一些画面动态演算要求低的手机游戏会采用30FPS,而60FPS对设备性能要求更高,而且功耗会更大,对优化有更高的要求

Vsync是一种通过同步游戏帧率和游戏显示器刷新率来处理屏幕撕裂的方法。

以Unity引擎为例,开启Vsync垂直同步,会限制GPU端的帧数,但CPU可能空跑(可以通过setTargetFrame从CPU端限制提交帧缓冲数据的频率)。特别是现代游戏大量的渲染计算,很可能是GPU Bound,除非CPU需要做一些繁重的逻辑与物理计算(这部分可以多线程机制来优化),当然还有向GPU发送的数据量,其中数据量可以体现为Batch和SetPass Call的数量。值得注意的是Vsync在移动平台上是默认启用的,即便我们在编辑器选项中禁用Vsync,Vsync功能仍会在硬件层面上启用。

CPU和GPU在整个渲染过程中是解耦的,CPU设置好渲染状态,提交渲染数据,调用渲染命令通知GPU执行渲染,GPU不会在渲染过程中回读CPU,性能成本太高。

有3种方法可以降低这个数据量:

  1. 降低需要渲染的对象的数量,可以同时减少Batch和SetPass Call。
  2. 降低每个对象需要被渲染的次数,可以减少SetPass Call。
  3. 将对象合并到更少的批处理当中,可以减少Batch。批处理下面渲染管线的程序阶段还会提到。

当Unity GPU Bound时,Main Thread会处于等待Render Thread的状态。

开启多线程渲染时,CPU等待GPU完成工作的耗时会被统计到Gfx.WaitForPresent函数中.

逻辑帧与渲染帧的关系如下图:


而关闭多线程渲染时这一部分耗时则被主要统计到Graphics.PresentAndSync中。

帧率与刷新率

刷新率,就是显卡将显示信号输出刷新的速度。60赫兹(hertz)就是每秒钟显卡向显示器输出60次信号。高于刷新率的帧数都是无效帧数,而且可能导致画面异常和性能低下。不过如果用户输入Input采用了高采样率,必然就需要高刷新率来反馈给人的视觉系统。

我们知道,现在手机设备开始推出高刷新率了。有些手机需要开白名单(手机厂商),不在白名单的APP,ROM自动会把它降成60FPS。

Unity对于高刷新率的手机帧率不稳定的情况下可以开启以下Frame Pacing(Swappy for OpenGL/Vulkan)这项来让帧率更加平滑。

Swap Chain换页机制

上文提到CPU通知LCD控制器获取图形数据交给LCD驱动器,这里有一个Swap Chain的换页机制。Swap Chain是一系列虚拟帧缓冲区,由GPU(写入)和图形API用于稳定帧速率(一般需要使用多缓冲区的地方都是由于“生产者”和“消费者”供需不一致所造成的,而多缓冲机制可以让速度较慢的一方可以独立的操作数据不至于造成阻塞或丢失)和其他一些功能。

第一个Framebuffer,即Screen Buffer(有时也称为Front Buffer),是呈现到视频卡输出的缓冲区。其余的缓冲区称为后向缓冲区(Back Buffer)。每次显示一个新帧时,交换链中的第一个Back Buffer将取代Screen Buffer,这称为“呈现(Present)”或”交换(Swap)”。

一般采用双缓冲模式:

如果GPU速度更快的话,我们也可以设置Triple Buffer模式:

三缓冲机制对于交互式目的应用,如:游戏,来说是更优越的方案。

Framebuffer

也叫帧缓冲,是经过图形渲染机制得到的最终结果,可以将其简单理解为屏幕上显示内容对应的缓存。

帧缓冲不是指一块现存,而是类似VAO(Vertex Array Object)的一个组织结构,里面包含了很多Attachment(ColorBuffer、DepthBuffer、StencilBuffer),Attachment里会根据不同需求开辟不同的缓存空间。实际上Framebuffer 对象的占用空间非常小。连接到Framebuffer的每个缓冲区可以是Renderbuffer或Texture。

Renderbuffer对象是一个2D图像缓冲区,区别于Texture。它允许将场景直接渲染到渲染缓冲区对象,而不是渲染到纹理对象。 Renderbuffer只是一个数据存储对象,包含可渲染内部格式的单个图像。它用于存储没有相应纹理格式的OpenGL逻辑缓冲区,例如模板或深度缓冲区。

Framebuffer的结构:

一个完整的帧缓冲需要满足以下的条件:

  1. 附加至少一个缓冲(颜色、深度或模板缓冲)
  2. 至少有一个颜色附件(Attachment)
  3. 所有的附件都必须是完整的(保留了内存)
  4. 每个缓冲都应该有相同的样本数。

Framebuffer的存储位置可以位于显存,也可以位于内存。比如我们看下PC和Mobile端的硬件结构差异。

PC:GPU和CPU各自有自己的一块内存,GPU有一块专用的显存VRAM,访问起来非常快。

Mobile:GPU和CPU会共享一块内存,GPU有一块on-chip的缓存,不过on-chip缓存放不下一帧FrameBuffer,所以GPU的架构为Tile Based Deferred Renderers(TBDR)

好了,说了那么多提交渲染结果的操作,那么什么是渲染呢?

什么是渲染

一般指把给定3D场景中的模型对象,按照设定好的环境、灯光、材质及渲染参数,交由渲染程序投影生成一张二维图像数据的过程。

1 渲染的对象

  1. 物体的模型,材质(光照模型)、纹理、坐标等
  2. 照相机的位置及朝向
  3. 光源信息等

2 渲染效果

光照,阴影,后处理,是影响画面品质的三个重要技术因素。

  1. 光照技术:光源,SkyLight,直接光,间接光,光照模式,光照贴图,HDR,体积光,环境光遮蔽AO,光照模型,Light Probe,Reflection Probe等。
  2. 阴影技术:Shadow Map,Shadow Mask,Cascaded Shading Map,VSM(Voxels Shadding Map)等。
  3. 后处理技术:AA,Bloom,Blur,Lens Flare,GlobalFog,SSGI,SSR,ToneMapping等。

另外基于BRDF光照模型的PBR写实风格和基于PBR的NPR卡通渲染流程,也在移动端备受关注。

3 渲染过程

也可理解为渲染管线,根据发展时间线区隔,有固定管线和可编程管线。这个过程涉及到大量的计算和图形处理技术,如光线追踪、阴影计算、反射和折射等。

Unity的可编程渲染管线有:Built-In,SRP和SRP的两套模板URP(全平台兼容)和HDRP(PC/主机平台)。

Fixed Rendering Pipeline – OpenGL ES 1.1

Scriptable Render Pipeline – OpenGL ES 2.0

可编程渲染管线是现在的主要方式,下面重点介绍。

可编程渲染管线

再换个视角看下基本流程:

一 程序应用阶段(CPU)

  1. 准备好要渲染的场景数据,如:模型,相机,灯光等信息;
  2. 其中需要对非渲染对象和不可见对象排除;
  3. 最后设置好每个即将渲染的模型所对应的渲染状态。渲染状态包括但不限于材质,纹理,Shader等;
  4. CPU提交渲染数据到GPU前端模块(寄存器)中,只是一个图元对象记录列表(不包含模型,材质信息)。

模型对象

所有顶点信息由VAO/VBO/EBO表示。

VAO:是OpenGL用来处理顶点数据的一个缓冲区对象,它不能单独使用,都是结合VBO来一起使用的。VAO是OpenGL CoreProfile 引入的一个特性,在CoreProfile中做顶点数据传入时,必须使用VAO方式。

VBO:是在显卡存储空间中开辟出的一块内存缓存区,用于存储顶点的各类属性信息,如顶点坐标,顶点法向量,顶点颜色数据等。

EBO:规定哪些点规定一个三角形,面组成方式的信息。

VAO是一个保存了所有顶点数据属性的状态结合,它对应上一个VBO和一个EBO。VAO中的数据是顶点着色器中获取数据输入的地方。

PS:延迟渲染将物体的几何信息(位置、法线、颜色、镜面值)存到几何缓冲区G-Buffer中。

Texture对象

由访问者不同分为两种:一种是Client-Side创建在内存,由CPU访问;另外一种是Server-Side创建在显存/共享内存,由GPU访问。当CPU提交渲染请求时,会把内存中的纹理对象复制到GPU可访问的显存中。

批处理优化

CPU在游戏程序中的主要职责包含两个部分:设置渲染状态和调用DC(不考虑复杂的脚本或者物理模拟的情况下)。 其中设置渲染状态属于比较重要的分工,如果每个物体的材质和贴图等都不一样,游戏的运行可能会比较缓慢,因为此时CPU的主要工作就是设置这些物体的渲染状态(当然调用DC也会更多,但渲染状态的改变更消耗性能)。

在常见的游戏中,

  1. 对于大量的不需要改变位置的物体,都会采用静态批处理的方式
  2. 对于一些共享材质的物体可以采用动态批处理的方式;来解决设置渲染状态的性能瓶颈
  3. 对于大批量实例化相同的Mesh,可以采用Instancing,不过无法解决SkinnedMeshRender对象

二 几何处理阶段

图形硬件渲染管线种的第一个处理阶段,在每个顶点上执行一系列的数学操作,将三维几何信息转换成二维几何信息的过程,

1 Vertex Shading

计算顶点(vertex)的位置(postition),计算顶点输出数据(vertex output data),例如法线、纹理坐标。

2 几何体转换

根据输入的vertices(顶点数据集),通过曲面细分着色程序生成更小更多的小的简单的图形,另外需要执行以下流程:背面裁剪–>光照–>裁剪–>投影–>视图变换,最终得到裁剪好的图元(点,线段或多边形)作为下一阶段的输入。

  • 投影阶段(Projection Stage)
    • 正交投影(Orthographic Projection)
    • 透视投影(Perspective Projection)
  • 屏幕映射
    • 每个primitive的坐标通过变换成为屏幕坐标(screen coordinates)
    • 屏幕坐标和坐标一起被称为窗口坐标(window coordinates)

三 光栅化阶段

  1. 三角形装配(Triangle Setup)
  2. 三角形遍历(Triangle Traversal)
  3. 点采样(Point Sampling)
  4. 输出片元

光栅化阶段将裁剪好的图元转换为片元信息,一个片元包含的信息有:

  • 屏幕坐标
  • 深度信息
  • 顶点信息
  • 法线信息
  • 纹理坐标
  • 颜色信息等等,

这些信息大部分在Vertex Processing中就已经存在。

四 像素处理阶段

在Primitive内部的像素(Pixels)或样本(Samples)上完成逐像素(Per-Pixel)或者(Per-Sample)操作或计算

1 像素着色

通过片段着色器/像素着色器,最重要的一个任务是texturing,最终的产出是每个fragment的像素值。

2 Merging合并

输出到Framebuffer,包括:颜色缓冲,深度缓冲,模板缓冲等多个缓冲区。

关于后处理(Post-Process Effect)

对渲染之后的画面进行再加工。使用FBO颜色附件(Color Attachment)作为其纹理,并通过指定算法的后期处理着色器对其进一步处理,后期处理着色器的结果将会替换原来屏幕的画面,而该处理结果又可作为另一个后期处理着色器的输入。比如镜头特效:镜头模糊,镜头光晕/辉光,AO环境光遮蔽,扭曲等。每个特效都有可能分配一个全屏幕大小的RenderTexture。RenderTexture因为用来实时渲染,所以不能压缩,对内存的占用非常高,我们经常看到有的游戏后期特效分配了几十兆甚至更高的内存。

参考