Xcode与C++之游戏开发:OpenGL

  • Post author:
  • Post category:其他


上一篇:

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 环境了,这也意味着我们把渲染器换成了 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。我们需要有以下几步:

  1. 加载和编译顶点着色器;
  2. 加载和编译片段着色器;
  3. 链接两个 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的前面有了)才能继续进行下去。



版权声明:本文为guyu2019原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。