OpenGL under QML Squircle

The OpenGL under QML example shows how an application can make use of the QQuickWindow::beforeRendering() signal to draw custom OpenGL content under a Qt Quick scene. This signal is emitted at the start of every frame, before the scene graph starts its rendering, thus any OpenGL draw calls that are made as a response to this signal, will stack under the Qt Quick items.

As an alternative, applications that wish to render OpenGL content on top of the Qt Quick scene, can do so by connecting to the QQuickWindow::afterRendering() signal.

In this example, we will also see how it is possible to have values that are exposed to QML which affect the OpenGL rendering. We animate the threshold value using a NumberAnimation in the QML file and this value is used by the OpenGL shader program that draws the squircles.

Squircle Screenshot
#############################################################################

import sys
from pathlib import Path

from PySide6.QtCore import QUrl
from PySide6.QtGui import QGuiApplication
from PySide6.QtQuick import QQuickView, QQuickWindow, QSGRendererInterface

from squircle import Squircle

if __name__ == "__main__":
    app = QGuiApplication(sys.argv)

    QQuickWindow.setGraphicsApi(QSGRendererInterface.OpenGL)

    view = QQuickView()
    view.setResizeMode(QQuickView.SizeRootObjectToView)
    qml_file = Path(__file__).parent / "main.qml"
    view.setSource(QUrl.fromLocalFile(qml_file))

    if view.status() == QQuickView.Error:
        sys.exit(-1)
    view.show()

    sys.exit(app.exec())
import QtQuick
import OpenGLUnderQML

Item {

    width: 320
    height: 480

    Squircle {
        SequentialAnimation on t {
            NumberAnimation { to: 1; duration: 2500; easing.type: Easing.InQuad }
            NumberAnimation { to: 0; duration: 2500; easing.type: Easing.OutQuad }
            loops: Animation.Infinite
            running: true
        }
    }
    Rectangle {
        color: Qt.rgba(1, 1, 1, 0.7)
        radius: 10
        border.width: 1
        border.color: "white"
        anchors.fill: label
        anchors.margins: -10
    }

    Text {
        id: label
        color: "black"
        wrapMode: Text.WordWrap
        text: "The background here is a squircle rendered with raw OpenGL using the 'beforeRender()' signal in QQuickWindow. This text label and its border is rendered using QML"
        anchors.right: parent.right
        anchors.left: parent.left
        anchors.bottom: parent.bottom
        anchors.margins: 20
    }
}
from PySide6.QtCore import Property, QRunnable, Qt, Signal, Slot
from PySide6.QtQml import QmlElement
from PySide6.QtQuick import QQuickItem, QQuickWindow

from squirclerenderer import SquircleRenderer

# To be used on the @QmlElement decorator
# (QML_IMPORT_MINOR_VERSION is optional)
QML_IMPORT_NAME = "OpenGLUnderQML"
QML_IMPORT_MAJOR_VERSION = 1


class CleanupJob(QRunnable):
    def __init__(self, renderer):
        super().__init__()
        self._renderer = renderer

    def run(self):
        del self._renderer


@QmlElement
class Squircle(QQuickItem):

    tChanged = Signal()

    def __init__(self, parent=None):
        super().__init__(parent)
        self._t = 0.0
        self._renderer = None
        self.windowChanged.connect(self.handleWindowChanged)

    def t(self):
        return self._t

    def setT(self, value):
        if self._t == value:
            return
        self._t = value
        self.tChanged.emit()
        if self.window():
            self.window().update()

    @Slot(QQuickWindow)
    def handleWindowChanged(self, win):
        if win:
            win.beforeSynchronizing.connect(self.sync, type=Qt.DirectConnection)
            win.sceneGraphInvalidated.connect(self.cleanup, type=Qt.DirectConnection)
            win.setColor(Qt.black)
            self.sync()

    @Slot()
    def cleanup(self):
        del self._renderer
        self._renderer = None

    @Slot()
    def sync(self):
        window = self.window()
        if not self._renderer:
            self._renderer = SquircleRenderer()
            window.beforeRendering.connect(self._renderer.init, Qt.DirectConnection)
            window.beforeRenderPassRecording.connect(
                self._renderer.paint, Qt.DirectConnection
            )
        self._renderer.setViewportSize(window.size() * window.devicePixelRatio())
        self._renderer.setT(self._t)
        self._renderer.setWindow(window)

    def releaseResources(self):
        self.window().scheduleRenderJob(
            CleanupJob(self._renderer), QQuickWindow.BeforeSynchronizingStage
        )
        self._renderer = None

    t = Property(float, t, setT, notify=tChanged)
