最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render
我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的图形学渲染算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。
一、重构代码
我们先来一起看看中的内容吧,看看它是如何执行工作的。
首先,我们定义了少数的全局常量:屏幕维度、相机位置、等等。
Secondly,我们看看中包含的内容:
- 解析.obj文件
- 矩阵的初始化(这些矩阵的实际栗子是在our_gl的模块中的)
- 遍历model模型中的所有三角形;还有每一个三角形的栅格化(函数中有两个for循环,第一个for遍历了screen_coords和triangle(),第二个for则是screen_coords对应的vertex)
最后一步的遍历是很有趣的。
外层循环遍历了所有的三角形。
内层循环遍历了当前这个三角形中的所有顶点,同时,对于每一个顶点都调用了顶点着色器Vertex Shader。
注意:建立顶点着色器的主要目标是转换所有顶点的坐标,将坐标从世界空间转换到屏幕空间。
第二个主要目标则是为片段着色器准备数据。
那么接下来是什么呢?
我们调用了光栅化例程。
然后光栅格化器为每一个像素都调用了光栅化例程,也就是,片段着色器。
同样地,对于三角形中的每一个像素,光栅格化器将会调用片段着色器。(对于三角形中的每个像素,光栅格化器在为每一个像素调用完光栅化例程后,会再调用一次片段着色器)
该片段是在triangle()函数中的。可以看到,最后的画图的color,是经过Fragment Shader处理过的。
那么说了半天的片段着色器,片段着色器主要完成的目标是什么呢?
第一个需要完成的目标就是确定当前像素的颜色;
第二个目标是我们可以通过来丢弃当前这个像素。
有人会问,的图形渲染管线是什么?
- 得到Vertex Data
- 数据处理Primitive Processing
- 顶点着色器Vertex Shader
- 图元组装Primitive Assembly
- 光栅化Rasterizer
- 片段着色器Fragment Shader
- 深度值和模板值Depth & Stencil
- 混合颜色缓冲区Color Buffer Blend
- 抖动(数字信号处理)Dither
- 帧缓冲器Frame Buffer
获取顶点数据-->图元处理-->顶点着色器-->图元组装-->光栅格化器-->片段着色器-->得到深度值和模板值-->混合颜色缓冲区-->抖动-->帧缓冲器
我们这次关于OpenGL2的主要应用是顶点着色器和片段着色器,当然新的OpenGL还有新的着色器,可以动态生成几何图形。
实际上,我们的就是图元处理的实例。
它调用了。我们这里没有图元组合,因为我们画的三角形都是最基本的。
在我们的代码中,图元组合是和图元处理(primitive processing)合并的。
其中的函数,就是光栅格化器(rasterizer),对于三角形中的每个顶点,它都调用了,也就是片段着色器,然后则是执行深度检查(z-buffer)等等。
现在大家都知道了Shaders是什么样的,那么我们就可以创造出我们自己的shaders了。
让我们再来熟悉一下之前写的shader:
Vec3f varying_intensity; // written by vertex shader, read by fragment shader
// 由顶点着色器写入,由片段着色器读取
virtual Vec4f vertex(int ith_face, int nth_vert)
//顶点vertex是vec<4, float>,一直不明白这个4是通道数的意思吗?
{
Vec4f gl_Vertex = embed<4>(model->vert(ith_face, nth_vert));
//read the vertex from .obj file
gl_Vertex = ViewPort * Projection * ModelView * gl_Vertex;
//transform gl_Vertex to screen coordinates
varying_intensity[nth_vert] = std::max(0.f, model->normal(ith_face, nth_vert) * light_dir);
// get diffuse lighting intensity 获得漫射光强
return gl_Vertex; //返回顶点
}
- GLSL-OpenGL Shading Language。
varying是一个在GLSL中的保留关键字,我们上面使用了变量,目的是为了显示对应关系。
在变量中,我们存储要在三角形中进行插值的数据,然后片段着色器可以得到插值(针对当前的像素)。
让我们重新来看下片段着色器:
Vec3f varying_intensity; //written by vertex shader, read by fragment shader
// [...]
virtual bool fragment(Vec3f bar, TGAColor &color)
{
float intensity = varying_intensity * bar;
// interpolate intensity for the current pixel
color = TGAColor(255, 255, 255) * intensity;
// intensity应该是一个1×3的矩阵,从而可以得到不同的颜色
return false;
// return true 则说明当前的pixel是片段,会丢弃这个Pixel
}
上面这个例程routine,也就是片段着色器,我们每画一个像素,都会调用片段着色器一次。
作为输入input,它会得到用于插值的顶点数据的重心坐标。
所以,插值强度()可以计算为——
或者就是两个向量和的点积——
在真正的GLSL中,片段着色器是直接接收已经插值过的数据的的。
注意:返回了一个值,我们看看光栅格化器的内部构造(在中的triangle()函数中)
TGAColor color;
bool discard = shader.fragment(c, color);
// c是重心坐标
if ( !discard )
{
zbuffer.set(P.x, P.y, TGAColor(P.z));
image.set(P.x, P.y, color);
}
片段着色器可以丢弃当前像素的绘图,然后光栅格化器可以简单地跳过它。如果我们还想创造出binary masks,使用相同的方式(discarding pixels)也是很简单的。
当然了,光栅格化器并不是万能的。因此它不能使用shader进行预编译。
在这里,我们使用抽象类作为两者之间的中介。(两者就是光栅格化器和着色器) (函数指针也可以,但是会让代码变得很不优雅)
二、着色器的第一次更改
virtual bool fragment (Vec3f bar, TGAColor &color)
{
float intensity = varying_intensity * bar;
if (intensity > .85)
intensity = 1;
else if (intensity > .60)
intensity = .80;
else if (intensity > .45)
intensity = .60;
else if (intensity > .30)
intensity = .45;
else if (intensity > .15)
intensity = .30;
else
intensity = 0;
color = TGAColor(255, 155, 0) * intensity;
return false;
}
我们以上对古尔德shading进行简单的更改,更改后,intensities的值只允许有6个值。结果如下:
三、渲染纹理
我们先跳过冯氏着色法](https://en.wikipedia.org/wiki/Phong_shading)。
之前Lesson 3的课后作业是给人脸模型画上纹理。为了画上纹理,我们必须对坐标进行插值。
这里,可能有人会问,什么是插值?
如果大家对uv也有疑惑,看这里。
所以,为了对uv纹理坐标进行插值,我建立了一个2×3的矩阵。
其中,2对应的两行是分别对于u和v而言的。
3列则是针对单个顶点的,x, y, z分量各一列。
struct Shader : public IShader
{
Vec3f varying_intensity; //written by vertex shader, read by fragment shader
mat<2, 3, float> varying_uv; //same as above
virtual Vec4f vertex(int ith_face, int nth_vert)
{
//为什么vertex对应的是Vec4f?
varying_uv.set_col(nth_vert, model->uv(ith_face, nth_vert));
//varying_uv对应的是矩阵,矩阵中的内容就是顶点和face
//set_col应该是设置columns
varying_intensity[nth_vert] = std::max(0.f,
model->normal(ith_face, nth_vert) * light_dir);
//get diffuse intensity
//这个intensity是顶点vertex的intensity,
//intensity值的确定是通过model中的法线向量normal中的face和vert两个值,同时×光照方向
//光照方向为垂直方向时,光照强度最大
Vec4f gl_Vertex = embed<4>(model->vert(ith_face, nth_vert));
//read the vertex from .obj 文件
//model中有uv, normal, vert等元素
//embed竟然是读取文件的函数,不过为什么vert()括号中还有个vert呢》两个有什么区别?
return ViewPort * Projection * ModelView * gl_Vertex;
//transform it to screen coordinates
}
virtual bool fragment(Vec3f bar, TGAColor &color)
{
// fragment shader的参数有两个,一个是bar,另一个是color
// 这里应该要改color的值,所以传递color参数用的是引用&
float intensity = varying_intensity * bar;
// interpolate intensity for the current pixel
// 说明bar里面包含的就是当前pixel的值
Vec2f uv = varying_uv * bar;
// interpolate uv for the current pixel
// bar 依然代表了当前的 pixel
// 不过我不懂 插值interpolate 的实现就是靠的 * 吗?
color = model->diffuse(uv) * intensity;
// 这个diffuse是在model.h+.cpp中定义的
// diffuse将uv坐标作为参数,那么最终会返回一个什么值呢?
// diffuse返回的值显然和intensity相× 可以得到color,那么这个值是什么呢?
return false;
// false means that we do not discard the current pixel
}
}
加入了Texture以后,结果是这样的——
四、法线映射 NormalMapping
基于以上,我们得到了纹理坐标。
那么我们得到纹理坐标的用处是什么呢?
我们可以往里存储任何东西——
可以存储颜色color、方向direction、温度temperature等等。
好,让我们来加载这个纹理。
上图是一个RGB图,如果我们把图像中的RGB值解释为xyz方向,那么上面这张图所提供给我们的则是渲染器中每一个像素的法线向量。(注意!是每一个像素!)
而不是像之前那样提供的是每一个顶点的法线向量,现在是每一个像素的法线向量!
顺便说一下,大家可以比较一下两幅图,同样的信息,不同的帧frame——
两张图的区别在于,基于的坐标系不同,一个是基于笛卡尔坐标系,另一个是达布框架(所谓的切线空间)。
在达布框架中向量是物体的法线向量,表示主曲率方向,表示叉积。
- 大家感觉上面的两张图哪个是笛卡尔哪个是达布?那么你又感觉哪个更好些?
struct Shader : public IShader
{
mat<2, 3, float> varying_uv;
// uv是个矩阵嘛
mat<4, 4, float> uniform_M;
// 本质上是 Projection * ModelView
mat<4, 4, float> uniform_MIT;
// projection -- 投射
// modelview -- 模型观测
// uniform_MIT 本质上是 (Projection * ModelView).invert_transpose()
// 就是将P*M矩阵转置
virtual Vec4f vertex(int ith_face, int nth_vert)
{
varying_uv.set_col(nth_vert, model->uv(ith_face, nth_vert));
// 为什么这个vert出现了2次,有什么区别?
Vec4f gl_Vertex = embed<4>(model->vert(ith_face, nth_vert));
// read the vertex from .obj file
// 也就是说我们从.obj文件中获取的就是 顶点 信息
return ViewPort * Projection * ModelView * gl_Vertex;
// gl_Vertex包含的是顶点信息
// 这4个一连×,就是将顶点信息转换成screen coordinates
}
virtual bool fragment(Vec3f bar, TGAColor &color)
{
Vec2f uv = varying_uv * bar;
// interpolate uv for the current pixel
// 说明在fragment shader中使用到了uv
Vec3f n = proj<3> (uniform_MIT * embed<4>(model->normal(uv))).normalize();
// 不过embed上面不是从.obj文件中读取顶点信息的么
// 在这里是读取normal法线信息吗?
Vec3f l = proj<3> (uniform_M * embed<4>(light_dir )).normalize();
// 不管是法线normal还是光照方向light_direction,最后都要normalize()一下
float intensity = std::max(0.f, n * l);
// 法线向量normal和光照方向light_dir相乘,结果就是intensity
color = model->diffuse(uv) * intensity;
return false;
}
};
[...]
Shader shader;
shader.uniform_M = Projection * ModelView;
shader.uniform_MIT = (Projection * ModelView).invert_transpose();
for (int i = 0; i < model->nfaces(); i++)
{
// 一直对这个nfaces()不理解,到底是个啥?
Vec4f screen_coords[3];
// 有点奇怪,上面不是已经通过vertex,经过transform,得到screen_coords了么
for (int j = 0; j < 3; j++)
{
screen_coords[j] = shader.vertex(i, j);
// 上面vertex()函数里面,返回值就是screen_coords,这里将值赋给数组screen_coords[]
}
triangle(screen_coords, shader, image, zbuffer);
}
uniform在GLSL中也是一个保留关键字,它允许将常量传递给着色器。
在这里,我通过矩阵和它的逆转置矩阵来变换法线向量(法线向量转换的概念在Lesson 5)。
所以,光照强度的计算方法和之前是一样的,但是有一个例外——
我们从法线贴图纹理(the normal mapping texture)中检索信息,而不是插值法线向量(不要忘记转换光向量和法线向量)。
之前计算最终的光照强度是将光照强度intensity和重心坐标bar进行插值!
好,我们来重新列举一下片段着色器——
Vec3f varying_intensity; // written by vertex shader, read by fragment shader
// [...]
virtual bool fragment(Vec3f bar, TGAColor &color) {
float intensity = varying_intensity*bar; // interpolate intensity for the current pixel
color = TGAColor(255, 255, 255)*intensity; // well duh
return false; // no, we do not discard this pixel
}
(以上这段代码取自最开始的Groudar Shading抽象类中的fragment shader)
总结:片段着色器这个例程,是在我们画三角形内的每一个像素时调用的。它接收重心坐标barycentric作为输入,对varying_intensity进行插值。
所以,插值后的强度可以被计算为
varying_intensity[0] * bar[0] + varying_intensity[1] * bar[1] + varying_intensity[2] * bar[2]
也就是varying_intensity和bar这两个向量之间的点积——varying_intensity * bar
同时,细心的大家一定会发现,片段着色器的返回值是一个bool型。什么意思呢?大家去看看我们写的光栅格化器就明白了。(光栅格化器就是our_gl.cpp中的triangle()函数)
TGAColor color;
bool discard = shader.fragment(c, color);
if (!discard) {
zbuffer.set(P.x, P.y, TGAColor(P.z));
image.set(P.x, P.y, color);
}
上述写的光栅格化器可以create binary masks。
但是因为the rasterizer can not imagine all the weird stuff you could program, therefore it can not be pre-compiled with your shader. (什么叫预编译?)
所以,我们使用抽象类 IShader来作为光栅格化器rasterizer和着色器shader二者之间的一个连接的桥梁。
最后的生成图是下面这样——
我们可以发现相对于上图,刀疤更深了。这是法线贴图的作用。将法线向量改进,使得人脸上的沟壑更深了!
五、镜面贴图
其实计算机图形学就是骗人眼睛的艺术。
为了更好地欺骗我们的眼睛,这里采用了光照模型的冯氏近似算法。
冯氏提出将最后的光照强度视作三个光照强度的加权总和,三个分别是:
- 环境照明(每个场景恒定)
- 漫射照明(我们上面一直计算的呢个)
- 镜面照明(新增的)
给大家看一张概念图——
我们计算漫射照明作为法线向量和光照方向之间形成的角的余弦值。
我们是在一个假定环境下计算的,环境就是:光在所有方向上都是均匀反射的。
那么在光泽的表面会发生什么变化?在极限情况下,比如镜子,当且仅当我们能看到该像素所反射的光源时,该像素才被照亮。
由上图可得,对于漫射照明,大家需要计算向量和之间的余弦值。
现在,我们对向量和之间的余弦值也需要计算。其中,向量代表的是反射的光照方向,代表的是视图方向。
这里问大家一个问题,
- 如果给定了向量和,如何找到向量?
如果和是标准化的,那么。
在漫射光照的计算中,我们将计算出的光照强度作为余弦值。
但是光滑的表面在一个方向上的反射要比在另一个方向上的反射大得多。那么如果我们取余弦的十次方会发生什么?回想一下所有小于1的数字在使用幂的时候将会变小。这个证明余弦的十次方将会使反射光束的半径更小。百次方就会更小。
这个幂的数值是存储在一个特殊的纹理(镜面贴图纹理)中,来分辨每个点它是否光滑:
struct Shader : public IShader
{
mat<2, 3, float> varying_uv;
mat<4, 4, float> uniform_M;
// Projection * ModelView
mat<4, 4, float> uniform_MIT;
// (Projection * ModelView).invert_transpose()
virtual Vec4f vertex(int ith_face, int nth_vert)
{
varying_uv.set_col(nth_vert, model->uv(ith_face, nth_vert));
Vec4f gl_Vertex = embed<4>(model->vert(ith_face, nth_vert));
// read the vertex from .obj file
return ViewPort * Projection * ModelView * gl_Vertex;
// transform gl_Vertex to screen coordinates
}
virtual bool fragment(Vec3f bar, TGAColor &color)
{
Vec2f uv = varying_uv * bar;
Vec3f n = proj<3>(uniform_MIT * embed<4>(model->normal(uv))).normalize();
// 法线向量是由逆转置矩阵求得的
Vec3f l = proj<3>(uniform_M * embed<4>(light_dir )).normalize();
Vec3f r = (n * (n * l * 2.f) - l).normalize();
// r代表的是 reflected light
float spec = pow(std::max(r.z, 0.0f), model->specular(uv));
// 计算幂的时候选用的是 reflected light 的z方向分量
// specular还是选用uv为参数,看来uv是万能参数。。。
float diff = std::max(0.f, n * l);
// 法线向量normal一直都是跟光照方向ligth_direction相乘
TGAColor c = model->diffuse(uv);
color = c;
// 这个c应该是余弦值,因为通过diffuse算出来的就是n和l的余弦值
for (int i = 0; i < 3; i++)
color[i] = std::min(5 + c[i] * (diff + .6 * spec), 255);
// 为什么min后面会有一个?而不是(float)
return false;
}
};
上述代码中,需要着重解释的一行就是38行的代码,它就是我们上面所说的specular mapping的计算光强加权总和公式的代码实现。
其中5是用于环境照明的系数,1是漫射照明的系数,.6是镜像照明的系数。
那么这个系数的大小的选择是如何来定呢?whatever! (请注意,通常系数的和必须等于1,但其实也可以不遵守。)
最终效果图:
学习之初,我整理了每节课中代码的变化,现在看来用处不大,但是删了可惜,大家有需要的可以看看哈~~
这篇博客用到的代码文件的变化是这样的:
- shaders.txt
(first shaders)
- our_gl.cpp
(投影逻辑bugfix)
- our_gl.h
(投影逻辑bugfix)
- Makefile
(initial import)->(cleaning up after refactoring 重构后清理)
- tgaimage.h
(初始导入)->(新光栅化器+z-buffer)->(初始导入)->(diffuse texture work)->(高洛德着色)->(投影逻辑bugfix)
- tgaimage.cpp
(初始导入)->(新光栅化器+z-buffer)->(初始导入)->(代码清理,C++学步检查)->(代码清理,C++学步检查)
- .obj文件夹
(线框渲染)->(新光栅化器+z-buffer)->(matrix class,立方体模型)->(textures)->(暗黑破坏神摆pose)
- geometry.h
(线框渲染)->(新光栅化器+z-buffer)->(templates)->(投影和viewport矩阵)->(代码清理,C++学步检查)->(投影逻辑bugfix)
- geometry.cpp
(templates)->(投影和viewport matrices矩阵)->(代码清理,C++学步检查)->(投影逻辑bugfix)
- main.cpp
(朴素线段追踪)->(线段追踪、减少划分的次数)->(线段追踪:all integer Bresenham)->(线框渲染)->(better test triangles)->(三角形绘制routine)->(背面剔除 + 高洛德着色)->(y-buffer!)->(新光栅化器+z-buffer)->(templates)->(投影和viewport matrices矩阵)->(lookat 矩阵 bug修正)->(投影逻辑bugfix)
- model.cpp
(线框渲染)->(新光栅化器+z-buffer)->(线框渲染)->(diffuse texture homework)->(高洛德着色)->(投影逻辑bugfix)
- model.h
(线框渲染)->(diffuse texture homework)->(高洛德着色)->(投影逻辑bugfix)
解释一下上述文件括号中的文字———
model.h,从高洛德着色变到了投影逻辑bugfix。
model.cpp与.h同步,也从高洛德着色变到了投影逻辑bugfix。
main.cpp则是从lookat矩阵,并进行了bugfix变到了投影逻辑bugfix。
geometry.cpp,则是从代码清理和C++检查变成了投影逻辑bugfix。
geometry.h与.cpp同步。
.obj文件夹中则是加入了暗黑破坏神摆pose的文件。
tgaimage.h则是从高洛德着色到了投影逻辑bugfix环节。
tgaimage.cpp还是在代码清理环节。
其中model时用来test测试的。
此番,还加入了一些新的文件:
Makefile其实一直都有,只不过我前面没有贴。
our_gl.h/.cpp的操作是一样的,投影逻辑bugfix。
shader.txt文件则表示的是first shaders,.txt文件中的内容也是代码,暂时还不知道是什么用?