Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions crates/rustmotion-core/src/engine/animator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,25 @@ pub fn shatter_progress(cfg: &crate::schema::ShatterConfig, time: f64) -> Option
}
}

pub fn burst_progress(cfg: &crate::schema::BurstConfig, time: f64) -> Option<f32> {
if cfg.duration <= 0.0 {
return None;
}
let elapsed = time - cfg.delay;
if elapsed < 0.0 || elapsed >= cfg.duration {
return None;
}
Some((elapsed / cfg.duration) as f32)
}

pub fn burst_stroke_span(progress: f32, phase: f32) -> (f32, f32) {
let phase = phase.clamp(0.0, 0.9);
let local = ((progress - phase) / (1.0 - phase)).clamp(0.0, 1.0);
let head = ease_out_cubic((local * 2.0).min(1.0) as f64) as f32;
let tail = ease_in_cubic((local * 2.0 - 1.0).clamp(0.0, 1.0) as f64) as f32;
(tail, head)
}

#[cfg(test)]
mod shatter_progress_tests {
use super::*;
Expand Down Expand Up @@ -3247,3 +3266,92 @@ mod repeat_cycle_tests {
);
}
}

#[cfg(test)]
mod burst_progress_tests {
use super::*;
use crate::schema::BurstConfig;

fn cfg() -> BurstConfig {
BurstConfig {
delay: 1.0,
duration: 0.5,
count: 8,
length: 40.0,
gap: 12.0,
width: 4.0,
color: "#FFB020".to_string(),
seed: 3,
jitter: 0.2,
}
}

#[test]
fn nothing_before_the_delay_and_nothing_at_or_after_the_end() {
let c = cfg();
assert_eq!(burst_progress(&c, c.delay - 0.01), None);
assert_eq!(burst_progress(&c, c.delay + c.duration), None);
assert_eq!(burst_progress(&c, 9.0), None);
assert!(burst_progress(&c, c.delay).is_some());
assert!(burst_progress(&c, c.delay + c.duration * 0.99).is_some());
}

#[test]
fn a_frame_landing_one_ulp_inside_the_window_still_paints_nothing() {
let mut c = cfg();
c.duration = 0.4;
let at_end = c.delay + c.duration;
let progress = burst_progress(&c, at_end)
.expect("0.4 is not exactly representable: delay + duration - delay < duration");
let (tail, head) = burst_stroke_span(progress, 0.0);
assert_eq!(
tail, head,
"the window test cannot be exact for every duration, so the guarantee is \
geometric: a stroke at progress 1.0 has zero length and paints nothing"
);
}

#[test]
fn a_zero_or_negative_duration_disables_the_effect_entirely() {
let mut c = cfg();
c.duration = 0.0;
assert_eq!(burst_progress(&c, 1.0), None);
c.duration = -1.0;
assert_eq!(burst_progress(&c, 1.0), None);
}

#[test]
fn the_stroke_has_zero_length_at_both_ends_of_its_own_window() {
let (tail, head) = burst_stroke_span(0.0, 0.0);
assert_eq!(tail, head, "at the start the head has not left the tail");
let (tail, head) = burst_stroke_span(1.0, 0.0);
assert_eq!(tail, head, "at the end the tail has caught the head");
}

#[test]
fn the_head_reaches_the_far_end_halfway_through_while_the_tail_waits() {
let (tail, head) = burst_stroke_span(0.5, 0.0);
assert!((head - 1.0).abs() < 1e-6, "head at the far end, got {head}");
assert_eq!(tail, 0.0, "tail has not started");
}

#[test]
fn the_head_never_overtakes_the_far_end_nor_the_tail_the_head() {
for step in 0..=100 {
let p = step as f32 / 100.0;
for phase in [0.0_f32, 0.15, 0.4] {
let (tail, head) = burst_stroke_span(p, phase);
assert!((0.0..=1.0).contains(&head), "head {head} at p={p}");
assert!(tail <= head + 1e-6, "tail {tail} past head {head} at p={p}");
}
}
}

#[test]
fn a_phase_delays_the_stroke_without_letting_it_outlive_the_window() {
let (tail, head) = burst_stroke_span(0.3, 0.4);
assert_eq!(tail, head, "a phased stroke has not started at p=0.3");
let (tail, head) = burst_stroke_span(1.0, 0.4);
assert_eq!(tail, head, "a phased stroke still ends with the window");
}
}
207 changes: 205 additions & 2 deletions crates/rustmotion-core/src/engine/paint_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@ fn paint_node(canvas: &Canvas, node: &BoxNode, ctx: &PaintContext, tree_depth: u
}
}

