- 前言
- 简单的框架
- 渲染精灵
- 碰撞检测
- 粒子拖尾
- 游戏音效
前言
本博客借鉴学习LearnOpenGL-CN的实战教程,深入了解从图形底层制作2D游戏基本过程,参考链接放在这里。整个工程的代码在github链接下载:https://github.com/ZeusYang/Breakout
框架
废话不多说,看图。
Shader类:编译、链接着色器程序,包括顶点着色器、片元着色器、几何着色器,也提供设置GPU中的uniform变量的接口。
Texture2D类:封装了OpenGL的纹理接口,用于从数据中创建纹理,指定纹理格式,并用于绑定。
ResourceManager类:资源管理器,用于管理着色器资源和纹理资源,统一给每个着色器命名进行管理,提供从文件中获取着色器代码进而传入Shader类进行编译以及读取图片数据生成纹理,保存所有着色器程序和纹理的副本。
SpriteRenderer类:渲染精灵,这里是一个2D的四边形,提供一个统一的四边形VAO接口,一个精灵有不同的位置、大小、纹理、旋转角度、颜色。
PostProcessor类:后期特效处理,主要使用OpenGL的帧缓冲技术,将游戏渲染后的画面进一步地处理,这里包括震荡、反相、边缘检测和混沌。
TextRenderer类:文本渲染,用于渲染文字。
GameObject类:游戏物品的高层抽象,每个游戏物品有位置、大小、速度、颜色、旋转度、是否实心、是否被破坏、纹理等属性,每次调用SpriteRenderer类的一个实例的Draw方法渲染GameObject。
BallObject类:继承自GameObject类,它是一个小球,Breakout游戏中打击砖块的媒介。
GameLevel类:表示游戏的一个关卡,在这里表示的是每个关卡砖块的分布情况,提供从文件加载关卡、渲染关卡、检测是否通关的接口。
PowerUp类:游戏道具,在这里主要是击打砖块带来的反馈,如震荡。
ISoundEngine:第三方库irrKlang的实例,用于播放游戏音效。
使用的库有:glew、glfw、irrKlang、freetype。
渲染精灵
2D的投影是正交投影,我们把窗口宽高全部投影到[-1,1]之间,使用如下的投影矩阵:
glm::mat4 projection = glm::ortho(0.0f, static_cast<GLfloat>(this->Width),
static_cast<GLfloat>(this->Height), 0.0f, -1.0f, 1.0f);
渲染精灵就是我们创建的一个有纹理的四边形,这个四边形没有固定的纹理,全靠外部传入的参数决定精灵的纹理,如此便统一了一个简洁的接口。
class SpriteRenderer
{
public:
SpriteRenderer(Shader &shader);
~SpriteRenderer();
//绘制精灵,指定纹理、位置、大小、旋转度、颜色
void DrawSprite(Texture2D &texture, glm::vec2 position,
glm::vec2 size = glm::vec2(10, 10), GLfloat rotate = 0.0f,
glm::vec3 color = glm::vec3(1.0f));
private:
Shader shader;
GLuint quadVAO;
void initRenderData();
};
void SpriteRenderer::initRenderData()
{
// 配置 VAO/VBO
GLuint VBO;
GLfloat vertices[] = {
// 位置 // 纹理
0.0f, 1.0f, 0.0f, 1.0f,
1.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 0.0f
};
glGenVertexArrays(1, &this->quadVAO);
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
glBindVertexArray(this->quadVAO);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindVertexArray(0);
}
void SpriteRenderer::DrawSprite(Texture2D &texture, glm::vec2 position,
glm::vec2 size, GLfloat rotate, glm::vec3 color)
{
// 准备变换,先缩放、旋转然后平移
this->shader.Use();
glm::mat4 model(1.0f);
//平移到目的地
model = glm::translate(model, glm::vec3(position, 0.0f));
//旋转,绕着z轴
model = glm::translate(model, glm::vec3(0.5f * size.x, 0.5f * size.y, 0.0f));
model = glm::rotate(model, rotate, glm::vec3(0.0f, 0.0f, 1.0f));
model = glm::translate(model, glm::vec3(-0.5f * size.x, -0.5f * size.y, 0.0f));
//缩放
model = glm::scale(model, glm::vec3(size, 1.0f));
//传至gpu中
this->shader.SetMatrix4("model", model);
this->shader.SetVector3f("spriteColor", color);
//绑定纹理
glActiveTexture(GL_TEXTURE0);
texture.Bind();
glBindVertexArray(this->quadVAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
}
SpriteRenderer::SpriteRenderer(Shader &shader)
{
this->shader = shader;
this->initRenderData();
}
SpriteRenderer::~SpriteRenderer()
{
glDeleteVertexArrays(1, &this->quadVAO);
}
着色器代码
#version 330 core
layout (location = 0) in vec4 vertex;
// <vec2 position, vec2 texCoords>
out vec2 TexCoords;
uniform mat4 model;
uniform mat4 projection;
void main()
{
TexCoords = vertex.zw;
gl_Position = projection * model * vec4(vertex.xy, 0.0, 1.0);
}
#version 330 core
in vec2 TexCoords;
out vec4 color;
uniform sampler2D image;
uniform vec3 spriteColor;
void main()
{
color = vec4(spriteColor, 1.0) * texture(image, TexCoords);
}
碰撞检测
碰撞是游戏制作的难点,它提供了玩家一个直观的物理反馈。
1、边界碰撞:已知窗口的四个边界,我们可以直接写出的边界的碰撞检测。小球碰到边界之后速度取反,同时要将位置复原。这里的Position是小球精灵的左上角而非中心。
glm::vec2 BallObject::Move(GLfloat dt, GLuint window_width)
{
// 如果没有被固定在挡板上
if (!this->Stuck){
// 移动球
this->Position += this->Velocity * dt;
// 检查是否在窗口边界以外,如果是的话反转速度并恢复到正确的位置
if (this->Position.x <= 0.0f){//左
this->Velocity.x = -this->Velocity.x;
this->Position.x = 0.0f;
}
else if (this->Position.x + this->Size.x >= window_width){//右
this->Velocity.x = -this->Velocity.x;
this->Position.x = window_width - this->Size.x;
}
if (this->Position.y <= 0.0f){//上
this->Velocity.y = -this->Velocity.y;
this->Position.y = 0.0f;
}
}
return this->Position;
}
2、AABB包围盒碰撞检测:当两个碰撞外形进入对方的区域时就会发生碰撞,例如定义了第一个物体的碰撞外形以某种形式进入了第二个物体的碰撞外形。对于AABB来说很容易判断,因为它们是与坐标轴对齐的:对于每个轴我们要检测两个物体的边界在此轴向是否有重叠。因此我们只是简单地检查两个物体的水平边界是否重合以及垂直边界是否重合。如果水平边界和垂直边界都有重叠那么我们就检测到一次碰撞。
GLboolean Game::CheckCollisionAABB(GameObject &one, GameObject &two)//AABB包围盒碰撞检测
{ //我们检查第一个物体的最右侧是否大于第二个物体的最左侧
//并且第二个物体的最右侧是否大于第一个物体的最左侧;
//垂直的轴向与此相似。
bool collisionX = one.Position.x + one.Size.x >= two.Position.x
&& two.Position.x + two.Size.x >= one.Position.x;
bool collisionY = one.Position.y + one.Size.y >= two.Position.y
&& two.Position.y + two.Size.y >= one.Position.y;
return collisionX && collisionY;
}
3、AABB包围球碰撞检测:由于小球是一个圆形的物体,AABB或许不是球的最佳碰撞外形。碰撞的代码中将球视为长方形框,因此常常会出现球碰撞了砖块但此时球精灵还没有接触到砖块。使用圆形碰撞外形而不是AABB来代表球会更合乎常理。因此我们在球对象中包含了Radius变量,为了定义圆形碰撞外形,我们需要的是一个位置矢量和一个半径。
检测圆和AABB碰撞的算法会稍稍复杂,关键点如下:我们会找到AABB上距离圆最近的一个点,如果圆到这一点的距离小于它的半径,那么就产生了碰撞。
难点在于获取AABB上的最近点P¯。下图展示了对于任意的AABB和圆我们如何计算该点:
首先我们要获取球心C¯与AABB中心B¯的矢量差D¯。接下来用AABB的半边长(half-extents)w和h¯来限制(clamp)矢量D¯。这一过程返回的是一个总是位于AABB的边上的位置矢量(除非圆心在AABB内部)。有了矢量D′¯,我们就可以比较它的长度和圆的半径以判断是否发生了碰撞。
Collision Game::CheckCollisionSphere(BallObject &one, GameObject &two){//包围球碰撞检测
// 获取圆的中心
glm::vec2 center(one.Position + one.Radius);//Position是左上角
// 计算AABB的信息(中心、半边长)
glm::vec2 aabb_half_extents(two.Size.x / 2, two.Size.y / 2);
glm::vec2 aabb_center(
two.Position.x + aabb_half_extents.x,
two.Position.y + aabb_half_extents.y
);
// 获取两个中心的差矢量
glm::vec2 difference = center - aabb_center;
glm::vec2 clamped = glm::clamp(difference, -aabb_half_extents, aabb_half_extents);
// AABB_center加上clamped这样就得到了碰撞箱上距离圆最近的点closest
glm::vec2 closest = aabb_center + clamped;
// 获得圆心center和最近点closest的矢量并判断是否 length <= radius
difference = closest - center;
if (glm::length(difference) <= one.Radius)
return std::make_tuple(GL_TRUE, VectorDirection(difference), difference);
else
return std::make_tuple(GL_FALSE, UP, glm::vec2(0, 0));
}
4、碰撞反馈:
(1)、碰撞重定位:
为了把球对象定位到碰撞的AABB的外部,我们必须明确球侵入碰撞框的距离。
此时球少量进入了AABB,所以检测到了碰撞。我们现在希望将球从移出AABB的外形使其仅仅碰触到AABB,像是没有碰撞一样。为了确定需要将球从AABB中移出多少距离,我们需要找回矢量R¯,它代表的是侵入AABB的程度。为得到R¯我们用球的半径减去V¯。矢量V¯是最近点P¯和球心C¯的差矢量。有了R¯之后我们将球的位置偏移R¯就将球直接放置在与AABB紧邻的位置;此时球已经被重定位到合适的位置。
(2)、碰撞方向:
于Breakout我们使用以下规则来改变球的速度:
如果球撞击AABB的右侧或左侧,它的水平速度(x)将会反转。
如果球撞击AABB的上侧或下侧,它的垂直速度(y)将会反转。
但是如何判断球撞击AABB的方向呢?我们定义指向北、南、西和东的四个矢量,然后计算它们和给定矢量的夹角。由这四个方向矢量和给定的矢量点乘积的结果中的最高值(点乘积的最大值为1.0f,代表0度角)即是矢量的方向。
enum Direction {
UP,
RIGHT,
DOWN,
LEFT
};
Direction Game::VectorDirection(glm::vec2 target) {
glm::vec2 compass[] = {
glm::vec2(0.0f, 1.0f), // 上
glm::vec2(1.0f, 0.0f), // 右
glm::vec2(0.0f, -1.0f), // 下
glm::vec2(-1.0f, 0.0f) // 左
};
GLfloat max = 0.0f;
GLuint best_match = -1;
for (GLuint i = 0; i < 4; i++){
GLfloat dot_product = glm::dot(glm::normalize(target), compass[i]);
if (dot_product > max){
max = dot_product;
best_match = i;
}
}
return (Direction)best_match;
}
void Game::DoCollisions()
{
for (GameObject &box : this->Levels[this->Level].Bricks)
{
if (!box.Destroyed)
{
Collision collision = CheckCollision(*Ball, box);
if (std::get<0>(collision)) // 如果collision 是 true
{
// 如果砖块不是实心就销毁砖块
if (!box.IsSolid)
box.Destroyed = GL_TRUE;
// 碰撞处理
Direction dir = std::get<1>(collision);
glm::vec2 diff_vector = std::get<2>(collision);
if (dir == LEFT || dir == RIGHT) // 水平方向碰撞
{
Ball->Velocity.x = -Ball->Velocity.x; // 反转水平速度
// 重定位
GLfloat penetration = Ball->Radius - std::abs(diff_vector.x);
if (dir == LEFT)
Ball->Position.x += penetration; // 将球右移
else
Ball->Position.x -= penetration; // 将球左移
}
else // 垂直方向碰撞
{
Ball->Velocity.y = -Ball->Velocity.y; // 反转垂直速度
// 重定位
GLfloat penetration = Ball->Radius - std::abs(diff_vector.y);
if (dir == UP)
Ball->Position.y -= penetration; // 将球上移
else
Ball->Position.y += penetration; // 将球下移
}
}
}
}
}
粒子拖尾
单个粒子
struct Particle {
glm::vec2 Position, Velocity;
glm::vec4 Color;
GLfloat Life;
Particle() : Position(0.0f), Velocity(0.0f), Color(1.0f), Life(0.0f) { }
};
着色器:
#version 330 core
layout (location = 0) in vec4 vertex; // <vec2 position, vec2 texCoords>
out vec2 TexCoords;
out vec4 ParticleColor;
uniform mat4 projection;
uniform vec2 offset;
uniform vec4 color;
void main()
{
float scale = 20.0f;
TexCoords = vertex.zw;
ParticleColor = color;
gl_Position = projection * vec4((vertex.xy * scale) + offset, 0.0, 1.0);
}
#version 330 core
in vec2 TexCoords;
in vec4 ParticleColor;
out vec4 color;
uniform sampler2D sprite;
void main()
{
color = (texture(sprite, TexCoords) * ParticleColor);
}
我们创建一个vector容器来存储粒子,粒子数量固定,我们每次在死亡的粒子上创建新的粒子。
class ParticleGenerator
{
public:
// Constructor
ParticleGenerator(Shader shader, Texture2D texture, GLuint amount);
// Update all particles
void Update(GLfloat dt, GameObject &object, GLuint newParticles, glm::vec2 offset = glm::vec2(0.0f, 0.0f));
// Render all particles
void Draw();
private:
// State
std::vector<Particle> particles;
GLuint amount;
// Render state
Shader shader;
Texture2D texture;
GLuint VAO;
// Initializes buffer and vertex attributes
void init();
// Returns the first Particle index that's currently unused e.g. Life <= 0.0f or 0 if no particle is currently inactive
GLuint firstUnusedParticle();
// Respawns particle
void respawnParticle(Particle &particle, GameObject &object, glm::vec2 offset = glm::vec2(0.0f, 0.0f));
};
ParticleGenerator::ParticleGenerator(Shader shader, Texture2D texture, GLuint amount)
: shader(shader), texture(texture), amount(amount)
{
this->init();
}
void ParticleGenerator::Update(GLfloat dt, GameObject &object, GLuint newParticles, glm::vec2 offset)
{
// Add new particles
for (GLuint i = 0; i < newParticles; ++i)
{
int unusedParticle = this->firstUnusedParticle();//找到第一个未使用的粒子位置
this->respawnParticle(this->particles[unusedParticle], object, offset);
}
// Update all particles
for (GLuint i = 0; i < this->amount; ++i)
{
Particle &p = this->particles[i];
p.Life -= dt; // reduce life
if (p.Life > 0.0f)
{ // particle is alive, thus update
p.Position -= p.Velocity * dt;
p.Color.a -= dt * 2.5;
}
}
}
// Render all particles
void ParticleGenerator::Draw()
{
// Use additive blending to give it a 'glow' effect
glBlendFunc(GL_SRC_ALPHA, GL_ONE);
this->shader.Use();
for (Particle particle : this->particles)
{
if (particle.Life > 0.0f)
{
this->shader.SetVector2f("offset", particle.Position);
this->shader.SetVector4f("color", particle.Color);
this->texture.Bind();
glBindVertexArray(this->VAO);
glDrawArrays(GL_TRIANGLES, 0, 6);
glBindVertexArray(0);
}
}
// Don't forget to reset to default blending mode
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
}
void ParticleGenerator::init()
{
// Set up mesh and attribute properties
GLuint VBO;
GLfloat particle_quad[] = {
0.0f, 1.0f, 0.0f, 1.0f,
1.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 1.0f,
1.0f, 1.0f, 1.0f, 1.0f,
1.0f, 0.0f, 1.0f, 0.0f
};
glGenVertexArrays(1, &this->VAO);
glGenBuffers(1, &VBO);
glBindVertexArray(this->VAO);
// Fill mesh buffer
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(particle_quad), particle_quad, GL_STATIC_DRAW);
// Set mesh attributes
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 4 * sizeof(GLfloat), (GLvoid*)0);
glBindVertexArray(0);
// Create this->amount default particle instances
for (GLuint i = 0; i < this->amount; ++i)
this->particles.push_back(Particle());
}
// Stores the index of the last particle used (for quick access to next dead particle)
GLuint lastUsedParticle = 0;
GLuint ParticleGenerator::firstUnusedParticle()
{
// First search from last used particle, this will usually return almost instantly
for (GLuint i = lastUsedParticle; i < this->amount; ++i) {
if (this->particles[i].Life <= 0.0f) {
lastUsedParticle = i;
return i;
}
}
// Otherwise, do a linear search,在Last之后未找到有被杀死的粒子
for (GLuint i = 0; i < lastUsedParticle; ++i) {
if (this->particles[i].Life <= 0.0f) {
lastUsedParticle = i;
return i;
}
}
// All particles are taken, override the first one
//(note that if it repeatedly hits this case, more particles should be reserved)
lastUsedParticle = 0;
return 0;
}
void ParticleGenerator::respawnParticle(Particle &particle, GameObject &object, glm::vec2 offset)
{
//随机产生一个粒子
//-5到+5的随机数
GLfloat random = ((rand() % 100) - 50) / 10.0f;
//srand(time(NULL));
//随机颜色
GLfloat rColor1 = 0.5 + ((rand() % 100) / 100.0f);
GLfloat rColor2 = 0.5 + ((rand() % 100) / 100.0f);
GLfloat rColor3 = 0.5 + ((rand() % 100) / 100.0f);
particle.Position = object.Position + random + offset;
particle.Color = glm::vec4(rColor1, rColor2, rColor3, 1.0f);
particle.Life = 1.0f;
particle.Velocity = object.Velocity * 0.2f;
}
游戏音效
我们使用第三方库irrKlang来为游戏增添有去的音效。IrrKlang是一个可以播放WAV,MP3,OGG和FLAC文件的高级二维和三维(Windows,Mac OS X,Linux)声音引擎和音频库。它还有一些可以自由调整的音频效果,如混响、延迟和失真。IrrKlang是一个易于使用的音频库,只需几行代码便可播放大多数音频文件。
irrKlang可以从这里下载,下载之后引入irrKlang的头文件,将他们的库文件(irrKlang.lib)添加到链接器设置中,并将他们的dll文件复制到适当的目录下(通常和.exe在同一目录下)。需要注意的是,如果你想要加载MP3文件,则还需要引入ikpMP3.dll文件。
#include <irrKlang.h>
using namespace irrklang;
...
//音效
ISoundEngine *SoundEngine = createIrrKlangDevice();
//背景音乐
SoundEngine->play2D("../res/Audio/breakout.mp3", GL_TRUE);
如此类似使用,非常简单,当然游戏里我们还添加了一些其他碰撞音效,不再赘述。
总结
这里仅仅叙述了一部分,整个工程的代码在github链接下载:
https://github.com/ZeusYang/Breakout
一些后期特效处理: