From 07ab0de7d4c84c2c22036856242f167f7ad88e58 Mon Sep 17 00:00:00 2001 From: Nathan Date: Mon, 25 May 2026 10:08:19 -0500 Subject: [PATCH] qt/wayland: OpenGL renderer presents via subsurface too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New wayland::EglDmabufTarget allocates a GL_RGBA8 texture, wraps it as an EGLImage via eglCreateImage, exports its memory as a dmabuf via eglExportDMABUFImageMESA, and attaches the texture to a GL framebuffer for libghostty's GL renderer to draw into. The cached fd / fourcc / modifier / stride feed straight into SubsurfacePresenter::presentDmabuf — same compositor path the Vulkan renderer uses, just sourced from EGL instead of Vulkan. GhosttySurface (GL path) builds the target in syncSurfaceSize when the wl_subsurface presenter is up and the EGL display advertises EGL_MESA_image_dma_buf_export; falls back to the existing QOpenGLFramebufferObject + toImage + QPainter blit otherwise. renderTerminal routes to either target. paintEvent already gates its blit on m_useSubsurface so the new path skips the readback entirely. The initial syncSurfaceSize fires before QEvent::Show, when the presenter doesn't exist yet — so it takes the legacy branch. event(Show) now invalidates m_fbw on the GL path and re-runs syncSurfaceSize once the presenter comes up, giving the target a second chance to materialize. Verified on NVIDIA RTX 2080 + KDE Wayland: GL build picks fourcc=AB24 (ABGR8888, matches GL_RGBA8 byte order on LE) with a vendor-tiled modifier (0x300000000e08014), no wl_display protocol errors, frames flow via the subsurface. Multi-plane exports are refused at create-time (the present-callback ABI is single-plane). Single-plane vendor-tiled is the common case on RGBA, but multi-plane would need a wider ABI to land cleanly. Co-Authored-By: claude-flow --- qt/CMakeLists.txt | 7 + qt/src/GhosttySurface.cpp | 87 ++++++++-- qt/src/GhosttySurface.h | 9 + qt/src/wayland/EglDmabufTarget.cpp | 255 +++++++++++++++++++++++++++++ qt/src/wayland/EglDmabufTarget.h | 81 +++++++++ 5 files changed, 428 insertions(+), 11 deletions(-) create mode 100644 qt/src/wayland/EglDmabufTarget.cpp create mode 100644 qt/src/wayland/EglDmabufTarget.h diff --git a/qt/CMakeLists.txt b/qt/CMakeLists.txt index 41186a7dc..e48c3bc9c 100644 --- a/qt/CMakeLists.txt +++ b/qt/CMakeLists.txt @@ -53,6 +53,11 @@ find_package(LayerShellQt REQUIRED) # QPA native-handle accessors. find_package(PkgConfig REQUIRED) pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) +# libEGL for the OpenGL present path's dmabuf export +# (EGL_MESA_image_dma_buf_export). Resolved at runtime via +# eglGetProcAddress, so we only need the link for the base entry +# points (eglQueryString, eglGetCurrentDisplay, eglGetError). +pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) # libxkbcommon: derive the unshifted Unicode codepoint for a key event # from its XKB keycode, so libghostty's kitty encoder finds an entry for # punctuation keys (Qt's ev->key() reports the SHIFTED symbol, e.g. @@ -162,6 +167,7 @@ add_executable(ghastty src/TabWidget.cpp src/undo/UndoStack.cpp src/Util.cpp + src/wayland/EglDmabufTarget.cpp src/wayland/SubsurfacePresenter.cpp src/WindowBlur.cpp src/XkbTracker.cpp @@ -193,6 +199,7 @@ target_link_libraries(ghastty PRIVATE Qt6::Svg PkgConfig::WAYLAND_CLIENT PkgConfig::XKBCOMMON + PkgConfig::EGL LayerShellQt::Interface vulkan "${GHOSTTY_LINK_SO}" diff --git a/qt/src/GhosttySurface.cpp b/qt/src/GhosttySurface.cpp index e4f84c128..f0647cb45 100644 --- a/qt/src/GhosttySurface.cpp +++ b/qt/src/GhosttySurface.cpp @@ -9,6 +9,7 @@ #include "TabWidget.h" #include "Util.h" #include "vulkan/Host.h" +#include "wayland/EglDmabufTarget.h" #include "wayland/SubsurfacePresenter.h" #include @@ -255,10 +256,29 @@ void GhosttySurface::syncSurfaceSize() { } if (!makeCurrent()) return; + m_eglTarget.reset(); delete m_fbo; - QOpenGLFramebufferObjectFormat fmt; - fmt.setInternalTextureFormat(GL_RGBA8); - m_fbo = new QOpenGLFramebufferObject(QSize(w, h), fmt); + m_fbo = nullptr; + + // Prefer the dmabuf-backed target when the wl_subsurface presenter + // is up and EGL_MESA_image_dma_buf_export is available — the + // renderer draws directly into a texture whose memory is exported + // as a dmabuf, and we hand the fd straight to the compositor. + // When that's not available (no presenter, missing EGL extension, + // multi-plane export, etc.) we fall back to the legacy + // QOpenGLFramebufferObject + toImage + QPainter blit path. + if (m_subsurfacePresenter) { + m_eglTarget = wayland::EglDmabufTarget::create(m_context, w, h); + if (m_eglTarget) { + m_useSubsurface.store(true, std::memory_order_release); + } + } + if (!m_eglTarget) { + m_useSubsurface.store(false, std::memory_order_release); + QOpenGLFramebufferObjectFormat fmt; + fmt.setInternalTextureFormat(GL_RGBA8); + m_fbo = new QOpenGLFramebufferObject(QSize(w, h), fmt); + } ghostty_surface_set_content_scale(m_surface, dpr, dpr); ghostty_surface_set_size(m_surface, static_cast(w), @@ -324,13 +344,26 @@ bool GhosttySurface::event(QEvent *e) { if (auto *h = windowHandle()) { m_subsurfacePresenter = wayland::SubsurfacePresenter::tryCreate(h); - if (m_subsurfacePresenter && m_useVulkan) { - // Flip the Vulkan present path over to the zero-copy - // wl_subsurface route. Release-style store pairs with - // the renderer thread's acquire-load — once it observes - // true, it stops parking QImages and just hands us the - // dmabuf descriptor for compositor handoff. - m_useSubsurface.store(true, std::memory_order_release); + if (m_subsurfacePresenter) { + if (m_useVulkan) { + // Flip the Vulkan present path over to the zero-copy + // wl_subsurface route. Release-style store pairs with + // the renderer thread's acquire-load — once it + // observes true, it stops parking QImages and just + // hands us the dmabuf descriptor for compositor + // handoff. + m_useSubsurface.store(true, std::memory_order_release); + } else { + // OpenGL path: re-sync the framebuffer so + // syncSurfaceSize can build an EglDmabufTarget. + // syncSurfaceSize's initial call ran *before* this + // Show — m_subsurfacePresenter was null then, so it + // took the legacy QOpenGLFramebufferObject branch. + // Invalidate the cached size so the early-return at + // the top of syncSurfaceSize doesn't bail. + m_fbw = m_fbh = -1; + syncSurfaceSize(); + } } } } @@ -407,7 +440,39 @@ void GhosttySurface::renderTerminal() { return; } - if (!m_fbo || !makeCurrent()) return; + if (!makeCurrent()) return; + if (!m_eglTarget && !m_fbo) return; + + // Two render-target variants: + // - EglDmabufTarget (zero-copy): libghostty draws into a + // dmabuf-backed texture; we hand the fd to the subsurface + // presenter and the compositor scans it out directly. No + // readback, no QPainter blit for the terminal pixels. + // - QOpenGLFramebufferObject (legacy): glReadPixels into a + // QImage, then paintEvent blits via QPainter. Used when the + // EGL dmabuf path isn't available. + if (m_eglTarget) { + m_eglTarget->bind(); + m_context->functions()->glViewport(0, 0, m_fbw, m_fbh); + ghostty_surface_draw(m_surface); + premultiplyFramebuffer(); + m_eglTarget->release(); + if (m_subsurfacePresenter) { + const int scale = + std::max(1, static_cast(std::lround(devicePixelRatioF()))); + m_subsurfacePresenter->presentDmabuf( + m_eglTarget->fd(), m_eglTarget->drmFormat(), + m_eglTarget->drmModifier(), + static_cast(m_eglTarget->width()), + static_cast(m_eglTarget->height()), m_eglTarget->stride(), + scale); + } + // The terminal pixels reach the compositor via the subsurface, + // not via QPainter — but chrome (overlays, dim, bell flash) + // still goes through paintEvent. update() schedules that. + update(); + return; + } // libghostty renders into its own target and blits the result to the // currently bound framebuffer — bind ours so we get the final image. diff --git a/qt/src/GhosttySurface.h b/qt/src/GhosttySurface.h index 9bb2d8d66..9bcb3df55 100644 --- a/qt/src/GhosttySurface.h +++ b/qt/src/GhosttySurface.h @@ -15,6 +15,7 @@ namespace wayland { class SubsurfacePresenter; +class EglDmabufTarget; } class MainWindow; @@ -245,6 +246,14 @@ private: QOpenGLContext *m_context = nullptr; QOffscreenSurface *m_offscreen = nullptr; QOpenGLFramebufferObject *m_fbo = nullptr; + // Dmabuf-exporting GL target (zero-copy path). Set when the EGL + // display advertises EGL_MESA_image_dma_buf_export and the + // wl_subsurface presenter is up; the renderer draws into this + // texture-backed framebuffer and we attach its fd straight to the + // subsurface — no glReadPixels, no QImage, no QPainter blit. + // Stays null when EGL support is missing or the subsurface failed + // to bring up, and the legacy m_fbo path runs as fallback. + std::unique_ptr m_eglTarget; QImage m_image; // last frame, read back from m_fbo // True when this surface is using the Vulkan platform. The diff --git a/qt/src/wayland/EglDmabufTarget.cpp b/qt/src/wayland/EglDmabufTarget.cpp new file mode 100644 index 000000000..2d621a28a --- /dev/null +++ b/qt/src/wayland/EglDmabufTarget.cpp @@ -0,0 +1,255 @@ +#include "EglDmabufTarget.h" + +#include +#include +#include + +#include +#include + +#include +#include + +namespace wayland { + +namespace { + +// EGL_MESA_image_dma_buf_export entry points (loaded once per +// process). Resolved via `eglGetProcAddress`, which returns null if +// the extension isn't present. +using PFNeglExportDMABUFImageQueryMESA = + EGLBoolean (*)(EGLDisplay dpy, EGLImageKHR image, int *fourcc, + int *num_planes, EGLuint64KHR *modifiers); +using PFNeglExportDMABUFImageMESA = + EGLBoolean (*)(EGLDisplay dpy, EGLImageKHR image, int *fds, + EGLint *strides, EGLint *offsets); + +struct EglFns { + PFNEGLCREATEIMAGEKHRPROC createImage = nullptr; + PFNEGLDESTROYIMAGEKHRPROC destroyImage = nullptr; + PFNeglExportDMABUFImageQueryMESA queryExport = nullptr; + PFNeglExportDMABUFImageMESA exportImage = nullptr; + bool resolved = false; + bool available = false; +}; + +EglFns &eglFns() { + static EglFns f; + return f; +} + +bool ensureEglFns(EGLDisplay display) { + EglFns &f = eglFns(); + if (f.resolved) return f.available; + f.resolved = true; + + const char *exts = eglQueryString(display, EGL_EXTENSIONS); + if (!exts) return false; + auto hasExt = [exts](const char *name) { + const std::size_t n = std::strlen(name); + const char *p = exts; + while ((p = std::strstr(p, name)) != nullptr) { + if ((p == exts || p[-1] == ' ') && (p[n] == '\0' || p[n] == ' ')) + return true; + p += n; + } + return false; + }; + if (!hasExt("EGL_KHR_image_base") || + !hasExt("EGL_MESA_image_dma_buf_export")) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: EGL display lacks " + "EGL_KHR_image_base or EGL_MESA_image_dma_buf_export\n"); + return false; + } + + f.createImage = reinterpret_cast( + eglGetProcAddress("eglCreateImageKHR")); + f.destroyImage = reinterpret_cast( + eglGetProcAddress("eglDestroyImageKHR")); + f.queryExport = reinterpret_cast( + eglGetProcAddress("eglExportDMABUFImageQueryMESA")); + f.exportImage = reinterpret_cast( + eglGetProcAddress("eglExportDMABUFImageMESA")); + if (!f.createImage || !f.destroyImage || !f.queryExport || + !f.exportImage) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: eglGetProcAddress returned " + "null for required entry points\n"); + return false; + } + f.available = true; + return true; +} + +EGLDisplay currentEglDisplay() { + return eglGetCurrentDisplay(); +} + +// GL constants come from indirectly via the Qt +// GL headers — GL_TEXTURE_2D / GL_RGBA8 / GL_FRAMEBUFFER etc. are +// in scope without further includes. + +} // namespace + +bool EglDmabufTarget::available(QOpenGLContext *ctx) { + if (!ctx) return false; + if (!ctx->isValid()) return false; + EGLDisplay dpy = currentEglDisplay(); + if (dpy == EGL_NO_DISPLAY) { + std::fprintf( + stderr, + "[ghastty] EglDmabufTarget: no current EGL display (call after " + "QOpenGLContext::makeCurrent on a Wayland QPA)\n"); + return false; + } + return ensureEglFns(dpy); +} + +std::unique_ptr EglDmabufTarget::create(QOpenGLContext *ctx, + int width_px, + int height_px) { + if (!ctx || !ctx->isValid()) return nullptr; + if (width_px <= 0 || height_px <= 0) return nullptr; + EGLDisplay dpy = currentEglDisplay(); + if (dpy == EGL_NO_DISPLAY) return nullptr; + if (!ensureEglFns(dpy)) return nullptr; + const EglFns &fns = eglFns(); + auto *gl = ctx->functions(); + if (!gl) return nullptr; + + auto target = std::unique_ptr(new EglDmabufTarget()); + target->m_eglDisplay = dpy; + target->m_width = width_px; + target->m_height = height_px; + + // 1. Allocate a GL texture sized to the desired framebuffer. + unsigned int tex = 0; + gl->glGenTextures(1, &tex); + if (tex == 0) return nullptr; + gl->glBindTexture(GL_TEXTURE_2D, tex); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl->glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl->glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width_px, height_px, 0, GL_RGBA, + GL_UNSIGNED_BYTE, nullptr); + gl->glBindTexture(GL_TEXTURE_2D, 0); + target->m_texture = tex; + + // 2. Wrap as an EGLImage targeting the GL texture. + EGLImageKHR img = fns.createImage( + dpy, ctx->nativeInterface() + ? reinterpret_cast( + ctx->nativeInterface() + ->nativeContext()) + : eglGetCurrentContext(), + EGL_GL_TEXTURE_2D_KHR, + reinterpret_cast(static_cast(tex)), nullptr); + if (img == EGL_NO_IMAGE_KHR) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: eglCreateImageKHR failed (0x%x)\n", + eglGetError()); + gl->glDeleteTextures(1, &tex); + return nullptr; + } + target->m_eglImage = img; + + // 3. Query the export metadata (fourcc, plane count, modifier). + int fourcc = 0; + int num_planes = 0; + EGLuint64KHR modifier = 0; + if (!fns.queryExport(dpy, img, &fourcc, &num_planes, &modifier)) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: eglExportDMABUFImageQueryMESA " + "failed (0x%x)\n", + eglGetError()); + return nullptr; + } + if (num_planes != 1) { + // Multi-plane modifiers need a wider present-callback ABI on the + // subsurface side. NVIDIA / Mesa default tilings for RGBA are + // single-plane in practice; refuse multi-plane cleanly and fall + // back to the QImage path. + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: refusing multi-plane export " + "(num_planes=%d fourcc=0x%x mod=0x%llx)\n", + num_planes, fourcc, + static_cast(modifier)); + return nullptr; + } + target->m_drmFormat = static_cast(fourcc); + target->m_drmModifier = static_cast(modifier); + + // 4. Export the dmabuf fd + per-plane stride/offset. + int fd = -1; + EGLint stride = 0; + EGLint offset = 0; + if (!fns.exportImage(dpy, img, &fd, &stride, &offset) || fd < 0) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: eglExportDMABUFImageMESA failed " + "(0x%x fd=%d)\n", + eglGetError(), fd); + return nullptr; + } + target->m_fd = fd; + target->m_stride = static_cast(stride); + + // 5. Attach to a framebuffer so libghostty can render into it. + unsigned int fbo = 0; + gl->glGenFramebuffers(1, &fbo); + if (fbo == 0) { + ::close(fd); + target->m_fd = -1; + return nullptr; + } + target->m_framebuffer = fbo; + gl->glBindFramebuffer(GL_FRAMEBUFFER, fbo); + gl->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, tex, 0); + const unsigned int status = gl->glCheckFramebufferStatus(GL_FRAMEBUFFER); + gl->glBindFramebuffer(GL_FRAMEBUFFER, 0); + if (status != GL_FRAMEBUFFER_COMPLETE) { + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: framebuffer incomplete (0x%x)\n", + status); + return nullptr; + } + + std::fprintf(stderr, + "[ghastty] EglDmabufTarget: %dx%d fd=%d fourcc=0x%x mod=0x%llx " + "stride=%u\n", + width_px, height_px, fd, target->m_drmFormat, + static_cast(target->m_drmModifier), + target->m_stride); + return target; +} + +EglDmabufTarget::EglDmabufTarget() = default; + +EglDmabufTarget::~EglDmabufTarget() { + // Caller must ensure the owning QOpenGLContext is current; on + // GhosttySurface destruction we go through `makeCurrent` first. + auto ctx = QOpenGLContext::currentContext(); + if (ctx) { + auto *gl = ctx->functions(); + if (m_framebuffer) gl->glDeleteFramebuffers(1, &m_framebuffer); + if (m_texture) gl->glDeleteTextures(1, &m_texture); + } + if (m_eglImage && m_eglDisplay) { + eglFns().destroyImage(m_eglDisplay, m_eglImage); + } + if (m_fd >= 0) ::close(m_fd); +} + +void EglDmabufTarget::bind() const { + auto ctx = QOpenGLContext::currentContext(); + if (!ctx || !m_framebuffer) return; + ctx->functions()->glBindFramebuffer(GL_FRAMEBUFFER, m_framebuffer); +} + +void EglDmabufTarget::release() const { + auto ctx = QOpenGLContext::currentContext(); + if (!ctx) return; + ctx->functions()->glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +} // namespace wayland diff --git a/qt/src/wayland/EglDmabufTarget.h b/qt/src/wayland/EglDmabufTarget.h new file mode 100644 index 000000000..1622b7cf4 --- /dev/null +++ b/qt/src/wayland/EglDmabufTarget.h @@ -0,0 +1,81 @@ +// Dmabuf-exporting GL render target for the OpenGL present path. +// +// libghostty's GL renderer draws into a host-owned framebuffer (see +// GhosttySurface's `m_fbo`). Today that framebuffer's pixels get +// pulled back through `glReadPixels` (via `QOpenGLFramebufferObject::toImage`) +// into a QImage, then re-uploaded to the QWidget backing store by +// QPainter. After this class is wired in, the host instead allocates +// a GL texture, wraps it as an `EGLImage` via `eglCreateImage`, +// exports its memory as a dmabuf via `eglExportDMABUFImageMESA`, +// and attaches that texture to a GL framebuffer for libghostty to +// draw into. The cached dmabuf fd / fourcc / modifier / stride are +// then handed straight to the `wayland::SubsurfacePresenter` — same +// zero-copy path the Vulkan renderer's Target uses, just sourced +// from EGL instead of Vulkan. +// +// Requires `EGL_MESA_image_dma_buf_export` (checked by the static +// `available()` predicate). Wayland-only by project decision. + +#pragma once + +#include +#include + +class QOpenGLContext; + +namespace wayland { + +class EglDmabufTarget { +public: + // Detect at runtime whether the current EGL display advertises + // `EGL_MESA_image_dma_buf_export`. Caller MUST have a Wayland QPA + // and `ctx` must be a usable, makeCurrent-able QOpenGLContext. + // Cached after first call. + static bool available(QOpenGLContext *ctx); + + // Build a target of the given device-pixel size. Returns nullptr + // on any EGL / GL failure (caller falls back to the legacy + // QOpenGLFramebufferObject + toImage path). `ctx` must be current + // on the calling thread when called. + static std::unique_ptr create(QOpenGLContext *ctx, + int width_px, + int height_px); + + ~EglDmabufTarget(); + + // Bind the framebuffer for draw operations. Caller is responsible + // for `glViewport` / `glClear` etc. Mirrors `QOpenGLFramebufferObject::bind`. + void bind() const; + void release() const; + + // Pixel + dmabuf metadata. Stable for the lifetime of this target; + // resize allocates a new target. `stride` is the value returned by + // `eglExportDMABUFImageMESA` for plane 0. + int width() const { return m_width; } + int height() const { return m_height; } + int fd() const { return m_fd; } + std::uint32_t drmFormat() const { return m_drmFormat; } + std::uint64_t drmModifier() const { return m_drmModifier; } + std::uint32_t stride() const { return m_stride; } + + EglDmabufTarget(const EglDmabufTarget &) = delete; + EglDmabufTarget &operator=(const EglDmabufTarget &) = delete; + +private: + EglDmabufTarget(); + + // Opaque to callers (and avoids leaking EGL/GL handle types into + // the header). The .cpp owns the EGLDisplay/EGLImage casts. + void *m_eglDisplay = nullptr; + void *m_eglImage = nullptr; + unsigned int m_texture = 0; + unsigned int m_framebuffer = 0; + int m_width = 0; + int m_height = 0; + int m_fd = -1; + std::uint32_t m_drmFormat = 0; + std::uint64_t m_drmModifier = 0; + std::uint32_t m_stride = 0; +}; + +} // namespace wayland