上一篇:
Xcode与C++之游戏开发:带有简单AI的塔防游戏
SDL 渲染器支持 2D 图形,但是不支持 3D 图形。为了同时支持 2D 和 3D,这里使用了著名的 OpenGL。
OpenGL 介绍
OpenGL(Open Graphics Library)是指定义了一个跨编程语言、跨平台的编程接口规格的专业的图形程序接口。它用于三维图像(二维的亦可),是一个功能强大,调用方便的底层图形库。底层图形库提供的接口用于渲染二维或者三维图形,可以说这些接口架起了上层应用程序和底层 GPU 之间的桥梁。应用程序使用这些接口发送渲染命令,而这些接口会向显卡驱动发送渲染命令。
切换到 OpenGL
在前面的章节里,使用的是
SDL_Renderer
,现在要换成 OpenGL,就要把它移除掉了。在
Game.cpp
的实现中把原本的标志位
0
换成 OpenGL:
// 创建 SDL 窗体
mWindow = SDL_CreateWindow(
"OpenGL", // 标题
100, // 窗体左上角的 x 坐标
100, // 窗体左上角的 y 坐标
1024, // 窗体宽度
768, // 窗体高度
SDL_WINDOW_OPENGL // OpenGL
);
在创建 OpenGL 窗口之前,可以设置 OpenGL 相关的一些属性,比如色彩深度,版本等。要配置这些参数可以使用
SDL_GL_SetAttribute
:
// 设置 OpenGL 参数
// core OpenGL
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
// 指定版本 3.3
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 3);
// RGBA
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
SDL_GL_SetAttribute(SDL_GL_ALPHA_SIZE, 8);
// 双缓冲
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
// 强制使用硬件加速
SDL_GL_SetAttribute(SDL_GL_ACCELERATED_VISUAL, 1);
将之后的
SDL_Renderer
创建部分删除。接下来就是
创建一个 OpenGL 上下文
(context),可以把 context 理解成 OpenGL 世界。
添加一个成员变量到
Game
中:
SDL_GLContext mContext;
并在
Initialize
中进行初始化:
mContext = SDL_GL_CreateContext(mWindow);
有了 Create 自然需要有销毁,在
Shutdown
函数里添加析构,通用声明顺序和析取顺序相反,应该在
SDL_DeleteWindow
之前添加:
void Game::Shutdown()
{
UnloadData();
IMG_Quit();
SDL_GL_DeleteContext(mContext);
SDL_DestroyWindow(mWindow);
SDL_Quit();
}
现在已经创建好了 OpenGL 的上下文,但还有一个问题。OpenGL 为了保持后向兼容,需要手动使用扩展才能使用 OpenGL 3.3。为了避免这个繁琐的过程,不妨使用 GLEW 库(OpenGL Extension Wrangler Library)。这样的话只要一个函数调用就可以获得支持的所有扩展功能,包括3.3 版本之前的功能。
GLEW 用起来还是挺方便的,就是去
GLEW官网
下载源文件,之后解压,一个
make
就自动编译了。使用 GLEW 的方式和之前添加 SDL 库一样了,添加库依赖,在
Build Settings
中加上头文件的路径。
要初始化 GLEW,可以添加下面的代码到创建 context 之后:
glewExperimental = GL_TRUE;
if (glewInit() != GLEW_OK)
{
SDL_Log("初始化 GLEW 失败!");
return false;
}
渲染一帧
渲染的原理和之前的 SDL 还是一样的,清除、绘制、交换缓冲区,使用 OpenGL 本质上就是换了个渲染器:
void Game::GenerateOutput()
{
// 灰色
glClearColor(0.86f, 0.86f, 0.86f, 1.0f);
// 清除颜色缓冲区
glClear(GL_COLOR_BUFFER_BIT);
// TODO: 绘制场景
// 交换缓冲区
SDL_GL_SwapWindow(mWindow);
}
需要添加系统自带的 OpenGL 库
。
正常来讲,应该就可以看到一个灰色背景的 OpenGL 环境了,这也意味着我们把渲染器换成了 OpenGL 了。
渲染基础
2D 图形和 3D 图形其实没什么区别,因为本质上 3D 图形也是 2D图形,只不过它是被压平到了 2D 屏幕上。早期的一些 2D 游戏,比如说任天堂,会简单的把画面图像复制到颜色缓冲区,这个过程被称为
blitting
(印迹)。然而,现在的硬件并不擅长干这种事情,但是在多边形渲染上却非常高效。因此,可以说现在所有的游戏,无论是 2D 还是 3D,最终都是用
多边形
来满足图形需求。
最简单的多边形是三角形
,这是现在游戏最终的底层基础(四边形可以分解成两个三角形,五边形可以分解为3个三角形…)。多边形不仅计算简单,可以具有很好的伸缩性,如果设备性能不行,那么三角形绘制得少一些,这个技术也就是所谓的 HLOD(分层 LOD)。
规范化的设备坐标
NDC(Normalized device coordinates )是 OpenGL 默认的坐标系统。窗口的中心是坐标原点,左下角是 (-1,-1),右上角是 (1,1)。这个规范化的坐标体系与屏幕的实际高度和宽度无关。在渲染过程中,GPU(准确来说是图形硬件)会把 NDC 的坐标转为实际的像素位置。
在 3D 中,z 坐标分量范围也是从 -1 到 +1,正轴朝向屏幕内部。对于 2D,显然 z 分量为 0。
顶点和索引缓冲
如果有一个由三角形组成的 3D 模型,需要通过某种方式在内存中存储这些三角形的顶点。最简单粗暴就是直接存顶点,比如这样:
float vertices[] = {
-0.5f, 0.5f, 0.0f,
0.5f, 0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
0.5f, -0.5f, 0.0f,
-0.5f, -0.5f, 0.0f,
-0.5f, 0.5f, 0.0f,
};
很容易发现这种简单的存储冗余度很高,
-0.5f, 0.5f,
和
0.5f, -0.5f,
出现了两次。如果移除这些重复的顶点,可以节省 33% 的内存。如果有一个很大的模型,比如由 20000 个三角面组成,那么剩下的存储空间就非常多了。
解决方案是把顶点的存储分成两个部分。首先,创建一个
顶点的缓冲区
,存储不重复的顶点坐标。然后,引入一个索引缓冲区,指定三角形的坐标顺序:
float vertexBuffer[] = {
-0.5f, 0.5f, 0.0f, // 0
0.5f,0.5f, 0.0f, // 1
0.5f,-0.5f, 0.0f, // 2
-0.5f,-0.5f, 0.0f // 3
};
unsigned short indexBuffer[] = {
0, 1, 2,
2, 3, 0
};
简单的存储顶点需要内存是 72 byte(3 * 4 byte * 6),使用两个缓冲区只需要,3 * 4 byte * 4 + 6 * 2 byte = 60 byte,大约节省20%。
为了使用顶点和索引缓冲,需要让 OpenGL 知道有这个东西。OpenGL 使用
vertex array object
(顶点序列对象) 包含了一个顶点缓冲、索引缓冲还有顶点布局。顶点布局指定了模型中每个顶点存储什么数据。
由于每个模型都需要一个顶点序列对象,那就封装成一个
VertexArray
类:
class VertexArray
{
public:
VertexArray(const float* verts, unsigned int numVerts,
const unsigned int* indices, unsigned int numIndices);
~VertexArray();
// 激活顶点序列(你才可以绘制它)
void SetActive();
unsigned int GetNumIndices() const { return mNumIndices; }
unsigned int GetNumVerts() const { return mNumVerts; }
private:
// 顶点缓冲区的顶点数量
unsigned int mNumVerts;
// 索引缓冲区的数量
unsigned int mNumIndices;
// 顶点缓冲区的 OpenGL ID
unsigned int mVertexBuffer;
// 索引缓冲区的 OpenGL ID
unsigned int mIndexBuffer;
// 顶点序列对象 的 OpenGL ID
unsigned int mVertexArray;
};
需要注意一下,OpenGL 并没有返回指针,只有一个 ID。
ID 不同对象之间也不是唯一的
,很可能是1。
构造函数稍微有一点复杂,首先需要创建一个顶点序列对象(vertex array object)并且存储它的 id 到
mVertexArray
:
// 创建顶点序列对象
glGenVertexArrays(1, &mVertexArray);
glBindVertexArray(mVertexArray);
有了顶点序列对象,就可以创建一个顶点的缓冲区:
// 创建顶点缓冲区
glGenBuffers(1, &mVertexBuffer);
// 打算使用这个缓冲区作为顶点缓冲区
glBindBuffer(GL_ARRAY_BUFFER, mVertexBuffer);
// 复制顶点数据到序列缓冲对象
glBufferData(GL_ARRAY_BUFFER,
numVerts * 3 * sizeof(float), // 复制的字节数量
verts, // 源
GL_STATIC_DRAW // 使用方式
);
glBufferData
不需要传入对象 ID,只需要指定写入的范围。
GL_STATIC_DRAW
意味着仅仅只是加载一次数据并且经常用于绘制。
接下来创建索引缓冲区:
glGenBuffers(1, &mIndexBuffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, mIndexBuffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, numIndices * sizeof(unsigned int), indices, GL_STATIC_DRAW);
最后,必须指定顶点布局:
// 指定顶点属性
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(float) * 3, 0)
第4个参数只与整数相关,所以设置为
GL_FALSE
。
析构和激活相对简单:
VertexArray::~VertexArray()
{
glDeleteBuffers(1, &mVertexBuffer);
glDeleteBuffers(1, &mIndexBuffer);
glDeleteVertexArrays(1, &mVertexArray);
}
void VertexArray::SetActive()
{
glBindVertexArray(mVertexArray);
}
接下来在
Game
中初始化
VertexArray
,并存在成员变量
mSpriteVerts
中:
private:
void CreateSpriteVerts();
VertexArray* mSpriteVerts;
CreateSpriteVerts
具体实现:
void Game::CreateSpriteVerts()
{
float vertexBuffer[] = {
-0.5f, 0.5f, 0.0f, // 0
0.5f,0.5f, 0.0f, // 1
0.5f,-0.5f, 0.0f, // 2
-0.5f,-0.5f, 0.0f // 3
};
unsigned int indexBuffer[] = {
0, 1, 2,
2, 3, 0
};
mSpriteVerts = new VertexArray(vertexBuffer, 4, indexBuffer, 6);
}
记得在
Game::Initialize()
中调用
CreateSpriteVerts()
,并且在
Shutdown
中
delete
。
Shader
在现代图形管线中,不能简单的使用顶点和索引缓冲区来绘制三角形,而要指定如何绘制,比如使用固定的颜色,还是特定的纹理,需不需要光照计算。
这些操作显然没有一个一刀切的方法,因此 OpenGL 提供了一套编程接口定制过程——Shader 程序。这些 Shader 程序执行特定的任务,而且都是独立的(各自有
main
方法,适合 GPU 进行并行计算。
Shader 程序使用的不是 C++ 语言,而是一种特定的语言,OpenGL 使用的是一种类似 C 语言的 GLSL 语言。除了 GLSL,常见的着色器语言还有 Direct 的 HLSL(High Level Shading Language)、NVIDIA 的 CG(C for Graphics)。
Shader 是独立的程序,会写在特定的文件中,C++ 代码告诉 OpenGL 什么时候进行加载和编译。Shader 的种类有很多,但是有两个是必须实现的,那就是顶点着色器和片段(像素)着色器。
顶点着色器
顶点着色器会在每次绘制模型的每个顶点的时候调用。三角形有三个顶点,可以把顶点着色器看作每个三角形运行三次。顶点着色器可以修改这些顶点属性。如果使用了顶点和索引缓冲,有一些三角形共享了顶点,那么调用的频率会降低一些。
片段着色器
三角形的顶点经过顶点着色器后,OpenGL 必须确定三角形对应的像素。将三角形转换为像素的过程称为
栅格化
(rasterization)。栅格化的算法有很多,目前图形硬件已经内置了栅格化了。
片段着色器(像素着色器)的工作就是确定每个像素的颜色,因此片段着色器对每个像素至少运行一次。颜色的确定可能涉及很多方面,包括纹理、材料、颜色等等。如果存在光照,片段着色器可能还要进行光照的计算。
基础的 Shader
Shader 程序当然可以硬编码成字符串,但是分开肯定是更好的选择。在代码文件夹下创建一个
Shaders
文件夹,用来存储 Shader 程序文件。新建一个 Basic.vert 文件来包含顶点着色器代码(再说一次,不是 C++ 代码):
#version 330
in vec3 inPosition;
void main()
{
gl_Position = vec4(inPosition, 1.0);
}
每个 GLSL 都必须指定 OpenGL 版本,这里是 OpenGL 3.3。
inPosition
的类型是
vec3
。类似 C 语言,每个 Shader 同样必须要有
main
作为执行入口。GLSL 使用全局变量来作为 Shader 的输出。在这里使用的是内建的一个变量
gl_Position
来存储着色器的输出。
在这里输入直接复制到输出,可以注意到
gl_Position
是
vec4
,而不是
vec3
。除了熟悉的
x
x
x
、
y
y
y
、
z
z
z
,还有一个
w
w
w
分量,主要的功能作为齐次运算的时候使用,基本上总是1.0。
新建一个 Basic.frag 文件来存储片段着色器代码。片段着色器计算一个当前像素的输出颜色。在这里,直接硬编码成蓝色。和顶点着色器一样,也需要指定版本,换句话说,开头总是
#version
。
#version 330
out vec4 outColor;
void main()
{
// RGBA
outColor = vec4(0.0, 0.0, 1.0, 1.0);
}
加载 Shader
有了独立的 Shader 文件之后,就可以通过 C++ 加载进来,适时告诉 OpenGL。我们需要有以下几步:
- 加载和编译顶点着色器;
- 加载和编译片段着色器;
- 链接两个 Shader 程序,组成一个 Shader 程序。
我们同样可以把这个过程封装到一个
Shader
类中,在 Shader.hpp 中写入:
class Shader
{
public:
Shader();
~Shader();
// 加载给定名称的顶点/片段着色器
bool Load(const std::string& vertName, const std::string& fragName);
void Unload();
// 作为激活的 Shader 程序
void SetActive();
private:
// 尝试编译指定的 Shader
bool CompileShader(const std::string& fileName,
GLenum shaderType,
GLuint& outShader);
// 测试是否成功编译
bool IsCompiled(GLuint shader);
// 测试顶点/片段着色器的连接
bool IsValidProgram();
private:
// 存储Shader对象的ID
GLuint mVertexShader;
GLuint mFragShader;
GLuint mShaderProgram;
};
Shader 对象的 ID 类似顶点缓冲的 ID 和索引缓冲的 ID。
GLuint
是 OpenGL 版本的
unsigned int
的简化。
编译 Shader 的
CompileShader
的大概有这几步。第一,使用
ifstream
加载 Shader 程序文件,写入到一个
string
变量中,然后通过
c_str
获得 C 风格字符串的指针。第二,使用
glCreateShader
函数创建一个 OpenGL Shader 对象。Shader 的类型由
GL_VERTEX_SHADER
和
GL_FRAGMENT_SHADER
指定。
bool Shader::CompileShader(const std::string& fileName,
GLenum shaderType,
GLuint& outShader)
{
// 打开文件
std::ifstream shaderFile(fileName);
if (shaderFile.is_open())
{
std::stringstream sstream;
sstream << shaderFile.rdbuf();
std::string contents = sstream.str();
const char* contentsChar = contents.c_str();
// 创建指定类型的 Shader
outShader = glCreateShader(shaderType);
// 设置代码内容并尝试编译
glShaderSource(outShader, 1, &(contentsChar), nullptr);
glCompileShader(outShader);
if (!IsCompiled(outShader))
{
SDL_Log("编译 shader 失败 %s", fileName.c_str());
return false;
}
}
else
{
SDL_Log("Shader 文件没找到: %s", fileName.c_str());
return false;
}
return true;
}
验证 Shader 是否编译核心是通过
glGetShaderiv
:
bool Shader::IsCompiled(GLuint shader)
{
GLint status;
// 询问编译状态
glGetShaderiv(shader, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE)
{
char buffer[512];
memset(buffer, 0, 512);
glGetShaderInfoLog(shader, 511, nullptr, buffer);
SDL_Log("GLSL 编译失败:\n%s", buffer);
return false;
}
return true;
}
是否是有效的 Shader 的判断类似
IsCompiled
,只不过核心函数是
glGetProgramiv
:
bool Shader::IsValidProgram()
{
GLint status;
// 询问连接状态
glGetProgramiv(mShaderProgram, GL_LINK_STATUS, &status);
if (status != GL_TRUE)
{
char buffer[512];
memset(buffer, 0, 512);
glGetProgramInfoLog(mShaderProgram, 511, nullptr, buffer);
SDL_Log("GLSL 连接状态:\n%s", buffer);
return false;
}
return true;
}
至于
Load
,只不过把这个过程统一了起来:
bool Shader::Load(const std::string& vertName, const std::string& fragName)
{
// 编译顶点和像素着色器
if (!CompileShader(vertName,
GL_VERTEX_SHADER,
mVertexShader) ||
!CompileShader(fragName,
GL_FRAGMENT_SHADER,
mFragShader))
{
return false;
}
// 将顶点和片段着色器连接
mShaderProgram = glCreateProgram();
glAttachShader(mShaderProgram, mVertexShader);
glAttachShader(mShaderProgram, mFragShader);
glLinkProgram(mShaderProgram);
// 验证连接
if (!IsValidProgram())
{
return false;
}
return true;
}
void Shader::Unload()
{
glDeleteProgram(mShaderProgram);
glDeleteShader(mVertexShader);
glDeleteShader(mFragShader);
}
在 Game 中使用 Shader
有了
Shader
类,可以把它作为成员加入到
Game
中:
Shader* mSpriteShader;
添加一个
LoadShaders()
函数来加载和编译 Shader:
bool Game::LoadShaders()
{
mSpriteShader = new Shader();
if (!mSpriteShader->Load("Shaders/Basic.vert", "Shaders/Basic.frag"))
{
return false;
}
mSpriteShader->SetActive();
return true;
}
记得在
Game::Shutdown
中释放:
mSpriteShader->Unload();
delete mSpriteShader;
在初始化中调用:
if (!LoadShaders())
{
SDL_Log("加载 shaders 失败");
return false;
}
绘制多边形
到了这里,就剩下绘制的操作了:
glDrawElements(
GL_TRIANGLES, // 图元
6, // 索引数量
GL_UNSIGNED_INT, // 索引类型
nullptr // 经常是 nullptr
);
调用
glDrawElements
需要激活顶点序列对象和激活 Shader。因此每帧之前都要进行激活操作。在
Game::GenerateOutput
中激活::
void Game::GenerateOutput()
{
// 灰色
glClearColor(0.86f, 0.86f, 0.86f, 1.0f);
// 清除颜色缓冲区
glClear(GL_COLOR_BUFFER_BIT);
mSpriteShader->SetActive();
mSpriteVerts->SetActive();
glDrawElements(
GL_TRIANGLES, // 图元
6, // 索引数量
GL_UNSIGNED_INT, // 索引类型
nullptr // 经常是 nullptr
);
// 交换缓冲区
SDL_GL_SwapWindow(mWindow);
}
终于可以输出矩形了:
当然在
GenerateOutput
中自己绘制其实不好,按照组件化的思想,应该在
SpriteComponent
中绘制。
小结
到了这里,可以说我们就到了 2D 和 3D分岔口了,后面就要进入 3D 的世界了。然而,我觉得还是需要补充一些 3D 的数学知识(2D的前面有了)才能继续进行下去。