这次大作业算是做的比较认真的了,记录一下。全文几乎 完全参考 Learn OpenGL 的教程,感谢大佬 Orz
学生可以通过层级建模( 实验补充1和2)的方式建立多个虚拟物体,由多个虚拟物体组成一个虚拟场景,要求在程序中显示该虚拟场景,场景可以是室内或者室外场景;场景应包含地面。
场景设计和显示
添加纹理
添加光照、材质、阴影效果
用户交互实现视角切换完成对场景的任意角度浏览
通过交互控制物体
这是一个我十分喜欢的场景,出自游戏守望先锋的 CG 电影《最后的堡垒》。在智械危机大战之后,沉睡的战争机器 “堡垒”,在艾兴瓦尔德旁的原始森林中苏醒……
该场景分为四个部分:
其中树木和地面我们使用obj文件+纹理的方式进行渲染,因为这些物体是静态的。而环境我们则使用立方体贴图(cubeMap)来进行绘制。
而机器人我们使用层级建模的方式来描述其每一个组件。层级建模分为 3 层,第一层是身体层,我们将所有肢体都附着到身体上。第二层是主肢体层,它包括了头,大腿,大臂,和机器人机枪炮台。最后第三层是次肢体层,它包括了脚和手,机器人机枪枪管。下面是我们机器人的概览图:
机器人的所有肢体均采用立方体组成,一个立方体对应一个 TriMesh 对象。我们定义一个 Robot 类,其中包含一个 map 以根据名字,快速查询对应的组件。
我们定义如下的几个组件名称:body, head, back, gun, left_arm, right_arm, left_hand, right_hand, left_leg, right_leg, left_foot, right_foot
此外,在构造函数中,加入对应的组件。下面以加入head组件为例:
注:这里我大改了 TriMesh 和 MeshPainter 的实现。在 TriMesh 中添加 bindData 方法以单独绑定数据,实现模型和着色器对象分离。
texture_path 会在 TriMesh 的 bindData 中被利用为纹理贴图路径从而进行纹理的加载。而 rotatePoint 则是部件的旋转点,用以描述部件的旋转轴。
我们通过手动指定纹理坐标的方式,为立方体 TriMesh 的每一个面片贴上对应的纹理。我们将一个立方体的纹理描述为 6 张正方形图片的拼接,于是我们用一张图就可以描述立方体的 6 个面。以机枪炮台组件为例:
我们改动 TriMesh 类的 generateCube 函数,手动绑定 36 个顶点的纹理坐标(这里列出部分):
因为一个一个贴实在是太累人了,我给出我实现的一种贴图方案:
// 立方体生成12个三角形的顶点索引
void TriMesh::generateCube(vec3 _color, vec3 _scale)
{
// 创建顶点前要先把那些vector清空
cleanData();
for (int i = 0; i < 8; i++)
{
vertex_positions.push_back(cube_vertices[i] * _scale);
if (_color[0] == -1){
vertex_colors.push_back(basic_colors[i]);
}
else{
vertex_colors.push_back( _color );
}
}
// 每个三角面片的顶点下标
// 每个三角面片的顶点下标
faces.push_back(vec3i(0, 3, 1));
faces.push_back(vec3i(0, 2, 3));
faces.push_back(vec3i(1, 5, 4));
faces.push_back(vec3i(1, 4, 0));
faces.push_back(vec3i(4, 2, 0));
faces.push_back(vec3i(4, 6, 2));
faces.push_back(vec3i(5, 6, 4));
faces.push_back(vec3i(5, 7, 6));
faces.push_back(vec3i(2, 6, 7));
faces.push_back(vec3i(2, 7, 3));
faces.push_back(vec3i(1, 7, 5));
faces.push_back(vec3i(1, 3, 7));
// 颜色下标,让一个面的颜色都一样
for (int i = 0; i < 6; i++) {
color_index.push_back(vec3i(i, i, i));
color_index.push_back(vec3i(i, i, i));
}
texture_index = faces;
normal_index = faces;
storeFacesPoints();
textures.clear();
// 顶点纹理坐标,只是自己想的一种贴图方式而已
// 031
textures.push_back(vec2(0.25, 0.25));
textures.push_back(vec2(0.5, 0.5));
textures.push_back(vec2(0.5, 0.25));
// 023
textures.push_back(vec2(0.25, 0.25));
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.5, 0.5));
// 154
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.5, 0.0));
textures.push_back(vec2(0.25, 0.0));
// 140
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.25, 0.0));
textures.push_back(vec2(0.25, 0.25));
// 420
textures.push_back(vec2(0.0, 0.25));
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.25, 0.25));
// 462
textures.push_back(vec2(0.0, 0.25));
textures.push_back(vec2(0.0, 0.5));
textures.push_back(vec2(0.25, 0.5));
// 564
textures.push_back(vec2(0.75, 0.25));
textures.push_back(vec2(1.0, 0.5));
textures.push_back(vec2(1.0, 0.25));
// 576
textures.push_back(vec2(0.75, 0.25));
textures.push_back(vec2(0.75, 0.5));
textures.push_back(vec2(1.0, 0.5));
// 267
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.25, 0.75));
textures.push_back(vec2(0.5, 0.75));
// 273
textures.push_back(vec2(0.25, 0.5));
textures.push_back(vec2(0.5, 0.75));
textures.push_back(vec2(0.5, 0.5));
// 175
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.75, 0.5));
textures.push_back(vec2(0.75, 0.25));
// 137
textures.push_back(vec2(0.5, 0.25));
textures.push_back(vec2(0.5, 0.5));
textures.push_back(vec2(0.75, 0.5));
normals.clear();
// 正方形的法向量不能靠之前顶点法向量的方法直接计算,因为每个四边形平面是正交的,不是连续曲面
for (int i = 0; i < faces.size(); i++)
{
normals.push_back( face_normals[i] );
normals.push_back( face_normals[i] );
normals.push_back( face_normals[i] );
}
}
我们通过关键帧的形式,让机器人动起来。关键帧代表了一个动作的关键点,以长方体绕点转动为例,我们只需要两个参数就可以描述这个运动,即:
我们根据时间,在两个关键帧之间进行插值,即可得到当前时间下的旋转角:
比如有一个动作,持续时间是 1s,起始旋转角是 30,结束旋转角是 120,当前时间是 0.5s,那么我们可以得到当前的旋转角是 (120+30)*0.5 = 75°
我们定义机器人的关键帧状态:一个关键帧即为一组关节状态,这些状态描述了该时刻各个关节的旋转,平移,缩放参数。我们手动在 Robot 类内部指定关键帧,通过 map 寻址。以定义名为 stay 的关键帧为例,我们使用二重 map 进行定义:
注:
这里我们定义的缩放参数并没有作用。因为这些缩放是不等轴的!对于不等轴的缩放,我们在将其法线变换到世界空间下时,不能够使用模型矩阵,而是应该使用模型矩阵的逆矩阵的转置。
严格来说助教师兄师姐的模板代码,在该情况下会得到一个错误的法线方向,但是在大多数时候都是正确的,因为等轴缩放时,模型矩阵是一个正交矩阵,其逆矩阵等于转置。我们直接使用模型矩阵对法线进行变换即可!
我们的angle.h又没有计算逆矩阵的方法。于是为了解决缩放的问题,我们在generateCube的时候,就应该在c++中直接进行缩放!即让cpu将这些顶点进行缩放,从而解决缩放后法向量错误的问题。。。
重回到关键帧动画问题上,对于一个关键帧动画的播放,我们必须指定三个参数:
我们在 Robot 类中,利用 timer 记录当前关键帧播放的进度,当 timer 减小到 0 之后,我们认为动画播放完成。
然后我们编写 playMotion 函数,将机器人的动作在两个关键帧之间,根据时间进度进行插值。其中 setMotion 是将机器人的状态调整到当前关键帧 currentMotion:
然后我们定义一个函数 changeState,控制机器人的变形动作。我们指定两个关键帧,分别是 drive 和 stay,表示变形状态和人形状态:
我们为键盘的 p 按键绑定该事件,并且执行 robot 的 changeState 方法,即可实现机器人的变形动画。我们让机器人在站立和变形之间切换:
我们仅实现了关键帧动画的播放,我们还需要循环播放关键帧动画,以使得机器人完成走路的动作。参照列表循环的原则,我们建立关键帧列表,不断循环播放关键帧列表里面的动画:
同时修改我们的 playMotion 更新函数
最后我们在键盘回调函数中,通过按键判断来进行播放循环动画。我们指定两个关键帧,分别是 run1 和 run2,对应跑步动画的两个关键帧:
如图,我们实现了简单跑步动画的播放,下面是两个关键帧的详情:
使用 MagicaVoxel 软件进行体素建模,并且导出结果到 obj 文件,方便我们读取。我们建立两颗不同的树的模型,并且导出对应的 obj 文件:
我们生成一个正方形平面,并且为其贴上草地的纹理,这就是我们的地面了。
而树是重复的,我们无需建立多个 TriMesh 对象,相反地,我们创建两个 TriMesh 即可,我们通过改变其位移 + 多次调用 draw call 的方式实现树木的重复绘制:
我们需要为其添加一些光影特效。这里我简单的实现了如下的渲染效果:
在正式开始为场景添加特效之前,我们必须实现一些比较规范的东西。
我们的所有实验都是使用前向渲染,但是大作业我打算实现一个简单的延迟渲染管线。延迟渲染管线能够有效的减少片元着色器的开销,因为我们无需对那些被遮挡的像素运行片段着色器!
我们的延迟渲染管线分为三个阶段:
在 shadowMap 阶段我们从光源方向进行一次渲染,获取光源方向的场景的深度图 shadowTexture。
在 gbuffer 阶段,我们只渲染必要的信息,比如颜色,法线,世界坐标和场景深度。我们把这些信息存储到帧缓冲的多个颜色(和深度)附件中,他们分别是由两个帧缓冲和 3 个颜色附件,2 个深度附件组成:
在后处理阶段,我们利用 gbuffer 阶段和 shadowMap 阶段绘制的帧缓冲信息(就是那5张纹理的数据),对最终输出的片元进行计算,比如光照或者是阴影等开销比较大的特效。
下图描述了我的简易延迟渲染管线及其三个阶段之间的缓冲区与顺序关系:
我们使用 5 组着色器进行绘制:
其中 shadow 着色器负责从光源视角渲染深度纹理,而 skybox 和 gbuffer 负责生成 gbuffer 阶段的颜色,法线,世界坐标,深度纹理。其中 skybox 是天空盒专用绘制着色器。composite 着色器负责最终的特效绘制,而 debug 着色器负责输出 5 张纹理的内容,方便我改 bug。
值得注意的是,composite 和 debug 着色器的绘制对象都是一个正方形,它铺满了整个屏幕,我们只是把 gbuffer 的纹理数据取出来并且贴上去而已。
从 shadowMap 阶段开始,我们首先创建光源方向上的阴影贴图:
在 display 中,我们调用一次 draw call 以完成光源方向的绘制:
gbuffer 阶段也是类似,首先我们创建纹理。我们的纹理都是 RGBA32 格式,这样不容易发生截断或者是溢出的异常情况:
然后我们如法炮制进行绘制即可
gbuffer 阶段的着色器也十分简单。我们根据传入的数据,将片元数据输出到对应的纹理即可。下面是 gbuffer 顶点着色器:
片元着色器也是一样的,注意这里我们将反射系数存入 w 分量:
值得注意的是,gl_FragData[] 数组指向的正是我们在init中调用的附件纹理的绘制顺序:
注:这里我们修改了 MeshPainter,一个 TriMesh 在绘制的时候,传递它自己的模型矩阵和纹理,我们将模型矩阵和纹理(也包括一些其他的OpenGL对象,比如vao,vbo等)视为 TriMesh 自己的成员变量:
后处理阶段则稍微简单,我们绘制一个正方形,然后把纹理贴上去即可:
这里就显示出延迟渲染管线的优势:不管场景多么复杂,片元数目都是屏幕分辨率。这意味着片元着色器被更少的执行。
随后我们传递对应的 5 个纹理进去,并且执行 draw call 即可:
我们在 composite 的片段着色器中,直接采样 gbuffer 阶段传递的颜色纹理的值,即可输出我们 gbuffer 阶段绘制的基本画面:
我们直接输出 gbuffer 阶段绘制的颜色纹理,我们很快发现这个场景十分单调,天空是黑色的。于是我们准备添加天空。这意味着天空的绘制也发生在 gbuffer 阶段。
我们决定使用立方体贴图来贴上天空与环境。立方体贴图本质上是利用一个长宽高为 2 的立方体包住我们的摄像机,然后将黑色的背景改写为立方体贴图的颜色:
如图这是一张立方体贴图,它由 6 张图片组成,我们将把它贴到一个立方体上,以包围我们的相机:
我们编写 liadCubeMap 函数以快速加载我们的立方体贴图。我们以 GL_TEXTURE_CUBE_MAP 的形式加载该纹理:
然后我们在绘制物体的同时,利用 skybox.fsh 和 skybox.vsh 两个着色器,对立方体贴图进行渲染。这里我们将立方体贴图的位置设置到相机的 eye 位置。此外,我们关闭深度测试以在背景处绘制天空贴图:
我们编写 skybox 着色器,下面是顶点着色器:
片段着色器则更加简单,我们直接利用顶点坐标取立方体贴图颜色即可:
注:这里我们对世界坐标缓冲直接输出非常远的距离(比如1000),法线缓冲随意。同时我们往w坐标里面输出一个-1以标记天空。
现在我们应该能够在 gbuffer 的颜色缓冲中,查看到立方体贴图的绘制:
gbuffer 阶段的绘制到此结束。后面的都是后处理阶段的绘制,并且发生在 composite 着色器中进行。所需要的所有信息,都存储在 gbuffer 和 shadowMap 阶段 pass 过来的 5 张纹理中:
机器人身上的金属部件会反光。我们需要收集其反射的颜色,收集的方法也很简单,根据视线方向和法线,计算反射光线方向,并且到环境立方图里面取值即可。我们编写函数,根据片元的世界坐标和法向量,取天空盒的颜色:
然后在 main 函数中,我们根据反射率,对原像素进行混色即可:
我们带入 phong 光照模型的公式计算光照的分量。我们以引用的形式返回数据。此外,因为我们没有材质数据,我们默认三个光分量的材质都是 1.0 即可:
相比于使用投影矩阵,我们使用更加通用的阴影映射方法进行阴影绘制。我们利用 shadowMap 阶段绘制的深度纹理,和光源坐标系的变换矩阵 shadowVP 即可实现绘制。我们通过比较采样最近深度和当前深度,以判断点是否在阴影中。编写 shadowMapping 函数以实现阴影的绘制。返回值为 1 则表示在阴影中。
我们将 phong 光照和阴影结合。在有阴影的地方,我们只绘制环境光,其他地方,我们直接绘制所有的 phong 光照:
我们可以看到,在阳光之下的地方和阴影的明显区别:
体积光是一个后处理阶段的特效,利用光线追踪方法,从相机视角出发向世界空间投射光线并且沿途记录信息,直到发生碰撞或者达到最大迭代次数。如果当前点不在阴影之中,那么我们累积颜色,否则我们不做处理,光线继续前进:
为了记录世界空间下一点是否和实体发生碰撞,我们需要利用深度缓冲的数据。我们将世界空间的位置,通过视图,投影,视口变换,转换到屏幕坐标系,然后查询深度缓冲中的数据并且进行比对即可,该过程和阴影映射类似。我们编写两个辅助函数,他们分别是屏幕深度转线性深度,和碰撞测试函数:
注:这里因为我们相机的 zFar 高达 100,如果直接读取深度缓冲中的数据,那么会是一片全白,因为他们的数值几乎非常接近 1,而且我们的硬件比较远远达不到精度要求。我们要做一次透视投影的逆变换,将深度重新映射回 0~1 的区间,以方便 FPU 进行比较
然后我们正式开始编写光线行进过程。我们从相机原点出发,沿途积累亮度直到碰撞或者达到最大迭代次数。当当前采样点不在阴影中时,我们积累亮度,表示碰到体积光。否则我们不做处理:
debug 着色器负责输出 gbuffer 阶段绘制的纹理。因为要可视化深度缓冲,我们还是得转线性深度。此外我通过一个 uniform 变量名叫 mode 来控制 debug 模式:
注:因为光源方向的深度缓冲用的是正交投影,所以深度不用线性化。
和后处理阶段一致,我们直接绘制一张四方形,然后将纹理贴上去即可:
在场景中我们绑定按键事件:按下 y 即可呼出调试界面,按下 u 可以切换调试模式。调试模式使用 viewport,开了一个小窗口在左下角: