Intro

本章先介绍如何在 macOS 中设置开发环境,然后讲解实时游戏的核心概念:游戏循环、游戏如何随时间更新,以及游戏输入和输出的基本知识。同时,本章会在最后实现一个简单的 2D 游戏《Pong》。

配置开发环境

为了创建游戏窗体,我们需要使用一个跨平台的第三方库,Simple DirectMedia Layer(SDL),它负责处理创建窗体、2D 图形、音频输出、键盘输入等过程。

打开终端,使用 brew install sdl2 命令,就可以一键式的安装好 SDL。

利用 brew info sdl2 可以用来查看安装信息。

安装好了之后应该就能够在 /usr/local/Cellar/sdl2 的安装目录下看到。

接下来分别讲如何在 Xcode 和 CLion 中进行配置。

Xcode

在创建好一个项目后,按照以下步骤进行设置:

  1. 在 Xcode 左侧栏点击项目进入项目管理界面
  2. 进入 Build Phases 界面
  3. Link Binary With Libraries 一栏点击添加符号
  4. 在弹出界面中选择 Add Files
  5. 在路径 /usr/local/Cellar/sdl2/2.28.1/lib/libSDL2-2.0.0.dylib 下找到添加的文件并选中
  6. 进入 Build Settings 界面并下拉至 Search Paths 一栏
  7. Header Search Paths 中添加 /usr/local/include

接下来就可以在项目中通过 #include <SDL2/SDL.h> 来使用 SDL 库。

使用 Xcode 时还可能用到以下设置:

  1. 设置读取相对路径
    1. 在 Xcode 顶端菜单栏选择 Product -> Scheme -> Edit Scheme
    2. 在弹出页面选择 Run -> Options
    3. 在 Working Dictionary 选项里面,勾选 Using cusom working dictionary
  2. 修改默认生成路径
    1. 在 Xcode 顶端菜单栏选择 Xcode -> Settings
    2. 在弹出页面选择 Locations -> Advanced
    3. 在弹出页面选择 Custome,然后在下拉菜单选择 Relative to Workspace

CLion

在创建好一个项目后,需要在自动生成的项目文件 CMakeLists.txt 中进行下面的配置:

1
2
3
4
5
6
7
8
9
10
11
12
cmake_minimum_required(VERSION 3.25)
project(Chapter01)

set(CMAKE_CXX_STANDARD 17)

set(SDL_H /usr/local/Cellar/sdl2/2.28.1/include) # 这个 SDL 开发包的路径,可以通过 brew info sdl2 查出来
include_directories(${SDL_H}) # 添加 SDL 头文件搜索路径

set(SDL_LIB /usr/local/Cellar/sdl2/2.28.1/lib/libSDL2-2.0.0.dylib)
link_libraries(${SDL_LIB}) # 增加 SDL 链接库目录

add_executable(Chapter01 main.cpp Game.cpp Game.h)

接下来就可以在项目中通过 #include <SDL2/SDL.h> 来使用 SDL 库。

不使用 IDE

如果我们不想使用 IDE,也可以通过下面的 makefile 模版来运行项目。

假设我现在有 Game.cppGame.hppmain.cpp 三个文件,其中有用到 #include <SDL2/SDL.h> 来使用 SDL 库。我们需要首先在项目根目录下创建一个名为 makefile 的文件,并输入以下内容。

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
# 头文件文件夹
INCDIR := include
# 源代码文件夹
SRCDIR := src
# 编译输出文件夹
BUILDDIR := build
# 可执行文件名
TARGET := game

# 编译器
CXX := g++
# 编译器选项
CXXFLAGS := -std=c++11 -Wall -Wextra -O2 -I$(INCDIR)
# 链接器选项
LDFLAGS := -L/usr/local/Cellar/sdl2/2.28.1/lib
# 需要链接的库
LDLIBS := -lSDL2-2.0.0

# 所有源文件
SRCEXT := cpp
SOURCES := $(shell find $(SRCDIR) -type f -name *.$(SRCEXT))
# 所有对象文件
OBJECTS := $(patsubst $(SRCDIR)/%,$(BUILDDIR)/%,$(SOURCES:.$(SRCEXT)=.o))
# 所有依赖文件
DEPS := $(OBJECTS:.o=.d)

# 默认目标
all: $(TARGET)

# 链接可执行文件
$(TARGET): $(OBJECTS)
@echo "Linking $(TARGET)..."
@mkdir -p $(@D)
$(CXX) $^ -o $(TARGET) $(LDFLAGS) $(LDLIBS)

# 编译源文件为对象文件
$(BUILDDIR)/%.o: $(SRCDIR)/%.$(SRCEXT)
@echo "Compiling $<..."
@mkdir -p $(@D)
$(CXX) $(CXXFLAGS) -c -MMD -MP $< -o $@

# 包含所有依赖文件
-include $(DEPS)

# 清理编译生成的文件
clean:
@echo "Cleaning..."
$(RM) -r $(BUILDDIR) $(TARGET)

.PHONY: clean

然后在项目根目录下创建一个名为 src 的文件夹,并将 Game.cppmain.cpp 移动到 src 文件夹中。

之后在项目根目录下创建一个名为 include 的文件夹,并将 Game.hpp 移动到 include 文件夹中。

