课程链接:GAMES101-现代计算机图形学入门-闫令琪
本篇笔记为本人学习上述课程总结之笔记,仅供交流
1 Shading
到目前为止,我们已经学习了如何将物体随着相机,变换到相机位于原点看向-Z方向时的位置,即View Transformation,接着我们可以将模型映射到二维的从\((0,0)\)到\((w,h)\)的屏幕上Project 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. 即对不同物体应用不同材质的过程。不同的材质和光照会发生不同的作用效果。
2 Blinn-Phong
最基础的着色模型为Blinn-Phong反射模型。通过下图几个茶杯,我们可以看到光源应该在右上方,在每个茶杯上有高光(Specular highlights),茶杯的其他部位的变化相对不明显,这部分叫做漫反射(Diffuse reflection)。对于最底下的茶杯的最左侧,光源从右上方照射,按说这个茶杯的最左侧不应该为黑色,因为这部分没有被光直接照射到,但为什么这里有颜色呢?是因为间接光或环境光(Ambient lighting),它是由光线在其他物体之间发生反弹从而照亮这部分区域的。任何一个点都会接收到来自环境的反射光,它很复杂,我们在后面会讲到。
- 我们现在考虑光照是对任何一个点而言,假设这个点叫做shading point,那么这个点的着色结果是什么?我们定义在一个极小的范围内,它是一个平面,即与它所在的曲面相切的平面
- 既然是平面,那么就有法线\(\vec n\)垂直于平面
- 同样我们还可以定义一个观测方向,我们规定从shading point到相机的方向为观测方向\(\vec v\)
- 同样道理,从shading point到光源的方向称为光照方向\(\vec l\)
- 注意,因为我们只关心这些向量的方向,所以它们都是单位向量,长度为\(1\)
- 我们还需要定义一些表面参数,比如颜色,亮度(shininess)等
Diffuse Reflection
Blinn-Phong反射模型分为三个不同的部分,我们从最简单的漫反射开始。当有一根光线打到物体表面上的某一点,光线会被均匀地反射到不同方向上去,这个过程叫漫反射。(漫反射与观察方向无关)
当我们考虑shading point所在表面的朝向与光照方向的夹角不同的时候,会发现得到的明暗是不一样的。如图所示,假如光是离散的,有六根光线打到表面上,每一根光线代表一个固定的能量,如果表面和光线垂直的话会接收到所有的光,但是如果表面旋转到了某一个角度,比如,我们会发现只接受到了三根光线,那么就会变得暗一些。因此会发现表面的明暗会与光线与法线的夹角存在一定关系。为了量化,我们用shading point周围的单位面积来定义接收到的能量,它与夹角有关,这就是Lambert的余弦定律。
提到了接收能量也不得不提一下发散能量。在这里我们认为光是一个点光源,它会无时无刻地在向外辐射出不同的能量,一个很聪明的观测方法是一个点向外发散能量,那么在某一时刻,它们一定集中在某一个球壳上。根据能量守恒定律,离中心近的球壳和离中心远的球壳的能量应该是完全相同的能量,但是随着球壳离中心越来越远,其表面积会越变越大,那也就意味着在某一个点,它的能量会越来越少。因此我们定义在距离为\(1\)的地方定义光的强度为\(\it I\),如果传播到距离为\(r\)的球壳上时,它的能量为\(\it I/r^2\)。这就告诉了我们,光在某一时刻某一位置所能传播的能力是与它与光源的距离的平方成反比的。光线传播的距离越长,所能接收到的能量越小。也就是说,只要知道一个点光源,又知道shading point离光源的距离,那么就能知道有多少光传播到了当前的shading point。
这样我们就得到了diffuse的表示方法:
假设我们有一个点光源,假设它与shading point的距离为\(r\),我们定义在单位距离上它的强度为\(\it I\),那么我们就能求出它到达shading point处的能量\(\it I/r^2\),我们又通过lambert定律算出了有多少光能被接收。max函数的作用是取其中大值。当向量点乘为负数的时候,这是说光从背面穿过了物体达到了shading point,显然没有物理意义,因为我们考虑的是反射,因此当点乘为负时就认为是\(0\)。我们再来考虑一个问题,对于shading point,它自己本身为什么会有颜色,是因为这个点会吸收颜色,或者说能量,它反射出去的是不吸收的颜色。如果我们在能量被接收后定义一个系数,表示漫反射系数,如果为\(1\)就是最亮,如果为\(0\)那么表面就是黑的,那么如果我们把它表示成一个三通道的向量就可以表达它对RGB三个颜色的反射程度。
既然光打到shading point被反射到各个方向,那就意味着无论我们从哪观测它,所看到的结果应该是一样的,从公式上看也是如此,我们考虑的是光线与法线的夹角,因此漫反射跟观测角度无关完全无关。
需要注意的一点是:我们说的着色是在一个点上进行着色,如果想要得到一整张图就要着色很多次。
Specular Term
高光有一个特点:它的反射方向非常接近镜面反射的方向。如果是镜面,这个物体就是无限光滑的,我们可以根据入射方向和法线来求出它的出射方向,如图\(\vec R\)所示。如果物体是金属,那么这个物体就没有那么光滑,它的反射方向会沿着\(\vec R\)分布(图示黄色部分),当我们的观察方向和镜面反射方向接近的时候,我们就能看到高光了,其他时候我们都看不到高光。这就告诉了我们,高光项和我们观察的方向及镜面反射方向有关。
Blinn-Phong模型做了一个很聪明的近似,因为当我们的观察方向和镜面方向接近的时候,其实就说明法线方向和半程向量(Half Vector)很接近。也就是说,如果给定入射方向\(\vec l\)和出射方向\(\vec v\),我们可以求它的角平分线方向,只需要将两个向量相加,根据平行四边形法则,然后再做归一化即可得到两向量的半程向量\(\vec h\)(计算简单)。如果此时\(\vec h\)和\(\vec n\)接近,一定程度上就可以反映\(\vec v\)和\(\vec R\)接近。这样的话,就能根据\(\vec n\)和\(\vec h\)的点乘的结果来算出高光,得出如下图所示公式。又因为通常高光都是白色,所以高光系数\(k_s\)为白色的值。当然,我们还要考虑有多少能量被吸收,也就需要加上一项\(\vec l\)和\(\vec n\)的点乘,这里没有考虑是因为Blinn-Phong模型是经验性模型,这里将其简化掉了,其主要关注的是是否能看到高光。
那么为什么要用半程向量而不是直接用\(\vec v\)和\(\vec R\)呢?当然可以,那个模型就被称作Phong Reflection Model,Blinn-Phong是它的一个改进。这是因为半程向量太好算了,而反射方向就不那么好算了,计算量要大很多。除此之外,观察上图公式,我们在\(\vec n\)和\(\vec h\)的点乘加了一个指数\(p\),这是因为尽管向量之间的夹角余弦值能体现两个向量是否足够接近,但是容忍度太高了,比如\(45°\)时,它的余弦值仍然很大,如果我们只用夹角余弦去做高光的话会得到一个超级大的高光,看上去就很不自然,我们平常认为高光是非常亮的并且集中在很小的区域中。所以我们要对夹角余弦加上若干个指数就能得到较为合理的结果,正常情况下我们用的指数要达到[100,200]。
下面是一个实际的例子,显示了漫反射和高光项在一块的效果,我们会发现随着指数的增长,高光会越来越小,因此指数就是用来控制高光的大小的参数。
Ambient Term
环境光是一个非常复杂的东西,我们做一个非常大胆的假设(但是事实上不是这么回事),假设任何一个点所接收到的来自环境的光永远都是相同的,我们记作\(I_a\),再给定一个系数\(k_a\)就可以直接近似地来得到环境光。观察图我们知道,环境光跟视点无关,跟光源位置也无关,并且和法线也没关系,因此环境光其实是一个常数,也就是某一种颜色。比如你看到一个物体,任何一个地方都有一个常数的颜色,总会得到一个“平”的结果。环境光的作用就是保证没有地方是黑的。但实际上如果我们需要很精确地计算它,就需要全局光照的知识。
现在我们把所有的项都加起来就可以看到一个完整的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的很多很多倍,所以特别适合来做图形,因为很多像素的着色方法是一样的,这就非常利于做并行计算。
「如果这篇文章对你有用,请随意打赏」
如果这篇文章对你有用,请随意打赏
使用微信扫描二维码完成支付

comments powered by Disqus