Skip to content

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

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

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

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

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

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:

  1. open("/dev/dri/card0") → DRM file descriptor
  2. gbm_create_device(drm_fd) → GBM device
  3. eglGetPlatformDisplayEXT(EGL_PLATFORM_GBM_MESA, gbm_device) → EGL display
  4. eglInitialize → EGL 1.5
  5. eglCreateContext (GLES2) with EGL_NO_CONTEXT share → surfaceless context
  6. eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, context) → bind context
  7. 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

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