Hazel学习笔记:Renderer2D部分
最近一段时间一直想拓展一下个人的技术栈,想学习一些图形学的知识,毕竟目前在做客户端相关的开发,学一些图形相关的知识应该也没有什么坏处,从我过去一段时间的工作经历来看:只要是学过的芝士,总有一天会用得上的。
正好翻B站看到了有人搬运了TheCherno大佬的Hazel游戏引擎教程,就决定从这里入手学习,了解OpenGL和一些图形学的基础知识,并将一些芝士整理在此, 对于引擎基础部分的搭建就先略过(比如:日志部分(log)、项目搭建(Premake)、事件、窗口等,后面可以单开一节整理Premake和性能分析部分),重点整理他对OpenGL的封装部分,以及后面的Renderer2D的抽象。
整体架构
OpenGL封装
Hazel从OpenGL的“Hello World”入手,逐步减少原生OpenGL代码的编写,所以我们首先需要知道,原生OpenGL绘制一个三角形都需要哪些步骤?
首先,需要准备一些顶点数据:
1
2
3
4
5
6
| float vertices[] = {
// 位置 // 颜色
-0.5f, -0.5f, 0.0f, 1.0f, 0.0f, 0.0f,
0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f,
0.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f
};
|
准备VAO/VBO:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| unsigned int vao, vbo;
glGenVertexArrays(1, &vao);
glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// 位置属性,location = 0
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*)0);
// 颜色属性,location = 1
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE,
6 * sizeof(float), (void*)(3 * sizeof(float)));
glBindVertexArray(0);
|
准备着色器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| /* 顶点着色器 */
#version 330 core
layout(location = 0) in vec3 a_Position;
layout(location = 1) in vec3 a_Color;
out vec3 v_Color;
uniform mat4 u_ViewProjection;
uniform mat4 u_Model;
void main()
{
v_Color = a_Color;
gl_Position = u_ViewProjection * u_Model * vec4(a_Position, 1.0);
}
/* 片段着色器 */
#version 330 core
in vec3 v_Color;
out vec4 FragColor;
void main()
{
FragColor = vec4(v_Color, 1.0);
}
|
然后通过glCreateShader / glCompileShader / glAttachShader / glLinkProgram这一套流程将着色器程序编译成GPU可以执行的Program
最后在渲染循环中绘制图形:
1
2
3
4
5
6
7
8
9
10
| // 清屏
glClearColor(0.1f, 0.1f, 0.1f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 绑定程序、VAO
glUseProgram(shaderProgram);
glBindVertexArray(vao);
// Draw
glDrawArrays(GL_TRIANGLES, 0, 3);
// Swap
SwapBuffers();
|

这套流程非常繁琐且复用性非常差,Hazel就从这里入手,开始封装整个绘制流程。
整体框架设计如下:

希望能够抽象出一个RendererAPI,对所有用到的OpenGL接口进行封装,做到“零OpenGL”。
Hazel这里使用了“静态工厂” + 多态的方式进行封装,可以看到,需要绘制一个三角形,我们都需要:
- 顶点缓冲(
VertexBuffer) - 索引缓冲(
IndexBuffer) - 顶点数组(
VertexArray = VAO) - 着色器(
Shader)
当然还有后面会用到纹理(Texture2D)
VBO、IBO

首先是顶点缓冲和索引缓冲,Hazel抽象如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // 顶点缓冲
class VertexBuffer
{
public:
virtual ~VertexBuffer() {}
virtual void Bind() const = 0;
virtual void Unbind() const = 0;
virtual void SetData(const void* data, uint32_t size) = 0;
virtual const BufferLayout& GetLayout() const = 0;
virtual void SetLayout(const BufferLayout& layout) = 0;
static Ref<VertexBuffer> Create(uint32_t size);
static Ref<VertexBuffer> Create(float* vertices, uint32_t size);
};
|
对于索引缓冲,只是去掉了BufferLayout部分和SetData部分,因为一般情况下只有VBO的数据可能会变动,这里的BufferLayout是对VBO中的layout数据进行的封装,用来描述一个顶点数据在显存中是如何排布的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| struct BufferElement
{
std::string Name;
ShaderDataType Type;
uint32_t Size;
uint32_t Offset;
bool Normalized;
}
class BufferLayout
{
public:
BufferLayout() {}
BufferLayout(const std::initializer_list<BufferElement>& elements)
: m_Elements(elements) {}
inline uint32_t GetStride() const { return m_Stride; }
inline const std::vector<BufferElement>& GetElements() const { return m_Elements; }
private:
std::vector<BufferElement> m_Elements; // layout
uint32_t m_Stride = 0; // 步长
};
|
最终的用法如下:
1
2
3
4
5
6
| vertexBuffer->SetLayout({
{ Float3, "a_Position" }, // 3 * 4 = 12 bytes
{ Float4, "a_Color" }, // 4 * 4 = 16 bytes
{ Float2, "a_TexCoord" }, // 2 * 4 = 8 bytes
{ Float, "a_TexIndex" } // 1 * 4 = 4 bytes
});
|
| 字段 | Type | Size(字节) | Offset(字节) |
|---|
| a_Position | Float3 | 12 | 0 |
| a_Color | Float4 | 16 | 12 |
| a_TexCoord | Float2 | 8 | 28 |
| a_TexIndex | Float | 4 | 36 |
最终 m_Stride = 12 + 16 + 8 + 4 = 40 字节。 这意味着:每个顶点占 40 字节,下一顶点的数据从当前顶点地址 + 40 开始。
最终这部分会用来代替繁琐的:
1
2
3
4
5
6
7
| glEnableVertexAttribArray(m_VertexBufferIndex);
glVertexAttribPointer(m_VertexBufferIndex, // bufferIndex
element.GetComponentCount(), // 元素长度
ShaderDataTypeToOpenGLBaseType(element.Type), // 数据类型
element.Normalized ? GL_TRUE : GL_FALSE, // 标准化
layout.GetStride(), // 步长
(const void*)(uint32_t)element.Offset); // 偏移量
|
这些数据会在VBO初始化的时候,绑定到当前VAO上。
VAO
VAO 是“顶点输入状态”的一个打包快照,刚才我们对VBO的Layout设置最后就会绑定到某个VAO上,Hazel将VAO抽象为了VertexArray:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class VertexArray
{
public:
virtual ~VertexArray() = default;
virtual void Bind() const = 0;
virtual void Unbind() const = 0;
virtual void AddVertexBuffer(const Ref<VertexBuffer>& vertexBuffer) = 0;
virtual void SetIndexBuffer(const Ref<IndexBuffer>& indexBuffer) = 0;
virtual const std::vector<Ref<VertexBuffer>>& GetVertexBuffers() const = 0;
virtual const Ref<IndexBuffer>& GetIndexBuffer() const = 0;
static Ref<VertexArray> Create();
};
|
Hazel认为:一个VAO可能绑定多个VBO,但可能只有一个IBO。作为快照,VAO只需要暴露重要的Bind/UnBind和设置VBO/IBO的接口。而在AddVertexBuffer的时候就会用到刚才提到的BufferLayout:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| void OpenGLVertexArray::AddVertexBuffer(const Ref<VertexBuffer>& vertexBuffer)
{
glBindVertexArray(m_RendererID);
vertexBuffer->Bind();
const auto& layout = vertexBuffer->GetLayout();
uint32_t index = 0;
for (const auto& element : layout)
{
glEnableVertexAttribArray(index);
glVertexAttribPointer(
index,
element.GetComponentCount(),
ShaderDataTypeToOpenGLBaseType(element.Type),
element.Normalized ? GL_TRUE : GL_FALSE,
layout.GetStride(),
(const void*)(uintptr_t)element.Offset
);
index++;
}
m_VertexBuffers.push_back(vertexBuffer);
}
|
现在我们的VAO配置已经简化到:
1
2
3
4
5
6
7
8
9
10
11
12
13
| auto quadVA = VertexArray::Create();
auto quadVB = VertexBuffer::Create(maxSize);
quadVB->SetLayout({
{ ShaderDataType::Float3, "a_Position" },
{ ShaderDataType::Float4, "a_Color" },
{ ShaderDataType::Float2, "a_TexCoord" },
{ ShaderDataType::Float, "a_TexIndex" },
{ ShaderDataType::Float, "a_TilingFactor" }
});
quadVA->AddVertexBuffer(quadVB);
quadVA->SetIndexBuffer(quadIB);
|
这种程度了。
Shader
在前面已经整理了顶点布局(BufferLayout)、VAO(VertexArray)等内容,它们解决的是**“数据长什么样、怎么放进显存”**的问题。
要让这些数据真正参与光栅化,仍然需要一个关键组件:Shader。
从 OpenGL 的视角看,Shader 是一段在 GPU 上运行的小程序:
- 顶点着色器负责“如何把顶点从模型空间变换到裁剪空间”;
- 片段着色器负责“屏幕上每一个像素点最终是什么颜色”。
对于Shader的封装,也就是对:“glCreateShader / glCompileShader / glLinkProgram / glUniformXXX”的封装。
Hazel 将Shader分成了如下三层:
ShaderOpenGLShaderShaderLibrary
封装如下:
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 Shader
{
public:
virtual ~Shader() = default;
virtual void Bind() const = 0;
virtual void Unbind() const = 0;
// Uniform 设置接口
virtual void SetInt(const std::string& name, int value) = 0;
virtual void SetFloat(const std::string& name, float value) = 0;
virtual void SetFloat2(const std::string& name, const glm::vec2& value) = 0;
virtual void SetFloat3(const std::string& name, const glm::vec3& value) = 0;
virtual void SetFloat4(const std::string& name, const glm::vec4& value) = 0;
virtual void SetMat4(const std::string& name, const glm::mat4& value) = 0;
virtual const std::string& GetName() const = 0;
// 静态工厂:从文件 / 源码创建
static Ref<Shader> Create(const std::string& filepath);
static Ref<Shader> Create(const std::string& name,
const std::string& vertexSrc,
const std::string& fragmentSrc);
};
|
主要提供绑定Program、设置Uniform变量和加载Shader的能力,抽象后只需要:
1
2
3
| TextureShader = Shader::Create("assets/shaders/Texture.glsl");
TextureShader->Bind();
TextureShader->SetIntArray("u_Textures", value, slot);
|
就能完成一个着色器的加载和对Uniform的设置。并且提供了从文件读取着色器代码的能力
Texture
这是最后一部分抽象,要在 OpenGL 里创建一张 2D 纹理,通常会写出类似如下代码:
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
| // 1. 生成并绑定纹理对象
GLuint tex;
glGenTextures(1, &tex);
glBindTexture(GL_TEXTURE_2D, tex);
// 2. 设置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// 3. 把像素数据上传到 GPU
int width, height, channels;
stbi_uc* data = stbi_load("assets/textures/Checkerboard.png",
&width, &height, &channels, 0);
GLenum internalFormat = channels == 4 ? GL_RGBA8 : GL_RGB8;
GLenum dataFormat = channels == 4 ? GL_RGBA : GL_RGB;
glTexImage2D(GL_TEXTURE_2D, 0,
internalFormat,
width, height, 0,
dataFormat,
GL_UNSIGNED_BYTE,
data);
// 可选:生成 mipmap
glGenerateMipmap(GL_TEXTURE_2D);
stbi_image_free(data);
|
真正绘制时,还需要选择纹理单元(slot)并绑定:
1
2
3
4
5
6
| glBindTextureUnit(slot, m_RendererID);
// Shader 里
// uniform sampler2D u_Texture;
// C++ 端设置
glUniform1i(location_u_Texture, slot);
|
而在 Hazel 中,纹理的抽象接口大致如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| class Texture
{
public:
virtual ~Texture() = default;
virtual uint32_t GetWidth() const = 0;
virtual uint32_t GetHeight() const = 0;
virtual void Bind(uint32_t slot = 0) const = 0;
};
class Texture2D : public Texture
{
public:
static Ref<Texture2D> Create(uint32_t width, uint32_t height);
static Ref<Texture2D> Create(const std::string& path);
};
|
主要几点:
Texture 抽象出所有纹理共有的能力:Texture2D 是具体的二维纹理类型,提供两个静态工厂方法:- 按尺寸创建一张空纹理(例如渲染目标或纯色纹理)
- 从文件路径加载一张纹理(常见的 PNG、JPEG)
在应用层,创建纹理的代码就简化为:
1
2
3
| Texture2D* checkerboard = Texture2D::Create("assets/textures/xxx.png");
// 渲染前调用,将Texture绑定到片段着色器
checkerboard->Bind(slot);
|
至此,我们就可以将原本繁琐的调用代码转化成如下方式:
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
| // VAO
m_VertexArray = Hazel::VertexArray::Create();
float vertices[3 * 7] = {
-0.5f, -0.5f, 0.0f, 0.8f, 0.2f, 0.8f, 1.0f,
0.5f, -0.5f, 0.0f, 0.2f, 0.3f, 0.8f, 1.0f,
0.0f, 0.5f, 0.0f, 0.8f, 0.8f, 0.2f, 1.0f
};
// VBO
std::shared_ptr<Hazel::VertexBuffer> vertexBuffer;
vertexBuffer = (Hazel::VertexBuffer::Create(vertices, sizeof(vertices)));
// Layout
Hazel::BufferLayout layout = {
{ Hazel::ShaderDataType::Float3, "a_Position" },
{ Hazel::ShaderDataType::Float4, "a_Color" }
};
vertexBuffer->SetLayout(layout);
m_VertexArray->AddVertexBuffer(vertexBuffer);
// IBO
uint32_t indices[3] = { 0, 1, 2 };
std::shared_ptr<Hazel::IndexBuffer> indexBuffer;
indexBuffer = (Hazel::IndexBuffer::Create(indices, sizeof(indices) / sizeof(uint32_t)));
m_VertexArray->SetIndexBuffer(indexBuffer);
// Shader
m_Shader = Hazel::Shader::Create("xxx.glsl");
m_Shader->Bind();
m_Shader->UploadUniformInt("u_Texture", 0);
// Update
shader->Bind();
vertexArray->Bind();
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, nullptr); // 封装
|
Camera
Renderer2D
渲染逻辑

整体使用流程:
