motion-content 0.2.0

Backend-independent images, text, vectors, particles and artwork
Documentation
//! Instanced rendering of particle states and scene integration for simulations and trajectories.

#[cfg(test)]
use super::batch::SpatialNode;
use super::batch::{Instance, build_spatial_index};
use super::lazy::{Instances, Materialized};
use super::*;
use motion_core::{Node, Rect, SceneContext};

#[cfg(all(test, feature = "gpu"))]
mod analytic_tests;
#[cfg(test)]
mod instance_protocol_tests;

/// Seeded trajectories whose positions the GPU evaluates: the CPU keeps only a conservative
/// center extent and a source that samples states if the CPU reference path asks for them.
struct Deferred {
    bounds: Rect,
    source: Arc<dyn Fn() -> Result<Arc<[Particle]>, ValidationError> + Send + Sync>,
}

fn render(
    style: &ParticleStyle,
    context: &SceneContext<'_>,
    states: Arc<[Particle]>,
    analytic: Option<Result<motion_core::instances::AnalyticInstanceUpload, ValidationError>>,
    deferred: Option<Deferred>,
    mut sample: impl FnMut(Time) -> Result<Arc<[Particle]>, ValidationError>,
) -> Result<Node, ValidationError> {
    match style {
        ParticleStyle::Configured { style, options } => {
            if matches!(style.as_ref(), ParticleStyle::Trails { .. })
                && options.blend.is_none()
                && options.sheet.is_none()
                && options.stretch.is_none()
                && options.parallax == [0.0; 2]
                && !options.sort_depth
                && !options.flip
                && options.trails.segments == 8
                && options.trails.taper.is_none()
                && options.trails.fade.is_none()
                && !options.trails.smooth
                && !options.trails.ribbon
            {
                return render(style, context, states, None, None, sample);
            }
            return super::extended::render(style, options, context, states, sample);
        }
        ParticleStyle::Shape { .. } | ParticleStyle::Bokeh { .. } => {
            return super::extended::render(
                style,
                &StyleOptions::default(),
                context,
                states,
                sample,
            );
        }
        _ => {}
    }
    if let ParticleStyle::Custom(callback) = style {
        return callback(&states, context);
    }
    let extent = match style {
        ParticleStyle::Dots { radius }
        | ParticleStyle::GlowDots { radius }
        | ParticleStyle::Plexus { radius, .. } => *radius,
        ParticleStyle::Sprites { size, .. } => *size,
        ParticleStyle::Trails { width, .. } => *width,
        _ => unreachable!("only dot, trail, plexus and sprite styles reach this branch"),
    };
    if !extent.is_finite() || !(0.0..=4096.0).contains(&extent) {
        return Err(invalid(
            "style",
            "Particle style size must be finite in 0..=4096",
        ));
    }
    if let ParticleStyle::Plexus { max_distance, .. } = style
        && (!max_distance.is_finite() || *max_distance <= 0.0)
    {
        return Err(invalid(
            "distance",
            "Plexus distance must be finite and positive",
        ));
    }
    if states.is_empty() && deferred.is_none() && !matches!(style, ParticleStyle::Trails { .. }) {
        return Ok(Node::empty());
    }
    let (kind, radius, width, align, sprite) = match style {
        ParticleStyle::Dots { radius } => (0, *radius, 1.0, false, None),
        ParticleStyle::GlowDots { radius } => (1, *radius, 1.0, false, None),
        ParticleStyle::Sprites {
            image,
            size,
            align_to_velocity,
        } => match image.get() {
            Ok(image) => (2, *size * 0.5, 1.0, *align_to_velocity, Some(image)),
            Err(_) => {
                return Ok(Node::from(
                    crate::Image::new(image.clone()).size(*size, *size),
                ));
            }
        },
        ParticleStyle::Trails { width, .. } => (3, 1.0, *width, false, None),
        ParticleStyle::Plexus { radius, .. } => (4, *radius, 1.0, false, None),
        _ => unreachable!("only dot, trail, plexus and sprite styles reach this branch"),
    };
    if !radius.is_finite() || radius < 0.0 || !width.is_finite() || width < 0.0 {
        return Err(invalid("style", "Invalid particle style geometry"));
    }
    if let (Some(deferred), Some(Ok(upload))) = (deferred, &analytic) {
        let count = upload.len();
        let reach = (radius * if kind == 1 { 3.0 } else { 2.0 }).max(width);
        let centers = deferred.bounds;
        let source = deferred.source;
        let instances = Instances::deferred(count, move || {
            let states = source()?;
            if states.len() != count {
                return Err(invalid("analytic", "Analytic initial-state count mismatch"));
            }
            let mut instances = Vec::new();
            instances
                .try_reserve_exact(count)
                .map_err(|_| limit("Particle instance allocation failed"))?;
            for particle in states.iter() {
                particle.validate()?;
                instances.push(Instance::new(*particle, particle.position, false));
            }
            let instances: Arc<[Instance]> = instances.into();
            let index = build_spatial_index(kind, radius as f32, width as f32, &instances);
            Ok(Materialized { instances, index })
        });
        let batch = ParticleBatch {
            instances,
            kind,
            radius: radius as f32,
            width: width as f32,
            align,
            sprite,
            bounds: Rect::new(
                centers.left - reach,
                centers.top - reach,
                centers.right + reach,
                centers.bottom + reach,
            ),
            analytic,
        };
        batch.validate()?;
        return Ok(Node::from(batch));
    }
    let mut instances = Vec::new();
    let mut append = |particle: Particle, previous: [f32; 2], line: bool| {
        if instances.len() >= MAX_INSTANCE_COUNT {
            return Err(limit("Particle instance allowance exhausted"));
        }
        instances
            .try_reserve(1)
            .map_err(|_| limit("Particle instance allocation failed"))?;
        instances.push(Instance::new(particle, previous, line));
        Ok(())
    };
    if let ParticleStyle::Trails { length, .. } = style {
        if *length <= Duration::ZERO || length.as_seconds() > 60.0 {
            return Err(invalid("trail", "Trail duration must be in (0,60] seconds"));
        }
        let mut previous = sample(context.time() - *length)?;
        for segment in 1..=8 {
            let time =
                context.time() - *length + Duration::from_ticks(length.ticks() * segment / 8);
            let current = sample(time)?;
            let lookup = previous
                .iter()
                .map(|particle| (particle.index, particle))
                .collect::<std::collections::BTreeMap<_, _>>();
            for particle in current.iter() {
                if let Some(before) = lookup.get(&particle.index) {
                    append(*particle, before.position, true)?;
                }
            }
            previous = current;
        }
    } else {
        for particle in states.iter() {
            append(*particle, particle.position, false)?;
        }
    }
    if let ParticleStyle::Plexus { max_distance, .. } = style {
        for (first, second) in super::neighbors::pairs(&states, *max_distance, 100_000_000)? {
            append(states[first], states[second].position, true)?;
        }
    }
    let mut bounds = Rect::default();
    for (index, instance) in instances.iter().enumerate() {
        let particle = instance.particle;
        let reach =
            (radius * f64::from(particle.size) * if kind == 1 { 3.0 } else { 2.0 }).max(width);
        let left = f64::from(particle.position[0].min(instance.previous[0])) - reach;
        let top = f64::from(particle.position[1].min(instance.previous[1])) - reach;
        let right = f64::from(particle.position[0].max(instance.previous[0])) + reach;
        let bottom = f64::from(particle.position[1].max(instance.previous[1])) + reach;
        let extent = Rect::new(left, top, right, bottom);
        bounds = if index == 0 {
            extent
        } else {
            bounds.union(extent)
        };
    }
    let instances: Arc<[Instance]> = instances.into();
    let index = build_spatial_index(kind, radius as f32, width as f32, &instances);
    let batch = ParticleBatch {
        instances: Instances::ready(instances, index),
        kind,
        radius: radius as f32,
        width: width as f32,
        align,
        sprite,
        bounds,
        analytic,
    };
    batch.validate()?;
    Ok(Node::from(batch))
}

