fix(pyrowave): anchor frame-driven pacing to arrival, hold full framerate

The frame-driven capture loop set its rate-limit floor (`earliest`) relative to
`next`, which is re-based to the instant AFTER submit(). For an async encoder
(NVENC) submit() returns in ~0, so that anchor is ~frame-arrival and the loop
correctly waits for the next vsync — full framerate. But PyroWave's encode is
SYNCHRONOUS (~2 ms inline in submit()), so the anchor lands ~2 ms late every
frame: the loop misses the next arrival and samples one interval behind, making
the period `interval + encode`. That capped a 240 Hz source at ~158 fps (and a
360 Hz request at ~200) with the link and the encoder both idle — no drops.

Anchor the floor to this frame's arrival (`t_cap`) instead. The synchronous
encode now overlaps the interval rather than stacking onto it; the ≥0.9×interval
spacing from the last grab still caps the rate at ~1.11× target. No-op for async
NVENC (t_cap ≈ post-submit there), which is why H.26x already held full rate.
Measured on-glass (5120x1440@240, RTX 4090 host, macOS client): desktop now
holds 240 fps. Also reduces latency (samples fresher frames).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 01:01:59 +02:00
parent 017b083e32
commit dc20e4452e
+11 -1
View File
@@ -2041,7 +2041,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// rate at ~1.11× target when the source runs faster (compositor Hz > session fps); // rate at ~1.11× target when the source runs faster (compositor Hz > session fps);
// the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape, // the +0.5×interval keepalive keeps a static desktop re-encoding (bitrate shape,
// client liveness) at 1.5×interval cadence and bounds control-servicing latency. // client liveness) at 1.5×interval cadence and bounds control-servicing latency.
let earliest = next - interval.mul_f32(0.1); //
// Anchor the floor to THIS frame's arrival (`t_cap`), not to `next` — `next` is
// re-based to the instant *after* submit(), so a synchronous encoder folds its whole
// encode into the cadence: PyroWave's ~2 ms inline encode pushes the floor out by
// that much, the loop misses the next arrival and samples one interval late, and the
// period becomes interval + encode (≈158 fps off a 240 Hz source; 360 Hz → ~200).
// An async encoder (NVENC) returns from submit in ≈0, so t_cap ≈ post-submit and this
// is a no-op for it — which is why H.26x already holds full rate. Arrival-anchoring
// lets the synchronous encode overlap the interval; the ≥0.9×interval spacing from
// the last grab still caps the rate at ~1.11× target.
let earliest = t_cap + interval.mul_f32(0.9);
if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) { if let Some(d) = earliest.checked_duration_since(std::time::Instant::now()) {
std::thread::sleep(d); std::thread::sleep(d);
} }