最后在项目根目录下打开终端,输入指令 make,就可以生成可运行文件。

游戏循环

游戏在运行时,每秒钟都需要进行多次刷新。游戏循环是用于控制整个游戏流程的一个循环。游戏循环的每一次迭代就是一帧。如果游戏以 60 帧/秒(FPS)运行,就意味着游戏循环每秒完成 60 次迭代。游戏每秒运行这么多次的迭代,就造成了连续运动的错觉,实际上它只是定时在刷新。

在宏观上,游戏的每一帧都需要执行以下步骤:

  1. 处理进程输入(例如键盘、鼠标,甚至是 GPS 或多人游戏时的网络用户数据等);
  2. 更新游戏世界(检查并更新游戏中的所有变量);
  3. 生成输出(输出新的画面、音效、震动反馈等等)。

搭建游戏窗口

实现一个骨骼 Game 类

有了上述的简单介绍,就可以开始开发一个基本的游戏骨架了。

先创建一个 Game.cpp 文件,如果有使用 IDE,则会默认生成 Game.hGame.hpp 文件,否则需要手动创建其中一个。由于 CLion 自动生成的是 Game.h 文件,因此我们后面统一使用 .h 文件。

之后在 Game.h 中导入 SDL 的头文件:<SDL.h>

1
2
3
4
5
6
#ifndef CHAPTER01_GAME_H
#define CHAPTER01_GAME_H

#include <SDL2/SDL.h>

#endif //CHAPTER01_GAME_H

一个游戏基本过程,可以分成初始化运行时的游戏循环关闭游戏这三个阶段。简而言之就是创建、运行和关闭。

进一步地,根据上面对游戏循环过程的简单介绍。我们可以将游戏循环的过程划分成三个大致步骤,分别是 ProcessInputUpdateGameGenerateOutput。为了判断游戏是否应该继续运行,还需要加上一个 bool 变量 mIsRunning

因此我们可以得到下面的 Game 类的声明:

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
#ifndef CHAPTER01_GAME_H
#define CHAPTER01_GAME_H

#include <SDL2/SDL.h>

class Game {
public:
Game();
// 初始化
bool Initialize();
// 游戏运行
void RunLoop();
// 关闭游戏
void Shutdown();
private:
// 处理进程
void ProcessInput();
// 更新游戏
void UpdateGame();
// 生成输出
void GenerateOutput();
// 通过 SDL 创建窗体
SDL_Window* mWindow;
// 判断是否继续运行
bool mIsRunning;
};

#endif //CHAPTER01_GAME_H

有了这个声明,就可以开始在 Game.cpp 文件中实现成员函数了。

构造函数

Game() 作为一个简单的构造函数,只需要把 mWindow 初始化为 nullptrmIsRunning 初始化为 true

1
2
3
4
Game::Game()
:mWindow(nullptr)
,mIsRunning(true)
{}

Initialize 函数

Initialize 函数如果返回 true 则代表初始化成功,false 则代表创建失败。

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
bool Game::Initialize() {
// 初始化 SDL 库
int sdlResult = SDL_Init(SDL_INIT_VIDEO);
if (sdlResult != 0) {
SDL_Log("不能初始化 SDL: %s", SDL_GetError());
return false;
}

// 创建 SDL 窗体
mWindow = SDL_CreateWindow(
"游戏环境的搭建", // 标题
100, // 窗体左上角的 x 坐标
100, // 窗体左上角的 y 坐标
windowWidth, // 窗体宽度
windowHeight, // 窗体高度
0 // 标志位
);
if (!mWindow) {
SDL_Log("创建窗体失败: %s", SDL_GetError());
return false;
}

// 完成初始化
return true;
}

其中,变量 windowWidthwindowHeight 都是整形常量(const int),这方便我们修改窗口的大小。在 Game.cpp 文件中,在头文件声明之后添加下面的代码:

1
2
const int windowWidth = 1024;
const int windowHeight = 768;

初始化时,需要先初始化 SDL 库,这需要调用 SDL 库提供的 SDL_Init 函数。这个函数接收标志位来初始化子系统,如果需要多个子系统,只需要使用 |(OR)运算把标志位连接起来,下面的表格列出了最常用的子系统,具体的子系统列表可以查阅API 文档

标志位 子系统
SDL_INIT_AUDIO 音频设备管理、回放和录音
SDL_INIT_VIDEO 创建窗口的视频子系统,与 OpenGL 库和 2D 图形相连接
SDL_INIT_HAPTIC 力反馈子系统
SDL_INIT_GAMECONTROLLER 支持游戏控制器输入的子系统

这里初始化的是视频子系统。SDL_Init 初始化成功时返回 0 ,失败时返回负错误代码。出错时可以调用 SDL_GetError() 来获取更多的错误信息。SDL_Logprintf 类似,把信息输出到控制台。

如果 SDL 库初始化成功,接下来就是用 SDL_CreateWindow 函数来创建窗口。SDL_CreateWindow 函数接受多个参数:窗口标题、窗口左上角的 x/y 坐标、窗口的宽度/高度,以及可选的窗口创建标志。常用的标志位如下表所示,如果需要使用多个标志位,只需要使用 |(OR)运算把标志位连接起来。

