perf(canvas): smooth streamed reveal pacing

This commit is contained in:
Kayshen-X 2026-06-15 06:20:54 +08:00
parent b2202b5882
commit 9b74ba7c1d
5 changed files with 78 additions and 30 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_520;
pub const REVEAL_DURATION_MS: u64 = 1_680;
/// Delay between the first generated nodes in one applied batch.
pub const REVEAL_STAGGER_MS: u64 = REVEAL_FRAME_MS;
pub const REVEAL_STAGGER_MS: u64 = 48;
/// 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 = REVEAL_FRAME_MS * 6;
pub const REVEAL_CHILD_RUNWAY_MS: u64 = 80;
/// 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.
@ -31,13 +31,13 @@ pub const REVEAL_DEPTH_STAGGER_MS: u64 = 0;
/// Parent reveals suppress child transforms only during their opening
/// 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.08;
pub const REVEAL_CHILD_SUPPRESS_FRACTION: f32 = 0.04;
const REVEAL_FULL_STAGGER_SIBLINGS: u64 = 18;
const REVEAL_MID_STAGGER_SIBLINGS: u64 = 72;
const REVEAL_COMPRESSED_STAGGER_MS: u64 = REVEAL_FRAME_MS;
const REVEAL_TAIL_STAGGER_MS: u64 = REVEAL_FRAME_MS;
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_FRAME_MS;
const REVEAL_BURST_RECOVERY_STAGGER_MS: u64 = REVEAL_STAGGER_MS;
const CLOCK_REBASE_THRESHOLD_MS: u64 = 60_000;
const REVEAL_FRAME_MS: u64 = 16;
@ -452,7 +452,7 @@ mod tests {
"reveal should stay active long enough to read as a smooth entrance"
);
assert!(
snapshot_at(2_600).reveals.is_empty(),
snapshot_at(2_800).reveals.is_empty(),
"expired reveal should be pruned"
);
assert!(!is_active(), "expired reveal should not keep animating");
@ -564,7 +564,7 @@ mod tests {
assert!(
snap.reveals
.get("n2")
.is_some_and(|started_at| *started_at <= 1_244),
.is_some_and(|started_at| *started_at <= 1_220 + REVEAL_BURST_RECOVERY_STAGGER_MS),
"overdue recovery should stay close enough to feel continuous"
);
end_if_epoch(epoch);
@ -611,12 +611,12 @@ mod tests {
assert!(
offsets
.windows(2)
.take(24)
.all(|pair| pair[1] - pair[0] == REVEAL_FRAME_MS),
"the first visible nodes should stream at frame cadence, not slower"
.take(REVEAL_FULL_STAGGER_SIBLINGS as usize)
.all(|pair| pair[1] - pair[0] == REVEAL_STAGGER_MS),
"the first visible nodes should stream at readable cadence, not bunch into one frame"
);
assert!(
offsets[20] - offsets[0] <= 340,
offsets[20] - offsets[0] <= 1_000,
"the first screenful should still avoid a slow reveal queue"
);
assert!(
@ -626,8 +626,25 @@ mod tests {
"stream items should not share an entrance start frame at 60 fps"
);
assert!(
offsets[39] - offsets[0] < 1_200,
offsets[39] - offsets[0] < 1_800,
"dense generated batches should stay responsive instead of waiting several seconds"
);
}
#[test]
fn reveal_offsets_keep_first_screen_readable_without_clustering() {
let offsets: Vec<u64> = (0..24).map(|i| reveal_offset_ms(0, i)).collect();
assert!(
offsets
.windows(2)
.take(16)
.all(|pair| (40..=64).contains(&(pair[1] - pair[0]))),
"the first screenful should stream at a readable cadence instead of bunching into one paint frame"
);
assert!(
offsets[19] - offsets[0] <= 1_100,
"readable pacing should still keep the first screenful responsive"
);
}
}

View file

@ -229,8 +229,8 @@ fn paint_reveal_sweep(
fn lifted_scaled_rect(rect: Rect, ease: f32) -> Rect {
let settle = 1.0 - ease;
let scale = 0.986 + ease * 0.014;
let lift = settle * 5.0;
let scale = 0.970 + ease * 0.030;
let lift = settle * 8.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_600,
2_800,
);
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 = 6.5 * settle * (1.0 - phase.t * 0.18);
let scale = 0.982 + 0.018 * phase.ease;
let lift = 9.0 * settle * (1.0 - phase.t * 0.12);
let scale = 0.968 + 0.032 * phase.ease;
let pivot = Point2D::new(
rect.origin.x + rect.size.x / 2.0,
rect.origin.y + rect.size.y / 2.0,

View file

@ -8,6 +8,8 @@ use std::collections::HashMap;
struct RevealCaptureBackend {
ops: Vec<String>,
scales: usize,
scale_values: Vec<Point2D>,
translations: Vec<Point2D>,
}
impl RenderBackend for RevealCaptureBackend {
@ -26,9 +28,12 @@ impl RenderBackend for RevealCaptureBackend {
fn restore(&mut self) {
self.ops.push("restore".into());
}
fn translate(&mut self, _: Point2D) {}
fn scale(&mut self, _: Point2D, _: Point2D) {
fn translate(&mut self, delta: Point2D) {
self.translations.push(delta);
}
fn scale(&mut self, factor: Point2D, _: Point2D) {
self.scales += 1;
self.scale_values.push(factor);
self.ops.push("scale".into());
}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
@ -118,6 +123,31 @@ fn active_reveal_wraps_node_paint_in_transform() {
);
}
#[test]
fn active_reveal_starts_with_readable_lift_and_scale() {
let mut node = SceneNode::leaf("c", NodeKind::Rect);
node.bounds = Rect::xywh(10.0, 10.0, 50.0, 30.0);
node.fill = Some(Color::RED);
let reveals = HashMap::from([("c".to_string(), 1_000)]);
let backend = paint_with_reveals(&node, &reveals, 1_000);
assert!(
backend
.scale_values
.first()
.is_some_and(|scale| scale.x <= 0.972 && scale.y <= 0.972),
"reveal should start with enough scale delta to read as an entrance"
);
assert!(
backend
.translations
.first()
.is_some_and(|delta| delta.y >= 8.0),
"reveal should start with enough lift to avoid an instant pop"
);
}
#[test]
fn opening_parent_reveal_prevents_nested_child_transform() {
let frame = frame_with_child();
@ -138,9 +168,9 @@ fn opening_parent_reveal_prevents_nested_child_transform() {
#[test]
fn parent_reveal_suppresses_child_transform_through_opening_beat() {
let frame = frame_with_child();
let reveals = HashMap::from([("f".to_string(), 1_000), ("c".to_string(), 1_080)]);
let reveals = HashMap::from([("f".to_string(), 1_000), ("c".to_string(), 1_048)]);
let backend = paint_with_reveals(&frame, &reveals, 1_120);
let backend = paint_with_reveals(&frame, &reveals, 1_056);
assert_eq!(
backend.scales, 1,

View file

@ -40,15 +40,15 @@ fn reveal_schedule_streams_large_subtrees_without_long_tail() {
let section = *snapshot.reveals.get("section").expect("section reveal");
let last_label = *snapshot.reveals.get("label-19").expect("last label reveal");
assert!(
last_label - section >= 300,
last_label - section >= 800,
"large nested subtrees should stay visibly streamed instead of arriving in one burst"
);
assert!(
last_label - section <= 760,
last_label - section <= 1_100,
"large nested subtrees should avoid a slow reveal queue"
);
assert!(
snapshot.reveals.values().all(|start| *start < 2_000),
snapshot.reveals.values().all(|start| *start < 2_100),
"new content should finish entering within a responsive window"
);
op_editor_core::agent_indicators::end_if_epoch(epoch);
@ -103,8 +103,9 @@ fn reveal_schedule_keeps_nested_stream_order_across_sibling_groups() {
assert!(
[label_0 - row_0, row_1 - label_0, label_1 - row_1]
.into_iter()
.all(|gap| gap >= 16 && gap <= op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS),
"nested stream items should keep frame cadence while giving new containers a runway"
.all(|gap| gap >= 40
&& gap <= op_editor_core::agent_indicators::REVEAL_CHILD_RUNWAY_MS),
"nested stream items should keep readable cadence while giving new containers a runway"
);
op_editor_core::agent_indicators::end_if_epoch(epoch);
}
@ -161,8 +162,8 @@ fn reveal_schedule_gives_new_container_children_an_entrance_runway() {
"children of a new container need their own runway instead of starting inside the parent's first beat"
);
assert!(
starts.windows(2).all(|pair| pair[1] - pair[0] >= 16),
"container children should continue one-per-frame after the runway"
starts.windows(2).all(|pair| pair[1] - pair[0] >= 40),
"container children should continue at readable cadence after the runway"
);
op_editor_core::agent_indicators::end_if_epoch(epoch);
}