https://bugs.kde.org/show_bug.cgi?id=522538
Bug ID: 522538
Summary: Proposal: dmabuf export for `org.kde.KWin.ScreenShot2`
Classification: Plasma
Product: kwin
Version First unspecified
Reported In:
Platform: unspecified
OS: Linux
Status: REPORTED
Severity: wishlist
Priority: NOR
Component: wayland-generic
Assignee: [email protected]
Reporter: [email protected]
Target Milestone: ---
## 1. Problem Statement
The `org.kde.KWin.ScreenShot2` D-Bus interface always performs a GPU→CPU
readback (`glReadnPixels` into a `QImage`) and writes raw pixels through a
caller-provided pipe fd. This is acceptable for consumers that need CPU-side
pixel data (e.g., Spectacle saving to disk), but it imposes unnecessary
overhead for GPU-side consumers that want to process captured frames without a
roundtrip through system memory.
**Primary example:** [wluma](https://github.com/max-baz/wluma) is an adaptive
brightness tool that captures screen contents several times per second and
processes them entirely on the GPU using Vulkan. It achieves minimal resource
impact by importing dmabufs directly — but on KDE Plasma, it has no
dmabuf-capable capture path:
| Screen capture mechanism | dmabuf? | Works on KWin?
|
| ---------------------------------------------- | ----------- |
-----------------------------------------------------------------------------------------------------------------------
|
| `wlr-export-dmabuf-unstable-v1` | ✓ | ✗
(wlroots-only protocol)
|
| `wlr-screencopy-unstable-v1` | ✗ | ✗ (KWin
doesn't implement)
|
| `ext-image-capture-source-v1` | ✓ | ✗ **KWin
explicitly won't implement** ([bug
513785](https://bugs.kde.org/show_bug.cgi?id=513785), RESOLVED INTENTIONAL) |
| `org.freedesktop.portal.Screenshot` | ✗ | ✓ (saves to
file; high latency; unsuitable for frame-by-frame)
|
| `org.freedesktop.portal.ScreenCast` + PipeWire | ✓ | ✓ (streaming
API; heavy PipeWire dependency; overkill for periodic snapshots)
|
| **`org.kde.KWin.ScreenShot2`** | ✗ **today** | ✓ (KWin's
native API; pull-based; used by Spectacle)
|
### Why not implement `ext-image-capture-source-v1` instead?
KWin's decision to not implement `ext-image-capture-source-v1` ([bug
513785](https://bugs.kde.org/show_bug.cgi?id=513785), RESOLVED INTENTIONAL,
comment by David Edmundson: _"Our priority is portals and improving this
experience"_) reflects a preference for routing capture through sanctioned,
permission-gated interfaces rather than adding new Wayland protocol bindings.
This proposal is consistent with that philosophy:
- **ScreenShot2 already exists.** It is KWin's own D-Bus interface, already
used by Spectacle, already gated by `X-KDE-DBUS-Restricted-Interfaces` with a
working permission model. Adding dmabuf export to an existing, permission-gated
API is categorically different from implementing a new Wayland protocol with
global bindings, resource lifecycle, and no built-in authorization.
- **It targets the same privileged consumers ScreenShot2 already serves.**
Spectacle, wluma, and similar tools that need per-frame capture already use
ScreenShot2 or would benefit from it. A new Wayland protocol would expose
capture to _any_ Wayland client regardless of sandbox status, which is
precisely what the portal-first philosophy seeks to avoid.
- **It's complementary to portals, not competing.** The Screenshot portal
writes to files (high latency, high overhead). The ScreenCast portal streams
via PipeWire (push-based, heavy dependency). ScreenShot2 fills the gap neither
portal addresses: pull-based single-frame capture with the option of zero-copy
GPU access.
### Why not require PipeWire / ScreenCast for dmabuf capture?
PipeWire screen casting is designed for continuous video streams — it requires
session negotiation, buffer pool allocation, and a push-based delivery model.
For tools that capture a single frame on demand (e.g., wluma polling screen
luminance every 250ms), this is excessive: it forces the consumer to maintain a
persistent PipeWire session and discard 99% of the streamed frames.
ScreenShot2's pull model — request a frame, receive a frame — is the correct
abstraction for this use case. This proposal simply extends it to deliver the
frame in a GPU-accessible format.
---
## 2. Proposed API
### 2.1 New option
A single new key in the existing `options` QVariantMap, accepted by all
`Capture*` methods:
```json
{ "dmabuf": true }
```
When set, KWin attempts to export the offscreen render target as a dmabuf fd.
If dmabuf export is unavailable (unsupported GPU driver, missing EGL extension,
export failure), the call silently falls back to the existing `glReadnPixels`
pipe path. The caller determines which path was taken by inspecting the
`"type"` field in the reply.
### 2.2 Reply vardict
The dmabuf fd and its metadata are returned in the D-Bus reply vardict:
| Key | Type | Description
|
| ---------- | ---- |
--------------------------------------------------------------------------------------------------------------------------
|
| `type` | `s` | `"dmabuf"` when dmabuf export succeeded; `"raw"`
otherwise |
| `fd` | `h` | dmabuf file descriptor (`QDBusUnixFileDescriptor`). Only
present when `type` is `"dmabuf"` |
| `format` | `u` | DRM fourcc code (e.g., `875708993` =
`DRM_FORMAT_ABGR8888`). Only present when `type` is `"dmabuf"`
|
| `width` | `u` | Width in pixels
|
| `height` | `u` | Height in pixels
|
| `stride` | `u` | Row stride in bytes
|
| `offset` | `u` | Byte offset from start of buffer to first pixel
|
| `modifier` | `t` | DRM format modifier (`uint64_t`; e.g., `0` for
`DRM_FORMAT_MOD_LINEAR`, `0x00ffffffffffffff` for `DRM_FORMAT_MOD_INVALID`) |
| `scale` | `d` | Device pixel ratio (native resolution / logical
resolution) |
When `type` is `"raw"`, the reply contains the existing fields (`format` as
`QImage::Format`, `stride`, `width`, `height`, `scale`) and pixel data is
written through the pipe fd as before.
**Consumer contract:** The `format` and `modifier` fields MUST be treated as
opaque values from the reply — consumers MUST NOT assume a specific fourcc. The
driver may choose `DRM_FORMAT_ABGR8888`, `DRM_FORMAT_ARGB8888`, or
`DRM_FORMAT_XRGB8888` depending on GPU architecture and endianness.
### 2.3 Pipe fd behavior
The `pipe` (type `h`) D-Bus argument remains in the method signature to
preserve API compatibility. When `dmabuf: true` and dmabuf export succeeds:
- The `pipe` fd is **not used** (no data is written to it)
- Callers may pass `-1` as a sentinel, or any valid fd which will be ignored
- The `F_DUPFD_CLOEXEC` check that currently validates the pipe fd is
**skipped** when `dmabuf` is set
- The dmabuf fd is delivered in the reply vardict, not through the pipe
**Rationale:** Unix pipes created with `pipe()` cannot carry file descriptors —
only data bytes. Passing the dmabuf fd through the pipe would require switching
to `socketpair(AF_UNIX, ...)` plus `SCM_RIGHTS` ancillary data, which is a more
invasive API change. D-Bus natively supports fd passing in reply messages
(`type="h"` in vardicts), and since D-Bus already runs over a Unix domain
socket, the kernel-level `SCM_RIGHTS` transfer happens transparently. Returning
the dmabuf fd alongside its metadata in the reply keeps related values together
and is idiomatic D-Bus.
### 2.4 Example: wluma integration (pseudocode)
```rust
// Currently: on KDE, wluma's "wayland" capturer finds no usable protocols
// and falls back to doing nothing (no screen-content analysis).
// After this proposal: new "kwin-screenshot2" capturer backend
let reply: QVariantMap = dbus_proxy.call(
"CaptureActiveScreen",
options: { "dmabuf": true, "native-resolution": true },
pipe: -1_i32
).await?;
match reply["type"].as_str() {
"dmabuf" => {
let fd = reply["fd"].as_unix_fd();
let fourcc = reply["format"].as_u32();
let modifier = reply["modifier"].as_u64();
let (width, height, stride) = (...);
// Import into Vulkan via VK_EXT_external_memory_dma_buf +
// VK_EXT_image_drm_format_modifier
// Compute frame luminance → adjust backlight
}
"raw" => {
// Graceful fallback: read pixels from pipe, upload to Vulkan
}
}
```
---
## 3. Technical Design
### 3.1 EGL extension dependency chain
Three EGL extensions are needed to go from a GL texture to a dmabuf fd:
| Extension | Role
| Availability
|
| ------------------------------- |
-------------------------------------------------------------------- |
---------------------------------------------------------------------------------------------------------------------------------------
|
| `EGL_KHR_image_base` | Creates `EGLImageKHR` wrappers around
buffer objects | **Core in EGL 1.5** — always present
|
| `EGL_KHR_gl_texture_2D_image` | Allows `EGL_GL_TEXTURE_2D_KHR` as a target
for `eglCreateImageKHR` | **Core in EGL 1.5** — always present
|
| `EGL_MESA_image_dma_buf_export` | Exports an `EGLImageKHR` as dmabuf fds with
fourcc/modifier metadata | **Mesa 10.6+** (June 2015); available on all Mesa
drivers. **NOT available** on NVIDIA proprietary drivers. Must be checked at
runtime. |
> **Note on API evolution:** The version of `EGL_MESA_image_dma_buf_export`
> that shipped in Mesa 10.6 used the v2 API without `modifiers` or `offsets`
> parameters (`eglExportDMABUFImageQueryMESA` returned only `fourcc` and
> `num_planes`; `eglExportDMABUFImageMESA` took only `fd` and `stride`). The
> `modifiers` and `offsets` parameters were added in later Mesa releases and
> are present in all currently supported versions. Since KWin targets modern
> Mesa (≥ 23.x), the current API shape (spec v4) can be assumed.
KWin requires EGL ≥ 1.5 for `zwp_linux_dmabuf_v1` support, so the first two
extensions are unconditionally available. KWin already includes `<epoxy/egl.h>`
(`egldisplay.h`) and uses `EglDisplay::hasExtension()` for runtime checks. The
third requires:
```cpp
// Runtime check — do not ifdef
if
(eglDisplay->hasExtension(QByteArrayLiteral("EGL_MESA_image_dma_buf_export")))
{
// dmabuf export path
} else {
// fallback to glReadnPixels
}
```
This matches the existing pattern used in `eglcontext.cpp`:
```cpp
const bool haveRobustness =
display->hasExtension(QByteArrayLiteral("EGL_EXT_create_context_robustness"));
```
### 3.2 Export sequence
The offscreen FBO is bound with an RGBA8 texture before the readback. The
dmabuf export path replaces `glReadnPixels` with the following sequence:
```cpp
// Step 0: Ensure GPU rendering is complete before export.
// Unlike glReadnPixels (which implicitly synchronizes), dmabuf export has
// no implicit barrier. glFinish() is a full pipeline stall on the compositor
// thread — acceptable for low-frequency use (a few Hz), but see §7 risk table
// for higher-frequency concerns and future sync_file enhancement path.
glFinish();
// Step 1: Create EGLImage from the offscreen GL texture
// Uses EGL_KHR_gl_texture_2D_image (EGL 1.5 core)
::EGLDisplay dpy = eglDisplay->handle();
EGLImageKHR image = eglDisplay->createImage(
EGL_NO_CONTEXT,
EGL_GL_TEXTURE_2D_KHR,
reinterpret_cast<EGLClientBuffer>(static_cast<uintptr_t>(offscreenTexture->texture())),
nullptr);
if (image == EGL_NO_IMAGE_KHR) {
return std::nullopt; // triggers fallback to readpixels
}
// Step 2: Query exported format (EGL_MESA_image_dma_buf_export)
int fourcc = 0;
int num_planes = 0;
EGLuint64KHR modifiers[4] = {};
if (!epoxy_eglExportDMABUFImageQueryMESA(dpy, image, &fourcc, &num_planes,
modifiers)) {
eglDisplay->destroyImage(image);
return std::nullopt; // triggers fallback
}
// Single-plane formats only for initial implementation
if (num_planes != 1) {
eglDisplay->destroyImage(image);
return std::nullopt; // triggers fallback
}
// Step 3: Export dmabuf fds (arrays sized for up to 4 planes)
int fds[4] = {-1, -1, -1, -1};
EGLint strides[4] = {0};
EGLint offsets[4] = {0};
if (!epoxy_eglExportDMABUFImageMESA(dpy, image, fds, strides, offsets)) {
// Clean up any fds that were returned before the failure
for (int i = 0; i < 4; i++) {
if (fds[i] != -1) close(fds[i]);
}
eglDisplay->destroyImage(image);
return std::nullopt; // triggers fallback
}
// Step 4: Clean up EGLImage (dmabuf fds keep the backing GEM buffer alive)
eglDisplay->destroyImage(image);
// Step 5: Return results
// For GL_RGBA8 offscreen textures, num_planes is always 1
DmaBufResult result{
.fd = fds[0],
.fourcc = static_cast<uint32_t>(fourcc),
.modifier = modifiers[0],
.width = offscreenTexture->width(),
.height = offscreenTexture->height(),
.stride = static_cast<uint32_t>(strides[0]),
.offset = static_cast<uint32_t>(offsets[0]),
.scale = scale,
};
// Close unused plane fds (indices 1-3, which are -1 for single-plane)
for (int i = 1; i < 4; i++) {
if (fds[i] != -1) close(fds[i]);
}
return result;
```
Key design decisions:
- **Single-plane only for the initial implementation.** The offscreen texture
is always `GL_RGBA8`, which exports as a single-plane format. The query call
returns `num_planes` — if it's not 1, we fall back to `glReadnPixels` (this
shouldn't happen for `GL_RGBA8`, but it's a safety check).
- **Query before export.** The `eglExportDMABUFImageQueryMESA` call gives us
the actual DRM fourcc the driver chose (which may differ from our requested
internal format — e.g., the driver may choose `DRM_FORMAT_ABGR8888` vs
`DRM_FORMAT_ARGB8888` depending on endianness and GPU architecture) and the
format modifier.
- **Arrays sized to 4.** The maximum number of planes for any dmabuf format is
4. Unused slots remain at `-1`/`0`/`0`.
- **The dmabuf fd keeps the backing GEM buffer alive after `eglDestroyImage`.**
This is standard dmabuf semantics — the lifecycle is decoupled from the
EGLImage.
- **The `EGLContext` parameter to `eglCreateImageKHR` is `EGL_NO_CONTEXT`**
when the target is a GL texture, since the texture belongs to the current
context. This is the conventional usage.
- **Cleanup on partial failure.** If `eglExportDMABUFImageMESA` returns failure
after partially populating the fd array, any fds that were written are
explicitly closed before the fallback.
- **`glFinish()` before export.** Unlike `glReadnPixels` which implicitly
flushes the pipeline, dmabuf export provides no synchronization guarantee.
`glFinish()` is a full GPU pipeline stall executed on KWin's main compositor
thread. At the intended call frequency (wluma captures a few times per second),
this has negligible impact. Higher-frequency usage (e.g., 60 Hz screen
recording through this interface) would cause visible frame drops. A future
enhancement path using `EGL_ANDROID_native_fence_sync` to export an explicit
sync_file is noted in §8.
### 3.3 Code locations
Three files are modified:
**`src/kwin/src/plugins/screenshot/screenshot.h`**
Add `ScreenShotDmabuf = 0x10` to the existing `ScreenShotFlag` enum (next
available bit after `0x1, 0x2, 0x4, 0x8`).
**`src/kwin/src/plugins/screenshot/screenshot.cpp`**
Add a `takeScreenShotDmabuf()` private helper that performs the EGL export
sequence from §3.2. The existing `takeScreenShot(LogicalOutput*, ...)`,
`takeScreenShot(const Rect&, ...)`, and `takeScreenShot(Window*, ...)` methods
(which currently return `std::optional<QImage>`) branch: if the dmabuf flag is
set and the extension is available, call the dmabuf path; otherwise, proceed
with `glReadnPixels`.
Existing code (from all three overloads, e.g., lines 146–149 in the
`takeScreenShot(Window*, ...)` overload):
```cpp
GLFramebuffer::pushFramebuffer(target.get());
QImage snapshot = QImage(offscreenTexture->size(),
QImage::Format_ARGB32_Premultiplied);
context->glReadnPixels(0, 0, snapshot.width(), snapshot.height(), GL_RGBA,
GL_UNSIGNED_BYTE,
snapshot.sizeInBytes(), static_cast<GLvoid
*>(snapshot.bits()));
```
New return type becomes `std::optional<std::variant<QImage, DmaBufResult>>`.
Branch:
```cpp
if (flags & ScreenShotDmabuf && eglBackend->eglDisplayObject()->hasExtension(
QByteArrayLiteral("EGL_MESA_image_dma_buf_export"))) {
auto dmabufResult = takeScreenShotDmabuf(offscreenTexture.get(),
eglBackend, scale);
if (dmabufResult) {
return dmabufResult;
}
// fall through to glReadnPixels on failure
}
```
**`src/kwin/src/plugins/screenshot/screenshotdbusinterface2.cpp`**
In `screenShotFlagsFromOptions()`, add parsing of the `"dmabuf"` key from
`options`.
Extend `ScreenShotSinkPipe2::flush()` (or add a sibling
`ScreenShotSinkDmabuf2`) to embed the dmabuf metadata in the D-Bus reply. The
`F_DUPFD_CLOEXEC` check in each `Capture*` method is skipped when `dmabuf` is
set:
```cpp
// In each Capture* method, guard the existing fd check:
const bool dmabuf = screenShotFlagsFromOptions(options) & ScreenShotDmabuf;
const int fileDescriptor = dmabuf
? -1 // pipe is unused when dmabuf succeeds
: fcntl(pipe.fileDescriptor(), F_DUPFD_CLOEXEC, 0);
if (!dmabuf && fileDescriptor == -1) {
sendErrorReply(s_errorFileDescriptor, s_errorFileDescriptorMessage);
return QVariantMap();
}
```
New flush overload:
```cpp
void ScreenShotSinkPipe2::flush(const DmaBufResult &dmabuf, const QVariantMap
&attributes)
{
QVariantMap results = attributes;
results.insert(QStringLiteral("type"), QStringLiteral("dmabuf"));
// QDBusUnixFileDescriptor dups dmabuf.fd internally (kernel SCM_RIGHTS
// happens during sendmsg()). We close our original copy immediately —
// the dup in QDBusUnixFileDescriptor keeps the GEM buffer alive. This
// close is safe even though dbus send is queued/async, because the
// kernel-level fd duplication already happened at construction time.
results.insert(QStringLiteral("fd"), QDBusUnixFileDescriptor(dmabuf.fd));
results.insert(QStringLiteral("format"), quint32(dmabuf.fourcc));
results.insert(QStringLiteral("width"), quint32(dmabuf.width));
results.insert(QStringLiteral("height"), quint32(dmabuf.height));
results.insert(QStringLiteral("stride"), quint32(dmabuf.stride));
results.insert(QStringLiteral("offset"), quint32(dmabuf.offset));
results.insert(QStringLiteral("modifier"), quint64(dmabuf.modifier));
results.insert(QStringLiteral("scale"), dmabuf.scale);
QDBusConnection::sessionBus().send(m_replyMessage.createReply(results));
// fd ownership transferred to QDBusUnixFileDescriptor above; close our
copy.
// If QDBusUnixFileDescriptor construction failed (invalid fd), close() is
a
// harmless no-op/failsafe on -1.
close(dmabuf.fd);
// No pipe writer needed — the fd is already in the reply
}
```
**`src/kwin/src/plugins/screenshot/org.kde.KWin.ScreenShot2.xml`**
Document the new option and reply format. Add `fd`, `format`, `modifier`, and
`offset` to the documented reply keys under the `"dmabuf"` type. Bump version
to 6 (or document that the feature is detectable by checking `type == "dmabuf"`
in the reply).
### 3.4 Estimated code size
| Component |
Lines |
| ------------------------------------------------------------------------- |
-------------- |
| `ScreenShotDmabuf` flag + options parsing |
~10 |
| `DmaBufResult` struct |
~14 |
| `takeScreenShotDmabuf()` helper (including `glFinish()`, export, cleanup) |
~55 |
| Branch + fallback in three `takeScreenShot` overloads |
~25 |
| Return type change (`QImage` → `variant<QImage, DmaBufResult>`) |
~10 |
| Pipe fd guard in 7 `Capture*` methods |
~15 |
| `ScreenShotSinkPipe2::flush(DmaBufResult)` overload (incl. fd lifecycle) |
~30 |
| EGL extension runtime check |
~5 |
| Error handling, fd cleanup on failure paths |
~15 |
| XML documentation |
~35 |
| **Total** |
**~214 lines** |
---
## 4. Driver Compatibility and Fallback
### 4.1 Compatibility matrix
| GPU / Driver | `EGL_MESA_image_dma_buf_export` | Behavior
with `dmabuf: true`
|
| -------------------------------- | ------------------------------- |
------------------------------------------------------------------------------------------------------------------------------------------------
|
| Intel iGPU (i965 / iris, Mesa) | ✓ | dmabuf
in reply
|
| AMD dGPU/iGPU (radeonsi, Mesa) | ✓ | dmabuf
in reply
|
| ARM Mali (Panfrost, Mesa) | ✓ | dmabuf
in reply
|
| NVIDIA (nouveau/nvk, Mesa 24.1+) | ✓ | dmabuf
in reply
|
| NVIDIA (proprietary) | ✗ | Falls
back to `glReadnPixels`; reply `type: "raw"`
|
| Software rendering (llvmpipe) | ✗ | Falls
back to `glReadnPixels`; reply `type: "raw"`
|
| Hybrid-graphics (PRIME) | Depends on render GPU | Consumer
on a different GPU may not be able to import the dmabuf directly; the
`modifier` field should be used to determine import compatibility |
The `dmabuf: true` option is a **hint**, not a contract. Consumers MUST inspect
`reply["type"]` and handle both paths. This is identical to how wluma already
selects between `wlr-export-dmabuf`, `wlr-screencopy`, and
`ext-image-capture-source` based on runtime protocol availability.
### 4.2 Consumer contract
```
if reply["type"] == "dmabuf":
→ dmabuf fd is valid; pipe contains no data; use format/modifier from reply
if reply["type"] == "raw":
→ pipe contains pixel data; read with existing semantics; dmabuf keys
absent
```
No new D-Bus error codes are needed. Export failure is not an error — it's a
degradation to the existing raw path.
---
## 5. Relationship to Other Capture Mechanisms
This proposal does not compete with or replace any existing mechanism:
| Mechanism | Transport | Scope
| Pull/Push | dmabuf | KWin status |
| ----------------------------------- | ---------------- | -------------------
| --------- | ------ | ------------------- |
| ScreenShot2 `"raw"` | D-Bus + pipe | Single frame
| Pull | ✗ | Existing, unchanged |
| ScreenShot2 `"dmabuf"` | D-Bus reply | Single frame
| Pull | ✓ | **This proposal** |
| `org.freedesktop.portal.Screenshot` | D-Bus | Single frame → file
| Pull | ✗ | Existing |
| `org.freedesktop.portal.ScreenCast` | PipeWire stream | Continuous stream
| Push | ✓ | Existing |
| `ext-image-capture-source-v1` | Wayland protocol | Per-frame buffer
| Pull | ✓ | **Won't implement** |
The ScreenShot2 dmabuf path fills the specific niche of **pull-based,
single-frame, dmabuf-capable capture on KWin** — a combination no other path
provides. It is complementary to the ScreenCast portal (which is push-based and
requires PipeWire) and the Screenshot portal (which writes to files).
---
## 6. Security Considerations
### 6.1 Access control
The ScreenShot2 interface already requires authorization via
`X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2` in the caller's
desktop file (enforced in `checkPermissions()`). The dmabuf path inherits this
gate. No additional authorization is needed.
### 6.2 fd lifecycle
The dmabuf fd is created by `eglExportDMABUFImageMESA`, which internally calls
`dma_buf_fd()` with `O_CLOEXEC`. The fd flows through three ownership stages:
1. **Creation:** `eglExportDMABUFImageMESA` returns the fd in `fds[0]`. KWin
owns it. The backing GEM buffer's refcount is 1.
2. **Transfer to D-Bus:** `QDBusUnixFileDescriptor(fds[0])` dups the fd (the Qt
constructor calls `dup()` internally). The kernel's `SCM_RIGHTS` transfer will
use this dup'd fd when `send()` executes asynchronously. KWin then explicitly
`close()`s `fds[0]` — the caller's copy in `QDBusUnixFileDescriptor` retains
the GEM buffer refcount. The `close()` is safe to call before `send()`
completes because the kernel-level `dup()` already occurred at
`QDBusUnixFileDescriptor` construction time.
3. **Consumer receives:** D-Bus delivers the fd to the caller's process via
`SCM_RIGHTS`. The caller owns its copy and is responsible for closing it when
done.
4. **Cleanup:** The backing GEM buffer is freed when all fd references in all
processes are closed — standard kernel dmabuf lifecycle.
### 6.3 Content visibility
A dmabuf fd grants the holder read access to GPU memory containing the captured
frame. This is no different from granting read access to raw pixel data through
the existing pipe mechanism. The same authorization gate applies.
---
## 7. Risks and Mitigations
| Risk | Mitigation
|
| ------------------------------------------------ |
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
| NVIDIA proprietary driver doesn't support export | Runtime extension check;
automatic `glReadnPixels` fallback; `type: "raw"` in reply
|
| Driver exports with unexpected fourcc/modifier |
`eglExportDMABUFImageQueryMESA` returns actual values; consumer uses them
verbatim
|
| Texture format not exportable as dmabuf | Export failure → `nullopt`
→ fallback to `glReadnPixels`
|
| fd leak if consumer crashes before closing | D-Bus tracks fd lifecycle;
kernel closes fds on process exit
|
| fd leak on KWin side after reply sent | Explicit
`close(dmabuf.fd)` after `QDBusUnixFileDescriptor` dup; see §6.2
|
| Multi-plane formats (not expected for RGBA8) | Query returns
`num_planes`; if > 1, fall back to `glReadnPixels`
|
| D-Bus message size limit for large vardicts | dmabuf path sends only
metadata (~100 bytes) + fd; no impact
|
| Consumer on different GPU (multi-GPU/PRIME) | `modifier` field enables
consumer to check import compatibility
|
| **`glFinish()` stalls compositor thread** | Full GPU pipeline stall on
KWin's main thread. Acceptable at low frequency (wluma: ~4 Hz → ~1 ms/frame on
modern GPUs, negligible). At high frequency (60 Hz screen recording via this
interface), would cause visible frame drops. The intended use case is periodic
snapshots, not continuous streaming. A future enhancement path using
`EGL_ANDROID_native_fence_sync` to export an explicit sync_file would allow the
consumer to wait on GPU completion without blocking the compositor; see §8. |
| Partial fd population on export failure | All fds written by
`eglExportDMABUFImageMESA` are explicitly closed on failure before fallback;
see §3.2 step 3 error path
|
---
## 8. Open Questions for Review
1. **Version bump?** `version()` currently returns `5`. Adding a new key to a
vardict that degrades gracefully doesn't strictly require a version bump —
callers can detect support by checking `reply["type"] == "dmabuf"`. However,
bumping to `6` signals capability to consumers inspecting `Version` before
making a call. Recommendation: bump to `6`.
2. **Pipe sentinel value.** Should the API explicitly document that `pipe = -1`
is accepted when `dmabuf: true`? Current code calls
`fcntl(pipe.fileDescriptor(), F_DUPFD_CLOEXEC, 0)` and checks for `-1` —
passing `-1` would trigger the `s_errorFileDescriptor` path. The implementation
must skip the `F_DUPFD_CLOEXEC` check when `dmabuf` is set.
3. **Multiple planes in the reply?** If multi-plane formats are supported in
the future, the reply could include `num_planes` (u) and `fd`, `stride`,
`offset` could become arrays. For the initial single-plane implementation,
scalar values suffice. The reply format can be extended compatibly by adding
new keys.
4. **Should `EglDisplay` gain an `exportDmaBufFromImage()` convenience
method?** Currently `EglDisplay` has `createImage()`, `destroyImage()`, and
`importDmaBufAsImage()` but no export helper. Adding one would keep the dmabuf
logic in the OpenGL layer rather than in the screenshot plugin. Recommendation:
yes, this improves layering.
5. **`glFinish()` vs `EGL_ANDROID_native_fence_sync` for GPU synchronization.**
The initial implementation uses `glFinish()` for simplicity — it's a full
pipeline stall on the compositor thread, matching the implicit synchronization
`glReadnPixels` already provides. At the intended call frequency (a few Hz for
brightness tools), this has negligible impact. A future enhancement could use
`GLsync` + `EGL_ANDROID_native_fence_sync` to export a sync_file fd alongside
the dmabuf, allowing the consumer to wait on the GPU timeline without blocking
KWin's GL context. This would enable higher-frequency usage (e.g., 30–60 Hz
capture) without compositor frame drops. Scoped as a follow-up, not v1.
---
## 9. Alternatives Considered
| Alternative | Why rejected
|
| --------------------------------------------------------- |
-----------------------------------------------------------------------------------------------------------------------------------------------
|
| Use PipeWire/ScreenCast for single frames | High latency;
requires PipeWire session management; wrong abstraction for pull-based
snapshots |
| Implement `wlr-export-dmabuf-unstable-v1` | wlroots-specific
protocol; KWin doesn't use wlr protocols
|
| Implement `ext-image-capture-source-v1` | Already rejected
by KWin ([bug 513785](https://bugs.kde.org/show_bug.cgi?id=513785)); full
Wayland protocol overhead; no built-in authorization |
| Add dmabuf support to `org.freedesktop.portal.Screenshot` | Cross-desktop
consensus required; slow process; portal saves to files
|
| Return dmabuf fd through the pipe with `SCM_RIGHTS` | Requires
`socketpair` instead of `pipe`; more invasive API change; D-Bus fd passing
already handles this |
---
## 10. Summary
This proposal adds a `dmabuf: true` option to the ScreenShot2 D-Bus interface.
When set, KWin exports the captured framebuffer as a dmabuf fd and returns it
in the D-Bus reply alongside DRM format metadata, eliminating the GPU→CPU→pipe
roundtrip for GPU-side consumers.
- **~210 lines of code** across three files
- **Opt-in and fully backward-compatible** — existing consumers are unaffected
- **Runtime extension detection** — graceful fallback on unsupported hardware
- **Same metadata shape as existing dmabuf protocols** — consumers can reuse
their dmabuf import code
- **Correct fd lifecycle** — explicit close after D-Bus dup, documented for
reviewer confidence
- **Explicit `glFinish()` cost acknowledged** — scoped as acceptable for v1,
with sync_file enhancement path for v2
- **Aligned with KWin's existing capture philosophy** — extends a
permission-gated, first-party API rather than adding new protocol surface
- **Fills a real gap** — the only pull-based dmabuf capture path available on
KWin, enabling wluma and other GPU-side tools on KDE Plasma
---
## AI Disclosure
This proposal was researched and drafted with AI assistance (used for
codebase/API research, drafting, and structuring — all technical claims and
code were reviewed and verified against the KWin source).
The author takes full responsibility for review feedback.
--
You are receiving this mail because:
You are watching all bug changes.