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 / 材质系统,全都建立在这套接口之上。