说白了,渲染就是计算机绘制数张用于显示的图片,并连续播放实现动画效果的过程。像在3D游戏里面,任何物体都是一个预先制作好的模型,玩家作为游戏世界里的主人公可以调整自己的视角,屏幕显示的内容会随视角的变化而变化。游戏里面的物体模型是3D的,屏幕显示出来的画面只是一张具3D视觉效果的2D图片,计算机将3D场景通过计算产生出符合人物当前视角下的2D图片,这个过程叫做渲染。
其实不止在游戏中使用到渲染,比如我们知道,任何视频都是由许多张图片组成的,快速且连续播放这些图片即成了视频,每一张图片称作一帧,播放图片的速度叫做帧率。拿动画制作举例,屏幕上有一个小球,将它一分钟内的运动轨迹设置成从屏幕左边移动到屏幕右边,然后计算机将这一分钟的动画生成出来的时候,就需要去根据设置好的运动轨迹去渲染(计算并生成)每一张用来播放的图片。再举个例子,做视频编辑的时候,给整个视频加一个灰调滤镜,计算机会渲染整个视频在时长范围内的每一帧,将每一帧的色调都变成灰色。这两个例子都属于离线渲染,即在播放图片之前就已经渲染好了,播放的内容是之前就决定好的内容。
而游戏中的渲染就是实时渲染,即一边渲染一边播放,因为游戏画面是一个依靠玩家操作来决定播放内容的东西,当计算机渲染好当前任务视角下的场景后,计算机并不知道下一秒玩家会把视角切换到什么地方,如果实时渲染的速度太低,就会导致游戏画面卡顿等问题。
本篇文章仅介绍实时渲染。
渲染管线不是某个具体的东西,它指的是渲染的整个流程,它是一条完整的流水线,由不同的阶段组成,上一个阶段的计算输出是下一个阶段的计算输入。下图就是渲染管线,接下来会就其中每个阶段进行粗略介绍。
应用阶段的主要参与者是CPU,当然也可能忽视掉GPU对渲染的特殊功能来使用GPU。这个阶段做的事情就是物理模拟、碰撞检测、计算移动等等。
几何阶段重点说一下,这个阶段又可以更细地划分为四个步骤:顶点计算、投影、裁剪、屏幕映射。
首先大家需要知道什么是三角形面片和顶点,任何3D模型都是由若干三角形面片组成的(如下图),而每个三角面片由三个顶点构成,每个顶点中存储了该顶点的坐标、颜色、纹理、光照等信息,计算机通过存储一个模型的顶点集合来存储这个模型。
在应用阶段计算了物体模型运动后的位置,每个顶点内存储的坐标信息都得到了更新,但这个位置信息并不是可以直接使用的。物体模型有自己的坐标系,顶点中存储的就是物体模型自己的坐标系坐标,但我们要渲染的是一张以摄像机为视角的画面,所以我们要将物体坐标系转换成世界坐标系,再转换成摄像机坐标系,这一系列的坐标转换就是顶点计算步骤所做的重要事情。
顶点计算完成后,接下来将这个准备就绪的3D空间通过投影转换成2D图像。渲染中常用的两种投影方式是 Orthographic 和 Perspective 。下图 a 为 Orthographic ,b 为 Perspective 。
在上图中,圆柱体与摄像机的距离比正方体与摄像机的距离要小,在这种情况下,使用 Orthographic 投影出来的图像,圆柱体和正方体一样大,使用 Perspective 投影出的图像,圆柱体比正方体小, Perspective 投影方式可以实现近大远小的效果。
和 Perspective 投影相关的还有一个重要概念,齐次坐标。人们接触过得很多图像都遵循近大远小的规则,就像下面这张图片,近处的小女孩很大,远处的埃菲尔铁塔很小。仅仅使用物体模型的(x, y, z)坐标是无法实现近大远小的效果的,需要使用(x, y, z, w)齐次坐标,其中w表示深度,用它来标识物体模型的远近程度。
将物体模型的(x, y, z)坐标用(x, y, z, w)齐次坐标表示后,投影出来的物体模型坐标是(x/w, y/w, z/w),由此看来好像是w控制着x、y、z的缩放,其实不是,事实上是z在控制着w,z决定w的大小,z越大,物体越远,w就越大,而对应的投影后的x和y越小。
顶点计算和投影做完后,意味着3D信息转化成2D信息基本完成,裁剪步骤是将整个场景的2D信息中视角范围的部分(要显示在屏幕上的部分)裁剪出来,去除多余的部分,只有裁剪出来的部分才能进行光栅化处理。
屏幕映射是将物体的3D坐标转换成屏幕的2D坐标,方便之后光栅化阶段的像素处理。
光栅化阶段主要是对屏幕的每个像素点进行处理。这个阶段可以分成两个步骤:像素着色和合并,像素着色是高度可编程的,可以自定义计算机要进行的着色计算,合并是高度可配置的,可以决定计算出来的颜色该如何使用。
经过几何阶段的处理,我们已经拿到了2D信息,接下来可以针对屏幕上的每个像素点进行处理了。像素着色是根据该像素点的位置、光照、纹理等信息计算出该像素点的颜色。像素着色计算完毕后,会把颜色结果存储在 ColorBuffer 中,ColorBuffer 是一块用来存放RGB颜色的内存。
一般来说,渲染场景内的多个物体时按照它们从远到近的顺序渲染才能保证远处的物体不会遮挡住近处的物体,但实际上,渲染管线并不会遵循这个规则来渲染物体模型,并且也能正常渲染好各个物体,之所以能顺利完成这个工作,是因为有 DepthBuffer 的支持,DepthBuffer 是一块用来存放像素深度的内存,里面存放了每个物体模型的深度值。当渲染某一个物体模型时,如何处理该物体与其他物体的重合处,是拿当前物体的深度值和重合物体的深度值作比较,来判断重合的地方该渲染谁的颜色,判断是谁遮挡了谁。有了 DepthBuffer 的支持,渲染管线不必按照物体从远到近的顺序去渲染物体模型。
以上说的渲染方式大多数情况下适用于非透明物体,而透明物体一般是没有记录深度值的,透明物体按照从远到近的顺序来渲染。另外,透明物体会在非透明物体渲染好之后再被渲染。
实时渲染中,实现半透明渲染的通常做法是将在场景不透明物体完成渲染之后,对场景中半透明物体到摄像机的距离进行排序,从距离摄像机最远的(Z值最大)的物体开始逐个叠加渲染,并在渲染中与输出缓冲中的原有颜色进行混合叠加。对于 2D 半透明渲染,这样的实现是足够的,而在 3D 场景中,由于排序基于物体的轴点位置,渲染时会出现以物体为单位的覆盖效果,例如物体间循环覆盖的效果(如下图),就无法被正确的渲染。此外对于一些自身具有复杂结构的半透明物体,自身三角形的渲染次序也会影响画面的观感。这种情况下,需要像渲染非透明物体一样使用深度值来渲染透明物体。
你可以粗浅地认为上述整个渲染管线中应用阶段和几何阶段是由CPU负责的,光栅化阶段是由GPU负责的,最重要的一点是,GPU才是那个将 ColorBuffer 的颜色填充到每个像素点的人,GPU才是那个负责最后的2D图像绘制的人。
有两个概念需要提一下,那就时渲染频率和显示频率,这是两个不同的概念,渲染频率指的是每秒能渲染出来的图像数量,显示频率指的是每秒能绘制的图像的数量,也就是平常说的帧率。
之前也说过,在像素着色的时候,会把一张将要显示的图像的每个像素点的颜色存储在 ColorBuffer 中,假设我们只有一个 ColorBuffer , 根本没办法连贯地渲染一连串的图像,在第一张图像渲染完成后,GPU将 ColorBuffer 拿去做绘制显示,这个 ColorBuffer 在显示期间将一直被占用,导致CPU没有另外的 Buffer 拿来做第二张图像的计算。
所以至少需要两个 ColorBuffer 用于渲染期间的交替使用,下图展示的就是使用两个 ColorBuffer 的工作流,VSync 代表帧率,屏幕占用 ColorBufferA 显示图像A时,GPU和CPU可以使用 ColorBufferB 来渲染图像B,下一帧再将渲染好的图像B显示出来,两个 ColorBuffer 如此交替即可实现连贯地图像渲染。
但是即使使用了两个 ColorBuffer 也还是存在一个问题。上图中保证两张图片之间能够流畅切换有一个重要前提,那就是渲染频率小于帧率,在显示下一张图像前就将这张图像渲染完成,这件事在现实中是没办法保证的,当渲染频率大于帧率时,就会出现下面的情况。
在屏幕想要切换图像时,发现下一张图像(B)还没有被渲染好,它就会继续显示当前图像(A),直到下一次切换图像,才会显示下一张图像(B),或许你会发现这样一个问题,假设在渲染频率小于帧率的情况下,渲染工作进行得非常理想,此时的帧率是20,后来渲染频率变得比帧率稍慢一点,导致每两次显示机会都在显示同一张图像,那么此时帧率为10——仅仅是因为渲染频率下降了一点,帧率就减半了,这显然是一种不合理的损失。
或许你会指出,在切换图像时,为什么不能把图像B已经渲染好的部分先拿来绘制,因为即使这样做,也不会有特别好的效果。
就像这张图像一样,绘制一半的图像B和剩下的另一半图像A,这种效果也不怎么样。
为了优化这个问题,可以使用三个 ColorBuffer 。
使用三个 ColorBuffer 或许在刚开始渲染的时候还是没办法避免卡顿,但后来就可以很流畅了。
渲染相关的基础知识我暂时就学习了这么多。