🎨 CG | 使用 OpenGL 实现 3D 贪吃蛇

1558526123236

本文记录了使用 OpenGL 实现一个 3D 贪吃蛇的完整过程。从建立游戏框架到设计游戏逻辑,最后加入动画效果,形成一个真正可玩的小游戏。

🚀 代码: Github

首先,开局一张图…

auto

程序框架

这次需要使用 OpenGL 完成一个完整的游戏,因此需要先设计程序的框架架构。

这里的程序参考了 LearnOpenGL 这个网站上的教程。在此基础上完善并加入自己的设计。

程序的基本架构如下

1558455563373

其中比较重要的部分有:

  • Camera : 负责摄像机管理和控制
  • Resource Manager: 资源管理器,管理贴图、字体、模型、着色器等资源
  • Shader : 着色器的管理和控制
  • Sprite: 游戏中的单位对象
  • Sprite Renderer : 负责渲染游戏单位

总体结构

首先,我们可以把游戏逻辑从 OpenGL 中抽取出来,基本可以分成三种状态Init(初始化), Update(更新)Clear(清理),其大概结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
class Application {
public:
// 初始化窗口并准备资源
virtual void Init(GLFWwindow* window) = 0;
// 更新并渲染
virtual void Update() = 0;
// 清除资源占用
virtual void Clear() = 0;
// 更新窗口大小
virtual void UpdateSize(int w, int h) = 0;
// ...
};

然后,就可以将 OpenGL 中的 窗口初始化、窗口大小更改回调、渲染准备等代码和应用的逻辑分离开来,在 Application 中,我们只需要关注游戏逻辑的处理和对象的渲染即可。

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
29
30
31
32
33
34
enum GameState {GAME_ACTIVE, GAME_MENU, GAME_LOAD, GAME_OVER, GAME_PAUSE};

class GameApp : public Application {
public:
GameApp();
void Init(GLFWwindow* window);
void Update();
void Clear();
void UpdateSize(int w, int h);
// 其他窗口参数
private:
// 处理输入
void processInput();
// 渲染
void render();
// 更新游戏逻辑
void updateGame();
void updateSnake();
// 重置游戏
void resetGame();
// 游戏状态
GameState state;
// 精灵渲染
SpriteRenderer* Renderer;
// 摄像机
Camera* camera;
// 游戏对象
SpriteSnake* spriteSnake;
SpriteSun* spriteSun;
SpriteTable* spriteTable;
SpriteGarden* spriteGarden;
// 游戏参数
// ...
};

Init

在初始化阶段,需要加载各种资源,初始化各种变量、对象以及状态。

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
29
30
31
void GameApp::Init(GLFWwindow* window) {
this->window = window;
glEnable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
// 加载字体
ResourceManager::InitFont("resources/fonts/RAVIE.TTF");
// 加载着色器
ResourceManager::LoadShader("Game/glsl/object.vs.glsl",
"Game/glsl/object.fs.glsl", nullptr, "object");
ResourceManager::LoadShader("Game/glsl/font.vs.glsl",
"Game/glsl/font.fs.glsl", nullptr, "font").Use().
SetMatrix4("projection", glm::ortho(0.0f, (float)this->WindowWidth, 0.0f, (float)this->WindowHeight));
// 初始化精灵渲染器
Renderer = new SpriteRenderer(ResourceManager::GetShader("object"),
ResourceManager::GetShader("font"));
// 初始化游戏精灵对象
this->spriteSnake = new SpriteSnake(this->Renderer, MAX_X, MAX_Y);
this->spriteSun = new SpriteSun(this->Renderer);
this->spriteGarden = new SpriteGarden(this->Renderer);
this->spriteTable = new SpriteTable(this->Renderer);
// 初始化背景颜色
this->clearColor = ImVec4(0.45f, 0.55f, 0.60f, 1.00f);
// 初始化摄像机
this->camera = new Camera(window);
this->camera->SetLookPostion(glm::vec3(20, 20, 30));
// 初始化状态
this->state = GAME_MENU;
// 重置状态
this->resetGame();
}

Update