impl ParticleSimulationBuilder {
    /// Sets presentation; it does not affect the physics snapshots.
    pub fn style(mut self, style: ParticleStyle) -> Self {
        self.style = Arc::new(style);
        self
    }
}

impl ParticleSimulation {
    /// Renders through the instance protocol using explicit clip metadata.
    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
        let duration = context.clip_duration();
        // Trail history is queried in ascending time, so one cursor steps through it once
        // instead of replaying from a retained snapshot for every historical sample.
        let mut cursor = super::simulation::SampleCursor::default();
        let states = self.sample_with(&mut cursor, context.time(), duration)?;
        render(&self.style, context, states, None, None, |time| {
            self.sample_with(&mut cursor, time, duration)
        })
    }
}

impl ParticleTrajectory {
    /// Replaces presentation without adding simulation state.
    pub fn style(mut self, style: ParticleStyle) -> Self {
        self.style = Arc::new(style);
        self
    }

    /// Retains CPU reference states and attaches eligible seeded analytic GPU evaluation.
    pub fn try_render(&self, context: &SceneContext<'_>) -> Result<Node, ValidationError> {
        let primitive = match self.style.as_ref() {
            ParticleStyle::Dots { .. } => Some((motion_core::instances::InstanceKind::Disc, false)),
            ParticleStyle::GlowDots { .. } => {
                Some((motion_core::instances::InstanceKind::Glow, false))
            }
            ParticleStyle::Sprites {
                align_to_velocity, ..
            } => Some((
                motion_core::instances::InstanceKind::Sprite,
                *align_to_velocity,
            )),
            _ => None,
        };
        let analytic = match primitive
            .map(|(kind, align)| {
                self.model
                    .analytic_upload(context.time().as_seconds() as f32, kind, align)
            })
            .transpose()
        {
            Ok(upload) => upload.flatten().map(Ok),
            Err(error) if error.kind == motion_core::ValidationErrorKind::Unsupported => {
                Some(Err(error))
            }
            Err(error) => return Err(error),
        };
        let deferred = match (&analytic, primitive) {
            (Some(Ok(_)), Some(_)) => self
                .model
                .analytic_bounds(context.time().as_seconds() as f32)?
                .map(|bounds| {
                    let trajectory = self.clone();
                    let time = context.time();
                    Deferred {
                        bounds,
                        source: Arc::new(move || trajectory.sample(time)),
                    }
                }),
            _ => None,
        };
        let states = if deferred.is_some() {
            Arc::from([])
        } else {
            self.sample(context.time())?
        };
        render(&self.style, context, states, analytic, deferred, |time| {
            self.sample(time)
        })
    }
}

impl motion_core::authoring::Scene for ParticleSimulation {
    fn render(&self, context: &SceneContext<'_>) -> Node {
        self.try_render(context)
            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
    }

    fn duration(&self) -> Option<Duration> {
        self.natural_duration()
    }
}

impl motion_core::authoring::Scene for ParticleTrajectory {
    fn render(&self, context: &SceneContext<'_>) -> Node {
        self.try_render(context)
            .unwrap_or_else(motion_core::authoring::transition::diagnostic)
    }

    fn duration(&self) -> Option<Duration> {
        self.model.duration()
    }
}

#[cfg(test)]
mod tests;