feat(client): Vulkan session binary — SDL3 + ash presenter MVP (phase 1)

punktfunk-session streams one --connect session in an SDL3 window: ash
swapchain with a transfer-only letterboxed blit of the software-decode
path (no graphics pipeline until the phase-2 dmabuf/CSC pass), the
ui_stream input-capture state machine on SDL events (scancode→VK table
cross-checked against the evdev one), gamepads via a new caller-pumped
GamepadService mode (SDL video owns the main thread here), and the
shell↔session stdout contract: {"ready":true}, per-window stats:
lines, JSON error + exit codes 0/2/3/4. Strict trust — no pin, no
connect. Design: punktfunk-planning linux-client-rearchitecture.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 16:55:07 +02:00
parent 0ab97b597c
commit 89d45f2a55
12 changed files with 2014 additions and 27 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "pf-presenter"
description = "The Vulkan session presenter — SDL3 window, ash swapchain, frame present, input capture; the stage-2 presenter of punktfunk-planning linux-client-rearchitecture.md"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
# Same Linux gating as the rest of the client stack.
[target.'cfg(target_os = "linux")'.dependencies]
pf-client-core = { path = "../pf-client-core" }
punktfunk-core = { path = "../punktfunk-core", features = ["quic"] }
# `loaded` dlopens libvulkan at runtime (no link-time dependency — GPU-less boxes still
# start and fail into a clean error). `ash` on sdl3 types SDL_Vulkan_CreateSurface with
# ash 0.38 handles so the surface hands over without transmutes.
ash = { version = "0.38", features = ["loaded"] }
sdl3 = { version = "0.18", features = ["hidapi", "ash"] }
async-channel = "2"
anyhow = "1"
tracing = "0.1"
+183
View File
@@ -0,0 +1,183 @@
//! Input capture — the `ui_stream` state machine on SDL events.
//!
//! Capture is a deliberate, reversible STATE (Moonlight-style): engaged when the stream
//! starts and when the user clicks into the video (that click is suppressed toward the
//! host); released by Ctrl+Alt+Shift+Q (toggles) or focus loss — held keys/buttons are
//! flushed host-side on release so nothing sticks down. While captured the local cursor
//! is hidden (the host renders its own); while released nothing is forwarded.
//!
//! Keys are SDL scancodes → VK via `keymap_sdl`, layout-independent. Mouse is absolute
//! (`MouseMoveAbs` scaled into the negotiated mode through the letterbox transform,
//! window size packed in `flags` — the GTK client's exact contract); motion is coalesced
//! to one datagram per loop iteration. Pointer-lock relative capture is the follow-up
//! (§5.3 of the plan) — absolute first for GTK parity.
use crate::keymap_sdl;
use punktfunk_core::client::NativeClient;
use punktfunk_core::input::{InputEvent, InputKind};
use std::collections::HashSet;
use std::sync::Arc;
pub struct Capture {
connector: Arc<NativeClient>,
captured: bool,
/// VKs / GameStream button ids currently held — flushed up on release.
held_keys: HashSet<u8>,
held_buttons: HashSet<u32>,
/// Newest absolute pointer position (window coordinates) not yet on the wire —
/// motion events only store here; `flush_motion` sends at most one `MouseMoveAbs`
/// per loop iteration (a 1000 Hz mouse would otherwise send a datagram per event).
pending_abs: Option<(f32, f32)>,
/// Fractional wheel remainder per axis (x, y) in 120-unit WHEEL_DELTA space —
/// precision surfaces deliver sub-unit deltas; truncating each event drops the tail.
scroll_acc: (f64, f64),
}
fn send(connector: &NativeClient, kind: InputKind, code: u32, x: i32, y: i32, flags: u32) {
let _ = connector.send_input(&InputEvent {
kind,
_pad: [0; 3],
code,
x,
y,
flags,
});
}
impl Capture {
pub fn new(connector: Arc<NativeClient>) -> Capture {
Capture {
connector,
captured: false,
held_keys: HashSet::new(),
held_buttons: HashSet::new(),
pending_abs: None,
scroll_acc: (0.0, 0.0),
}
}
pub fn captured(&self) -> bool {
self.captured
}
/// Engage capture. The caller hides the cursor (SDL state lives with the loop).
pub fn engage(&mut self) -> bool {
!std::mem::replace(&mut self.captured, true)
}
/// Release capture, flushing everything held so nothing sticks down on the host.
/// The caller restores the cursor. Returns false if it wasn't engaged.
pub fn release(&mut self) -> bool {
if !std::mem::replace(&mut self.captured, false) {
return false;
}
self.pending_abs = None; // never flush motion gathered while captured
for vk in self.held_keys.drain() {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
for b in self.held_buttons.drain() {
send(&self.connector, InputKind::MouseButtonUp, b, 0, 0, 0);
}
true
}
/// Forward the coalesced pointer position, if any — one datagram per loop iteration.
/// `win` is the window's logical size (SDL mouse coordinates' space).
pub fn flush_motion(&mut self, win: (u32, u32)) {
if let Some((x, y)) = self.pending_abs.take() {
self.send_abs(x, y, win);
}
}
/// Absolute position: window coordinates → video pixels through the Contain-fit
/// letterbox (the blit uses the same math in pixel space — the ratios are identical).
/// `flags` packs the coordinate-space size (`(w << 16) | h`) — the host normalizes
/// against it before mapping into the EIS region; without it the event is dropped.
fn send_abs(&self, x: f32, y: f32, win: (u32, u32)) {
let mode = self.connector.mode();
let (ww, wh) = (win.0.max(1) as f64, win.1.max(1) as f64);
let (vw, vh) = (mode.width.max(1) as f64, mode.height.max(1) as f64);
let scale = (ww / vw).min(wh / vh);
let (ox, oy) = ((ww - vw * scale) / 2.0, (wh - vh * scale) / 2.0);
let px = ((f64::from(x) - ox) / scale).round().clamp(0.0, vw - 1.0) as i32;
let py = ((f64::from(y) - oy) / scale).round().clamp(0.0, vh - 1.0) as i32;
let flags = (mode.width << 16) | (mode.height & 0xffff);
send(&self.connector, InputKind::MouseMoveAbs, 0, px, py, flags);
}
pub fn on_motion(&mut self, x: f32, y: f32) {
if self.captured {
self.pending_abs = Some((x, y));
}
}
pub fn on_key_down(&mut self, sc: sdl3::keyboard::Scancode, win: (u32, u32)) {
if !self.captured {
return;
}
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
// Keep the wire ordered: the host must see the cursor where the user does
// when the key lands (e.g. "press E at the crosshair").
self.flush_motion(win);
self.held_keys.insert(vk);
send(&self.connector, InputKind::KeyDown, vk as u32, 0, 0, 0);
}
}
pub fn on_key_up(&mut self, sc: sdl3::keyboard::Scancode) {
if let Some(vk) = keymap_sdl::scancode_to_vk(sc) {
// Flush-on-release may have beaten us to it — only forward if still held.
if self.held_keys.remove(&vk) {
send(&self.connector, InputKind::KeyUp, vk as u32, 0, 0, 0);
}
}
}
/// A button press while captured. The engaging click is the caller's business (it
/// never reaches here). The click's own coordinates supersede pending motion so the
/// button-down lands exactly where the cursor last was.
pub fn on_button_down(&mut self, b: sdl3::mouse::MouseButton, x: f32, y: f32, win: (u32, u32)) {
if !self.captured {
return;
}
self.pending_abs = Some((x, y));
self.flush_motion(win);
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
self.held_buttons.insert(gs);
send(&self.connector, InputKind::MouseButtonDown, gs, 0, 0, 0);
}
}
pub fn on_button_up(&mut self, b: sdl3::mouse::MouseButton, win: (u32, u32)) {
self.flush_motion(win); // the release must not beat the motion before it
if let Some(gs) = keymap_sdl::mouse_button_to_gs(b) {
if self.held_buttons.remove(&gs) {
send(&self.connector, InputKind::MouseButtonUp, gs, 0, 0, 0);
}
}
}
/// Wheel — the wire carries WHEEL_DELTA(120) units, positive = up / right. SDL3's y
/// is positive = up and x positive = right already (no negation — GTK's dy was the
/// inverted one). Fractional remainders accumulate per axis.
pub fn on_wheel(&mut self, dx: f32, dy: f32, win: (u32, u32)) {
if !self.captured {
return;
}
self.flush_motion(win); // scroll happens at the latest cursor position
let (mut ax, mut ay) = self.scroll_acc;
ay += f64::from(dy) * 120.0;
ax += f64::from(dx) * 120.0;
let vy = ay.trunc() as i32;
if vy != 0 {
ay -= f64::from(vy);
send(&self.connector, InputKind::MouseScroll, 0, vy, 0, 0);
}
let vx = ax.trunc() as i32;
if vx != 0 {
ax -= f64::from(vx);
send(&self.connector, InputKind::MouseScroll, 1, vx, 0, 0);
}
self.scroll_acc = (ax, ay);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! SDL scancodes / mouse buttons → the punktfunk input wire contract.
//!
//! Same VK codes as `pf_client_core::keymap` (the wire carries Windows Virtual-Keys, the
//! GameStream convention), keyed on SDL scancodes instead of evdev codes: SDL normalizes
//! the platform keycode to its USB-HID-derived scancode table, so this stays
//! layout-independent exactly like the evdev table. Coverage mirrors `keymap::evdev_to_vk`
//! one-to-one — a key the wire contract doesn't cover (media keys etc.) returns `None`
//! and is dropped rather than guessed.
use sdl3::keyboard::Scancode;
/// Map an SDL scancode to the Windows VK code the host expects.
pub fn scancode_to_vk(sc: Scancode) -> Option<u8> {
use Scancode as S;
Some(match sc {
// --- Navigation / editing / whitespace ---
S::Backspace => 0x08,
S::Tab => 0x09,
S::Return => 0x0D,
S::Pause => 0x13,
S::CapsLock => 0x14,
S::Escape => 0x1B,
S::Space => 0x20,
S::PageUp => 0x21,
S::PageDown => 0x22,
S::End => 0x23,
S::Home => 0x24,
S::Left => 0x25,
S::Up => 0x26,
S::Right => 0x27,
S::Down => 0x28,
S::PrintScreen => 0x2C,
S::Insert => 0x2D,
S::Delete => 0x2E,
// --- Digit row ---
S::_0 => 0x30,
S::_1 => 0x31,
S::_2 => 0x32,
S::_3 => 0x33,
S::_4 => 0x34,
S::_5 => 0x35,
S::_6 => 0x36,
S::_7 => 0x37,
S::_8 => 0x38,
S::_9 => 0x39,
// --- Letters ---
S::A => 0x41,
S::B => 0x42,
S::C => 0x43,
S::D => 0x44,
S::E => 0x45,
S::F => 0x46,
S::G => 0x47,
S::H => 0x48,
S::I => 0x49,
S::J => 0x4A,
S::K => 0x4B,
S::L => 0x4C,
S::M => 0x4D,
S::N => 0x4E,
S::O => 0x4F,
S::P => 0x50,
S::Q => 0x51,
S::R => 0x52,
S::S => 0x53,
S::T => 0x54,
S::U => 0x55,
S::V => 0x56,
S::W => 0x57,
S::X => 0x58,
S::Y => 0x59,
S::Z => 0x5A,
// --- Meta / context-menu ---
S::LGui => 0x5B,
S::RGui => 0x5C,
S::Application => 0x5D,
// --- Numpad ---
S::Kp0 => 0x60,
S::Kp1 => 0x61,
S::Kp2 => 0x62,
S::Kp3 => 0x63,
S::Kp4 => 0x64,
S::Kp5 => 0x65,
S::Kp6 => 0x66,
S::Kp7 => 0x67,
S::Kp8 => 0x68,
S::Kp9 => 0x69,
S::KpMultiply => 0x6A,
S::KpPlus => 0x6B,
// KP-Enter → VK_SEPARATOR mirrors the evdev table (KEY_KPENTER → 0x6C).
S::KpEnter => 0x6C,
S::KpMinus => 0x6D,
S::KpPeriod => 0x6E,
S::KpDivide => 0x6F,
// --- Function keys ---
S::F1 => 0x70,
S::F2 => 0x71,
S::F3 => 0x72,
S::F4 => 0x73,
S::F5 => 0x74,
S::F6 => 0x75,
S::F7 => 0x76,
S::F8 => 0x77,
S::F9 => 0x78,
S::F10 => 0x79,
S::F11 => 0x7A,
S::F12 => 0x7B,
// --- Locks ---
S::NumLockClear => 0x90,
S::ScrollLock => 0x91,
// --- Left/right modifiers ---
S::LShift => 0xA0,
S::RShift => 0xA1,
S::LCtrl => 0xA2,
S::RCtrl => 0xA3,
S::LAlt => 0xA4,
S::RAlt => 0xA5,
// --- OEM punctuation (US-layout positions) ---
S::Semicolon => 0xBA,
S::Equals => 0xBB,
S::Comma => 0xBC,
S::Minus => 0xBD,
S::Period => 0xBE,
S::Slash => 0xBF,
S::Grave => 0xC0,
S::LeftBracket => 0xDB,
S::Backslash => 0xDC,
S::RightBracket => 0xDD,
S::Apostrophe => 0xDE,
S::NonUsBackslash => 0xE2,
_ => return None,
})
}
/// SDL mouse button → the GameStream button id the wire expects (1=left, 2=middle,
/// 3=right, 4=X1, 5=X2) — the SDL twin of `keymap::gdk_button_to_gs`.
pub fn mouse_button_to_gs(b: sdl3::mouse::MouseButton) -> Option<u32> {
use sdl3::mouse::MouseButton as B;
Some(match b {
B::Left => 1,
B::Middle => 2,
B::Right => 3,
B::X1 => 4,
B::X2 => 5,
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pf_client_core::keymap::evdev_to_vk;
/// Both tables feed the same wire: for every key the evdev table knows, the SDL
/// scancode of the same physical key must map to the same VK.
#[test]
fn agrees_with_the_evdev_table() {
use Scancode as S;
let same_key: &[(Scancode, u16)] = &[
(S::Backspace, 14),
(S::Tab, 15),
(S::Return, 28),
(S::Escape, 1),
(S::Space, 57),
(S::PageUp, 104),
(S::Left, 105),
(S::_0, 11),
(S::_1, 2),
(S::_9, 10),
(S::A, 30),
(S::Q, 16),
(S::Z, 44),
(S::LGui, 125),
(S::Kp0, 82),
(S::Kp9, 73),
(S::KpEnter, 96),
(S::F1, 59),
(S::F11, 87),
(S::F12, 88),
(S::NumLockClear, 69),
(S::LShift, 42),
(S::RAlt, 100),
(S::Semicolon, 39),
(S::Grave, 41),
(S::NonUsBackslash, 86),
];
for &(sc, ev) in same_key {
assert_eq!(scancode_to_vk(sc), evdev_to_vk(ev), "scancode {sc:?}");
}
assert_eq!(scancode_to_vk(Scancode::Mute), None); // not in the wire contract
}
}
+21
View File
@@ -0,0 +1,21 @@
//! The Vulkan session presenter (punktfunk-planning `linux-client-rearchitecture.md`,
//! Phase 1): an SDL3 window + ash swapchain that presents the shared session pump's
//! decoded frames, captures input on the `ui_stream` state-machine contract, and reports
//! the unified stats window on stdout. No UI toolkit anywhere in the dependency tree.
//!
//! Phase 1 is the software path: `CpuFrame` RGBA uploads + a transfer-only letterbox
//! blit (no graphics pipeline, no shaders — those arrive with the Phase 2 dmabuf/CSC
//! pass). A hardware (dmabuf) frame slipping through demotes the decoder to software via
//! the session pump's `force_software` contract, same as the GTK presenter.
#[cfg(target_os = "linux")]
pub mod input;
#[cfg(target_os = "linux")]
pub mod keymap_sdl;
#[cfg(target_os = "linux")]
mod run;
#[cfg(target_os = "linux")]
pub mod vk;
#[cfg(target_os = "linux")]
pub use run::{run_session, Outcome, SessionOpts};
+415
View File
@@ -0,0 +1,415 @@
//! The session lifecycle loop: one SDL context on the caller's main thread driving the
//! window, the Vulkan presenter, input capture, the pumped gamepad service, and the
//! shared session pump's event/frame channels.
//!
//! Stdout is the machine interface (the shell↔session contract): one `{"ready":true}`
//! line after the first presented frame, `stats: …` lines once per window while enabled
//! (Ctrl+Alt+Shift+S toggles). Logs go to stderr (the binary configures tracing so).
use crate::input::Capture;
use crate::vk::Presenter;
use anyhow::{Context as _, Result};
use pf_client_core::gamepad::GamepadService;
use pf_client_core::session::{self, SessionEvent, SessionParams, Stats};
use pf_client_core::video::{DecodedFrame, DecodedImage};
use punktfunk_core::client::NativeClient;
use punktfunk_core::config::Mode;
use sdl3::event::{Event, WindowEvent};
use sdl3::keyboard::Mod;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
pub struct SessionOpts {
pub window_title: String,
/// Start fullscreen (gamescope / `--fullscreen`).
pub fullscreen: bool,
/// Print `stats:` lines (Ctrl+Alt+Shift+S toggles live).
pub print_stats: bool,
/// Emit the `{"ready":true}` stdout line after the first presented frame.
pub json_status: bool,
/// Called once on `Connected` with the host's fingerprint (trust persistence is the
/// binary's business — this loop stays store-agnostic).
pub on_connected: Option<Box<dyn FnMut([u8; 32])>>,
}
pub enum Outcome {
/// The session ran and ended: `None` = deliberate exit (user quit), `Some` = the
/// reason the pump reported (host ended, transport error…).
Ended(Option<String>),
ConnectFailed {
msg: String,
trust_rejected: bool,
},
}
/// Everything SDL on the main thread, per the plan (§5.1): window + events + gamepads in
/// ONE event loop — the shell-era worker-thread arrangement can't coexist with an SDL
/// video window. `build_params` receives the gamepad service (for `auto_pref`), the
/// window's native display mode (the `0 = native` resolution fallback), and the shared
/// `force_software` flag, and returns the pump parameters.
pub fn run_session<F>(mut opts: SessionOpts, build_params: F) -> Result<Outcome>
where
F: FnOnce(&GamepadService, Mode, Arc<AtomicBool>) -> SessionParams,
{
sdl3::hint::set("SDL_JOYSTICK_THREAD", "1");
let sdl = sdl3::init().context("SDL init")?;
let video = sdl.video().context("SDL video")?;
let mut window = {
let mut b = video.window(&opts.window_title, 1280, 720);
b.position_centered().resizable().vulkan();
if opts.fullscreen {
b.fullscreen();
}
b.build().context("SDL window")?
};
let instance_exts = window
.vulkan_instance_extensions()
.map_err(|e| anyhow::anyhow!("vulkan instance extensions: {e}"))?;
let mut presenter = Presenter::new(&window, &instance_exts).context("vulkan presenter")?;
// A valid black frame immediately — the window is honest while the connect runs.
presenter.present(&window, None)?;
let gamepad_subsystem = sdl.gamepad().context("SDL gamepad")?;
let (gamepad, mut pump) = GamepadService::pumped(gamepad_subsystem);
let escape_rx = gamepad.escape_events();
let disconnect_rx = gamepad.disconnect_events();
// The native display mode — the `0 = native` fallback for the requested stream mode
// (the GTK client reads the monitor under its window; same idea).
let native = window
.get_display()
.and_then(|d| d.get_mode())
.map(|m| Mode {
width: m.w.max(0) as u32,
height: m.h.max(0) as u32,
refresh_hz: m.refresh_rate.round().max(0.0) as u32,
})
.unwrap_or(Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
});
let force_software = Arc::new(AtomicBool::new(false));
let params = build_params(&gamepad, native, force_software.clone());
let handle = session::start(params);
let mut event_pump = sdl
.event_pump()
.map_err(|e| anyhow::anyhow!("SDL event pump: {e}"))?;
let mouse = sdl.mouse();
// --- Loop state --------------------------------------------------------------------
let mut connector: Option<Arc<NativeClient>> = None;
let mut capture: Option<Capture> = None;
let mut fullscreen = opts.fullscreen;
let mut ready_announced = false;
let mut print_stats = opts.print_stats;
let mut mode_line = String::new();
let mut clock_offset_ns: i64 = 0;
let mut hdr = false;
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
let mut win_e2e_us: Vec<u64> = Vec::with_capacity(256);
let mut win_disp_us: Vec<u64> = Vec::with_capacity(256);
let mut win_start = Instant::now();
let mut presented = PresentedWindow::default();
// Retained newest frame for expose/resize re-blits (the presenter keeps the GPU
// image; this keeps the pending upload if one arrives while minimized).
let mut dmabuf_demoted = false;
let outcome = 'main: loop {
// --- SDL events (input, window, gamepads) ---------------------------------------
// Block briefly in SDL's own wait so idle costs nothing; while streaming, frames
// arrive on the channel below and 1 ms bounds the added present latency.
let timeout = Duration::from_millis(if connector.is_some() { 1 } else { 10 });
let first = event_pump.wait_event_timeout(timeout);
let mut queued: Vec<Event> = Vec::new();
if let Some(e) = first {
queued.push(e);
}
while let Some(e) = event_pump.poll_event() {
queued.push(e);
}
for event in queued {
match event {
Event::Quit { .. } => {
// Window close / SIGINT: deliberate user exit — QUIT_CLOSE_CODE so
// the host tears down instead of lingering for a reconnect.
if let Some(c) = &connector {
c.disconnect_quit();
}
handle.stop.store(true, Ordering::SeqCst);
break 'main Outcome::Ended(None);
}
Event::Window { win_event, .. } => match win_event {
WindowEvent::FocusLost => {
if let Some(cap) = &mut capture {
if cap.release() {
mouse.show_cursor(true);
tracing::info!("focus lost — input released");
}
}
}
WindowEvent::PixelSizeChanged(..) | WindowEvent::Resized(..) => {
presenter.recreate_swapchain(&window)?;
presenter.present(&window, None)?;
}
WindowEvent::Exposed => {
presenter.present(&window, None)?;
}
_ => {}
},
Event::KeyDown {
scancode: Some(sc),
keymod,
repeat: false,
..
} => {
let chord = keymod.intersects(Mod::LCTRLMOD | Mod::RCTRLMOD)
&& keymod.intersects(Mod::LALTMOD | Mod::RALTMOD)
&& keymod.intersects(Mod::LSHIFTMOD | Mod::RSHIFTMOD);
use sdl3::keyboard::Scancode;
if chord && sc == Scancode::Q {
if let Some(cap) = &mut capture {
if cap.captured() {
cap.release();
mouse.show_cursor(true);
} else {
cap.engage();
mouse.show_cursor(false);
}
tracing::info!(captured = cap.captured(), "chord: release/engage");
}
continue;
}
if chord && sc == Scancode::D {
tracing::info!("chord: disconnect");
if let Some(cap) = &mut capture {
cap.release();
}
if let Some(c) = &connector {
c.disconnect_quit();
}
handle.stop.store(true, Ordering::SeqCst);
break 'main Outcome::Ended(None);
}
if chord && sc == Scancode::S {
print_stats = !print_stats;
continue;
}
if sc == Scancode::F11 {
fullscreen = !fullscreen;
if let Err(e) = window.set_fullscreen(fullscreen) {
tracing::warn!(error = %e, "fullscreen toggle");
}
continue;
}
if let Some(cap) = &mut capture {
cap.on_key_down(sc, window.size());
}
}
Event::KeyUp {
scancode: Some(sc), ..
} => {
if let Some(cap) = &mut capture {
cap.on_key_up(sc);
}
}
Event::MouseMotion { x, y, .. } => {
if let Some(cap) = &mut capture {
cap.on_motion(x, y);
}
}
Event::MouseButtonDown {
mouse_btn, x, y, ..
} => {
if let Some(cap) = &mut capture {
if !cap.captured() {
// The engaging click is suppressed toward the host.
cap.engage();
mouse.show_cursor(false);
} else {
cap.on_button_down(mouse_btn, x, y, window.size());
}
}
}
Event::MouseButtonUp { mouse_btn, .. } => {
if let Some(cap) = &mut capture {
cap.on_button_up(mouse_btn, window.size());
}
}
Event::MouseWheel { x, y, .. } => {
if let Some(cap) = &mut capture {
cap.on_wheel(x, y, window.size());
}
}
// Everything else (gamepad add/remove/button/axis/touchpad/sensor…) is
// the pumped gamepad worker's — it ignores what it doesn't know.
other => pump.handle_event(other),
}
}
pump.tick();
// Controller escape chord: release capture (+ leave fullscreen on desktop — under
// a `--fullscreen` gamescope launch there is nothing to release into).
while escape_rx.try_recv().is_ok() {
if let Some(cap) = &mut capture {
if cap.release() {
mouse.show_cursor(true);
}
}
if fullscreen && !opts.fullscreen {
fullscreen = false;
let _ = window.set_fullscreen(false);
}
}
// Escape chord held past the threshold: the controller's Ctrl+Alt+Shift+D.
if disconnect_rx.try_recv().is_ok() {
tracing::info!("controller chord: disconnect");
if let Some(cap) = &mut capture {
cap.release();
}
if let Some(c) = &connector {
c.disconnect_quit();
}
handle.stop.store(true, Ordering::SeqCst);
break Outcome::Ended(None);
}
// --- Session events --------------------------------------------------------------
while let Ok(ev) = handle.events.try_recv() {
match ev {
SessionEvent::Connected {
connector: c,
mode,
fingerprint,
} => {
mode_line = format!("{}×{}@{}", mode.width, mode.height, mode.refresh_hz);
tracing::info!(mode = %mode_line, "connected");
window.set_title(&format!("{} · {}", opts.window_title, mode_line)).ok();
gamepad.attach(c.clone());
clock_offset_ns = c.clock_offset_ns;
let mut cap = Capture::new(c.clone());
cap.engage(); // capture engages when the stream starts (ui_stream parity)
mouse.show_cursor(false);
capture = Some(cap);
connector = Some(c);
if let Some(f) = opts.on_connected.as_mut() {
f(fingerprint);
}
}
SessionEvent::Stats(s) => {
if print_stats {
print_stats_line(&mode_line, &s, &presented, hdr);
}
}
SessionEvent::Failed { msg, trust_rejected } => {
break 'main Outcome::ConnectFailed { msg, trust_rejected };
}
SessionEvent::Ended(reason) => {
gamepad.detach();
if let Some(cap) = &mut capture {
cap.release();
mouse.show_cursor(true);
}
break 'main Outcome::Ended(reason);
}
}
}
// --- Frames: drain to the newest, upload + present -------------------------------
let mut newest: Option<DecodedFrame> = None;
while let Ok(f) = handle.frames.try_recv() {
newest = Some(f);
}
if let Some(f) = newest {
match f.image {
DecodedImage::Cpu(c) => {
hdr = c.color.is_pq();
presenter.present(&window, Some(&c))?;
let displayed_ns = session::now_ns();
if opts.json_status && !ready_announced {
ready_announced = true;
println!("{{\"ready\":true}}");
}
// The `displayed` stamp (same clamp rules as the pump's windows).
let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - f.pts_ns as i128)
.max(0) as u64;
if e2e > 0 && e2e < 10_000_000_000 {
win_e2e_us.push(e2e / 1000);
}
win_disp_us.push(displayed_ns.saturating_sub(f.decoded_ns) / 1000);
}
DecodedImage::Dmabuf(_) => {
// Phase 1 has no hardware present path — demote the decoder to
// software through the same contract the GTK presenter uses. Once.
if !dmabuf_demoted {
dmabuf_demoted = true;
tracing::warn!(
"hardware (dmabuf) frame reached the phase-1 presenter — \
demoting the decoder to software"
);
force_software.store(true, Ordering::Relaxed);
}
}
}
}
// Fold the presenter window into the shared stats line once per second.
if win_start.elapsed() >= Duration::from_secs(1) {
let (e2e_p50, e2e_p95) = session::window_percentiles(&mut win_e2e_us);
let (disp_p50, _) = session::window_percentiles(&mut win_disp_us);
presented = PresentedWindow {
e2e_p50_ms: e2e_p50 as f32 / 1000.0,
e2e_p95_ms: e2e_p95 as f32 / 1000.0,
display_ms: disp_p50 as f32 / 1000.0,
};
win_e2e_us.clear();
win_disp_us.clear();
win_start = Instant::now();
}
};
handle.stop.store(true, Ordering::SeqCst);
Ok(outcome)
}
/// The presenter's share of the unified stats window — folded into each printed line.
#[derive(Default)]
struct PresentedWindow {
e2e_p50_ms: f32,
e2e_p95_ms: f32,
display_ms: f32,
}
/// One `stats:` line per 1 s window — the OSD's numbers (design/stats-unification.md) in
/// terminal form: headline end-to-end capture→displayed, then the per-stage p50s.
fn print_stats_line(mode_line: &str, s: &Stats, p: &PresentedWindow, hdr: bool) {
let mut line = format!(
"stats: {mode_line} · {:.0} fps · {:.1} Mb/s · {}{} · e2e {:.1}/{:.1} ms (p50/p95)",
s.fps,
s.mbps,
if s.decoder.is_empty() { "-" } else { s.decoder },
if hdr { " · HDR" } else { "" },
p.e2e_p50_ms,
p.e2e_p95_ms,
);
if s.split {
line.push_str(&format!(
" · host {:.1} · net {:.1}",
s.host_ms, s.net_ms
));
} else {
line.push_str(&format!(" · host+net {:.1}", s.host_net_ms));
}
line.push_str(&format!(
" · decode {:.1} · display {:.1} ms",
s.decode_ms, p.display_ms
));
if s.lost > 0 {
line.push_str(&format!(" · lost {} ({:.1}%)", s.lost, s.lost_pct));
}
println!("{line}");
}
+747
View File
@@ -0,0 +1,747 @@
//! The transfer-only Vulkan presenter: swapchain + staging upload + letterboxed blit.
//!
//! Phase 1 deliberately has NO graphics pipeline: software-decoded RGBA uploads to a
//! device-local image (`copy_buffer_to_image`, the row stride handled by
//! `buffer_row_length`), and each present clears the swapchain image and
//! `vkCmdBlitImage`s the video into the Contain-fit letterbox rect (linear filter,
//! format conversion by the blit). No render pass, no framebuffers, no image views, no
//! SPIR-V toolchain — all of that arrives with the Phase 2 dmabuf/CSC pass, which is the
//! first thing that actually needs a shader.
//!
//! Pacing: one frame in flight (the submit fence is waited before each record), FIFO by
//! default (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate` if available). Present is
//! arrival-paced by the caller: `present(Some(frame))` on each decoded frame,
//! `present(None)` re-blits the retained video image (expose/resize redraws).
use anyhow::{anyhow, bail, Context as _, Result};
use ash::vk;
use pf_client_core::video::CpuFrame;
use std::ffi::CString;
/// The one video image (device-local RGBA the size of the decoded stream) + its staging.
struct VideoImage {
image: vk::Image,
memory: vk::DeviceMemory,
width: u32,
height: u32,
}
struct Staging {
buffer: vk::Buffer,
memory: vk::DeviceMemory,
ptr: *mut u8,
capacity: usize,
}
pub struct Presenter {
// Field order = drop order documentation only; teardown is explicit in `Drop`.
entry: ash::Entry,
instance: ash::Instance,
surface_i: ash::khr::surface::Instance,
surface: vk::SurfaceKHR,
pdev: vk::PhysicalDevice,
mem_props: vk::PhysicalDeviceMemoryProperties,
device: ash::Device,
swap_d: ash::khr::swapchain::Device,
queue: vk::Queue,
format: vk::SurfaceFormatKHR,
present_mode: vk::PresentModeKHR,
swapchain: vk::SwapchainKHR,
images: Vec<vk::Image>,
extent: vk::Extent2D,
/// Per-swapchain-image render-finished semaphores (present consumes them on the
/// image's schedule — one shared semaphore could be re-submitted while a previous
/// present still holds it).
render_sems: Vec<vk::Semaphore>,
acquire_sem: vk::Semaphore,
fence: vk::Fence,
cmd_pool: vk::CommandPool,
cmd_buf: vk::CommandBuffer,
staging: Option<Staging>,
video: Option<VideoImage>,
/// The submit fence has a submission pending (wait before recording again — also
/// what makes the single staging buffer safe to overwrite).
submitted: bool,
}
impl Presenter {
/// Bring up instance → surface → device → swapchain over an SDL window.
/// `instance_extensions` comes from `VideoSubsystem::vulkan_instance_extensions()`.
pub fn new(window: &sdl3::video::Window, instance_extensions: &[String]) -> Result<Presenter> {
let entry = unsafe { ash::Entry::load() }.context("libvulkan not loadable")?;
let app_name = CString::new("punktfunk-session").unwrap();
let app_info = vk::ApplicationInfo::default()
.application_name(&app_name)
.api_version(vk::API_VERSION_1_2);
let ext_cstrings: Vec<CString> = instance_extensions
.iter()
.map(|e| CString::new(e.as_str()).unwrap())
.collect();
let ext_ptrs: Vec<*const i8> = ext_cstrings.iter().map(|e| e.as_ptr()).collect();
let instance = unsafe {
entry.create_instance(
&vk::InstanceCreateInfo::default()
.application_info(&app_info)
.enabled_extension_names(&ext_ptrs),
None,
)
}
.context("vkCreateInstance")?;
let surface_i = ash::khr::surface::Instance::new(&entry, &instance);
let surface = unsafe { window.vulkan_create_surface(instance.handle()) }
.map_err(|e| anyhow!("SDL_Vulkan_CreateSurface: {e}"))?;
let (pdev, qfi) = pick_device(&instance, &surface_i, surface)?;
let mem_props = unsafe { instance.get_physical_device_memory_properties(pdev) };
{
let props = unsafe { instance.get_physical_device_properties(pdev) };
let name = props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default();
tracing::info!(device = %name, queue_family = qfi, "vulkan device");
}
let queue_info = [vk::DeviceQueueCreateInfo::default()
.queue_family_index(qfi)
.queue_priorities(&[1.0])];
// Phase 2 (dmabuf import) adds VK_EXT_external_memory_dma_buf +
// VK_KHR_external_memory_fd + VK_EXT_image_drm_format_modifier here.
let dev_exts = [ash::khr::swapchain::NAME.as_ptr()];
let device = unsafe {
instance.create_device(
pdev,
&vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_info)
.enabled_extension_names(&dev_exts),
None,
)
}
.context("vkCreateDevice")?;
let swap_d = ash::khr::swapchain::Device::new(&instance, &device);
let queue = unsafe { device.get_device_queue(qfi, 0) };
let format = pick_format(&surface_i, pdev, surface)?;
let present_mode = pick_present_mode(&surface_i, pdev, surface)?;
tracing::info!(?format, ?present_mode, "swapchain config");
let cmd_pool = unsafe {
device.create_command_pool(
&vk::CommandPoolCreateInfo::default()
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER)
.queue_family_index(qfi),
None,
)
}?;
let cmd_buf = unsafe {
device.allocate_command_buffers(
&vk::CommandBufferAllocateInfo::default()
.command_pool(cmd_pool)
.level(vk::CommandBufferLevel::PRIMARY)
.command_buffer_count(1),
)
}?[0];
let acquire_sem =
unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) }?;
let fence = unsafe {
device.create_fence(
&vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED),
None,
)
}?;
let mut p = Presenter {
entry,
instance,
surface_i,
surface,
pdev,
mem_props,
device,
swap_d,
queue,
format,
present_mode,
swapchain: vk::SwapchainKHR::null(),
images: Vec::new(),
extent: vk::Extent2D::default(),
render_sems: Vec::new(),
acquire_sem,
fence,
cmd_pool,
cmd_buf,
staging: None,
video: None,
submitted: false,
};
p.recreate_swapchain(window)?;
Ok(p)
}
/// (Re)build the swapchain for the window's current pixel size. Also the resize path.
pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> {
unsafe { self.device.device_wait_idle() }.ok();
self.submitted = false;
let caps = unsafe {
self.surface_i
.get_physical_device_surface_capabilities(self.pdev, self.surface)
}?;
let (pw, ph) = window.size_in_pixels();
let extent = if caps.current_extent.width != u32::MAX {
caps.current_extent
} else {
vk::Extent2D {
width: pw.clamp(caps.min_image_extent.width, caps.max_image_extent.width),
height: ph.clamp(caps.min_image_extent.height, caps.max_image_extent.height),
}
};
if extent.width == 0 || extent.height == 0 {
// Minimized — keep the old swapchain; presents will report OUT_OF_DATE and
// land back here once the window has a size again.
return Ok(());
}
let mut min_images = caps.min_image_count + 1;
if caps.max_image_count > 0 {
min_images = min_images.min(caps.max_image_count);
}
let old = self.swapchain;
let info = vk::SwapchainCreateInfoKHR::default()
.surface(self.surface)
.min_image_count(min_images)
.image_format(self.format.format)
.image_color_space(self.format.color_space)
.image_extent(extent)
.image_array_layers(1)
// TRANSFER_DST is the whole phase-1 pipeline (clear + blit); COLOR_ATTACHMENT
// keeps the phase-2 render pass from forcing a swapchain rebuild contract change.
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST)
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
.pre_transform(caps.current_transform)
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
.present_mode(self.present_mode)
.clipped(true)
.old_swapchain(old);
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
.context("vkCreateSwapchainKHR")?;
if old != vk::SwapchainKHR::null() {
unsafe { self.swap_d.destroy_swapchain(old, None) };
}
self.swapchain = swapchain;
self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?;
self.extent = extent;
for s in self.render_sems.drain(..) {
unsafe { self.device.destroy_semaphore(s, None) };
}
for _ in 0..self.images.len() {
self.render_sems.push(unsafe {
self.device
.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)
}?);
}
tracing::debug!(
width = extent.width,
height = extent.height,
images = self.images.len(),
"swapchain (re)created"
);
Ok(())
}
/// Present one frame: upload `frame` if given (else re-blit the retained video
/// image), clear, letterbox-blit, present. Returns false when the swapchain was out
/// of date — the caller recreates (with current window state) and may retry.
pub fn present(&mut self, window: &sdl3::video::Window, frame: Option<&CpuFrame>) -> Result<bool> {
if self.extent.width == 0 || self.extent.height == 0 {
return Ok(true); // minimized — nothing to do
}
// One frame in flight: the fence covers the command buffer AND the staging
// buffer, so waiting here makes both safe to reuse.
unsafe {
if self.submitted {
self.device.wait_for_fences(&[self.fence], true, u64::MAX)?;
self.submitted = false;
}
self.device.reset_fences(&[self.fence])?;
}
if let Some(f) = frame {
self.stage_frame(f)?;
}
let (index, _suboptimal) = match unsafe {
self.swap_d.acquire_next_image(
self.swapchain,
u64::MAX,
self.acquire_sem,
vk::Fence::null(),
)
} {
Ok(r) => r,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.recreate_swapchain(window)?;
return Ok(false);
}
Err(e) => return Err(e).context("vkAcquireNextImageKHR"),
};
let swap_image = self.images[index as usize];
unsafe {
self.device.begin_command_buffer(
self.cmd_buf,
&vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
)?;
// New frame: staging → video image (stride carried by buffer_row_length).
if let (Some(f), Some(v), Some(s)) = (frame, &self.video, &self.staging) {
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
);
let region = vk::BufferImageCopy::default()
.buffer_row_length((f.stride / 4) as u32)
.image_subresource(subresource_layers())
.image_extent(vk::Extent3D {
width: v.width,
height: v.height,
depth: 1,
});
self.device.cmd_copy_buffer_to_image(
self.cmd_buf,
s.buffer,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[region],
);
barrier(
&self.device,
self.cmd_buf,
v.image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
);
}
// Swapchain image: discard old content, clear to black (the letterbox bars),
// blit the video in, hand to present.
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::UNDEFINED,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
);
self.device.cmd_clear_color_image(
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&vk::ClearColorValue {
float32: [0.0, 0.0, 0.0, 1.0],
},
&[subresource_range()],
);
if let Some(v) = &self.video {
let (dst0, dst1) = letterbox(self.extent, v.width, v.height);
let blit = vk::ImageBlit::default()
.src_subresource(subresource_layers())
.src_offsets([
vk::Offset3D { x: 0, y: 0, z: 0 },
vk::Offset3D {
x: v.width as i32,
y: v.height as i32,
z: 1,
},
])
.dst_subresource(subresource_layers())
.dst_offsets([dst0, dst1]);
self.device.cmd_blit_image(
self.cmd_buf,
v.image,
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
&[blit],
vk::Filter::LINEAR,
);
}
barrier(
&self.device,
self.cmd_buf,
swap_image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
vk::ImageLayout::PRESENT_SRC_KHR,
);
self.device.end_command_buffer(self.cmd_buf)?;
let render_sem = self.render_sems[index as usize];
let wait_sems = [self.acquire_sem];
let wait_stages = [vk::PipelineStageFlags::TRANSFER];
let cmd_bufs = [self.cmd_buf];
let signal_sems = [render_sem];
self.device.queue_submit(
self.queue,
&[vk::SubmitInfo::default()
.wait_semaphores(&wait_sems)
.wait_dst_stage_mask(&wait_stages)
.command_buffers(&cmd_bufs)
.signal_semaphores(&signal_sems)],
self.fence,
)?;
self.submitted = true;
let swapchains = [self.swapchain];
let indices = [index];
let present_sems = [render_sem];
match self.swap_d.queue_present(
self.queue,
&vk::PresentInfoKHR::default()
.wait_semaphores(&present_sems)
.swapchains(&swapchains)
.image_indices(&indices),
) {
Ok(_) => Ok(true),
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => {
self.recreate_swapchain(window)?;
Ok(false)
}
Err(e) => Err(e).context("vkQueuePresentKHR"),
}
}
}
/// Copy the frame's RGBA into the staging buffer and (re)build the video image on a
/// stream-size change. Rows keep their stride — `buffer_row_length` unpacks it.
fn stage_frame(&mut self, f: &CpuFrame) -> Result<()> {
anyhow::ensure!(
f.stride % 4 == 0 && f.stride >= f.width as usize * 4,
"unexpected RGBA stride {} for width {}",
f.stride,
f.width
);
if self
.video
.as_ref()
.is_none_or(|v| v.width != f.width || v.height != f.height)
{
self.rebuild_video_image(f.width, f.height)?;
tracing::info!(width = f.width, height = f.height, "video image (re)built");
}
let needed = f.stride * f.height as usize;
if self.staging.as_ref().is_none_or(|s| s.capacity < needed) {
self.rebuild_staging(needed)?;
}
let s = self.staging.as_ref().unwrap();
let n = f.rgba.len().min(needed);
unsafe { std::ptr::copy_nonoverlapping(f.rgba.as_ptr(), s.ptr, n) };
Ok(())
}
fn rebuild_video_image(&mut self, width: u32, height: u32) -> Result<()> {
unsafe { self.device.device_wait_idle() }.ok();
self.submitted = false;
if let Some(v) = self.video.take() {
unsafe {
self.device.destroy_image(v.image, None);
self.device.free_memory(v.memory, None);
}
}
let image = unsafe {
self.device.create_image(
&vk::ImageCreateInfo::default()
.image_type(vk::ImageType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
.extent(vk::Extent3D {
width,
height,
depth: 1,
})
.mip_levels(1)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
.usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::TRANSFER_SRC)
.initial_layout(vk::ImageLayout::UNDEFINED),
None,
)
}?;
let reqs = unsafe { self.device.get_image_memory_requirements(image) };
let memory = self.allocate(reqs, vk::MemoryPropertyFlags::DEVICE_LOCAL)?;
unsafe { self.device.bind_image_memory(image, memory, 0) }?;
self.video = Some(VideoImage {
image,
memory,
width,
height,
});
Ok(())
}
fn rebuild_staging(&mut self, capacity: usize) -> Result<()> {
unsafe { self.device.device_wait_idle() }.ok();
self.submitted = false;
if let Some(s) = self.staging.take() {
unsafe {
self.device.unmap_memory(s.memory);
self.device.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, None);
}
}
let buffer = unsafe {
self.device.create_buffer(
&vk::BufferCreateInfo::default()
.size(capacity as u64)
.usage(vk::BufferUsageFlags::TRANSFER_SRC)
.sharing_mode(vk::SharingMode::EXCLUSIVE),
None,
)
}?;
let reqs = unsafe { self.device.get_buffer_memory_requirements(buffer) };
let memory = self.allocate(
reqs,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
)?;
unsafe { self.device.bind_buffer_memory(buffer, memory, 0) }?;
let ptr = unsafe {
self.device
.map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty())
}? as *mut u8;
self.staging = Some(Staging {
buffer,
memory,
ptr,
capacity,
});
Ok(())
}
fn allocate(
&self,
reqs: vk::MemoryRequirements,
flags: vk::MemoryPropertyFlags,
) -> Result<vk::DeviceMemory> {
let type_index = (0..self.mem_props.memory_type_count)
.find(|&i| {
reqs.memory_type_bits & (1 << i) != 0
&& self.mem_props.memory_types[i as usize]
.property_flags
.contains(flags)
})
.with_context(|| format!("no memory type for {flags:?}"))?;
unsafe {
self.device.allocate_memory(
&vk::MemoryAllocateInfo::default()
.allocation_size(reqs.size)
.memory_type_index(type_index),
None,
)
}
.context("vkAllocateMemory")
}
}
impl Drop for Presenter {
fn drop(&mut self) {
unsafe {
self.device.device_wait_idle().ok();
if let Some(s) = self.staging.take() {
self.device.unmap_memory(s.memory);
self.device.destroy_buffer(s.buffer, None);
self.device.free_memory(s.memory, None);
}
if let Some(v) = self.video.take() {
self.device.destroy_image(v.image, None);
self.device.free_memory(v.memory, None);
}
for s in self.render_sems.drain(..) {
self.device.destroy_semaphore(s, None);
}
self.device.destroy_semaphore(self.acquire_sem, None);
self.device.destroy_fence(self.fence, None);
self.device.destroy_command_pool(self.cmd_pool, None);
if self.swapchain != vk::SwapchainKHR::null() {
self.swap_d.destroy_swapchain(self.swapchain, None);
}
self.device.destroy_device(None);
self.surface_i.destroy_surface(self.surface, None);
self.instance.destroy_instance(None);
}
// `entry` (the libvulkan handle) drops last, after every vk call is done.
let _ = &self.entry;
}
}
/// First physical device with a queue family that does graphics + present here;
/// `PUNKTFUNK_VK_DEVICE=<index>` overrides on multi-GPU boxes.
fn pick_device(
instance: &ash::Instance,
surface_i: &ash::khr::surface::Instance,
surface: vk::SurfaceKHR,
) -> Result<(vk::PhysicalDevice, u32)> {
let devices = unsafe { instance.enumerate_physical_devices() }?;
let forced: Option<usize> = std::env::var("PUNKTFUNK_VK_DEVICE")
.ok()
.and_then(|v| v.parse().ok());
let candidates: Vec<vk::PhysicalDevice> = match forced {
Some(i) => devices.get(i).copied().into_iter().collect(),
None => devices,
};
for pdev in candidates {
let families = unsafe { instance.get_physical_device_queue_family_properties(pdev) };
for (i, f) in families.iter().enumerate() {
let graphics = f.queue_flags.contains(vk::QueueFlags::GRAPHICS);
let present = unsafe {
surface_i.get_physical_device_surface_support(pdev, i as u32, surface)
}
.unwrap_or(false);
if graphics && present {
return Ok((pdev, i as u32));
}
}
}
bail!("no Vulkan device with a graphics+present queue family")
}
/// Prefer BGRA8 UNORM (the near-universal presentable format); RGBA8 second; else
/// whatever the surface offers first. UNORM (not SRGB) — the decoded RGBA is already
/// display-referred, the blit must not re-encode it.
fn pick_format(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
) -> Result<vk::SurfaceFormatKHR> {
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
if let Some(f) = formats.iter().find(|f| f.format == want) {
return Ok(*f);
}
}
formats
.first()
.copied()
.ok_or_else(|| anyhow!("surface offers no formats"))
}
/// FIFO unless overridden (`PUNKTFUNK_PRESENT_MODE=mailbox|immediate`) and available —
/// a streaming client defaults to tear-free.
fn pick_present_mode(
surface_i: &ash::khr::surface::Instance,
pdev: vk::PhysicalDevice,
surface: vk::SurfaceKHR,
) -> Result<vk::PresentModeKHR> {
let modes = unsafe { surface_i.get_physical_device_surface_present_modes(pdev, surface) }?;
let want = match std::env::var("PUNKTFUNK_PRESENT_MODE").ok().as_deref() {
Some("mailbox") => vk::PresentModeKHR::MAILBOX,
Some("immediate") => vk::PresentModeKHR::IMMEDIATE,
_ => vk::PresentModeKHR::FIFO,
};
Ok(if modes.contains(&want) {
want
} else {
vk::PresentModeKHR::FIFO // always available per spec
})
}
/// The Contain-fit letterbox: video (vw×vh) into the swapchain extent, centered.
fn letterbox(extent: vk::Extent2D, vw: u32, vh: u32) -> (vk::Offset3D, vk::Offset3D) {
let (ew, eh) = (f64::from(extent.width), f64::from(extent.height));
let scale = (ew / f64::from(vw.max(1))).min(eh / f64::from(vh.max(1)));
let dw = (f64::from(vw) * scale).round();
let dh = (f64::from(vh) * scale).round();
let ox = ((ew - dw) / 2.0).floor() as i32;
let oy = ((eh - dh) / 2.0).floor() as i32;
(
vk::Offset3D { x: ox, y: oy, z: 0 },
vk::Offset3D {
x: (ox + dw as i32).min(extent.width as i32),
y: (oy + dh as i32).min(extent.height as i32),
z: 1,
},
)
}
fn subresource_layers() -> vk::ImageSubresourceLayers {
vk::ImageSubresourceLayers::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.layer_count(1)
}
fn subresource_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.level_count(1)
.layer_count(1)
}
/// A full-subresource layout transition with the conservative ALL_COMMANDS/TRANSFER
/// scopes this transfer-only pipeline needs (per-frame granularity, not per-stage).
fn barrier(
device: &ash::Device,
cmd: vk::CommandBuffer,
image: vk::Image,
from: vk::ImageLayout,
to: vk::ImageLayout,
) {
let b = vk::ImageMemoryBarrier::default()
.src_access_mask(vk::AccessFlags::MEMORY_WRITE)
.dst_access_mask(vk::AccessFlags::MEMORY_READ | vk::AccessFlags::MEMORY_WRITE)
.old_layout(from)
.new_layout(to)
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
.image(image)
.subresource_range(subresource_range());
unsafe {
device.cmd_pipeline_barrier(
cmd,
vk::PipelineStageFlags::ALL_COMMANDS,
vk::PipelineStageFlags::ALL_COMMANDS,
vk::DependencyFlags::empty(),
&[],
&[],
&[b],
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn letterbox_pillarboxes_a_wide_window() {
// 16:10 video in a 21:9-ish window: full height, centered horizontally.
let (a, b) = letterbox(
vk::Extent2D {
width: 3440,
height: 1440,
},
1280,
800,
);
assert_eq!((a.y, b.y), (0, 1440));
assert_eq!(b.x - a.x, 2304); // 1280 * (1440/800)
assert_eq!(a.x, (3440 - 2304) / 2);
}
#[test]
fn letterbox_matches_exact_fit() {
let (a, b) = letterbox(
vk::Extent2D {
width: 1280,
height: 800,
},
1280,
800,
);
assert_eq!((a.x, a.y), (0, 0));
assert_eq!((b.x, b.y), (1280, 800));
}
}