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类,其中fvvtvn向量依次存放面、点、材质、法向量。通过Model构造函数即可新建模型。并定义了全局变量models,存放所有场景中的模型。通过以下代码载入模型并放入models中:

1
2
3
4
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向量即可。

1
2
3
4
5
6
7
8
9
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 计算包围盒

遍历所有顶点,得到三个坐标到最大值和最小值,这样就得到了包围盒子对角线的两个顶点坐标 (xmax,ymax,zmax) (xmin,ymin,zmin) ,这两个坐标取中点即为中心。接下来,计算包围盒子的中点和对角线长度

l=(xmaxxmin)2+(ymaxymin)2+(zmaxzmin)2

最后,修改模型的坐标,将模型中心平移到原点处,即所有的坐标均减去中心坐标。并且将模型“归一化”,即将所有坐标除以上述对角线长度,缩放成最远的点距离原点不超过 1 的大小。

1.3 计算法向量

如果三角面的三个顶点按 OBJ 文件中的顺序给出,设为 p0(x0,y0,z0) p1(x1,y1,z1) p2(x2,y2,z2) ,则该三角面按照右手规则的一个法向量为:

p0p1×p0p2=ijkx1x0y1y0z1z0x2x0y2y0z2z0=[(y1y0)(z2z0)(y2y0)(z1z0)(x2x0)(z1z0)(x1x0)(z2z0)(x1x0)(y2y0)(x2x0)(y1y0)]

由于该法向量能体现三角形的面积,因此计算顶点法向量时,直接取相邻面法向量的平均就可以计算顶点法向量,不用再对面积加权。

根据是平面渲染模型还是光滑渲染模型,选择不同的法向量赋值给最终的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);
}

对于没有材质的模型,预先启用颜色材质

1
2
glEnable(GL_COLOR_MATERIAL);
glColorMaterial(GL_FRONT, GL_AMBIENT_AND_DIFFUSE);

之后,重载上述方法,实现对模型颜色的设置,其中trtgtb三个结构体属性为模型的材质颜色:

1
2
3
4
5
6
7
// 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结构体中还包括xyzpqr,分别代表模型摆放的位置,沿三个轴旋转的角度。首先应在原点处旋转该模型,再平移到对应的摆放位置,如果先平移的话,显然一转就转飞了。因此在render函数中,首先应用以下变换:

1
2
3
4
5
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向量里的材质坐标,进行绘制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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();

对于没有贴图的模型,不需要上述的glTexEnvfglBindTextureglTexCoord2d,只需要通过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 不会受到光照的影响:

1
2
glEnable(GL_TEXTURE_CUBE_MAP);
glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);

3.2 地面

以参考代码FieldAndSky.cpp中草地的材质作为地面。为了重复使用Model类,可以自己手动编写一个 OBJ 文件ground.obj,表示一个矩形平面,并设置材质坐标:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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,其余使用默认的材质参数:

1
2
3
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×1024 ,因此光的投影矩阵长宽比为 1:1,视井体最远处设为两倍世界的大小(极端情况下,Skybox 对角线距离为 3 倍世界大小)。由于是点光源,无法设置 FOV,但这里将光源视为太阳,在场景的高处,因此只需考虑向下发出的这部分光线,将 FOV 设大一些即可,这里的LIGHTFOV为 120。因此得到投影矩阵

1
gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2);

对于视图矩阵,认为光源始终照向原点。用lpos表示光源的位置,则光的视图矩阵为

1
gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0);

同时,设置视口大小为深度图的大小SHADOWSIZE,这里为 1024。

1
glViewport(0, 0, SHADOWSIZE, SHADOWSIZE);

设置好上述矩阵后,禁用颜色输出,并裁掉模型朝光的视角的前面,因为用后面即可确定深度图。之后,不用材质渲染模型以节约时间,得到光的视角下的深度信息,将其存放在最后一个材质内:

1
2
3
4
5
6
7
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 渲染场景

之后,清除深度值的缓冲,以相机的视角渲染场景。相机的投影矩阵为

1
gluPerspective(FOV, (double)w / (double)h, 1, WORLDSIZE * 2);

其中宽高比为窗口的宽高比,FOV设为 60。相机的视图矩阵为

