//! The dedicated video render thread: decoded frames flow session pump → bounded channel → here → //! `Presenter::present`. Presenting off the XAML thread means UI jank (layout, input, dialogs) //! never stalls video, and a filled present queue never blocks the UI thread — the two failure //! modes of the old present-from-`on_rendering` design. //! //! Pacing: block on the channel (the host paces the stream), then on the swapchain's //! frame-latency waitable (≤1 queued present — see `present.rs`), then drain to the NEWEST frame //! so a stream faster than the display drops backlog before any GPU work. The UI thread only //! writes panel size/DPI into [`RenderShared`] atomics; the loop applies them before the next //! draw (and redraws the held frame after a resize — fresh back buffers are blank). use crate::present::Presenter; use crate::session::FrameRx; use crossbeam_channel::RecvTimeoutError; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; /// The last 1-second render window, published for the HUD (one render thread at a time): /// presents/s, frames skipped by the newest-wins drain, and the capture→presented p50 in µs. /// Zeroed when a render thread starts so a new session never shows the previous one's numbers. static PRESENT_FPS: AtomicU32 = AtomicU32::new(0); static PRESENT_SKIPPED: AtomicU32 = AtomicU32::new(0); static PRESENT_P50_US: AtomicU64 = AtomicU64::new(0); /// `(presents/s, skipped/s, capture→presented p50 ms)` of the last render window — the HUD's /// display-side line. pub fn present_stats() -> (u32, u32, f32) { ( PRESENT_FPS.load(Ordering::Relaxed), PRESENT_SKIPPED.load(Ordering::Relaxed), PRESENT_P50_US.load(Ordering::Relaxed) as f32 / 1000.0, ) } /// UI-thread → render-thread state. Size is packed into ONE atomic (w<<32|h) so a resize never /// tears into a (new-width, old-height) pair. pub struct RenderShared { size_px: AtomicU64, dpi: AtomicU32, stop: AtomicBool, } impl RenderShared { pub fn new(width: u32, height: u32, dpi: u32) -> Arc { Arc::new(RenderShared { size_px: AtomicU64::new(pack(width, height)), dpi: AtomicU32::new(dpi), stop: AtomicBool::new(false), }) } pub fn set_size(&self, width: u32, height: u32) { self.size_px.store(pack(width, height), Ordering::Relaxed); } pub fn set_dpi(&self, dpi: u32) { self.dpi.store(dpi, Ordering::Relaxed); } fn snapshot(&self) -> (u32, u32, u32) { let s = self.size_px.load(Ordering::Relaxed); ((s >> 32) as u32, s as u32, self.dpi.load(Ordering::Relaxed)) } } fn pack(w: u32, h: u32) -> u64 { ((w as u64) << 32) | h as u64 } /// Handle owned by the stream page; stops + joins the thread on unmount (and on drop, so a /// navigation away can't leak a presenting thread). pub struct RenderThread { shared: Arc, join: Option>, } impl RenderThread { pub fn shared(&self) -> &Arc { &self.shared } pub fn stop_and_join(&mut self) { self.shared.stop.store(true, Ordering::SeqCst); if let Some(j) = self.join.take() { let _ = j.join(); } } } impl Drop for RenderThread { fn drop(&mut self) { self.stop_and_join(); } } /// Moves the presenter (COM interfaces, `!Send` by default) onto the render thread. Sound here: /// the shared device + immediate context are multithread-protected (see `crate::gpu`), D3D/DXGI /// objects are apartment-agile, and after this one handoff the swapchain/RTV/context calls happen /// on exactly the render thread — the same single-owner discipline as `SharedDevice`. struct SendPresenter(Presenter); unsafe impl Send for SendPresenter {} /// Spawn the render thread. `frames` carries `(frame, capture pts_ns)`; `clock_offset_ns` maps our /// wall clock onto the host's so the logged present latency is end-to-end (same math as the pump). pub fn spawn( presenter: Presenter, frames: FrameRx, shared: Arc, clock_offset_ns: i64, ) -> RenderThread { let boxed = SendPresenter(presenter); let shared_w = shared.clone(); let join = std::thread::Builder::new() .name("pf-render".into()) .spawn(move || run(boxed, frames, shared_w, clock_offset_ns)) .expect("spawn render thread"); RenderThread { shared, join: Some(join), } } fn now_ns() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0) } /// The window DPI, polled ~1 Hz as belt-and-braces for a monitor move that changes DPI without a /// `SizeChanged` (same DIP size on both screens). `None` when the window isn't up (headless). fn poll_window_dpi() -> Option { use windows::Win32::UI::HiDpi::GetDpiForWindow; use windows::Win32::UI::WindowsAndMessaging::FindWindowW; unsafe { let hwnd = FindWindowW(None, windows::core::w!("Punktfunk")).ok()?; match GetDpiForWindow(hwnd) { 0 => None, d => Some(d), } } } fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc, clock_offset_ns: i64) { let mut p = presenter.0; let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter let mut presented = 0u32; let mut dropped = 0u32; let mut lat_us: Vec = Vec::with_capacity(256); let mut window_start = Instant::now(); let mut last_dpi_poll = Instant::now(); PRESENT_FPS.store(0, Ordering::Relaxed); PRESENT_SKIPPED.store(0, Ordering::Relaxed); PRESENT_P50_US.store(0, Ordering::Relaxed); loop { if shared.stop.load(Ordering::SeqCst) { break; } let first = match frames.recv_timeout(Duration::from_millis(50)) { Ok(f) => Some(f), Err(RecvTimeoutError::Timeout) => None, Err(RecvTimeoutError::Disconnected) => break, }; if last_dpi_poll.elapsed() >= Duration::from_secs(1) { last_dpi_poll = Instant::now(); if let Some(dpi) = poll_window_dpi() { shared.set_dpi(dpi); } } let snap = shared.snapshot(); let resized = snap != applied && snap.0 > 0 && snap.1 > 0; if resized { p.resize(snap.0, snap.1, snap.2); applied = snap; } if first.is_none() && !resized { continue; // nothing new to show — don't burn GPU re-presenting a static frame } // Throttle to the compositor: with ≤1 present outstanding this returns as DWM frees a // slot, and frames decoded meanwhile are drained below so the newest is what's drawn. if !p.wait_present_slot(1000) { tracing::debug!("frame-latency waitable timed out — presenting anyway"); } let mut newest = first; while let Ok(f) = frames.try_recv() { if newest.is_some() { dropped += 1; } newest = Some(f); } // The session pump is the sole 0xCE consumer and stashes the latest here (rare updates). if let Some(meta) = *crate::present::LATEST_HDR_META.lock().unwrap() { p.set_hdr_metadata(meta); } let pts_ns = newest.as_ref().map(|(_, pts)| *pts); p.present(newest.map(|(f, _)| f)); presented += 1; if let Some(pts) = pts_ns { // Capture→presented, host-clock corrected — the glass-side companion to the pump's // capture→decoded p50. let lat = (now_ns() as i128 + clock_offset_ns as i128 - pts as i128).max(0) as u64; if lat > 0 && lat < 10_000_000_000 { lat_us.push(lat / 1000); } } if window_start.elapsed() >= Duration::from_secs(1) { lat_us.sort_unstable(); let p50 = lat_us.get(lat_us.len() / 2).copied().unwrap_or(0); tracing::debug!(presented, dropped, present_p50_us = p50, "render window"); PRESENT_FPS.store(presented, Ordering::Relaxed); PRESENT_SKIPPED.store(dropped, Ordering::Relaxed); PRESENT_P50_US.store(p50, Ordering::Relaxed); window_start = Instant::now(); presented = 0; dropped = 0; lat_us.clear(); } } tracing::info!("render thread exiting"); }