在更新阶段,游戏需要处理的有更新摄像机的位置、处理输入、渲染以及更新游戏逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
void GameApp::Update() {
this->camera->Update();
this->processInput();
// 渲染场景
this->render();
// 每n帧更新一次
if (frame > updateFrameCount) {
this->updateGame();
frame = 0;
}
frame++;
}

Clear

在清理阶段,需要释放各种资源

1
2
3
void GameApp::Clear() {
ResourceManager::Clear();
}

Camera

摄像机类是一个比较重要的类,负责管理游戏中的摄像机位置以及控制视图的移动

其基本的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Camera {
public:
Camera(GLFWwindow* window);
// 更新视图
void Update();
// 获取视图矩阵
glm::mat4 GetViewMatrix();
// 处理鼠标移动
static void MouseCallback(GLFWwindow* window, double xpos, double ypos);
// 处理滚轮滚动
static void ScrollCallback(GLFWwindow* window, double xoffset, double yoffset);
// 设置固定视角
void SetLookPostion(glm::vec3 pos, glm::vec3 look = glm::vec3(0, 0, 0));
// 设置自由视角
void SetViewPostion(glm::vec3 pos, glm::vec3 front, glm::vec3 up = glm::vec3(0, 1, 0));
// 平滑过渡摄像机位置
void TransitionTo(glm::vec3 target, float p);

private:
void processInput();
void upDateDeltaTime();
// Camera 各种属性
...
};

我们可以在程序任意地方设置摄像机的位置,然后渲染时通过GetViewMatrix获取视图矩阵设置到着色器中。

Resource Manager

资源管理器是一个单例,负责全局加载资源以及复用,把纹理、模型、着色器、字体等资源从文件中读取到解析封装到资源管理器中后,我们就可以不必重复处理这些繁杂的操作,仅仅需要一句话就可以加载我们需要的资源。

1
2
3
4
5
6
7
8
9
// 加载字体
ResourceManager::InitFont("resources/fonts/RAVIE.TTF");
// 加载着色器
ResourceManager::LoadShader("Game/glsl/object.vs.glsl",
"Game/glsl/object.fs.glsl", nullptr, "object");
// 加载纹理
ResourceManager::LoadTexture("resources/textures/white.jpg", GL_FALSE, "white");
// 加载模型
ResourceManager::LoadModel("resources/objects/Garden.obj", "garden");

然后在程序的任意地方通过Get就可以获取预先加载好的资源,实现了资源的有效复用

1
ResourceManager::GetShader("object")

其基本架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ResourceManager
{
public:
static std::map<std::string, Shader> Shaders;
static std::map<std::string, Texture2D> Textures;
static std::map<std::string, Model> Models;
static std::map<GLchar, Character> Characters;
static unsigned int fontVAO, fontVBO;
static Shader& LoadShader(const GLchar* vShaderFile, const GLchar* fShaderFile, const GLchar* gShaderFile, std::string name);
static Shader& GetShader(std::string name);
static Texture2D& LoadTexture(const GLchar* file, GLboolean alpha, std::string name);
static Texture2D& GetTexture(std::string name);
static Model& LoadModel(const GLchar* file, std::string name);
static Model& GetModel(std::string name);
static void InitFont(const GLchar* path);
static void Clear();
private:
ResourceManager() {}
static Shader loadShaderFromFile(const GLchar* vShaderFile,
const GLchar* fShaderFile,
const GLchar* gShaderFile = nullptr);
static Texture2D loadTextureFromFile(const GLchar* file, GLboolean alpha);
static Model loadModelFromFile(const GLchar* file);
};

Shader

Shader 的加载比较简单,只需要从文件中读取出字符串,然后使用glCompileShader将其编译为着色器就成功了。

加载完毕之后,在渲染过程中,我们经常会更新一些信息,如model, view, projection以及光照、材质等信息,这些信息通过是以uniform的形式存在,需要我们在渲染过程中实时更新。因此,我们必须给Shader类加上一系列的方法,以便我们更新这些信息。

1
2
3
4
5
void SetFloat(const GLchar* name, GLfloat value, GLboolean useShader = false);
void SetInteger(const GLchar* name, GLint value, GLboolean useShader = false);
void SetVector2f(const GLchar* name, GLfloat x, GLfloat y,
GLboolean useShader = false);
// ...

