本文记录了使用 OpenGL 实现一个 3D 贪吃蛇的完整过程。从建立游戏框架到设计游戏逻辑,最后加入动画效果,形成一个真正可玩的小游戏。
🚀 代码: Github
首先,开局一张图…
程序框架
这次需要使用 OpenGL 完成一个完整的游戏,因此需要先设计程序的框架架构。
这里的程序参考了 LearnOpenGL 这个网站上的教程。在此基础上完善并加入自己的设计。
程序的基本架构如下
其中比较重要的部分有:
- Camera : 负责摄像机管理和控制
- Resource Manager: 资源管理器,管理贴图、字体、模型、着色器等资源
- Shader : 着色器的管理和控制
- Sprite: 游戏中的单位对象
- Sprite Renderer : 负责渲染游戏单位
总体结构
首先,我们可以把游戏逻辑从 OpenGL 中抽取出来,基本可以分成三种状态Init(初始化)
, Update(更新)
和Clear(清理)
,其大概结构如下:
1 | class Application { |
然后,就可以将 OpenGL 中的 窗口初始化、窗口大小更改回调、渲染准备等代码和应用的逻辑分离开来,在 Application 中,我们只需要关注游戏逻辑的处理和对象的渲染即可。
1 | enum GameState {GAME_ACTIVE, GAME_MENU, GAME_LOAD, GAME_OVER, GAME_PAUSE}; |
Init
在初始化阶段,需要加载各种资源,初始化各种变量、对象以及状态。
1 | void GameApp::Init(GLFWwindow* window) { |
Update
在更新阶段,游戏需要处理的有更新摄像机的位置、处理输入、渲染以及更新游戏逻辑。
1 | void GameApp::Update() { |
Clear
在清理阶段,需要释放各种资源
1 | void GameApp::Clear() { |
Camera
摄像机类是一个比较重要的类,负责管理游戏中的摄像机位置以及控制视图的移动
其基本的结构如下:
1 | class Camera { |
我们可以在程序任意地方设置摄像机的位置,然后渲染时通过GetViewMatrix
获取视图矩阵设置到着色器中。
Resource Manager
资源管理器是一个单例,负责全局加载资源以及复用,把纹理、模型、着色器、字体等资源从文件中读取到解析封装到资源管理器中后,我们就可以不必重复处理这些繁杂的操作,仅仅需要一句话就可以加载我们需要的资源。
1 | // 加载字体 |
然后在程序的任意地方通过Get
就可以获取预先加载好的资源,实现了资源的有效复用
1 | ResourceManager::GetShader("object") |
其基本架构如下:
1 | class ResourceManager |
Shader
Shader 的加载比较简单,只需要从文件中读取出字符串,然后使用glCompileShader
将其编译为着色器就成功了。
加载完毕之后,在渲染过程中,我们经常会更新一些信息,如model
, view
, projection
以及光照、材质等信息,这些信息通过是以uniform
的形式存在,需要我们在渲染过程中实时更新。因此,我们必须给Shader
类加上一系列的方法,以便我们更新这些信息。
1 | void SetFloat(const GLchar* name, GLfloat value, GLboolean useShader = false); |
这这个游戏里面,主要用到两种着色器,字体着色器以及精灵对象着色器
字体着色器主要需要投影信息以及纹理和颜色信息,一般来说,需要字体直接绘制在屏幕上,只需要设置于屏幕大小一样的正交矩阵即可。(下面仅展示主要信息,需要详细了解可以直接查看代码)
1 | // font.vs.glsl |
精灵对象的着色器就比较复杂,为了更好地体现模型,这里引入了 Blinn-Phong
光照模型,使得游戏画面更加立体真实。
1 | // object.vs.glsl |
完成后,可以看到比较好的光照效果,在花园背景上和桌子上以及精灵上,都可以看出光照的反射效果和不同程度的明暗。
Texture
材质的加载这里使用的是stb_image
这个库,从图片中把数据读取出来,然后使用glTexImage2D
生成纹理。
1 | glBindTexture(GL_TEXTURE_2D, this->ID); |
Model
模型的加载这里使用的是Assimp
这个库
首先从模型文件(obj
)中读取信息,得到一个aiScene
1 | Assimp::Importer importer; |
在这个 Scene 中, 包含了模型顶点位置、法向量、面索引以及材质等数据,他们都是以 Node 构成的树形式存储的。我们需要做的就是从中读取数据保存在我们的程序当中。
通过递归遍历处理节点,就可以将节点中所有的 Mesh 都读取出来。
1 | void processNode(aiNode * node, const aiScene * scene) { |
对于每个 Mesh, 我们不仅仅要读取其所有的顶点信息,还需要将其材质也一同读取出来。
根据 LearnOpenGL 上的教程,可以将纹理材质的信息读取出来,但是却没有把材质的颜色信息也一同读取,这样就导致了没有图片纹理的模型是没办法读取的,当我们加载只包含颜色材质信息的模型,就会显示一片漆黑。
因此,我们需要修改一下,使其支持材质中颜色数据的读取。
我们知道,在obj
文件中,材质都是以链接的形式存放在另一个mtl
文件中
我们打开任意一个mtl
文件看看
1 | # Blender MTL File: '7.blend' |
其中比较重要的数据有 Ns
(反射率), Ka
(环境反射颜色), Kd
(漫反射颜色), Ks
(镜面反射颜色)
因此,我们仅需要把这四种信息都读取出来。
首先,先改造一下他的材质结构,把颜色信息也包含进去。
1 | struct Texture { |
然后在读取 Mesh 材质信息的时候,顺便把颜色信息也一同读取
1 | Mesh processMesh(aiMesh * mesh, const aiScene * scene) |
把颜色信息读取到材质里面之后,渲染的时候,就可以通过着色器把这些数据一同传递进去了。
在渲染的时候,还需要将是否具有纹理或者颜色的信息一起传递给着色器,因为如果当没有纹理依然使用纹理信息的时候渲染出来的结果往往不是预期的结果。
1 | void Draw(Shader* shader) { |
本游戏中的所有模型都是来自于 Poly 这个网站,在这个网站中,我们可以下载到各种模型,而且模型的风格都很类似,很适合结合在一起用。
这里需要提一下,在这个网站上面下载了 obj
模型文件后,通常还需要自己调整一下。因为很多模型的中心点并不在我们预期的文字中,因此对其进行旋转变换后往往会偏移当前的位置。这个可以通过导入到 Blender
中调整中心点再导出得到重置中心点的新模型。
Font
对于字体的显示,这里使用了freeType
这个库。
使用前,我们需要预先读取字体生成贴图,由于这里仅使用英文字符,只需要对 128 个字符进行初始化生成纹理。
1 | // 初始化字体 |
初始化之后,可以绑定到 VAO
和 VBO
上,然后在渲染的时候只需要绑定好这个纹理和顶点,然后计算出顶点组放入缓冲中直接进行渲染即可。
1 | void SpriteRenderer::RenderText(std::string text, glm::vec2 postion, GLfloat scale, glm::vec3 color) { |
Sprite
精灵是游戏中的主要单位,在这次这个游戏中,主要的精灵单位有:
- 太阳
- 花园场景
- 桌子
- 蛇
- 蛇头
- 蛇身
- 食物
其中由于食物、蛇头、蛇身这三个部分耦合度比较高,因此这里将其封装在一起,构成以下的代码文件:
所有的精灵都是继承于Sprite
这个基类
1 | class Sprite { |
这里封装了一些精灵通用的属性以及方法。
对于一些简单的精灵,如太阳,仅需要实现其构造函数加载模型以及渲染函数,就可以简单形成一个精灵对象,在渲染循环中使用。
1 | class SpriteSun : public Sprite { |
对于一些复杂的精灵,可以封装其方法,仅提供必要的接口。
1 | class SpriteSnake { |
Sprite Renderer
从上面的 Sprite 类可以看到,里面提到了一个 精灵渲染器的东西,这个东西是用来辅助我们实现渲染的,可以实现立方体、带纹理的立方体、模型、文字等对象的渲染,仅需要提供渲染对象以及一些渲染属性就可以实现。
这里还预先生成并保存了立方体的 VAO,可以高效地绘制多个不同纹理的立方体而无需重复绑定顶点组等。
1 | class SpriteRenderer { |
对于文字的渲染,这里使用了FreeType
这个库,使用前需要使用资源管理器预先加载字体文件,然后生成 128 个字符的贴图,接下来就可以快速地从贴图中提取纹理对文字进行渲染。
游戏逻辑
对于游戏整体的逻辑,可以直接在GameApp
里面实现
其整体的逻辑上文以及提及过了,主要是在Update
里面实现。在这里面,我们主要需要处理的是用户的输入、渲染场景以及更新游戏。
1 | void GameApp::Update() { |
处理用户输入
这里创建了一个keys
数组,用于存储键盘按键的状态
1 | // 处理输入 |
然后在不同的场景下,不同的输入会触发不同的操作。比如在菜单状态下,用户按下Enter
或者Space
可以改变游戏状态,切换到加载状态。在游戏进行中状态下,用户按下方向键或者WASD
可以控制蛇头方向。
渲染场景
处理完用户输入之后,就可以进行渲染场景了
1 |
|
在场景的渲染中,需要实时更新着色器中的视图,然后调用精灵的渲染接口进行渲染。
我们同样可以在不同的游戏状态下分别渲染不同的内容,比如在游进行期间在屏幕的上方渲染一行文字显示当前的得分。
更新游戏
在所有游戏逻辑当中,最重要的就是关于贪食蛇本身的逻辑,在贪吃蛇每一次更新过程中,都需要做以下的处理:
- 根据用户的输入更新蛇头的方向
- 更新蛇体的数据,使后一个 Body 等于前一个 Body ,实现蛇体的总体位移
- 根据方向更新蛇头方向
- 检测蛇是否碰到了自己
- 检测蛇是否出界
- 检测蛇头是否吃到了食物
1 | void SpriteSnake::Update(bool run, bool ai) { |
值得一提的是,这里我加入了一个 DFS + 简单策略 的自动寻路贪吃蛇 ,在一般情况下都可以走完 90%的格子,在运气比较好的时候甚至可以走完所有空间。具体的算法可以到这里查看
过渡动画
为了游戏中的过渡更加自然,我在游戏中很多地方都加入了动画效果,比如文章一开始的图。当游戏处于菜单状态的时候,就会随着事件不停旋转,然后使用算法自动进行展示,效果如下:
其中旋转可以通过对摄像机做旋转变换即可。
除此之外,蛇的移动以及旋转都显得十分自然,这是因为我对其做了插帧动画处理。
在一开始的设定中,游戏逻辑(蛇的移动)每 10 帧左右才更新一次(在游戏中会随着长度变快),但是这样一来,蛇的移动就会变得一格一格的,显然这是一个极其不好的体验。因此可以通过插帧处理之间的动画。
首先,我们需要保留上一次更新的状态,结合这一次的状态,根据当前帧处于 10 帧的百分比,计算出中间的旋转和位置。
1 | // 计算蛇的中间插值位置 |
这样一来,蛇的转向和移动都会变得十分地平滑
可以看出,蛇头的旋转是相当平滑的,而且对于食物也做了相同的处理,食物的出现的消失是慢慢地变大/缩小,这样就不会显得很突兀。
在游戏的开始和结束,对于镜头的移动这里同样做了平滑过渡处理,与蛇体的处理不同的是,这里的过渡使用了sin
作为激活函数,使得一开始和结束的过渡起步比较慢,中间比较快
1 | void Camera::TransitionTo(glm::vec3 target, float p) { |
这样一来,整个游戏过程都会显得很自然,不会出现突兀的变化。
到此,我们就完成了一个 3D 的贪吃蛇小游戏,虽然游戏比较简单,但是也用到了 OpenGL 中不少的功能和概念。比如模型、纹理的加载、着色器的使用、光照的处理、文字的渲染、键盘鼠标的输入等等。