Qt Quick Scene Graph 实战1:自定义几何与材质
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_castVertex *(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_castQSGGeometryNode *(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_castQSGGeometryNode *(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_VISUALIZEbatches yourApp # 可视化批处理 QSG_RENDERER_DEBUGrender 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_castQSGGeometryNode *(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_castDynamicGeometry *(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查询当前使用的图形APIif (window-rendererInterface()-graphicsApi() QSGRendererInterface::OpenGL) { // OpenGL特定代码 }这种抽象让你的代码更容易适配不同的图形后端如Vulkan、Metal或Direct3D。