2021-06-12OpenGL 大作业:3D
场景渲染
编写了一个可交互的虚拟 3D
场景演示程序,代码结构如下,点此下载
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 计算包围盒
遍历所有顶点,得到三个坐标到最大值和最小值,这样就得到了包围盒子对角线的两个顶点坐标
(x_{max},
y_{max},z_{max})、(x_{min},y_{min},z_{min})
,这两个坐标取中点即为中心。接下来,计算包围盒子的中点和对角线长度
l =
\sqrt{(x_{max}-x_{min})^2+(y_{max}-y_{min})^2+(z_{max}-z_{min})^2}
最后,修改模型的坐标,将模型中心平移到原点处,即所有的坐标均减去中心坐标。并且将模型“归一化”,即将所有坐标除以上述对角线长度,缩放成最远的点距离原点不超过
1 的大小。
1.3 计算法向量
如果三角面的三个顶点按 OBJ 文件中的顺序给出,设为
p_0(x_0,y_0,z_0)、p_1(x_1,y_1,z_1)、p_2(x_2,y_2,z_2),则该三角面按照右手规则的一个法向量为:
\overrightarrow{p_0p_1}\times\overrightarrow{p_0p_2}=\left|\begin{array}{ccc}
\boldsymbol{i} & \boldsymbol{j} & \boldsymbol{k}
\\
x_1-x_0 & y_1-y_0 & z_1-z_0 \\
x_2-x_0 & y_2-y_0 & z_2-z_0 \\
\end{array}\right|=\left[\begin{array}{c}
(y_1-y_0)(z_2-z_0)-(y_2-y_0)(z_1-z_0)\\
(x_2-x_0)(z_1-z_0)-(x_1-x_0)(z_2-z_0)\\
(x_1-x_0)(y_2-y_0)-(x_2-x_0)(y_1-y_0)\end{array}\right]
由于该法向量能体现三角形的面积,因此计算顶点法向量时,直接取相邻面法向量的平均就可以计算顶点法向量,不用再对面积加权。
根据是平面渲染模型还是光滑渲染模型,选择不同的法向量赋值给最终的vn向量。对于平面渲染,将每个三角面的法向量长度归一化后,作为该三角面的第一个顶点的vn值,对于光滑渲染,将加权后的顶点法向量长度归一化后,作为该三角面的第一个顶点的vn值:
// 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
贴图绑定到对应的材质。这里针对材质的缩放,设为效果更好的线性插值而非最近点差值,并且如果坐标超出原先的材质范围,采用重复的方式填充。
// 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 六个面的贴图,并依次绑定:
// 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 创建深度图
首先要以光的视角创建深度图。为此需要知道光的投影矩阵和光的视图矩阵。设置深度图的大小为
1024\times1024,因此光的投影矩阵长宽比为
1:1,视井体最远处设为两倍世界的大小(极端情况下,Skybox
对角线距离为 \sqrt{3}
倍世界大小)。由于是点光源,无法设置
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为玩家面朝前方的方向向量。并设置视口和窗口大小相同:
glViewport(0, 0, w, h);
设置好上述矩阵后,允许颜色输出,并裁减相机视角下模型的背面以节省时间,启用光照,依次渲染环境(Skybox)和模型
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 渲染阴影部分
为了将相机坐标中的某点 \boldsymbol{p}_C
和光的视角下的深度图的某点 \boldsymbol{p}_L
的深度进行比较,需要进行坐标变换。在相机坐标中的某点
(x_C,y_C,z_C),需要经过相机的视图逆变换
\boldsymbol{V}_C^{-1},得到在真实的坐标系(canonical)下的坐标,之后再经过光的视图变换
\boldsymbol{V}_L,得到在光的视角下的坐标,最后经过光的投影矩阵
\boldsymbol{P}_L,得到了深度图上对应的某点坐标。但是投影后的深度信息在
[-1,1]
之间,为了和深度图中 [0,
1]
对应,需要先缩放一半,再平移半个单位,即对应偏移矩阵
\boldsymbol{B}。最后的结果为
\boldsymbol{p}_L=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}\boldsymbol{p}_C
其中 \boldsymbol{B}
矩阵可用
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
之后,为了应用相机视图矩阵逆变换,取出上述左边三个矩阵相乘的结果
\boldsymbol{T}=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}
并参考示例代码,通过材质坐标生成实现逆变换
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*和当前坐标对应的原始坐标的点乘,即
S=\boldsymbol{t}_1\boldsymbol{p}\\
T=\boldsymbol{t}_2\boldsymbol{p}\\
R=\boldsymbol{t}_3\boldsymbol{p}\\
Q=\boldsymbol{t}_4\boldsymbol{p}\\
其中 \boldsymbol{t}_i 为变换矩阵
\boldsymbol{T}
的按行分块:
\boldsymbol{T}=\left[\begin{array}{c}\boldsymbol{t}_1\\\boldsymbol{t}_2\\\boldsymbol{t}_3\\\boldsymbol{t}_4\end{array}\right]
\boldsymbol{p}
为原始坐标,由于执行这段代码时,当前的视图矩阵正是相机的视图矩阵,因此
\boldsymbol{p}
对应的视图中的坐标正是相机视图坐标 \boldsymbol{p}_C
\boldsymbol{p}=\boldsymbol{V}_C^{-1}\boldsymbol{p}_C
故
\left[\begin{array}{c}S\\T\\R\\Q\end{array}\right]=\boldsymbol{Tp}=\boldsymbol{TV}_C^{-1}\boldsymbol{p}_C=\boldsymbol{B}\boldsymbol{P}_L\boldsymbol{V}_L\boldsymbol{V}_C^{-1}\boldsymbol{p}_C=\boldsymbol{p}_L
因此,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
值即可。这也符合所谓的吃鸡等游戏的跳跃/飞行操作逻辑。如果用
\boldsymbol{m}
表示移动的单位方向向量,\boldsymbol{p}
表示玩家的方向向量,则这种情况下
\boldsymbol{m}=\left[\begin{array}{c}0\\1\\0\\\end{array}\right]
向上移动的代码如下所示,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相乘:
\boldsymbol{m}=\left[\begin{array}{c}p_x\\0\\p_z\\\end{array}\right]/\sqrt{p_x^2+p_z^2}
并依次加到玩家的 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
轴朝右),再与玩家坐标相加:
\boldsymbol{m}=\left[\begin{array}{ccc}0&0&1\\0&1&0\\-1&0&0\end{array}\right]\left[\begin{array}{c}p_x\\0\\p_z\\\end{array}\right]/\sqrt{p_x^2+p_z^2}=\left[\begin{array}{c}p_z\\0\\-p_x\\\end{array}\right]/\sqrt{p_x^2+p_z^2}
代码如下:
// 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
轴对玩家的方向向量顺时针(也不是逆时针,和上述一样)旋转即可,如果用
\boldsymbol{p}'
表示旋转后的方向向量,则
\boldsymbol{p'}=\left[\begin{array}{ccc}\cos\alpha&0&\sin\alpha\\0&1&0\\-\sin\alpha&0&\cos\alpha\end{array}\right]\left[\begin{array}{c}p_x\\p_y\\p_z\\\end{array}\right]=\left[\begin{array}{c}p_x\cos\alpha+p_z\sin\alpha\\p_y\\-p_x\sin\alpha+p_z\cos\alpha\\\end{array}\right]
代码如下,如果是向右,则设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
轴先逆时针(不是顺时针)转动 \theta 角,然后再绕 x
轴顺时针(这里也不是逆时针)旋转所需角度,最后再转回
\theta
角,如下图所示:

故
\boldsymbol{p'}=
\left[\begin{array}{ccc}\cos\theta&0&-\sin\theta\\0&1&0\\\sin\theta&0&\cos\theta\end{array}\right]
\left[\begin{array}{ccc}1&0&0\\0&\cos\alpha&\sin\alpha\\0&-\sin\alpha&\cos\alpha\end{array}\right]
\left[\begin{array}{ccc}\cos\theta&0&\sin\theta\\0&1&0\\-\sin\theta&0&\cos\theta\end{array}\right]
\left[\begin{array}{c}p_x\\p_y\\p_z\\\end{array}\right]
其中
\theta=\arctan\frac{p_x}{p_z}
借助rotLeft函数,代码如下:
// 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,高光则一直为白色。
// 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这一全局变量控制动画是否运行,该变量通过菜单修改。
// 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 阴影画质提升
默认情况下,在没有阴影的区域,有时会出现条带状阴影的图案,这是因为在这些区域上的点,经坐标变换深度检测后,其深度刚好与深度图的深度相等。但是由于浮点数的计算显然有误差,因此有一部分点会被判小于,而落入阴影区域内。解决这一问题的方法,个人想到了可以通过修改偏移矩阵
\boldsymbol{B}
实现,将最后的平移改为
glTranslated(0.5, 0.5, 0.4997);
这样,深度值在比较时就有了一定的阈值,防止了由副点误差导致的阴影条带的出现。但是这里不能少太多,不然会出现影子断层的现象。0.4997
是该场景下经验试出的最优值。
同时,也可以改变 Shadow Map
的大小,减小阴影的锯齿。这里将 Shadow Map
大小从原来示例代码中的 512\times512 改为 1024\times1024。
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 上编译运行。
编译完成后,输入
./a.out
即可运行,打开窗口界面:

大小为 800\times600,载入了 6
个模型:
- 草地:绑定了示例代码中的贴图,使用平面渲染,Shininess
为 0.5
- GitHub 贡献统计:自己的 GitHub 账号在 2020
年的贡献 3D 图,使用光滑渲染,Shininess 为 20
- 老虎:绑定了给定的贴图,使用光滑渲染,Shininess 为
10
- 鸭子、猫:使用颜色作为材质,使用光滑渲染,Shininess
分别为 10、5
- 路灯:使用上一次实验的模型,使用平面渲染,Shininess
为 10
所有的模型参数在utils.cpp里可以查看:
// 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 方法渲染阴影
- 通过矩阵计算,控制玩家自由移动和转向
- 通过抗锯齿和超采样提升画质
- 研究了提升阴影画质的两种方法
- 实现了光源动画、光源颜色的修改
- 实现光源、阴影的启用和关闭
当然存在不足之处
- 使用原始的绘制方法,帧率较低
- 阴影画质仍然较差
- 模型贴图分辨率低(懒得找),没有贴图的模型不好看