feat(clients): render-scale setting on every client — shared punktfunk_core::render_scale

Client-side supersampling/downscaling: the client asks the host to render
and encode at chosen-resolution × scale (the host does no scaling) and the
presenter rescales the decoded frame to the display. >1 supersamples for
sharpness; <1 lightens the host GPU and the link. Default 1.0 = Native, the
prior behavior.

The geometry lives once in punktfunk_core::render_scale (multiply, preserve
aspect ratio, floor to even, clamp to the codec's per-axis ceiling — 4096
for H.264, 8192 otherwise), the Rust twin of the Apple client's
RenderScale.swift, consumed by the native session client, the presenter's
match-window path, the Windows/Linux settings UIs, Decky, and Android
(settings + host connect + unit test).

Implemented and platform-verified by the Apple-client-features session
(Linux+Android+Apple green there); the punktfunk-core wiring
(pub mod render_scale) is restored here after being lost in a working-tree
reconciliation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 17:14:57 +02:00
parent 600693914f
commit 871ebb31ce
15 changed files with 450 additions and 13 deletions
+48 -10
View File
@@ -69,6 +69,13 @@ pub struct SessionOpts {
/// persists it for the next launch. `None` = never auto-resize (Auto-native /
/// Explicit keep today's behavior).
pub match_window: Option<Box<dyn FnMut(u32, u32)>>,
/// Render-resolution multiplier applied to the window pixel size under Match-window (the
/// fixed-mode path scales in the binary's `session_params`). `> 1` supersamples (host renders
/// larger, the presenter downscales); `1.0` = the window's native pixels. See
/// [`punktfunk_core::render_scale`].
pub render_scale: f64,
/// The codec's per-axis ceiling for the render-scale clamp (4096 for H.264, else 8192).
pub render_scale_max_dim: u32,
}
pub enum Outcome {
@@ -411,7 +418,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
presenter.vulkan_decode(),
);
if opts.match_window.is_some() {
apply_match_window(&mut params, &window);
apply_match_window(
&mut params,
&window,
opts.render_scale,
opts.render_scale_max_dim,
);
}
Some(StreamState::new(
params,
@@ -749,7 +761,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
ActionOutcome::Handled => {}
ActionOutcome::Start(mut params) => {
if opts.match_window.is_some() {
apply_match_window(&mut params, &window);
apply_match_window(
&mut params,
&window,
opts.render_scale,
opts.render_scale_max_dim,
);
}
stream = Some(StreamState::new(
*params,
@@ -896,7 +913,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// --- Match-window (D2): debounced mode-follow ----
if let Some(persist) = opts.match_window.as_mut() {
if let Some(st) = stream.as_mut() {
resize_tick(st, &mut window, persist.as_mut());
resize_tick(
st,
&mut window,
persist.as_mut(),
opts.render_scale,
opts.render_scale_max_dim,
);
}
}
// Resize overlay timeout: a switch the host rejected/capped never delivers the exact
@@ -1212,14 +1235,22 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
/// size — even-floored (the host's `validate_dimensions` rejects odd) and clamped to a
/// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
/// display, so this degenerates to the display's native mode.
fn apply_match_window(params: &mut SessionParams, window: &sdl3::video::Window) {
fn apply_match_window(
params: &mut SessionParams,
window: &sdl3::video::Window,
render_scale: f64,
max_dim: u32,
) {
let (pw, ph) = window.size_in_pixels();
params.mode.width = (pw & !1).max(320);
params.mode.height = (ph & !1).max(200);
// × the render scale (even + codec-clamped), so match-window supersamples/undersamples exactly
// like the fixed-mode path; 1.0 leaves the window's native pixels (the prior behaviour).
let (w, h) = punktfunk_core::render_scale::apply(pw, ph, render_scale, max_dim);
params.mode.width = w;
params.mode.height = h;
tracing::info!(
w = params.mode.width,
h = params.mode.height,
"match-window: requesting the window's pixel size"
w,
h,
"match-window: requesting the scaled window pixel size"
);
}
@@ -1250,18 +1281,25 @@ fn resize_tick(
st: &mut StreamState,
window: &mut sdl3::video::Window,
persist: &mut dyn FnMut(u32, u32),
render_scale: f64,
max_dim: u32,
) {
let Some(c) = &st.connector else {
return; // not connected yet — the pending stamp survives until we are
};
let m = c.mode();
// × the render scale (even + codec-clamped) so a resize under Match-window targets the same
// supersampled space the live mode is in; 1.0 leaves the window's native pixels. resize_decision
// re-normalizes idempotently.
let (pw, ph) = window.size_in_pixels();
let pixel_size = punktfunk_core::render_scale::apply(pw, ph, render_scale, max_dim);
match resize_decision(
Instant::now(),
&mut st.resize_pending,
st.resize_sent_at,
st.resize_requested,
(m.width, m.height),
window.size_in_pixels(),
pixel_size,
) {
ResizeAction::Wait => {}
ResizeAction::Settled(target) => {