标志位 效果
SDL_WINDOW_FULLSCREEN 使用全屏模式
SDL_WINDOW_FULLSCREEN_DESKTOP 在当前桌面分辨率下使用全屏模式(忽略 SDL_CreateWindow 的宽度/高度参数)
SDL_WINDOW_OPENGL 为 OpenGL 图形库添加支持
SDL_WINDOW_RESIZABLE 允许用户调整窗口大小

如果 SDL_CreateWindow 函数创建窗口失败,mWindow 会被设置为空指针,因此需要对创建结果进行检查。如果创建成功,Game::Initialize 函数返回 true

Shutdown 函数

Shutdown 函数的功能与 Initialize 的相反。首先需要调用 SDL_DestroyWindow 函数来销毁 SDL_Window 对象,然后使用 SDL_Quit 函数关闭 SDL 库。

1
2
3
4
void Game::Shutdown() {
SDL_DestroyWindow(mWindow);
SDL_Quit();
}

RunLoop 函数

RunLoop 函数会保持游戏循环的迭代运行,直到 mIsRunning 变成 false,此时函数返回,游戏停止运行。因为对于游戏循环的各个阶段,存在 3 个对应的辅助函数,所以 RunLoop 函数只是在循环中调用这些辅助函数:

1
2
3
4
5
6
7
void Game::RunLoop() {
while (mIsRunning) {
ProcessInput();
UpdateGame();
GenerateOutput();
}
}

ProcessInput 函数

在任何一个桌面操作系统中,用户都可以在程序窗口上执行某些操作,例如移动窗口、最小化或最大化窗口、关闭窗口等。这些行为用不同的事件(events)表示。用户执行不同的操作时,程序从操作系统接收到事件,并根据事件作出不同的响应。

SDL 库管理一个从操作系统接收事件的内部队列。这个队列包含了许多不同的窗口操作的事件,以及与输入设备相关的事件,它们的元素类型是 SDL_Event。对于每一帧,游戏都需要轮询队列中的事件,并选择忽略或处理队列中的每个事件。对于某些事件,比如移动窗口,忽略该事件意味着 SDL 会自动处理该事件。但对于其他事件,忽略事件意味着什么都不会发生。

我们在 ProcessInput 函数中实现对事件的处理。由于在每一帧中,事件队列都可能包含了多个事件,因此必须遍历队列中的所有事件。当 SDL_PollEvent 函数会在事件队列中找到事件时,会修改传入的元素指针的值用来记录事件的相关信息(SDL 是用 C 语言 编写的,因此叫指针,不叫引用)并返回 true。因此,ProcessInput 函数的基本实现就是:在其调用 SDL_PollEvent 函数返回值为 true 的情况下,不停地继续调用 SDL_PollEvent 函数,直到事件中不再有任何事件。

1
2
3
4
5
6
void Game::ProcessInput() {
SDL_Event event;
// 队列中有 event 就一直循环
while (SDL_PollEvent(&event)) {
}
}

需要注意的是,SDL_PollEvent 函数通过指针(变量 event 的地址)接收 SDL_Event 事件。变量 event 会存储刚刚从事件队列中移除的有关事件的所有信息。

上面的实现只是简单地从事件队列中删除了所有事件,但没有对这些事件作出响应。

给定一个 SDL_Event 对象 event,它的成员变量 event.type 会记录对应事件的类型。SDL_QUIT 事件是在玩家尝试通过单击窗口上的关闭按钮时被触发的事件。因此我们可以得到下面的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Game::ProcessInput() {
SDL_Event event;
// 队列中有 event 就一直循环
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
mIsRunning = false;
break;
default:
break;
}
}
}

如果希望玩家通过按下 Esc 键退出游戏,可以使用 SDL_GetKeyboardState 函数获取键盘的状态,该函数会返回一个指向数组的指针。在获得数组后,我们可以通过使用键对应的 SDL_SCANCODE 值查询索引到该数组中的特定键。修改后的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void Game::ProcessInput() {
SDL_Event event;
// 队列中有 event 就一直循环
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
mIsRunning = false;
break;
default:
break;
}
}
// 获取键盘的状态
const Uint8* state = SDL_GetKeyboardState(NULL);
// 如果按了 Esc,结束循环
if (state[SDL_SCANCODE_ESCAPE]) {
mIsRunning = false;
}
}

到这里,我们就基本实现了一个简单的 Game 类,关于另外两个辅助函数的具体实现会在之后完成,此时我们只需要先定义它们,并留空就行。

1
2
3
void Game::UpdateGame() {}

void Game::GenerateOutput() {}

Main 函数

所有 C++ 程序的入口都是 main 函数,它的实现很简单,先构造对象,然后初始化,初始化成功则进入游戏循环,在退出游戏循环后,关闭游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "Game.h"

int main(int argc, char** argv) {
Game game;
bool success = game.Initialize();

if (success) {
game.RunLoop();
}

game.Shutdown();
return 0;
}

在完成上面的内容后,就可以运行游戏项目了。运行后,我们会看到一个如下的窗口,并可以通过鼠标或按下 Esc 键来关闭游戏。

游戏窗口

基本的 2D 图形

在实现游戏循环的“生成输出”之前,有必要了解 2D 图形在游戏中的作用。

