qt/wayland: OpenGL renderer presents via subsurface too

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 <ruv@ruv.net>
pull/12846/head
Nathan 2026-05-25 10:08:19 -05:00
parent 33560fe83e
commit 07ab0de7d4
5 changed files with 428 additions and 11 deletions

View File

@ -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}"

View File

@ -9,6 +9,7 @@
#include "TabWidget.h"
#include "Util.h"
#include "vulkan/Host.h"
#include "wayland/EglDmabufTarget.h"
#include "wayland/SubsurfacePresenter.h"
#include <algorithm>
@ -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<uint32_t>(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<int>(std::lround(devicePixelRatioF())));
m_subsurfacePresenter->presentDmabuf(
m_eglTarget->fd(), m_eglTarget->drmFormat(),
m_eglTarget->drmModifier(),
static_cast<quint32>(m_eglTarget->width()),
static_cast<quint32>(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.

View File

@ -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<wayland::EglDmabufTarget> m_eglTarget;
QImage m_image; // last frame, read back from m_fbo
// True when this surface is using the Vulkan platform. The

View File

@ -0,0 +1,255 @@
#include "EglDmabufTarget.h"
#include <cstdio>
#include <cstring>
#include <unistd.h>
#include <QOpenGLContext>
#include <QOpenGLFunctions>
#include <EGL/egl.h>
#include <EGL/eglext.h>
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<PFNEGLCREATEIMAGEKHRPROC>(
eglGetProcAddress("eglCreateImageKHR"));
f.destroyImage = reinterpret_cast<PFNEGLDESTROYIMAGEKHRPROC>(
eglGetProcAddress("eglDestroyImageKHR"));
f.queryExport = reinterpret_cast<PFNeglExportDMABUFImageQueryMESA>(
eglGetProcAddress("eglExportDMABUFImageQueryMESA"));
f.exportImage = reinterpret_cast<PFNeglExportDMABUFImageMESA>(
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 <QOpenGLFunctions> 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> 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<EglDmabufTarget>(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<QNativeInterface::QEGLContext>()
? reinterpret_cast<EGLContext>(
ctx->nativeInterface<QNativeInterface::QEGLContext>()
->nativeContext())
: eglGetCurrentContext(),
EGL_GL_TEXTURE_2D_KHR,
reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(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<unsigned long long>(modifier));
return nullptr;
}
target->m_drmFormat = static_cast<std::uint32_t>(fourcc);
target->m_drmModifier = static_cast<std::uint64_t>(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<std::uint32_t>(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<unsigned long long>(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

View File

@ -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 <cstdint>
#include <memory>
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<EglDmabufTarget> 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