fix(client/android): present on the panel's real vsync grid, and pin the refresh rate best-effort
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / web (pull_request) Successful in 2m53s
ci / bun-nix (pull_request) Successful in 2m43s
ci / docs-site (pull_request) Successful in 3m3s
android / android (pull_request) Successful in 5m28s
ci / rust (pull_request) Successful in 12m13s
ci / rust-arm64 (pull_request) Successful in 1m38s
ci / web (pull_request) Successful in 2m53s
ci / bun-nix (pull_request) Successful in 2m43s
ci / docs-site (pull_request) Successful in 3m3s
android / android (pull_request) Successful in 5m28s
ci / rust (pull_request) Successful in 12m13s
Two frame-rate fixes on top of the ASurfaceControl backend, both found on-glass (NP3, 2800x1260@120). Present target: the presenter derived its desired-present-time grid from the panel period it LEARNED from latch spacings — but the target produces the latch, so once a couple of 60 Hz-spaced latches landed the learner flipped to 60 and then paced every frame onto the 60 Hz grid, locking the panel there. A plain ASAP present did not fix it either: applying two transactions close together let SurfaceFlinger coalesce the pair onto one vsync and idle the next (also 60). The grid now uses the mode-table SEED period (the honest panel maximum, fixed for the session) for its spacing and the last real latch only for phase, so every frame claims its own vsync. The latch-learned period is demoted to a HUD readout. Layer frame-rate vote is now re-asserted on every transaction (FIXED_SOURCE) rather than once, so a governor that decays a one-shot vote keeps seeing it. Refresh-rate pin: alongside preferredDisplayModeId, the stream window now sets preferredRefreshRate and clears frameRatePowerSavingsBalanced (API 34) — the documented levers to hold an LTPO panel at the mode's rate. Restored on stream exit. On-glass result: the picture reaches a clean 120 (panelMs 8.1, displays ~120, latch p50 ~4.5, e2e p50 ~16 ms) but does NOT hold it on this device — Nothing OS's LTPO governor keeps the app render-range floor at 0 in the DisplayModeDirector regardless of every app-side API tried (mode id, preferredRefreshRate, power-savings flag, and the per-layer Exact/FIXED_SOURCE vote), and decays "video" content to 60 after the touch-boost window. That is a pre-existing OEM limitation that constrained the old SurfaceView path too, not something the presenter can override; the hints stay as best-effort for compliant panels. The latency + no-dropped-frames wins stand regardless (e2e 30 -> ~18 ms, skipped 40-50/s -> 0). Verified: cargo ndk arm64 check + clippy + rustfmt clean; installed and streamed on the NP3.
This commit is contained in:
@@ -535,10 +535,32 @@ class MainActivity : ComponentActivity() {
|
||||
fun setStreamDisplayMode(hz: Int) {
|
||||
if (hz <= 0) {
|
||||
setConsoleHighRefreshRate(false)
|
||||
// Hand the LTPO governor back its power-saving freedom off-stream.
|
||||
window.attributes = window.attributes.apply {
|
||||
@Suppress("DEPRECATION")
|
||||
run { preferredRefreshRate = 0f }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
isFrameRatePowerSavingsBalanced = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val target = streamModeFor(hz) ?: return
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
|
||||
window.attributes = window.attributes.apply {
|
||||
preferredDisplayModeId = target.modeId
|
||||
// The pin that actually holds an LTPO panel. `preferredDisplayModeId` only sets the base
|
||||
// mode; the DisplayModeDirector still records the app's render range as [0, hz], and a
|
||||
// MIN of 0 lets the governor seamlessly downshift 120→60 for power (observed on the NP3:
|
||||
// starts 120, decays to 60 within seconds, SurfaceFlinger flickering between them). The
|
||||
// deprecated `preferredRefreshRate` records a FIXED app request — min == max == hz —
|
||||
// which is what pins the render floor at 120. `frameRatePowerSavingsBalanced=false`
|
||||
// (API 34) additionally tells the governor not to trade the rate away for power.
|
||||
@Suppress("DEPRECATION")
|
||||
run { preferredRefreshRate = target.refreshRate }
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
isFrameRatePowerSavingsBalanced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,8 +106,14 @@ pub(super) struct AscBackend {
|
||||
/// Images on SurfaceFlinger, oldest first, awaiting release.
|
||||
presented: VecDeque<Presented>,
|
||||
|
||||
// -- present clock, learned from real latch times --
|
||||
// -- present clock --
|
||||
/// The panel period learned from real latch spacings — a READOUT for the pf.present line only.
|
||||
/// It must NOT drive the present target: the target produces the latch, so learning the period
|
||||
/// from the latch and then targeting it locks the panel to whatever it first latched.
|
||||
panel: PanelGrid,
|
||||
/// The honest panel period from the mode table (`panel_hz`) — what the smooth grid snaps to.
|
||||
/// Fixed for the session; the mode table is authoritative for the panel's fastest refresh.
|
||||
panel_seed_ns: i64,
|
||||
last_latch_ns: i64,
|
||||
/// HDR `ADataSpace` for the transaction (`0` = SDR / leave default).
|
||||
dataspace: i32,
|
||||
@@ -210,6 +216,11 @@ impl AscBackend {
|
||||
fifo: VecDeque::new(),
|
||||
presented: VecDeque::new(),
|
||||
panel: PanelGrid::seeded(panel_hz),
|
||||
panel_seed_ns: if panel_hz > 0 {
|
||||
1_000_000_000 / panel_hz as i64
|
||||
} else {
|
||||
FALLBACK_PERIOD_NS
|
||||
},
|
||||
last_latch_ns: 0,
|
||||
dataspace,
|
||||
frame_rate: if source_hz > 0 { source_hz as f32 } else { 0.0 },
|
||||
@@ -279,12 +290,22 @@ impl AscBackend {
|
||||
None
|
||||
}
|
||||
|
||||
/// The next real panel vsync at or after `not_before` — predicted from the last measured latch
|
||||
/// and the learned period. `0` (ASAP) until the first latch establishes the phase.
|
||||
/// The desired present time for the frame being released, `CLOCK_MONOTONIC` (`0` = ASAP, only
|
||||
/// used to bootstrap the phase before the first latch is known).
|
||||
///
|
||||
/// Both modes snap `not_before` up to an explicit panel-grid point: without one, applying two
|
||||
/// transactions close together lets SurfaceFlinger coalesce the pair onto a single vsync and
|
||||
/// idle the next — the on-glass 60-on-a-120-panel result of a plain ASAP present. Giving each
|
||||
/// frame its own grid-spaced present time makes SF present them on consecutive vsyncs.
|
||||
///
|
||||
/// PERIOD is the mode-table seed (the honest panel maximum) — NEVER the latch-learned period,
|
||||
/// or a slow latch would ratchet the target down and hold the panel at the lower rate. PHASE is
|
||||
/// the last real latch. Latency passes `not_before = now + margin`; smooth additionally floors
|
||||
/// it at the source due time.
|
||||
fn next_present_target(&self, now_mono: i64, not_before: i64) -> i64 {
|
||||
let period = self.panel.period_ns();
|
||||
let period = self.panel_seed_ns;
|
||||
if self.last_latch_ns <= 0 || period <= 0 {
|
||||
return 0; // bootstrap: let SurfaceFlinger present at its next vsync
|
||||
return 0; // bootstrap: no phase yet — present ASAP to establish the first latch
|
||||
}
|
||||
let floor = not_before.max(now_mono);
|
||||
let ahead = floor - self.last_latch_ns;
|
||||
@@ -308,7 +329,7 @@ impl AscBackend {
|
||||
let frame = if self.fifo_capacity == 0 {
|
||||
self.candidate.take()
|
||||
} else {
|
||||
let reach = now_mono + LATCH_MARGIN_NS + self.panel.period_ns().max(FALLBACK_PERIOD_NS);
|
||||
let reach = now_mono + LATCH_MARGIN_NS + self.panel_seed_ns;
|
||||
match self.fifo.front() {
|
||||
Some(f) if f.due_ns.is_none_or(|due| due <= reach) => self.fifo.pop_front(),
|
||||
_ => return false,
|
||||
@@ -329,11 +350,9 @@ impl AscBackend {
|
||||
frame.fence.take(),
|
||||
target,
|
||||
self.dataspace,
|
||||
if self.next_seq == 0 {
|
||||
self.frame_rate
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
// Re-asserted every frame (idempotent): the LTPO governor decays a one-shot rate vote
|
||||
// and drops the panel to 60 — see `Layer::present`.
|
||||
self.frame_rate,
|
||||
seq,
|
||||
ev_tx,
|
||||
);
|
||||
|
||||
@@ -384,14 +384,20 @@ impl Layer {
|
||||
if !self.configured {
|
||||
(self.api.txn_set_visibility)(txn, sc, VISIBILITY_SHOW);
|
||||
(self.api.txn_set_z_order)(txn, sc, 0);
|
||||
if frame_rate > 0.0 {
|
||||
if let Some(f) = self.api.txn_set_frame_rate {
|
||||
// compatibility 1 = FIXED_SOURCE (fixed-rate video the app can't re-pace).
|
||||
f(txn, sc, frame_rate, 1);
|
||||
}
|
||||
}
|
||||
self.configured = true;
|
||||
}
|
||||
// Vote the layer's frame rate on EVERY transaction (idempotent), not just the first, so
|
||||
// an LTPO governor that decays a one-shot vote keeps seeing it. compatibility 1 =
|
||||
// FIXED_SOURCE — the conventional value for fixed-rate video the app can't re-pace.
|
||||
// (On a compliant panel this + the window's mode/refresh pin holds the rate; a
|
||||
// non-compliant OEM governor — e.g. Nothing OS, which keeps the app render-range floor
|
||||
// at 0 regardless — downshifts to 60 for "video" content anyway, which no app-side API
|
||||
// observed here overrides.)
|
||||
if frame_rate > 0.0 {
|
||||
if let Some(f) = self.api.txn_set_frame_rate {
|
||||
f(txn, sc, frame_rate, 1);
|
||||
}
|
||||
}
|
||||
(self.api.txn_set_present_time)(txn, desired_present_ns);
|
||||
// One-shot completion context, reclaimed inside the callback. The `Arc` clone keeps the
|
||||
// control alive for the callback even past the layer's own drop.
|
||||
|
||||
Reference in New Issue
Block a user