大多数现代高级显示器采用光栅图形,也称为位图,来显示图像。这意味着这些屏幕都是由像素点构成的,每个像素点都可以显示不同数量的光线和颜色。光栅显示的分辨率是指像素点方格的宽度和高度。例如,常见的 1080 p 分辨率代表屏幕有 1080 行像素点,每行有 1920 个像素点。同样,4K 分辨率代表屏幕每行有 3840 个像素点,总共有 2160 行。

彩色显示器通过混合多种颜色来显示特定的颜色。最常见的方法是混合红(R)、绿(G)、蓝(B)三种基本颜色。RGB 三种颜色的不同强度组合形成了一种颜色范围,被称为色域。除了 RGB 之外,许多游戏内部还使用 alpha 值来控制透明度,这种颜色表示方式称为 RGBA。

颜色缓冲区

为了让显示器显示 RGB 图像,必须指定每个像素的颜色。在计算机图形中,颜色缓冲区是内存中包含整个屏幕颜色信息的内存区域。显示屏可以使用颜色缓冲区在屏幕上绘制内容。将颜色缓冲区视为一个二维数组,其中每个 (x, y) 索引对应于屏幕上相应位置的像素。在游戏循环“生成输出”阶段的每一帧中,游戏都会将图形输出写入到颜色缓冲区。

颜色缓冲区的内存使用率取决于颜色深度(color depth),即储存 1 像素的颜色所用的位数,它也称为位/像素(bpp)。举个例子,一个 24 比特的颜色深度,红、绿、蓝每个使用 8 个 bit。意味着有 种独一无二的颜色。如果游戏还要另外使用 8 位来存储 alpha 值,每个像素总共需要 32 位来存储。

一个 1080 p(1920 × 1080)的图像,每个像素点有 32 bit,那么大概需要的内存空间就是 bytes,大概是 7.9 MB。一些现代游戏使用 16 位来表示 RGB 的每个组成,这可以增加颜色的数量。当然,这将导致内存的使用量是原来的两倍,一张 1080 p 的图像就接近 16 MB。不过现在显卡的显存基本都有几千 MB,这个使用量还是显得微不足道。

色彩的表示

要在代码中表示颜色,通常有两种方法。假如给定一个 8 bit 的颜色值,一种方法是简单地使用非负整数值去代表每种颜色(通道,channel)。8 bit 色彩深度的通道,值就介于 0 和 255 之间。另外一种方法就是将这个值规范化到 0.0 和 1.0 之间。

使用浮点数的一个好处是可以不用过分地关注色彩深度。举个例子,RGB 颜色 (1.0, 0.0, 0.0) 是一个纯粹的红色。在 8 bit 色彩深度下表示成非负整数是 (255, 0, 0),但是如果是在 16 bit 的色彩深度下,它不再是纯红色,而接近黑色。

在这两种表示之间转换是很简单的。给定一个非负整数值,除以非负整数的最大值就可以获得规范化的浮点数。给定一个颜色的浮点数表示,乘以非负整数的最大值,就可以获得非负整数的表示形式。

SDL 接受的是非负整数的表示形式。

双缓冲区

屏幕刷新的频率可能不同于游戏刷新的频率。有的显示屏刷新的频率是 59.94 Hz,这就意味着它比每秒60次的刷新频率要低。有的显示屏刷新频率支持 144 Hz 的刷新频率,这比游戏的刷新频率的 2 倍还多。

此外, 目前的任何显示技术都不能立即更新整个屏幕。更新过程总是有一个更新顺序——逐行、逐列,或者按照棋盘形式等。

假设游戏写入颜色缓冲区,同时显示器从相同的颜色缓冲区中读取颜色来显示。由于游戏帧速率的计时可能与显示器的刷新率不匹配,因此显示器很可能会在游戏写入缓冲区的同时,从颜色缓冲区读取数据。这是有问题的。

很容易想到的一个问题就是游戏正在写入 B 帧,用来覆盖颜色缓冲区中的 A 帧。然而,还没等 B 帧写入结束,该画面就被读取,造成只显示部分的 A 帧和部分的 B 帧。这种现象称为画面撕裂(screen tearing)。

画面撕裂

要解决画面撕裂的问题,需要两个技术,分别是双缓冲区技术和垂直同步(vertical synchronization, VSync)技术。

双缓冲区技术就是创建两个独立的颜色缓冲区,游戏向后台缓冲区(back buffer)写入数据,显示器从前台缓冲区(front buffer)读取数据,在后台缓冲区写入完成后,游戏和显示器交换缓冲区。

可是,双缓冲区本身并不能解决画面撕裂的问题。如果在显示器仍在显示前台缓冲区的时候,游戏就要写入该缓冲区,一样会导致画面撕裂。这种情况通常发生在游戏更新过快的时候。为了解决这个问题,就需要用到垂直同步技术。

垂直同步技术要求必须等到显示器完成前台缓冲区的绘制之后再交换前后缓冲区。也就是说,即便游戏先完成了后台缓冲区的写入操作,也必须等待显示器完成对前台缓冲区的读取操作,然后由显示器发送刷新屏幕的信号,然后交换前后缓冲区。

