use bevy::prelude::*;
use super::params::lerp_packed_params;
use super::registry::{FilterRegistry, ResolvedFilterPass, stamp_and_push};
use super::wire::{FilterChain, FilterUse};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FilterStrategy {
Matched,
Extended,
Discrete,
}
pub(crate) fn classify_filter_transition(
from: &FilterChain,
to: &FilterChain,
registry: Option<&FilterRegistry>,
) -> FilterStrategy {
if from.0.len() == to.0.len() && from.0.iter().zip(&to.0).all(|(a, b)| a.name == b.name) {
return FilterStrategy::Matched;
}
let (short, long) = if from.0.len() <= to.0.len() {
(from, to)
} else {
(to, from)
};
let prefix = short.0.iter().zip(&long.0).all(|(s, l)| s.name == l.name);
let has_identity = |chain: &FilterChain| {
chain.0.iter().all(|fu| {
registry.is_some_and(|r| {
r.entries
.get(fu.name.as_str())
.is_some_and(|e| (e.identity)().is_some())
})
})
};
if prefix && has_identity(from) && has_identity(to) {
FilterStrategy::Extended
} else {
FilterStrategy::Discrete
}
}
#[derive(Debug, Clone)]
pub(crate) enum FilterEase {
Aligned {
start: Vec<ResolvedFilterPass>,
target: Vec<ResolvedFilterPass>,
settle: Vec<ResolvedFilterPass>,
},
Discrete {
start: Vec<ResolvedFilterPass>,
target: Vec<ResolvedFilterPass>,
},
}
impl FilterEase {
pub(crate) fn sample(&self, p: f32) -> Vec<ResolvedFilterPass> {
match self {
Self::Aligned { start, target, .. } => start
.iter()
.zip(target)
.map(|(s, t)| {
let mut pass = t.clone();
pass.params = lerp_packed_params(&s.params, &t.params, p, &t.layout);
pass
})
.collect(),
Self::Discrete { start, target } => {
if p < 0.5 {
start.clone()
} else {
target.clone()
}
}
}
}
pub(crate) fn settle(&self) -> &[ResolvedFilterPass] {
match self {
Self::Aligned { settle, .. } => settle,
Self::Discrete { target, .. } => target,
}
}
}
pub(crate) fn plan_filter_ease(
from_wire: &FilterChain,
to_wire: &FilterChain,
start: Vec<ResolvedFilterPass>,
target: Vec<ResolvedFilterPass>,
registry: Option<&FilterRegistry>,
assets: Option<&AssetServer>,
scale: f32,
) -> FilterEase {
match classify_filter_transition(from_wire, to_wire, registry) {
FilterStrategy::Discrete => FilterEase::Discrete { start, target },
FilterStrategy::Matched => aligned_or_discrete(start, target.clone(), target),
FilterStrategy::Extended => {
let (Some(registry), Some(assets)) = (registry, assets) else {
return FilterEase::Discrete { start, target };
};
let growing = from_wire.0.len() < to_wire.0.len();
let (short, long) = if growing {
(from_wire, to_wire)
} else {
(to_wire, from_wire)
};
let pad = identity_passes(
&long.0[short.0.len()..],
short.0.len(),
registry,
assets,
scale,
);
match pad {
Some(pad) if growing => {
let mut start = start;
start.extend(pad);
aligned_or_discrete(start, target.clone(), target)
}
Some(pad) => {
let mut padded = target.clone();
padded.extend(pad);
aligned_or_discrete(start, padded, target)
}
None => FilterEase::Discrete { start, target },
}
}
}
}
fn aligned_or_discrete(
start: Vec<ResolvedFilterPass>,
target: Vec<ResolvedFilterPass>,
settle: Vec<ResolvedFilterPass>,
) -> FilterEase {
let compatible = start.len() == target.len()
&& start.iter().zip(&target).all(|(s, t)| {
s.params.len() == t.params.len() && s.layout == t.layout && s.shader == t.shader
});
if compatible {
FilterEase::Aligned {
start,
target,
settle,
}
} else {
FilterEase::Discrete {
start,
target: settle,
}
}
}
fn identity_passes(
entries: &[FilterUse],
offset: usize,
registry: &FilterRegistry,
assets: &AssetServer,
scale: f32,
) -> Option<Vec<ResolvedFilterPass>> {
let mut out = Vec::new();
for (i, fu) in entries.iter().enumerate() {
let reg = registry.entries.get(fu.name.as_str())?;
let identity = (reg.identity)()?;
let passes = (reg.resolve)(&identity, assets).ok()?;
stamp_and_push(passes, offset + i, scale, &mut out);
}
Some(out)
}
#[cfg(test)]
mod tests {
use serde_json::{Map, json};
use super::super::test_util::{
asset_app, builtin_registry, create, drain_dirt, ease_app, entity_of, tick, update,
};
use super::*;
use crate::filters::{BlurParams, ReactFilter, ResolvedFilterChain};
use crate::layer::{LayerContentDirt, PromotedLayer};
use crate::protocol::Op;
fn wire(names: &[&str]) -> FilterChain {
FilterChain(
names
.iter()
.map(|n| FilterUse {
name: (*n).into(),
params: Map::new(),
})
.collect(),
)
}
#[test]
fn classify_same_names_is_matched() {
use FilterStrategy::Matched;
let r = builtin_registry();
assert_eq!(
classify_filter_transition(
&wire(&["blur", "sepia"]),
&wire(&["blur", "sepia"]),
Some(&r)
),
Matched
);
assert_eq!(
classify_filter_transition(&wire(&["glow"]), &wire(&["glow"]), Some(&r)),
Matched
);
}
#[test]
fn classify_mismatch_without_identity_is_discrete() {
use FilterStrategy::Discrete;
let r = builtin_registry();
assert_eq!(
classify_filter_transition(&wire(&[]), &wire(&["glow"]), Some(&r)),
Discrete
);
assert_eq!(
classify_filter_transition(&wire(&["glow"]), &wire(&["glow", "blur"]), Some(&r)),
Discrete
);
assert_eq!(
classify_filter_transition(&wire(&["sepia"]), &wire(&["blur"]), Some(&r)),
Discrete
);
assert_eq!(
classify_filter_transition(&wire(&["sepia"]), &wire(&["blur", "sepia"]), Some(&r)),
Discrete
);
assert_eq!(
classify_filter_transition(&wire(&[]), &wire(&["blur"]), None),
Discrete
);
}
#[test]
fn classify_builtin_prefix_extension_is_extended() {
use FilterStrategy::Extended;
let r = builtin_registry();
assert_eq!(
classify_filter_transition(&wire(&[]), &wire(&["blur"]), Some(&r)),
Extended
);
assert_eq!(
classify_filter_transition(&wire(&["blur"]), &wire(&["blur", "sepia"]), Some(&r)),
Extended
);
assert_eq!(
classify_filter_transition(&wire(&["blur", "sepia"]), &wire(&["blur"]), Some(&r)),
Extended
);
assert_eq!(
classify_filter_transition(&wire(&["blur"]), &wire(&["blur", "bloom"]), Some(&r)),
Extended
);
}
#[test]
fn plan_extends_shorter_side_with_trailing_identity_passes() {
let app = asset_app();
let assets = app.world().resource::<AssetServer>();
let r = builtin_registry();
let from = wire(&["blur"]);
let to = wire(&["blur", "sepia"]);
let blur = (r.entries["blur"].resolve)(&json!({ "radius": 4 }), assets).unwrap();
let mut sepia = (r.entries["sepia"].resolve)(&json!({ "amount": 0.5 }), assets).unwrap();
sepia[0].wire_index = 1;
let short_passes = blur.clone();
let mut long_passes = blur.clone();
long_passes.extend(sepia);
let ease = plan_filter_ease(
&from,
&to,
short_passes.clone(),
long_passes.clone(),
Some(&r),
Some(assets),
1.0,
);
let FilterEase::Aligned {
start,
target,
settle,
} = ease
else {
panic!("expected aligned");
};
assert_eq!(target, long_passes);
assert_eq!(settle, long_passes);
assert_eq!(start.len(), 3, "start padded at the end");
assert_eq!(start[..2], short_passes[..], "existing passes untouched");
assert_eq!(
start[2].wire_index, 1,
"pad sits at the longer chain's position"
);
assert_eq!(start[2].layout, long_passes[2].layout);
assert_eq!(start[2].shader, long_passes[2].shader);
assert_eq!(start[2].params[1].x, 0.0);
let ease = plan_filter_ease(
&to,
&from,
long_passes.clone(),
short_passes.clone(),
Some(&r),
Some(assets),
1.0,
);
let FilterEase::Aligned {
start,
target,
settle,
} = ease
else {
panic!("expected aligned");
};
assert_eq!(start, long_passes);
assert_eq!(settle, short_passes, "settle is the resolver's own output");
assert_eq!(target.len(), 3);
assert_eq!(target[..2], short_passes[..]);
assert_eq!(target[2].wire_index, 1);
assert_eq!(target[2].params[1].x, 0.0, "sepia fades out to identity");
}
#[test]
fn plan_pads_added_bloom_with_four_identity_passes() {
let app = asset_app();
let assets = app.world().resource::<AssetServer>();
let r = builtin_registry();
let from = wire(&["blur"]);
let to = wire(&["blur", "bloom"]);
let blur = (r.entries["blur"].resolve)(&json!({ "radius": 4 }), assets).unwrap();
let mut bloom = (r.entries["bloom"].resolve)(
&json!({ "radius": 8, "threshold": 0.5, "intensity": 2 }),
assets,
)
.unwrap();
for pass in &mut bloom {
pass.wire_index = 1;
}
let short_passes = blur.clone();
let mut long_passes = blur.clone();
long_passes.extend(bloom);
let ease = plan_filter_ease(
&from,
&to,
short_passes.clone(),
long_passes.clone(),
Some(&r),
Some(assets),
1.0,
);
let FilterEase::Aligned { start, target, .. } = &ease else {
panic!("expected aligned");
};
assert_eq!(target, &long_passes);
assert_eq!(start.len(), 6, "blur H+V plus four padded bloom passes");
assert_eq!(start[..2], short_passes[..], "existing passes untouched");
for (pad, tgt) in start[2..].iter().zip(&long_passes[2..]) {
assert_eq!(pad.wire_index, 1);
assert_eq!(pad.shader, tgt.shader);
assert_eq!(pad.layout, tgt.layout);
assert_eq!(pad.params[1].y, 0.0, "identity bloom: intensity 0");
}
let mid = ease.sample(0.5);
let bloom_mid = &mid[2..];
for pass in bloom_mid {
assert_eq!(pass.params[0].x, 10.0);
assert!((pass.params[1].x - 0.6).abs() < 1e-6);
assert_eq!(pass.params[1].y, 1.0);
}
assert_eq!(bloom_mid[0].params[0].w, 0.0, "bright mode");
assert_eq!(bloom_mid[3].params[0].w, 1.0, "combine mode");
}
#[test]
fn plan_mismatched_shaders_fall_back_to_discrete() {
let app = asset_app();
let assets = app.world().resource::<AssetServer>();
let r = builtin_registry();
let chain = wire(&["glow"]);
let start = (r.entries["grayscale"].resolve)(&json!({}), assets).unwrap();
let mut target = start.clone();
target[0].shader = <BlurParams as ReactFilter>::shader(assets);
assert_ne!(start[0].shader, target[0].shader);
assert_eq!(start[0].layout, target[0].layout);
assert_eq!(start[0].params.len(), target[0].params.len());
let ease = plan_filter_ease(
&chain,
&chain,
start.clone(),
target.clone(),
Some(&r),
Some(assets),
1.0,
);
let FilterEase::Discrete {
start: s,
target: t,
} = ease
else {
panic!("expected discrete fallback on shader mismatch");
};
assert_eq!(s, start);
assert_eq!(t, target);
let ease = plan_filter_ease(
&chain,
&chain,
start.clone(),
start.clone(),
Some(&r),
Some(assets),
1.0,
);
assert!(matches!(ease, FilterEase::Aligned { .. }));
}
#[test]
fn filter_transition_eases_matched_params_and_settles_exactly() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"filter": { "name": "grayscale", "params": { "amount": 0.0 } },
"transition": { "filter": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update(); let e = entity_of(&app, 1);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].w,
0.0
);
drain_dirt(&mut app);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "grayscale", "params": { "amount": 1.0 } } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.25);
let (mut last_w, mut last_version) = {
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
let w = chain.passes[0].params[0].w;
assert!(w > 0.0 && w < 1.0, "strictly between endpoints: {w}");
(w, chain.version)
};
for _ in 0..2 {
drain_dirt(&mut app);
tick(&mut app, 0.25);
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert!(chain.version > last_version, "easing frame bumps version");
let w = chain.passes[0].params[0].w;
assert!(w > last_w && w < 1.0, "monotone ease: {last_w} → {w}");
(last_w, last_version) = (w, chain.version);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "{dirt:?}");
}
tick(&mut app, 0.5);
let settled_version = {
let world = app.world();
let chain = world.get::<ResolvedFilterChain>(e).unwrap();
let expected = (world.resource::<FilterRegistry>().entries["grayscale"].resolve)(
&json!({ "amount": 1.0 }),
world.resource::<AssetServer>(),
)
.unwrap();
assert_eq!(chain.passes, expected, "settles on the resolver's output");
chain.version
};
drain_dirt(&mut app);
tick(&mut app, 0.25);
tick(&mut app, 0.25);
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.version, settled_version, "settled: no more churn");
let dirt = app.world().resource::<LayerContentDirt>();
assert!(!dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "{dirt:?}");
}
#[test]
fn filter_transition_mid_ease_retarget_starts_from_current() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"filter": { "name": "grayscale", "params": { "amount": 0.0 } },
"transition": { "filter": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "grayscale", "params": { "amount": 1.0 } } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
let mid = app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].w;
assert!(
(mid - 0.5).abs() < 1e-3,
"midpoint expected ~0.5, got {mid}"
);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "grayscale", "params": { "amount": 0.25 } } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.1);
let w = app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].w;
assert!(
(w - 0.475).abs() < 1e-3,
"new ease starts from current: expected ~0.475, got {w}"
);
tick(&mut app, 0.4);
let w2 = app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].w;
assert!(w2 < w && w2 > 0.25, "easing down: {w} → {w2}");
tick(&mut app, 0.6);
let settled_version = {
let world = app.world();
let chain = world.get::<ResolvedFilterChain>(e).unwrap();
let expected = (world.resource::<FilterRegistry>().entries["grayscale"].resolve)(
&json!({ "amount": 0.25 }),
world.resource::<AssetServer>(),
)
.unwrap();
assert_eq!(chain.passes, expected, "settles on the resolver's output");
chain.version
};
tick(&mut app, 0.25);
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().version,
settled_version,
"settled: no more churn"
);
}
#[test]
fn filter_transition_fades_in_added_blur() {
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![
create(
1,
json!({ "style": {
"opacity": 0.5,
"transition": { "filter": { "duration": 1000, "easing": "linear" } },
} }),
),
create(2, json!({})),
Op::Append {
parent: 1,
child: 2,
},
])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"promoted via opacity"
);
assert!(app.world().get::<ResolvedFilterChain>(e).is_none());
drain_dirt(&mut app);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "blur", "params": { "radius": 8 } } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.5);
let mid = {
let chain = app
.world()
.get::<ResolvedFilterChain>(e)
.expect("chain resolved");
assert_eq!(chain.passes.len(), 2, "blur H + V");
let r = chain.passes[0].params[0].x;
assert!(r > 0.0 && r < 8.0, "radius eases 0→8, got {r}");
assert_eq!(chain.passes[1].params[0].x, r, "both directions agree");
assert_eq!(chain.passes[0].params[0].y, 1.0);
assert_eq!(chain.passes[1].params[0].z, 1.0);
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "capture never dirtied: {dirt:?}");
r
};
drain_dirt(&mut app);
tick(&mut app, 0.25);
{
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
let r = chain.passes[0].params[0].x;
assert!(r > mid && r < 8.0, "still easing: {mid} → {r}");
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "capture never dirtied: {dirt:?}");
}
tick(&mut app, 0.5);
let world = app.world();
let chain = world.get::<ResolvedFilterChain>(e).unwrap();
let expected = (world.resource::<FilterRegistry>().entries["blur"].resolve)(
&json!({ "radius": 8 }),
world.resource::<AssetServer>(),
)
.unwrap();
assert_eq!(chain.passes, expected);
assert_eq!(chain.passes[0].params[0].x, 8.0);
}
#[test]
fn hover_filter_eases_through_interaction_restyle() {
use bevy::ui::Interaction;
let (mut app, ops_tx) = ease_app();
ops_tx
.send(vec![create(
1,
json!({
"style": {
"filter": { "name": "blur", "params": { "radius": 0.0 } },
"transition": { "filter": { "duration": 1000, "easing": "linear" } },
},
"hoverStyle": { "filter": { "name": "blur", "params": { "radius": 8.0 } } },
}),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"hover-filter node promotes eagerly"
);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].x,
0.0
);
assert_eq!(app.world().get::<Interaction>(e), Some(&Interaction::None));
drain_dirt(&mut app);
*app.world_mut()
.entity_mut(e)
.get_mut::<Interaction>()
.unwrap() = Interaction::Hovered;
tick(&mut app, 0.25);
let mid = {
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.passes.len(), 2, "matched blur chain: H + V");
let r = chain.passes[0].params[0].x;
assert!(r > 0.0 && r < 8.0, "radius eases 0→8, got {r}");
assert_eq!(chain.passes[1].params[0].x, r, "both directions agree");
assert!(app.world().get::<PromotedLayer>(e).is_some());
r
};
drain_dirt(&mut app);
tick(&mut app, 0.25);
let later = app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].x;
assert!(later > mid && later < 8.0, "monotone ease: {mid} → {later}");
{
let dirt = app.world().resource::<LayerContentDirt>();
assert!(dirt.composite_only.contains(&e), "{dirt:?}");
assert!(!dirt.nodes.contains(&e), "capture never dirtied: {dirt:?}");
}
tick(&mut app, 0.6);
{
let world = app.world();
let chain = world.get::<ResolvedFilterChain>(e).unwrap();
let expected = (world.resource::<FilterRegistry>().entries["blur"].resolve)(
&json!({ "radius": 8.0 }),
world.resource::<AssetServer>(),
)
.unwrap();
assert_eq!(chain.passes, expected, "settles on the resolver's output");
}
*app.world_mut()
.entity_mut(e)
.get_mut::<Interaction>()
.unwrap() = Interaction::None;
tick(&mut app, 0.25);
let down = app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].x;
assert!(down > 0.0 && down < 8.0, "eases back down, got {down}");
tick(&mut app, 1.0);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().passes[0].params[0].x,
0.0,
"settled back on the base radius"
);
assert!(
app.world().get::<PromotedLayer>(e).is_some(),
"still promoted after hover-out (presence union)"
);
}
#[test]
fn filter_transition_discrete_swaps_at_midpoint() {
use crate::react_filter;
#[react_filter(shader = "shaders/step.wgsl")]
struct StepParams {
amount: f32,
}
let (mut app, ops_tx) = ease_app();
app.world_mut()
.resource_mut::<FilterRegistry>()
.register::<StepParams>();
ops_tx
.send(vec![create(
1,
json!({ "style": {
"filter": { "name": "stepParams", "params": { "amount": 2.0 } },
"transition": { "filter": { "duration": 1000, "easing": "linear" } },
} }),
)])
.unwrap();
app.update();
let e = entity_of(&app, 1);
let start_passes = app
.world()
.get::<ResolvedFilterChain>(e)
.unwrap()
.passes
.clone();
assert_eq!(start_passes[0].params[0].x, 2.0);
ops_tx
.send(vec![update(
1,
json!({ "style": { "filter": { "name": "grayscale" } } }),
&[],
)])
.unwrap();
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().passes,
start_passes
);
tick(&mut app, 0.3); let expected = {
let world = app.world();
(world.resource::<FilterRegistry>().entries["grayscale"].resolve)(
&json!({}),
world.resource::<AssetServer>(),
)
.unwrap()
};
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().passes,
expected
);
tick(&mut app, 0.5);
let chain = app.world().get::<ResolvedFilterChain>(e).unwrap();
assert_eq!(chain.passes, expected);
let v = chain.version;
tick(&mut app, 0.25);
assert_eq!(
app.world().get::<ResolvedFilterChain>(e).unwrap().version,
v,
"settled: no more churn"
);
}
}