QSG 与 QRhi:把一个 PBR 渲染器搬进 QML 渲染栈

工程:QtPBRDemo(分支 qsg)

环境:Qt 6.9.0 / macOS / Metal 后端

本文记录的是把一条 QOpenGLWidget + 裸 OpenGL 的 PBR/IBL 渲染路径,改写成 QML + QRhi 的完整过程------包含架构判断、API 契约、五次崩溃、三次性能跌落,以及一次由色彩空间公式写反而导致的配色 bug。


目录

  1. 要解决什么问题
  2. [QSG:Qt Quick 的渲染树](#QSG:Qt Quick 的渲染树)
  3. [QRhi:Qt 的图形抽象层](#QRhi:Qt 的图形抽象层)
  4. QQuickRhiItem:两者的接合点
  5. 五个必须知道的契约
  6. [性能:从 1fps 到 57fps](#性能:从 1fps 到 57fps)
  7. [附录 A:一次配色 bug 的完整定位](#附录 A:一次配色 bug 的完整定位)
  8. [附录 B:构建与验证](#附录 B:构建与验证)

1. 要解决什么问题

原始工程有两条渲染路径,共享同一套 PBR 着色模型与 IBL 资源:

目标 集成方式 说明
QtPBRDemo QOpenGLWidget + 裸 OpenGL 原始版本,IBL 卷积在启动时用 GPU 在线计算
PBRQuick QML + QQuickRhiItem + QRhi 同一套算法走 RHI,可跑 Metal / Vulkan / D3D11 / GLES

目标很明确:让 3D 渲染内容能作为 QML 场景里的一个普通 Item 存在 ,从而获得 QML 那一整套能力------声明式布局、属性动画、MouseArea / Keys 交互、与其它 QML 控件混排、无缝嵌入已有的 QML 界面。

这背后是一个真实的产品形态:车载 HMI 的界面正在从"纯 QWidget 控件"迁移到"QML 场景 + 3D 视口"。3D 部分不能再是一个独占窗口的 QOpenGLWidget,它必须是场景图里的一环。

1.1 动手前的架构判断

在写任何代码之前,先要选对承载方式。Qt Quick 里塞自定义 3D 渲染,有三条路:

方案 机制 能否承载完整 PBR
QSGGeometryNode + QSGMaterial 完全走 QSG 自己的材质体系 不能
QQuickFramebufferObject 用 FBO 把内容画进去,QSG 只当贴图用 能,但绕
QQuickRhiItem 直接拿 QRhi command buffer 自己画 能,且最直接

排除第一条路线的原因值得单独说,因为这是 QSG 最关键的一条限制。

QSGMaterialShader 的 80 字节天花板

QSGMaterialShader 只给自定义 uniform 提供一个固定大小、不可扩容的 uniform buffer。用 Qt 6.9 自带的库验证一下这个 buffer 里到底有什么:

console 复制代码
$ strings /usr/local/Cellar/qt/6.9.0/lib/QtQuick.framework/Versions/A/QtQuick | grep '^qt_'
qt_Matrix
qt_Opacity

就这两个。换算成字节:

复制代码
layout(std140) uniform {
    mat4  qt_Matrix;   // 64 字节
    float qt_Opacity;  //  4 字节
};
// std140 要求结构体大小是最大成员对齐(vec4 = 16)的整数倍
// 64 + 4 = 68 → 向上取整到 80 字节

80 字节,没有扩容接口。 而 PBR 需要传下去的东西是:

复制代码
mat4  mvp                       64 字节   ← 已经超了
mat4  model                     64
vec3  camPos   + float exposure
vec3  lightPos[2] + vec3 lightCol[2]
vec3  ambientColor + float ambientIntensity
float metallic, roughness, useNormalMap, useRmacMap

实测 PBRUniforms 结构体 240 字节------UBO 的 3 倍。QSG 这条路当场就走不通了。

QQuickFramebufferObject 理论可行,但它要求你自己管理 FBO、渲染循环、以及 QSG 与你的 GL 上下文之间的同步,本质上是在 QSG 外面包一层壳,还得额外多一次全屏 blit。

所以选 QQuickRhiItem:QML 负责声明式外壳和交互,QRhi 负责 explicit pipeline,绕开 QSG 的材质契约,但不绕开 QSG 的调度。


2. QSG:Qt Quick 的渲染树

2.1 两棵树,别混

这是理解 QSG 的第一个门槛:QML 世界里同时存在两棵结构完全不同的树。

复制代码
QML 对象树(GUI 线程)                QSG 渲染树(渲染线程)
─────────────────────────            ─────────────────────────
Window                                RootNode
├── PBRItem            ──同步──▶      ├── TransformNode
├── Rectangle                        │     └── QSGGeometryNode
├── Text          (属性求值+打包)    ├── QSGRectangleNode
└── ...                              └── QSGClipNode
     QObject,可含信号槽                    纯数据,无 QObject 开销
     任意 QObject 子类                    只为渲染而存在

关键差异:左边是对象,右边是数据 。左边可以随便加信号槽、加 Q_PROPERTY、加业务逻辑;右边是扁平的、按绘制顺序排好的一组节点,每一帧重建。

这个分离是性能的来源------渲染线程不需要知道 QObject、信号槽、属性系统的存在,它只吃打包好的数据。

2.2 三阶段流水线

QSG 的帧循环分三个阶段,跑在两个线程上:

复制代码
    GUI 线程                          渲染线程
    ─────────                          ─────────
    QML 求值
    绑定计算、布局、
    属性变更             ─────sync────▶  Sync 阶段
                                       遍历 QML 树,
                                       调用 updatePaintNode()
                                       构建/更新 QSGNode
                                            │
                                            ▼
                                       Render 阶段
                                       排序、合批、剔除,
                                       提交给 GPU
  • QML 求值:动画推进、绑定重算、布局变化。产出的是"哪些属性变了"。
  • Sync :QSG 遍历 QML 树,问每个 QQuickItem "你的渲染节点是什么"。自定义项在这里通过 updatePaintNode() 拿到旧节点、返回新节点。这个阶段是唯一能安全访问 QSG 节点树的地方。
  • Render :QSG 把节点树排序(按材质/深度/透明度分桶)、剔除不可见节点、合批可以合批的,然后下发命令。这里绝对不能碰 QML 对象。

两阶段之间用一次线程同步隔开,所以 Sync 阶段读到的属性值是那一刻的快照。

2.3 自定义渲染节点长什么样

如果走 QSG 路线,一个 QQuickItem 子类大概是这样:

cpp 复制代码
class PBRNodeItem : public QQuickItem
{
    QSGNode *updatePaintNode(QSGNode *old, UpdatePaintNodeData *) override;
};

QSGNode *PBRNodeItem::updatePaintNode(QSGNode *old, UpdatePaintNodeData *)
{
    auto *node = static_cast<QSGGeometryNode *>(old);
    if (!node) {
        node = new QSGGeometryNode;
        node->setGeometry(new QSGGeometry(QSGGeometry::defaultAttributes_PNT(), 3));
        auto *mat = new PBRMaterial;          // 自定义 QSGMaterial
        node->setMaterial(mat);
        node->setFlag(QSGNode::OwnsGeometry);
        node->setFlag(QSGNode::OwnsMaterial);
    }
    // 更新顶点数据 ...
    return node;
}

看起来很美,但立刻撞上 §1.1 那面墙:PBRMaterial::createShader() 返回的 QSGMaterialShader 只有 80 字节 uniform。想多传一个矩阵都没地方放。

能想到的歪招是拆多个 pass、把数据编码进顶点属性、或者用纹理传 uniform------都能跑通,但都是在绕着一个本可以不绕的限制。

2.4 QSG 真正适合什么

不是否定 QSG。它极其适合:

  • 矩形、圆角、渐变、描边(QSGRectangleNode)
  • 图片(QSGImageNode / QSGOpaqueTextureMaterial)
  • 九宫格(QSGNinePatchNode)
  • 文字(QSGInternalTextNode)
  • 着色器特效 ------ShaderEffect,比如模糊、色差、液化,输入是纹理输出是纹理
  • 中等复杂度的自定义几何体 + 简单材质

共同点是:uniform 需求小,或者数据可以塞进顶点属性/纹理。完整 PBR 属于"uniform 需求大"的那一类。


3. QRhi:Qt 的图形抽象层

3.1 定位

QRhi 是 Qt 6 引入的私有但稳定的图形抽象层 (在 QtGui 模块里,头文件位于 Gui 私有 include 路径下,需要链接 Qt6::GuiPrivate)。

它的定位一句话:用一套 explicit API 描述渲染,由 QRhi 翻译成各后端的具体调用。

QRhi 本身不是新东西------QSG 内部就是用它实现的。QQuickWindow 有一个 rhi() 接口,场景图的所有绘制最终都经过 QRhi。所以用 QQuickRhiItem 不是引入第二套渲染器,而是直接使用 QSG 正在用的那一个 。这一点很重要:意味着你和场景图共享同一个 QRhi 实例、同一个 command buffer、同一个 render pass,天然合批、天然有正确的深度/模板状态。

3.2 后端矩阵

后端 平台
Metal macOS / iOS
Vulkan Windows / Linux / Android
OpenGL / OpenGL ES 全平台,兜底
Direct 3D 11 Windows

着色器由 qsb (Qt Shader Baker)在编译期从一份 GLSL 450 交叉编译成各后端的中间表示。这是 Qt 6 相比 Qt 5 最大的便利之一------写一份 GLSL,跑四个后端:

cmake 复制代码
qt6_add_shaders(PBRQuick "pbrqml_shaders" PREFIX "/" BASE ${SHADER_DIR}
    FILES
        ${SHADER_DIR}/pbr_rhi.vert
        ${SHADER_DIR}/pbr_rhi.frag
        ${SHADER_DIR}/sky_rhi.vert
        ${SHADER_DIR}/sky_rhi.frag
)

生成 .qsb,运行时用 QShader::fromFile() 加载,QRhi 根据当前后端挑对应的那一份。

坑 :qt6_add_shaders 必须加 BASE ${SHADER_DIR}。不加的话资源路径会变成绝对路径(:///Users/huailiang/...),运行时 :/pbr_rhi.vert.qsb 找不到。

3.3 Explicit pipeline state

QRhi 的 API 风格接近 Vulkan/Metal 而非 OpenGL:所有状态在创建 pipeline 时一次性固定,运行时几乎无状态变更。

对比一下同一次绘制在两套 API 下的写法:

裸 OpenGL(旧版)

cpp 复制代码
glUseProgram(program);                          // 全局状态
glUniformMatrix4fv(uMVP, 1, GL_FALSE, mvp.data());
glUniform3fv(uCamPos, 1, camPos);
glUniform3fv(uLightPos0, 1, l0);
// ... 十几个 uniform 逐个设
glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, albedo);
glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_2D, normal);
glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_CUBE_MAP, irradiance);
glBindVertexArray(vao);
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, nullptr);

QRhi(新版)

cpp 复制代码
// 状态全部预置在 pipeline 里:着色器、顶点布局、深度测试、混合、MSAA
QRhiGraphicsPipeline *pipeline = rhi->newGraphicsPipeline();
pipeline->setShaderStages({{QRhiShaderStage::Vertex, vertQsh},
                           {QRhiShaderStage::Fragment, fragQsh}});
pipeline->setVertexInputLayout(layout);
pipeline->setDepthTest(true);
pipeline->setDepthWrite(true);
pipeline->setCullMode(QRhiGraphicsPipeline::Back);
pipeline->setSampleCount(4);                       // MSAA
pipeline->setRenderPassDescriptor(rp->renderPassDescriptor());
pipeline->create();

// 资源绑定:binding 号 ↔ 着色器里的 layout(binding = N)
QRhiShaderResourceBindings *srb = rhi->newShaderResourceBindings();
srb->setBindings({
    QRhiShaderResourceBinding::uniformBuffer(0, VertexStage | FragmentStage, ubo),
    QRhiShaderResourceBinding::sampledTexture(1, FragmentStage, albedo, sampler2D),
    QRhiShaderResourceBinding::sampledTexture(2, FragmentStage, normal, sampler2D),
    QRhiShaderResourceBinding::sampledTexture(4, FragmentStage, irradiance, samplerCube),
});
srb->create();

// 每帧只剩四条调用
cb->setGraphicsPipeline(pipeline);
cb->setShaderResources(srb);
cb->setVertexInput(0, 1, vin, ibuf, 0, QRhiCommandBuffer::IndexUInt32);
cb->drawIndexed(indexCount);

代价是样板代码多;收益是每帧的 CPU 开销极小、状态变更可控、且这份代码在 Metal 上和 Vulkan 上是同一份。

3.4 核心对象清单

对象 职责
QRhi 上下文入口,rhi()->backendName() 可查后端
QRhiCommandBuffer 录制命令:beginPass / setPipeline / draw / endPass
QRhiRenderTarget 渲染目标(QQuickRhiItem 会给你)
QRhiRenderPassDescriptor render pass 描述,与 render target 绑定
QRhiGraphicsPipeline 完整的绘制状态快照
QRhiShaderResourceBindings binding 号 → 资源 的映射表
QRhiBuffer uniform / vertex / index 缓冲,分 Dynamic 和 Immutable
QRhiTexture 2D / cubemap / render target,指定像素格式
QRhiSampler 过滤方式、寻址方式
QRhiResourceUpdateBatch 资源上传的批处理容器

最后一个值得强调:QRhiResourceUpdateBatch 是 QRhi 里一个容易被忽略但很关键的设计。资源上传不直接调 API,而是先攒进一个 batch,再随命令一起提交:

cpp 复制代码
QRhiResourceUpdateBatch *rub = rhi->nextResourceUpdateBatch();
rub->uploadStaticBuffer(vbuf, data);
rub->uploadTexture(tex, desc);
rub->updateDynamicBuffer(ubo, 0, size, &uniforms);
cb->resourceUpdate(rub);        // 或随 beginPass 一起提交

这样做的好处是上传操作被排到正确的时序点上,且可以合并。实践中的教训是:一个 batch 只能提交一次,重复提交会拿到空指针或校验失败。

3.5 与 QSG 的关系

一个容易混淆的点:QRhi 和 QSG 不是二选一。

复制代码
                    ┌──────────────┐
                    │  QML / QQuick │
                    └──────┬───────┘
                           │
              ┌────────────┴────────────┐
              ▼                         ▼
     ┌────────────────┐        ┌─────────────────┐
     │  QSG 场景图     │        │ QQuickRhiItem   │
     │  QSGNode 树     │        │ 你的渲染代码     │
     │  QSGMaterial    │        │ 你自己的 pipeline│
     └───────┬────────┘        └────────┬────────┘
             │                          │
             └──────────┬───────────────┘
                        ▼
                  ┌──────────┐
                  │   QRhi   │
                  └────┬─────┘
                       ▼
        Metal / Vulkan / D3D11 / GLES

QQuickRhiItem 是 Qt 官方提供的接合点:它作为 QSG 场景图里的一个节点存在(所以 QML 布局、裁剪、透明度都正常生效),同时把 QRhi 的 command buffer 直接交给你。


4. QQuickRhiItem:两者的接合点

4.1 四个回调的生命周期

cpp 复制代码
class PBRItem : public QQuickRhiItem
{
    // 渲染线程:renderer 创建时调一次,做 GPU 资源初始化
    // 注意:会被反复调用,必须幂等(见 §5.2)
    QQuickRhiItemRenderer *createRenderer() override;

    // GUI 线程 → 渲染线程:把 QML 属性搬进 renderer
    void synchronize(QQuickRhiItem *item) override;
};

class PBRRenderer : public QQuickRhiItemRenderer
{
    void initialize(QRhiCommandBuffer *cb) override;   // 渲染线程
    void synchronize(QQuickRhiItem *item) override;    // 同步阶段
    void render(QRhiCommandBuffer *cb) override;       // 渲染阶段
};

时序:

复制代码
GUI 线程                          渲染线程
─────────                          ─────────
QML 实例化 PBRItem
  └─ 构造函数里 reloadModel()  ←── 关键:模型要早于 createRenderer 就绪
                                       │
QML 求值完成                          createRenderer()   ← 只调一次
                                       synchronize()     ← 每个 sync 阶段
                                       initialize()      ← 反复调用!
                                       render()          ← 每帧
                                       synchronize()
                                       render()
                                       ...

QQuickItem::update() 请求重绘。但要注意有两个 update(),作用完全不同:

调用 效果
QQuickRhiItemRenderer::update() 请求下一帧,renderer 保留
QQuickItem::update()(item 侧) 触发 QSG 重新同步,renderer 可能被重建

本工程在 render() 末尾调的是前者:

cpp 复制代码
void PBRRenderer::render(QRhiCommandBuffer *cb)
{
    ...
    cb->endPass();

    // 请求下一帧:QQuickRhiItemRenderer::update() 是渲染循环的正确驱动方式。
    // item 侧的 QQuickItem::update() 会导致节点重建 renderer,
    // 而这里只会重新走一遍 synchronize() + render()。
    update();
}

QQuickRhiItemRenderer::update() 在头文件里是 protected 的,专为这个场景设计------renderer 自己说"我下一帧还要画"。

4.2 一个必须记住的时序问题

initialize() 的调用时机不保证晚于 synchronize()。 这意味着初始化时可能还拿不到 QML 侧传过来的数据。

本工程的解法是两路兜底:

cpp 复制代码
// 1) 构造函数里就加载模型,不等 QML
PBRItem::PBRItem(QQuickItem *parent)
    : QQuickRhiItem(parent)
{
    setFlag(QQuickItem::ItemHasContents, true);
    reloadModel();
}

// 2) createRenderer() 时把场景直接注入 renderer
QQuickRhiItemRenderer *PBRItem::createRenderer()
{
    auto *renderer = new PBRRenderer;
    renderer->setScene(m_scene);
    return renderer;
}

// 3) render() 里发现几何缓冲还空着就补建
void PBRRenderer::render(QRhiCommandBuffer *cb)
{
    QRhiResourceUpdateBatch *rub = m_rhi->nextResourceUpdateBatch();
    ensureGeometryBuffers(rub);          // 内部判空,幂等
    ...
}

4.3 uniform 块:GLSL 与 C++ 逐字节对齐

自定义 uniform 块走 std140,C++ 侧用一个等长结构体镜像,用 static_assert 钉死:

glsl 复制代码
layout(std140, binding = 0) uniform buf {
    mat4 mvp;                    // 64
    mat4 model;                  // 64
    vec3 camPos;     float exposure;
    vec3 lightPos0;  float _pad0;
    vec3 lightCol0;  float _pad1;
    vec3 lightPos1;  float _pad2;
    vec3 lightCol1;  float _pad3;
    vec3 ambientColor; float ambientIntensity;
    float metallic;  float roughness;
    float useNormalMap; float useRmacMap;
} u;                              // 合计 240 字节
cpp 复制代码
struct PBRUniforms {
    float mvp[16];
    float model[16];
    float camPos[3];    float exposure;
    float lightPos0[3]; float _pad0;
    float lightCol0[3]; float _pad1;
    float lightPos1[3]; float _pad2;
    float lightCol1[3]; float _pad3;
    float ambientColor[3]; float ambientIntensity;
    float metallic;     float roughness;
    float useNormalMap; float useRmacMap;
};
static_assert(sizeof(PBRUniforms) == 240, "uniform block must stay 240 bytes");

std140 规则就一条:vec3 后面必须补一个 float 才能填满 16 字节槽位。忘了补,GPU 读到错位数据,症状是各种莫名其妙的颜色和位置错误,极难查。

4.4 binding 号的分配表

binding 号是全局的,跨 stage 共享命名空间:

binding 内容 stage
0 PBRUniforms UBO Vertex + Fragment
1 albedo (sampler2D) Fragment
2 normal (sampler2D) Fragment
3 roughness/metallic/AO (sampler2D) Fragment
4 irradiance cubemap (samplerCube) Fragment
5 prefiltered env cubemap (samplerCube) Fragment
6 BRDF LUT (sampler2D) Fragment
1 sky UBO Vertex
1 sky env cubemap Fragment

最后两行是天空盒管线的独立分配(不同 pipeline,互不干扰)。

坑 :天空盒的 samplerCube 最初也写了 binding = 0,和顶点阶段的 UBO 撞号。binding 冲突在 QRhi 里通常没有诊断信息------不报错、不崩溃,只是画面不对,只能靠二分排查。改成就诊这段经历的唯一办法是:每次只改一个 binding,抓帧看结果。


5. 五个必须知道的契约

这一节是整篇里最该记住的部分。移植过程中真正踩到的坑,每一条都对应一次崩溃或一次静默错误。

5.1 render() 进来时没有活跃的 render pass

这是最疼的一条,也是反复 SIGSEGV 的根因。

Qt 文档对 QQuickRhiItemRenderer::render() 的说明是:调用时处于 "without an active render pass" 状态。

这意味着此时 Metal 没有 command encoder、Vulkan 没有 begun render pass instance。此时调 setViewport()、setPipeline() 一类命令,直接段错误。

错误写法

cpp 复制代码
void PBRRenderer::render(QRhiCommandBuffer *cb)
{
    cb->setViewport(QRhiViewport(0, 0, w, h));   // ← 崩
    cb->setGraphicsPipeline(m_pipeline.get());
    cb->drawIndexed(m_indexCount);
}

正确写法:自己开合 pass

cpp 复制代码
void PBRRenderer::render(QRhiCommandBuffer *cb)
{
    QRhiResourceUpdateBatch *rub = m_rhi->nextResourceUpdateBatch();
    ensureGeometryBuffers(rub);
    rub->updateDynamicBuffer(m_ubuf.get(), 0, sizeof(PBRUniforms), &m_uniforms);

    const QSize sz = renderTarget()->pixelSize();

    // 自己 beginPass ------ 资源上传可以搭这个 pass 的车一起提交
    cb->beginPass(renderTarget(), QColor::fromRgbF(0.04f, 0.04f, 0.05f, 1.0f),
                  { 1.0f, 0 }, rub);
    cb->setViewport(QRhiViewport(0.0f, 0.0f, float(sz.width()), float(sz.height())));

    // 模型
    cb->setGraphicsPipeline(m_pipeline.get());
    cb->setShaderResources(m_srb.get());
    const QRhiCommandBuffer::VertexInput vin[] = { { m_vbuf.get(), 0 } };
    cb->setVertexInput(0, 1, vin, m_ibuf.get(), 0, QRhiCommandBuffer::IndexUInt32);
    cb->drawIndexed(m_indexCount);

    // 天空盒:深度钉在远平面,只在模型没覆盖的像素上通过
    if (m_skyPipeline) {
        cb->setGraphicsPipeline(m_skyPipeline.get());
        cb->setShaderResources(m_skySrb.get());
        const QRhiCommandBuffer::VertexInput svin[] = { { m_skyVbuf.get(), 0 } };
        cb->setVertexInput(0, 1, svin);
        cb->draw(36);
    }

    cb->endPass();
}

beginPass() 的第二个参数是 clear color,第三个是 clear 值(深度 1.0、模板 0)。第四个参数可以捎带一个 QRhiResourceUpdateBatch,省掉单独一次 resourceUpdate() 调用。

症状对照 :如果 drawIndexed() 明明执行了却什么也看不见,也是同一个原因------命令录制到了一个不存在的 encoder 上,被静默丢弃。

5.2 initialize() 会被反复调用

实测:一次 createRenderer(),42 次 initialize()(每帧一次)。

若把纹理创建放在 initialize() 里且不做判重,等于每帧重新上传几十 MB 的 cubemap------帧率直接掉到 1fps。

改成幂等:与 render target 无关的资源只在第一次建;只有 render pass 描述符变化(窗口 resize、MSAA 开关变化)时才需要重建 pipeline。

cpp 复制代码
void PBRRenderer::initialize(QRhiCommandBuffer *cb)
{
    m_rhi = rhi();
    if (!m_rhi) { qWarning() << "no QRhi available"; return; }

    // render pass 变了才需要重建 pipeline
    QRhiRenderPassDescriptor *rp = renderTarget()->renderPassDescriptor();
    if (m_ready && m_lastRenderPass == rp)
        return;

    const bool firstTime = !m_ready;
    m_lastRenderPass = rp;

    QRhiResourceUpdateBatch *rub = m_rhi->nextResourceUpdateBatch();

    if (firstTime) {                       // 重资源只建一次
        createBuffers(rub);
        ensureGeometryBuffers(rub);
        createTextures(rub);
    }
    createPipelines();                     // 与 render target 相关,可能重建

    cb->resourceUpdate(rub);
    m_ready = (m_pipeline != nullptr);
}

用 m_lastRenderPass 记住上次绑定的 render pass 描述符,是判断"要不要重建"的最可靠依据。

5.3 cubemap 上传:逐 face 逐 level,格式必须匹配

cubemap 不能一次上传,要逐面逐级:

cpp 复制代码
QRhiTexture *tex = m_rhi->newTexture(QRhiTexture::RGBA32F, QSize(size, size), 1,
                                     QRhiTexture::CubeMap | QRhiTexture::MipMapped);
tex->create();

for (int f = 0; f < 6; ++f) {
    for (int level = 0; level < levels; ++level) {
        const BakedMip &mip = cube.faces[f][level];
        QRhiTextureSubresourceUploadDescription desc(
            mip.rgba.data(), quint32(mip.rgba.size() * sizeof(float)));
        desc.setDataStride(mip.size * 4 * int(sizeof(float)));
        rub->uploadTexture(tex, QRhiTextureUploadDescription(
                                    QRhiTextureUploadEntry(f, level, desc)));
    }
}

QRhiTextureUploadEntry(face, level, desc) 的 face 参数就是干这个用的。

坑 :最初声明 RGBA16F 却上传 float32 数据。setDataStride() 按 float 算,而纹理格式是 half float,实际读取宽度是声明的一半,直接读到越界内存 。改成 RGBA32F 后正常。这条规则很简单:上传什么类型的数据,纹理就用什么类型。

5.4 binding 号跨 stage 共享命名空间

见 §4.4。补一句:QRhiShaderResourceBinding::uniformBuffer(binding, stages, buffer) 的 stages 参数要显式写全,漏写 FragmentStage 会导致片元着色器里读不到这个 UBO------又是一个静默错误。

5.5 重资源必须进程级缓存

HDR 解码 + 三张 cubemap 烘焙是秒级操作,绝不能挂在 renderer 的生命周期上------renderer 归 QSG 管,不保证稳定(render target 变化、窗口显隐、上下文丢失都可能触发重建)。

解法是进程级 static 缓存,renderer 只持有一份指针:

cpp 复制代码
void PBRRenderer::createTextures(QRhiResourceUpdateBatch *rub)
{
    // IBL 三张 cubemap 同源,一次解码、一次烘焙,进程级缓存。
    // renderer 的生命周期由 QSG 管理,秒级的解码 + 烘焙不能挂在上面;
    // 做成 static 后这部分开销与帧率彻底解耦------实测 9fps → 27fps。
    const IBLCache &ibl = iblCache();      // 内部是 static

    m_irradiance.reset(makeCubemap(rub, ibl.irradiance, 32, false));
    m_prefilter.reset(makeCubemap(rub, ibl.prefilter, 256, true));
    m_skyCube.reset(makeCubemap(rub, ibl.skybox, 512, false));
    ...
}

这里有个值得一提的演进过程。 最开始驱动续帧用的是 item 侧的 QQuickItem::update(),QSG 会因此重建 renderer,烘焙跟着每帧重跑------这就是 9fps 的成因。后来改成在 render() 里调 QQuickRhiItemRenderer::update()(见 §4.1),实测 createRenderer() 全程只调用 1 次,renderer 不再每帧重建,帧率回到 27fps。

缓存仍然保留。原因是这个开销不应该和帧率有任何耦合------把重资源做成进程级之后,无论 QSG 何时重建 renderer,代价都只是一次指针传递。

顺带纠正一个容易想当然的点:createRenderer() 只调 1 次,initialize() 每帧调 1 次。这两个的生命周期完全不对等,§5.2 的幂等问题正由此而来。


6. 性能:从 1fps 到 57fps

6.1 三次跌落的定位过程

性能问题是一步步跌下去又一步步爬回来的,每一步都有明确的病因。

复制代码
帧率
 60 ┤                                              ●●●●  最终
 57 ┤                                            ●●●●●●  (Release)
    │                                          ●●●
 30 ┤                                    ●●●●●●
 27 ┤                              ●●●●●●         (Debug 稳态)
    │                        ●●●●●●
 10 ┤                  ●●●●●●
  9 ┤            ●●●●●●
    │      ●●●●●●
  1 ┤●●●●●●
    └──┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──▶
       ①      ②      ③      ④      ⑤      ⑥      ⑦    时间
阶段 现象 病因 处置
① 1fps initialize() 每帧重建全部纹理(42 次) 幂等化,见 §5.2
② 9fps initialize() 修好了,但续帧用的是 item 侧 QQuickItem::update(),QSG 每帧重建 renderer,HDR 解码 + IBL 烘焙跟着每帧跑 改用 QQuickRhiItemRenderer::update() 续帧 + 重资源改进程级缓存,见 §4.1 / §5.5
③ 27fps Debug 构建(-O0)下的稳态 ---
④ 启动 20s+ GGX 卷积在 -O0 下被放大 切 Release,见 §6.3
⑤ ~57fps Release 稳态 ---

①②两步加起来把帧率从 1fps 拉到 27fps(Debug),是纯算法/结构层面的修复,没有牺牲任何画质。③④⑤是构建配置问题。

③ 和 ⑤ 的数字差异值得强调:此前所有性能数字都是在 Debug 构建下测的 。-O0 对这段代码的影响极大------IBL 烘焙从 2.5 秒变成 20 秒以上,帧率从 57 掉到 27。性能数据必须标注构建类型,否则没有可比性。

6.2 优化清单

按收益排序:

1. initialize() 幂等 (1fps → 可交互)

最核心的一条。判据:m_ready && m_lastRenderPass == rp 就直接返回。

2. 重资源进程级缓存 (9fps → 27fps)

判据:任何超过 1ms 的操作都不能放在 renderer 的生命周期里。 HDR 解码、IBL 烘焙、贴图解码全部走 static。实测 createRenderer() 全程只调 1 次,但 renderer 生命周期终究不由你控制------做成进程级缓存后,这部分开销与帧率彻底解耦。

3. Release 构建 (27fps → 57fps)

纯编译优化,零代码改动。

4. 卷积期间的方向查询走 cube 而非 equirect (启动 20s → 2.5s)

这是唯一一处真正的算法优化。GGX 重要性采样卷积需要对每个样本方向查询环境辐射度:

  • 慢 :直接用 equirect HDR 采样,每个样本要算 atan2 + asin(约 50ns)
  • 快:先把 equirect 烘成一张低分辨率 cube(128³),采样时只需几次除法做面投影
cpp 复制代码
// 低分辨率立方体源,仅供卷积期间做方向查询。
// 直接用等距图采样的话,每个样本都要算 atan2 + asin(约 50ns),
// 三千多万样本就是秒级开销;换成 cube 面投影只有几次除法。
struct CubeSource {
    int size = 0;
    std::vector<float> rgb;   // 6 面,每面 size*size,每像素 3 float

    void build(const HDRImage &equirect, int s) { /* 逐面填充 */ }

    QVector3D sample(const QVector3D &d) const
    {
        // 按最大分量选面,一次除法得到 (u,v),双线性插值
        const float ax = std::abs(d.x()), ay = std::abs(d.y()), az = std::abs(d.z());
        int f; float u, v;
        if (ax >= ay && ax >= az) {
            f = d.x() > 0.0f ? 0 : 1;
            const float inv = 1.0f / ax;
            u = -d.z() * inv;  v = -d.y() * inv;
        } else if (ay >= az) {
            f = d.y() > 0.0f ? 2 : 3;
            const float inv = 1.0f / ay;
            u = d.x() * inv;   v = d.z() * inv;
        } else {
            f = d.z() > 0.0f ? 4 : 5;
            const float inv = 1.0f / az;
            u = d.x * inv;     v = -d.y() * inv;
        }
        // ... 双线性插值
    }
};

卷积量级:prefilter 256×256×5 级 mip × 6 面 × 平均 128 样本 ≈ 2500 万次采样。50ns/次就是 1.2 秒------值得省。

5. 6 个面并行卷积

cpp 复制代码
BakedCube bakePrefilter(const HDRImage &sky, const CubeSource &src,
                        int baseSize, int levels)
{
    BakedCube cube;
    cube.valid = true;
    cube.faces.assign(6, {});
    for (auto &f : cube.faces)
        f.resize(size_t(levels));

    std::vector<std::thread> workers;
    for (int f = 0; f < 6; ++f) {
        workers.emplace_back([&cube, &sky, &src, f, baseSize, levels]() {
            bakeFace(sky, f, baseSize, cube.faces[size_t(f)][0].rgba);
            cube.faces[size_t(f)][0].size = baseSize;

            for (int level = 1; level < levels; ++level) {
                const int size = std::max(1, baseSize >> level);
                const float roughness = float(level) / float(levels - 1);
                // 越粗糙的 mip 越小,采样数也相应减少
                const int samples = std::max(64, 256 >> (level - 1));
                BakedMip &mip = cube.faces[size_t(f)][size_t(level)];
                mip.size = size;
                prefilterFace(src, f, size, roughness, samples, mip.rgba);
            }
        });
    }
    for (auto &t : workers)
        t.join();
    return cube;
}

6. 采样数随 mip 递减

roughness = level / (levels - 1),越粗糙的 mip 尺寸越小、需要的样本也越少:samples = max(64, 256 >> (level - 1))。最后一级只采 64 个,比固定 256 省 75%。

7. 合批资源上传

所有 uploadStaticBuffer / uploadTexture / updateDynamicBuffer 都攒进同一个 QRhiResourceUpdateBatch,随 beginPass() 一次提交,省掉额外的 resourceUpdate() 往返。

6.3 启动开销

阶段 Debug Release
HDR 解码 ~1s <0.3s
irradiance 烘焙(32×32×6) ~1s <0.2s
prefilter 烘焙(256×256×5×6) ~15s ~1.8s
skybox 烘焙(512×512×6) ~3s ~0.2s
合计 ~20s ~2.5s

一次性开销,之后走进程级缓存。如果还想再快,可把 prefilter 的采样数从 256 降到 128,画质损失很小(预滤波本身就是低频量)。

6.4 还没做的优化

诚实列一下:

  1. IBL 卷积没用 GPU 。现在全在 CPU 上做。GL 版是在启动时用 GPU 离屏 pass 卷积的。要用 QRhi 做,得在 QQuickRhiItem 之外自建一个离屏 QRhi 渲染(比如临时 QOffscreenSurface),复杂度明显上升。当前 CPU 方案的 2.5 秒是可接受的,所以没做。
  2. MSAA 固定 4x。没有做动态降级(比如帧率低时自动降到 2x 或关掉)。
  3. render pass 描述符变化时会重建 pipeline 。窗口 resize、sampleCount 变更都会触发。目前是全额重建,没有做 pipeline 缓存或按需差分。

附录 A:一次配色 bug 的完整定位

这一节和架构无关,但它是本文所有截图的来源,且定位手法完全可以复用。

A.1 症状

QML 版渲染出来:握把的深红木纹变成了亮黄色,深色枪身变成近白。

复制代码
修复前                              修复后
┌──────────────────────┐          ┌──────────────────────┐
│  亮黄色握把            │          │  深红棕木纹握把        │
│  近白色枪身            │          │  深色枪金属 + 银色雕花  │
└──────────────────────┘          └──────────────────────┘

而同一个工程的 GL 版(QOpenGLWidget + 裸 GL)渲染完全正常------同样的模型、同样的贴图、同样的 PBR 算法。只有 QML 版出问题。

A.2 定位:三层排除

不是靠猜参数,是逐层把可能性削掉。

第一层:是光照问题吗?

抓帧取像素,对比模型区域和背景区域:

区域 像素值 判断
背景环境 正常 光照/曝光没问题
模型握把 #C2A102(亮黄) 只有模型错

→ 排除曝光、环境光、点光源。

第二层:是贴图/UV 问题吗?

把片元着色器的输出临时改成只输出 albedo,不做任何光照计算:

glsl 复制代码
// [DEBUG] 仅输出 albedo,用于定位贴图/UV 问题
fragColor = vec4(linearToSRGB(albedoColor), 1.0);

模型依旧是白/黄。→ 排除采样、UV、贴图加载。

第三层:是色彩空间变换问题吗?

再把输出改成直出未经任何转换的采样值:

glsl 复制代码
// [DEBUG] 原样输出贴图采样值,不做任何色彩空间转换
fragColor = vec4(texture(albedoMap, vTexCoord).rgb, 1.0);

同时用 fract(vTexCoord) 拿到该像素的真实 UV:

python 复制代码
# 该像素的 UV
u, v = 0.137, 0.702

# 反查贴图对应 texel
texel = (59, 23, 1)      # 深红棕 ------ 贴图里就是这个颜色

# 而渲染输出是
output = (194, 161, 2)   # 亮黄

并且:在整张贴图里暴力搜索 (194,161,2) 这个颜色,零命中。

结论钉死:这个黄色在贴图里根本不存在,只能是算出来的。

A.3 根因

shader/pbr_rhi.frag 里的 srgbToLinear() 方向写反了:

glsl 复制代码
// ✗ 错误:这是 linear → sRGB 的编码曲线
vec3 srgbToLinear(vec3 c)
{
    return mix(c / 12.92,
               1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055,
               step(vec3(0.04045), c));
}

// ✓ 正确:((c + 0.055) / 1.055) ^ 2.4
vec3 srgbToLinear(vec3 c)
{
    return mix(c / 12.92,
               pow((c + 0.055) / 1.055, vec3(2.4)),
               step(vec3(0.04045), c));
}

两个公式只差一个指数:

方向 公式
sRGB → linear(解码,渲染时需要) ((c + 0.055) / 1.055)^2.4
linear → sRGB(编码,输出到屏幕时需要) 1.055 · c^(1/2.4) − 0.055

写反的后果是把暗部整体提亮、色相往中性拉。以握把木纹为例:

复制代码
贴图原值      (59, 23, 1)   深红棕
   ↓ 用错公式(相当于又编码了一次)
输出值        (194, 161, 2) 亮黄

红通道 59 → 194(大幅提亮),绿通道 23 → 161(提亮得比红还多),蓝通道 1 → 2。绿通道被抬得比红通道猛,红 + 绿 = 黄。 枪身的深枪金属同理被推到近白 (186,187,185)。

GL 版用的是 pow(c, 2.2),方向是对的(精度略低但方向没错),所以只有 QML 版出问题。

A.4 顺带修掉的两件事

一、IBL 源被截断

原来 irradiance / prefilter 用的是仓库里预烘焙好的 irrdiance.hdr / radiance.hdr。统计一下这两张图的亮度分布:

文件 max 值
irrdiance.hdr 1.0
radiance.hdr 1.0
skybox.hdr 109.5

前两张图的最大值被截断在 1.0,窗口高光全丢了 ,金属反射因此发闷。改成三张 cubemap 全部从 skybox.hdr 现场烘焙,与 GL 版语义一致。

二、prefilter 算法从盒式降采样换成 GGX 重要性采样

原来为了省事用 2×2 盒式降采样近似重要性采样卷积。能量虽然守恒,但窗口那种高亮区域会被摊平到整个面 ------粗糙表面的反射因此整体偏亮,木握把会被洗成灰白 (131,91,76)。

换成真正的 GGX 卷积后,数值和 GL 参照对上:

握把像素
GL 参照(红色像素均值) (87, 45, 15)
修复后 (77, 37, 18)
修复前 (194, 161, 2)

卷积核心:

cpp 复制代码
// 单个面、单个 mip 的卷积
void prefilterFace(const CubeSource &src, int face, int size, float roughness,
                   int sampleCount, std::vector<float> &out)
{
    const FaceBasis b = basisForFace(face);
    out.assign(size_t(size) * size_t(size) * 4, 0.0f);

    for (int y = 0; y < size; ++y) {
        for (int x = 0; x < size; ++x) {
            const QVector3D n = (b.right * u + b.up * v + b.forward).normalized();
            const QVector3D &view = n;              // N = V = R,split-sum 的前提
            // 构造该点的正交基
            const QVector3D up = std::abs(n.z()) < 0.999f ? QVector3D(0,0,1) : QVector3D(1,0,0);
            const QVector3D tangent   = QVector3D::crossProduct(up, n).normalized();
            const QVector3D bitangent = QVector3D::crossProduct(n, tangent);

            const float a = roughness * roughness;
            QVector3D sum(0,0,0);
            float weight = 0.0f;

            for (int s = 0; s < sampleCount; ++s) {
                const float xi0 = (float(s) + 0.5f) / float(sampleCount);
                const float xi1 = radicalInverseVdC(quint32(s));   // Hammersley
                const float phi = 2.0f * kPi * xi0;
                const float cosTheta = std::sqrt((1.0f - xi1) / (1.0f + (a*a - 1.0f) * xi1));
                const float sinTheta = std::sqrt(std::max(0.0f, 1.0f - cosTheta * cosTheta));

                const QVector3D h = (tangent * (sinTheta * std::cos(phi))
                                     + bitangent * (sinTheta * std::sin(phi))
                                     + n * cosTheta).normalized();
                const QVector3D l = (h * (2.0f * QVector3D::dotProduct(view, h)) - view).normalized();
                const float ndl = std::max(0.0f, QVector3D::dotProduct(n, l));
                if (ndl <= 0.0f) continue;
                sum += src.sample(l) * ndl;
                weight += ndl;
            }
            if (weight > 0.0f) sum /= weight;
            // 写入 out ...
        }
    }
}

radicalInverseVdC 是 Van der Corput 序列的位反转实现,配 xi0 = (s + 0.5)/N 构成 Hammersley 低差异序列------比纯随机采样收敛快得多。

A.5 最终效果

同一模型的 GL 版参照:

握把特写对比:

修复前 修复后

附录 B:构建与验证

B.1 构建

bash 复制代码
# Qt 6.7+,需要 Quick / ShaderTools / GuiPrivate + Assimp
cmake -S . -B build-qsg2 -G Ninja \
  -DCMAKE_BUILD_PROGRAM=/Applications/CLion.app/Contents/bin/ninja/mac/x64/ninja \
  -DCMAKE_BUILD_TYPE=Release
cmake --build build-qsg2 --target PBRQuick
./build-qsg2/src/PBRQuick

B.2 无头抓帧

给两个目标都加了 PBR_CAPTURE 开关,渲染 5 秒后抓帧退出,便于 CI 或远程环境校验:

bash 复制代码
PBR_CAPTURE=1 PBR_CAPTURE_FILE=/tmp/pbr.png ./PBRQuick
PBR_CAPTURE=1 PBR_CAPTURE_FILE=/tmp/pbr_gl.png ./QtPBRDemo

这个开关的价值 :整个配色 bug 的定位过程(§A)完全依赖它------无头环境下没法看窗口,但可以抓帧、可以逐像素取色、可以写脚本比对。可编程的验证手段比目视检查可靠一个量级。

B.3 速查表

问题 答案
自定义 3D 渲染放 QML 里,首选哪个 API? QQuickRhiItem
QSGMaterialShader 能传多少 uniform? 固定 80 字节(mat4 qt_Matrix + float qt_Opacity),不可扩容
render() 里能直接 setViewport() 吗? 不能,必须先 beginPass()
initialize() 调几次? 反复调(实测每帧一次),必须幂等
驱动重绘用哪个 update()? QQuickRhiItemRenderer::update()
重资源放哪? 进程级 static 缓存,不能放 renderer 里
上传 float32 数据用什么纹理格式? RGBA32F
binding 号会撞吗? 会,跨 stage 共享命名空间,且无诊断信息
sRGB 解码公式? ((c + 0.055) / 1.055)^2.4
性能数据要标注什么? 构建类型(Debug / Release)

本文所有性能数字来自 Qt 6.9.0 / macOS / Metal 后端 / Release 构建,2048×1536(Retina 2x backing store)+ 4x MSAA。Debug 构建下帧率约为其一半。

相关推荐
qq_466302455 小时前
如何通过部分文件名,读取出整个文件名
c++·qt
半杯咖啡半行码7 小时前
QML进阶实战(二):异步网络、性能优化与工程化落地
网络·qt·性能优化·qml
半杯咖啡半行码9 小时前
QML与 Qt Quick入门实战:从零基础到 C++ 双向通信详解
java·qt·交互
扶尔魔ocy10 小时前
【QT+FFmpeg】知识点编目及传送门
qt·ffmpeg
qq_4017004110 小时前
Qt Modbus 工程化封装:超时、重试、取消与资源释放,别让异步请求变成隐患
数据库·qt
码匠许师傅20 小时前
【C++三方组件】Qt 概览:桌面 C++ 的事实标准与许可证三轨
开发语言·c++·qt
小王C语言1 天前
Qt常用控件:输入类控件,单行/多行输入框、下拉框、(日期)微调框、旋钮、滑动条
开发语言·qt
1001101_QIA1 天前
.exe does not seem to be a Qt executable.
开发语言·qt
ShineWinsu1 天前
对于Qt:QWidget通用属性的解析—上
数据库·qt·microsoft·面试·gui·属性·qwidget