[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();
+};