这这个游戏里面,主要用到两种着色器,字体着色器以及精灵对象着色器

字体着色器主要需要投影信息以及纹理和颜色信息,一般来说,需要字体直接绘制在屏幕上,只需要设置于屏幕大小一样的正交矩阵即可。(下面仅展示主要信息,需要详细了解可以直接查看代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
// font.vs.glsl
// ...
void main() {
vec4 sampled = vec4(1.0, 1.0, 1.0, texture(text, TexCoords).r);
color = vec4(textColor, 1.0) * sampled;
}

// font.fs.glsl
// ...
void main() {
gl_Position = projection * vec4(vertex.xy, 0.0, 1.0);
TexCoords = vertex.zw;
}

精灵对象的着色器就比较复杂,为了更好地体现模型,这里引入了 Blinn-Phong 光照模型,使得游戏画面更加立体真实。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// object.vs.glsl
// ...
void main() {
gl_Position = projection * view * model * vec4(aPos, 1.0f);
FragPos = vec3(model * vec4(aPos, 1.0));
Normal = mat3(transpose(inverse(model))) * aNormal;
TexCoords = aTexCoords;
}
// object.fs.glsl
// ...
struct Material {
sampler2D diffuse;
sampler2D specular;
sampler2D normal;
sampler2D height;
};
struct Light {
vec3 position;
vec3 ambient;
vec3 diffuse;
vec3 specular;
float shininess;
vec3 color;
};
uniform Material material;
uniform Light light;
uniform vec3 strength;
uniform vec3 viewPos;
uniform bool hasColor;
uniform bool hasTexture;

void main() {
// 环境光
vec3 ambient = strength.x * light.color;
// 漫反射
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(light.position - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = strength.y * diff * light.color;
// 镜面反射
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfwayDir = normalize(lightDir + viewDir);
float spec = pow(max(dot(norm, halfwayDir), 0.0), light.shininess);
vec3 specular = strength.z * spec * light.color;
vec3 result = ambient + diffuse + specular;
// 纹理/颜色
if (hasTexture) {
result *= texture(material.diffuse, TexCoords);
} else {
result *= light.diffuse;
}
FragColor = vec4(result, 1.0);
}

完成后,可以看到比较好的光照效果,在花园背景上和桌子上以及精灵上,都可以看出光照的反射效果和不同程度的明暗。

1558526123236

Texture

材质的加载这里使用的是stb_image这个库,从图片中把数据读取出来,然后使用glTexImage2D生成纹理。

1
2
3
4
5
6
7
glBindTexture(GL_TEXTURE_2D, this->ID);
glTexImage2D(GL_TEXTURE_2D, 0, this->Internal_Format, width, height, 0, this->Image_Format, GL_UNSIGNED_BYTE, data);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, this->Wrap_S);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, this->Wrap_T);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, this->Filter_Min);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, this->Filter_Max);
glBindTexture(GL_TEXTURE_2D, 0);

Model

模型的加载这里使用的是Assimp这个库

首先从模型文件(obj)中读取信息,得到一个aiScene

1
2
Assimp::Importer importer;
const aiScene* scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs | aiProcess_CalcTangentSpace);

img

在这个 Scene 中, 包含了模型顶点位置、法向量、面索引以及材质等数据,他们都是以 Node 构成的树形式存储的。我们需要做的就是从中读取数据保存在我们的程序当中。

通过递归遍历处理节点,就可以将节点中所有的 Mesh 都读取出来。

1
2
3
4
5
6
7
8
9
void processNode(aiNode * node, const aiScene * scene) {
for (unsigned int i = 0; i < node->mNumMeshes; i++) {
aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
meshes.push_back(processMesh(mesh, scene));
}
for (unsigned int i = 0; i < node->mNumChildren; i++) {
processNode(node->mChildren[i], scene);
}
}

对于每个 Mesh, 我们不仅仅要读取其所有的顶点信息,还需要将其材质也一同读取出来。

