1. 为什么需要自定义几何与材质
在Qt Quick开发中,我们通常使用现成的QML元素就能完成大部分UI开发。但当你需要实现特殊形状的绘制、高性能动画或者复杂视觉效果时,标准组件往往力不从心。这时候就需要深入到Qt Quick Scene Graph(场景图)的底层,直接操作几何体和材质。
我刚开始接触场景图时,最困惑的就是为什么要绕开方便的QPainter,去折腾这些底层概念。直到有一次需要实现一个实时心电图波形渲染,用QQuickPaintedItem发现性能完全跟不上,才明白场景图的优势所在。场景图直接操作OpenGL(或其它图形API),避免了QPainter的中间层开销,性能可以提升数倍。
自定义几何体(QSGGeometry)本质上就是定义你要绘制的形状的顶点数据。比如画一个三角形需要3个顶点,画矩形需要4个顶点(或2个三角形共6个顶点)。而材质(QSGMaterial)则决定了这些顶点如何被渲染,包括颜色、纹理、光照等效果。
2. 场景图基础架构解析
2.1 场景图核心组件
Qt Quick的场景图系统由几个关键类组成:
- QSGNode:场景图中的基类节点,可以理解为场景中的任意对象
- QSGGeometryNode:继承自QSGNode,专门用于可渲染的几何体
- QSGGeometry:定义几何体的顶点数据和绘制方式
- QSGMaterial:定义几何体的表面特性(颜色、纹理等)
- QSGMaterialShader:实现实际的着色器代码
这种架构和主流3D引擎(如Unity、Unreal)非常相似。一个QSGGeometryNode必须包含一个QSGGeometry和一个QSGMaterial,就像3D模型需要网格和材质一样。
2.2 渲染管线工作流程
当QML场景需要渲染时,场景图会执行以下步骤:
- 遍历场景树,收集所有需要渲染的节点
- 对节点进行排序和批处理,优化渲染性能
- 为每个QSGGeometryNode准备顶点数据
- 调用对应QSGMaterial的着色器进行绘制
理解这个流程很重要,因为它解释了为什么我们需要在updatePaintNode中设置dirty标志。只有标记为dirty的节点才会被重新处理,这是场景图性能优化的关键。
3. 从零实现自定义几何体
3.1 继承QSGGeometry
让我们从最简单的需求开始:绘制一条带顶点颜色的线段。Qt提供了QSGGeometry::defaultAttributes_ColoredPoint2D(),但为了更深入理解,我们完全自己实现一个。
class CustomGeometry : public QSGGeometry { public: struct Vertex { float x, y; unsigned char r, g, b, a; void set(float nx, float ny, uchar nr, uchar ng, uchar nb, uchar na = 255) { x = nx; y = ny; r = nr; g = ng; b = nb; a = na; } }; static const QSGGeometry::AttributeSet &customAttributes() { static QSGGeometry::Attribute attr[] = { QSGGeometry::Attribute::create(0, 2, GL_FLOAT, true), // 位置 QSGGeometry::Attribute::create(1, 4, GL_UNSIGNED_BYTE, false) // 颜色 }; static QSGGeometry::AttributeSet attrs = { 2, sizeof(Vertex), attr }; return attrs; } CustomGeometry(int vertexCount) : QSGGeometry(customAttributes(), vertexCount) {} Vertex *vertexData() { return static_cast<Vertex *>(QSGGeometry::vertexData()); } };这个自定义几何体类做了几件关键事情:
- 定义了顶点数据结构,包含位置(x,y)和颜色(r,g,b,a)
- 创建了属性集(AttributeSet),告诉渲染器如何解析顶点数据
- 提供了方便的vertexData()访问方法
3.2 在QQuickItem中使用
有了自定义几何体,我们需要在QQuickItem中使用它:
class CustomLineItem : public QQuickItem { Q_OBJECT public: CustomLineItem(QQuickItem *parent = nullptr) : QQuickItem(parent) { setFlag(ItemHasContents, true); // 必须设置! } protected: QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) override { QSGGeometryNode *node = static_cast<QSGGeometryNode *>(oldNode); if (!node) { node = new QSGGeometryNode; CustomGeometry *geometry = new CustomGeometry(2); geometry->setDrawingMode(GL_LINES); node->setGeometry(geometry); node->setFlag(QSGNode::OwnsGeometry); CustomMaterial *material = new CustomMaterial; node->setMaterial(material); node->setFlag(QSGNode::OwnsMaterial); } CustomGeometry::Vertex *vertices = node->geometry()->vertexData(); vertices[0].set(0, 0, 255, 0, 0); // 起点红色 vertices[1].set(width(), height(), 0, 0, 255); // 终点蓝色 node->markDirty(QSGNode::DirtyGeometry); return node; } };这里有几个关键点容易出错:
- 忘记setFlag(ItemHasContents, true)会导致不渲染
- 没有设置OwnsGeometry和OwnsMaterial会造成内存泄漏
- 更新顶点数据后必须调用markDirty,否则修改不会生效
4. 深度定制材质系统
4.1 实现自定义材质
材质决定了几何体如何被着色。Qt提供了QSGFlatColorMaterial等简单材质,但功能有限。要实现顶点颜色效果,我们需要自定义材质:
class CustomMaterial : public QSGMaterial { public: QSGMaterialType *type() const override { static QSGMaterialType type; return &type; } QSGMaterialShader *createShader() const override { return new CustomShader; } int compare(const QSGMaterial *other) const override { return 0; // 所有CustomMaterial实例视为相同 } };4.2 编写GLSL着色器
材质的核心是着色器。我们需要编写顶点和片段着色器:
class CustomShader : public QSGMaterialShader { public: CustomShader() { setShaderSourceCode(QOpenGLShader::Vertex, R"( attribute vec4 vertexCoord; attribute vec4 vertexColor; uniform mat4 matrix; uniform float opacity; varying vec4 color; void main() { gl_Position = matrix * vertexCoord; color = vertexColor * opacity; } )"); setShaderSourceCode(QOpenGLShader::Fragment, R"( varying vec4 color; void main() { gl_FragColor = color; } )"); } void updateState(const RenderState &state, QSGMaterial *, QSGMaterial *) override { if (state.isMatrixDirty()) program()->setUniformValue(m_matrixId, state.combinedMatrix()); if (state.isOpacityDirty()) program()->setUniformValue(m_opacityId, state.opacity()); } void initialize() override { m_matrixId = program()->uniformLocation("matrix"); m_opacityId = program()->uniformLocation("opacity"); } private: int m_matrixId; int m_opacityId; };着色器代码中的几个关键点:
- vertexCoord和vertexColor对应我们几何体中的属性
- matrix是场景图自动提供的变换矩阵
- opacity处理节点的透明度
- varying变量用于在顶点和片段着色器间传递数据
5. 性能优化实战技巧
5.1 减少状态变更
场景图渲染性能的关键在于最小化状态变更。每次切换材质、着色器或几何体都会带来开销。我们可以通过以下方式优化:
- 材质合并:尽量使用相同材质的不同实例
- 几何体批处理:将多个简单几何体合并为一个
- 避免频繁更新:只在数据变化时调用update()
// 不好的做法:每次更新都创建新几何体 QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { QSGGeometryNode *node = new QSGGeometryNode; // 每次都新建 // ... } // 好的做法:复用现有节点 QSGNode *updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { QSGGeometryNode *node = static_cast<QSGGeometryNode *>(oldNode); if (!node) { node = new QSGGeometryNode; // 初始化... } // 更新数据... return node; }5.2 合理使用Dirty标志
场景图通过dirty标志知道需要更新什么。过度标记会导致不必要的计算,标记不足则会导致渲染错误。正确的做法是:
- DirtyGeometry:顶点数据变化时使用
- DirtyMaterial:材质属性变化时使用
- DirtyMatrix:变换矩阵变化时使用
// 只更新必要的部分 if (positionsChanged) { updatePositions(); node->markDirty(QSGNode::DirtyGeometry); } if (colorChanged) { updateColor(); node->markDirty(QSGNode::DirtyMaterial); }6. 常见问题排查指南
6.1 渲染空白或异常
当你的自定义渲染不显示或显示异常时,可以按以下步骤排查:
- 检查ItemHasContents标志:没有设置这个标志就不会调用updatePaintNode
- 验证顶点数据:确保顶点坐标在可视范围内(通常是-1到1的标准化坐标)
- 检查着色器编译:在initialize()中添加错误检查
- 确认属性绑定:着色器中的attribute名称必须与几何体属性匹配
void initialize() override { if (!program()->link()) { qWarning() << "Shader link error:" << program()->log(); } // ... }6.2 性能问题诊断
如果渲染性能不佳,可以使用Qt的场景图调试工具:
QSG_VISUALIZE=batches yourApp # 可视化批处理 QSG_RENDERER_DEBUG=render yourApp # 显示渲染统计这些工具会显示批处理情况和渲染耗时,帮助你找到性能瓶颈。在我的项目中,通过优化批处理将帧率从30fps提升到了60fps。
7. 进阶应用:动态几何体更新
前面的例子展示了静态几何体,但实际应用中经常需要动态更新。比如实现一个可交互的绘图工具:
class DynamicGeometry : public QSGGeometry { public: // ... 同前面的CustomGeometry void appendPoint(float x, float y, const QColor &color) { int oldCount = vertexCount(); allocate(oldCount + 1); Vertex *v = vertexData(); v[oldCount].set(x, y, color.red(), color.green(), color.blue(), color.alpha()); } }; // 在QQuickItem中 void DynamicLineItem::addPoint(const QPointF &point) { m_points.append(point); update(); // 触发updatePaintNode调用 } QSGNode *DynamicLineItem::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *) { QSGGeometryNode *node = static_cast<QSGGeometryNode *>(oldNode); if (!node) { node = new QSGGeometryNode; DynamicGeometry *geometry = new DynamicGeometry(0); geometry->setDrawingMode(GL_LINE_STRIP); node->setGeometry(geometry); node->setFlag(QSGNode::OwnsGeometry); // ... 设置材质 } DynamicGeometry *geometry = static_cast<DynamicGeometry *>(node->geometry()); geometry->allocate(m_points.size()); DynamicGeometry::Vertex *vertices = geometry->vertexData(); for (int i = 0; i < m_points.size(); ++i) { const QPointF &p = m_points[i]; vertices[i].set(p.x(), p.y(), 0, 0, 255); } node->markDirty(QSGNode::DirtyGeometry); return node; }这种动态更新模式非常适合实现绘图应用、图表渲染等需要频繁更新几何体的场景。关键是要注意:
- 合理使用allocate()调整顶点缓冲区大小
- 避免每帧都重新分配内存
- 只在数据实际变化时更新
8. 跨平台兼容性考量
虽然场景图默认使用OpenGL,但Qt也支持其他图形API。要确保代码的兼容性,需要注意:
- 避免直接调用OpenGL:使用QSG提供的抽象接口
- 检查着色器语法:不同后端可能有细微差异
- 处理上下文丢失:某些平台可能会重置图形上下文
// 不好的做法:直接调用OpenGL glLineWidth(2.0f); // 好的做法:使用QSGGeometry接口 geometry->setLineWidth(2);在实现材质时,可以通过QSGRendererInterface查询当前使用的图形API:
if (window->rendererInterface()->graphicsApi() == QSGRendererInterface::OpenGL) { // OpenGL特定代码 }这种抽象让你的代码更容易适配不同的图形后端,如Vulkan、Metal或Direct3D。