Featured image of post Hazel学习笔记:Renderer2D(持续更新)

Hazel学习笔记:Renderer2D(持续更新)

Hazel学习内容整理

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

VAO、VBO

首先是顶点缓冲和索引缓冲,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
});
字段TypeSize(字节)Offset(字节)
a_PositionFloat3120
a_ColorFloat41612
a_TexCoordFloat2828
a_TexIndexFloat436

最终 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

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分成了如下三层:

  1. Shader
  2. OpenGLShader
  3. ShaderLibrary

封装如下:

 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 抽象出所有纹理共有的能力:
    • 查询尺寸
    • 绑定到指定纹理单元 Bind(slot)
  • 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

渲染逻辑

渲染逻辑

整体使用流程:

调用流程

Licensed under CC BY-NC-SA 4.0
最后更新于 Nov 25, 2025 00:00 CST
comments powered by Disqus

本博客已稳定运行 小时 分钟
共发表 7 篇文章 · 总计 27.20 k 字
本站总访问量