根据 LearnOpenGL 上的教程,可以将纹理材质的信息读取出来,但是却没有把材质的颜色信息也一同读取,这样就导致了没有图片纹理的模型是没办法读取的,当我们加载只包含颜色材质信息的模型,就会显示一片漆黑

因此,我们需要修改一下,使其支持材质中颜色数据的读取

我们知道,在obj文件中,材质都是以链接的形式存放在另一个mtl文件中

我们打开任意一个mtl文件看看

1
2
3
4
5
6
7
8
9
10
11
12
# Blender MTL File: '7.blend'
# Material Count: 1

newmtl 795548
Ns 96.078431
Ka 0.400000 0.400000 0.400000
Kd 0.474510 0.333333 0.282353
Ks 0.500000 0.500000 0.500000
Ke 0.000000 0.000000 0.000000
Ni 1.000000
d 0.000000
illum 7

其中比较重要的数据有 Ns (反射率), Ka (环境反射颜色), Kd (漫反射颜色), Ks (镜面反射颜色)

因此,我们仅需要把这四种信息都读取出来。

首先,先改造一下他的材质结构,把颜色信息也包含进去。

1
2
3
4
5
6
7
8
9
struct Texture {
unsigned int id;
string type;
string path;
glm::vec3 diffuseColor;
glm::vec3 ambientColor;
glm::vec3 specularColor;
float shininess;
};

然后在读取 Mesh 材质信息的时候,顺便把颜色信息也一同读取

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Mesh processMesh(aiMesh * mesh, const aiScene * scene)
{
vector<Vertex> vertices;
vector<unsigned int> indices;
vector<Texture> textures;
// 读取顶点信息
for (unsigned int i = 0; i < mesh->mNumVertices; i++) {
Vertex vertex;
// ...
vertices.push_back(vertex);
}
// 读取面索引数据
for (unsigned int i = 0; i < mesh->mNumFaces; i++) {
aiFace face = mesh->mFaces[i];
for (unsigned int j = 0; j < face.mNumIndices; j++)
indices.push_back(face.mIndices[j]);
}
// 读取材质信息
if (mesh->mMaterialIndex >= 0) {
// process materials
aiMaterial* material = scene->mMaterials[mesh->mMaterialIndex];
// ******加载颜色*****
textures.push_back(loadMaterialColor(material));
// 加载贴图
vector<Texture> diffuseMaps = loadMaterialTextures(material, aiTextureType_DIFFUSE, "material.diffuse");
// ...
}
return Mesh(vertices, indices, textures);
}
// 加载颜色
Texture loadMaterialColor(aiMaterial* mat) {
aiColor3D color(1.0f, 1.0f, 1.0f);
Texture texture;
texture.type = "color";
mat->Get(AI_MATKEY_COLOR_DIFFUSE, color);
texture.diffuseColor = glm::vec3(color.r, color.g, color.b);
color = aiColor3D(1.0f, 1.0f, 1.0f);
mat->Get(AI_MATKEY_COLOR_AMBIENT, color);
texture.ambientColor = glm::vec3(color.r, color.g, color.b);
color = aiColor3D(1.0f, 1.0f, 1.0f);
mat->Get(AI_MATKEY_COLOR_SPECULAR, color);
texture.specularColor = glm::vec3(color.r, color.g, color.b);
float shininess = 32;
mat->Get(AI_MATKEY_SHININESS, shininess);
texture.shininess = shininess;
return texture;
}

把颜色信息读取到材质里面之后,渲染的时候,就可以通过着色器把这些数据一同传递进去了。

