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
@@ -30,7 +30,13 @@ suspend fun connectToHost(
): Long { ): Long {
// Advertise HDR only when the user enabled it AND this device's display can present it (else the // Advertise HDR only when the user enabled it AND this device's display can present it (else the
// host sends a proper SDR stream rather than PQ the panel would mis-tone-map). // host sends a proper SDR stream rather than PQ the panel would mis-tone-map).
val (w, h, hz) = settings.effectiveMode(context) val (baseW, baseH, hz) = settings.effectiveMode(context)
// Render scale: ask the host for `chosen mode × scale` (even + codec-clamped) — > 1 supersamples
// (the compositor downscales the larger decoded frame to the SurfaceView), < 1 renders under
// native. 1.0 leaves the resolved mode untouched.
val (w, h) = RenderScale.apply(
baseW, baseH, settings.renderScale, RenderScale.maxDimension(settings.codec)
)
val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context) val hdrEnabled = settings.hdrEnabled && displaySupportsHdr(context)
// "Automatic" resolves to a concrete pad type from the connected controller's VID/PID. // "Automatic" resolves to a concrete pad type from the connected controller's VID/PID.
val gamepadPref = Gamepad.resolvePref(settings.gamepad) val gamepadPref = Gamepad.resolvePref(settings.gamepad)
@@ -16,6 +16,14 @@ data class Settings(
val height: Int = 0, val height: Int = 0,
val hz: Int = 0, val hz: Int = 0,
val bitrateKbps: Int = 0, val bitrateKbps: Int = 0,
/**
* Render-resolution multiplier: the client asks the host to render/encode at `chosen mode ×
* renderScale` and the compositor downscales the larger decoded frame to the SurfaceView
* (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under native
* for a lighter host/link). `1.0` = Native. Applied at connect via [RenderScale.apply], clamped
* even + to the codec's max dimension. Mirrors the Apple/Linux clients' render scale.
*/
val renderScale: Double = 1.0,
/** /**
* Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that * Advertise HDR (10-bit BT.2020 PQ) to the host. Default on, but only *effective* on a panel that
* can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never * can actually present HDR10 (see [displaySupportsHdr]) — on an SDR display HDR is never
@@ -137,6 +145,7 @@ class SettingsStore(context: Context) {
height = prefs.getInt(K_H, 0), height = prefs.getInt(K_H, 0),
hz = prefs.getInt(K_HZ, 0), hz = prefs.getInt(K_HZ, 0),
bitrateKbps = prefs.getInt(K_BITRATE, 0), bitrateKbps = prefs.getInt(K_BITRATE, 0),
renderScale = prefs.getFloat(K_RENDER_SCALE, 1.0f).toDouble(),
hdrEnabled = prefs.getBoolean(K_HDR, true), hdrEnabled = prefs.getBoolean(K_HDR, true),
compositor = prefs.getInt(K_COMPOSITOR, 0), compositor = prefs.getInt(K_COMPOSITOR, 0),
gamepad = prefs.getInt(K_GAMEPAD, 0), gamepad = prefs.getInt(K_GAMEPAD, 0),
@@ -171,6 +180,7 @@ class SettingsStore(context: Context) {
.putInt(K_H, s.height) .putInt(K_H, s.height)
.putInt(K_HZ, s.hz) .putInt(K_HZ, s.hz)
.putInt(K_BITRATE, s.bitrateKbps) .putInt(K_BITRATE, s.bitrateKbps)
.putFloat(K_RENDER_SCALE, s.renderScale.toFloat())
.putBoolean(K_HDR, s.hdrEnabled) .putBoolean(K_HDR, s.hdrEnabled)
.putInt(K_COMPOSITOR, s.compositor) .putInt(K_COMPOSITOR, s.compositor)
.putInt(K_GAMEPAD, s.gamepad) .putInt(K_GAMEPAD, s.gamepad)
@@ -193,6 +203,7 @@ class SettingsStore(context: Context) {
const val K_H = "height" const val K_H = "height"
const val K_HZ = "hz" const val K_HZ = "hz"
const val K_BITRATE = "bitrate_kbps" const val K_BITRATE = "bitrate_kbps"
const val K_RENDER_SCALE = "render_scale"
const val K_HDR = "hdr_enabled" const val K_HDR = "hdr_enabled"
const val K_COMPOSITOR = "compositor" const val K_COMPOSITOR = "compositor"
const val K_GAMEPAD = "gamepad" const val K_GAMEPAD = "gamepad"
@@ -281,6 +292,54 @@ fun Settings.effectiveMode(context: Context): Triple<Int, Int, Int> {
return Triple(w, h, hz) return Triple(w, h, hz)
} }
/**
* Client-side render-scale geometry — the Kotlin twin of `punktfunk-core`'s `render_scale` module
* (and the Apple client's `RenderScale`). Multiply a base size, preserve aspect, even-floor (the
* host rejects odd sizes), and clamp uniformly to the codec's per-axis ceiling so a connect can't
* ask for a size the encoder rejects. `1.0` = Native. Pure + covered by [RenderScaleTest].
*/
object RenderScale {
val PRESETS = listOf(0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0)
/** H.264 tops out at 4096 px/axis; HEVC/AV1/auto at 8192 — the host's `codec.rs` walls. */
fun maxDimension(codec: String): Int = if (codec == "h264") 4096 else 8192
/** Clamp a raw multiplier into [0.5, 4.0]; a missing / non-positive / NaN value → 1.0. */
fun sanitize(raw: Double): Double = if (raw > 0.0) raw.coerceIn(0.5, 4.0) else 1.0
/** "Native (1×)" / "1.5×" / "2× · supersample" — the picker label. */
fun label(scale: Double): String = when {
scale == 1.0 -> "Native (1×)"
scale > 1.0 -> "${trim(scale)}× · supersample"
else -> "${trim(scale)}×"
}
private fun trim(s: Double): String =
if (s == s.toLong().toDouble()) s.toLong().toString() else s.toString()
/** Apply [scale] to a base size → a host-valid even, aspect-preserved, codec-clamped (w, h). */
fun apply(baseW: Int, baseH: Int, scale: Double, maxDim: Int): Pair<Int, Int> {
val s = sanitize(scale)
var w = maxOf(baseW, 1) * s
var h = maxOf(baseH, 1) * s
val cap = maxDim.toDouble()
val over = maxOf(w / cap, h / cap)
if (over > 1.0) {
w /= over
h /= over
}
return Pair(evenFloor(w, 320), evenFloor(h, 200))
}
private fun evenFloor(value: Double, minimum: Int): Int {
val v = maxOf(kotlin.math.floor(value).toInt(), minimum).coerceAtLeast(0)
return v / 2 * 2
}
}
/** (scale, label) for the render-scale picker. `1.0` = Native. */
val RENDER_SCALE_OPTIONS = RenderScale.PRESETS.map { it to RenderScale.label(it) }
// ---- UI option tables (value, label). The first entry is always the "auto/native" default. ---- // ---- UI option tables (value, label). The first entry is always the "auto/native" default. ----
/** (width, height, label). `(0,0)` = native display. */ /** (width, height, label). `(0,0)` = native display. */
@@ -333,6 +333,15 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
update(s.copy(bitrateKbps = kbps)) update(s.copy(bitrateKbps = kbps))
} }
SettingDropdown(
label = "Render scale",
options = RENDER_SCALE_OPTIONS,
// Snap the stored value (a Float round-tripped to Double) to the nearest preset so the
// exact Double keys match. > 1 supersamples for sharpness (more bandwidth AND decode);
// < 1 renders under native for a lighter host — this device resamples to the display.
selected = RenderScale.PRESETS.minByOrNull { kotlin.math.abs(it - s.renderScale) } ?: 1.0,
) { scale -> update(s.copy(renderScale = scale)) }
// AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the // AV1 is only offered when the device has a real AV1 decoder (it's never advertised to the
// host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable // host otherwise, so preferring it would be a dead setting). A stored "av1" from a capable
// device stays visible so the selection is always representable. // device stays visible so the selection is always representable.
@@ -0,0 +1,73 @@
package io.unom.punktfunk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM test of the client-side render-scale geometry ([RenderScale]) — the Kotlin twin of
* `punktfunk-core`'s `render_scale` module. Run: `./gradlew :app:testDebugUnitTest`.
*/
class RenderScaleTest {
@Test
fun sanitizeClampsAndDefaults() {
assertEquals(1.0, RenderScale.sanitize(0.0), 0.0) // absent / zero → Native
assertEquals(1.0, RenderScale.sanitize(-2.0), 0.0)
assertEquals(1.0, RenderScale.sanitize(Double.NaN), 0.0)
assertEquals(0.5, RenderScale.sanitize(0.1), 0.0) // below the floor
assertEquals(4.0, RenderScale.sanitize(9.0), 0.0) // above the ceiling
assertEquals(1.5, RenderScale.sanitize(1.5), 0.0)
}
@Test
fun maxDimensionIsCodecAware() {
assertEquals(4096, RenderScale.maxDimension("h264"))
assertEquals(8192, RenderScale.maxDimension("hevc"))
assertEquals(8192, RenderScale.maxDimension("av1"))
assertEquals(8192, RenderScale.maxDimension("auto"))
}
@Test
fun nativeIsIdentity() {
assertEquals(1920 to 1080, RenderScale.apply(1920, 1080, 1.0, 8192))
}
@Test
fun supersampleDoubles() {
assertEquals(3840 to 2160, RenderScale.apply(1920, 1080, 2.0, 8192))
}
@Test
fun underRenderHalves() {
assertEquals(960 to 540, RenderScale.apply(1920, 1080, 0.5, 8192))
}
@Test
fun resultsAreEven() {
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
val (w, h) = RenderScale.apply(1366, 768, 1.5, 8192)
assertEquals(0, w % 2)
assertEquals(0, h % 2)
assertEquals(2048 to 1152, w to h)
}
@Test
fun overCeilingClampsUniformly() {
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
val (w, h) = RenderScale.apply(3840, 2160, 4.0, 8192)
assertTrue(w <= 8192 && h <= 8192)
assertEquals(8192 to 4608, w to h)
}
@Test
fun h264CeilingIsTighter() {
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
assertEquals(4096 to 2304, RenderScale.apply(1920, 1080, 4.0, 4096))
}
@Test
fun minimumFloorHonoured() {
val (w, h) = RenderScale.apply(400, 300, 0.5, 8192)
assertTrue(w >= 320 && h >= 200)
}
}
+2 -2
View File
@@ -896,8 +896,8 @@ class Plugin:
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
# The client's own defaults (native display, host-default bitrate, auto pad). # The client's own defaults (native display, host-default bitrate, auto pad).
return { return {
"width": 0, "height": 0, "refresh_hz": 0, "bitrate_kbps": 0, "width": 0, "height": 0, "refresh_hz": 0, "render_scale": 1.0,
"codec": "auto", "gamepad": "auto", "compositor": "auto", "bitrate_kbps": 0, "codec": "auto", "gamepad": "auto", "compositor": "auto",
"inhibit_shortcuts": True, "mic_enabled": False, "inhibit_shortcuts": True, "mic_enabled": False,
} }
+1
View File
@@ -99,6 +99,7 @@ export interface StreamSettings {
width: number; // 0 = native width: number; // 0 = native
height: number; // 0 = native height: number; // 0 = native
refresh_hz: number; // 0 = native refresh_hz: number; // 0 = native
render_scale?: number; // render-resolution multiplier; 1.0 = native (absent in pre-scale files)
bitrate_kbps: number; // 0 = host default bitrate_kbps: number; // 0 = host default
codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files) codec?: string; // "auto" | "hevc" | "h264" | "av1" — soft preference (absent in pre-codec files)
gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck" gamepad: string; // "auto" | "xbox360" | "xboxone" | "dualsense" | "dualshock4" | "steamdeck"
+22
View File
@@ -25,6 +25,10 @@ const RESOLUTIONS: [number, number, string][] = [
[2560, 1440, "2560 × 1440"], [2560, 1440, "2560 × 1440"],
]; ];
const REFRESH = [0, 30, 60, 90, 120]; const REFRESH = [0, 30, 60, 90, 120];
// Render-resolution multipliers (mirrors punktfunk_core::render_scale::PRESETS). 1.0 = native.
const RENDER_SCALES = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
const renderScaleLabel = (x: number): string =>
x === 1 ? "Native (1×)" : x > 1 ? `${x}× · supersample` : `${x}×`;
const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"]; const GAMEPADS = ["auto", "xbox360", "xboxone", "dualsense", "dualshock4", "steamdeck"];
const GAMEPAD_LABELS: Record<string, string> = { const GAMEPAD_LABELS: Record<string, string> = {
auto: "Automatic", auto: "Automatic",
@@ -106,6 +110,24 @@ export const SettingsSection: FC = () => {
</div> </div>
</RowActions> </RowActions>
</Field> </Field>
<Field
label="Render scale"
description="Supersample for sharpness (> 1×, more bandwidth) or render below native (< 1×) — the Deck resamples to its screen"
childrenContainerWidth="max"
>
<RowActions>
<div style={selectShell}>
<Dropdown
rgOptions={RENDER_SCALES.map((x) => ({ data: x, label: renderScaleLabel(x) }))}
// Snap the stored value to the nearest preset so the dropdown always shows a match.
selectedOption={RENDER_SCALES.reduce((best, x) =>
Math.abs(x - (s.render_scale ?? 1)) < Math.abs(best - (s.render_scale ?? 1)) ? x : best,
)}
onChange={(o) => patch({ render_scale: o.data as number })}
/>
</div>
</RowActions>
</Field>
<SliderField <SliderField
label="Bitrate" label="Bitrate"
description="Mbit/s · 0 = host default" description="Mbit/s · 0 = host default"
+34
View File
@@ -17,6 +17,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
]; ];
/// `0` = the monitor's native refresh, resolved at connect. /// `0` = the monitor's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
/// `1.0` = Native. Applied at connect and each match-window resize.
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
fn render_scale_label(scale: f64) -> String {
if scale == 1.0 {
"Native".to_string()
} else if scale > 1.0 {
format!("{scale}× (supersample)")
} else {
format!("{scale}×")
}
}
const GAMEPADS: &[&str] = &[ const GAMEPADS: &[&str] = &[
"auto", "auto",
"xbox360", "xbox360",
@@ -304,6 +318,18 @@ pub fn show(
"", "",
&hz_names.iter().map(String::as_str).collect::<Vec<_>>(), &hz_names.iter().map(String::as_str).collect::<Vec<_>>(),
); );
let scale_names: Vec<String> = RENDER_SCALES
.iter()
.map(|&s| render_scale_label(s))
.collect();
let scale_row = ChoiceRow::new(
&dialog,
inline,
"Render scale",
"Supersample for sharpness (> 1×, more bandwidth and decode) or render below native \
(< 1×) for a lighter host — this device resamples to the window",
&scale_names.iter().map(String::as_str).collect::<Vec<_>>(),
);
let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0); let bitrate_row = adw::SpinRow::with_range(0.0, 3000.0, 5.0);
bitrate_row.set_title("Bitrate"); bitrate_row.set_title("Bitrate");
bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high"); bitrate_row.set_subtitle("Mbit/s · 0 = host default · run a speed test before going high");
@@ -346,6 +372,7 @@ pub fn show(
.build(); .build();
stream.add(res_row.widget()); stream.add(res_row.widget());
stream.add(hz_row.widget()); stream.add(hz_row.widget());
stream.add(scale_row.widget());
stream.add(&bitrate_row); stream.add(&bitrate_row);
stream.add(compositor_row.widget()); stream.add(compositor_row.widget());
stream.add(decoder_row.widget()); stream.add(decoder_row.widget());
@@ -500,6 +527,11 @@ pub fn show(
res_row.set_selected(res_i as u32); res_row.set_selected(res_i as u32);
let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0); let hz_i = REFRESH.iter().position(|&r| r == s.refresh_hz).unwrap_or(0);
hz_row.set_selected(hz_i as u32); hz_row.set_selected(hz_i as u32);
let scale_i = RENDER_SCALES
.iter()
.position(|&x| (x - s.render_scale).abs() < 1e-6)
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
scale_row.set_selected(scale_i as u32);
bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0); bitrate_row.set_value(f64::from(s.bitrate_kbps) / 1000.0);
let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0); let pad_i = GAMEPADS.iter().position(|&g| g == s.gamepad).unwrap_or(0);
pad_row.set_selected(pad_i as u32); pad_row.set_selected(pad_i as u32);
@@ -545,6 +577,8 @@ pub fn show(
RESOLUTIONS[res_i - 1] RESOLUTIONS[res_i - 1]
}; };
s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)]; s.refresh_hz = REFRESH[(hz_row.selected() as usize).min(REFRESH.len() - 1)];
s.render_scale =
RENDER_SCALES[(scale_row.selected() as usize).min(RENDER_SCALES.len() - 1)];
s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32; s.bitrate_kbps = (bitrate_row.value() * 1000.0) as u32;
s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string(); s.gamepad = GAMEPADS[(pad_row.selected() as usize).min(GAMEPADS.len() - 1)].to_string();
s.touch_mode = s.touch_mode =
+2
View File
@@ -175,6 +175,8 @@ pub fn run(target: Option<&str>) -> u8 {
// Latched at console start (like the stats tier above): toggling Match window in // Latched at console start (like the stats tier above): toggling Match window in
// the console's settings screen applies from the next console launch. // the console's settings screen applies from the next console launch.
match_window: crate::session_main::match_window(&settings_at_start), match_window: crate::session_main::match_window(&settings_at_start),
render_scale: settings_at_start.render_scale,
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings_at_start.codec),
}; };
let result = let result =
+16
View File
@@ -127,6 +127,20 @@ mod session_main {
settings.refresh_hz settings.refresh_hz
}, },
}; };
// Render scale: multiply the resolved mode (even + codec-clamped) so the host renders
// larger/smaller and the presenter resamples to the window. 1.0 = Native. Applied after the
// Native/explicit resolution so it composes uniformly with both.
let (sw, sh) = punktfunk_core::render_scale::apply(
mode.width,
mode.height,
settings.render_scale,
punktfunk_core::render_scale::max_dimension(&settings.codec),
);
let mode = Mode {
width: sw,
height: sh,
..mode
};
SessionParams { SessionParams {
host: addr, host: addr,
port, port,
@@ -372,6 +386,8 @@ mod session_main {
overlay: None, overlay: None,
window_size: window_size(&settings), window_size: window_size(&settings),
match_window: match_window(&settings), match_window: match_window(&settings),
render_scale: settings.render_scale,
render_scale_max_dim: punktfunk_core::render_scale::max_dimension(&settings.codec),
}; };
let outcome = let outcome =
+33
View File
@@ -19,6 +19,20 @@ const RESOLUTIONS: &[(u32, u32)] = &[
]; ];
/// `0` = the display's native refresh, resolved at connect. /// `0` = the display's native refresh, resolved at connect.
const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240]; const REFRESH: &[u32] = &[0, 30, 60, 90, 120, 144, 165, 240];
/// Render-scale multipliers (persisted as f64; mirrors [`punktfunk_core::render_scale::PRESETS`]).
/// `1.0` = Native. Applied at connect and each match-window resize.
const RENDER_SCALES: &[f64] = &[0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// A compact label for a render-scale multiplier: "Native" / "1.5×" / "2× (supersample)".
fn render_scale_label(scale: f64) -> String {
if scale == 1.0 {
"Native".to_string()
} else if scale > 1.0 {
format!("{scale}\u{00D7} (supersample)")
} else {
format!("{scale}\u{00D7}")
}
}
/// Decode backend presets: `(stored value, display label)`. /// Decode backend presets: `(stored value, display label)`.
// A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows // A stored legacy "hardware" (the D3D11VA era) matches no preset, so the combo shows
// Automatic — which is exactly how the session's decoder chain reads that value. // Automatic — which is exactly how the session's decoder chain reads that value.
@@ -193,6 +207,24 @@ pub(crate) fn settings_page(
s.refresh_hz = REFRESH[i]; s.refresh_hz = REFRESH[i];
}) })
.tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect."); .tooltip("\u{201C}Native\u{201D} resolves to this display's refresh rate at connect.");
let (scale_names, scale_i) = {
let names: Vec<String> = RENDER_SCALES
.iter()
.map(|&x| render_scale_label(x))
.collect();
let i = RENDER_SCALES
.iter()
.position(|&x| (x - s.render_scale).abs() < 1e-6)
.unwrap_or_else(|| RENDER_SCALES.iter().position(|&x| x == 1.0).unwrap());
(names, i)
};
let scale_combo = setting_combo(ctx, "Render scale", scale_names, scale_i, |s, i| {
s.render_scale = RENDER_SCALES[i];
})
.tooltip(
"Supersample for sharpness (above 1\u{00D7}, more bandwidth and decode) or render below \
native (below 1\u{00D7}) for a lighter host \u{2014} this device resamples to the window.",
);
let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor); let (comp_names, comp_i) = presets(COMPOSITORS, |v| *v == s.compositor);
let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| { let comp_combo = setting_combo(ctx, "Host compositor", comp_names, comp_i, |s, i| {
s.compositor = COMPOSITORS[i].0.to_string(); s.compositor = COMPOSITORS[i].0.to_string();
@@ -441,6 +473,7 @@ pub(crate) fn settings_page(
settings_card(vec![ settings_card(vec![
res_combo.into(), res_combo.into(),
hz_combo.into(), hz_combo.into(),
scale_combo.into(),
fullscreen_toggle.into(), fullscreen_toggle.into(),
comp_combo.into(), comp_combo.into(),
]), ]),
+9
View File
@@ -461,6 +461,14 @@ pub struct Settings {
pub refresh_hz: u32, pub refresh_hz: u32,
/// Requested encoder bitrate (kbps); 0 = host default. /// Requested encoder bitrate (kbps); 0 = host default.
pub bitrate_kbps: u32, pub bitrate_kbps: u32,
/// Render-resolution multiplier: the client asks the host to render/encode at
/// `resolved mode × render_scale` and the presenter downscales the larger decoded frame to the
/// window (`> 1` supersamples for sharpness, at more bandwidth AND decode; `< 1` renders under
/// native for a lighter host/link). `1.0` = Native (the prior behaviour). Applied at connect
/// (and each match-window resize) via [`punktfunk_core::render_scale`], clamped even + to the
/// codec's max dimension. Missing in a pre-existing store → the `Default` (1.0) via the
/// container `#[serde(default)]`.
pub render_scale: f64,
pub gamepad: String, pub gamepad: String,
/// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller /// Stable identity (`vid:pid:name`, see `PadInfo::key`) of the physical controller
/// forwarded as pad 0; empty = automatic (most recently connected). Applied to the /// forwarded as pad 0; empty = automatic (most recently connected). Applied to the
@@ -590,6 +598,7 @@ impl Default for Settings {
height: 0, height: 0,
refresh_hz: 0, refresh_hz: 0,
bitrate_kbps: 0, bitrate_kbps: 0,
render_scale: 1.0,
gamepad: "auto".into(), gamepad: "auto".into(),
forward_pad: String::new(), forward_pad: String::new(),
compositor: "auto".into(), compositor: "auto".into(),
+48 -10
View File
@@ -69,6 +69,13 @@ pub struct SessionOpts {
/// persists it for the next launch. `None` = never auto-resize (Auto-native / /// persists it for the next launch. `None` = never auto-resize (Auto-native /
/// Explicit keep today's behavior). /// Explicit keep today's behavior).
pub match_window: Option<Box<dyn FnMut(u32, u32)>>, 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 { pub enum Outcome {
@@ -411,7 +418,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
presenter.vulkan_decode(), presenter.vulkan_decode(),
); );
if opts.match_window.is_some() { 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( Some(StreamState::new(
params, params,
@@ -749,7 +761,12 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
ActionOutcome::Handled => {} ActionOutcome::Handled => {}
ActionOutcome::Start(mut params) => { ActionOutcome::Start(mut params) => {
if opts.match_window.is_some() { 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( stream = Some(StreamState::new(
*params, *params,
@@ -896,7 +913,13 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
// --- Match-window (D2): debounced mode-follow ---- // --- Match-window (D2): debounced mode-follow ----
if let Some(persist) = opts.match_window.as_mut() { if let Some(persist) = opts.match_window.as_mut() {
if let Some(st) = stream.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 // 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 /// 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 /// sane minimum — keeping the resolved refresh. Under `--fullscreen` the window IS the
/// display, so this degenerates to the display's native mode. /// 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(); let (pw, ph) = window.size_in_pixels();
params.mode.width = (pw & !1).max(320); // × the render scale (even + codec-clamped), so match-window supersamples/undersamples exactly
params.mode.height = (ph & !1).max(200); // 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!( tracing::info!(
w = params.mode.width, w,
h = params.mode.height, h,
"match-window: requesting the window's pixel size" "match-window: requesting the scaled window pixel size"
); );
} }
@@ -1250,18 +1281,25 @@ fn resize_tick(
st: &mut StreamState, st: &mut StreamState,
window: &mut sdl3::video::Window, window: &mut sdl3::video::Window,
persist: &mut dyn FnMut(u32, u32), persist: &mut dyn FnMut(u32, u32),
render_scale: f64,
max_dim: u32,
) { ) {
let Some(c) = &st.connector else { let Some(c) = &st.connector else {
return; // not connected yet — the pending stamp survives until we are return; // not connected yet — the pending stamp survives until we are
}; };
let m = c.mode(); 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( match resize_decision(
Instant::now(), Instant::now(),
&mut st.resize_pending, &mut st.resize_pending,
st.resize_sent_at, st.resize_sent_at,
st.resize_requested, st.resize_requested,
(m.width, m.height), (m.width, m.height),
window.size_in_pixels(), pixel_size,
) { ) {
ResizeAction::Wait => {} ResizeAction::Wait => {}
ResizeAction::Settled(target) => { ResizeAction::Settled(target) => {
+1
View File
@@ -45,6 +45,7 @@ pub mod packet;
pub mod quic; pub mod quic;
pub mod reanchor; pub mod reanchor;
pub mod reject; pub mod reject;
pub mod render_scale;
pub mod session; pub mod session;
pub mod stats; pub mod stats;
#[cfg(feature = "tls")] #[cfg(feature = "tls")]
+134
View File
@@ -0,0 +1,134 @@
//! Render-resolution scaling — the pure geometry behind the clients' render-scale setting.
//!
//! Client-side supersampling: a client asks the host to render/encode at `chosen resolution ×
//! scale` (a normal larger/smaller [`Mode`](crate::Mode) — the host does no scaling), then its
//! presenter downscales the larger decoded frame to the display (`> 1` = supersampling for
//! sharpness) or upscales a smaller one (`< 1` = a lighter host GPU / thinner link). This module is
//! where the multiplier becomes a host-valid mode dimension: multiply, preserve the aspect ratio,
//! floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the codec's
//! per-axis ceiling so a connect can't request a size the encoder will reject.
//!
//! It is the Rust twin of the Apple client's `PunktfunkShared/RenderScale.swift` and is shared by
//! the native (Linux/`clients/session`, Windows) clients and the Android JNI path — all of which
//! reach `punktfunk-core`. Kept dependency-free + side-effect-free so it is unit-tested here.
/// Minimum supported multiplier (renders under native, upscaled on present).
pub const MIN_SCALE: f64 = 0.5;
/// Maximum supported multiplier (supersamples, clamped to the codec ceiling per axis).
pub const MAX_SCALE: f64 = 4.0;
/// The multipliers a picker offers. `1.0` (Native) is the default; the rest are the round stops
/// users reason about. Shared so every client's list stays identical.
pub const PRESETS: [f64; 9] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0];
/// The encoder/host per-axis ceiling for a codec preference string (the clients' `codec` setting:
/// `"auto"`/`"hevc"`/`"h264"`/`"av1"`/`"pyrowave"`). H.264 tops out at 4096 px/axis; everything else
/// (incl. "auto", which negotiates HEVC/AV1 in practice) at 8192 — the same walls the host enforces
/// in `pf-encode`'s `codec.rs::max_dimension`.
pub fn max_dimension(codec: &str) -> u32 {
if codec == "h264" {
4096
} else {
8192
}
}
/// Clamp a raw stored multiplier into `[MIN_SCALE, MAX_SCALE]`, treating a missing / non-positive /
/// NaN value as `1.0` (Native).
pub fn sanitize(raw: f64) -> f64 {
if raw.is_nan() || raw <= 0.0 {
return 1.0;
}
raw.clamp(MIN_SCALE, MAX_SCALE)
}
/// Apply `scale` to a base pixel size: preserve aspect, even-floor each axis, and clamp uniformly so
/// neither axis exceeds `max_dim` (the larger axis lands on the cap, the ratio is kept). Also floors
/// each axis at 320×200 (the host never accepts smaller). The result is a directly host-valid
/// [`Mode`](crate::Mode) width/height.
pub fn apply(base_w: u32, base_h: u32, scale: f64, max_dim: u32) -> (u32, u32) {
let scale = sanitize(scale);
let mut w = base_w.max(1) as f64 * scale;
let mut h = base_h.max(1) as f64 * scale;
// Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact.
let cap = max_dim as f64;
let over = (w / cap).max(h / cap);
if over > 1.0 {
w /= over;
h /= over;
}
(even_floor(w, 320), even_floor(h, 200))
}
/// Floor a dimension to an even integer, not below `minimum` (also even-floored).
fn even_floor(value: f64, minimum: u32) -> u32 {
let v = (value.floor() as i64).max(minimum as i64).max(0) as u32;
v / 2 * 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_clamps_and_defaults() {
assert_eq!(sanitize(0.0), 1.0);
assert_eq!(sanitize(-3.0), 1.0);
assert_eq!(sanitize(f64::NAN), 1.0);
assert_eq!(sanitize(0.1), 0.5);
assert_eq!(sanitize(9.0), 4.0);
assert_eq!(sanitize(1.5), 1.5);
}
#[test]
fn max_dimension_is_codec_aware() {
assert_eq!(max_dimension("h264"), 4096);
assert_eq!(max_dimension("hevc"), 8192);
assert_eq!(max_dimension("av1"), 8192);
assert_eq!(max_dimension("auto"), 8192);
}
#[test]
fn native_is_identity() {
assert_eq!(apply(1920, 1080, 1.0, 8192), (1920, 1080));
}
#[test]
fn supersample_doubles() {
assert_eq!(apply(1920, 1080, 2.0, 8192), (3840, 2160));
}
#[test]
fn under_render_halves() {
assert_eq!(apply(1920, 1080, 0.5, 8192), (960, 540));
}
#[test]
fn results_are_even() {
// 1366×768 × 1.5 = 2049×1152 → even-floored to 2048×1152.
let (w, h) = apply(1366, 768, 1.5, 8192);
assert_eq!(w % 2, 0);
assert_eq!(h % 2, 0);
assert_eq!((w, h), (2048, 1152));
}
#[test]
fn over_ceiling_clamps_uniformly() {
// 4K × 4 = 15360×8640; both exceed 8192 → width lands on cap, 16:9 kept (8192×4608).
let (w, h) = apply(3840, 2160, 4.0, 8192);
assert!(w <= 8192 && h <= 8192);
assert_eq!((w, h), (8192, 4608));
}
#[test]
fn h264_ceiling_is_tighter() {
// 1080p × 4 = 7680×4320; under H.264's 4096 wall → 4096×2304.
assert_eq!(apply(1920, 1080, 4.0, 4096), (4096, 2304));
}
#[test]
fn minimum_floor_honoured() {
let (w, h) = apply(400, 300, 0.5, 8192);
assert!(w >= 320 && h >= 200);
}
}