目录
- 前言
- 要求
- 场景概览
- 机器人层级模型
- 为立方体部件贴纹理
- 关键帧动画
- 关键帧动画循环
- 体素建模
- 场景布局
- 增加光影特效
- 提早渲染管线
- 立方体贴图
- 环境映射
- Phong 光照
- 暗影映射
- 体积光
- debug 着色器
前言
这次大作业算是做的比拟认真的了,记录一下。全文简直 齐全参考 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] );
}
}`
* 1
* 2
* 3
* 4
* 5
* 6
* 7
* 8
* 9
* 10
* 11
* 12
* 13
* 14
* 15
* 16
* 17
* 18
* 19
* 20
* 21
* 22
* 23
* 24
* 25
* 26
* 27
* 28
* 29
* 30
* 31
* 32
* 33
* 34
* 35
* 36
* 37
* 38
* 39
* 40
* 41
* 42
* 43
* 44
* 45
* 46
* 47
* 48
* 49
* 50
* 51
* 52
* 53
* 54
* 55
* 56
* 57
* 58
* 59
* 60
* 61
* 62
* 63
* 64
* 65
* 66
* 67
* 68
* 69
* 70
* 71
* 72
* 73
* 74
* 75
* 76
* 77
* 78
* 79
* 80
* 81
* 82
* 83
* 84
* 85
* 86
* 87
* 88
* 89
* 90
* 91
* 92
* 93
* 94
* 95
* 96
* 97
* 98
* 99
* 100
* 101
* 102
* 103
关键帧动画
咱们通过关键帧的模式,让机器人动起来。关键帧代表了一个动作的关键点,以长方体绕点转动为例,咱们只须要两个参数就能够形容这个静止,即:
- 起始状态旋转角度
- 完结状态旋转角度
咱们依据工夫,在两个关键帧之间进行插值,即可失去以后工夫下的旋转角:
比方有一个动作,持续时间是 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 的形式实现树木的反复绘制:
增加光影特效
留神到咱们的场景非常枯燥:
咱们须要为其增加一些光影特效。这里我简略的实现了如下的渲染成果:
- 提早渲染管线
- phong 光照
- 立方体贴图
- 环境映射
- 暗影映射
- 体积光
在正式开始为场景增加特效之前,咱们必须实现一些比拟标准的货色。
提早渲染管线
咱们的所有试验都是应用前向渲染,然而大作业我打算实现一个简略的提早渲染管线。提早渲染管线可能无效的缩小片元着色器的开销,因为咱们无需对那些被遮挡的像素运行片段着色器!
咱们的提早渲染管线分为三个阶段:
- shadowMap 阶段
- gbuffer 阶段
- 后处理阶段
在 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 光照
咱们带入 phong 光照模型的公式计算光照的重量。咱们以援用的模式返回数据。此外,因为咱们没有材质数据,咱们默认三个光重量的材质都是 1.0 即可:
暗影映射
相比于应用投影矩阵,咱们应用更加通用的暗影映射办法进行暗影绘制。咱们利用 shadowMap 阶段绘制的深度纹理,和光源坐标系的变换矩阵 shadowVP 即可实现绘制。咱们通过比拟采样最近深度和以后深度,以判断点是否在暗影中。编写 shadowMapping 函数以实现暗影的绘制。返回值为 1 则示意在暗影中。
咱们将 phong 光照和暗影联合。在有暗影的中央,咱们只绘制环境光,其余中央,咱们间接绘制所有的 phong 光照:
咱们能够看到,在阳光之下的中央和暗影的显著区别:
体积光
体积光用于模仿丁达尔效应,能够大大晋升场景的好看水平。
体积光是一个后处理阶段的特效,利用光线追踪办法,从相机视角登程向世界空间投射光线并且沿途记录信息,直到产生碰撞或者达到最大迭代次数。如果以后点不在暗影之中,那么咱们累积色彩,否则咱们不做解决,光线继续前进:
为了记录世界空间下一点是否和实体产生碰撞,咱们须要利用深度缓冲的数据。咱们将世界空间的地位,通过视图,投影,视口变换,转换到屏幕坐标系,而后查问深度缓冲中的数据并且进行比对即可,该过程和暗影映射相似。咱们编写两个辅助函数,他们别离是屏幕深度转线性深度,和碰撞测试函数:
注:这里因为咱们相机的 zFar 高达 100,如果间接读取深度缓冲中的数据,那么会是一片全白,因为他们的数值简直十分靠近 1,而且咱们的硬件比拟远远达不到精度要求。咱们要做一次透视投影的逆变换,将深度从新映射回 0~1 的区间,以不便 FPU 进行比拟
而后咱们正式开始编写光线前进过程。咱们从相机原点登程,沿途积攒亮度直到碰撞或者达到最大迭代次数。当以后采样点不在暗影中时,咱们积攒亮度,示意碰到体积光。否则咱们不做解决:
紧接着咱们间接将获取的色彩附加到最终的输入:
能够看到最终成果还能够
debug 着色器
debug 着色器负责输入 gbuffer 阶段绘制的纹理。因为要可视化深度缓冲,咱们还是得转线性深度。此外我通过一个 uniform 变量名叫 mode 来管制 debug 模式:
注:因为光源方向的深度缓冲用的是 正交投影,所以深度不必线性化。
和后处理阶段统一,咱们间接绘制一张四方形,而后将纹理贴上去即可:
在场景中咱们绑定按键事件:按下 y 即可呼出调试界面,按下 u 能够切换调试模式。调试模式应用 viewport,开了一个小窗口在左下角