在渲染的时候,还需要将是否具有纹理或者颜色的信息一起传递给着色器,因为如果当没有纹理依然使用纹理信息的时候渲染出来的结果往往不是预期的结果。

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
29
30
31
32
33
34
35
36
37
void Draw(Shader* shader) {
shader->SetVector3f("light.ambient", glm::vec3(0));
shader->SetVector3f("light.diffuse", glm::vec3(0));
shader->SetVector3f("light.specular", glm::vec3(0));
shader->SetFloat("light.shininess", 32);
shader->SetInteger("material.specular", 0);
shader->SetInteger("material.diffuse", 0);
shader->SetInteger("material.normal", 0);
shader->SetInteger("material.height", 0);
bool hasColor = false;
bool hasTexture = false;
for (unsigned int i = 0; i < textures.size(); i++) {
string name = textures[i].type;
if (name == "color") {
hasColor = true;
shader->SetVector3f("light.ambient", textures[i].ambientColor);
shader->SetVector3f("light.diffuse", textures[i].diffuseColor);
shader->SetVector3f("light.specular", textures[i].specularColor);
if (textures[i].shininess != 0) {
shader->SetFloat("light.shininess", textures[i].shininess);
}
} else {
hasTexture = true;
glActiveTexture(GL_TEXTURE0 + i);
shader->SetInteger(name.c_str(), i);
glBindTexture(GL_TEXTURE_2D, textures[i].id);
}
}
shader->SetInteger("hasColor", hasColor);
shader->SetInteger("hasTexture", hasTexture);

glBindVertexArray(VAO);
glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
glBindVertexArray(0);

glActiveTexture(GL_TEXTURE0);
}

本游戏中的所有模型都是来自于 Poly 这个网站,在这个网站中,我们可以下载到各种模型,而且模型的风格都很类似,很适合结合在一起用。

这里需要提一下,在这个网站上面下载了 obj 模型文件后,通常还需要自己调整一下。因为很多模型的中心点并不在我们预期的文字中,因此对其进行旋转变换后往往会偏移当前的位置。这个可以通过导入到 Blender 中调整中心点再导出得到重置中心点的新模型。

Font

对于字体的显示,这里使用了freeType这个库。

使用前,我们需要预先读取字体生成贴图,由于这里仅使用英文字符,只需要对 128 个字符进行初始化生成纹理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 初始化字体
void ResourceManager::InitFont(const GLchar* path) {
// ...
for (GLubyte c = 0; c < 128; c++) {
// ...
// 生成纹理
// ...
Character character = {
texture,
glm::ivec2(face->glyph->bitmap.width, face->glyph->bitmap.rows),
glm::ivec2(face->glyph->bitmap_left, face->glyph->bitmap_top),
face->glyph->advance.x
};
Characters.insert(std::pair<GLchar, Character>(c, character));
}
glBindTexture(GL_TEXTURE_2D, 0);
FT_Done_Face(face);
FT_Done_FreeType(ft);
// 绑定 VAO 和 VBO
glGenVertexArrays(1, &fontVAO);
glGenBuffers(1, &fontVBO);
// ...
}

初始化之后,可以绑定到 VAOVBO 上,然后在渲染的时候只需要绑定好这个纹理和顶点,然后计算出顶点组放入缓冲中直接进行渲染即可。

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
29
30
31
void SpriteRenderer::RenderText(std::string text, glm::vec2 postion, GLfloat scale, glm::vec3 color) {
fontShader.Use();
fontShader.SetVector3f("textColor", color);
fontShader.SetInteger("text", 0);
// 绑定纹理
glActiveTexture(GL_TEXTURE0);
glBindVertexArray(ResourceManager::fontVAO);

std::string::const_iterator c;
for (c = text.begin(); c != text.end(); c++) {
Character ch = ResourceManager::Characters[*c];
// 计算位置
GLfloat xpos = postion.x + ch.Bearing.x * scale;
GLfloat ypos = postion.y - (ch.Size.y - ch.Bearing.y) * scale;
GLfloat w = ch.Size.x * scale;
GLfloat h = ch.Size.y * scale;
// 生成顶点组
GLfloat vertices[6][4] = {
{ xpos, ypos + h, 0.0, 0.0 },
{ xpos, ypos, 0.0, 1.0 },
{ xpos + w, ypos, 1.0, 1.0 },

{ xpos, ypos + h, 0.0, 0.0 },
{ xpos + w, ypos, 1.0, 1.0 },
{ xpos + w, ypos + h, 1.0, 0.0 }
};
// 渲染
// ...
}
// ...
}

Sprite

精灵是游戏中的主要单位,在这次这个游戏中,主要的精灵单位有:

  • 太阳
  • 花园场景
  • 桌子
    • 蛇头
    • 蛇身
    • 食物

1558500925970

其中由于食物、蛇头、蛇身这三个部分耦合度比较高,因此这里将其封装在一起,构成以下的代码文件:

