2021-06-12
OpenGL 大作业:3D 场景渲染
编写了一个可交互的虚拟 3D 场景演示程序,代码结构如下,点此下载
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 | source
├── bmp.cpp # 读取 BMP 位图,由 getBMP.cpp 修改而来
├── bmp.h # 定义 BMP 结构体及其方法
├── build.sh # macOS 下的编译+运行脚本,Windows 下无法使用
├── main.cpp # 主程序
├── model # OBJ 模型文件
│ ├── cat.obj
│ ├── duck.obj
│ ├── github.obj
│ ├── ground.obj
│ ├── lamp.obj
│ └── tiger.obj
├── model.cpp # 读取、渲染模型
├── model.h # 定义 Model 结构体及其方法
├── player.cpp # 移动玩家,计算玩家的坐标和方向向量
├── player.h
├── texture # BMP 贴图文件
│ ├── env # Skybox 环境贴图
│ │ ├── negx.bmp
│ │ ├── negy.bmp
│ │ ├── negz.bmp
│ │ ├── posx.bmp
│ │ ├── posy.bmp
│ │ └── posz.bmp
│ ├── ground.bmp
│ └── tiger.bmp
├── utils.cpp # 通用函数:初始化、渲染天空、计算包围盒子、载入 OBJ 文件等
└── utils.h
|
1 载入模型
第一节的内容除了涉及多个模型的载入、读取材质坐标、绑定材质外,其余和上次实验基本相同,为了完整性重新说明一遍。
为了载入多个模型,在model.h
中定义了Model
类,其中f
、v
、vt
、vn
向量依次存放面、点、材质、法向量。通过Model
构造函数即可新建模型。并定义了全局变量models
,存放所有场景中的模型。通过以下代码载入模型并放入models
中:
| Model *model;
model = new Model(string filename, double x, double y, double z, double p, double q, double r, double scale, float *ka, float *kd, float *ks, float a, bool flat);
model->bindTexture(string filename, unsigned int *texture, int tid);;
models.push_back(model);
|
Model
的构造函数依次执行 1.1-1.3 下面三个步骤,最后是 1.4 中的绑定材质。
1.1 读取模型
修改示例代码OBJModelViewer.cpp
中的loadOBJ
函数,放入utils.cpp
中。该函数将从 OBJ 文件读入的顶点坐标依次放在v
向量中,将材质坐标放在vt
向量中,将三角面的顶点索引依次放在f
向量中,对于非三角面,按照 triangle fan 的模式拆分成三角面。
由于原先的loadOBJ
函数不支持材质坐标的读取,因此需要在原来的函数中添加对vt
的判断。这里默认材质均为二维贴图,因此连续读取两个坐标放入vt
向量即可。
| else if (line.substr(0, 3) == "vt ")
{
istringstream str(line.substr(3));
for (i = 1; i <= 2; i++)
{
str >> val;
vt.push_back(val);
}
}
|
1.2 计算包围盒
遍历所有顶点,得到三个坐标到最大值和最小值,这样就得到了包围盒子对角线的两个顶点坐标
、
,这两个坐标取中点即为中心。接下来,计算包围盒子的中点和对角线长度
最后,修改模型的坐标,将模型中心平移到原点处,即所有的坐标均减去中心坐标。并且将模型“归一化”,即将所有坐标除以上述对角线长度,缩放成最远的点距离原点不超过 1 的大小。
1.3 计算法向量
如果三角面的三个顶点按 OBJ 文件中的顺序给出,设为
、
、
,则该三角面按照右手规则的一个法向量为:
由于该法向量能体现三角形的面积,因此计算顶点法向量时,直接取相邻面法向量的平均就可以计算顶点法向量,不用再对面积加权。
根据是平面渲染模型还是光滑渲染模型,选择不同的法向量赋值给最终的vn
向量。对于平面渲染,将每个三角面的法向量长度归一化后,作为该三角面的第一个顶点的vn
值,对于光滑渲染,将加权后的顶点法向量长度归一化后,作为该三角面的第一个顶点的vn
值:
1
2
3
4
5
6
7
8
9
10
11
12 | // flat shading
if (flat)
for (int i = 0; i < f.size(); i += 3)
{
vn[3 * f[i]] = fnArr[i];
vn[3 * f[i] + 1] = fnArr[i + 1];
vn[3 * f[i] + 2] = fnArr[i + 2];
}
// smooth shading
else
for (int i = 0; i < v.size(); i++)
vn[i] = vnArr[i];
|
1.4 绑定材质
将示例代码getBMP.cpp
改为bmp.cpp
,在bmp.h
中定义了 BMP 贴图结构体,并且通过BMP
结构体初始化函数读取模型,而非getBMP
函数。
在model.cpp
中定义Model
结构体绑定材质的方法,其中bmp
为该结构体中BMP
结构体类型的指针。传入初始化后的材质数组和对应的材质下标后,通过new BMP
读取 BMP 文件,通过glTexImage2D
将该 BMP 贴图绑定到对应的材质。这里针对材质的缩放,设为效果更好的线性插值而非最近点差值,并且如果坐标超出原先的材质范围,采用重复的方式填充。
1
2
3
4
5
6
7
8
9
10
11
12 | // bind bitmap textrue from file
void Model::bindTexture(string filename, unsigned int *texture, int tid)
{
this->tid = tid;
bmp = new BMP(filename);
glBindTexture(GL_TEXTURE_2D, texture[tid]);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, bmp->w, bmp->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, bmp->data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
}
|
对于没有材质的模型,预先启用颜色材质
| glEnable(GL_COLOR_MATERIAL);
glColorMaterial(GL_FRONT, GL_AMBIENT_AND_DIFFUSE);
|
之后,重载上述方法,实现对模型颜色的设置,其中tr
、tg
、tb
三个结构体属性为模型的材质颜色:
| // bind color texture
void Model::bindTexture(double tr, double tg, double tb)
{
this->tr = tr;
this->tg = tg;
this->tb = tb;
}
|
2 渲染模型
在model.cpp
中定义Model
结构体的render
函数用于渲染模型。
2.1 平移旋转
Model
结构体中还包括x
、y
、z
、p
、q
、r
,分别代表模型摆放的位置,沿三个轴旋转的角度。首先应在原点处旋转该模型,再平移到对应的摆放位置,如果先平移的话,显然一转就转飞了。因此在render
函数中,首先应用以下变换:
| glPushMatrix();
glTranslated(x, y, z);
glRotated(r, 0, 0, 1);
glRotated(q, 0, 1, 0);
glRotated(p, 1, 0, 0);
|
2.2 渲染材质
之后,对于有贴图材质的模型,首先根据材质标号tid
启用该材质,并将混合模式设为GL_MODULATE
,将材质颜色和光照效果的颜色相乘,为后面的光照做准备。否则将只有材质的颜色,没有光照的颜色。之后通过glTexCoord2d
指定vt
向量里的材质坐标,进行绘制:
| glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE);
glBindTexture(GL_TEXTURE_2D, texture[tid]);
glBegin(GL_TRIANGLES);
for (int i : f)
{
glTexCoord2d(vt[2 * i], vt[2 * i + 1]);
glNormal3d(vn[3 * i], vn[3 * i + 1], vn[3 * i + 2]);
glVertex3d(v[3 * i] * scale, v[3 * i + 1] * scale, v[3 * i + 2] * scale);
}
glEnd();
glPopMatrix();
|
对于没有贴图的模型,不需要上述的glTexEnvf
、glBindTexture
、glTexCoord2d
,只需要通过glColor
指定预先设置好的颜色,绘制即可。判断模型有没有贴图,可以通过vt
向量的长度实现,如果长度为 0,说明该模型没有贴图信息。
3 渲染环境
3.1 天空
参考示例代码SkyBox.cpp
,使用该示例中的材质。首先在utils.cpp
中的init
函数中初始化 Skybox 的材质,材质编号为 0,之后设置全局变量 env,用于存放 Skybox 六个面的贴图,并依次绑定:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | // set environment skybox
env[0] = new BMP("texture/env/posx.bmp");
env[1] = new BMP("texture/env/negx.bmp");
env[2] = new BMP("texture/env/posy.bmp");
env[3] = new BMP("texture/env/negy.bmp");
env[4] = new BMP("texture/env/posz.bmp");
env[5] = new BMP("texture/env/negz.bmp");
glBindTexture(GL_TEXTURE_CUBE_MAP, texture[0]);
for (int i = 0; i < 6; i++)
{
int target = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i;
glTexImage2D(target, 0, GL_RGBA, env[i]->w, env[i]->h, 0, GL_RGBA, GL_UNSIGNED_BYTE, env[i]->data);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
初始化后,编写renderEnvironment
函数,该函数使用该材质,通过GL_POLYGON
依次渲染 6 个正方形面。在渲染前 Skybox 前,还要启用该材质,并将混合模式设为替换,使得 Skybox 不会受到光照的影响:
| glEnable(GL_TEXTURE_CUBE_MAP);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
|
3.2 地面
以参考代码FieldAndSky.cpp
中草地的材质作为地面。为了重复使用Model
类,可以自己手动编写一个 OBJ 文件ground.obj
,表示一个矩形平面,并设置材质坐标:
| v 0 0 0
v 0 0 1
v 1 0 1
v 1 0 0
vt 0 0
vt 0 1
vt 1 1
vt 1 0
f 1 2 3
f 1 3 4
|
之后,通过上述载入模型、绑定材质的方法,即可载入地面这一模型。其大小(矩形边长)为 20,并向下平移了 1.5 个单位。因为结构简单,最后一个参数为true
,即使用平面渲染模型。由于草地不是金属,因此设置草地的 Shininess(即光照模型中的 alpha 值)为 0.5,其余使用默认的材质参数:
| model = new Model("model/ground.obj", 0, -1.5, 0, 0, 0, 0, 20, ka, kd, ks, 0.5, true);
model->bindTexture("texture/ground.bmp", texture, 1);
models.push_back(model);
|
4 渲染阴影
参考示例代码BallAndTorusShadowMapped.cpp
,使用 Shadow Mapping 的方法渲染阴影。该方法的思想是,以光的视角得到深度图,之后将相机视角里的点进行坐标变换,转换到光的视角下,比较深度信息。如果相机视角里的点对应的深度值结果比深度图深,则说明该点在阴影下。
渲染步骤原本为:
- 创建深度图
- 仅开启全局环境光,渲染场景的所有内容
- 开启所有光源,渲染场景中未在阴影的部分
但是由于示例代码在第三步的渲染中用到了材质坐标变换,如果第三步的模型有贴图,则会覆盖已经求得的深度信息材质,并且贴图的坐标会跟着乱掉。因此示例代码仅支持不带纹理的渲染。
为了解决这一问题,将渲染步骤改为:
- 创建深度图
- 开启所有光源,渲染场景的所有内容
- 关闭所有光源,直接以深灰色渲染阴影部分
最后渲染时,阴影部分用深灰色表示,不考虑将其与影子下本来的材质颜色相乘。
4.1 创建深度图
首先要以光的视角创建深度图。为此需要知道光的投影矩阵和光的视图矩阵。设置深度图的大小为
,因此光的投影矩阵长宽比为 1:1,视井体最远处设为两倍世界的大小(极端情况下,Skybox 对角线距离为
倍世界大小)。由于是点光源,无法设置 FOV,但这里将光源视为太阳,在场景的高处,因此只需考虑向下发出的这部分光线,将 FOV 设大一些即可,这里的LIGHTFOV
为 120。因此得到投影矩阵
| gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2);
|
对于视图矩阵,认为光源始终照向原点。用lpos
表示光源的位置,则光的视图矩阵为
| gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0);
|
同时,设置视口大小为深度图的大小SHADOWSIZE
,这里为 1024。
| glViewport(0, 0, SHADOWSIZE, SHADOWSIZE);
|
设置好上述矩阵后,禁用颜色输出,并裁掉模型朝光的视角的前面,因为用后面即可确定深度图。之后,不用材质渲染模型以节约时间,得到光的视角下的深度信息,将其存放在最后一个材质内:
| glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glCullFace(GL_FRONT);
// draw the scene without texture, capture the depth buffer
for (Model *model : models)
model->render();
glBindTexture(GL_TEXTURE_2D, texture[MAXTID - 1]);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 0, 0, SHADOWSIZE, SHADOWSIZE, 0);
|
即可得到深度图材质。
4.2 渲染场景
之后,清除深度值的缓冲,以相机的视角渲染场景。相机的投影矩阵为
| gluPerspective(FOV, (double)w / (double)h, 1, WORLDSIZE * 2);
|
其中宽高比为窗口的宽高比,FOV
设为 60。相机的视图矩阵为
| gluLookAt(ppos[0], ppos[1], ppos[2], ppos[0] + pvec[0], ppos[1] + pvec[1], ppos[2] + pvec[2], 0, 1, 0);
|
其中ppos
为玩家的坐标,pvec
为玩家面朝前方的方向向量。并设置视口和窗口大小相同:
设置好上述矩阵后,允许颜色输出,并裁减相机视角下模型的背面以节省时间,启用光照,依次渲染环境(Skybox)和模型
1
2
3
4
5
6
7
8
9
10
11
12 | glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glCullFace(GL_BACK);
// draw the scene
glEnable(GL_LIGHTING);
if (renderLight)
{
glEnable(GL_LIGHT0);
glLightfv(GL_LIGHT0, GL_POSITION, lpos);
}
renderEnvironment(texture);
for (Model *model : models)
model->render(texture);
|
4.3 渲染阴影部分
为了将相机坐标中的某点
和光的视角下的深度图的某点
的深度进行比较,需要进行坐标变换。在相机坐标中的某点
,需要经过相机的视图逆变换
,得到在真实的坐标系(canonical)下的坐标,之后再经过光的视图变换
,得到在光的视角下的坐标,最后经过光的投影矩阵
,得到了深度图上对应的某点坐标。但是投影后的深度信息在
之间,为了和深度图中
对应,需要先缩放一半,再平移半个单位,即对应偏移矩阵
。最后的结果为
其中
矩阵可用
| glTranslated(0.5, 0.5, 0.5);
glScaled(0.5, 0.5, 0.5);
|
实现。而光的投影矩阵、光的视图矩阵同上,直接放在下方相乘。
| gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2); // light Projection
gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0); // light Modelview
|
之后,为了应用相机视图矩阵逆变换,取出上述左边三个矩阵相乘的结果
并参考示例代码,通过材质坐标生成实现逆变换
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | double trans[16];
glGetDoublev(GL_MODELVIEW_MATRIX, trans);
glPopMatrix();
// generate texture coordinates
double tr0[] = {trans[0], trans[4], trans[8], trans[12]};
double tr1[] = {trans[1], trans[5], trans[9], trans[13]};
double tr2[] = {trans[2], trans[6], trans[10], trans[14]};
double tr3[] = {trans[3], trans[7], trans[11], trans[15]};
glEnable(GL_TEXTURE_GEN_S);
glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_S, GL_EYE_PLANE, tr0);
glEnable(GL_TEXTURE_GEN_T);
glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_T, GL_EYE_PLANE, tr1);
glEnable(GL_TEXTURE_GEN_R);
glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_R, GL_EYE_PLANE, tr2);
glEnable(GL_TEXTURE_GEN_Q);
glTexGeni(GL_Q, GL_TEXTURE_GEN_MODE, GL_EYE_LINEAR);
glTexGendv(GL_Q, GL_EYE_PLANE, tr3);
|
其中GL_TEXTURE_GEN_*
设置了材质的*
的坐标值为tr*
和当前坐标对应的原始坐标的点乘,即
其中
为变换矩阵
的按行分块:
为原始坐标,由于执行这段代码时,当前的视图矩阵正是相机的视图矩阵,因此
对应的视图中的坐标正是相机视图坐标
故
因此,R 值对应的就是变换后待比较的的深度信息。因此设置比较函数,如果变换后的深度(R)比原先的深度图深,则置 alpha 值为 0,否则为 1。为了渲染阴影部分,应丢弃 alpha 值为 1 的部分,通过启用GL_ALPHA_TEST
并设置比较函数为小于 0.5 阈值以实现:
| // activate shadow map, set shadow comparison to generate an alpha value
glBindTexture(GL_TEXTURE_2D, texture[MAXTID - 1]);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_R_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
glTexParameteri(GL_TEXTURE_2D, GL_DEPTH_TEXTURE_MODE, GL_ALPHA);
// 0 for failed comparisons, 1 for successful, discard 1
glEnable(GL_ALPHA_TEST);
glAlphaFunc(GL_LESS, 0.5);
|
最后,绘制阴影区域:
| // draw shadow area
if (renderShadow)
for (Model *model : models)
model->renderShadow();
|
这里为Model
结构体编写了renderShadow
方法,渲染时不启用材质,而是通过
| // draw with black to create shadow effect
glColor3d(0.1, 0.1, 0.1);
|
设置渲染的颜色为深灰色,以此实现影子的效果。
5 交互控制
5.1 移动玩家
通过glutKeyboardFunc
设置输入的回调函数为input1
,该函数中,通过 w/a/s/d 控制玩家前后左右移动,通过空格/c 控制玩家上下飞。为了确定玩家位置和朝向,维护ppos
和pvec
两个全局变量。并在player.cpp
中编写玩家移动的相关函数。
最简单的是上下移动,无论玩家朝向何处,只要改变 y 值即可。这也符合所谓的吃鸡等游戏的跳跃/飞行操作逻辑。如果用
表示移动的单位方向向量,
表示玩家的方向向量,则这种情况下
向上移动的代码如下所示,d
为移动距离。向下移动只需将d
设为负数,之后的几种移动同理:
| // move player up (i.e. fly)
void moveUp(double *ppos, double *pvec, double d)
{
ppos[1] += d;
}
|
其次是向前移动,这里取玩家的方向向量在 x-z 平面上的投影,作为向前移动的方向。即玩家抬头时按住 w,只会在平面方向移动,并不会同时向上飞,这也符合所谓的吃鸡等游戏的操作逻辑。因此,仅需丢掉 y 分量的值,将 x-z 平面投影的结果归一化后,与d
相乘:
并依次加到玩家的 x 和 z 坐标即可:
| // move player go front
void moveFront(double *ppos, double *pvec, double d)
{
double len = sqrt(pvec[0] * pvec[0] + pvec[2] * pvec[2]);
ppos[0] += pvec[0] / len * d;
ppos[2] += pvec[2] / len * d;
}
|
稍微麻烦一点的是左右移动,需要将上述 x-z 平面投影顺时针旋转 90 度(不是逆时针,因为 z 轴朝外,x 轴朝右),再与玩家坐标相加:
代码如下:
| // move player left
void moveLeft(double *ppos, double *pvec, double d)
{
double len = sqrt(pvec[0] * pvec[0] + pvec[2] * pvec[2]);
ppos[0] += pvec[2] / len * d;
ppos[2] -= pvec[0] / len * d;
}
|
5.2 玩家转向
通过glutSpecialFunc
设置输入的回调函数为input2
,该函数中,通过左/右控制玩家向左向右看,通过上/下控制玩家向上向下看。不考虑玩家的头靠在另一半的肩膀上歪着的情况(沿玩家坐标的 z 轴旋转),实际的所谓吃鸡等游戏也没有将画面歪着的场景。
稍微简单一点的是左右看,直接沿 y 轴对玩家的方向向量顺时针(也不是逆时针,和上述一样)旋转即可,如果用
表示旋转后的方向向量,则
代码如下,如果是向右,则设a
为负数即可:
| // rotate player's head left
void rotLeft(double *pvec, double a)
{
double x = pvec[0] * cos(a) + pvec[2] * sin(a);
double z = -pvec[0] * sin(a) + pvec[2] * cos(a);
pvec[0] = x;
pvec[2] = z;
}
|
最复杂的是上下转。因为这时候的转轴是玩家的自然坐标中的 x 轴,不是实际的 x 轴。这时,可将方向向量沿 y 轴先逆时针(不是顺时针)转动
角,然后再绕 x 轴顺时针(这里也不是逆时针)旋转所需角度,最后再转回
角,如下图所示:
故
其中
借助rotLeft
函数,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13 | // rotate player's head up
void rotUp(double *pvec, double a)
{
if ((pvec[1] > 0.99 && a > 0) || (pvec[1] < -0.99 && a < 0))
return;
double theta = atan2(pvec[0], pvec[2]);
rotLeft(pvec, -theta);
double y = pvec[1] * cos(a) + pvec[2] * sin(a);
double z = -pvec[1] * sin(a) + pvec[2] * cos(a);
pvec[1] = y;
pvec[2] = z;
rotLeft(pvec, theta);
}
|
其中要限制玩家的头转过天花板和地板,如果单位向量的 y 分量接近 1 或 -1,则禁止其再向上或向下转头。
5.3 移动光源和物体
通过controlType
这一变量控制移动的是玩家、光源还是物体,由菜单修改。如果移动的是光源和物体,则通过 w/a/s/d/c/空格直接修改lpos
或者模型的 x/y/z 坐标,不考虑其与玩家的相对位置。且为了演示需要,模型仅移动场景中的第二个模型。
5.4 灯光颜色和阴影
在utils.cpp
中编写setLightColor
函数,改变灯光颜色的值。这里仅改变环境光和漫反射光,并且环境光的光强有所减弱,为 0.3,高光则一直为白色。
1
2
3
4
5
6
7
8
9
10
11
12
13 | // change light color (ambient and diffuse, specular is always white)
void setLightColor(double r, double g, double b)
{
la[0] = 0.3 * r;
la[1] = 0.3 * g;
la[2] = 0.3 * b;
ld[0] = r;
ld[1] = g;
ld[2] = b;
glLightfv(GL_LIGHT0, GL_AMBIENT, la);
glLightfv(GL_LIGHT0, GL_DIFFUSE, ld);
glLightfv(GL_LIGHT0, GL_SPECULAR, ls);
}
|
同时,通过菜单可修改renderLight
、renderShadow
函数,前者决定是否渲染灯光,如果不渲染,则只有全局的环境光;后者决定是否渲染阴影,如果不渲染,则不启用 4.3 中的代码。
修改后,需要通过glutPostRedisplay
让更改立刻生效。
5.5 灯光动画
通过glutTimerFunc
绑定animate
回调函数,以 50 毫秒为间隔。
动画中,从左向右平移光源,模拟日出日落的过程。如果 x 坐标超过 10,则再移回去。通过animation
这一全局变量控制动画是否运行,该变量通过菜单修改。
1
2
3
4
5
6
7
8
9
10
11
12 | // animation callback
void animate(int entry)
{
if (animation)
{
lpos[0] += 0.3;
if (lpos[0] > 10)
lpos[0] = -10;
glutPostRedisplay();
}
glutTimerFunc(50, animate, 1);
}
|
6 画质提升
参考示例代码antiAliasing+Multisampling.cpp
,实现超采样和抗锯齿。
6.1 超采样
通过
| glEnable(GL_MULTISAMPLE);
|
并在初始化时额外设置GLUT_MULTISAMPLE
| glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_MULTISAMPLE);
|
实现超采样。开启后,帧率有所下降。
6.2 抗锯齿
通过使绘制的点、线、多边形平滑,实现抗锯齿,并按照示例设置混合函数:
| glEnable(GL_POLYGON_SMOOTH);
glEnable(GL_LINE_SMOOTH);
glEnable(GL_POINT_SMOOTH);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
开启后,帧率也有所下降。
6.3 阴影画质提升
默认情况下,在没有阴影的区域,有时会出现条带状阴影的图案,这是因为在这些区域上的点,经坐标变换深度检测后,其深度刚好与深度图的深度相等。但是由于浮点数的计算显然有误差,因此有一部分点会被判小于,而落入阴影区域内。解决这一问题的方法,个人想到了可以通过修改偏移矩阵
实现,将最后的平移改为
| glTranslated(0.5, 0.5, 0.4997);
|
这样,深度值在比较时就有了一定的阈值,防止了由副点误差导致的阴影条带的出现。但是这里不能少太多,不然会出现影子断层的现象。0.4997 是该场景下经验试出的最优值。
同时,也可以改变 Shadow Map 的大小,减小阴影的锯齿。这里将 Shadow Map 大小从原来示例代码中的
改为
。
7 运行结果
7.1 编译
在 macOS 下,需要通过 Homebrew 安装 glew 和 glut 库:
| brew install glew
brew install glut
|
之后执行./build.sh
即可编译。如需手动编译,则输入
| c++ utils.cpp bmp.cpp model.cpp player.cpp main.cpp -lglew -framework glut -framework opengl
|
链接这些库和系统自带的 OpenGL 框架,即可编译。
代码仅在 macOS 上进行了测试,但考虑了一定的 Windows 和 Linux 的兼容性,在 Windows 上应该也可成功编译。如果编译失败或效果与下面的截图不一致,可在 macOS 上编译运行。
编译完成后,输入
即可运行,打开窗口界面:
大小为
,载入了 6 个模型:
- 草地:绑定了示例代码中的贴图,使用平面渲染,Shininess 为 0.5
- GitHub 贡献统计:自己的 GitHub 账号在 2020 年的贡献 3D 图,使用光滑渲染,Shininess 为 20
- 老虎:绑定了给定的贴图,使用光滑渲染,Shininess 为 10
- 鸭子、猫:使用颜色作为材质,使用光滑渲染,Shininess 分别为 10、5
- 路灯:使用上一次实验的模型,使用平面渲染,Shininess 为 10
所有的模型参数在utils.cpp
里可以查看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | // load models
Model *model;
model = new Model("model/ground.obj", 0, -1.5, 0, 0, 0, 0, 20, ka, kd, ks, 0.5, true);
model->bindTexture("texture/ground.bmp", texture, 1);
models.push_back(model);
model = new Model("model/github.obj", 0, -0.4, 4, 270, 0, 0, 10, ka, kd, ks, 20, false);
model->bindTexture(0.5, 0.5, 0.5);
models.push_back(model);
model = new Model("model/tiger.obj", 0, -0.6, 0, 270, 90, 0, 5, ka, kd, ks, 10, false);
model->bindTexture("texture/tiger.bmp", texture, 2);
models.push_back(model);
model = new Model("model/cat.obj", 3.5, -0.5, 1, 270, 90, 0, 3, ka, kd, ks, 5, false);
model->bindTexture(0.8, 0.4, 0.1);
models.push_back(model);
model = new Model("model/duck.obj", -3.5, -0.4, 4, 270, 180, 0, 3, ka, kd, ks, 10, false);
model->bindTexture(0.8, 0.6, 0);
models.push_back(model);
model = new Model("model/lamp.obj", -4, 0, 0.5, 0, 0, 0, 4, ka, kd, ks, 10, true);
model->bindTexture(0.1, 0.6, 0.9);
models.push_back(model);
|
这些模型的默认的材质参数ka
、kd
、ks
分别为
| float ka[4] = {1, 1, 1, 1};
float kd[4] = {0.6, 0.6, 0.6, 1};
float ks[4] = {0.4, 0.4, 0.4, 1};
|
7.2 移动
可以通过 5.1 和 5.2 中的方法,自由移动玩家,旋转视角。
也可以通过菜单改变设置,移动光源和模型:
7.3 灯光和阴影
可以关闭光源和阴影:
可以更改灯光颜色(关闭阴影,红色,并非纯红,因此有少许蓝色分量):
打开阴影,绿色,同样并非纯绿:
7.5 动画
可以控制动画的运行和关闭。上述移动光源就是在光源动画关闭下完成的。
8 总结
本次大作业涉及了
- 模型载入,包括自定义的模型
- BMP 贴图载入
- 使用 Skybox 渲染环境
- 渲染模型、光照、贴图
- 使用 Shadow Mapping 方法渲染阴影
- 通过矩阵计算,控制玩家自由移动和转向
- 通过抗锯齿和超采样提升画质
- 研究了提升阴影画质的两种方法
- 实现了光源动画、光源颜色的修改
- 实现光源、阴影的启用和关闭
当然存在不足之处
- 使用原始的绘制方法,帧率较低
- 阴影画质仍然较差
- 模型贴图分辨率低(懒得找),没有贴图的模型不好看