[system/karton] src: Refactor DomainViewer to take generic SpiceDisplayRenderer (GL scanout/primary surface)

Derek Lin <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit 9c4d3939a858d554f72aaac1ba28d0736a19bc7d by Derek Lin.
Committed on 30/07/2026 at 19:53.
Pushed by kenoi into branch 'master'.

Refactor DomainViewer to take generic SpiceDisplayRenderer (GL scanout/primary surface)

M  +3    -0    src/CMakeLists.txt
M  +44   -264  src/domainviewer.cpp
M  +12   -24   src/domainviewer.h
A  +294  -0    src/glscanoutrenderer.cpp     [License: GPL(v3.0+)]
A  +51   -0    src/glscanoutrenderer.h     [License: GPL(v3.0+)]
A  +109  -0    src/primarysurfacerenderer.cpp     [License: GPL(v3.0+)]
A  +43   -0    src/primarysurfacerenderer.h     [License: GPL(v3.0+)]
A  +35   -0    src/spicedisplayrenderer.h     [License: GPL(v3.0+)]

https://invent.kde.org/system/karton/-/commit/9c4d3939a858d554f72aaac1ba28d0736a19bc7d

diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b59fd6d..61d23bf 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -26,6 +26,9 @@ target_sources(karton
     libvirtmonitor.cpp
     libvirteventloop.cpp
     domainviewer.cpp
+    spicedisplayrenderer.h
+    glscanoutrenderer.cpp
+    primarysurfacerenderer.cpp
     domaininstaller.cpp
     osinfoconfig.cpp
     domainxmlreader.cpp
diff --git a/src/domainviewer.cpp b/src/domainviewer.cpp
index c2d3d83..0a4b3dd 100644
--- a/src/domainviewer.cpp
+++ b/src/domainviewer.cpp
@@ -3,11 +3,6 @@
 
 #include "domainviewer.h"
 
-#include <QOpenGLContext>
-#include <QOpenGLFunctions>
-#include <QSGSimpleTextureNode>
-
-#include <rhi/qrhi.h> // need
 #include <spice-client.h>
 
 #include <QGuiApplication>
@@ -17,7 +12,9 @@
 
 #include "domain.h"
 #include "glib.h"
+#include "glscanoutrenderer.h"
 #include "karton_debug.h"
+#include "primarysurfacerenderer.h"
 
 #include "evdev_to_xtkbd_map.h"
 
@@ -30,12 +27,6 @@ DomainViewer::DomainViewer(QQuickItem *parent)
     , m_port(5900)
     , m_password(QString())
     , m_connected(false)
-    , m_imageWidth(0)
-    , m_imageHeight(0)
-    , m_scanout({})
-    , m_hasScanout(false)
-    , m_eglImage(EGL_NO_IMAGE_KHR)
-    , m_texId(0)
     , m_session(nullptr)
     , m_display_channel(nullptr)
     , m_inputs_channel(nullptr)
@@ -106,10 +97,12 @@ void DomainViewer::handleHostPort(int exitCode, const QString &output)
     m_port = url.port(); // defaults to -1 if not found
     m_spiceUri = url.toString();
 
-    if (url.toString().startsWith(QStringLiteral("spice+unix:///tmp/spice"))) {
-        qCInfo(KARTON_DEBUG) << "Detected UNIX socket. Connection parameters set.";
+    if (m_spiceUri.startsWith(QStringLiteral("spice+unix:///tmp/spice"))) {
+        qCInfo(KARTON_DEBUG) << "Detected UNIX socket. Using GL scanout rendering.";
+        m_renderMode = RenderMode::GlScanout;
     } else {
-        qCInfo(KARTON_DEBUG) << "Detected network TCP socket. Connection parameters set.";
+        qCInfo(KARTON_DEBUG) << "Detected network TCP socket. Using primary surface rendering.";
+        m_renderMode = RenderMode::PrimarySurface;
     }
 
     if (!connectToSpice()) {
@@ -161,6 +154,11 @@ void DomainViewer::disconnectFromSpice()
 {
     stopAudio();
 
+    if (m_renderer) {
+        m_renderer->detach();
+        m_renderer.reset();
+    }
+
     if (m_session) {
         spice_session_disconnect(m_session);
 
@@ -182,13 +180,7 @@ void DomainViewer::channel_new_callback(SpiceSession *session, SpiceChannel *cha
     item->checkChannelStatus(); // uncomment for channel debug msgs
     if (SPICE_IS_DISPLAY_CHANNEL(channel)) {
         qCInfo(KARTON_DEBUG) << "SPICE display connected";
-
-        spice_channel_connect(channel);
-        item->m_display_channel = channel;
-
-        g_signal_connect(channel, "gl-draw", G_CALLBACK(gl_draw_callback), item);
-        // TODO: Might need to check if gl is enabled. Domains created by virt-manager are not accel3d by default.
-
+        item->attachDisplayChannel(channel);
     } else if (SPICE_IS_INPUTS_CHANNEL(channel)) {
         qCInfo(KARTON_DEBUG) << "SPICE: Inputs connected";
         spice_channel_connect(channel);
@@ -208,259 +200,44 @@ void DomainViewer::channel_new_callback(SpiceSession *session, SpiceChannel *cha
 
 // ========================== Display rendering  ========================
 
-void DomainViewer::gl_draw_callback(SpiceDisplayChannel *channel, guint x, guint y, guint width, guint height, gpointer user_data)
-{
-    Q_UNUSED(x);
-    Q_UNUSED(y);
-    Q_UNUSED(width);
-    Q_UNUSED(height);
-
-    DomainViewer *item = static_cast<DomainViewer *>(user_data);
-    auto scanout = spice_display_get_gl_scanout(channel);
-    if (!scanout)
-        return;
-    item->handleGlScanout(scanout);
-    spice_display_gl_draw_done(channel); // releases the GL resource
-}
-void DomainViewer::handleGlScanout(const SpiceGlScanout *scanout)
+void DomainViewer::attachDisplayChannel(SpiceChannel *channel)
 {
-    if (m_hasScanout && m_scanout.fd >= 0) {
-        close(m_scanout.fd);
-        m_scanout.fd = -1;
-    }
-
-    cleanupEGLImage();
-
-    m_scanout = *scanout; // struct copy
+    spice_channel_connect(channel);
+    m_display_channel = channel;
 
-    // duplicate the file descriptor if exists
-    // we will be using the duplicate, and the original is freed by SPICE (gl_draw_done).
-    if (scanout->fd >= 0) {
-        m_scanout.fd = dup(scanout->fd);
-        if (m_scanout.fd < 0) {
-            qCWarning(KARTON_DEBUG) << "Failed to duplicate scanout FD";
-            return;
-        }
+    switch (m_renderMode) {
+    case RenderMode::GlScanout:
+        m_renderer = std::make_unique<GlScanoutRenderer>();
+        break;
+    case RenderMode::PrimarySurface:
+        m_renderer = std::make_unique<PrimarySurfaceRenderer>();
+        break;
     }
 
-    m_imageHeight = scanout->height;
-    m_imageWidth = scanout->width;
-    setImplicitWidth(m_imageWidth);
-    setImplicitHeight(m_imageHeight);
-    m_hasScanout = true;
+    connect(m_renderer.get(), &SpiceDisplayRenderer::frameReady, this, &QQuickItem::update);
+    connect(m_renderer.get(), &SpiceDisplayRenderer::frameSizeChanged, this, &DomainViewer::handleRendererFrameSizeChanged);
 
-    update();
+    m_renderer->attach(SPICE_DISPLAY_CHANNEL(channel));
 }
 
-void DomainViewer::createTextureFromScanout(const SpiceGlScanout *scanout)
+void DomainViewer::handleRendererFrameSizeChanged()
 {
-    qCDebug(KARTON_DEBUG) << "=== createTextureFromScanout()";
-    qCDebug(KARTON_DEBUG) << "FD:" << scanout->fd;
-    qCDebug(KARTON_DEBUG) << "Size:" << scanout->width << "x" << scanout->height;
-    qCDebug(KARTON_DEBUG) << "Format:" << QStringLiteral("0x%1").arg(scanout->format, 0, 16);
-    qCDebug(KARTON_DEBUG) << "Stride:" << scanout->stride;
-
-    if (scanout->fd == -1) {
+    if (!m_renderer) {
         return;
     }
-
-    QOpenGLContext *context = QOpenGLContext::currentContext();
-    if (!context) {
-        qCDebug(KARTON_DEBUG) << "No current OpenGL context";
-        return;
-    }
-
-    QOpenGLFunctions *gl = context->functions();
-    if (!gl) {
-        qCDebug(KARTON_DEBUG) << "Failed to get OpenGL functions";
-        return;
-    }
-
-    // generate texture if empty
-    if (m_texId == 0) {
-        gl->glGenTextures(1, &m_texId);
-        if (m_texId == 0) { // glGenTextures modifies m_texId
-            qCDebug(KARTON_DEBUG) << "Failed to generate texture";
-            return;
-        }
-    }
-
-    // setup EGL display
-    EGLDisplay display = eglGetCurrentDisplay();
-    if (display == EGL_NO_DISPLAY) {
-        qCDebug(KARTON_DEBUG) << "Failed to get EGL display";
-        return;
-    }
-
-    EGLint attrs[] = {EGL_DMA_BUF_PLANE0_FD_EXT,
-                      scanout->fd,
-                      EGL_DMA_BUF_PLANE0_PITCH_EXT,
-                      static_cast<EGLint>(scanout->stride),
-                      EGL_DMA_BUF_PLANE0_OFFSET_EXT,
-                      0,
-                      EGL_WIDTH,
-                      static_cast<EGLint>(scanout->width),
-                      EGL_HEIGHT,
-                      static_cast<EGLint>(scanout->height),
-                      EGL_LINUX_DRM_FOURCC_EXT,
-                      static_cast<EGLint>(scanout->format),
-                      EGL_NONE};
-
-    // create egl image
-    if (!m_eglCreateImageKHR) {
-        m_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
-        if (!m_eglCreateImageKHR) {
-            qCDebug(KARTON_DEBUG) << "eglCreateImageKHR not available";
-            return;
-        }
-    }
-    m_eglImage = m_eglCreateImageKHR(display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs);
-
-    if (m_eglImage == EGL_NO_IMAGE_KHR) {
-        EGLint error = eglGetError();
-        qCDebug(KARTON_DEBUG) << "Failed to create EGL image, error:" << QStringLiteral("0x%1").arg(error, 0, 16);
-        return;
-    }
-
-    gl->glBindTexture(GL_TEXTURE_2D, m_texId);
-
-    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
-    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
-    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
-    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
-
-    // set image texture
-    if (!m_glEGLImageTargetTexture2DOES) {
-        m_glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
-        if (!m_glEGLImageTargetTexture2DOES) {
-            qCDebug(KARTON_DEBUG) << "glEGLImageTargetTexture2DOES not supported";
-            return;
-        }
-    }
-    m_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, (GLeglImageOES)m_eglImage);
-
-    GLenum glError = gl->glGetError();
-    if (glError != GL_NO_ERROR) {
-        qCDebug(KARTON_DEBUG) << "OpenGL error after glEGLImageTargetTexture2DOES:" << QStringLiteral("0x%1").arg(glError, 0, 16);
-    }
-
-    gl->glBindTexture(GL_TEXTURE_2D, 0); // unbind texture
+    const QSize size = m_renderer->frameSize();
+    setImplicitWidth(size.width());
+    setImplicitHeight(size.height());
 }
 
 // triggered by update()
 QSGNode *DomainViewer::updatePaintNode(QSGNode *oldNode, UpdatePaintNodeData *)
 {
-    // create opengl texture from scanout data
-    if (m_hasScanout && m_texId == 0) {
-        createTextureFromScanout(&m_scanout);
-    }
-
-    if (!m_texId) {
-        qCDebug(KARTON_DEBUG) << "No texture available yet";
+    if (!m_renderer) {
         delete oldNode;
         return nullptr;
     }
-
-    auto *textureNode = static_cast<QSGSimpleTextureNode *>(oldNode);
-    if (!textureNode) {
-        textureNode = new QSGSimpleTextureNode();
-        textureNode->setOwnsTexture(true);
-    }
-
-    QOpenGLContext *context = QOpenGLContext::currentContext();
-    if (!context) {
-        qCDebug(KARTON_DEBUG) << "No current OpenGL context in updatePaintNode";
-        return textureNode;
-    }
-
-    QOpenGLFunctions *gl = context->functions();
-    if (gl) {
-        gl->glBindTexture(GL_TEXTURE_2D, m_texId);
-        gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
-        gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
-        gl->glBindTexture(GL_TEXTURE_2D, 0);
-    }
-
-    // loading the gl image texture onto the QSG
-    QQuickWindow::CreateTextureOptions options;
-    QRhi *rhi = window()->rhi();
-    if (!rhi) {
-        qCDebug(KARTON_DEBUG) << "No RHI available";
-        return textureNode;
-    }
-
-    QRhiTexture::Format rhiFormat = QRhiTexture::RGBA8;
-    QRhiTexture *rhiTexture = rhi->newTexture(rhiFormat, QSize(m_imageWidth, m_imageHeight));
-    if (!rhiTexture) {
-        qCDebug(KARTON_DEBUG) << "Failed to create RHI texture";
-        return textureNode;
-    }
-
-    // create native texture to be contained in RHI
-    QRhiTexture::NativeTexture nativeTex;
-    nativeTex.object = m_texId;
-    nativeTex.layout = 0;
-
-    if (!rhiTexture->createFrom(nativeTex)) {
-        qCDebug(KARTON_DEBUG) << "Failed to create RHI texture from native";
-        delete rhiTexture;
-        return textureNode;
-    }
-
-    QSGTexture *texture = window()->createTextureFromRhiTexture(rhiTexture, options);
-    if (!texture) {
-        qCDebug(KARTON_DEBUG) << "Failed to create QSG texture";
-        delete rhiTexture;
-        return textureNode;
-    }
-
-    textureNode->setTexture(texture);
-    textureNode->setRect(boundingRect());
-
-    qCDebug(KARTON_DEBUG) << m_domain->config()->name() << ": Successfully updated canvas.";
-    qCDebug(KARTON_DEBUG) << "  SPICE Graphics URI: " << m_spiceUri;
-    qCDebug(KARTON_DEBUG) << "  Texture ID:" << m_texId;
-    qCDebug(KARTON_DEBUG) << "  Size:" << m_imageWidth << "x" << m_imageHeight;
-
-    return textureNode;
-}
-
-// cleans up scanout and egl image, textures
-void DomainViewer::cleanupEGLResources()
-{
-    if (m_hasScanout && m_scanout.fd >= 0) {
-        close(m_scanout.fd);
-        m_scanout.fd = -1;
-    }
-    m_hasScanout = false;
-
-    cleanupEGLImage();
-
-    QOpenGLContext *context = QOpenGLContext::currentContext();
-    if (m_texId && context) {
-        QOpenGLFunctions *gl = context->functions();
-        if (gl) {
-            gl->glDeleteTextures(1, &m_texId);
-        }
-        m_texId = 0;
-    }
-}
-
-// for cleanup of duplicate
-void DomainViewer::cleanupEGLImage()
-{
-    if (m_eglImage != EGL_NO_IMAGE_KHR) {
-        EGLDisplay display = eglGetCurrentDisplay();
-        if (display != EGL_NO_DISPLAY) {
-            if (!m_eglDestroyImageKHR) {
-                m_eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
-            }
-            if (m_eglDestroyImageKHR) {
-                m_eglDestroyImageKHR(display, m_eglImage);
-            }
-        }
-        m_eglImage = EGL_NO_IMAGE_KHR;
-    }
+    return m_renderer->updatePaintNode(window(), oldNode, boundingRect());
 }
 
 void DomainViewer::saveFrameToDomain()
@@ -607,9 +384,10 @@ void DomainViewer::wheelEvent(QWheelEvent *event)
     qreal x = event->position().x();
     qreal y = event->position().y();
 
-    if (m_imageWidth > 0 && m_imageHeight > 0 && width() > 0 && height() > 0) {
-        x = (x * m_imageWidth) / width();
-        y = (y * m_imageHeight) / height();
+    const QSize frameSize = m_renderer ? m_renderer->frameSize() : QSize();
+    if (frameSize.width() > 0 && frameSize.height() > 0 && width() > 0 && height() > 0) {
+        x = (x * frameSize.width()) / width();
+        y = (y * frameSize.height()) / height();
     }
 
     spice_inputs_channel_position(m_inputs_channel, x, y, 0, 0);
@@ -645,9 +423,10 @@ void DomainViewer::mouseMoveEvent(QMouseEvent *event)
     qreal x = event->position().x();
     qreal y = event->position().y();
 
+    const QSize frameSize = m_renderer ? m_renderer->frameSize() : QSize();
     if (width() > 0 && height() > 0) {
-        x = (x * m_imageWidth) / width();
-        y = (y * m_imageHeight) / height();
+        x = (x * frameSize.width()) / width();
+        y = (y * frameSize.height()) / height();
     }
 
     // note: theres a warning that it's deprecated, but newer version has some bug with drag.
@@ -669,9 +448,10 @@ void DomainViewer::hoverMoveEvent(QHoverEvent *event)
         qreal x = event->position().x();
         qreal y = event->position().y();
 
-        if (m_imageWidth > 0 && m_imageHeight > 0 && width() > 0 && height() > 0) {
-            x = (x * m_imageWidth) / width();
-            y = (y * m_imageHeight) / height();
+        const QSize frameSize = m_renderer ? m_renderer->frameSize() : QSize();
+        if (frameSize.width() > 0 && frameSize.height() > 0 && width() > 0 && height() > 0) {
+            x = (x * frameSize.width()) / width();
+            y = (y * frameSize.height()) / height();
         }
 
         spice_inputs_channel_position(m_inputs_channel, x, y, 0, 0);
diff --git a/src/domainviewer.h b/src/domainviewer.h
index bb66c58..5f93400 100644
--- a/src/domainviewer.h
+++ b/src/domainviewer.h
@@ -3,27 +3,23 @@
 
 #pragma once
 
-#include <EGL/egl.h>
-#include <EGL/eglext.h>
-#include <GLES2/gl2.h>
-#include <GLES2/gl2ext.h>
 #include <linux/input-event-codes.h>
 #include <spice-client.h>
 
 #include <QHash>
-#include <QImage>
-#include <QMutex>
 #include <QObject>
 #include <QQuickItem>
 #include <QSGNode>
-#include <QSGTexture>
 
 #include <QAudioFormat>
 #include <QAudioSink>
 #include <QIODevice>
 
+#include <memory>
+
 #include "commandrunner.h"
 #include "domain.h"
+#include "spicedisplayrenderer.h"
 
 class DomainViewer : public QQuickItem
 {
@@ -52,8 +48,6 @@ public:
 
     void stopAudio();
 
-    void updateTexture();
-
     void checkChannelStatus();
 
     void mouseMoveEvent(QMouseEvent *event) override;
@@ -100,15 +94,16 @@ Q_SIGNALS:
 
 private Q_SLOTS:
     void handleHostPort(int exitCode, const QString &output);
+    void handleRendererFrameSizeChanged();
 
 private:
+    enum class RenderMode {
+        GlScanout,
+        PrimarySurface,
+    };
+
     static void channel_new_callback(SpiceSession *session, SpiceChannel *channel, gpointer user_data);
-    static void gl_draw_callback(SpiceDisplayChannel *channel, guint x, guint y, guint width, guint height, gpointer user_data);
-    void handleGlScanout(const SpiceGlScanout *scanout);
-    void createTextureFromScanout(const SpiceGlScanout *scanout);
-    void cleanupEGLImage();
-    void cleanupEGLResources();
-    void captureFromSceneGraph();
+    void attachDisplayChannel(SpiceChannel *channel);
     static uint8_t evdevToPcXt(uint32_t evdev_scancode);
 
     static void playback_start_callback(SpicePlaybackChannel *channel, gint format, gint channels, gint rate, gpointer user_data);
@@ -122,16 +117,9 @@ private:
     int m_port;
     QString m_password;
     bool m_connected = false;
+    RenderMode m_renderMode = RenderMode::GlScanout;
 
-    int m_imageWidth;
-    int m_imageHeight;
-    SpiceGlScanout m_scanout;
-    bool m_hasScanout;
-    EGLImageKHR m_eglImage = EGL_NO_IMAGE_KHR;
-    PFNEGLDESTROYIMAGEKHRPROC m_eglDestroyImageKHR = nullptr;
-    PFNGLEGLIMAGETARGETTEXTURE2DOESPROC m_glEGLImageTargetTexture2DOES = nullptr;
-    PFNEGLCREATEIMAGEKHRPROC m_eglCreateImageKHR = nullptr;
-    GLuint m_texId;
+    std::unique_ptr<SpiceDisplayRenderer> m_renderer;
 
     SpiceSession *m_session = nullptr;
     SpiceChannel *m_display_channel = nullptr;
diff --git a/src/glscanoutrenderer.cpp b/src/glscanoutrenderer.cpp
new file mode 100644
index 0000000..ec1b66a
--- /dev/null
+++ b/src/glscanoutrenderer.cpp
@@ -0,0 +1,294 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2025 Derek Lin <[email protected]>
+
+#include "glscanoutrenderer.h"
+
+#include <unistd.h>
+
+#include <QOpenGLContext>
+#include <QOpenGLFunctions>
+#include <QQuickWindow>
+#include <QSGSimpleTextureNode>
+
+#include <rhi/qrhi.h> // need
+
+#include "karton_debug.h"
+
+GlScanoutRenderer::~GlScanoutRenderer()
+{
+    detach();
+}
+
+void GlScanoutRenderer::attach(SpiceDisplayChannel *channel)
+{
+    detach();
+    m_channel = channel;
+    m_glDrawHandlerId = g_signal_connect(channel, "gl-draw", G_CALLBACK(gl_draw_callback), this);
+    // TODO: Might need to check if gl is enabled. Domains created by virt-manager are not accel3d by default.
+}
+
+void GlScanoutRenderer::detach()
+{
+    if (m_channel && m_glDrawHandlerId) {
+        g_signal_handler_disconnect(m_channel, m_glDrawHandlerId);
+    }
+    m_glDrawHandlerId = 0;
+    m_channel = nullptr;
+
+    cleanupEGLResources();
+}
+
+void GlScanoutRenderer::gl_draw_callback(SpiceDisplayChannel *channel, guint x, guint y, guint width, guint height, gpointer user_data)
+{
+    Q_UNUSED(x);
+    Q_UNUSED(y);
+    Q_UNUSED(width);
+    Q_UNUSED(height);
+
+    auto *self = static_cast<GlScanoutRenderer *>(user_data);
+    auto scanout = spice_display_channel_get_gl_scanout(channel);
+    if (!scanout) {
+        return;
+    }
+    self->handleGlScanout(scanout);
+    spice_display_channel_gl_draw_done(channel); // releases the GL resource
+}
+
+void GlScanoutRenderer::handleGlScanout(const SpiceGlScanout *scanout)
+{
+    if (m_hasScanout && m_scanout.fd >= 0) {
+        close(m_scanout.fd);
+        m_scanout.fd = -1;
+    }
+
+    cleanupEGLImage();
+
+    m_scanout = *scanout; // struct copy
+
+    // duplicate the file descriptor if exists
+    // we will be using the duplicate, and the original is freed by SPICE (gl_draw_done).
+    if (scanout->fd >= 0) {
+        m_scanout.fd = dup(scanout->fd);
+        if (m_scanout.fd < 0) {
+            qCWarning(KARTON_DEBUG) << "Failed to duplicate scanout FD";
+            return;
+        }
+    }
+
+    m_imageHeight = scanout->height;
+    m_imageWidth = scanout->width;
+    m_hasScanout = true;
+
+    Q_EMIT frameSizeChanged();
+    Q_EMIT frameReady();
+}
+
+void GlScanoutRenderer::createTextureFromScanout(const SpiceGlScanout *scanout)
+{
+    qCDebug(KARTON_DEBUG) << "=== createTextureFromScanout()";
+    qCDebug(KARTON_DEBUG) << "FD:" << scanout->fd;
+    qCDebug(KARTON_DEBUG) << "Size:" << scanout->width << "x" << scanout->height;
+    qCDebug(KARTON_DEBUG) << "Format:" << QStringLiteral("0x%1").arg(scanout->format, 0, 16);
+    qCDebug(KARTON_DEBUG) << "Stride:" << scanout->stride;
+
+    if (scanout->fd == -1) {
+        return;
+    }
+
+    QOpenGLContext *context = QOpenGLContext::currentContext();
+    if (!context) {
+        qCDebug(KARTON_DEBUG) << "No current OpenGL context";
+        return;
+    }
+
+    QOpenGLFunctions *gl = context->functions();
+    if (!gl) {
+        qCDebug(KARTON_DEBUG) << "Failed to get OpenGL functions";
+        return;
+    }
+
+    // generate texture if empty
+    if (m_texId == 0) {
+        gl->glGenTextures(1, &m_texId);
+        if (m_texId == 0) { // glGenTextures modifies m_texId
+            qCDebug(KARTON_DEBUG) << "Failed to generate texture";
+            return;
+        }
+    }
+
+    // setup EGL display
+    EGLDisplay display = eglGetCurrentDisplay();
+    if (display == EGL_NO_DISPLAY) {
+        qCDebug(KARTON_DEBUG) << "Failed to get EGL display";
+        return;
+    }
+
+    EGLint attrs[] = {EGL_DMA_BUF_PLANE0_FD_EXT,
+                      scanout->fd,
+                      EGL_DMA_BUF_PLANE0_PITCH_EXT,
+                      static_cast<EGLint>(scanout->stride),
+                      EGL_DMA_BUF_PLANE0_OFFSET_EXT,
+                      0,
+                      EGL_WIDTH,
+                      static_cast<EGLint>(scanout->width),
+                      EGL_HEIGHT,
+                      static_cast<EGLint>(scanout->height),
+                      EGL_LINUX_DRM_FOURCC_EXT,
+                      static_cast<EGLint>(scanout->format),
+                      EGL_NONE};
+
+    // create egl image
+    if (!m_eglCreateImageKHR) {
+        m_eglCreateImageKHR = (PFNEGLCREATEIMAGEKHRPROC)eglGetProcAddress("eglCreateImageKHR");
+        if (!m_eglCreateImageKHR) {
+            qCDebug(KARTON_DEBUG) << "eglCreateImageKHR not available";
+            return;
+        }
+    }
+    m_eglImage = m_eglCreateImageKHR(display, EGL_NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, nullptr, attrs);
+
+    if (m_eglImage == EGL_NO_IMAGE_KHR) {
+        EGLint error = eglGetError();
+        qCDebug(KARTON_DEBUG) << "Failed to create EGL image, error:" << QStringLiteral("0x%1").arg(error, 0, 16);
+        return;
+    }
+
+    gl->glBindTexture(GL_TEXTURE_2D, m_texId);
+
+    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    // set image texture
+    if (!m_glEGLImageTargetTexture2DOES) {
+        m_glEGLImageTargetTexture2DOES = (PFNGLEGLIMAGETARGETTEXTURE2DOESPROC)eglGetProcAddress("glEGLImageTargetTexture2DOES");
+        if (!m_glEGLImageTargetTexture2DOES) {
+            qCDebug(KARTON_DEBUG) << "glEGLImageTargetTexture2DOES not supported";
+            return;
+        }
+    }
+    m_glEGLImageTargetTexture2DOES(GL_TEXTURE_2D, (GLeglImageOES)m_eglImage);
+
+    GLenum glError = gl->glGetError();
+    if (glError != GL_NO_ERROR) {
+        qCDebug(KARTON_DEBUG) << "OpenGL error after glEGLImageTargetTexture2DOES:" << QStringLiteral("0x%1").arg(glError, 0, 16);
+    }
+
+    gl->glBindTexture(GL_TEXTURE_2D, 0); // unbind texture
+}
+
+// triggered by update()
+QSGNode *GlScanoutRenderer::updatePaintNode(QQuickWindow *window, QSGNode *oldNode, const QRectF &bounds)
+{
+    // create opengl texture from scanout data
+    if (m_hasScanout && m_texId == 0) {
+        createTextureFromScanout(&m_scanout);
+    }
+
+    if (!m_texId) {
+        qCDebug(KARTON_DEBUG) << "No texture available yet";
+        delete oldNode;
+        return nullptr;
+    }
+
+    auto *textureNode = static_cast<QSGSimpleTextureNode *>(oldNode);
+    if (!textureNode) {
+        textureNode = new QSGSimpleTextureNode();
+        textureNode->setOwnsTexture(true);
+    }
+
+    QOpenGLContext *context = QOpenGLContext::currentContext();
+    if (!context) {
+        qCDebug(KARTON_DEBUG) << "No current OpenGL context in updatePaintNode";
+        return textureNode;
+    }
+
+    QOpenGLFunctions *gl = context->functions();
+    if (gl) {
+        gl->glBindTexture(GL_TEXTURE_2D, m_texId);
+        gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+        gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+        gl->glBindTexture(GL_TEXTURE_2D, 0);
+    }
+
+    // loading the gl image texture onto the QSG
+    QQuickWindow::CreateTextureOptions options;
+    QRhi *rhi = window->rhi();
+    if (!rhi) {
+        qCDebug(KARTON_DEBUG) << "No RHI available";
+        return textureNode;
+    }
+
+    QRhiTexture::Format rhiFormat = QRhiTexture::RGBA8;
+    QRhiTexture *rhiTexture = rhi->newTexture(rhiFormat, QSize(m_imageWidth, m_imageHeight));
+    if (!rhiTexture) {
+        qCDebug(KARTON_DEBUG) << "Failed to create RHI texture";
+        return textureNode;
+    }
+
+    // create native texture to be contained in RHI
+    QRhiTexture::NativeTexture nativeTex;
+    nativeTex.object = m_texId;
+    nativeTex.layout = 0;
+
+    if (!rhiTexture->createFrom(nativeTex)) {
+        qCDebug(KARTON_DEBUG) << "Failed to create RHI texture from native";
+        delete rhiTexture;
+        return textureNode;
+    }
+
+    QSGTexture *texture = window->createTextureFromRhiTexture(rhiTexture, options);
+    if (!texture) {
+        qCDebug(KARTON_DEBUG) << "Failed to create QSG texture";
+        delete rhiTexture;
+        return textureNode;
+    }
+
+    textureNode->setTexture(texture);
+    textureNode->setRect(bounds);
+
+    qCDebug(KARTON_DEBUG) << "GlScanoutRenderer: Successfully updated canvas.";
+    qCDebug(KARTON_DEBUG) << "  Texture ID:" << m_texId;
+    qCDebug(KARTON_DEBUG) << "  Size:" << m_imageWidth << "x" << m_imageHeight;
+
+    return textureNode;
+}
+
+// cleans up scanout and egl image, textures
+void GlScanoutRenderer::cleanupEGLResources()
+{
+    if (m_hasScanout && m_scanout.fd >= 0) {
+        close(m_scanout.fd);
+        m_scanout.fd = -1;
+    }
+    m_hasScanout = false;
+
+    cleanupEGLImage();
+
+    QOpenGLContext *context = QOpenGLContext::currentContext();
+    if (m_texId && context) {
+        QOpenGLFunctions *gl = context->functions();
+        if (gl) {
+            gl->glDeleteTextures(1, &m_texId);
+        }
+        m_texId = 0;
+    }
+}
+
+// for cleanup of duplicate
+void GlScanoutRenderer::cleanupEGLImage()
+{
+    if (m_eglImage != EGL_NO_IMAGE_KHR) {
+        EGLDisplay display = eglGetCurrentDisplay();
+        if (display != EGL_NO_DISPLAY) {
+            if (!m_eglDestroyImageKHR) {
+                m_eglDestroyImageKHR = (PFNEGLDESTROYIMAGEKHRPROC)eglGetProcAddress("eglDestroyImageKHR");
+            }
+            if (m_eglDestroyImageKHR) {
+                m_eglDestroyImageKHR(display, m_eglImage);
+            }
+        }
+        m_eglImage = EGL_NO_IMAGE_KHR;
+    }
+}
diff --git a/src/glscanoutrenderer.h b/src/glscanoutrenderer.h
new file mode 100644
index 0000000..5484956
--- /dev/null
+++ b/src/glscanoutrenderer.h
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2026 Derek Lin <[email protected]>
+
+#pragma once
+
+#include "spicedisplayrenderer.h"
+
+#include <EGL/egl.h>
+#include <EGL/eglext.h>
+#include <GLES2/gl2.h>
+#include <GLES2/gl2ext.h>
+
+// hardware-accel renderer
+class GlScanoutRenderer : public SpiceDisplayRenderer
+{
+    Q_OBJECT
+
+public:
+    using SpiceDisplayRenderer::SpiceDisplayRenderer;
+    ~GlScanoutRenderer() override;
+
+    void attach(SpiceDisplayChannel *channel) override;
+    void detach() override;
+
+    QSGNode *updatePaintNode(QQuickWindow *window, QSGNode *oldNode, const QRectF &bounds) override;
+
+    QSize frameSize() const override
+    {
+        return QSize(m_imageWidth, m_imageHeight);
+    }
+
+private:
+    static void gl_draw_callback(SpiceDisplayChannel *channel, guint x, guint y, guint width, guint height, gpointer user_data);
+    void handleGlScanout(const SpiceGlScanout *scanout);
+    void createTextureFromScanout(const SpiceGlScanout *scanout);
+    void cleanupEGLImage();
+    void cleanupEGLResources();
+
+    SpiceDisplayChannel *m_channel = nullptr;
+    gulong m_glDrawHandlerId = 0;
+
+    int m_imageWidth = 0;
+    int m_imageHeight = 0;
+    SpiceGlScanout m_scanout = {};
+    bool m_hasScanout = false;
+    EGLImageKHR m_eglImage = EGL_NO_IMAGE_KHR;
+    PFNEGLDESTROYIMAGEKHRPROC m_eglDestroyImageKHR = nullptr;
+    PFNGLEGLIMAGETARGETTEXTURE2DOESPROC m_glEGLImageTargetTexture2DOES = nullptr;
+    PFNEGLCREATEIMAGEKHRPROC m_eglCreateImageKHR = nullptr;
+    GLuint m_texId = 0;
+};
diff --git a/src/primarysurfacerenderer.cpp b/src/primarysurfacerenderer.cpp
new file mode 100644
index 0000000..05e226a
--- /dev/null
+++ b/src/primarysurfacerenderer.cpp
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2026 Derek Lin <[email protected]>
+
+#include "primarysurfacerenderer.h"
+
+#include <QMutexLocker>
+#include <QQuickWindow>
+#include <QSGSimpleTextureNode>
+
+#include "karton_debug.h"
+
+void PrimarySurfaceRenderer::attach(SpiceDisplayChannel *channel)
+{
+    detach();
+    m_channel = channel;
+    m_primaryCreateHandlerId = g_signal_connect(channel, "display-primary-create", G_CALLBACK(display_primary_create_callback), this);
+    m_invalidateHandlerId = g_signal_connect(channel, "display-invalidate", G_CALLBACK(display_invalidate_callback), this);
+}
+
+void PrimarySurfaceRenderer::detach()
+{
+    if (m_channel) {
+        if (m_primaryCreateHandlerId) {
+            g_signal_handler_disconnect(m_channel, m_primaryCreateHandlerId);
+        }
+        if (m_invalidateHandlerId) {
+            g_signal_handler_disconnect(m_channel, m_invalidateHandlerId);
+        }
+    }
+    m_primaryCreateHandlerId = 0;
+    m_invalidateHandlerId = 0;
+    m_channel = nullptr;
+
+    QMutexLocker locker(&m_frameLock);
+    m_frame = QImage();
+    m_frameUpdated = false;
+}
+
+void PrimarySurfaceRenderer::display_primary_create_callback(SpiceChannel *channel,
+                                                             gint format,
+                                                             gint width,
+                                                             gint height,
+                                                             gint stride,
+                                                             gint shmid,
+                                                             gpointer imgdata,
+                                                             gpointer user_data)
+{
+    Q_UNUSED(channel);
+    Q_UNUSED(format);
+    Q_UNUSED(shmid);
+
+    auto *self = static_cast<PrimarySurfaceRenderer *>(user_data);
+    qCInfo(KARTON_DEBUG) << "SPICE: primary surface received! size:" << width << "x" << height;
+    qCInfo(KARTON_DEBUG) << "SPICE: format is:" << format;
+
+    {
+        QMutexLocker locker(&self->m_frameLock);
+        // TODO: map incoming SPICE format properly instead of assuming RGB32.
+        self->m_frame = QImage(static_cast<uchar *>(imgdata), width, height, stride, QImage::Format_RGB32);
+        self->m_imageWidth = width;
+        self->m_imageHeight = height;
+        self->m_frameUpdated = true;
+    }
+
+    Q_EMIT self->frameSizeChanged();
+    Q_EMIT self->frameReady();
+}
+
+void PrimarySurfaceRenderer::display_invalidate_callback(SpiceDisplayChannel *channel, gint x, gint y, gint width, gint height, gpointer user_data)
+{
+    Q_UNUSED(channel);
+    Q_UNUSED(x);
+    Q_UNUSED(y);
+    Q_UNUSED(width);
+    Q_UNUSED(height);
+
+    auto *self = static_cast<PrimarySurfaceRenderer *>(user_data);
+    {
+        QMutexLocker locker(&self->m_frameLock);
+        self->m_frameUpdated = true;
+    }
+
+    Q_EMIT self->frameReady();
+}
+
+QSGNode *PrimarySurfaceRenderer::updatePaintNode(QQuickWindow *window, QSGNode *oldNode, const QRectF &bounds)
+{
+    QMutexLocker locker(&m_frameLock);
+
+    if (!m_frameUpdated || m_frame.isNull() || m_frame.width() <= 0 || m_frame.height() <= 0) {
+        delete oldNode;
+        return nullptr;
+    }
+
+    auto *node = static_cast<QSGSimpleTextureNode *>(oldNode);
+    if (!node) {
+        node = new QSGSimpleTextureNode();
+        node->setOwnsTexture(true);
+    }
+
+    QSGTexture *texture = window->createTextureFromImage(m_frame);
+    if (texture) {
+        node->setTexture(texture);
+        node->setRect(bounds);
+        m_frameUpdated = false;
+    }
+
+    return node;
+}
diff --git a/src/primarysurfacerenderer.h b/src/primarysurfacerenderer.h
new file mode 100644
index 0000000..e45cd86
--- /dev/null
+++ b/src/primarysurfacerenderer.h
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2026 Derek Lin <[email protected]>
+
+#pragma once
+
+#include "spicedisplayrenderer.h"
+
+#include <QImage>
+#include <QMutex>
+
+// CPU-based VM display renderer
+class PrimarySurfaceRenderer : public SpiceDisplayRenderer
+{
+    Q_OBJECT
+
+public:
+    using SpiceDisplayRenderer::SpiceDisplayRenderer;
+
+    void attach(SpiceDisplayChannel *channel) override;
+    void detach() override;
+
+    QSGNode *updatePaintNode(QQuickWindow *window, QSGNode *oldNode, const QRectF &bounds) override;
+
+    QSize frameSize() const override
+    {
+        return QSize(m_imageWidth, m_imageHeight);
+    }
+
+private:
+    static void
+    display_primary_create_callback(SpiceChannel *channel, gint format, gint width, gint height, gint stride, gint shmid, gpointer imgdata, gpointer user_data);
+    static void display_invalidate_callback(SpiceDisplayChannel *channel, gint x, gint y, gint width, gint height, gpointer user_data);
+
+    SpiceDisplayChannel *m_channel = nullptr;
+    gulong m_primaryCreateHandlerId = 0;
+    gulong m_invalidateHandlerId = 0;
+
+    int m_imageWidth = 0;
+    int m_imageHeight = 0;
+    QImage m_frame;
+    QMutex m_frameLock;
+    bool m_frameUpdated = false;
+};
diff --git a/src/spicedisplayrenderer.h b/src/spicedisplayrenderer.h
new file mode 100644
index 0000000..a6088e8
--- /dev/null
+++ b/src/spicedisplayrenderer.h
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: 2026 Derek Lin <[email protected]>
+
+#pragma once
+
+#include <spice-client.h>
+
+#include <QObject>
+#include <QRectF>
+#include <QSGNode>
+#include <QSize>
+
+class QQuickWindow;
+
+// Abstract base class for renderers
+class SpiceDisplayRenderer : public QObject
+{
+    Q_OBJECT
+
+public:
+    using QObject::QObject;
+    ~SpiceDisplayRenderer() override = default;
+
+    virtual void attach(SpiceDisplayChannel *channel) = 0;
+
+    virtual void detach() = 0;
+
+    virtual QSGNode *updatePaintNode(QQuickWindow *window, QSGNode *oldNode, const QRectF &bounds) = 0;
+
+    virtual QSize frameSize() const = 0;
+
+Q_SIGNALS:
+    void frameSizeChanged();
+    void frameReady();
+};
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.