场景图 - 在 QML 下使用 RHI

展示了如何在一个 Qt Quick 场景下直接使用 QRhi 进行渲染。

简介

在 QML 下使用 RHI 的示例展示了如何通过连接到 QQuickWindow::beforeRendering() 和 QQuickWindow::beforeRenderPassRecording() 信号,在 Qt Quick 场景下方绘制基于 QRhi 的自定义内容。

希望在实际的 Qt Quick 场景上方绘制 QRhi 内容的应用程序可以通过连接到 QQuickWindow::afterRendering() 和 QQuickWindow::afterRenderPassRecording() 信号来实现。

在这个例子中,我们还将看到如何有一些值暴露给 QML,这些值会影响基于 QRhi 的渲染。我们使用 QML 文件中的 NumberAnimation 来动画化阈值值,然后将这个浮点值传递到片元着色器中的统一缓冲区。

这个示例在大多数方面与 OpenGL 在 QML 下Direct3D 11 在 QML 下Metal 在 QML 下Vulkan 在 QML 下 示例相同。这些例子通过直接使用 3D API 渲染相同的内容。然而,这个示例完全跨平台且可移植,因为它本质上支持与 QRhi 支持的所有 3D API 进行操作(例如,OpenGL、Vulkan、Metal、Direct 3D 11 和 12)。

注意:此示例演示了高级、低级的、跨平台 3D 渲染功能,并依赖于来自 Qt Gui 模块具有有限兼容性保证的 API。要使用 QRhi API,应用程序需要链接到 Qt::GuiPrivate 并包含 <rhi/qrhi.h>

将自定义渲染作为底层/图层添加是集成自定义 2D/3D 渲染到 Qt Quick 场景的三种方法之一。其他两种选项是在 Qt Quick 场景自己的渲染中“内联”执行渲染,使用 QSGRenderNode 执行,或者生成一个针对专用渲染目标(纹理)的单独渲染通道,然后在场景中的一个项中显示该纹理。有关这些方法,请参阅 场景图 - RHI 纹素项场景图 - 自定义 QSGRenderNode 示例。

核心概念

在每帧的开始时,会发出beforeRendering()信号,在场景图开始其渲染之前,因此相应于此信号的任何QRhi绘图调用将会堆积在Qt Quick项之下。然而,这里有两条相关的信号:应用程序自己的QRhi命令应记录到与场景图使用的相同命令缓冲区中,而且更重要的是,命令应属于同一渲染通道。仅凭beforeRendering()本身是不够的,因为它在帧的开始发出,在通过QRhiCommandBuffer::beginPass()开始记录渲染通道之前。通过连接到beforeRenderPassRecording(),应用程序自己的命令和场景图的渲染将最终以正确的顺序完成。

教程

自定义渲染被封装在一个自定义的QQuickItem中。RhiSquircleQQuickItem派生,并且暴露给了QML(注意QML_ELEMENT)。QML场景实例化了RhiSquircle。然而请注意,这并不是一个视觉项:QQuickItem::ItemHasContents标志未设置。因此项的位置和大小无关紧要,且没有重新实现updatePaintNode()。

class RhiSquircle : public QQuickItem
{
    Q_OBJECT
    Q_PROPERTY(qreal t READ t WRITE setT NOTIFY tChanged)
    QML_ELEMENT

public:
    RhiSquircle();

    qreal t() const { return m_t; }
    void setT(qreal t);

signals:
    void tChanged();

public slots:
    void sync();
    void cleanup();

private slots:
    void handleWindowChanged(QQuickWindow *win);

private:
    void releaseResources() override;

    qreal m_t = 0;
    SquircleRenderer *m_renderer = nullptr;
};

相反,当该项与一个QQuickWindow相关联时,它连接到QQuickWindow::beforeSynchronizing()信号。使用Qt::DirectConnection很重要,因为这个信号是在Qt Quick渲染线程发出的,如果有的话。我们希望连接的槽在这个线程上调用。

RhiSquircle::RhiSquircle()
{
    connect(this, &QQuickItem::windowChanged, this, &RhiSquircle::handleWindowChanged);
}

