IDRenderer的设计与实现
1. IDRenderer的设计与实现
IDGameEngine 分两层:引擎本体 ID 负责场景、ECS、渲染管线这些"上层逻辑";而所有真正碰 GPU 的活,都收拢在一个独立的渲染后端库 IDLib/IDRenderer 里。这篇笔记从底往上梳理 IDRenderer:先看它如何封装 GPU 资源,再看它如何暴露渲染命令(RenderCommand),顺便记录一些设计上的取舍。
1.1 引擎中的位置
IDRenderer 是一个动态库,夹在引擎本体和 OpenGL 之间:
┌────────────────────────────────────────────┐
│ ID(引擎本体) │
│ Renderer / RenderGraph / 各种 RenderPass │
└──────────────────┬─────────────────────────┘
│ 只 include 公开头文件
┌──────────────────▼─────────────────────────┐
│ IDLib/IDRenderer │
│ ┌────────────────┐ ┌─────────────────┐ │
│ │ Resource 资源层 │ │ RenderCommand │ │
│ │ 8 类资源 + 资源池│ │ 渲染命令 + 状态缓存│ │
│ └────────────────┘ └─────────────────┘ │
└──────────────────┬─────────────────────────┘
│ glad
OpenGL
上层代码里看不到任何 gl 前缀的调用——#include "IDRenderer.hpp" 之后,创建纹理、绑定管线、提交绘制,全部通过 IDRenderer 的接口完成。这样做的好处不只是"整洁":如果将来要支持 Vulkan 或者 DirectX,理论上只需要重写 IDLib/IDRenderer 的实现层,引擎本体一行不改。
1.2 资源句柄 ResourceID
OpenGL 的资源句柄是裸的 GLuint——一个纹理的 ID 和一个 shader 的 ID 都是整数,传错了类型编译器也不会报错。IDRenderer 的第一个设计就是用模板把这些句柄类型隔离:
template<std::unsigned_integral T, ResourceType ResType>
class ResourceID
{
friend class ResourceManager<T, ResType>;
public:
ResourceID() : m_id(static_cast<T>(-1)) { }
static constexpr ResourceID invalid_id() { return ResourceID(static_cast<T>(-1)); }
bool is_valid() const { return m_id != static_cast<T>(-1); }
T get_id() const { return m_id; }
private:
ResourceID(T id) : m_id(id) { } // 私有构造:只有 ResourceManager 能造出有效 ID
T m_id;
};
using VertexBufferID = ResourceID<VertexBufferUINT, ResourceType::VertexBuffer>;
using ShaderID = ResourceID<ShaderUINT, ResourceType::Shader>;
using TextureID = ResourceID<TextureUINT, ResourceType::Texture>;
// ... 共 8 种
两个模板参数各有分工:
T:底层整数的宽度。不同资源的数量级差异很大——帧缓冲(FrameBuffer)同时存在十几个就很多了,用uint8_t足够;纹理可能成千上万,得用uint32_t;ResType:资源类型枚举。它不参与运行时数据,纯粹让TextureID和ShaderID成为不同的类型——拿纹理 ID 去调 shader 接口,直接编译报错。
构造函数是私有的,只有友元类 ResourceManager 能创建有效 ID。业务代码拿到的 ID 要么有效、要么是 invalid_id(),不存在"随手造一个合法 ID 乱用"的可能。
由于 T 往往只占 4 字节甚至 1 字节,注释里明确建议:传参用 const ResourceID(值传递)而非 const ResourceID&,避免为了一个小整数多传一次指针。
1.3 ResourceManager 与资源池
每种资源对应一个静态模板类管理器,负责创建、销毁和访问:
template<std::unsigned_integral T, ResourceType ResType>
class ResourceManager
{
public:
ResourceManager() = delete; // 纯静态类,禁止实例化
static TextureID create(const TextureCreateInfo& create_info)
requires TextureRes<T, ResType>; // concept 约束
static void destroy(const TextureID& id)
requires TextureRes<T, ResType>;
// ...
};
using TextureManager = ID::ResourceManager<ID::TextureUINT, ID::ResourceType::Texture>;
using PipelineManager = ID::ResourceManager<ID::PipelineUINT, ID::ResourceType::Pipeline>;
// 共 8 个:VBManager / IBManager / ShaderManager / FBManager / SamplerManager / UBManager ...
requires TextureRes<T, ResType> 是一个 concept 约束:只有 T 和 ResType 的组合恰好匹配(比如 uint32_t + Texture),这个 create 重载才存在。于是 TextureManager::create(...) 只接受 TextureCreateInfo、只返回 TextureID,类型链条从头锁到尾。
资源本体存在实现层的一个池子里:
template<typename ResUINT, typename Res>
struct ResourcePool
{
std::vector<Res> m_pool; // 资源对象数组,下标即 ID
std::unordered_set<ResUINT> m_freed_ids; // 已销毁的槽位,复用用
};
create 时优先从 freed_ids 里捡一个空槽复用,否则 emplace_back 追加;destroy 时调用资源自己的 destroy() 释放 GL 对象,槽位号记入 freed_ids。ID 就是池下标,访问是 O(1) 的数组索引,越界会得到明确的错误日志而不是崩溃。
每种资源的整型宽度在 ResourceType.hpp 里统一声明:
| 资源 | 底层整数 | 说明 |
|---|---|---|
| VertexBuffer | uint32_t |
网格多,量级大 |
| IndexBuffer | uint32_t |
同上 |
| Shader | uint8_t |
编译成本高,总量小 |
| Pipeline | uint16_t |
shader × layout × state 的组合 |
| Texture | uint32_t |
资产纹理数量级大 |
| FrameBuffer | uint8_t |
同时存在的渲染目标极少 |
| Sampler | uint16_t |
采样状态组合有限 |
| UniformBuffer | uint32_t |
—— |
1.4 CreateInfo 模式
创建任何资源都要求传入一个对应的 XXXCreateInfo 描述结构体,且默认构造一律 = delete,逼你在构造时把必要参数填全。以纹理和帧缓冲为例:
struct TextureCreateInfo
{
TextureCreateInfo() = delete;
TextureCreateInfo(uint32_t w, uint32_t h, const void* data = nullptr,
TextureFormat fmt = TextureFormat::RGBA8)
: width(w), height(h), format(fmt), pixel_data(data) { }
uint32_t width = 0;
uint32_t height = 0;
uint32_t layers = 1; // >1 → GL_TEXTURE_2D_ARRAY(CSM 级联阴影用)
bool is_array = false; // 强制 array 形态(1 层也是 array,阴影贴图统一形态用)
TextureFormat format = TextureFormat::RGBA8;
TextureFilter filter = TextureFilter::Linear;
TextureWrap wrap_s = TextureWrap::Repeat;
TextureWrap wrap_t = TextureWrap::Repeat;
const void* pixel_data = nullptr;
bool gen_mips = true;
};
几个值得注意的格式细节:
RGBA16F:半精度浮点,能存超过 1.0 的亮度值,HDR 场景渲染目标用它;SRGB8_ALPHA8:sRGB 内部格式,采样时硬件自动解码到线性空间,省掉 shader 里手动pow;Depth:深度格式,只允许出现在 FrameBuffer 的深度附件,混进颜色附件会在构造时被剔除。
帧缓冲的 CreateInfo 支持 MRT(Multiple Render Targets,一次片元着色输出多个颜色附件)——传一个格式数组即可,最多 8 个附件。延迟渲染的 G-Buffer 就是靠它一次写出 albedo / 世界坐标 / 法线三张贴图:
// 3 附件 G-Buffer:RGBA8 + RGBA16F × 2,外加深度附件
FrameBufferCreateInfo info(w, h,
std::vector<TextureFormat>{ TextureFormat::RGBA8, TextureFormat::RGBA16F, TextureFormat::RGBA16F });
color_formats 为空时创建的是纯深度 FBO(阴影贴图渲染用),has_depth_attachment = false 则是无深度的纯颜色目标(后处理中间缓冲用)。
1.5 顶点布局与管线
顶点数据怎么读,由 VertexBufferLayout 描述——一串 VertexBufferAttribute(名字、类型、是否归一化、偏移),构造时自动累计出 stride(每个顶点的字节数):
VertexBufferLayout l;
l.push(VertexBufferAttribute{ "aPos", AttributeType::Float3, false, 0 });
l.push(VertexBufferAttribute{ "aUV", AttributeType::Float2, false, 12 });
l.push(VertexBufferAttribute{ "aNormal", AttributeType::Float3, false, 20 });
// stride = 32 字节
有了 shader、顶点布局和一组光栅化状态,三者打包成一个 Pipeline:
struct PipelineCreateInfo
{
PipelineCreateInfo(const ShaderID shaderID, const VertexBufferLayout& layout,
const PipelineState& pipelineState = PipelineState());
const ShaderID shaderID; // 着色器
VertexBufferLayout layout; // 顶点布局
PipelineState pipelineState; // 光栅化状态
};
PipelineState 收拢了 OpenGL 里散落各处的全局状态——深度测试、深度写入、比较函数、面剔除、混合开关与混合因子、图元类型。这是 D3D11/Vulkan 风格的状态对象设计:OpenGL 本身没有管线对象,IDRenderer 在实现层用缓存模拟出了"绑定管线 = 一次性切换全部状态"的语义。
实现层还有一个细节:同一个 pipeline 面对不同 VBO 时,需要不同的 VAO(Vertex Array Object,OpenGL 里记录顶点装配状态的对象)。Pipeline 内部维护一张 vbo → vao 的映射表,第一次遇到某个 VBO 时创建对应 VAO 并缓存:
GLuint Pipeline::get_vao(GLuint vbo) const
{
auto it = vaoMap.find(vbo);
if (it != vaoMap.end())
return it->second; // 命中缓存
// 创建 VAO:绑定 VBO,按 layout 逐属性 glVertexAttribPointer ...
}
上层调用 draw_indexed(pipeline, vb, ib) 时完全感知不到 VAO 的存在——这正是封装的目的。
1.6 RenderCommand 命令层
资源层管"创建与持有",RenderCommand 管"每帧怎么用"。它是一组自由函数,按职责分成几类:
| 分类 | 代表接口 | 说明 |
|---|---|---|
| 清屏与视口 | set_clear_color / clear / set_viewport |
每帧起点 |
| 渲染目标 | bind_framebuffer / bind_framebuffer_color / blit_framebuffer 系列 |
绑定 FBO、把附件绑为采样纹理、FBO 间拷贝 |
| 管线 | bind_pipeline / bind_shader / get_pipeline_state |
切换着色与光栅化状态 |
| 绘制 | draw_indexed / draw_arrays |
提交图元 |
| 参数 | set_param 系列 |
写 uniform |
| 资源绑定 | bind_texture / bind_sampler / bind_uniform_buffer |
纹理槽、采样器、UBO 绑定点 |
上层代码用命名空间别名 IDRCmd:: 调用它们:IDRCmd::bind_pipeline(...)、IDRCmd::draw_indexed(...)。
set_param 与 uniform
写 uniform 是最频繁的操作,接口做成了模板重载——标量、Vec2/3/4、矩阵都能直接传:
IDRCmd::set_param(shader, "u_light_count", 3); // int
IDRCmd::set_param(shader, "u_ambient", ambient); // Vec3
IDRCmd::set_param(shader, "u_mvp", mvp); // Mat4
实现层有一处值得注意:glUniform* 系列函数作用于当前绑定的 program,而 set_param 按名字查 location 时用的是目标 shader 自己的 location 表。两者不一致时(比如刚 bind 完 A 管线却往 B shader 的 location 写数),数据会写错位置。所以引擎里所有 Pass 在设置 uniform 前都会先 bind_pipeline 确保当前 program 正确——这是 OpenGL 的历史包袱,封装层只能靠约定规避。
location 的查询结果缓存在 Shader 对象内部,glGetUniformLocation 只在名字第一次出现时调用。
bind_pipeline 的状态缓存
bind_pipeline 并不是每次都真切换状态,实现层维护了一个小型状态缓存:
struct OpenGLCache
{
ShaderID current_shader; // 当前绑定的 shader
PipelineID current_pipeline; // 当前绑定的管线
bool bind_pipeline(const PipelineID pipeline)
{
if (current_pipeline != pipeline)
{
current_pipeline = ...;
bind_shader(管线的 shader);
return false; // 返回 false:状态变化了,调用方需要 apply 光栅化状态
}
else if (管线的 shader != current_shader)
{
bind_shader(管线的 shader);
}
return true; // 管线没变,光栅化状态也不用动
}
};
连续用同一管线画多个物体时(不透明批次的常见情形),深度测试、混合这些 glEnable/glDisable 调用全部被跳过。配合渲染器里"按 pipeline 分组排序"的提交顺序,状态切换次数被压到最低。
一个封装价值的例子:clear 与 depth mask
clear 看似只是 glClear 的包装,但它藏着一个纯 OpenGL 陷阱:
void clear(bool clear_color, bool clear_depth)
{
// 清深度前必须确保 depth mask 可写:
// 上一帧末尾的 Pass(如 Skybox/PostProcess)可能遗留 glDepthMask(GL_FALSE),
// 否则 glClear(GL_DEPTH_BUFFER_BIT) 不会真正清除深度缓冲。
if (clear_depth)
{
glDepthMask(GL_TRUE);
}
glClear(mask);
}
OpenGL 的 glClear 受深度掩码影响——如果上一帧的 SkyboxPass 把 depth_write 关了没恢复,这一帧的深度清屏就会静默失效,画面表现成"深度全乱"。这类坑被收进实现层后,上层 Pass 只需要一句 IDRCmd::clear()。
1.7 后端无关性
IDRenderer 的公开头文件(include/ 目录)里找不到任何平台头:没有 glad/glad.h、没有 GL/gl.h,全部用自家的枚举和结构体表达。平台相关代码藏在 src/ 实现层里,用宏控制编译:
// Shader.cpp
#ifdef IDRENDERER_USE_OPENGL
// OpenGL 实现
#endif
导出符号用 IDR_API 宏统一处理(Windows 下是 __declspec(dllexport/dllimport))。Shader 层还提供了反射能力——link 完成后枚举全部 active uniform,生成后端无关的描述列表:
enum class ShaderUniformType : uint8_t
{
None, Float, Int, Bool, Vec2, Vec3, Vec4, Mat3, Mat4,
Sampler2D, SamplerCube, Unsupported
};
struct ShaderUniformDesc
{
std::string name; // 数组已截断 "[0]" 的基础名
ShaderUniformType type;
uint32_t count; // >1 表示数组
};
这个列表供引擎本体的材质反射系统使用(Inspector 面板能列出材质有哪些参数可调,就是靠它)。UBO block 的成员名字里带 .(如 ShadowBlock.u_bias)、gl_ 内建和无法映射的类型都会被过滤掉。
2. 小结
IDRenderer 的整体思路可以概括成三句话:
- 资源层:8 类 GPU 资源各配一个类型安全的 ID、一个静态 Manager、一个 CreateInfo,池化存储、显式销毁;
- 命令层:RenderCommand 以自由函数暴露渲染操作,实现层用状态缓存把 OpenGL 的全局状态模型包装成"绑定管线"的对象语义;
- 隔离层:公开头零平台依赖,引擎本体不接触任何 GL 类型。
上面的 RenderPass / RenderGraph / 材质系统,全都建立在这套接口之上。



