赞
踩
大家好,我是冤种
五月。
由于《初级软件实作》课程的需要,加上自己对渲染感兴趣,前段时间学了OpenGL,所以想着用OpenGL设计一个软件。老师给了我一个选题,是FPS游戏引擎。
写引擎的过程中我疯狂踩坑。
首先要谢谢learnOpenGL-cn,没有它就没有我前期的快速学习;谢谢它,更新了前面6章的代码,没更新第7章的代码;更谢谢我自己,学习不认真,以至于我两个版本的代码混用而不自知,浪费了一个月时间自闭。
所以,建议学习OpenGL的你,前期参考learnOpenGL-cn,第7章开始参考learnOpenGL(英文原版)。冷知识,learnOpenGL不需要科学上网,但是科学上网才可以看到中文译版和英文原版评论区(评论区里有很多前辈已经帮我们踩坑了)。
于是我决定写下这篇博客,跟大家分享我开发过程中的心得。
博客主要是分享思路大纲
,如果大家对具体的知识感兴趣,可以点开链接过去看。
加载玩家自定义的FPS的战斗场景,包括总体空间(一个包裹着整个游戏环境的长方体,其大小应由游戏配置文件来定义)、里面的各种障碍物/躲避物(立方体、或长方体即可);
加载玩家自定义的各种敌人:包括其位置和特性(角色形象可自定);
加载玩家自定义属性值的血包、弹药包。
允许玩家射击、拾取
参数由用户自定义
在开始造房子之前,我们需要先准备好建材。
glad是用来访问OpenGL规范接口的第三方库,实现代码跨平台。配置教程看这里
需要理解的是,OpenGL中许多缓冲区的分配、对象的开辟,都是用一个无符号整型数id
来进行引用和值传递。
glad库是glew库的升级版。老版的learnOpenGL代码就是用glew库写的,新版用的是glad。
全称Graphics Library Framework,主要是用来创建并管理窗口和OpenGL上下文的图形库框架。
全称OpenGL Mathematics,是基于GLSL规范的图形软件的数学库。
摄像机类的矩阵变换、着色器mvp矩阵的赋值,都离不开它。
因为glm库的实现都写在头文件中,所以不需要编译成库。下载链接在这里。
用于加载纹理的库。教程在这里。
这个库只有一个头文件stb_image.h,用的时候直接包含进去就行了,此时只能用加载和销毁函数。如果考虑到其它更高级的图片加载函数和配置,就需要在包含头文件之前加一行定义#define STB_IMAGE_IMPLEMENTATION
。
打开VS,观察一下头文件会发现,如果不加这个定义,stb_image.h源码中
#ifdef STB_IMAGE_IMPLEMENTATION
下面的代码都是灰色的。我不懂,网上的说法是这样加一行之后,预处理器会修改头文件,让其只包含相关的函数定义源码,相当于把.h文件变成.cpp文件。
老版的learnOpenGL的纹理加载方法是用SOIL库写的,但是这个库比较老了,而且有些mac电脑用不了。
全称Asset Importer Lib,用于加载和处理各种3D模型。配置和使用方法在这里。
这是一个动态链接库,要把assimp-vc143-mtd.dll放到与工程生成的.exe文件相同的目录下。
在准备部分有提到,我们会用glad库配置OpenGL的接口,用glfw配置渲染窗口,有印象嘛?
这是每个OpenGL都离不开的步骤,是最基础的。
所以我的小游戏的第一件事就是先把窗口创建出来。
思路是:先导库(glad,glfw,iostream)-> 初始化glfw渲染窗口 -> 初始化glad函数上下文 -> 配置OpenGL -> 空出位置配置其它素材 -> 编写渲染循环:响应用户输入,更新数据,渲染 -> 回收工作
考虑到要实现glfw窗口回调。也就是我希望,当我按下按键Esc时,glfw配置的窗口能知道并响应我们的行为,或者当我们拖拽窗口边界时,窗口大小能重新调整。因此我需要增加响应函数。
// 声明函数 void framebuffer_size_callback(GLFWwindow* window, int width, int height); // 配置窗口(帧缓冲)大小的函数 void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode); // 响应用户键盘输入的函数 ... // glfw: 窗口回调函数 glfwSetKeyCallback(window, key_callback); glfwSetFramebufferSizeCallback(window, framebuffer_size_callback); ... // 实现函数 void key_callback(GLFWwindow* window, int key, int scancode, int action, int mode) { // 当用户按下Esc键时,关闭程序 if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS) glfwSetWindowShouldClose(window, true); } void framebuffer_size_callback(GLFWwindow* window, int width, int height) { // 确保视口匹配新的窗口尺寸 // 注意宽度和高度将明显大于视网膜显示器上指定的高度 glViewport(0, 0, width, height); }
可以这么简单粗暴地理解:着色器就是一个电子画家,当你教会他画画的套路(着色器代码)之后,他指哪(图元)画哪(渲染)。
在这一节中,我只是测试了让着色器把图元填充成蓝色。
使用着色器有三件事要做:第一件事,从读取着色器代码,第二件事,配置参数、编译代码,第三件事,激活代码。接下来我一一说明。
根据单一职责原则,最好设计一个类专门负责读取代码,一个类专门负责编译代码。
又考虑到将来我们还要加载纹理和模型,还要管理他们,因此我先编写了一个ResourceManager
类。它的基本架构是,设置一个map字典
,用名字查找对应的着色器。增加Load函数
实现加载文件,Get函数
获取着色器对象,Clear函数
完成回收工作。Manager类一般是单例类,构造函数私有,成员函数全为静态,函数返回值一般是对应资源的引用。
剩下的事就交给Shdaer
类完成啦!
以顶点着色器为例(其实片元着色器也是必须的,只是便于理解我先不写进去),编译的上下文如下:
unsigned int sVertex;
sVertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(sVertex, 1, &vertexSource, NULL);// 链接代码
glCompileShader(sVertex); // 编译着色器
checkCompileErrors(sVertex, "VERTEX"); // 检查顶点着色器的编译错误
···
// 绑定着色器程序到id
this->ID = glCreateProgram(); // 开辟缓存空间(也可以理解为新建一个OpenGL的着色器对象),返回空间的id
glAttachShader(this->ID, sVertex); // 根据id,为着色器绑定顶点着色器部分
···
glLinkProgram(this->ID); // 链接程序
checkCompileErrors(this->ID, "PROGRAM"); // 检查整个着色器的编译错误
// 删除着色器(因为它们现在已经被链接到我们的程序了,不再需要了)
glDeleteShader(sVertex);
激活着色器的上下文非常简单:
glUseProgram(this->ID);
每次渲染模型之前,都要激活着色器。
s.Use(); //激活着色器
glBindVertexArray(quadVAO); //绑定图元
glDrawArrays(GL_TRIANGLES, 0, 6); //画它
glBindVertexArray(0); //解绑
参考教程在这里。
在ResourceManager
类中,开始用上了stb_image
库,只用了两个函数,无敌:
// 加载图像数据(是char数组)
int width, height, nrChannels;
unsigned char* data = stbi_load(file, &width, &height, &nrChannels, 0);
// 交给纹理对象,生成纹理
texture.Generate(width, height, data);
// 释放图像数据
stbi_image_free(data);
由于图片的y轴原点在左上角,OpenGL的纹理原点在左下角,所以加载纹理之前要先配置一下:
stbi_set_flip_vertically_on_load(true);//翻转图片y轴
用这个函数要记得先定义宏:
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
这部分是OpenGL上下文的事了,包含glad
库即可。
新增Texture2D
类,绑定纹理glBindTexture(GL_TEXTURE_2D, this->ID);
,使用glTexParameteri(···)
配合各种枚举配置纹理参数。为了减少空间的开销,在配置好纹理之后,不渲染时,程序会解绑纹理glBindTexture(GL_TEXTURE_2D, 0);
。
踩坑:做测试的时候,因为一行代码太长,命名上下不一致都没发现,浪费了半小时时间debug
测试:
s.Use(); //激活着色器
glActiveTexture(GL_TEXTURE0); // 激活纹理缓冲
t.Bind(); // 绑定问题
glBindVertexArray(quadVAO); //绑定图元
glDrawArrays(GL_TRIANGLES, 0, 6); //画它
glBindVertexArray(0); //解绑
assimp
库闪亮登场!依旧是沿用ResourceManager
类加载文件,Mesh
类处理OpenGL上下文的架构。参考教程在这里。
使用assimp库自带的导入器
Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_GenSmoothNormals | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);
按教程所说,一个模型所导出的scene对象,背后是一个树结构,从根结点出发,遍历每个子节点,就可以得到网格Mesh
。所有网格Mesh组成一个模型Model,可以说typedef vector<Mesh> Model
。我们将来在Render
类渲染的时候,也是一个一个网格渲染的。
创建一个Mesh对象同样需要准备顶点数组和索引数组,这将是构造函数必备的参数。接着,构造函数会以此初始化其它gl上下文,并得到一个顶点数组对象的引用VAO。绘制模型时,同样仅需三句代码:
glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, static_cast<unsigned int>(indices.size()), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);
完成了基础的渲染,是时候把这些代码从main函数封装起来了!
首先是渲染部分,我要定义一个Renderer
类,把着色器、纹理贴图、模型三种资源集合起来,在有需要的时候就调用它的成员函数Draw()
,调动这些资源完整地绘制出图像。
另外我还会定义一个负责配置Shader参数的函数void setPbrShaderParameters(glm::vec3 albedo, float metallic, float roughness, float ao);
。可惜的是,本项目中的PBR着色器是不正确的。
一个游戏不仅仅涉及渲染,它还要接收用户的设备输入,要计算众多数值。这些事情都塞在main函数里是不合适的,毕竟main函数已经承担了配置窗口等脱不开身的工作。
所以,我还需要定义一个Game
游戏类,它的成员属性包含了游戏中所需的各种参数和状态变量,成员函数包含了初始化函数void Init();
,处理输入的函数void ProcessInput(float dt);
,更新参数函数void Update(float dt);
,渲染函数void Render();
。
在这一阶段我的任务仅需复制粘贴learnOpenGL的代码,确保搬运到类中的渲染代码能正常执行即可。而之后的每个阶段,就多多少少都涉及对Game类的改动。
摄像机类Camera
的创建涉及MVP矩阵变换,教程在这里。
在作业中,我用到的摄像机是经过小改的FPS摄像机,禁止上下移动。我需要在game类中定义一个camera,初始化它。在game类的ProcessInput函数中处理摄像机的移动,在Update函数中获取视口矩阵camera.GetViewMatrix()
并以此赋值给着色器。
现在我可以通过WASD键和鼠标来移动视角啦!
随着代码量的增多,可调的参数也越来越多,例如模型的变换。那么现在可以开始考虑嵌入自定义参数了。
我在ResourceManager类中新增了参数结构体Parameter
,并添加了从文件中读取参数的函数static Parameter LoadParameter();
。
简单起见,我为角色定义了两个属性:生命值hp
和攻击力atk
。另外还定义了一个bool值isDeath
。
到了这一步类越来越多了,可能读者会犯迷糊。大家可以联想一下unity的GameObject是怎么设计的:一个3D游戏的GameObject一般包含Transform组件,Mesh Filter组件,Mesh Render组件,CScript自定义脚本组件。前三者被我规范成了我的Renderer类,最后一者就是这个Character类。
我还为角色类编写了改变hp和atk的函数void modifyHp(float value)
和void modifyAtk(float value)
。并且,当hp<=0时,isDeath = true。
这真的是一个很简单的类。
在这个简单的引擎中,游戏对象需要处理三件事:渲染成什么样子,角色属性是多少,tag。
参考OpenGL的教程,渲染对象Renderer将被独立出来、存在于Game类中,当需要的时候再调用函数virtual void Draw(Renderer& renderer);
渲染即可。毕竟多个GameObject可能对应的是同样的物体,例如,摆在位置(0,0)和摆在位置(5,-9)的障碍物都是同样的。
考虑到每个角色属性都不一样,我把Character对象设为GameObject的成员属性了(我不确定这是合适的)。
tag是为了区分不同类型的对象,以便区别处理。
enum tagType {
PLAYER, ENEMY, BAG, UNDEFINED
};
调整Game类的代码,根据读到的参数去配置场景中的物体。
在ProcessInput函数
中增加代码,当按下0键时重新加载参数和场景。
给Game类增加函数void Shoot();
实现思路是,从摄像机位置camera.Position
沿摄像机的前向方向camera.Front
发射一条射线,求出射线与物体的几何中心所在的平面(在此我错误地、粗糙地设平面为y=0)的交点。遍历每个可击中或拾取的物体,即敌人或血包,若物体与交点在x轴上的距离小于1,或者在z轴上的距离小于1,则说明击中。
现在我们需要调用它,就需要在mian函数新增对鼠标按键的响应,并把响应结果传给Game类。
void mouse_button_callback(GLFWwindow* window, int button, int action, int mods);
glfwSetMouseButtonCallback(window, mouse_button_callback);
void mouse_button_callback(GLFWwindow* window, int button, int action, int mods)
{
if (action == GLFW_PRESS && button == GLFW_MOUSE_BUTTON_LEFT)
fps.shooting = true;
}
后来我把GLFW_MOUSE_BUTTON_LEFT改成GLFW_MOUSE_BUTTON_RIGHT了
碰撞检测Hit();
需要在移动之前执行。
实现思路是,求出障碍物与摄像机在x轴上的距离Dx
,在z轴上的距离Dz
,若两者都小于障碍物的半径(在此我粗略地定为2),则说明摄像机与障碍物已发生碰撞。
如何判断碰撞的方向呢?求出障碍物相对于摄像机的方向向量glm::vec3 DirectionVec = glm::vec3(o->position.x, 0.0, o->position.y) - p;
,将方向向量与摄像机的前向向量进行点乘,若结果大于0,说明两者朝向相同,否则朝向相反。当大于0.5时,不能朝前走,小于-0.5时不能朝后走。左右同理。
其实这个算法不是完全正确的,只能实现阻挡,不能实现被迫挡着导致斜着走。
前期配置环境我走了很多弯路,消耗了太多的时间,后期也没有完善的力气了,就这样吧quq
代码已开源在GitHub,.sln的配置是不对的,需要同学有一定的重新配置能力。欢迎围观参考。
如果有任何疑问/建议/批评,欢迎在评论区留言告诉我XD
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。