工程:QtPBRDemo(分支
qsg)环境:Qt 6.9.0 / macOS / Metal 后端
本文记录的是把一条
QOpenGLWidget+ 裸 OpenGL 的 PBR/IBL 渲染路径,改写成 QML + QRhi 的完整过程------包含架构判断、API 契约、五次崩溃、三次性能跌落,以及一次由色彩空间公式写反而导致的配色 bug。
目录
- 要解决什么问题
- [QSG:Qt Quick 的渲染树](#QSG:Qt Quick 的渲染树)
- [QRhi:Qt 的图形抽象层](#QRhi:Qt 的图形抽象层)
- QQuickRhiItem:两者的接合点
- 五个必须知道的契约
- [性能:从 1fps 到 57fps](#性能:从 1fps 到 57fps)
- [附录 A:一次配色 bug 的完整定位](#附录 A:一次配色 bug 的完整定位)
- [附录 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 还没做的优化
诚实列一下:
- IBL 卷积没用 GPU 。现在全在 CPU 上做。GL 版是在启动时用 GPU 离屏 pass 卷积的。要用 QRhi 做,得在
QQuickRhiItem之外自建一个离屏QRhi渲染(比如临时QOffscreenSurface),复杂度明显上升。当前 CPU 方案的 2.5 秒是可接受的,所以没做。 - MSAA 固定 4x。没有做动态降级(比如帧率低时自动降到 2x 或关掉)。
- 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 构建下帧率约为其一半。