1
gluLookAt(ppos[0], ppos[1], ppos[2], ppos[0] + pvec[0], ppos[1] + pvec[1], ppos[2] + pvec[2], 0, 1, 0);

其中ppos为玩家的坐标,pvec为玩家面朝前方的方向向量。并设置视口和窗口大小相同:

1
glViewport(0, 0, w, h);

设置好上述矩阵后,允许颜色输出,并裁减相机视角下模型的背面以节省时间,启用光照,依次渲染环境(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 渲染阴影部分

为了将相机坐标中的某点 pC 和光的视角下的深度图的某点 pL 的深度进行比较,需要进行坐标变换。在相机坐标中的某点 (xC,yC,zC) ,需要经过相机的视图逆变换 VC1 ,得到在真实的坐标系(canonical)下的坐标,之后再经过光的视图变换 VL ,得到在光的视角下的坐标,最后经过光的投影矩阵 PL ,得到了深度图上对应的某点坐标。但是投影后的深度信息在 [1,1] 之间,为了和深度图中 [0,1] 对应,需要先缩放一半,再平移半个单位,即对应偏移矩阵 B 。最后的结果为

pL=BPLVLVC1pC

其中 B 矩阵可用

1
2
glTranslated(0.5, 0.5, 0.5);
glScaled(0.5, 0.5, 0.5);

实现。而光的投影矩阵、光的视图矩阵同上,直接放在下方相乘。

1
2
gluPerspective(LIGHTFOV, 1, 1, WORLDSIZE * 2);          // light Projection
gluLookAt(lpos[0], lpos[1], lpos[2], 0, 0, 0, 0, 1, 0); // light Modelview

之后,为了应用相机视图矩阵逆变换,取出上述左边三个矩阵相乘的结果

T=BPLVLVC1

并参考示例代码,通过材质坐标生成实现逆变换

 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*和当前坐标对应的原始坐标的点乘,即

S=t1pT=t2pR=t3pQ=t4p

其中 ti 为变换矩阵 T 的按行分块:

T=[t1t2t3t4]

p 为原始坐标,由于执行这段代码时,当前的视图矩阵正是相机的视图矩阵,因此 p 对应的视图中的坐标正是相机视图坐标 pC

p=VC1pC

[STRQ]=Tp=TVC1pC=BPLVLVC1pC=pL

因此,R 值对应的就是变换后待比较的的深度信息。因此设置比较函数,如果变换后的深度(R)比原先的深度图深,则置 alpha 值为 0,否则为 1。为了渲染阴影部分,应丢弃 alpha 值为 1 的部分,通过启用GL_ALPHA_TEST并设置比较函数为小于 0.5 阈值以实现:

1
2
3
4
5
6
7
8
// 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);

最后,绘制阴影区域:

1
2
3
4
// draw shadow area
if (renderShadow)
    for (Model *model : models)
        model->renderShadow();

这里为Model结构体编写了renderShadow方法,渲染时不启用材质,而是通过

1
2
// draw with black to create shadow effect
glColor3d(0.1, 0.1, 0.1);

设置渲染的颜色为深灰色,以此实现影子的效果。

5 交互控制

5.1 移动玩家

通过glutKeyboardFunc设置输入的回调函数为input1,该函数中,通过 w/a/s/d 控制玩家前后左右移动,通过空格/c 控制玩家上下飞。为了确定玩家位置和朝向,维护ppospvec两个全局变量。并在player.cpp中编写玩家移动的相关函数。

最简单的是上下移动,无论玩家朝向何处,只要改变 y 值即可。这也符合所谓的吃鸡等游戏的跳跃/飞行操作逻辑。如果用 m 表示移动的单位方向向量,p 表示玩家的方向向量,则这种情况下

m=[010]

向上移动的代码如下所示,d为移动距离。向下移动只需将d设为负数,之后的几种移动同理:

1
2
3
4
5
// move player up (i.e. fly)
void moveUp(double *ppos, double *pvec, double d)
{
    ppos[1] += d;
}

其次是向前移动,这里取玩家的方向向量在 x-z 平面上的投影,作为向前移动的方向。即玩家抬头时按住 w,只会在平面方向移动,并不会同时向上飞,这也符合所谓的吃鸡等游戏的操作逻辑。因此,仅需丢掉 y 分量的值,将 x-z 平面投影的结果归一化后,与d相乘:

m=[px0pz]/px2+pz2

并依次加到玩家的 x 和 z 坐标即可:

1
2
3
4
5
6
7
// 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 轴朝右),再与玩家坐标相加:

m=[001010100][px0pz]/px2+pz2=[pz0px]/px2+pz2

代码如下:

1
2
3
4
5
6
7
// 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 轴对玩家的方向向量顺时针(也不是逆时针,和上述一样)旋转即可,如果用 p 表示旋转后的方向向量,则

p=[cosα0sinα010sinα0cosα][pxpypz]=[pxcosα+pzsinαpypxsinα+pzcosα]

代码如下,如果是向右,则设a为负数即可:

1
2
3
4
5
6
7
8
// 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 轴顺时针(这里也不是逆时针)旋转所需角度,最后再转回 θ 角,如下图所示:

球坐标

p=[cosθ0sinθ010sinθ0cosθ][1000cosαsinα0sinαcosα][cosθ0sinθ010sinθ0cosθ][pxpypz]

其中

θ=arctanpxpz

借助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);
}

