perf(canvas): smooth reveal stream pacing

This commit is contained in:
Kayshen-X 2026-06-15 06:57:43 +08:00
parent 8bbe0768f3
commit 54295ce840
4 changed files with 52 additions and 17 deletions

View file

@ -17,13 +17,13 @@ use std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
/// Duration of the short generated-node entrance animation.
pub const REVEAL_DURATION_MS: u64 = 1_680;
pub const REVEAL_DURATION_MS: u64 = 1_920;
/// Delay between the first generated nodes in one applied batch.
pub const REVEAL_STAGGER_MS: u64 = 48;
pub const REVEAL_STAGGER_MS: u64 = 40;
/// Minimum delay before descendants of a newly revealed container begin
/// their own entrances. This leaves the parent opening beat readable
/// without making nested content feel stalled.
pub const REVEAL_CHILD_RUNWAY_MS: u64 = 80;
pub const REVEAL_CHILD_RUNWAY_MS: u64 = 72;
/// Extra delay for nested generated nodes. Visual traversal order
/// already places children after parents; keeping this at zero avoids
/// depth changes compressing adjacent stream slots into the same frame.
@ -32,12 +32,12 @@ pub const REVEAL_DEPTH_STAGGER_MS: u64 = 0;
/// beat. Once the parent has begun settling, delayed children animate
/// independently so streamed content does not pop in abruptly.
pub const REVEAL_CHILD_SUPPRESS_FRACTION: f32 = 0.04;
const REVEAL_FULL_STAGGER_SIBLINGS: u64 = 18;
const REVEAL_FULL_STAGGER_SIBLINGS: u64 = 24;
const REVEAL_MID_STAGGER_SIBLINGS: u64 = 72;
const REVEAL_COMPRESSED_STAGGER_MS: u64 = 32;
const REVEAL_TAIL_STAGGER_MS: u64 = 24;
const REVEAL_MAX_NEW_STARTS_PER_SNAPSHOT: usize = 1;
const REVEAL_BURST_RECOVERY_STAGGER_MS: u64 = REVEAL_STAGGER_MS;
const REVEAL_BURST_RECOVERY_STAGGER_MS: u64 = 32;
const CLOCK_REBASE_THRESHOLD_MS: u64 = 60_000;
const REVEAL_FRAME_MS: u64 = 16;
@ -334,11 +334,18 @@ fn smooth_overdue_reveal_burst(r: &mut AgentIndicators, now_ms: u64) {
}
let mut newly_due_seen = 0;
let mut reschedule_tail = false;
let mut next_slot = now_ms.saturating_add(REVEAL_BURST_RECOVERY_STAGGER_MS);
let mut next_slot = now_ms;
for (id, original_start) in ordered {
if original_start > prev_ms && original_start <= now_ms {
newly_due_seen += 1;
if newly_due_seen > REVEAL_MAX_NEW_STARTS_PER_SNAPSHOT {
if newly_due_seen <= REVEAL_MAX_NEW_STARTS_PER_SNAPSHOT {
if let Some(started_at) = r.reveals.get_mut(&id) {
*started_at = next_slot;
}
next_slot = next_slot.saturating_add(REVEAL_BURST_RECOVERY_STAGGER_MS);
reschedule_tail = true;
continue;
} else {
reschedule_tail = true;
}
}
@ -452,7 +459,7 @@ mod tests {
"reveal should stay active long enough to read as a smooth entrance"
);
assert!(
snapshot_at(2_800).reveals.is_empty(),
snapshot_at(3_000).reveals.is_empty(),
"expired reveal should be pruned"
);
assert!(!is_active(), "expired reveal should not keep animating");
@ -570,6 +577,34 @@ mod tests {
end_if_epoch(epoch);
}
#[test]
fn snapshot_replays_first_overdue_reveal_instead_of_jumping_mid_animation() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let epoch = begin();
for i in 0..6 {
add_reveal(
epoch,
&format!("n{i}"),
1_000 + i as u64 * REVEAL_STAGGER_MS,
);
}
assert_eq!(snapshot_at(1_000).reveals.get("n0"), Some(&1_000));
let snap = snapshot_at(1_220);
assert_eq!(
snap.reveals.get("n1"),
Some(&1_220),
"the first overdue node should restart at the current frame instead of appearing partway through"
);
assert_eq!(
snap.reveals.get("n2"),
Some(&(1_220 + REVEAL_BURST_RECOVERY_STAGGER_MS)),
"overdue siblings should recover one-by-one on a tighter cadence"
);
end_if_epoch(epoch);
}
#[test]
fn snapshot_recovery_preserves_stream_order_after_frame_gap() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());

View file

@ -205,11 +205,11 @@ fn paint_reveal_sweep(
let tail = (1.0 - t * t).clamp(0.0, 1.0);
let radius = 8.0_f32.min(animated.size.y / 2.0);
cx.backend
.fill_round_rect(animated, radius, REVEAL_ACCENT.with_alpha(0.075 * tail));
.fill_round_rect(animated, radius, REVEAL_ACCENT.with_alpha(0.060 * tail));
cx.backend.stroke_round_rect(
animated,
radius,
REVEAL_ACCENT.with_alpha(0.42 * tail),
REVEAL_ACCENT.with_alpha(0.34 * tail),
1.25,
);
let sweep_w = (animated.size.x * 0.18).clamp(16.0, 64.0);
@ -223,14 +223,14 @@ fn paint_reveal_sweep(
cx.backend.save();
cx.backend.clip_rect(animated);
cx.backend
.fill_round_rect(sweep, sweep_w / 2.0, Color::WHITE.with_alpha(0.24 * tail));
.fill_round_rect(sweep, sweep_w / 2.0, Color::WHITE.with_alpha(0.18 * tail));
cx.backend.restore();
}
fn lifted_scaled_rect(rect: Rect, ease: f32) -> Rect {
let settle = 1.0 - ease;
let scale = 0.970 + ease * 0.030;
let lift = settle * 8.0;
let scale = 0.960 + ease * 0.040;
let lift = settle * 11.0;
let w = rect.size.x * scale;
let h = rect.size.y * scale;
Rect {
@ -457,7 +457,7 @@ mod tests {
&roots,
Point2D::new(100.0, 50.0),
2.0,
2_800,
3_000,
);
assert!(expired_backend.round_fills.is_empty());
assert!(expired_backend.round_strokes.is_empty());

View file

@ -669,8 +669,8 @@ fn push_reveal_transform(cx: &mut PaintCx<'_>, rect: Rect, phase: RevealPhase) -
return false;
}
let settle = 1.0 - phase.ease;
let lift = 9.0 * settle * (1.0 - phase.t * 0.12);
let scale = 0.968 + 0.032 * phase.ease;
let lift = 11.0 * settle * (1.0 - phase.t * 0.10);
let scale = 0.960 + 0.040 * phase.ease;
let pivot = Point2D::new(
rect.origin.x + rect.size.x / 2.0,
rect.origin.y + rect.size.y / 2.0,

View file

@ -158,7 +158,7 @@ fn reveal_schedule_gives_new_container_children_an_entrance_runway() {
});
assert!(
starts[0] - status >= 80,
starts[0] - status >= op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS,
"children of a new container need their own runway instead of starting inside the parent's first beat"
);
assert!(