WAYNETS.ORG

Game and Program

抽象渲染接口

作者:

发表于

Context Polling System Post-Processing Renderer

1. 渲染上下文(Rendering Context)

渲染上下文是构建渲染接口时最基础、最关键的一步,它负责绑定图形 API 到窗口系统、管理渲染状态、初始化图形设备等底层资源

渲染上下文就是你和 GPU 沟通的“通道”,你必须先建立这个通道,才能开始绘制任何东西。

渲染上下文应该满足以下功能:

  • 与窗口句柄绑定:将 OpenGL/Vulkan/DirectX 的输出绑定到一个平台窗口(如 Windows HWND、GLFW 窗口等)。
  • 初始化图形API设备:加载 OpenGL 函数(如使用 GLAD)、创建 Vulkan 实例与设备。
  • 负责上下文生命周期管理:创建、切换、销毁、失效处理。

通用的上下文虚基类:

class GraphicsContext
{
public:
	virtual void Init() = 0;
	virtual void SwapBuffers() = 0;
};

对于一个渲染接口而言,他需要满足多种不同的图形API需求,然而不同的图形API所提供的创建渲染上下文的方式有所不同,所以对于每个图形API,都要有自己的上下文类。

OpenGL的上下文类:

class OpenGLContext : public GraphicsContext
{
public:
	OpenGLContext(GLFWwindow* windowHandle);

	virtual void Init() override;
	virtual void SwapBuffers() override;
private:
	GLFWwindow* m_WindowHandle;
};

实现:

void OpenGLContext::Init()
{
	glfwMakeContextCurrent(m_WindowHandle);
	int status = gladLoadGLLoader((GLADloadproc)glfwGetProcAddress);
}

void OpenGLContext::SwapBuffers()
{
	glfwSwapBuffers(m_WindowHandle);
}

对于OpenGL的上下文类OpenGLContext而言,它需要接收一个已被创建的窗口句柄,然后在初始化函数Init()中设置上下文,并且提供一个使用双缓冲(Double Buffering)渲染的函数SwapBuffers()。

2. Shader

使用一个单独的类Shader对顶点着色器和片段着色器进行编译。

创建着色器并编译:

GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
const GLchar* source = vertexSrc.c_str();		
glShaderSource(vertexShader, 1, &source, 0);		
glCompileShader(vertexShader);	

GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
source = fragmentSrc.c_str();
glShaderSource(fragmentShader, 1, &source, 0);
glCompileShader(fragmentShader);			

将顶点着色器和片段着色器编译成功后,将它们链接在一起形成一个程序(Object):

m_RendererID = glCreateProgram();
GLuint program = m_RendererID;

glAttachShader(program, vertexShader);
glAttachShader(program, fragmentShader);

glLinkProgram(program);

glDetachShader(program, vertexShader);
glDetachShader(program, fragmentShader);

在类Application中创建类Shader的Unique Pointer变量m_Shader,然后使用函数reset()初始化:

std::unique_ptr<Shader> m_Shader;
m_Shader.reset(new Shader(vertexSrc, fragmentSrc));

3. Buffer

目前为止,我们需要两种缓冲区:顶点缓冲区(Vertex Buffer)和索引缓冲区(Index Buffer)。

顶点缓冲区:存储顶点属性(如位置、颜色、法线、纹理坐标等)的连续内存。

索引缓冲区:存储画图顺序,指示如何使用顶点缓冲区中的数据组成图元(如三角形)。

举个例子:比如我们要画一个正方形,而正方形是由两个三角形组成的,所以画一个正方形就变成了画两个三角形并将它们“拼装”在一起。

我们在数组vertices[]中传入4行顶点,用于表示正方形的顶点位置。然后使用数组indices[]中传入2行顶点,每一行3个数字,表示:每一个三角形使用哪个顶点

float vertices[] = {
    // positions       
    -0.5f, -0.5f, 0.0f,  
     0.5f, -0.5f, 0.0f,  
     0.5f,  0.5f, 0.0f,  
    -0.5f,  0.5f, 0.0f   
};

unsigned int indices[] = {
    0, 1, 2,
    2, 3, 0
};

接下来让我们聊一聊在抽象渲染接口中该如何定义Buffer。

为了让游戏引擎支持多种图形API,仅仅创建一个Buffer类是不够的,因为每一种图形API创建Buffer的方式都不一样,因此我们需要为每一种图形API都创建一个单独的Buffer类,如:类OpenGLBuffer。

OpenGLVertexBuffer::OpenGLVertexBuffer(float* vertices, uint32_t size)
{
	glCreateBuffers(1, &m_RendererID);
	glBindBuffer(GL_ARRAY_BUFFER, m_RendererID);
	glBufferData(GL_ARRAY_BUFFER, size, vertices, GL_STATIC_DRAW);
}

OpenGLVertexBuffer::~OpenGLVertexBuffer()
{
	glDeleteBuffers(1, & m_RendererID);
}

void OpenGLVertexBuffer::Bind() const
{
	glBindBuffer(GL_ARRAY_BUFFER, m_RendererID);
}

void OpenGLVertexBuffer::Unbind() const
{
	glBindBuffer(GL_ARRAY_BUFFER, 0);
}

在类Application中,依旧使用Unique Pointer来创建Vertex Buffer和Index Buffer的对象,并且使用函数reset()来初始化。

std::unique_ptr<VertexBuffer> m_VertexBuffer;
std::unique_ptr<IndexBuffer> m_IndexBuffer;

m_VertexBuffer.reset(VertexBuffer::Create(vertices, sizeof(vertices)));
m_IndexBuffer.reset(IndexBuffer::Create(indices, sizeof(indices) / sizeof(uint32_t)));

4. Buffer Layout