if let Some((cfg, progress)) = active_burst(&node.css, ctx.frame.time) {
paint_burst(canvas, box_layout, cfg, progress);
}

canvas.restore();
}

Expand Down Expand Up @@ -896,6 +900,83 @@ fn paint_shattered_node(
}
}

const MAX_BURST_COUNT: u32 = 64;

fn active_burst(css: &CssStyle, time: f64) -> Option<(&crate::schema::BurstConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::Burst(c) => Some(c),
_ => None,
})?;
crate::engine::animator::burst_progress(cfg, time).map(|progress| (cfg, progress))
}

fn burst_track_start(half_width: f32, half_height: f32, dir: (f32, f32)) -> f32 {
let to_vertical = if dir.0.abs() < 1e-6 {
f32::INFINITY
} else {
half_width / dir.0.abs()
};
let to_horizontal = if dir.1.abs() < 1e-6 {
f32::INFINITY
} else {
half_height / dir.1.abs()
};
let reach = to_vertical.min(to_horizontal);
if reach.is_finite() {
reach
} else {
half_width.max(half_height)
}
}

fn paint_burst(
canvas: &Canvas,
box_layout: &BoxLayout,
cfg: &crate::schema::BurstConfig,
progress: f32,
) {
let count = cfg.count.clamp(1, MAX_BURST_COUNT);
let length = cfg.length.max(0.0);
let width = cfg.width.max(0.0);
if length <= 0.0 || width <= 0.0 {
return;
}
let jitter = cfg.jitter.clamp(0.0, 1.0);
let centre = (
box_layout.x + box_layout.width * 0.5,
box_layout.y + box_layout.height * 0.5,
);
let half_width = box_layout.width * 0.5;
let half_height = box_layout.height * 0.5;
let spacing = std::f32::consts::TAU / count as f32;

let mut paint = Paint::default();
paint.set_anti_alias(true);
paint.set_style(PaintStyle::Stroke);
paint.set_stroke_width(width);
paint.set_stroke_cap(skia_safe::PaintCap::Round);
paint.set_color(parse_color_string(&cfg.color).unwrap_or_else(|| unresolved_color(&cfg.color)));

for i in 0..count {
let angle = i as f32 * spacing + (shatter_hash(cfg.seed, i, 11) - 0.5) * jitter * spacing;
let dir = (angle.cos(), angle.sin());
let stroke_length = length * (1.0 + (shatter_hash(cfg.seed, i, 12) - 0.5) * jitter);
let phase = shatter_hash(cfg.seed, i, 13) * jitter * 0.5;
let (tail, head) = crate::engine::animator::burst_stroke_span(progress, phase);
if (head - tail) * stroke_length < 0.5 {
continue;
}
let base = burst_track_start(half_width, half_height, dir) + cfg.gap.max(0.0);
let near = base + tail * stroke_length;
let far = base + head * stroke_length;
canvas.draw_line(
(centre.0 + dir.0 * near, centre.1 + dir.1 * near),
(centre.0 + dir.0 * far, centre.1 + dir.1 * far),
&paint,
);
}
}