1558501046912

所有的精灵都是继承于Sprite这个基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Sprite {
public:
Sprite(SpriteRenderer* renderer): renderer(renderer) {
this->Rotation = 0.0f;
this->Postion = glm::vec3(1.0);
this->Size = glm::vec3(1.0);
}
virtual void Render() = 0;

glm::vec3 Postion;
glm::vec3 Size;
float Rotation;

protected:
SpriteRenderer* renderer;
};

这里封装了一些精灵通用的属性以及方法。

对于一些简单的精灵,如太阳,仅需要实现其构造函数加载模型以及渲染函数,就可以简单形成一个精灵对象,在渲染循环中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SpriteSun : public Sprite {
public:
SpriteSun(SpriteRenderer* renderer) : Sprite(renderer) {
this->model = &ResourceManager::LoadModel("resources/objects/sun.obj", "sun");
this->Size = glm::vec3(0.3f, 0.3f, 0.3f);
this->Postion = glm::vec3(0, 5, 0);
}

void Render() {
this->renderer->DrawSprite(*this->model, this->Postion, this->Size);
}

private:
Model* model;
};

对于一些复杂的精灵,可以封装其方法,仅提供必要的接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class SpriteSnake  {
public:
SpriteSnake(SpriteRenderer* renderer, int maxX, int maxY);
void Render(float p);
void Reset();
void Update(bool run, bool ai = false);
void SetHeadStyle(int style);
void SetBodyStyle(int style);
void SetNextDir(SnakeDir dir);
bool Over();
int GetLenght();
private:
void renderFood(float p, int style, glm::vec2 pos, float height, float size);
void newFood();
void updateDir(bool ai = false);
// 精灵属性
// ...
};

Sprite Renderer

从上面的 Sprite 类可以看到,里面提到了一个 精灵渲染器的东西,这个东西是用来辅助我们实现渲染的,可以实现立方体、带纹理的立方体、模型、文字等对象的渲染,仅需要提供渲染对象以及一些渲染属性就可以实现。

这里还预先生成并保存了立方体的 VAO,可以高效地绘制多个不同纹理的立方体而无需重复绑定顶点组等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SpriteRenderer {
public:
SpriteRenderer(Shader& shader, Shader& fontShader);
~SpriteRenderer();

void DrawSprite(Texture2D& texture, glm::vec3 position,
glm::vec3 size = glm::vec3(10, 10, 10), GLfloat rotate = 0.0f,
glm::vec3 color = glm::vec3(1.0f));

void DrawSprite(Model& model, glm::vec3 position,
glm::vec3 size = glm::vec3(1.0f, 1.0f, 1.0f), GLfloat rotate = 0.0f ,glm::vec3 value = glm::vec3(0.4, 1, 0.3));

void RenderText(std::string text, glm::vec2 postion, GLfloat scale, glm::vec3 color);

private:
void initRenderData();
Shader shader;
Shader fontShader;
GLuint quadVAO;
};

对于文字的渲染,这里使用了FreeType这个库,使用前需要使用资源管理器预先加载字体文件,然后生成 128 个字符的贴图,接下来就可以快速地从贴图中提取纹理对文字进行渲染。

游戏逻辑

对于游戏整体的逻辑,可以直接在GameApp里面实现

其整体的逻辑上文以及提及过了,主要是在Update里面实现。在这里面,我们主要需要处理的是用户的输入、渲染场景以及更新游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void GameApp::Update() {
// 更新摄像机
this->camera->Update();
// 处理用户输入
this->processInput();
// 渲染场景
this->render();
// 每n帧更新一次
if (frame > updateFrameCount) {
this->updateGame();
frame = 0;
}
frame++;
}

处理用户输入

