Games101(3): Shading
课程链接:GAMES101-现代计算机图形学入门-闫令琪
课程讲师:闫令琪
本系列笔记为本人根据学习该门课程的笔记,仅分享出来供大家交流,希望大家多多支持GAMES相关讲座及课程,如涉及侵权请联系我删除:albertlidesign@gmail.com
关于光栅化还有一点关于Z-Buffering的内容,我们在这里先做一个补充。前面的课程我们知道了屏幕就是一堆像素,如何光栅化一个三角形,一些采样理论的知识和如何进行反采样,就是先做模糊再做采样。但是上一讲我们只说了频域分析,介绍了先模糊再采样是正确的,但是没有讲为什么先采样再模糊是错的。这里给大家一个提示:先采样就是把信号的频谱进行搬移,它会有频谱的混叠,然后模糊就是在混叠之后截断信号,这里就会发现混叠的信号截断了还是混叠的。(采样=频谱搬移,模糊=截断)
Z-Bufferring
众所周知,空间中会有好多三角形,三角形各自离相机的距离也不一样,那么我们该如何将这些三角形画在屏幕上并且它们的遮挡关系是对的呢?近处的永远要遮挡远处的。在这里我们使用的方法就是深度缓存(Z-Buffering)。
Painter's Algorithm
场景中有很多不同的物体,既然要把这些物体放在屏幕上就要涉及到顺序的问题,以保证这张图是对的。一个比较直观的方法是像画水粉、油画一样,先把最远的物体绘制出来,再把近的物体覆盖在远的物体上,层层叠加来形成最终的结果。
画家先画远处的山,再画近处的草地,最后再在草地上画上树。这样由远及近地对物体做光栅化的算法就称之为画家算法(Painter's Algortihm)。例如如果我们画一个立方体,可以先画最远的面,然后再画周围的四个面,最后绘制最前方的面,这样就能出正确的结果。但是仔细思考会发现,如果绘制的顺序必须要非常严格,如果错了会造成多余的线被绘制。因此在一定程度上说,这个算法是可以的,它要求对所有物体的深度进行排序,排序的复杂度为(对于个三角形)。但是当三个空间三角形构成一组互承关系时,三个两两之间存在了覆盖关系,这样就没法定义它们之间的深度关系,也就无法对它们进行排序。因此为了解决这个问题,人们提出了Z-Buffer。
Z-Buffer
在图形学里,人们广泛采用了Z-Buffer,引入了深度缓存的概念。这一算法其实就是避免空间中三角形的排序,它是从像素的角度来检测能看到的三角形,对于每一个像素去记录它所看见的最浅的深度的物体,也就是距离相机最近距离的物体。在图形学中,我们不光能渲染出最后的结果(Frame Buffer),在生成这个图像的同时,我们还能得到一个深度图(Depth Buffer),它记录了每个像素所看到的几何图形的最浅深度的信息。我们利用深度图来维护遮挡信息。
在变换中,我们提到,我们始终假设相机是放在原点,并且看向,这样所有的点的坐标都是负的,并且它们越小,说明离得越远。为了方便理解,现在我们换一个概念,我们把它看作是点到摄像机的距离,这样就总是正的,那么值越小,物体越近。
如上图所示,对于一个像素会记录地板的深度和物体的深度,假设对于有一个能看到物体的像素,它先记录了地板的深度,接着再记录物体的深度,如果物体的深度比地板要小,那么意味着这个物体要遮挡住地板,这个点就要画上看到物体的颜色,然后右侧的深度图也会做相应的更新。这一流程的伪代码如下:
// first step: initialize depth buffer to infinite
for (each triangle T)
for(each sample (x,y,z) in T)
if (z<zbuffer[x,y]) // closest sample so far
framebuffer[x,y] = rgs; // update color
zbuffer[x,y] = z; // update depth
else
; // do nothing, this sample is occluded
注意:为无限大的一个值。初始化时要把所有的像素定义为无限大(或者足够大的一个值)。
在使用画家算法时,我们要把所有的三角形做一次排序,这需要花费的时间。而深度缓存,每个三角形都覆盖常数个像素的话,无非就是考虑每个三角形所覆盖的常数个数的像素,也就是的算法复杂度。它并没有进行排序,只是不断地更新结果,记录当前所看到的最小深度值,因此只花了线性的时间。
这里要注意,不会出现两个不同的三角形在同一个像素上有着相同的深度,因为深度缓存算法与顺序是没有关系的了,不管通过什么顺序绘制三角形,最后的结果都会是相同的。在图形学中,我们都是用浮点型来表示的,浮点型和浮点型判断相等是非常困难的事,基本上可以认为两个浮点数是完全不会相同的。此外,这一算法可以应用在几乎所有的硬件中。
之前提到,为了做反走样,我们使用了MSAA算法,对一个像素取很多采样点,对于这些不同的采样点,如果我们要运用深度缓存,就要对每一个采样点检测它所能看到的深度,因此Z-Buffer还得考虑到它不是对每个像素记录深度,实际上是对每个采样点做记录。
Shading
到目前为止,我们已经学习了如何将物体随着相机,变换到相机位于原点看向方向时的位置,即View Transformation,接着我们可以将模型映射到二维的从到的屏幕上,接着根据二维屏幕中的像素与三角形之间通过采样求出像素的值,也就是光栅化。
目前所能做的事表现出来就是这样的
而我们期待的结果是这样的,它与我们目前做到的结果的区别在于着色。
在下面的渲染图中,可以看到杯子中有茶、蛋挞、葡萄,不同的物体会有不同的颜色,在不同的光照下,这些物体上的颜色会有变化,这都是着色的问题。
着色 (Shading)在Merriam-Webster字典里的意思为:
shad·ing, noun, The darkening or coloring of an illustration or diagram with parallel lines or a block of color.
在我们这门课中,它的含义是:The process of applying a material to an object. 即对不同物体应用不同材质的过程。不同的材质和光照会发生不同的作用效果。
Blinn-Phong
最基础的着色模型为Blinn-Phong反射模型。通过下图几个茶杯,我们可以看到光源应该在右上方,在每个茶杯上有高光(Specular highlights),茶杯的其他部位的变化相对不明显,这部分叫做漫反射(Diffuse reflection)。对于最底下的茶杯的最左侧,光源从右上方照射,按说这个茶杯的最左侧不应该为黑色,因为这部分没有被光直接照射到,但为什么这里有颜色呢?是因为间接光或环境光(Ambient lighting),它是由光线在其他物体之间发生反弹从而照亮这部分区域的。任何一个点都会接收到来自环境的反射光,它很复杂,我们在后面会讲到。
我们看到一共有三部分,高光、漫反射和间接光,我们可以把这三部分分别做出来从而得到一个很相似的结果。在开始之前,我们需要定义一些东西。
- 我们现在考虑光照是对任何一个点而言,假设这个点叫做shading point,那么这个点的着色结果是什么?我们定义在一个极小的范围内,它是一个平面,即与它所在的曲面相切的平面
- 既然是平面,那么就有法线垂直于平面
- 同样我们还可以定义一个观测方向,我们规定从shading point到相机的方向为观测方向
- 同样道理,从shading point到光源的方向称为光照方向
- 注意,因为我们只关心这些向量的方向,所以它们都是单位向量,长度为
- 我们还需要定义一些表面参数,比如颜色,亮度(shininess)等
Diffuse Reflection
Blinn-Phone有三个不同的部分,我们从最简单的漫反射开始。当有一根光线打到物体表面上的某一点,光线会被均匀地反射到不同方向上去,这个过程叫漫反射。
当我们考虑shading point所在表面的朝向与光照方向有一定夹角的时候,会发现得到的明暗是不一样的。如图所示,假如光是离散的,有六根光线打到表面上,每一根光线代表一个固定的能量,如果表面和光线垂直的话会接收到所有的光,但是如果表面旋转到了某一个角度,比如,我们会发现只接受到了三根光线,那么就会变得暗一些。因此会发现表面的明暗会与光线与法线的夹角存在一定关系。为了量化,我们用shading point周围的单位面积来定义接收到的能量,它与夹角有关,这就是Lambert的余弦定律。
提到了接收能量也不得不提一下发散能量。不同的物体被光所照亮,光是一种能量,在这里我们认为它是一个点光源,它会无时无刻地在向外辐射出不同的能量,一个很聪明的观测方法是一个点向外发散能量,那么在某一时刻,它们一定集中在某一个球壳上。根据能量守恒定律,离中心近的球壳和离中心远的球壳的能量应该是完全相同的能量,但是随着球壳离中心越来越远,其表面积会越变越大,那也就意味着在某一个点,它的能量会越来越少。因此我们定义在距离为的地方定义光的强度为,如果传播到距离为的球壳上时,它的能量为。这就告诉了我们,光在某一时刻某一位置所能传播的能力是与它与光源的距离的平方成反比的。光线传播的距离越长,所能接收到的能量越小。也就是说,只要知道一个点光源,又知道shading point离光源的距离,那么就能知道有多少光传播到了当前的shading point。
根据前面我们又知道了有多少光能被接收,我们就得到了diffuse的表示方法了:
假设我们有一个点光源,假设它与shading point的距离为,我们定义在单位距离上它的强度为,那么我们就能求出它到达shading point处的能量,我们有算出了有多少光能被接收,就能算出我们最后所看到的在这一点上的能量。做一个是说,当向量点乘为负数的时候,这是说光从背面穿过了物体达到了shading point,显然是不可能的,因此没有物理意义,因为我们考虑的是反射,因此当点乘为负时就认为是。我们再来考虑一个问题,对于shading point,它自己本身为什么会有颜色,是因为这个点会吸收颜色,或者说能量,它反射出去的是不吸收的颜色。如果我们在能量被接收后定义一个系数,表示漫反射系数,如果为就是最亮,如果为那么表面就是黑的,那么如果我们把它表示成一个三通道的向量就可以表达它对RGB三个颜色的反射程度。
既然光打到shading point被反射到各个方向,那就意味着无论我们从哪观测它,所看到的结果应该是一样的,从公式上看也是如此,我们考虑的是光线与法线的夹角,因此漫反射跟观测角度无关完全无关。
(可以想想,你处在一个黑屋子里,眼前一个石膏球被照亮,无论你从哪个角度看这个石膏球,除了被照亮的部分其他地方都看不到,因此漫反射与观测角度无关)
需要注意的一点是:我们说的着色是在一个点上进行着色,如果想要得到一整张图就要着色很多次。
Specular Term
高光有一个特点:它的反射方向非常接近镜面反射的方向。如果是镜面,这个物体就是无限光滑的,我们可以根据入射方向和法线来求出它的出射方向,如图所示。如果物体是金属,那么这个物体就没有那么光滑,它的反射方向会沿着分布,当我们的观察方向和镜面反射方向接近的时候,我们就能看到高光了,其他时候我们都看不到高光。这就告诉了我们,高光项和我们观察的方向及镜面反射方向有关。
Blinn-Phong模型做了一个很聪明的事,因为当我们的观察方向和镜面方向接近的时候,其实就说明法线方向和**半程向量(Half Vector)**很接近。这是说,如果给定入射方向和出射方向,我们可以求它的角平分线方向,只需要将两个向量相加,根据平行四边形法则,然后再做归一化即可得到两向量的半程向量。如果此时和接近,一定程度上就可以反映和接近。这样的话,就能根据和的点乘的结果来算出高光,得出如下图所示公式。又因为通常高光都是白色,所以高光系数为白色的值。当然,我们还要考虑有多少能量被吸收,也就需要加上一项和的点乘,这里没有考虑是因为Blinn-Phong模型是经验性模型,这里将其简化掉了,其主要关注的是是否能看到高光。
那么为什么要用半程向量而不是直接用和呢?当然可以,那个模型就被称作Phong Reflection Model,Blinn-Phong是它的一个改进。这是因为半程向量太好算了,而反射方向就不那么好算了,计算量要大很多。除此之外,观察上图公式,我们在和的点乘加了一个指数,这是因为尽管向量之间的夹角余弦值能体现两个向量是否足够接近,但是容忍度太高了,比如时,它的余弦值仍然很大,如果我们只用夹角余弦去做高光的话会得到一个超级大的高光,看上去就很不自然,我们平常认为高光是非常亮的并且集中在很小的区域中。所以我们要对夹角余弦加上若干个指数就能得到较为合理的结果,正常情况下我们用的指数要达到。
下面是一个实际的例子,显示了漫反射和高光项在一块的效果,我们会发现随着指数的增长,高光会越来越小,因此指数就是用来控制高光的大小的参数。
Ambient Term
环境光是一个非常复杂的东西,我们做一个非常大胆的假设(但是事实上不是这么回事),假设任何一个点所接收到的来自环境的光永远都是相同的,我们记作,再给定一个系数就可以直接近似地来得到环境光。观察图我们知道,环境光跟视点无关,跟光源位置也无关,并且和法线也没关系,因此环境光其实是一个常数,也就是某一种颜色。比如你看到一个物体,任何一个地方都有一个常数的颜色,总会得到一个“平”的结果。环境光的作用就是保证没有地方是黑的。但实际上如果我们需要很精确地计算它,就需要全局光照的知识。
现在我们把所有的项都加起来就可以看到一个完整的Blinn-Phong Reflection Model。我们知道Blinn-Phong是个着色模型,它对所有的点都进行了着色。
Shading Frequencies
接下来讨论着色频率的问题,首先我们有这三个球,这三个球有着完全相同的几何形状(一模一样的模型),这从观察边界可以得出,那么为什么着色之后我们得到的结果各不相同呢?这就是因为不同的着色频率,即着色运用到哪些点上。如果我们把着色运用到网格面上,一个平面只做了一次shading,就能得到下图最左侧的结果。中间的结果是对每一个网格面上的顶点进行着色的。先求出它们的法线再对每一个顶点做一次着色,在面的内部通过插值的方法算出来。最右侧的着色是对每一个像素进行着色的。也就是说,我们对每一个四边形或三角形的顶点求出一个法线,然后把法线的方向在三角形内部进行插值,然后就得到任何一个像素自己的法线方向,并且可以做着色。也就是说,如果着色运用到像素上就可以得到非常好的结果。
下面我们对这些方法来做一个正规的定义:
-
每一个三角形都是平面,每个三角形的法线都非常容易求出,只需要将三角形的两个边做叉积,这样就可以算出一个shading结果,但也自然在三角形内部不会有着色的变化,也就是每个三角形面内各点的颜色是完全一样的。当然这个结果不太好,但是有它自己的名字,称为Flat Shading。
-
我们可以在任意一个顶点处求出它的法线,对每个顶点做一次着色,然后通过插值计算出每个三角形内部的颜色,得到的结果要比Flat Shading要好,但是当三角形大一点的话,高光可能就看不见了,因此它的效果也是有局限性的。这样的着色叫Gouraud Shading。
-
如果我们对于每一个像素,求出各三角形顶点的法线,然后在每一个像素都插值出一个法线方向,再对每一个像素进行一次着色,就能得到相对比较好的结果,这个结果就叫做Phong Shading。注意Blinn-Phong是一种着色模型,这里的Phong Shading指的是着色频率。
这三种着色具体的区别其实也取决于具体的模型,并不是说Flat Shading就一定会很差,下图中,每一行的模型都是完全一样的,每一列是不同网格顶点数的区别,也就是说当我们的几何模型相对复杂的话,其实也可以用一些相对简单的着色模型,而且得到的结果还可以。着色频率取决于面、点数量。当然,Phong Shading的着色效果好,其计算量当然也比Flat Shading大很多(但也不绝对,如果面数超过了像素数那么Phong Shading可能更小),所以具体用哪种着色方法要取决于具体的物体。当面数不是特别多的情况下,Phong Shading能得到一个较好的结果。
Defining Per-Vertex Normal Vectors
在Gouraud Shading中我们要对每一个顶点求法线,那么该如何做呢?
最好最简单的方法是,如果使用网格模型想拟合的模型,比如球模型,去求这几个网格点所在的球模型上的法线即可。但是它的应用情况会比较少。
第二种方法,一个顶点通常会位于多个三角形面上,即这多个三角形面共用该顶点,那么我们只需要求过该点的三角形面的法相的平均即可,也可以以为三角形面积为权重做加权平均。
Defining Per-Pixel Normal Vectors
下面一个问题是如何去定义一个逐像素的法线?我们要通过重心坐标的方法来进行插值,下面会详细讲,这里注意求出来的法线都要做一个归一化的处理,以保证它们的长度是一致的。
Graphics(Real-time Rendering) Pipeline
现在,把前面所有的知识合在一起就已经能够得到一个渲染的结果了。把这所有的东西合在一块就叫做图形管线(Graphics Pipeline),闫老师更愿意叫做实时渲染管线。当我们输入三维空间中的一些点,中间经历了什么样的过程,这个过程就叫Pipeline,其实就是一系列的操作。
如下图所示,下面整个的过程就是从三维场景到最后看到的二维像素的过程。而这个过程是已经在硬件里写好了,显卡所做的整个的操作就是这样的操作。
(1)输入三维空间中的点;
(2)投影变换,我们将三维空间中的点投影到了屏幕上;
(3)形成三角形
(4)屏幕是离散的,因此要通过光栅化来把三角形变成Fragments(OpenGL中的概念,类比于像素)
(5)着色Fragments
(6)显示
一个小问题:为什么说我们在投影到屏幕上再连接三角形而不是一开始输入三角形呢?其实是一样的,因为顶点无论如何变换,其连接关系是没有变的,因此在输入的时候用上连接关系形成三角形还是在投影之后形成三角形没有区别。
(1)Vertex Processing:对空间中每一个顶点做MVP变换。
(2)Rasterization:对每个像素采样判断是否在三角形内,即光栅化。
(3)Fragment Processing:判定像素是否可见(也可以归为光栅化)。
(4)Shading:Shading可以发生在顶点处理上也可以发生在Fragment处理上。
注意,这两部分是可编程的,即我们可以自己去决定如何运作,这部分代码称为Shader,它是控制这些顶点和像素如何着色的。
(5)Texture mapping
Shader Programs
前面提到了Shader,我们这里更详细了解一下。现代的GPU允许用户通过编程来解决顶点和像素如何做着色,这就需要用户来自己写Shader,Shader本质上就是一个能在硬件上执行的程序。OpenGL作为图形学的API,可以用它来写Shader,它是对每一个像素所执行的通用的程序,因此不需要写For-Loop。如果我们写的是顶点的Shader,就叫做顶点着色器(Vertex-Shader),如果是对像素的操作就叫做片段(或像素)着色器(Fragment-Shader or Pixel-Shader)。
下面是一个具体的例子,像素着色器是要确定像素最后的颜色,即写清楚怎么计算像素的颜色并且输出出去。这个例子是简单的着色语言GLSL。
uniform sampler2D myTexture; // uniform指的是全局变量,定义了一个纹理
uniform vec3 lightDir; // 固定的光照方向
varying vec2 uv;
varying vec3 norm; // 插值出来的法线
void diffuseShader()
{
vec3 kd;
kd = texture2d(myTexture, uv); // 每一个像素可以拿到一个漫反射系数,具体操作跟纹理相关,暂时忽略
kd*=clamp(dot(-lightDir, norm), 0.0, 1.0); // 一个最最简单的漫反射的部分,用clamp限定到[0,1]
gl_FragColor = vec4(kd, 1.0); // 将三维向量转四维向量,返回到gl_FragColor
}
因此 Shader能够定义顶点、像素如何操作。现在就已经把整个实时渲染的基本思路涵盖到了,在这个基础上,就已经可以去学习一系列图形API了,会发现非常简单,所有的矩阵都不需要自己来做,都可以借助API来生成,可以很方便的写出来。这里推荐一个叫ShaderToy的网站,在这里可以只写着色器,即顶点和像素如何着色,就可以通过这个web来执行程序,就可以看出结果。Shader可以做到千变万化。
随着现在GPU的发展,显卡可以同时处理大量的几何,并且着色非常快,高度并行。现在的图形学就是向一个能够实时渲染超级复杂的场景发展。随着GPU的发展,有越来越多不同的着色器产生,比如一种叫Geometry Shader,它可以动态的产生三角形。还有一种Compute Shader可以做通用的GPU计算,称GPGPU。还需要提到,GPU分两种,一种是独立显卡,另一种是集成显卡。GPU本身可以理解为高度并行化处理器,CPU通产有8核、16核等,GPU的核数是CPU的很多很多倍,所以特别适合来做图形,因为很多像素的着色方法是一样的,这就非常利于做并行计算。