在垂直同步的情况下,游戏刷新有可能需要等待更长的时间。这就意味着游戏循环可能达不到 30 或 60 FPS。这可能是有的玩家无法接受的帧速。因此,是否启用垂直同步,不同游戏、不同用户的选择可能不同。一个好主意是在引擎中提供 VSync 作为一个选项,这样就允许玩家可以在偶尔的屏幕撕裂或偶尔的屏幕卡壳之间做出选择。

现在,一些高端的显示屏采用了新的显示技术,可以做到自适应刷新频率(adaptive refresh rate)。通过这种方法,游戏会告诉显示器它何时要进行刷新,而不是让显示器告诉游戏何时能刷新。当然,这种屏幕很贵。iPad Pro 现在就支持这种显示技术,根据浏览的内容自动调整刷新率,既可以保证流畅的体验,又节约能源。

实现基本的 2D 图形

SDL 有一组函数可以实现简单的 2D 图形的绘制。由于目前我们的重点在 2D 图形,因此先使用这些函数。之后我们会切换到适用于图形的 OpenGL 库,因为它同时支持 2D 和 3D。

添加渲染器

为了使用 SDL 的图形代码,需要通过 SDL_CreateRenderer 函数来构造一个 SDL_Renderer渲染器(renderer)通常是指用于绘制图形的系统,包括 2D 图形和 3D 图形。因为每次绘制图形的时候都需要引用这个 SDL_Renderer 对象,因此要先在 Game 类中添加 mRenderer 作为成员变量,同时在构造函数中对其进行初始化。

Game.h 文件中,Game 类声明的私有区域添加:

1
2
// 渲染器
SDL_Renderer* mRenderer;

Game.cpp 文件中,更新构造函数:

1
2
3
4
5
Game::Game()
:mWindow(nullptr)
,mRenderer(nullptr)
,mIsRunning(true)
{}

初始化渲染器

之后,在 Game::Initialize() 函数中创建渲染器:

1
2
3
4
5
6
7
8
9
10
// 创建渲染器
mRenderer = SDL_CreateRenderer(
mWindow,
-1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC
);
if (!mRenderer) {
SDL_Log("创建渲染器失败: %s", SDL_GetError());
return false;
}

SDL_CreateRenderer 函数的第一个参数是指向 SDL_Window 结构的指针,表示要与渲染器关联的窗口。渲染器将用于在该窗口上进行渲染操作。

第二个参数是一个整数,用于指定要在窗口上使用的渲染器索引。如果传入 -1,SDL将选择支持硬件加速的第一个渲染器。如果有多个渲染器可用,可以通过更改索引来选择要使用的渲染器。

第三个参数是一个位掩码,用于指定渲染器的行为选项。可以使用 SDL_RendererFlags 中定义的常量来设置标志。具体内容可以参考 API 文档,常见的选项如下表所示:

标志 效果
SDL_RENDERER_SOFTWARE 指定渲染器应该以软件方式运行,而不是使用硬件加速。这在某些情况下可能会比较慢,但是在不支持硬件加速的系统上是必需的。
SDL_RENDERER_ACCELERATED 指定渲染器应该尽可能地使用硬件加速。如果硬件加速可用的话,这是默认选项。
SDL_RENDERER_PRESENTVSYNC 指定渲染器应该使用垂直同步(VSync)来消除图像撕裂。这将确保渲染的帧与显示器的刷新率同步。
SDL_RENDERER_TARGETTEXTURE 指定渲染器应该支持将纹理作为渲染目标。这对于高级渲染操作非常有用,例如渲染到纹理或创建后期处理效果。

函数返回一个指向 SDL_Renderer 结构的指针,表示创建的渲染器。如果创建失败,函数将返回空指针,此时 Game::Initialize() 函数应返回 false

销毁渲染器

最后,我们需要在游戏关闭的时候手动销毁渲染器。通常析构(销毁)顺序和构造顺序相反,因此先销毁渲染器,再销毁窗口。

更新 Game::Shutdown 函数如下:

1
2
3
4
5
void Game::Shutdown() {
SDL_DestroyRenderer(mRenderer);
SDL_DestroyWindow(mWindow);
SDL_Quit();
}

基本绘制设置

任何游戏图形库绘制图形时都包含这三个步骤:

  1. 清空后台缓冲区的颜色(当前的游戏缓冲区)
  2. 绘制整个游戏场景
  3. 交换前后缓冲区

渲染图形属于最终的输出,我们把这部分代码放到 Game::GenerateOutput 之中。要清除后缓冲区,需要先用 SDL_SetRenderDrawColor 指定一种颜色。这个函数接收一个指向渲染器的指针,和 RGBA 四元组件(0 到 255)。Tiffany 蓝的 RGB 色值是 (129, 216, 209),用它来更新 Game::GenerateOutput 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void Game::GenerateOutput() {
// 设置 Tiffany 蓝
SDL_SetRenderDrawColor(
mRenderer,
129, // R
216, // G
209, // B
255 // A
);
// 清理后缓冲区
SDL_RenderClear(mRenderer);
// 交换前后缓冲区
SDL_RenderPresent(mRenderer);
}

在设置完要填充的颜色后,利用 SDL_RenderClear 函数,把后台缓冲区清除为前面设置的颜色。之后需要绘制游戏场景,但目前我们先跳过这一步。最后,利用 SDL_RenderPresent 函数交换前后缓冲区。