from textwrap import dedent

import numpy as np
from OpenGL.GL import (GL_ARRAY_BUFFER, GL_BLEND, GL_DEPTH_TEST, GL_FLOAT,
                       GL_ONE, GL_SRC_ALPHA, GL_TRIANGLE_STRIP)
from PySide6.QtCore import QSize, Slot
from PySide6.QtGui import QOpenGLFunctions
from PySide6.QtOpenGL import (QOpenGLShader, QOpenGLShaderProgram,
                              QOpenGLVersionProfile)
from PySide6.QtQuick import QQuickWindow, QSGRendererInterface

VERTEX_SHADER = dedent(
    """\
    attribute highp vec4 vertices;
    varying highp vec2 coords;
    void main() {
        gl_Position = vertices;
        coords = vertices.xy;
    }
    """
)
FRAGMENT_SHADER = dedent(
    """\
    uniform lowp float t;
    varying highp vec2 coords;
    void main() {
        lowp float i = 1. - (pow(abs(coords.x), 4.) + pow(abs(coords.y), 4.));
        i = smoothstep(t - 0.8, t + 0.8, i);
        i = floor(i * 20.) / 20.;
        gl_FragColor = vec4(coords * .5 + .5, i, i);
    }
    """
)


class SquircleRenderer(QOpenGLFunctions):
    def __init__(self):
        QOpenGLFunctions.__init__(self)
        self._viewport_size = QSize()
        self._t = 0.0
        self._program = None
        self._window = QQuickWindow()

    def setT(self, t):
        self._t = t

    def setViewportSize(self, size):
        self._viewport_size = size

    def setWindow(self, window):
        self._window = window

    @Slot()
    def init(self):
        if not self._program:
            rif = self._window.rendererInterface()
            assert (rif.graphicsApi() == QSGRendererInterface.OpenGL)
            self.initializeOpenGLFunctions()
            self._program = QOpenGLShaderProgram()
            self._program.addCacheableShaderFromSourceCode(QOpenGLShader.Vertex, VERTEX_SHADER)
            self._program.addCacheableShaderFromSourceCode(QOpenGLShader.Fragment, FRAGMENT_SHADER)
            self._program.bindAttributeLocation("vertices", 0)
            self._program.link()

    @Slot()
    def paint(self):
        # Play nice with the RHI. Not strictly needed when the scenegraph uses
        # OpenGL directly.
        self._window.beginExternalCommands()

        self._program.bind()

        self._program.enableAttributeArray(0)

        values = np.array([-1, -1, 1, -1, -1, 1, 1, 1], dtype="single")

        # This example relies on (deprecated) client-side pointers for the vertex
        # input. Therefore, we have to make sure no vertex buffer is bound.
        self.glBindBuffer(GL_ARRAY_BUFFER, 0)

        self._program.setAttributeArray(0, GL_FLOAT, values, 2)
        self._program.setUniformValue1f("t", self._t)

        self.glViewport(0, 0, self._viewport_size.width(), self._viewport_size.height())

        self.glDisable(GL_DEPTH_TEST)

        self.glEnable(GL_BLEND)
        self.glBlendFunc(GL_SRC_ALPHA, GL_ONE)

        self.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4)

        self._program.disableAttributeArray(0)
        self._program.release()

        self._window.endExternalCommands()