feat(client-linux): in-process GL presenter — hardware decode ships on the Steam Deck

VAAPI decode stays; what changes is who touches the YUV. The direct path hands
the NV12 dmabuf (tiled AMD modifier since Mesa 25.1) to GdkDmabufTexture, and
GTK's tiled-NV12 import renders corrupt/gray/washed-out on the Deck. Moonlight
and mpv are clean on the same box because they import the dmabuf into their own
EGL context and convert with their own shader — video_gl.rs is that
architecture for the GTK client: per-plane EGLImages (R8 + GR88, modifier
passed through) → our YUV→RGB shader (matrix/range from the stream's CICP
signaling, unit-tested) → RGBA texture in a GdkGLContext-shared context →
fence-synced GdkGLTexture. GTK composites plain RGBA; no YUV negotiation, no
compositor CSC.

The Deck's decoder default flips back to hardware (the software stopgap is
gone); desktops keep the direct dmabuf path (offload/scan-out eligible).
PUNKTFUNK_PRESENT=direct|gl overrides either way. New failure ladder: GL
converter init failure or a convert-error streak raises a shared flag and the
session pump demotes the decoder to software with a keyframe re-request — the
same mechanism also closes the old silent-black-screen gap where a rejected
dmabuf import had no recovery at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 11:59:53 +00:00
parent 8b37badae4
commit b488bd1d99
8 changed files with 781 additions and 19 deletions
+9
View File
@@ -106,6 +106,9 @@ pub fn start_session_with(
}
let mode = resolve_mode(&app);
let s = app.settings.borrow();
// The presenter raises this when hardware frames can't be displayed; the session pump
// demotes the decoder to software (see `SessionParams::force_software`).
let force_software = Arc::new(AtomicBool::new(false));
let params = SessionParams {
host: req.addr.clone(),
port: req.port,
@@ -125,6 +128,7 @@ pub fn start_session_with(
pin,
identity: app.identity.clone(),
connect_timeout: opts.connect_timeout,
force_software: force_software.clone(),
};
let inhibit = s.inhibit_shortcuts;
let show_stats = s.show_stats;
@@ -149,6 +153,7 @@ pub fn start_session_with(
inhibit,
show_stats,
frames: Some(frames),
force_software,
waiting: opts.waiting,
page: None,
};
@@ -198,6 +203,9 @@ struct SessionUi {
stop: Arc<AtomicBool>,
/// Decoded-frame receiver, handed to the stream page once on `Connected`.
frames: Option<async_channel::Receiver<DecodedFrame>>,
/// Shared with the session pump — the stream page's presenter raises it to demote
/// the decoder to software when hardware frames can't be displayed.
force_software: Arc<AtomicBool>,
/// The "waiting for approval" dialog (request-access flow), dismissed on the first event.
waiting: Option<adw::AlertDialog>,
page: Option<crate::ui_stream::StreamPage>,
@@ -259,6 +267,7 @@ impl SessionUi {
window: self.app.window.clone(),
connector,
frames: self.frames.take().expect("Connected delivered once"),
force_software: self.force_software.clone(),
clock_offset_ns,
escape_rx: self.app.gamepad.escape_events(),
disconnect_rx: self.app.gamepad.disconnect_events(),