fn active_shimmer(css: &CssStyle, time: f64) -> Option<(&crate::schema::ShimmerConfig, f32)> {
let cfg = css.animation.iter().find_map(|e| match e {
crate::schema::AnimationEffect::Shimmer(c) => Some(c),
Expand Down Expand Up @@ -3881,8 +3962,8 @@ mod paint_order_tests {
use crate::engine::box_tree::{BoxKind, BoxNode};
use crate::engine::layout_pass::run_layout;
use crate::schema::{
AnimationEffect, ChromaticAberrationConfig, EasingType, ShatterConfig, ShatterMode,
ShatterOrigin,
AnimationEffect, BurstConfig, ChromaticAberrationConfig, EasingType, ShatterConfig,
ShatterMode, ShatterOrigin,
};

fn test_frame(w: u32, h: u32) -> PaintFrame {
Expand Down Expand Up @@ -5171,6 +5252,128 @@ mod paint_order_tests {
false
}

fn burst_cfg() -> BurstConfig {
BurstConfig {
delay: 0.2,
duration: 0.6,
count: 4,
length: 60.0,
gap: 14.0,
width: 6.0,
color: "#ff0000".to_string(),
seed: 5,
jitter: 0.0,
}
}

fn burst_node(cfg: BurstConfig) -> BoxNode {
white_square(vec![AnimationEffect::Burst(cfg)])
}

#[test]
fn burst_is_pixel_identical_to_no_effect_before_delay_and_at_or_after_the_end() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);

let cfg = burst_cfg();
let mut before = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
assert_eq!(
baseline,
render_pixels_at(&mut before, 400, 400, 0.0),
"before delay a burst must contribute nothing at all — pixel-identical to a node \
with no burst in its animation list"
);

let mut at_end = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
assert_eq!(
baseline,
render_pixels_at(&mut at_end, 400, 400, 0.8),
"at delay + duration the window is already closed — no stroke may survive the last \
frame and bleed into the next scene"
);

let mut after = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
assert_eq!(
baseline,
render_pixels_at(&mut after, 400, 400, 5.0),
"long after the window the node is plain again"
);
}

#[test]
fn burst_paints_ink_outside_the_nodes_own_box_at_full_extension() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
assert!(
!any_ink_in_band(&baseline, 400, 226, 155, 290, 166),
"the band to the right of the square must be empty without a burst"
);

let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);
assert!(
any_ink_in_band(&mid, 400, 226, 155, 290, 166),
"at the halfway point the head is at the far end of its track — a stroke must be \
painted well outside the node's own box"
);
}

#[test]
fn burst_leaves_the_node_itself_untouched() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
let baseline = render_pixels_at(&mut plain, 400, 400, 5.0);
let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);

for y in 100..220 {
for x in 100..220 {
let i = ((y * 400 + x) * 4) as usize;
assert_eq!(
(baseline[i], baseline[i + 1], baseline[i + 2]),
(mid[i], mid[i + 1], mid[i + 2]),
"gap keeps every stroke off the box — pixel ({x}, {y}) inside the node \
changed"
);
}
}
}

#[test]
fn burst_strokes_sit_on_the_rays_count_asks_for_and_nowhere_else() {
let mut bursting = root_node(400.0, 400.0, "#000000", vec![burst_node(burst_cfg())]);
let mid = render_pixels_at(&mut bursting, 400, 400, 0.5);

assert!(
any_ink_in_band(&mid, 400, 240, 156, 260, 165),
"count: 4 with no jitter puts a stroke straight to the right of the centre"
);
assert!(
any_ink_in_band(&mid, 400, 156, 240, 165, 260),
"count: 4 with no jitter puts a stroke straight below the centre"
);
assert!(
!any_ink_in_band(&mid, 400, 230, 230, 272, 272),
"count: 4 leaves the diagonals empty — the ring is not a halo"
);
}

#[test]
fn burst_two_renders_of_the_same_instant_are_byte_identical() {
let cfg = BurstConfig {
jitter: 0.9,
count: 24,
..burst_cfg()
};
let mut first = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg.clone())]);
let mut second = root_node(400.0, 400.0, "#000000", vec![burst_node(cfg)]);
assert_eq!(
render_pixels_at(&mut first, 400, 400, 0.45),
render_pixels_at(&mut second, 400, 400, 0.45),
"every stroke's angle, length and phase comes from (seed, index) alone — the same \
instant must render byte-identically for the parallel renderer"
);
}

#[test]
fn shatter_out_is_pixel_identical_to_no_effect_before_delay_and_after_duration() {
let mut plain = root_node(400.0, 400.0, "#000000", vec![white_square(vec![])]);
Expand Down
Loading
Loading