完成上面的代码后,运行程序就可以得到下面的窗口。

基本绘制设置

绘制游戏中的物体

本章的游戏项目是电子游戏《Pong》的一个经典版本,球在屏幕上移动,玩家可以控制球拍来击打球。可以说乒乓球游戏是游戏开发者的“Hello World”项目。这一节将介绍如何绘制《Pong》中的物体。

因为我们要绘制的都是游戏中的物体,因此这一过程发生在 GenerateOutput 函数中,具体的时间是在清除后台缓冲区之后,交换前后缓冲区之前。

我们可以通过 SDL 库提供的 SDL_RenderFillRect 函数来绘制填充的矩形。它接受一个 SDL_Rect 代表的填充矩形,而矩形颜色由当前的绘图颜色决定。也就是说,如果我们不先改变绘制颜色,矩形的颜色就会与前面绘制背景时使用的颜色相同,导致无法看到绘制出来的矩形。为了能够看出矩形,我们要把绘制颜色修改为蓝色,因此在 GenerateOutput 函数中交换前后缓冲区之前写入:

1
2
// 设置绘制颜色
SDL_SetRenderDrawColor(mRenderer, 0, 0, 255, 255);

要绘制矩形,需要指定一个 SDL_Rect 结构体。这个结构体有 4 个参数,分别是左上角的 x/y 坐标,还有矩形的宽度/高度。在绝大多数的图形库中,包括 SDL,窗口左上角的点的坐标是 ,x 正半轴向右,y 正半轴向下(和数学上的相反)。

假设我们要在屏幕上方绘制矩形作为游戏的墙,可以使用下面的 SDL_Rect 的定义:

1
2
3
4
5
6
7
// 顶部墙的参数
SDL_Rect wall {
0, // 左上 x 坐标
0, // 左上 y 坐标
windowWidth, // 宽度
thickness // 高度
};

其中,变量 thickness 是设置为 15 的整形常量(const int),这方便我们修改墙壁的厚度。在 Game.cpp 文件中,在头文件声明之后添加下面的代码:

1
const int thickness = 15;

C++ 不推荐使用 #define 进行宏预定义,更推荐使用 const

最后,使用 SDL_RenderFillRect 函数来绘制矩形,该函数需要传入 SDL_Rect 指针:

1
SDL_RenderFillRect(mRenderer, &wall);

完成上面代码后,游戏就会在屏幕的顶部绘制一面墙壁。

之后,只需要更改 SDL_Rect 的参数就可以用类似的代码画出屏幕底部和屏幕右侧的墙壁。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 绘制底部墙
wall.y = 768 - thickness;
SDL_RenderFillRect(mRenderer, &wall);

// 绘制右边的墙
wall = {
windowWidth - thickness,
0,
thickness,
windowWidth
};
SDL_RenderFillRect(mRenderer, &wall);

墙壁可以通过硬编码的方式实现,但是球拍和球不可以,因为它们会随额游戏循环而运动。在实际游戏编程中,球和球拍应该抽象成类,但是现在我们姑且先用变量代替一下。我们可以设置两个成员变量来存储两个对象的中心位置,并基于这些位置来绘制矩形。

首先,先定义一个 Vector2 结构体来存储 x 和 y 坐标。这个定义放到 Game.hpp 之中:

1
2
3
4
5
// Vector2 结构体仅存储 x 和 y 坐标
struct Vector2 {
float x;
float y;
};

接着添加两个 Vector2 的成员变量到 Game 类中,一个作为球拍 mPaddlePos,一个作为球 mBallPos

1
2
3
4
// 球拍位置
Vector2 mPaddlePos;
// 球的位置
Vector2 mBallPos;

之后在初始化时 Game::Initialize() 赋予它们一个合理的初始值。

1
2
3
4
5
// 初始化球拍和球的坐标
mPaddlePos.x = 10.0f;
mPaddlePos.y = windowHeight / 2.0f;
mBallPos.x = windowWidth / 2.0f;
mBallPos.y = windowHeight / 2.0f;

通过上面这些变量,就可以在 GenerateOutput 函数中绘制出球和球拍。但是,要注意,成员变量的值代表的是球和球拍的中心点,而 SDL_Rect 的构造函数是通过矩形左上角的点去构造的,因此要先把 x 坐标和 y 坐标的值减去物体长度/宽度的一半。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 绘制球拍
SDL_Rect paddle {
static_cast<int>(mPaddlePos.x),
static_cast<int>(mPaddlePos.y - paddleH / 2),
thickness,
static_cast<int>(paddleH)
};
SDL_RenderFillRect(mRenderer, &paddle);

// 绘制球
SDL_Rect ball {
static_cast<int>(mBallPos.x - thickness / 2),
static_cast<int>(mBallPos.y - thickness / 2),
thickness,
thickness
};
SDL_RenderFillRect(mRenderer, &ball);

其中,变量 paddleH 是设置为 100.0f 的浮点型常量(const float),这方便我们修改球拍的长度(游戏的难度)。在 Game.cpp 文件中,在头文件声明之后添加下面的代码:

1
const float paddleH = 100.0f;

