计算机图形学大作业
项目地址
yzs020220/Sailing-Stimulation-using-openGL: 基于openGL的船只航行模拟与水模拟 (github.com)
引言
因为这次实验花费了比较多的时间,个人感觉自己完成的也很不错,但是当时交报告实在是不太够时间写,所以专门写一篇博客,来记录一下(可能是刚刚建起来的博客实在是太空了,所以写一下丰富一下博客的内容)。
虽然花费了比较多的时间,但其实还是有一些不尽如人意的地方,比如我尝试将Phong光照模型应用在水上,但是却会出错,不知道为什么Phong光照模型总是只会返回大概2/3水面的效果,剩下1/3几乎是全黑的,于是只好放弃做水面的光照效果了,毕竟时间有限。
现在现实游戏里的水面大部分使用的是顶点动画来制作的,而我在这次作业中更新顶点的方法是简单粗暴地删除平面后添加平面,因为最近还在学Unity Shader入门精要,等到看到这部分的内容之后也许会来做补充(挖个坑)
现在在游戏里比较经典的做法是用噪声纹理作为高度图,不断修改水面的法向,不过比较麻烦的是,在我们这一次作业里没有读取法线的方法(已经做完作业了也不想写),上网去找了github的代码我没翻到用法线贴图做的,虽然别人做的效果都很不错,但是我也不太想研究是怎么弄出来的(OpenGL每个人配的环境都不太一样),我也尝试了将菲涅耳反射应用在水面中,但法线还是不行,因为水面本身获得的光照就不正确。
我在github中翻到的看着效果挺不错的一个项目,我反正没有尝试过。opengl_examples: Collection of examples for OpenGL: Perlin noise, ambient occlusion, shadow mapping, water reflection and others
详细步骤
水面实现
参考博客:(65条消息) [OpenGL] 动态的水面模拟_ZJU_fish1996的博客-CSDN博客_opengl水面
我更推荐看这篇博客来了解实现思路,其实水面模拟不是一件很困难的事情,我主要是创建了一个Fluid类继承TriMesh,实现思路都是大相径庭的。
-
水的平面可以看成是一个正方形平面,不同的是它不是只有四个顶点,而是由多个四顶点的正方形平面构成的一个网格:
-
根据我们每一条边的顶点数n+1,计算出每个相邻顶点的间距,然后再根据顺序计算出各顶点的坐标与纹理坐标:
float step = 1.0f / n; // 计算顶点位置 for (float i = 0; i < n + 1; i++) { for (float j = 0; j < n + 1; j++) { vertex_positions.push_back(glm::vec3(-0.5f + step * j, 0, -0.5f + step * i)); vertex_colors.push_back(color); vertex_textures.push_back(glm::vec2(step * j, step * i)); } }
-
根据网格的坐标计算出每个三角面片的顶点下标,颜色法向纹理的下标跟面片的顶点下标相同,最后调用storeFacesPoints方法将信息存储到需要传入GPU的数据:
// 每个三角面片的顶点下标 for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) { int a, b, c, d; a = i * (n + 1) + j; b = a + 1; c = b + n; d = c + 1; faces.push_back(vec3i(a, b, c)); faces.push_back(vec3i(b, c, d)); } } normal_index = faces; color_index = faces; texture_index = faces; storeFacesPoints();
-
在main.cpp中的init方法中生成水平面,设置坐标旋转与大小,并将其与贴图加载到painter容器中:
water->generateSurface(30, glm::vec3(0.0, 0.0, 0.0), 0.03f, 0.0f, 0.05f); water->coefficientCount(0.1f); water->setTranslation(glm::vec3(0, 0.25, 0)); water->setRotation(glm::vec3(0, 0, 0)); water->setScale(glm::vec3(waterScale, 1.0, waterScale)); painter->addMesh(water, "mesh_water", "./assets/water1.jpg", vshader, fshader, 2); meshList.push_back(water);
完成这一步骤之后我们应该能生成一个网格,如果直接生成应该能看到一个平面,想看到网格我们可以调整mesh的绘制模式,在MeshPainter.cpp文件下的drawMesh函数下调用glDrawArrays的参数中将GL_TRIANGLES换成GL_LINE_STRIP即可(可能需要更多参数,如报错请自行上网搜索),该步骤非必须,但是我建议你如果出现不知道什么问题试一下这个方法能让你更直观地观察这个平面是长什么样的
水的波动实现
- 水波的运动位移满足微分方程,其中是波速,是描述阻力大小的系数,是空间坐标,是时间:
-
这个方程的求解比较复杂,我们也不是使用代码来求解,所以我们直接关注用近似的方法得到的在i,j点的顶点的z方向上的位移公式:
其中k代表所在的帧,我们要计算下一帧需要依赖当前帧与上一帧的结果。
-
为了保证迭代方程收敛,和满足如下条件:
-
接下来只需要跟据位移公式写出对应代码即可,在coefficientCount方法下计算出2)中公式的三个系数k1,k2,k3,这一步在初始化水平面时计算一次即可:
void Fluid::coefficientCount(float t) { float f1 = c * c * t * t / d / d; float f2 = 1.0f / (u * t + 2); k1 = (4 - 8 * f1) * f2; k2 = (u * t - 2) * f2; k3 = 2 * f1 * f2; }
-
在generateSurface方法下随机化vpb的坐标(即上一帧,若与当前帧相同则水面保持稳定),在openGL水面高度对应的是y轴坐标:
// 随机化之前的坐标 for (int i = 1; i < n; i++) { for (int j = 1; j < n; j++) { tmp = rand() % 2; vpb[i * (n + 1) + j].y = d * (tmp - 0.5f) * 0.5f; } }
-
在方法updateSurfacePosition中根据公式更新顶点位置,其中vertex_positions存储当前顶点位置,vpa和vpb分别存储下一帧与上一帧的顶点位置信息。最后将坐标值转换成需要传入GPU的信息:
void Fluid::updateSurfacePosition() { // 顶点位置为最新的顶点 vertex_positions = vpa; for (int i = 0; i < numOfWidth + 1; i++) { for (int j = 0; j < numOfWidth + 1; j++) { float curY = vertex_positions[i * (numOfWidth + 1) + j].y; float beforeY = vpb[i * (numOfWidth + 1) + j].y; float updateY = k1 * curY + k2 * beforeY; float deltaY = 0.0f; if (i > 0) deltaY += vertex_positions[(i - 1) * (numOfWidth + 1) + j].y; if (i < numOfWidth) deltaY += vertex_positions[(i + 1) * (numOfWidth + 1) + j].y; if (j > 0) deltaY += vertex_positions[i * (numOfWidth + 1) + j - 1].y; if (j < numOfWidth) deltaY += vertex_positions[i * (numOfWidth + 1) + j + 1].y; updateY += deltaY * k3; vpa[i * (numOfWidth + 1) + j].y = updateY; } } // 之前的顶点位置变成当前顶点位置,当前更新为计算出后的顶点位置 vpb = vertex_positions; vertex_positions = vpa; int j = 0; for (int i = 0; i < faces.size(); i++) { // 坐标 points[j++] = vertex_positions[faces[i].x]; points[j++] = vertex_positions[faces[i].y]; points[j++] = vertex_positions[faces[i].z]; } }
-
给MeshPainter类添加replaceMesh方法,弹出最后一个mesh并添加新的mesh来更新水面的顶点信息(修改顶点位置不是通过模式变化矩阵来完成,而是直接修改了mesh的points变量,所以只能用这个方法),此处使用的方法其实有非常大的内存隐患,这个vector容器使用的类型是TriMesh而压入栈与弹出栈的是Fluid类,不过这毕竟只是一个作业所以我觉得能跑起来比什么都重要,这种地方要改都是牵一发而动全身:
void MeshPainter::replaceMesh(TriMesh* mesh, const std::string& name, const std::string& texture_image, const std::string& vshader, const std::string& fshader, int type) { mesh_names.pop_back(); mesh_names.push_back(name); meshes.pop_back(); meshes.push_back(mesh); mesh_types.pop_back(); mesh_types.push_back(type); openGLObject object; // 绑定openGL对象,并传递顶点属性的数据 bindObjectAndData(mesh, object, texture_image, vshader, fshader); opengl_objects.pop_back(); opengl_objects.push_back(object); };
-
在main函数中打开窗口的循环中调用replaceMesh方法将更新顶点后的水替换上去,t表示循环3次更新一次顶点,如果更新频率太高水会非常的鬼畜:
if (t > 3) { t = 0; water->updateSurfacePosition(); painter->replaceMesh(water, "mesh_water", "./assets/water1.jpg", vshader, fshader, 2); }
船的模型读取与添加贴图
-
在网上的许多obj模型文件用代码读取有时会出现贴图错误的问题,这是因为obj模型文件有两种版本的格式,两种格式的uv mapping的方式不同,如果你使用blender可以看到导出文件时的obj有两个选项分别是.obj与.obj(legacy),我们可以使用blender打开模型文件(blender对fbx格式的模型文件支持比较好),再转成.obj(legacy),关于贴图最好选择只有一张贴图的模型,而不是在mtl文件中带有多张贴图。
-
使用readObj读取🚢模型文件,并在addMesh中指定贴图文件即可(🦈和🪨也是同理):
ship->setNormalize(true); ship->readObj("./assets/ship.obj"); // 设置物体的旋转位移 ship->setTranslation(glm::vec3(0.0, 0.5, 0.0)); ship->setRotation(glm::vec3(0.0, -65.0, 0.0)); ship->setScale(glm::vec3(1.0, 1.0, 1.0)); // 设置材质 ship->setAmbient(mat_ambient); // 环境光 ship->setDiffuse(mat_diffuse); // 漫反射 ship->setSpecular(mat_specular); // 镜面反射 ship->setShininess(shine); //高光系数 painter->addMesh(ship, "mesh_ship", "./assets/Texture_01_A.png", vshader, fshader, 3); meshList.push_back(ship);
将Phong着色应用在船上
-
在shader文件fshader.frag中(修改后缀名有语法提示方便修改代码,只需要修改后缀名并安装glsl扩展即可)实现Phong光照模型(如不了解可以去看看博客里的光照模型文章),取一个权重将它和纹理混合起来(本来shader中应该避免用if等条件判断,gpu执行逻辑计算需要绕弯而且都是对多个顶点或片元来执行,但是由于时间问题来不及重构代码,建议开多个shader文件并在drawmesh的时候根据类型指定使用的shader):
// 贴图+Phong光照 else if(type == 3){ // 将顶点坐标、光源坐标和法向量转换到相机坐标系 vec3 pos = position - eye_position; vec3 norm = (vec4(normal, 0.0)).xyz; vec3 l_pos = light.position - eye_position; vec3 N = normalize(norm); vec3 V = normalize(-pos); vec3 L = normalize(l_pos - pos); vec3 R = reflect(-L, N); // 环境光分量I_a vec4 I_a = light.ambient * material.ambient; // @TODO: Task2 计算系数和漫反射分量I_d float diffuse_dot = max(dot(L, N), 0); vec4 I_d = diffuse_dot * light.diffuse * material.diffuse; // @TODO: Task2 计算系数和镜面反射分量I_s float specular_dot_pow = pow(max(dot(R, V), 0), material.shininess); vec4 I_s = specular_dot_pow * light.specular * material.specular; vec4 I = I_a + I_d + I_s; if( dot(L, N) < 0.0 ) { I_s = vec4(0.0, 0.0, 0.0, 1.0); } fColor = texture(texture1, texCoord) * 0.7 + I * 0.3; fColor.a = 1; }
-
在init函数下设定光源
// 设置光源位置 light->setTranslation(glm::vec3(0.0, waterScale / 2, -waterScale / 2)); light->setAmbient(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 环境光 light->setDiffuse(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 漫反射 light->setSpecular(glm::vec4(1.0, 1.0, 1.0, 1.0)); // 镜面反射 light->setAttenuation(1.0, 0.45, 0.075); // 衰减系数
-
设定船的材质参数
glm::vec4 mat_ambient = { 1, 1, 1,1.0f }; glm::vec4 mat_diffuse = { 1, 1, 1, 1.0f }; glm::vec4 mat_specular = { 0.5f, 0.5f, 0.5f, 1.0f }; float shine = 200.0f;
计算船的阴影并投影到平面上
-
在meshPainter类下修改drawMesh函数,当类型为3时绘制阴影(投射到y=0的平面上):
// 阴影绘制 if (type == 3) { // 三角形阴影绘制 glBindVertexArray(object.vao); // 根据光源位置,计算阴影投影矩阵 glm::mat4 shadowProjMatrix = light->getShadowProjectionMatrix(); modelMatrix = shadowProjMatrix * modelMatrix; glUniform1i(object.typeLocation, 5); // 传递 unifrom 关键字的矩阵数据。 glUniformMatrix4fv(object.modelLocation, 1, GL_FALSE, &modelMatrix[0][0]); glUniformMatrix4fv(object.viewLocation, 1, GL_TRUE, &camera->viewMatrix[0][0]); glUniformMatrix4fv(object.projectionLocation, 1, GL_TRUE, &camera->projMatrix[0][0]); // 绘制 glDrawArrays(GL_TRIANGLES, 0, mesh->getPoints().size()); }
-
修改fshader.frag(最好另开shader文件),当type为5时绘制阴影(颜色输出为黑色):
// 阴影 else if (type == 5) { fColor = vec4(0.0, 0.0, 0.0, 1.0); }
船的移动与旋转
-
在TriMesh类中添加Movement方法(因为模型读取不一定是摆正的,所以需要一个初始的修正),船的航行是向前的,不能像螃蟹一样横着走,所以他的位移要乘上当前旋转的角度:
void TriMesh::movement(float speed) { float theta = 0.005; setTranslation(glm::vec3( translation.x + speed * theta * -sin(glm::radians(rotation.y + 65)), translation.y, translation.z + speed * theta * -cos(glm::radians(rotation.y + 65)))); }
-
添加changeDir方法,修改船在y轴上的旋转(Movement应用了y轴的旋转,所以可以做到转向航行):
void TriMesh::changeDir(float dir) { float theta = 0.2; rotation.y += dir * theta; }
-
添加键盘监听,修改全局变量speed和dir(w、s加速减速,a、d向左向右):
// 键盘响应函数 void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode) { if (action == GLFW_PRESS) { switch (key) { case GLFW_KEY_ESCAPE: exit(EXIT_SUCCESS); break; case GLFW_KEY_H: printHelp(); break; case GLFW_KEY_2: controlShark = true; speed = 0; dir = 0; break; case GLFW_KEY_1: controlShark = false; speed = 0; dir = 0; break; case GLFW_KEY_W: speed += 1; printMsg(); break; case GLFW_KEY_S: speed -= 1; printMsg(); break; case GLFW_KEY_A: dir += 1; printMsg(); break; case GLFW_KEY_D: dir -= 1; printMsg(); break; default: camera->keyboard(key, action, mode); break; } } }
-
船只跟随水面高低波动,由于水面时高时低,在y轴上不会有位移的船很容易变成潜水艇或者飞船,所以要根据所在水面的顶点位置更新它的y轴位置,由于水面的网格的x,z值不会发生改变,我们只要根据船所在的位置映射到当前水面并获取周围的四个顶点的y值,计算出当前水面相对初始值的偏移量:
float Fluid::getOffset(glm::vec3 pos) { float x = pos.x; float z = pos.z; int i = (x + 0.5) * (numOfWidth + 1.0); int j = (z + 0.5) * (numOfWidth + 1.0); if (i < numOfWidth + 1 && j < numOfWidth + 1 && i >= 0 && j >= 0) { float ay = vertex_positions[i * (numOfWidth + 1) + j].y; float by = vertex_positions[i * (numOfWidth + 1) + j + 1].y; float cy = vertex_positions[(i + 1) * (numOfWidth + 1) + j].y; float dy = vertex_positions[(i + 1) * (numOfWidth + 1) + j + 1].y; return (ay + by + cy + dy) / 4; } return 0.0f; }
-
根据offset加入船只的左右前后晃动,让它更有在海上的感觉:
void TriMesh::shake(float offset) { rotation.x = offset * 60; rotation.z = offset * 30; }
-
在main函数中根据速度和方向调用movement和changeDir函数并根据偏移值在main函数中实时更新船只的y轴坐标并调用:
ship->movement(speed); if(fabs(speed) > 0.01f) { ship->changeDir(dir); glm::vec3 pos = ship->getTranslation(); pos.y = 0.5; camera->focus(glm::vec4(pos, 1.0f)); camera->updateCamera(); } glm::vec3 pos = ship->getTranslation(); float offset = water->getOffset(pos / waterScale); pos.y = 0.5 + offset / 10; ship->setTranslation(pos); ship->shake(offset);
相机跟随
-
为Camera类添加focus方法,修改at:
void Camera::focus(glm::vec4 _at) { at = _at; }
-
修改updateCamera方法,让相机跟随焦点位置更新:
eye = at + glm::vec4(eyex, eyey, eyez, 1.0);
天空盒的实现
-
从网上下载一个天空盒图片切成6张图片(可以在网上找设定像素切割的工具,如果你是PS好手也可以直接处理,暗藏玄🐔)
-
直接生成6个正方形平面拼起来,不想赘述了,简单的很
skybox1->generateSquare(glm::vec3(0.0, 0.0, 0.0)); skybox1->setTranslation(glm::vec3(0.0, waterScale / 15, -waterScale / 2)); skybox1->setRotation(glm::vec3(0.0, 0.0, 0.0)); skybox1->setScale(glm::vec3(waterScale, waterScale, waterScale)); painter->addMesh(skybox1, "skybox1", "./assets/_skybox_6.jpg", vshader, fshader, 1); meshList.push_back(skybox1);
水的半透明效果
-
在fshader中修改水输出颜色的透明度:
// 水 else if(type == 2){ fColor = texture(texture1, texCoord); fColor.a = 0.7; }
-
透明效果详细来讲的话会涉及非常多的东西,简略地讲,我们就是将前面的透明物体与后面的物体的颜色进行混合,混合因子就是控制它们的系数,我们选择用透明度与1-透明度作为混合因子,启动混合功能并设置混合函数,最后在设置深度缓冲区为读写:
void display() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); painter->drawMeshes(light, camera); glDepthMask(GL_TRUE); }