motion-content 0.1.0

Backend-independent images, text, vectors, particles and artwork
Documentation
use super::*;
use crate::asset::AssetsExt;
use motion_core::{
    Assets, BlendSpace, Canvas, Color, DrawContext, Drawable, Filter, Pivot, PixelSize, Point,
    Size, Transform, ValidationLimits, Vector2,
};
use motion_core::{NodeModifiers, TimeLiteral};
use motion_render::{Composition, Renderer};

#[derive(Clone, Debug)]
struct ScalarBatch(ParticleBatch);

impl motion_core::instances::InstanceBatch for ScalarBatch {
    fn len(&self) -> usize {
        self.0.len()
    }

    fn bounds(&self) -> Rect {
        self.0.bounds()
    }

    fn bytes(&self) -> u64 {
        self.0.bytes()
    }

    fn work(&self) -> u64 {
        self.0.work()
    }

    fn validate(&self) -> Result<(), ValidationError> {
        self.0.validate()
    }

    fn blend(&self) -> motion_core::instances::InstanceBlend {
        self.0.blend()
    }

    fn sample(
        &self,
        point: Point,
        antialias: f64,
        destination: [f32; 4],
        space: BlendSpace,
        composite: motion_core::instances::InstanceComposite,
    ) -> Result<[f32; 4], ValidationError> {
        self.0
            .sample_with_zero_policy::<true>(point, antialias, destination, space, composite)
    }

    fn as_any(&self) -> &dyn std::any::Any {
        self
    }

    fn content_eq(&self, other: &dyn motion_core::instances::InstanceBatch) -> bool {
        other
            .as_any()
            .downcast_ref::<Self>()
            .is_some_and(|other| self.0 == other.0)
    }
}

impl Drawable for ScalarBatch {
    fn bounds(&self, context: &DrawContext) -> Rect {
        Drawable::bounds(&self.0, context)
    }

    fn draw(&self, _: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
        canvas.instances(Arc::new(self.clone()), Transform::IDENTITY)
    }
}

fn compare_batches(style: ParticleStyle, renderer: Renderer) -> motion_core::Result {
    let count = if matches!(
        &style,
        ParticleStyle::Trails { .. } | ParticleStyle::Plexus { .. }
    ) {
        6
    } else {
        24
    };
    let source = ParticleTrajectory::new(
        Ballistic::burst(Point::new(96.0, 72.0), count)
            .seed(42)
            .speed(20.0, 40.0)
            .gravity(Vector2::new(0.0, 20.0)),
    )
    .style(style);
    let mut session = Composition::new(192, 144)
        .frame_count(120)
        .renderer(renderer)
        .background(Color::BLACK)
        .frame_renderer(Size::new(192.0, 144.0))?;
    for mode in 0..3 {
        let mut reference = None;
        let mut moving = None;
        for index in [30, 0, 45, 75, 30] {
            let make = |context: &SceneContext<'_>, expanded: bool| {
                let node = source.try_render(context).unwrap();
                let list = node
                    .compile(
                        context.size(),
                        PixelSize::new(192, 144),
                        ValidationLimits::default(),
                    )
                    .unwrap();
                let mut nodes = Vec::new();
                for command in &list.commands {
                    if let motion_core::DrawCommand::ParticleInstances { batch, .. } = command {
                        let batch = batch.as_any().downcast_ref::<ParticleBatch>().unwrap();
                        if expanded {
                            for instance in batch.instances.iter() {
                                let mut single = batch.clone();
                                single.instances = vec![instance.clone()].into();
                                single.index = Arc::from([]);
                                nodes.push(Node::from(ScalarBatch(single)));
                            }
                        } else {
                            nodes.push(Node::from(batch.clone()));
                        }
                    }
                }
                let node = Node::group(nodes);
                match mode {
                    1 => node
                        .pivot(Pivot::Point(Point::new(96.0, 72.0)))
                        .scale_xy(-0.85, 1.1)
                        .rotate(0.13)
                        .opacity(0.6),
                    2 => node
                        .clip_with(
                            Rect::new(20.0, 20.0, 160.0, 130.0),
                            motion_core::ClipMode::Geometric,
                        )
                        .filter(Filter::Blur { sigma: 1.0 }),
                    _ => node,
                }
            };
            let batch = session.render_frame(index, |context| make(context, false))?;
            let expanded = session.render_frame(index, |context| make(context, true))?;
            assert_eq!(
                batch.pixels(),
                expanded.pixels(),
                "batch/expanded {renderer:?}/{mode}/{index}"
            );
            if index == 30 {
                assert!(batch.pixels().iter().any(|pixel| *pixel != 0));
                if let Some(first) = &reference {
                    assert_eq!(batch.pixels(), first);
                }
                reference = Some(batch.pixels().to_vec());
            }
            if index == 45 {
                moving = Some(batch.pixels().to_vec());
            }
        }
        assert_ne!(reference, moving);
    }
    Ok(())
}