这里我们还使用了静态强制转换将 mBallPos.x 等变量从浮点型转换为整型,后者是 SDL_Rect 结构体使用的类型。

完成上面的代码后,就可以运行程序并得到下面的结果:

静态游戏画面.jpg

接下来,就要把静态变成动态,完成游戏循环和交互逻辑的编写。

更新游戏

在编写游戏循环的时候,需要格外注意时间这一概念。真实时间游戏时间并不总是 的关系。比如暂停游戏能暂停游戏时间,“子弹时间”能减慢游戏时间,有些游戏甚至允许游戏时间倒退。

增量时间

假设程序员在一个 8 MHz 的处理器上编写代码,在游戏循环内更新敌人位置的代码可能如下所示:

1
2
// x 坐标更新 5 个像素
enemy.mPosition.x += 5;

但当上面的代码在 16 MHz 的处理器上运行时,因为游戏循环的速度提高了一倍,敌人的移动速度也会增加一倍,游戏难度陡然提升。

为了解决这个问题,游戏编程引入了增量时间(delta time)的概念。增量时间的定义是从上一帧到现在的时间流逝长度。

要采用增量时间,我们就不再考虑每帧移动的像素数量,而要考虑每秒移动的像素数量。假设理想的移动速度是每秒移动 150 像素,则可以使用下面的代码:

1
2
// 每秒更新 150 个像素
enemy.mPosition.x += 150 * deltaTime;

现在,无论帧速率是多少,代码都能正常工作。随着帧速率的提升,敌人的移动会更加平滑,但移动速度不变。

综上所述,游戏中的一切数据都应根据增量时间进行更新。

为了计算增量时间,SDL 提供了 SDL_GetTicks 函数,它会返回从调用 SDL_Init 以来的毫秒数。通过在成员变量里存储上一帧的 SDL_GetTicks 的结果,就可以使用现在的值来计算增量时间。

因此,我们首先需要在 Game 类中声明一个 mTicksCount 成员变量,并在构造函数中将它初始化为零。

Game.h 中的 private 部分增加代码:

1
2
// 记录运行时间
Uint32 mTicksCount;

更新构造函数:

1
2
3
4
5
6
Game::Game()
:mWindow(nullptr)
,mRenderer(nullptr)
,mIsRunning(true)
,mTicksCount(0)
{}

之后,使用 SDL_GetTicks 函数更新 Game::UpdateGame 函数:

1
2
3
4
5
6
7
void Game::UpdateGame() {
// 增量时间是上一帧到现在的时间差
// (转换成秒)
float deltaTime = (SDL_GetTicks() - mTicksCount) / 1000.0f;
// 更新运行时间(为下一帧)
mTicksCount = SDL_GetTicks();
}

其实,上面的代码还是有问题的。最值得注意的一点是,有一些依赖于物理现象的游戏(比如平台跳跃类),行为会根据帧速率而有所不同。这个问题最简单的解决方案就是实施帧限制,它会强制停止游戏循环,直到等到一个指定的增量时间。例如,假设目标帧率为 60 FPS,如果一帧完成仅需要 15 ms,则帧限制会要求等待 1.6 ms,以便达到 16.6 ms 的目标时间。

SDL 已经为我们提供了限制帧速的方法。例如,要限制帧之间的间隔至少为 16 ms,可以在 UpdateGame 函数开始的部分添加下面的代码:

1
2
// 等到与上一帧间隔 16ms
while (!SDL_TICKS_PASSED(SDL_GetTicks(), mTicksCount + 16));

除了关注最短间隔,我们也应该关注最长间隔。例如,在调试的时候中断了游戏,那么一段时间之后恢复运行,游戏将产生一个突跃。为了解决这个问题,只需要设定一个增量时间的最大值:

1
2
3
4
// 固定增量时间最大值
if (deltaTime > 0.05f) {
deltaTime = 0.05f;
}

更新球拍位置

在《Pong》中,我们希望玩家可以通过 W 键向上移动球拍,通过 S 键向下移动球拍。可以定义一个整数类型的变量 mPaddleDir表示球拍运动的方向,-1 表示球拍向上(负 y 轴)移动,1 表示球拍向下移动(正 y 轴),0 表示球拍不移动。

Game.h 中的 private 部分增加代码:

1
2
// 球拍方向
int mPaddleDir;

更新构造函数:

1
2
3
4
5
6
7
Game::Game()
:mWindow(nullptr)
,mRenderer(nullptr)
,mIsRunning(true)
,mTicksCount(0)
,mPaddleDir(0)
{}

玩家通过键盘输入来控制球排的位置,因此需要更新 ProcessInput 函数:

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
void Game::ProcessInput() {
SDL_Event event;
// 队列中有 event 就一直循环
while (SDL_PollEvent(&event)) {
switch (event.type) {
case SDL_QUIT:
mIsRunning = false;
break;
default:
break;
}
}

// 获取键盘的状态
const Uint8* state = SDL_GetKeyboardState(NULL);

// 如果按了 Esc,结束循环
if (state[SDL_SCANCODE_ESCAPE]) {
mIsRunning = false;
}

// 通过 W/S 更新球拍位置
mPaddleDir = 0;
if (state[SDL_SCANCODE_W]) {
mPaddleDir -= 1;
}
if (state[SDL_SCANCODE_S]) {
mPaddleDir += 1;
}
}

注意这里是对变量进行加减法操作,而不是直接赋值为 -1 或 1,因为这样才能确保玩家同时按两个键时mPaddleDir 是 0。

接下来,在 UpdateGame 中根据增量时间和方向,更新球拍位置。除此之外,还需要防止球拍超出窗口,必须限制在有效范围内。

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
void Game::UpdateGame() {
// 等到与上一帧间隔 16ms
while (!SDL_TICKS_PASSED(SDL_GetTicks(), mTicksCount + 16));

// 增量时间是上一帧到现在的时间差
// (转换成秒)
float deltaTime = (SDL_GetTicks() - mTicksCount) / 1000.0f;
// 更新运行时间(为下一帧)
mTicksCount = SDL_GetTicks();

// 固定增量时间最大值
if (deltaTime > 0.05f) {
deltaTime = 0.05f;
}

// 根据方向更新球拍位置
if (mPaddleDir != 0) {
mPaddlePos.y += mPaddleDir * 300.0f * deltaTime;

// 确保球拍不能移出窗口
if (mPaddlePos.y < (paddleH / 2.0f + thickness)) {
mPaddlePos.y = paddleH / 2.0f + thickness;
} else if (mPaddlePos.y > (windowHeight - paddleH / 2.0f - thickness)) {
mPaddlePos.y = windowHeight - paddleH / 2.0f - thickness;
}
}
}

完成上面的代码以后,玩家就可以通过键盘,以 300 像素每秒的速度来移动球拍。

更新球的位置

更新球的位置会比更新球拍的位置更复杂。首先,球会在 x 轴和 y 轴两个方向上移动;其次,球碰到墙壁和球拍后会反弹。因此代码中既需要关注球的速度(速率和方向),还需要进行碰撞检测

我们可以通过矢量分解的方法来表示速度。添加一个 Vector2 类型的成员变量 mBallVel,初始化成 (-200.0f, 235.0f),这表示游戏开始时球会向 x 轴负方向每秒移动 200 像素,同时向 y 轴正方向每秒移动 235 像素。(换句话说,球会向左下方向移动。)

Game.h 中的 private 部分增加代码:

1
2
// 球的速度
Vector2 mBallVel;

Game::Initialize() 中进行初始化:

1
2
// 初始化球的速度
mBallVel = {-200.0f, 235.0f};

Game::UpdateGame() 函数中添加下面的代码来更新球的位置:

1
2
3
// 根据速度更新球的位置
mBallPos.x += mBallVel.x * deltaTime;
mBallPos.y += mBallVel.y * deltaTime;

接下来,需要编写碰撞检测相关的代码。用于确定球是否与墙壁碰撞的代码与检查球拍是否在屏幕外的代码类似。比如说,如果球的 y 轴上的位置小于或等于球的半径(矩形的高度),则球与顶壁碰撞。一个问题是,球在碰撞墙壁之后会怎么运动。

不难想到,假如球从上往下运动,那么在碰到底部的墙后,球将反弹向上。类型的,碰撞到右边,球会反弹向左。基于矢量的原理,我们只需要在相应的分量上乘以 -1,改变它的方向即可。

然而还有一个关键问题:改变球的速度分量的方向的条件是什么?比如说,在判断球是否与顶部的墙壁碰撞的时候,如果我们只通过 mBallPos.y <= thickness 的条件去判断,那么有可能在改变方向后,该条件仍然满足,此时球的方向会再次被改变,导致球被粘在了墙上。为了解决这个问题,需要额外检查球的运动方向。

综上所述,我们在 Game::UpdateGame() 函数中添加以下代码:

1
2
3
4
5
6
7
8
9
10
// 球是否和顶部或底部墙相碰
if (mBallPos.y <= thickness && mBallVel.y < 0.0f) {
mBallVel.y *= -1;
} else if (mBallPos.y >= (windowHeight - thickness) && mBallVel.y > 0.0f) {
mBallVel.y *= -1;
}
// 球是否和右侧墙壁相碰
if (mBallPos.x >= (windowWidth - thickness) && mBallVel.x > 0.0f) {
mBallVel.x *= -1;
}

接下来进行球与球拍的碰撞检测。这里需要同时注意球和球拍在 x 轴方向和 y 轴方向的距离。如果球出了窗口,就会结束游戏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 获得球和球拍 y 轴距离
float diff = mPaddlePos.y - mBallPos.y;
// 取绝对值
diff = (diff > 0.0f) ? diff : -diff;
if (
// y 轴距离足够小
diff <= paddleH / 2.0f &&
// 球在球拍的 x 范围内
mBallPos.x <= 25.0f && mBallPos.x >= 20.0f &&
// 球正向左运动
mBallVel.x < 0.0f
) {
mBallVel.x *= -1.0f;
}
// 如果球出了窗口,结束游戏
else if (mBallPos.x <= 0.0f) {
mIsRunning = false;
}

最后修改窗口标题,《Pong》游戏就完成了。但是,我们把游戏的对象都放到了 Game 中,这不利于扩展。这就是我们之后将进一步讨论的游戏对象。

Reference

  1. Xcode与C++之游戏开发:创建环境
  2. C++: SDL2 开发环境配置(Mac+CLion)