这里创建了一个keys数组,用于存储键盘按键的状态

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 处理输入
void GameApp::processInput() {
// 获取按键状态
this->keys[KEY_UP] = glfwGetKey(window, GLFW_KEY_UP) == GLFW_PRESS || glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS;
// ...

// 处理游戏逻辑
// 菜单
if (this->state == GAME_MENU) {
if (this->keys[KEY_ENTER]) {
this->first = false;
this->keys[KEY_ENTER] = false;
this->resetGame();
this->state = GAME_LOAD;
}
}

// 游戏暂停中
if (this->state == GAME_PAUSE) {
// 继续游戏
for (int i = 0; i < 5; i++) {
if (this->keys[i]) {
this->keys[KEY_P] = false;
this->state = GAME_ACTIVE;
break;
}
}
}

// 游戏进行中
if (this->state == GAME_ACTIVE) {
// 暂停游戏
if (this->keys[KEY_P]) {
this->keys[KEY_P] = false;
this->state = GAME_PAUSE;
}
// 方向控制
if (this->keys[KEY_UP]) this->spriteSnake->SetNextDir(DIR_UP);
if (this->keys[KEY_DOWN]) this->spriteSnake->SetNextDir(DIR_DOWN);
if (this->keys[KEY_LEFT]) this->spriteSnake->SetNextDir(DIR_LEFT);
if (this->keys[KEY_RIGHT]) this->spriteSnake->SetNextDir(DIR_RIGHT);
}
}

然后在不同的场景下,不同的输入会触发不同的操作。比如在菜单状态下,用户按下Enter或者Space可以改变游戏状态,切换到加载状态。在游戏进行中状态下,用户按下方向键或者WASD可以控制蛇头方向。

渲染场景

处理完用户输入之后,就可以进行渲染场景了

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

void GameApp::render() {
// 背景颜色
glClearColor(clearColor.x, clearColor.y, clearColor.z, clearColor.w);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 计算视图
Shader objectShader = ResourceManager::GetShader("object").Use();
if (this->state == GAME_MENU) {
this->camera->SetLookPostion(getNowEyePos());
}
objectShader.SetVector3f("viewPos", camera->Position);
objectShader.SetVector3f("light.position", this->spriteSun->Postion);
objectShader.SetMatrix4("projection",
glm::perspective((float)glm::radians(camera->Zoom), this->WindowWidth / (float)this->WindowHeight, 0.1f, 100.0f));
objectShader.SetMatrix4("view", camera->GetViewMatrix());

// 渲染精灵
this->spriteSnake->Render(frame / (float)updateFrameCount);
this->spriteSun->Render();
this->spriteGarden->Render();
this->spriteTable->Render();

// 菜单场景
if (this->state == GAME_MENU) {
// ...
}

// 加载中动画
if (this->state == GAME_LOAD) {
// ...
}

// 游戏开始
if (this->state == GAME_ACTIVE) {
Renderer->RenderText(
"Your score: " + std::to_string(this->spriteSnake->GetLenght() - 2),
glm::vec2(40, this->WindowHeight - 60));
}

// 游戏暂停
if (this->state == GAME_PAUSE) {
Renderer->RenderText("Pause", glm::vec2(40, this->WindowHeight - 60));
}

// 游戏结束,返回菜单
if (this->state == GAME_OVER) {
// ...
}
}

在场景的渲染中,需要实时更新着色器中的视图,然后调用精灵的渲染接口进行渲染。

我们同样可以在不同的游戏状态下分别渲染不同的内容,比如在游进行期间在屏幕的上方渲染一行文字显示当前的得分。

1558521080941

更新游戏

在所有游戏逻辑当中,最重要的就是关于贪食蛇本身的逻辑,在贪吃蛇每一次更新过程中,都需要做以下的处理:

  • 根据用户的输入更新蛇头的方向
  • 更新蛇体的数据,使后一个 Body 等于前一个 Body ,实现蛇体的总体位移
  • 根据方向更新蛇头方向
  • 检测蛇是否碰到了自己
  • 检测蛇是否出界
  • 检测蛇头是否吃到了食物
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
29
30
31
32
33
34
35
36
37
38
39
void SpriteSnake::Update(bool run, bool ai) {
// 更新过渡数据
// ...
// 计算游戏数据
if (run) {
if (this->isOver) return;
// 更新方向(是否使用AI自动寻路)
this->updateDir(ai);
// 更新蛇体
for (int i = this->snakeLength - 1; i > 0; i--) {
this->snakePos[i] = this->snakePos[i - 1];
}
// 更新蛇头
this->snakePos[0].x = this->snakePos[0].x + MoveX[(int)this->snakePos[0].z];
this->snakePos[0].y = this->snakePos[0].y + MoveY[(int)this->snakePos[0].z];
// 碰撞检测
for (int i = 1; i < this->snakeLength; i++) {
if (snakePos[0].x == snakePos[i].x && snakePos[0].y == snakePos[i].y) {
this->isOver = true;
break;
}
}
// 判断是否出界
if (this->snakePos[0].x > mapMaxX || this->snakePos[0].x < -mapMaxX) {
this->isOver = true;
}
else if (this->snakePos[0].y > mapMaxY || this->snakePos[0].y < -mapMaxY) {
this->isOver = true;
}
// 判断吃到食物
if (snakePos[0].x == foodPos.x && snakePos[0].y == foodPos.y) {
this->snakePos[this->snakeLength] = this->snakePos[this->snakeLength - 1];
this->snakeLength++;
this->newFood();
}
}
// 更新过渡数据
// ...
}

值得一提的是,这里我加入了一个 DFS + 简单策略 的自动寻路贪吃蛇 ,在一般情况下都可以走完 90%的格子,在运气比较好的时候甚至可以走完所有空间。具体的算法可以到这里查看

1558526746187

过渡动画

为了游戏中的过渡更加自然,我在游戏中很多地方都加入了动画效果,比如文章一开始的图。当游戏处于菜单状态的时候,就会随着事件不停旋转,然后使用算法自动进行展示,效果如下:

auto

其中旋转可以通过对摄像机做旋转变换即可。

除此之外,蛇的移动以及旋转都显得十分自然,这是因为我对其做了插帧动画处理。

在一开始的设定中,游戏逻辑(蛇的移动)每 10 帧左右才更新一次(在游戏中会随着长度变快),但是这样一来,蛇的移动就会变得一格一格的,显然这是一个极其不好的体验。因此可以通过插帧处理之间的动画。

首先,我们需要保留上一次更新的状态,结合这一次的状态,根据当前帧处于 10 帧的百分比,计算出中间的旋转和位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 计算蛇的中间插值位置
glm::vec3 getMiddlePos(glm::vec3 oldValue, glm::vec3 newValue, float p) {
float x = oldValue.x;
float y = oldValue.y;
float z = Rotate[(int)oldValue.z];
if (newValue.x != x || newValue.y != y) {
x += (newValue.x - x) * p;
y += (newValue.y - y) * p;
float stepZ = Rotate[(int)newValue.z] - z;
if (stepZ > 3.5) {
z += (Rotate[(int)newValue.z] - 6.28) * p;
}
else if (stepZ < -3.5) {
z += (6.28 - z) * p;
}
else {
z += (Rotate[(int)newValue.z] - z) * p;
}
}
return glm::vec3(x, y, z);
}

这样一来,蛇的转向和移动都会变得十分地平滑

turn

可以看出,蛇头的旋转是相当平滑的,而且对于食物也做了相同的处理,食物的出现的消失是慢慢地变大/缩小,这样就不会显得很突兀。

在游戏的开始和结束,对于镜头的移动这里同样做了平滑过渡处理,与蛇体的处理不同的是,这里的过渡使用了sin作为激活函数,使得一开始和结束的过渡起步比较慢,中间比较快

1
2
3
4
5
6
7
8
9
10
11
12
13
void Camera::TransitionTo(glm::vec3 target, float p) {
if (p < 0.1) {
this->oldPostion = this->Position;
}
else {
float current = UtilTool::scaleValue(p);
this->Position = this->oldPostion - (this->oldPostion - target) * current;
}
}

static float scaleValue(float value) {
return sin((value * 3.1415) - 1.57) * 0.5 + 0.5;
}

begin

over

这样一来,整个游戏过程都会显得很自然,不会出现突兀的变化。

到此,我们就完成了一个 3D 的贪吃蛇小游戏,虽然游戏比较简单,但是也用到了 OpenGL 中不少的功能和概念。比如模型、纹理的加载、着色器的使用、光照的处理、文字的渲染、键盘鼠标的输入等等。

土豪通道
0%