#[test]
fn particle_batches_match_expanded_instances_with_effects_and_seek() -> motion_core::Result {
    compare_batches(ParticleStyle::Dots { radius: 3.0 }, Renderer::Cpu)
}

#[test]
fn tiled_glow_lines_and_plexus_match_the_scalar_oracle() -> motion_core::Result {
    for style in [
        ParticleStyle::GlowDots { radius: 3.0 },
        ParticleStyle::Trails {
            width: 2.0,
            length: 100.milliseconds(),
        },
        ParticleStyle::Plexus {
            radius: 2.0,
            max_distance: 12.0,
        },
    ] {
        compare_batches(style, Renderer::Cpu)?;
    }
    Ok(())
}

#[test]
fn transparent_sprite_batches_match_expanded_instances_and_replay() -> motion_core::Result {
    let directory = tempfile::tempdir()?;
    let pixels = [
        255, 64, 16, 128, 10, 200, 40, 255, 255, 0, 255, 0, 40, 80, 220, 64, 255, 255, 255, 192, 0,
        0, 0, 0,
    ];
    image::save_buffer(
        directory.path().join("sprite.png"),
        &pixels,
        3,
        2,
        image::ColorType::Rgba8,
    )
    .map_err(crate::image_error)?;
    let assets = Assets::new(directory.path())?;
    compare_batches(
        ParticleStyle::Sprites {
            image: assets.image("sprite.png"),
            size: 20.0,
            align_to_velocity: false,
        },
        Renderer::Cpu,
    )
}

fn blend_source(style: ParticleStyle) -> ParticleSimulation {
    ParticleSimulation::new(3)
        .duration(1.seconds())
        .emitter(
            BurstEmitter::new(Time::ZERO, 24, Point::new(48.0, 36.0))
                .speed(10.0)
                .color([0.5, 0.1, 0.05, 0.6]),
        )
        .style(style)
}

#[test]
fn per_style_blend_reaches_instance_upload_and_cpu_pixels() -> motion_core::Result {
    const BLENDS: [ParticleBlend; 4] = [
        ParticleBlend::SourceOver,
        ParticleBlend::Plus,
        ParticleBlend::Screen,
        ParticleBlend::Multiply,
    ];
    let cache = motion_core::SceneCache::default();
    let context = SceneContext::new(
        300.milliseconds(),
        motion_core::FrameRate::from(30),
        Size::new(96.0, 72.0),
        &cache,
    )
    .with_clip_duration(1.seconds());
    let styles = [
        ParticleStyle::Dots { radius: 4.0 },
        ParticleStyle::Shape {
            radius: 4.0,
            shape: ParticleShape::Ring { thickness: 0.5 },
        },
        ParticleStyle::Trails {
            length: 100.milliseconds(),
            width: 3.0,
        },
    ];
    for style in styles {
        for blend in BLENDS {
            let node = blend_source(style.clone().blend(blend)).try_render(&context)?;
            let list = node.compile(
                context.size(),
                PixelSize::new(96, 72),
                ValidationLimits::default(),
            )?;
            let mut seen = 0;
            for command in &list.commands {
                if let motion_core::DrawCommand::ParticleInstances { batch, .. } = command {
                    assert_eq!(batch.blend(), blend);
                    let words = batch.upload()?.expect("device payload").into_words();
                    assert_eq!(words[3] as u32 >> 5, blend as u32);
                    seen += 1;
                }
            }
            assert_eq!(seen, 1);
        }
    }
    let mut session = Composition::new(96, 72)
        .frame_count(30)
        .renderer(Renderer::Cpu)
        .background(Color::rgb(96, 96, 96))
        .frame_renderer(Size::new(96.0, 72.0))?;
    let frames = BLENDS
        .into_iter()
        .map(|blend| {
            let source = blend_source(ParticleStyle::Dots { radius: 4.0 }.blend(blend));
            session
                .render_frame(9, |context| source.try_render(context).unwrap())
                .map(|frame| frame.pixels().to_vec())
        })
        .collect::<Result<Vec<_>, _>>()?;
    for first in 0..frames.len() {
        for second in first + 1..frames.len() {
            assert_ne!(frames[first], frames[second], "{first} vs {second}");
        }
    }
    Ok(())
}