GPU Rendering Strategy: wgpu, EGL Paths, and Mesa Turnip Driver
The Portal platform’s GPU rendering strategy is dictated by a single architectural constraint: the Mesa Freedreno driver on the Adreno X1-85 supports Wayland EGL but not GBM EGL window surfaces — the exact inverse of the legacy Mali-G610 blob on the Orange Pi. This asymmetry determines which rendering approach works for every subsystem, from the compositor’s perspective warp to the keyboard daemon’s glow animations and the streaming pipeline’s dmabuf linearizer.
Sources: docs/RENDERING_ARCHITECTURE.md#L1-L263
The Two EGL Code Paths
Section titled “The Two EGL Code Paths”EGL exposes two independent platform paths for creating renderable surfaces. These paths fail independently on different hardware, which is the foundational insight that drives all rendering decisions in the Portal stack.
flowchart TB
subgraph "Path A: GBM EGL"
A1["eglGetPlatformDisplayEXT<br/>(EGL_PLATFORM_GBM_KHR, gbm_device)"]
A2["eglCreateWindowSurface<br/>(display, config, gbm_surface)"]
A1 --> A2
end
subgraph "Path B: Wayland EGL"
B1["eglGetPlatformDisplayEXT<br/>(EGL_PLATFORM_WAYLAND_KHR, wl_display)"]
B2["wl_egl_window_create(wl_surface)"]
B3["eglCreateWindowSurface<br/>(display, config, wl_egl_window)"]
B1 --> B2 --> B3
end
subgraph "Path C: Vulkan WSI"
C1["VkInstance + VkPhysicalDevice<br/>(Adreno X1-85 via Turnip)"]
C2["VkSurfaceKHR<br/>(VK_KHR_wayland_surface)"]
C3["VkSwapchainKHR<br/>(VK_KHR_swapchain)"]
C1 --> C2 --> C3
end
A2 -->|"Adreno: EGL_BAD_NATIVE_WINDOW ❌<br/>Mali: Works ✅"| Result
B3 -->|"Adreno: EGL 1.5, 30+ configs ✅<br/>Mali: eglInitialize fails ❌"| Result
C3 -->|"Adreno: Vulkan 1.4.348 ✅<br/>Mali: No WSI extensions ❌"| Result
| Path | Adreno X1-85 (Mesa 26.1.2) | Mali-G610 (libmali blob) |
|---|---|---|
| GBM EGL window surface | ❌ EGL_BAD_NATIVE_WINDOW |
✅ Works |
| Wayland EGL | ✅ EGL 1.5, 30+ configs | ❌ eglInitialize fails |
| Vulkan WSI | ✅ VK_KHR_wayland_surface v6 |
❌ No WSI extensions |
| wgpu (Vulkan backend) | ✅ 300 frames validated | ❌ Surface creation fails |
The Mesa msm driver on the Spaceboard has the opposite limitation from the Mali blob: Wayland EGL works but GBM EGL window surfaces fail. However, GBM EGL without a window surface (surfaceless context for compute-style operations) works fine — this is critical for the streaming pipeline’s dmabuf linearizer.
Sources: docs/RENDERING_ARCHITECTURE.md#L13-L31, portal/input/src/bin/portal-keyboardd/wayland_egl.rs#L1-L14
Mesa Turnip (Vulkan) and Freedreno (GLES) Drivers
Section titled “Mesa Turnip (Vulkan) and Freedreno (GLES) Drivers”The Spaceboard runs Mesa 26.1.2, which provides two independent open-source driver stacks for the Adreno X1-85:
- Freedreno (
msm_dri.so) — the OpenGL ES driver, used by wlroots’s gles2 renderer for Wayfire’s compositor pipeline. This is what allocates dmabufs with the QCOM UBWC modifier. - Turnip — the Vulkan driver, Vulkan 1.4.348 conformant with full Wayland WSI support. This is what wgpu targets.
Both drivers coexist: Wayfire uses Freedreno for its internal compositor rendering, while Portal client applications use Turnip via wgpu’s Vulkan backend. The TU_DEBUG and FD_DEBUG environment variables exist but were found to have no effect on the UBWC modifier produced by wlroots’s gbm_bo_create_with_modifiers path.
Sources: docs/RENDERING_ARCHITECTURE.md#L67-L75, portal/streaming/STREAMING_UBWC_FIX.md#L33-L38
wgpu Validation Results
Section titled “wgpu Validation Results”The following test confirmed wgpu as the rendering API for all future Portal client applications:
| Test | Command | Result |
|---|---|---|
| Vulkan device info | vulkaninfo --summary |
Adreno X1-85, Turnip driver, Vulkan 1.4.348 |
| Vulkan Wayland surface | vulkaninfo | grep wayland |
VK_KHR_wayland_surface v6 ✅ |
| Vulkan swapchain | vulkaninfo | grep swapchain |
VK_KHR_swapchain v70, 16 surface formats ✅ |
| EGL Wayland platform | eglinfo -p wayland |
EGL 1.5, msm driver, 30+ configs ✅ |
| GBM EGL | eglinfo -p gbm |
Empty config list ❌ |
| wgpu render | Rust test (wgpu 24.0.5 + winit 0.30) | 300 frames, Vulkan backend, Bgra8UnormSrgb ✅ |
Sources: docs/RENDERING_ARCHITECTURE.md#L228-L262
The UBWC Problem: Why the Streaming Pipeline Needs EGL
Section titled “The UBWC Problem: Why the Streaming Pipeline Needs EGL”When wlroots’s gles2 renderer composites frames on the Adreno X1-85, it allocates output dmabufs with DRM_FORMAT_MOD_QCOM_COMPRESSED (0x0500000000000001) — Qualcomm Universal Bandwidth Compression. This is a tiled/compressed memory layout where raw bytes do not represent a linear pixel array. GStreamer’s videoconvert reads these bytes via mmap as if they were linear BGRx, producing garbage on the AR glasses.
The fix is an EGL linearizer embedded in portal_stream.c. This code opens /dev/dri/card0, creates a GBM device, initializes EGL on the GBM platform (which works because it requires only a context, not a window surface), and uses eglCreateImageKHR with modifier-aware attributes to import the UBWC dmabuf as a GL texture. From there, glReadPixels on a scratch FBO produces linear CPU-accessible pixels that GStreamer can safely feed to the H.265 encoder.
flowchart LR
WL["wlroots gles2 renderer<br/>(Freedreno)"] -->|"dmabuf<br/>mod=0x0500000000000001<br/>(UBWC)"| EXPORT
EXPORT["zwlr_export_dmabuf<br/>(frame_object callback)"]
EXPORT --> EGL["eglCreateImageKHR<br/>(modifier-aware import)"]
EGL --> TEX["glEGLImageTargetTexture2DOES<br/>→ GL texture"]
TEX --> FBO["scratch FBO<br/>glFramebufferTexture2D"]
FBO --> READ["glReadPixels<br/>→ linear BGRx pixels"]
READ --> GST["GstBuffer (system memory)<br/>appsrc → videoconvert → v4l2h265enc"]
The EGL state is initialized once at startup via portal_egl_init() and reused per-frame: one FBO, one texture, zero allocation churn. The only per-frame cost is eglCreateImageKHR + glDestroyImageKHR and the synchronous glReadPixels stall (~480 MB/s for BGRx at 1080p60).
Sources: portal/streaming/portal_stream.c#L64-L84, portal/streaming/portal_stream.c#L219-L336, portal/streaming/STREAMING_UBWC_FIX.md#L27-L72
Why TU_DEBUG/FD_DEBUG Did Not Help
Section titled “Why TU_DEBUG/FD_DEBUG Did Not Help”Both TU_DEBUG=noubwc (Turnip/Vulkan) and FD_DEBUG=noubwc (Freedreno/GLES) were tested via systemd overrides. Neither affected the modifier — wlroots continued producing 0x0500000000000001. The likely cause is that Mesa on the custom 7.0.13-iris kernel does not honor the flag for wlroots’s gbm_bo_create_with_modifiers allocation path, where the driver returns UBWC as a valid modifier and wlroots picks it regardless of debug flags.
| Debug Approach | Tried? | Result |
|---|---|---|
TU_DEBUG=noubwc (Turnip/Vulkan) |
Yes | No effect on modifier |
FD_DEBUG=noubwc (Freedreno/GLES) |
Yes | No effect on modifier |
gst_glupload ! gst_glcolorconvert ! gst_gldownload |
Yes | g_mutex_clear() crash |
| Force wlroots to Pixman renderer | N/A | No API in wlroots 0.20.1 |
| EGL UBWC linearizer (chosen) | Yes | Works end-to-end |
Sources: portal/streaming/STREAMING_UBWC_FIX.md#L33-L38, portal/streaming/STREAMING_UBWC_FIX.md#L218-L228
Subsystem-by-Subsystem Rendering Paths
Section titled “Subsystem-by-Subsystem Rendering Paths”Each Portal subsystem negotiates the EGL path asymmetry differently, depending on whether it acts as a DRM display owner, a Wayland client, or a headless compute process.
flowchart TB
subgraph "Wayfire Compositor (portal.service)"
WF["WLR_BACKENDS=drm<br/>wlroots gles2 renderer<br/>(Freedreno)"]
WF -->|"GLASSES-1 output<br/>UBWC dmabuf"| CAP
end
subgraph "portal-keyboardd (portal-input.service)"
direction TB
KW["Wayland layer_shell on eDP-1"]
KW --> WEL["WaylandEglContext<br/>eglGetPlatformDisplayEXT<br/>(EGL_PLATFORM_WAYLAND_KHR)"]
WEL --> RIVE["Rive GL renderer<br/>(via GLES3 + Skia)"]
end
subgraph "portal_stream (portal-stream.service)"
CAP["zwlr_export_dmabuf<br/>capture"]
CAP --> EGL_LIN["GBM EGL (surfaceless)<br/>eglGetPlatformDisplayEXT<br/>(EGL_PLATFORM_GBM_MESA)"]
EGL_LIN -->|"eglCreateImageKHR<br/>glReadPixels"| LINEAR["Linear BGRx"]
LINEAR --> ENC["IRIS H.265 encoder"]
end
subgraph "Future Portal Apps"
APP["Wayland client<br/>(iced, cosmic-text, etc.)"]
APP --> WGPU["wgpu 24.0.5<br/>Vulkan backend<br/>(Turnip)"]
end
Wayfire Compositor
Section titled “Wayfire Compositor”The compositor uses wlroots’s default gles2 renderer with WLR_BACKENDS=drm. No special renderer selection is needed — wlroots automatically selects the GLES backend, which runs through Freedreno. This produces UBWC-compressed dmabufs on the GLASSES-1 virtual output, which is the input to the streaming pipeline.
Sources: portal/systemd/portal.service#L7-L7, portal/compositor/wayfire.ini#L1-L24
portal-keyboardd: The GBM-to-Wayland Migration
Section titled “portal-keyboardd: The GBM-to-Wayland Migration”The keyboard daemon went through three rendering phases:
Phase 1 — GBM EGL (legacy DRM/KMS path): The daemon opened /dev/dri/card0, acquired DRM master, created a GBM device and surface, then attempted eglCreatePlatformWindowSurfaceEXT on the GBM surface. This path is now guarded by an explicit driver check: if the DRM driver symlink starts with msm, the EGL init is skipped entirely and the daemon falls back to DRM dumb buffers (CPU rendering via Cairo + mmap scanout, no GPU glow animations).
Phase 2 — Wayland EGL (active path): The daemon runs as a Wayland layer-shell client on eDP-1, using WaylandEglContext which calls eglGetPlatformDisplayEXT(EGL_PLATFORM_WAYLAND_KHR, wl_display) → wl_egl_window_create(wl_surface) → eglCreateWindowSurface. This path works perfectly on the Mesa msm driver because it avoids GBM window surfaces entirely. Rendering is done via the glow crate (GLES3 bindings) with GLSL ES 3.00 shaders for per-key glow animations, or optionally via Rive for vector-based keyboard animations.
Phase 3 — Future (Rive + wgpu): The plan is to replace the GLES renderer with Rive on wgpu’s Vulkan backend, eliminating both the glow dependency and the EGL function-pointer dance entirely.
Sources: portal/input/src/bin/portal-keyboardd/egl.rs#L358-L377, portal/input/src/bin/portal-keyboardd/wayland_egl.rs#L322-L398, portal/input/src/bin/portal-keyboardd/app/mod.rs#L175-L194, portal/input/src/bin/portal-keyboardd/dumb_buffer.rs#L1-L8, docs/RENDERING_ARCHITECTURE.md#L176-L204
portal_stream: Surfaceless GBM EGL for dmabuf Import
Section titled “portal_stream: Surfaceless GBM EGL for dmabuf Import”The streaming daemon uses the GBM EGL platform without a window surface — it needs only an EGL context for eglCreateImageKHR and glReadPixels. This sidesteps the EGL_BAD_NATIVE_WINDOW failure that blocks eglCreateWindowSurface on GBM. The initialization sequence is:
open("/dev/dri/card0")→ DRM file descriptorgbm_create_device(drm_fd)→ GBM deviceeglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_MESA, gbm_device)→ EGL displayeglInitialize→ EGL 1.5eglCreateContext(GLES2) withEGL_NO_CONTEXTshare → surfaceless contexteglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context)→ bind context- Cache extension pointers:
eglCreateImageKHR,eglDestroyImageKHR,glEGLImageTargetTexture2DOES
Sources: portal/streaming/portal_stream.c#L219-L274, portal/streaming/build.sh#L72-L88
portal-warp: The Archived GLES Warp Renderer
Section titled “portal-warp: The Archived GLES Warp Renderer”The portal-warp crate contains a self-contained OpenGL ES 3.0 warp renderer designed for homography-based perspective correction on AR glasses output. It includes GLSL ES 3.00 vertex and fragment shaders, a trait-based GL function injection layer (GlFunctions and GlRenderFunctions), and a WarpRenderer struct that manages VAO/VBO/FBO state for textured-quad rendering with homography transformation applied in the vertex shader.
However, this crate’s FFI surface is dead code. The active portal-spatial-warp Wayfire plugin (portal/wayfire-plugins/portal-spatial-warp/plugin.cpp) uses Wayfire’s built-in view_3d_transformer_t for perspective warp instead of calling into the Rust FFI functions. The C++ shim applies a trapezoidal keystone correction by setting proj[2][0] = ±WARP_STRENGTH in a GLM mat4, then feeds it to Wayfire’s scene graph — no custom shader compilation required. The portal-warp exports are marked #[deprecated] and scheduled for removal.
Sources: portal/warp/src/lib.rs#L1-L27, portal/warp/src/renderer.rs#L199-L330, portal/warp/src/shader.rs#L23-L74, portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L60-L83, portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L135-L165
Software Stack Summary
Section titled “Software Stack Summary”| Layer | Spaceboard (Adreno X1-85) | Orange Pi (Mali-G610, legacy) |
|---|---|---|
| GPU driver | Mesa 26.1.2 (Freedreno + Turnip) | libmali blob (proprietary) |
| Vulkan | 1.4.348, full Wayland WSI | No WSI extensions (compute-only) |
| EGL Wayland | EGL 1.5, 30+ configs ✅ | eglInitialize fails ❌ |
| EGL GBM window | EGL_BAD_NATIVE_WINDOW ❌ |
Works ✅ |
| Compositor renderer | wlroots gles2 (Freedreno) | wlroots gles2 (Mali) |
| Client rendering | wgpu (Vulkan) / Wayland EGL + glow | portal-render (GBM + dmabuf bypass) |
| dmabuf modifier | QCOM UBWC (0x0500000000000001) |
Linear |
| Linearization | EGL linearizer in portal_stream | Not needed |
The key architectural lesson: a GPU driver’s EGL platform support is not monolithic. A driver can support GBM EGL for contexts while failing GBM EGL window surfaces, and support Wayland EGL while a different driver on different hardware fails it entirely. Portal’s rendering strategy adapts to each subsystem’s needs by selecting the EGL path that the target driver actually supports.
Sources: docs/RENDERING_ARCHITECTURE.md#L244-L262, portal/docs/architecture.md#L10-L22
Next Steps
Section titled “Next Steps”- For the compositor and spatial warp rendering that produces the GLASSES-1 output, see Spatial Warp Rendering: Homography-Based Head Tracking Correction.
- For the streaming pipeline that consumes the UBWC dmabufs and the full capture → encode → RTP flow, see End-to-End Streaming: DMA-BUF to H.265 Hardware Encode to RTP/UDP.
- For the keyboard daemon’s DRM/KMS direct scanout and Cairo rendering details, see Keyboard Daemon: DRM/KMS Direct Scanout, Cairo Rendering, and evdev Parsing.
- For the dual-display Wayland architecture that explains how eDP-1 and GLASSES-1 coexist, see Dual-Display Wayland Architecture: eDP-1 and Virtual GLASSES-1 Output.