1use super::*;
4use motion_core::{Drawable, Point, Rect, Transform};
5
6#[derive(Clone, Debug, PartialEq)]
7pub(super) struct Instance {
8 pub(super) particle: Particle,
9 pub(super) previous: [f32; 2],
10 pub(super) line: bool,
11 pub(super) line_bounds: [f32; 4],
12 pub(super) line_guard: f64,
13}
14
15impl Instance {
16 pub(super) fn new(particle: Particle, previous: [f32; 2], line: bool) -> Self {
17 let line_bounds = if line {
18 [
19 previous[0].min(particle.position[0]),
20 previous[1].min(particle.position[1]),
21 previous[0].max(particle.position[0]),
22 previous[1].max(particle.position[1]),
23 ]
24 } else {
25 [0.0; 4]
26 };
27 let line_guard = if line {
28 previous
29 .map(f64::from)
30 .into_iter()
31 .chain(particle.position.map(f64::from))
32 .fold(1.0_f64, |scale, coordinate| scale.max(coordinate.abs()))
33 * (128.0 * f64::EPSILON)
34 } else {
35 0.0
36 };
37 Self {
38 particle,
39 previous,
40 line,
41 line_bounds,
42 line_guard,
43 }
44 }
45}
46
47#[derive(Clone, Debug)]
48pub(super) struct SpatialNode {
49 pub(super) bounds: Rect,
50 pub(super) guard: f64,
51 pub(super) end: usize,
52 pub(super) next: usize,
53 pub(super) leaf: bool,
54}
55
56impl SpatialNode {
57 pub(super) fn outside(&self, point: Point, margin: f64, point_guard: f64) -> bool {
58 let padding = margin + self.guard + point_guard;
59 point.x < self.bounds.left - padding
60 || point.x > self.bounds.right + padding
61 || point.y < self.bounds.top - padding
62 || point.y > self.bounds.bottom + padding
63 }
64}
65
66#[derive(Clone, Debug)]
68pub struct ParticleBatch {
69 pub(super) instances: Arc<[Instance]>,
70 pub(super) kind: u32,
71 pub(super) radius: f32,
72 pub(super) width: f32,
73 pub(super) align: bool,
74 pub(super) sprite: Option<Arc<crate::ImageAsset>>,
75 pub(super) bounds: Rect,
76 pub(super) index: Arc<[SpatialNode]>,
77}
78
79impl PartialEq for ParticleBatch {
80 fn eq(&self, other: &Self) -> bool {
81 self.instances == other.instances
82 && self.kind == other.kind
83 && self.radius == other.radius
84 && self.width == other.width
85 && self.align == other.align
86 && self.sprite.as_ref().map(Arc::as_ptr) == other.sprite.as_ref().map(Arc::as_ptr)
87 && self.bounds == other.bounds
88 }
89}
90
91impl ParticleBatch {
92 pub fn len(&self) -> usize {
94 self.instances.len()
95 }
96
97 pub fn is_empty(&self) -> bool {
99 self.instances.is_empty()
100 }
101
102 pub fn bounds(&self) -> Rect {
104 self.bounds
105 }
106
107 pub(crate) fn bytes(&self) -> u64 {
108 self.instances.len() as u64 * std::mem::size_of::<Instance>() as u64
109 + self.index.len() as u64 * std::mem::size_of::<SpatialNode>() as u64
110 + std::mem::size_of::<Arc<[SpatialNode]>>() as u64
111 + self.sprite.as_ref().map_or(0, |sprite| {
112 u64::from(sprite.width()) * u64::from(sprite.height()) * 16
113 })
114 }
115
116 pub(crate) fn work(&self) -> u64 {
117 self.instances.len() as u64 * 64 + self.index.len() as u64 * 16 + 1
118 }
119
120 pub(super) fn build_spatial_index(&self) -> Arc<[SpatialNode]> {
121 if self.kind == 2 || self.instances.len() < 32 || self.instances.len() > MAX_INSTANCE_COUNT
122 {
123 return Arc::from([]);
124 }
125
126 fn append(batch: &ParticleBatch, nodes: &mut Vec<SpatialNode>, first: usize, end: usize) {
127 let mut bounds = Rect::new(
128 f64::INFINITY,
129 f64::INFINITY,
130 f64::NEG_INFINITY,
131 f64::NEG_INFINITY,
132 );
133 for instance in &batch.instances[first..end] {
134 let particle = instance.particle;
135 let radius = f64::from(batch.radius * particle.size);
136 let extent = if instance.line {
137 let reach = f64::from(batch.width) * 0.5;
138 Rect::new(
139 f64::from(instance.line_bounds[0]) - reach,
140 f64::from(instance.line_bounds[1]) - reach,
141 f64::from(instance.line_bounds[2]) + reach,
142 f64::from(instance.line_bounds[3]) + reach,
143 )
144 } else {
145 let reach = radius * if batch.kind == 1 { 3.0 } else { 1.0 };
146 Rect::new(
147 f64::from(particle.position[0]) - reach,
148 f64::from(particle.position[1]) - reach,
149 f64::from(particle.position[0]) + reach,
150 f64::from(particle.position[1]) + reach,
151 )
152 };
153 bounds.left = bounds.left.min(extent.left);
154 bounds.top = bounds.top.min(extent.top);
155 bounds.right = bounds.right.max(extent.right);
156 bounds.bottom = bounds.bottom.max(extent.bottom);
157 }
158 let guard = [bounds.left, bounds.top, bounds.right, bounds.bottom]
159 .into_iter()
160 .fold(1.0_f64, |scale, coordinate| scale.max(coordinate.abs()))
161 * (256.0 * f64::EPSILON);
162 let current = nodes.len();
163 let leaf = end - first <= 8;
164 nodes.push(SpatialNode {
165 bounds,
166 guard,
167 end,
168 next: 0,
169 leaf,
170 });
171 if !leaf {
172 let middle = first + (end - first) / 2;
173 append(batch, nodes, first, middle);
174 append(batch, nodes, middle, end);
175 }
176 nodes[current].next = nodes.len();
177 }
178 let mut nodes = Vec::with_capacity(self.instances.len().div_ceil(8) * 4);
179 append(self, &mut nodes, 0, self.instances.len());
180 nodes.into()
181 }
182
183 pub(crate) fn validate(&self) -> Result<(), ValidationError> {
184 if self.instances.len() > MAX_INSTANCE_COUNT
185 || !self.radius.is_finite()
186 || self.radius < 0.0
187 || self.radius > 4096.0
188 || !self.width.is_finite()
189 || self.width < 0.0
190 || self.width > 4096.0
191 {
192 return Err(invalid("style", "Invalid particle style size/count"));
193 }
194 for instance in self.instances.iter() {
195 instance.particle.validate()?;
196 }
197 motion_core::drawing::validate_rect(self.bounds, "particles", "bounds")
198 }
199
200 pub(crate) fn sample(
201 &self,
202 point: Point,
203 antialias: f64,
204 destination: [f32; 4],
205 space: motion_core::drawing::BlendSpace,
206 composite: motion_core::instances::InstanceComposite,
207 ) -> Result<[f32; 4], ValidationError> {
208 self.sample_with_zero_policy::<false>(point, antialias, destination, space, composite)
209 }
210
211 pub(super) fn sample_with_zero_policy<const ZERO_IS_IDENTITY: bool>(
212 &self,
213 point: Point,
214 antialias: f64,
215 destination: [f32; 4],
216 space: motion_core::drawing::BlendSpace,
217 composite: motion_core::instances::InstanceComposite,
218 ) -> Result<[f32; 4], ValidationError> {
219 self.sample_selected::<ZERO_IS_IDENTITY>(
220 point,
221 antialias,
222 destination,
223 space,
224 composite,
225 None,
226 )
227 }
228
229 fn sample_selected<const ZERO_IS_IDENTITY: bool>(
230 &self,
231 point: Point,
232 antialias: f64,
233 mut destination: [f32; 4],
234 space: motion_core::drawing::BlendSpace,
235 composite: motion_core::instances::InstanceComposite,
236 selection: Option<&[usize]>,
237 ) -> Result<[f32; 4], ValidationError> {
238 let bounded = self.kind != 1
239 && self.kind != 2
240 && antialias.is_finite()
241 && antialias > 0.0
242 && point.x.is_finite()
243 && point.y.is_finite();
244 let point_guard = if bounded {
245 point.x.abs().max(point.y.abs()).max(1.0) * (128.0 * f64::EPSILON)
246 } else {
247 0.0
248 };
249 let indexed = selection.is_none()
250 && ZERO_IS_IDENTITY
251 && !self.index.is_empty()
252 && antialias.is_finite()
253 && antialias > 0.0
254 && point.x.is_finite()
255 && point.y.is_finite();
256 let index_guard = if indexed {
257 point.x.abs().max(point.y.abs()).max(1.0) * (256.0 * f64::EPSILON)
258 } else {
259 0.0
260 };
261 let mut cursor = 0;
262 let mut position = 0;
263 let count = selection.map_or(self.instances.len(), <[usize]>::len);
264 let mut leaf_end = if indexed { 0 } else { count };
265 while position < count {
266 if indexed && position == leaf_end {
267 loop {
268 let node = &self.index[cursor];
269 if node.outside(point, antialias * 0.5, index_guard) {
270 position = node.end;
271 cursor = node.next;
272 if position == self.instances.len() {
273 break;
274 }
275 } else {
276 cursor += 1;
277 if node.leaf {
278 leaf_end = node.end;
279 break;
280 }
281 }
282 }
283 if position == self.instances.len() {
284 break;
285 }
286 }
287 let instance = &self.instances[selection.map_or(position, |indices| indices[position])];
288 position += 1;
289 let particle = &instance.particle;
290 let radius = f64::from(self.radius * particle.size);
291 if radius == 0.0 || particle.color[3] == 0.0 {
292 continue;
293 }
294 let relative = [
295 point.x - f64::from(particle.position[0]),
296 point.y - f64::from(particle.position[1]),
297 ];
298 let mut color = particle.color;
299 let coverage = if instance.line {
300 let guard = instance.line_guard + point_guard;
301 let padding = f64::from(self.width) * 0.5 + antialias * 0.5 + guard;
302 if bounded
303 && (point.x < f64::from(instance.line_bounds[0]) - padding
304 || point.x > f64::from(instance.line_bounds[2]) + padding
305 || point.y < f64::from(instance.line_bounds[1]) - padding
306 || point.y > f64::from(instance.line_bounds[3]) + padding)
307 {
308 0.0
309 } else {
310 let start = instance.previous.map(f64::from);
311 let end = particle.position.map(f64::from);
312 let delta = [end[0] - start[0], end[1] - start[1]];
313 let square = delta[0] * delta[0] + delta[1] * delta[1];
314 let progress = if square == 0.0 {
315 0.0
316 } else {
317 ((point.x - start[0]) * delta[0] + (point.y - start[1]) * delta[1]) / square
318 }
319 .clamp(0.0, 1.0);
320 let distance = libm::hypot(
321 point.x - start[0] - delta[0] * progress,
322 point.y - start[1] - delta[1] * progress,
323 );
324 ((f64::from(self.width) * 0.5 - distance) / antialias + 0.5).clamp(0.0, 1.0)
325 }
326 } else if self.kind == 1 {
327 let square =
328 (relative[0] * relative[0] + relative[1] * relative[1]) / (radius * radius);
329 if square > 9.0 {
330 0.0
331 } else {
332 libm::exp(-2.0 * square)
333 }
334 } else if self.kind == 2 {
335 let angle = f64::from(particle.rotation)
336 + if self.align && particle.velocity != [0.0; 2] {
337 libm::atan2(
338 f64::from(particle.velocity[1]),
339 f64::from(particle.velocity[0]),
340 )
341 } else {
342 0.0
343 };
344 let cosine = libm::cos(angle);
345 let sine = libm::sin(angle);
346 let local = [
347 relative[0] * cosine + relative[1] * sine,
348 -relative[0] * sine + relative[1] * cosine,
349 ];
350 if local[0].abs() >= radius || local[1].abs() >= radius {
351 0.0
352 } else {
353 let sprite = self.sprite.as_ref().expect("sprite material");
354 let sample = sprite_sample(
355 sprite,
356 local[0] / (2.0 * radius) + 0.5,
357 local[1] / (2.0 * radius) + 0.5,
358 );
359 color = std::array::from_fn(|channel| color[channel] * sample[channel]);
360 1.0
361 }
362 } else {
363 let reach = radius + antialias * 0.5;
364 let guard = reach.abs().max(1.0) * (128.0 * f64::EPSILON) + point_guard;
365 if bounded && relative[0].abs().max(relative[1].abs()) > reach + guard {
366 0.0
367 } else {
368 ((radius - libm::hypot(relative[0], relative[1])) / antialias + 0.5)
369 .clamp(0.0, 1.0)
370 }
371 } as f32;
372 let source = color.map(|channel| channel * coverage);
373 if ZERO_IS_IDENTITY && source == [0.0; 4] {
374 continue;
375 }
376 composite(&mut destination, source, space)?;
377 }
378 Ok(destination)
379 }
380
381 #[cfg(feature = "gpu")]
382 pub(crate) fn upload_bytes(&self) -> u64 {
383 (4 + self.instances.len() as u64 * 12
384 + self.sprite.as_ref().map_or(0, |sprite| {
385 u64::from(sprite.width()) * u64::from(sprite.height()) * 4
386 }))
387 * 4
388 }
389
390 pub(super) fn blend(&self) -> motion_core::instances::InstanceBlend {
392 if self.kind == 1 {
393 motion_core::instances::InstanceBlend::Plus
394 } else {
395 motion_core::instances::InstanceBlend::SourceOver
396 }
397 }
398
399 #[cfg(feature = "gpu")]
400 pub(crate) fn upload(&self) -> Result<motion_core::instances::InstanceUpload, ValidationError> {
401 use motion_core::instances::{InstanceKind, InstanceRecord, InstanceUpload};
402
403 let sprite_size = self
404 .sprite
405 .as_ref()
406 .map_or([0, 0], |sprite| [sprite.width(), sprite.height()]);
407 let records = self.instances.iter().map(|instance| {
408 let particle = instance.particle;
409 let rotation = f64::from(particle.rotation)
410 + if self.align && particle.velocity != [0.0; 2] {
411 libm::atan2(
412 f64::from(particle.velocity[1]),
413 f64::from(particle.velocity[0]),
414 )
415 } else {
416 0.0
417 };
418 InstanceRecord {
419 position: particle.position,
420 start: instance.previous,
421 color: particle.color,
422 radius: self.radius * particle.size,
423 rotation: rotation as f32,
424 width: self.width,
425 kind: if instance.line {
426 InstanceKind::Segment
427 } else {
428 match self.kind {
429 1 => InstanceKind::Glow,
430 2 => InstanceKind::Sprite,
431 _ => InstanceKind::Disc,
432 }
433 },
434 }
435 });
436 let texels = self.sprite.iter().flat_map(|sprite| {
437 (0..sprite.height()).flat_map(move |row| {
438 (0..sprite.width()).map(move |column| {
439 sprite.levels()[0]
440 .pixel(column, row)
441 .expect("source texture bounds")
442 })
443 })
444 });
445 InstanceUpload::new_blended(records, sprite_size, texels, self.blend())
446 }
447}
448
449impl motion_core::instances::InstanceBatch for ParticleBatch {
450 fn blend(&self) -> motion_core::instances::InstanceBlend {
451 ParticleBatch::blend(self)
452 }
453
454 fn len(&self) -> usize {
455 self.len()
456 }
457
458 fn bounds(&self) -> Rect {
459 self.bounds()
460 }
461
462 fn bytes(&self) -> u64 {
463 self.bytes()
464 }
465
466 fn work(&self) -> u64 {
467 self.work()
468 }
469
470 fn spatial(&self) -> Option<&dyn motion_core::instances::SpatialInstances> {
471 Some(self)
472 }
473
474 fn validate(&self) -> Result<(), ValidationError> {
475 self.validate()
476 }
477
478 fn sample(
479 &self,
480 point: Point,
481 antialias: f64,
482 destination: [f32; 4],
483 space: motion_core::BlendSpace,
484 composite: motion_core::instances::InstanceComposite,
485 ) -> Result<[f32; 4], ValidationError> {
486 self.sample(point, antialias, destination, space, composite)
487 }
488
489 fn sample_with_zero_identity(
490 &self,
491 point: Point,
492 antialias: f64,
493 destination: [f32; 4],
494 space: motion_core::BlendSpace,
495 composite: motion_core::instances::InstanceComposite,
496 ) -> Result<[f32; 4], ValidationError> {
497 self.sample_with_zero_policy::<true>(point, antialias, destination, space, composite)
498 }
499
500 #[cfg(feature = "gpu")]
501 fn upload_bytes(&self) -> u64 {
502 self.upload_bytes()
503 }
504
505 #[cfg(feature = "gpu")]
506 fn upload(&self) -> Result<Option<motion_core::instances::InstanceUpload>, ValidationError> {
507 self.upload().map(Some)
508 }
509
510 fn as_any(&self) -> &dyn std::any::Any {
511 self
512 }
513
514 fn content_eq(&self, other: &dyn motion_core::instances::InstanceBatch) -> bool {
515 other
516 .as_any()
517 .downcast_ref::<Self>()
518 .is_some_and(|other| self == other)
519 }
520}
521
522impl motion_core::instances::SpatialInstances for ParticleBatch {
523 fn support(&self, index: usize, antialias: f64) -> Rect {
524 let instance = &self.instances[index];
525 let particle = instance.particle;
526 let radius = f64::from(self.radius * particle.size);
527 if radius == 0.0 || particle.color[3] == 0.0 {
528 return Rect::new(0.0, 0.0, 0.0, 0.0);
529 }
530 let padding = antialias * 0.5;
531 let (left, top, right, bottom) = if instance.line {
532 let reach = f64::from(self.width) * 0.5 + padding + instance.line_guard;
533 (
534 f64::from(instance.line_bounds[0]) - reach,
535 f64::from(instance.line_bounds[1]) - reach,
536 f64::from(instance.line_bounds[2]) + reach,
537 f64::from(instance.line_bounds[3]) + reach,
538 )
539 } else {
540 let reach = radius
541 * match self.kind {
542 1 => 3.0,
543 2 => 2.0,
544 _ => 1.0,
545 }
546 + padding;
547 (
548 f64::from(particle.position[0]) - reach,
549 f64::from(particle.position[1]) - reach,
550 f64::from(particle.position[0]) + reach,
551 f64::from(particle.position[1]) + reach,
552 )
553 };
554 let guard = [left, top, right, bottom]
555 .into_iter()
556 .fold(1.0_f64, |scale, value| scale.max(value.abs()))
557 * (4096.0 * f64::EPSILON);
558 Rect::new(left - guard, top - guard, right + guard, bottom + guard)
559 }
560
561 fn sample_indices(
562 &self,
563 indices: &[usize],
564 point: Point,
565 antialias: f64,
566 destination: [f32; 4],
567 space: motion_core::BlendSpace,
568 composite: motion_core::instances::InstanceComposite,
569 ) -> Result<[f32; 4], ValidationError> {
570 self.sample_selected::<true>(
571 point,
572 antialias,
573 destination,
574 space,
575 composite,
576 Some(indices),
577 )
578 }
579}
580
581fn sprite_sample(sprite: &crate::ImageAsset, horizontal: f64, vertical: f64) -> [f32; 4] {
582 let width = sprite.width() as usize;
583 let height = sprite.height() as usize;
584 let horizontal = horizontal * width as f64 - 0.5;
585 let vertical = vertical * height as f64 - 0.5;
586 let left = libm::floor(horizontal) as i64;
587 let top = libm::floor(vertical) as i64;
588 let fraction = [
589 (horizontal - left as f64) as f32,
590 (vertical - top as f64) as f32,
591 ];
592 let texel = |column: i64, row: i64| {
593 sprite.levels()[0]
594 .pixel(
595 column.clamp(0, width as i64 - 1) as u32,
596 row.clamp(0, height as i64 - 1) as u32,
597 )
598 .expect("source texture bounds")
599 };
600 let top_left = texel(left, top);
601 let top_right = texel(left + 1, top);
602 let bottom_left = texel(left, top + 1);
603 let bottom_right = texel(left + 1, top + 1);
604 std::array::from_fn(|channel| {
605 (top_left[channel] * (1.0 - fraction[0]) + top_right[channel] * fraction[0])
606 * (1.0 - fraction[1])
607 + (bottom_left[channel] * (1.0 - fraction[0]) + bottom_right[channel] * fraction[0])
608 * fraction[1]
609 })
610}
611
612impl Drawable for ParticleBatch {
613 fn bounds(&self, context: &motion_core::DrawContext) -> Rect {
614 let margin = 1.0 / context.device_scale();
615 Rect::new(
616 self.bounds.left - margin,
617 self.bounds.top - margin,
618 self.bounds.right + margin,
619 self.bounds.bottom + margin,
620 )
621 }
622
623 fn draw(
624 &self,
625 _: &motion_core::DrawContext,
626 canvas: &mut motion_core::Canvas<'_>,
627 ) -> Result<(), ValidationError> {
628 canvas.instances(Arc::new(self.clone()), Transform::IDENTITY)
629 }
630}