void RhiSquircle::handleWindowChanged(QQuickWindow *win)
{
    if (win) {
        connect(win, &QQuickWindow::beforeSynchronizing, this, &RhiSquircle::sync, Qt::DirectConnection);
        connect(win, &QQuickWindow::sceneGraphInvalidated, this, &RhiSquircle::cleanup, Qt::DirectConnection);
        // Ensure we start with cleared to black. The squircle's blend mode relies on this.
        win->setColor(Qt::black);
    }
}

在场景图的同步阶段,除非已经完成,否则将创建渲染基础设施,并将与渲染相关的数据同步,即从主线程上的RhiSquircle项复制到渲染线程上的SquircleRenderer对象。(如果没有渲染线程,则这两个对象都位于主线程上)访问数据是安全的,因为在渲染线程执行其同步阶段时,主线程被阻塞。有关场景图线程和渲染模型的更多信息,请参阅Qt Quick场景图

除了t的值外,还会复制关联的QQuickWindow指针。尽管SquircleRenderer可以在渲染线程上操作时查询RhiSquircle项的window(),这在理论上是完全安全的,但为了保险起见,还是进行复制。

在设置SquircleRenderer时,会连接到beforeRendering()和beforeRenderPassRecording(),这是在适当的时间执行和注入应用程序自定义3D渲染命令的关键。

void RhiSquircle::sync()
{
    // This function is invoked on the render thread, if there is one.

    if (!m_renderer) {
        m_renderer = new SquircleRenderer;
        // Initializing resources is done before starting to record the
        // renderpass, regardless of wanting an underlay or overlay.
        connect(window(), &QQuickWindow::beforeRendering, m_renderer, &SquircleRenderer::frameStart, Qt::DirectConnection);
        // Here we want an underlay and therefore connect to
        // beforeRenderPassRecording. Changing to afterRenderPassRecording
        // would render the squircle on top (overlay).
        connect(window(), &QQuickWindow::beforeRenderPassRecording, m_renderer, &SquircleRenderer::mainPassRecordingStart, Qt::DirectConnection);
    }
    m_renderer->setT(m_t);
    m_renderer->setWindow(window());
}

beforeRendering()事件被触发时,如果尚未创建,则会创建我们自定义渲染所需的所有QRhi资源,例如QRhiBufferQRhiGraphicsPipeline和相关的对象。

使用QRhiResourceUpdateBatchQRhiCommandBuffer::resourceUpdate()更新缓冲区中的数据(更确切地说是将数据更新操作入队)。一旦将初始顶点集合上传到顶点缓冲区中,顶点缓冲区的内容将不会更改。然而,统一缓冲区是一个动态缓冲区,这在此类缓冲区中很典型。至少某些区域的内容会在每个帧更新。因此,无条件调用updateDynamicBuffer()以更新偏移量为0且字节大小为4(这是由于C++中的float类型恰好匹配GLSL的32位float,所以它是sizeof(float))的缓冲区。在该位置存储的是t的值,这会在每个帧中更新,也就是说在每个frameStart()调用时更新。

在缓冲区中还有一个浮点值,起始偏移量为4。这是为了解决3D API的坐标系差异:当isYUpInNDC()返回false时,特别是在Vulkan中,该值会被设置为-1.0,这将导致翻转用于计算颜色的基于该值的按插值传递给片段着色器的2分量向量的Y值。这样,屏幕上的输出就相同(即左上角是绿色,右下角是红色),无论使用的是哪种3D API。此值同样只会更新一次到统一缓冲区,类似于顶点缓冲区。这说明了底层渲染代码通常需要解决的一个问题:在归一化设备坐标(NDC)和图像中以及帧缓冲区中的坐标系差异。例如,NDC除了Vulkan之外,所有地方都使用底左原点系统。而帧缓冲区除了OpenGL之外,所有地方都使用顶左原点系统。对于使用透视投影的典型渲染器,通常可以通过利用QRhi::clipSpaceCorrMatrix来解决此问题,它是一个可以通过乘到投影矩阵中来应用的矩阵,并在需要时执行Y翻转,同时应对clip space深度在OpenGL中为-1..1,在其他地方为0..1的情况。然而,在某些情况下,例如在本文的示例中,这不是适用的。而是应用程序和着色器逻辑需要根据查询QRhi::isYUpInNDC()和QRhi::isYUpInFramebuffer()来适当地 adjusts顶点和UV位置。