顶点缓冲布局系统是渲染抽象接口中很重要的部分,它用于描述:一组顶点数据在内存中的布局结构,从而使渲染API(如 OpenGL)能正确解释和读取顶点缓冲数据。

对于一个传入的顶点数据而言,它可能在同一个节点中表达了多个内容,比图:位置,颜色,归一化等。所以使用一个类Layout来解释这个顶点数据的各个数字都代表什么非常重要。

比如,我们定义一个Vertex Array:

float vertices[3 * 7] = {
        //X      Y      Z    R     G      B     A
 	-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
};

如果我们将它直接传入GPU,然后用OpenGL语言来解析它(比如:glVertexAttribPointer),显然它是无法得知这三组数字的含义的,因为它可能代表位置,也可能代表颜色。况且,每一类数据所使用的数据类型也不同:位置需要使用vec3来表示x, y, z三个值,而颜色则需要vec4来表示R, G, B, A。

使用一个Layout类可以模块化地自动解决这个问题,而不再需要程序员手动输入类型。

class BufferLayout
{
public:
	BufferLayout() {}

	BufferLayout(const std::initializer_list<BufferElement>& elements) : m_Elements(elements)
	{
		CalculateOffsetsAndStride();
	}

private:
	void CalculateOffsetsAndStride()
	{
		uint32_t offset = 0;
		m_Stride = 0;
		for (auto& element : m_Elements)
		{
			element.Offset = offset;
			offset += element.Size;
			m_Stride += element.Size;
		}
	}
private:
	std::vector<BufferElement> m_Elements;
	uint32_t m_Stride = 0;
};

在上述代码中,使用数据结构vector来记录结构体Buffer Element,每一个Buffer Element都保存着数据类型和数据名称,以用于步长(stride)等必要信息的计算。

定义并设置一个Vertex Buffer的Layout信息:

BufferLayout layout = {
	{ ShaderDataType::Float3, "a_Position" },
	{ ShaderDataType::Float4, "a_Color" }
};
m_VertexBuffer->SetLayout(layout);

5. Vertex Array

6. 渲染器抽象(Renderer Abstraction)

渲染器抽象是图形引擎架构中最关键的组成部分之一。由于不同图形 API(如 OpenGL、DirectX、Vulkan、Metal)在渲染流程、资源绑定、命令执行等方面存在显著差异,我们需要对底层图形 API 的调用进行统一封装。通过定义一个通用的、平台无关的渲染接口(例如 RendererAPI),并为每种图形 API 提供具体实现,我们可以在顶层通过一致的逻辑描述渲染任务,底层再根据当前平台自动转换为相应图形 API 的指令,从而实现跨平台、可扩展的渲染架构设计。这种设计不仅提高了代码可维护性与可移植性,也为后续支持多后端渲染器(如从 OpenGL 迁移到 Vulkan)奠定了良好基础。

渲染器类Renderer:

class Renderer
{
public:
	static void BeginScene();
	static void EndScene();

	static void Submit(const std::shared_ptr<VertexArray>& vertexArray);
	inline static API GetAPI() { return RendererAPI::GetAPI(); }
};
  • 函数BeginScene():每一帧的入口函数,通常会在这里设置摄像机视图投影矩阵;设置全局 shader uniforms,比如光照、时间、屏幕大小等;和初始化帧缓冲、清屏操作等。
  • 函数EndScene():结束当前帧渲染的函数,通常用于提交绘制命令队列,或一些收尾工作(比如交换缓冲区)。
  • 函数Submit():真正执行渲染的地方,将顶层逻辑转换为底层指令。他会绑定对应的VAO,告诉底层图形API你要画什么并且调用该API的绘图代码。

渲染接口类RendererAPI:

class RendererAPI
{
public:
	virtual void SetClearColor(const glm::vec4& color) = 0;
	virtual void Clear() = 0;

	virtual void DrawIndexed(const std::shared_ptr<VertexArray>& vertexArray) = 0;

	inline static API GetAPI() { return s_API; }
private:
	static API s_API;
};

这是一个抽象基类,需要建立额外的子类来实现具体逻辑,比如:OpenGLRendererAPI,里面封装的就是基于OpenGL的图形语言,在未来如果支持Vulkan或DirectX等接口,也需要编写相应的RendererAPI类。

在类OpenGLRendererAPI中,可以这样实现底层逻辑:

void OpenGLRendererAPI::SetClearColor(const glm::vec4& color)
{
	glClearColor(color.r, color.g, color.b, color.a);
}

void OpenGLRendererAPI::Clear()
{
	glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
}

void OpenGLRendererAPI::DrawIndexed(const std::shared_ptr<VertexArray>& vertexArray)
{
	glDrawElements(GL_TRIANGLES, vertexArray->GetIndexBuffer()->GetCount(), GL_UNSIGNED_INT, nullptr);
}

那么问题来了,类Renderer实现了渲染器基本功能,类Renderer负责实现各个图形API的绘制流程逻辑,游戏引擎怎么知道自己需要使用哪个图形API呢?

这就需要另一个类RenderCommand来负责:

class RenderCommand
{
public:
	inline static void SetClearColor(const glm::vec4& color)
	{
		s_RendererAPI->SetClearColor(color);
	}

	inline static void Clear()
	{
		s_RendererAPI->Clear();
	}

	inline static void DrawIndexed(const std::shared_ptr<VertexArray>& vertexArray)
	{
		s_RendererAPI->DrawIndexed(vertexArray);
	}
private:
	static RendererAPI* s_RendererAPI;
};

在这个类中,我们使用多态定义了RendererAPI的具体实现方式,并且调用统一接口来完成背景颜色处理,图形的绘制等功能,从而达到跨平台和高度模块化的要求。

Leave a comment