同时,通过菜单可修改renderLightrenderShadow函数,前者决定是否渲染灯光,如果不渲染,则只有全局的环境光;后者决定是否渲染阴影,如果不渲染,则不启用 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 超采样

通过

1
glEnable(GL_MULTISAMPLE);

并在初始化时额外设置GLUT_MULTISAMPLE

1
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_MULTISAMPLE);

实现超采样。开启后,帧率有所下降。

6.2 抗锯齿

通过使绘制的点、线、多边形平滑,实现抗锯齿,并按照示例设置混合函数:

1
2
3
4
glEnable(GL_POLYGON_SMOOTH);
glEnable(GL_LINE_SMOOTH);
glEnable(GL_POINT_SMOOTH);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

开启后,帧率也有所下降。

6.3 阴影画质提升

默认情况下,在没有阴影的区域,有时会出现条带状阴影的图案,这是因为在这些区域上的点,经坐标变换深度检测后,其深度刚好与深度图的深度相等。但是由于浮点数的计算显然有误差,因此有一部分点会被判小于,而落入阴影区域内。解决这一问题的方法,个人想到了可以通过修改偏移矩阵 B 实现,将最后的平移改为

1
glTranslated(0.5, 0.5, 0.4997);

这样,深度值在比较时就有了一定的阈值,防止了由副点误差导致的阴影条带的出现。但是这里不能少太多,不然会出现影子断层的现象。0.4997 是该场景下经验试出的最优值。

同时,也可以改变 Shadow Map 的大小,减小阴影的锯齿。这里将 Shadow Map 大小从原来示例代码中的 512×512 改为 1024×1024

7 运行结果

7.1 编译

在 macOS 下,需要通过 Homebrew 安装 glew 和 glut 库:

1
2
brew install glew
brew install glut

之后执行./build.sh即可编译。如需手动编译,则输入

1
c++ utils.cpp bmp.cpp model.cpp player.cpp main.cpp -lglew -framework glut -framework opengl

链接这些库和系统自带的 OpenGL 框架,即可编译。

代码仅在 macOS 上进行了测试,但考虑了一定的 Windows 和 Linux 的兼容性,在 Windows 上应该也可成功编译。如果编译失败或效果与下面的截图不一致,可在 macOS 上编译运行。

编译完成后,输入

1
./a.out

即可运行,打开窗口界面:

大小为 800×600 ,载入了 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);

这些模型的默认的材质参数kakdks分别为

1
2
3
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 方法渲染阴影
  • 通过矩阵计算,控制玩家自由移动和转向
  • 通过抗锯齿和超采样提升画质
  • 研究了提升阴影画质的两种方法
  • 实现了光源动画、光源颜色的修改
  • 实现光源、阴影的启用和关闭

当然存在不足之处

  • 使用原始的绘制方法,帧率较低
  • 阴影画质仍然较差
  • 模型贴图分辨率低(懒得找),没有贴图的模型不好看