要获取Qt Quick使用的QRhiQRhiSwapChain对象,可以直接从QQuickWindow查询。请注意,这假设QQuickWindow是一个常规的、屏幕上的窗口。如果它使用了QQuickRenderControl,例如将离屏渲染到一个纹理中,那么查询swapchain将是错误的,因为那时没有swapchain。

由于信号是在Qt Quick调用QRhi::beginFrame之后发射的,因此已经可以从swapchain中查询命令缓冲区和渲染目标。这就是为什么我们可以方便地在从QRhiSwapChain::currentFrameCommandBuffer返回的对象上发出QRhiCommandBuffer::resourceUpdate()。在创建图形管线时,可以从从QRhiSwapChain::currentFrameRenderTarget返回的QRhiRenderTarget中检索QRhiRenderPassDescriptor。(注意这意味着在这里构建的图形管线仅适用于渲染到swapchain,或者至多适用于与其兼容的另一个渲染目标;如果想要渲染到纹理,则需要不同的QRhiRenderPassDescriptor,因此需要不同的图形管线,因为纹理和swapchain的格式可能不同)

void SquircleRenderer::frameStart()
{
    // This function is invoked on the render thread, if there is one.

    QRhi *rhi = m_window->rhi();
    if (!rhi) {
        qWarning("QQuickWindow is not using QRhi for rendering");
        return;
    }
    QRhiSwapChain *swapChain = m_window->swapChain();
    if (!swapChain) {
        qWarning("No QRhiSwapChain?");
        return;
    }
    QRhiResourceUpdateBatch *resourceUpdates = rhi->nextResourceUpdateBatch();

    if (!m_pipeline) {
        m_vertexShader = getShader(QLatin1String(":/scenegraph/rhiunderqml/squircle_rhi.vert.qsb"));
        if (!m_vertexShader.isValid())
            qWarning("Failed to load vertex shader; rendering will be incorrect");

        m_fragmentShader = getShader(QLatin1String(":/scenegraph/rhiunderqml/squircle_rhi.frag.qsb"));
        if (!m_fragmentShader.isValid())
            qWarning("Failed to load fragment shader; rendering will be incorrect");

        m_vertexBuffer.reset(rhi->newBuffer(QRhiBuffer::Immutable, QRhiBuffer::VertexBuffer, sizeof(vertices)));
        m_vertexBuffer->create();
        resourceUpdates->uploadStaticBuffer(m_vertexBuffer.get(), vertices);

        const quint32 UBUF_SIZE = 4 + 4; // 2 floats
        m_uniformBuffer.reset(rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, UBUF_SIZE));
        m_uniformBuffer->create();

        float yDir = rhi->isYUpInNDC() ? 1.0f : -1.0f;
        resourceUpdates->updateDynamicBuffer(m_uniformBuffer.get(), 4, 4, &yDir);

        m_srb.reset(rhi->newShaderResourceBindings());
        const auto visibleToAll = QRhiShaderResourceBinding::VertexStage | QRhiShaderResourceBinding::FragmentStage;
        m_srb->setBindings({
            QRhiShaderResourceBinding::uniformBuffer(0, visibleToAll, m_uniformBuffer.get())
        });
        m_srb->create();

        QRhiVertexInputLayout inputLayout;
        inputLayout.setBindings({
            { 2 * sizeof(float) }
        });
        inputLayout.setAttributes({
            { 0, 0, QRhiVertexInputAttribute::Float2, 0 }
        });

        m_pipeline.reset(rhi->newGraphicsPipeline());
        m_pipeline->setTopology(QRhiGraphicsPipeline::TriangleStrip);
        QRhiGraphicsPipeline::TargetBlend blend;
        blend.enable = true;
        blend.srcColor = QRhiGraphicsPipeline::SrcAlpha;
        blend.srcAlpha = QRhiGraphicsPipeline::SrcAlpha;
        blend.dstColor = QRhiGraphicsPipeline::One;
        blend.dstAlpha = QRhiGraphicsPipeline::One;
        m_pipeline->setTargetBlends({ blend });
        m_pipeline->setShaderStages({
            { QRhiShaderStage::Vertex, m_vertexShader },
            { QRhiShaderStage::Fragment, m_fragmentShader }
        });
        m_pipeline->setVertexInputLayout(inputLayout);
        m_pipeline->setShaderResourceBindings(m_srb.get());
        m_pipeline->setRenderPassDescriptor(swapChain->currentFrameRenderTarget()->renderPassDescriptor());
        m_pipeline->create();
    }

    float t = m_t;
    resourceUpdates->updateDynamicBuffer(m_uniformBuffer.get(), 0, 4, &t);

    swapChain->currentFrameCommandBuffer()->resourceUpdate(resourceUpdates);
}

最后,在QQuickWindow::beforeRenderPassRecording上,记录了一个包含4个顶点的三角形条带绘制调用。这个例子实际上简单地绘制了一个四边形,并使用片元着色器的逻辑计算像素颜色,但应用程序可以进行更复杂的绘图:创建多个图形管线并记录多个绘制调用也是完全可行的。重要的是要注意,无论记录在从窗口的swapchain获取的QRhiCommandBuffer上的是什么,它实际上都是在主渲染通道内Qt Quick场景图自身渲染之前追加的。

注意:这表示如果涉及使用深度测试和写入深度值的深度缓冲区使用,则Qt Quick内容可能会受到写入深度缓冲区的值的影响。有关场景图渲染器的详细信息,请参阅Qt Quick场景图默认渲染器,特别是关于处理不透明alpha混合原语的部分。

要获取窗口的像素大小,使用QRhiRenderTarget::pixelSize。这样做很方便,因为这样可以避免通过其他方法计算视口大小,也不必担心应用高DPI缩放因子,如果有的话。

void SquircleRenderer::mainPassRecordingStart()
{
    // This function is invoked on the render thread, if there is one.

    QRhi *rhi = m_window->rhi();
    QRhiSwapChain *swapChain = m_window->swapChain();
    if (!rhi || !swapChain)
        return;

    const QSize outputPixelSize = swapChain->currentFrameRenderTarget()->pixelSize();
    QRhiCommandBuffer *cb = m_window->swapChain()->currentFrameCommandBuffer();
    cb->setViewport({ 0.0f, 0.0f, float(outputPixelSize.width()), float(outputPixelSize.height()) });
    cb->setGraphicsPipeline(m_pipeline.get());
    cb->setShaderResources();
    const QRhiCommandBuffer::VertexInput vbufBinding(m_vertexBuffer.get(), 0);
    cb->setVertexInput(0, 1, &vbufBinding);
    cb->draw(4);
}

顶点和片元着色器通过标准的QRhi着色器处理管道。最初用与Vulkan兼容的GLSL编写,它们被编译为SPIR-V,然后由Qt的工具转换成其他着色语言。当使用CMake时,示例依赖于简单的qt_add_shaders命令,这使得将着色器与应用程序捆绑在一起以及在编译时间执行必要的处理变得简单快捷。有关详细信息,请参阅Qt着色器工具构建系统集成

指定BASE有助于去除../shared前缀,而PREFIX则添加了预期的/scenegraph/rhiunderqml前缀。因此最终路径是:/scenegraph/rhiunderqml/squircle_rhi.vert.qsb

qt_add_shaders(rhiunderqml "rhiunderqml_shaders"
    PRECOMPILE
    OPTIMIZED
    PREFIX
        /scenegraph/rhiunderqml
    BASE
        ../shared
    FILES
        ../shared/squircle_rhi.vert
        ../shared/squircle_rhi.frag
)

为了支持qmake,该示例仍然包含构建时通常生成的.qsb文件,并将它们列在qrc文件中。然而,对于使用CMake作为构建系统的新的应用程序,此方法不建议使用。

示例项目在 code.qt.io

另请参阅:场景图 - RHI纹理项场景图 - 自定义QSGRenderNode

© 2024 Qt公司有限公司。本文件中包含的文档贡献是各自所有者的版权。本文件提供的文档授权根据由自由软件基金会发布的GNU自由文档许可1.3版条款提供。Qt及其相关标志是Qt公司有限公司在芬兰及/或全球其他国家的商标。所有其他商标均为各自所有者的财产。