1use crate::math::{add2, clamp2, dot2, len2, scale2, sign, smoothstep, sub2};
2use crate::shape::{
3 DRAIN_TIME, KERNEL, Look, MAX_PARTICLES, MAX_TARGETS, Params, Particle, Pour, SLOTS, SPACING,
4 SUBSTEP, Target, along, cells_for, poly6, region, rest_density, spawn, spiky,
5};
6
7#[path = "fluid_pipeline.rs"]
8mod pipeline;
9use pfx_gpu::GpuProfiler;
10use pipeline::Pipeline;
11#[path = "fluid_inspect.rs"]
12mod inspect;
13
14pub const SPH_WGSL: &str = include_str!("sph.wgsl");
15pub const BLUR_WGSL: &str = include_str!("fluid_blur.wgsl");
16pub const JFA_WGSL: &str = include_str!("fluid_jfa.wgsl");
17pub const EDGE_WGSL: &str = include_str!("fluid_edge.wgsl");
18pub const RAISE_WGSL: &str = include_str!("fluid_raise.wgsl");
19
20const BLOCK: u32 = 256;
21const SETTLE: f32 = 2.0;
22
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct Smoothing {
25 pub wide: f32,
26 pub fine: f32,
27 pub pool: f32,
28}
29
30impl Default for Smoothing {
31 fn default() -> Self {
32 Self {
33 wide: 10.0,
34 fine: 2.0,
35 pool: 20.0,
36 }
37 }
38}
39
40impl Smoothing {
41 pub fn residuals(self) -> [f32; 2] {
42 [
43 ((self.wide * self.wide - self.fine * self.fine).max(0.25)).sqrt(),
44 ((self.pool * self.pool - self.fine * self.fine).max(0.25)).sqrt(),
45 ]
46 }
47}
48
49#[derive(Clone, Copy, Debug, PartialEq)]
50pub struct Wire {
51 pub radius: f32,
52 pub lift: f32,
53 pub period: f32,
54}
55
56impl Default for Wire {
57 fn default() -> Self {
58 Self {
59 radius: 2.0,
60 lift: 4.0,
61 period: 3.0,
62 }
63 }
64}
65
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct Outline {
68 pub shape: [f32; 4],
69 pub size: [f32; 4],
70}
71
72impl Default for Outline {
73 fn default() -> Self {
74 Self {
75 shape: [8.0, 16.0, 0.0, 4.0],
76 size: [6.0, 16.0, 0.3, 0.0],
77 }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq)]
82pub struct FluidState {
83 pub live: u32,
84 pub asleep: u32,
85 pub bounds: Option<([f32; 2], [f32; 2])>,
86 pub height_min: f32,
87 pub height_max: f32,
88 pub height_mean: f32,
89 pub channel_histogram: [std::collections::BTreeMap<i32, u32>; 3],
90 pub resting: bool,
91 pub row: Option<Vec<f32>>,
92}
93
94#[repr(C)]
95#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
96struct Uniform {
97 grid: [f32; 4],
98 table: [f32; 4],
99 k: [f32; 4],
100 misc: [f32; 4],
101 apart: [f32; 4],
102 tension: [f32; 4],
103 clock: [f32; 4],
104}
105
106pub struct Reference {
107 particles: Vec<Particle>,
108 targets: Vec<Target>,
109 table: [f32; 2],
110 params: Params,
111 rest: f32,
112 clock: f32,
113}
114
115impl Reference {
116 pub fn bead(center: [f32; 2], radius: f32, table: [f32; 2]) -> Self {
117 let look = Look {
118 tall: 1.0,
119 active: 0.0,
120 weight_b: 0.0,
121 weight_a: 0.0,
122 };
123 let target = Target::bead(center, radius, &look);
124 Self {
125 particles: spawn(&target, 0, None, 1),
126 targets: vec![target],
127 table,
128 params: Params::default(),
129 rest: rest_density(),
130 clock: 0.0,
131 }
132 }
133
134 pub fn count(&self) -> usize {
135 self.particles.iter().filter(|p| p.born >= 0.0).count()
136 }
137
138 pub fn particles(&self) -> &[Particle] {
139 &self.particles
140 }
141
142 pub fn step(&mut self, dt: f32, time: f32, calm: f32) {
143 if !dt.is_finite() || dt <= 0.0 {
144 return;
145 }
146 self.clock = (self.clock + dt).min(SUBSTEP * 4.0);
147 let steps = (self.clock / SUBSTEP).floor() as usize;
148 self.clock -= steps as f32 * SUBSTEP;
149 for _ in 0..steps {
150 step_once(
151 &mut self.particles,
152 &self.targets,
153 self.table,
154 &self.params,
155 self.rest,
156 time,
157 calm,
158 );
159 }
160 }
161}
162
163fn step_once(
164 particles: &mut [Particle],
165 targets: &[Target],
166 table: [f32; 2],
167 params: &Params,
168 rest: f32,
169 time: f32,
170 calm: f32,
171) {
172 let cells = cells_for(table);
173 let Binned {
174 counts,
175 starts,
176 items,
177 } = bin(particles, cells);
178 let neighbors = Neighbors {
179 counts: &counts,
180 starts: &starts,
181 items: &items,
182 cells,
183 };
184 for i in 0..particles.len() {
185 if particles[i].born < 0.0 {
186 continue;
187 }
188 particles[i].rho = density(i, particles, &neighbors).max(rest * 0.05);
189 }
190 let accel: Vec<[f32; 2]> = (0..particles.len())
191 .map(|i| {
192 if particles[i].born < 0.0 {
193 [0.0; 2]
194 } else {
195 force(i, particles, targets, &neighbors, params, time, calm)
196 }
197 })
198 .collect();
199 for (i, particle) in particles.iter_mut().enumerate() {
200 if particle.born < 0.0 {
201 continue;
202 }
203 let Some(target) = targets.get(particle.owner as usize) else {
204 continue;
205 };
206 let mut vel = scale2(particle.vel, (-params.damping * SUBSTEP).exp());
207 vel = add2(vel, scale2(accel[i], SUBSTEP));
208 let speed = len2(vel);
209 if speed > 900.0 {
210 vel = scale2(vel, 900.0 / speed);
211 }
212 let pos = clamp2(
213 add2(
214 particle.pos,
215 scale2(add2(vel, [target.carry[0], target.carry[1]]), SUBSTEP),
216 ),
217 [1.0, 1.0],
218 [table[0] - 1.0, table[1] - 1.0],
219 );
220 let mut goal = if target.flow[3] > 0.5 {
221 target.flow[0]
222 } else {
223 0.0
224 };
225 if target.kind() == 2 && target.carry[3] > 0.5 {
226 let curve = along(target, pos);
227 goal *= smoothstep(target.carry[2] + 0.05, target.carry[2] - 0.05, curve[0]);
228 }
229 let rate = params.life_rate * SUBSTEP;
230 let life = (particle.life + (goal - particle.life).clamp(-rate, rate)).clamp(0.0, 4.0);
231 particle.vel = vel;
232 particle.pos = pos;
233 particle.life = life;
234 }
235}
236
237fn cell_of(p: [f32; 2], cells: [usize; 2]) -> [usize; 2] {
238 [
239 (p[0] / KERNEL).floor().clamp(0.0, cells[0] as f32 - 1.0) as usize,
240 (p[1] / KERNEL).floor().clamp(0.0, cells[1] as f32 - 1.0) as usize,
241 ]
242}
243
244fn binned_cell(particle: &Particle, cells: [usize; 2]) -> Option<usize> {
245 if particle.born < 0.0 || particle.life <= 1e-3 {
246 return None;
247 }
248 let c = cell_of(particle.pos, cells);
249 Some(c[1] * cells[0] + c[0])
250}
251
252fn exclusive_scan(counts: &[u32]) -> Vec<u32> {
253 let mut total = 0;
254 counts
255 .iter()
256 .map(|count| {
257 let start = total;
258 total += count;
259 start
260 })
261 .collect()
262}
263
264struct Binned {
265 counts: Vec<u32>,
266 starts: Vec<u32>,
267 items: Vec<u32>,
268}
269
270fn bin(particles: &[Particle], cells: [usize; 2]) -> Binned {
271 let mut counts = vec![0u32; cells[0] * cells[1]];
272 for particle in particles {
273 if let Some(id) = binned_cell(particle, cells) {
274 counts[id] += 1;
275 }
276 }
277 let starts = exclusive_scan(&counts);
278 let mut items = vec![0u32; counts.iter().sum::<u32>() as usize];
279 let mut next = starts.clone();
280 for (i, particle) in particles.iter().enumerate() {
281 if let Some(id) = binned_cell(particle, cells) {
282 items[next[id] as usize] = i as u32;
283 next[id] += 1;
284 }
285 }
286 Binned {
287 counts,
288 starts,
289 items,
290 }
291}
292
293struct Neighbors<'a> {
294 counts: &'a [u32],
295 starts: &'a [u32],
296 items: &'a [u32],
297 cells: [usize; 2],
298}
299
300fn visit_cell(home: [usize; 2], span: i32, neighbors: &Neighbors, mut body: impl FnMut(usize)) {
301 for dy in -span..=span {
302 for dx in -span..=span {
303 let c = [home[0] as i32 + dx, home[1] as i32 + dy];
304 if c[0] < 0
305 || c[1] < 0
306 || c[0] >= neighbors.cells[0] as i32
307 || c[1] >= neighbors.cells[1] as i32
308 {
309 continue;
310 }
311 let id = c[1] as usize * neighbors.cells[0] + c[0] as usize;
312 let start = neighbors.starts[id] as usize;
313 let n = neighbors.counts[id].min(SLOTS) as usize;
314 for &item in &neighbors.items[start..start + n] {
315 body(item as usize);
316 }
317 }
318 }
319}
320
321fn density(i: usize, particles: &[Particle], neighbors: &Neighbors) -> f32 {
322 let home = cell_of(particles[i].pos, neighbors.cells);
323 let mut rho = 0.0;
324 visit_cell(home, 1, neighbors, |j| {
325 let d = sub2(particles[i].pos, particles[j].pos);
326 rho += particles[j].life * poly6(dot2(d, d));
327 });
328 rho
329}
330
331fn force(
332 i: usize,
333 particles: &[Particle],
334 targets: &[Target],
335 neighbors: &Neighbors,
336 params: &Params,
337 time: f32,
338 calm: f32,
339) -> [f32; 2] {
340 let p = particles[i];
341 let Some(target) = targets.get(p.owner as usize).copied() else {
342 return [0.0; 2];
343 };
344 let home = cell_of(p.pos, neighbors.cells);
345 let mut accel = [0.0; 2];
346 let mut drag = [0.0; 2];
347 let card = target.kind() == 1;
348 let film = KERNEL * params.apart[1].max(1.0);
349 let span = if card {
350 params.apart[1].max(1.0).ceil() as i32
351 } else {
352 1
353 };
354 visit_cell(home, span, neighbors, |j| {
355 if j == i {
356 return;
357 }
358 let q = particles[j];
359 let d = sub2(p.pos, q.pos);
360 let r = len2(d);
361 if card
362 && q.owner != p.owner
363 && targets
364 .get(q.owner as usize)
365 .is_some_and(|other| other.kind() == 1)
366 {
367 if r < film && r > 1e-4 {
368 let falloff = 1.0 - r / film;
369 let push = params.apart[0] * q.life * p.life * falloff * falloff / r;
370 accel = add2(accel, scale2(d, push));
371 }
372 return;
373 }
374 if r >= KERNEL * 1.5 {
375 return;
376 }
377 if r < 1e-4 {
378 accel[0] += ((i as f32 * 0.618).fract() - 0.5) * 50.0;
379 accel[1] += ((j as f32 * 0.618).fract() - 0.5) * 50.0;
380 return;
381 }
382 let press = params.pressure * q.life * (1.0 / p.rho + 1.0 / q.rho) * spiky(r) / r;
383 accel = sub2(accel, scale2(d, press));
384 drag = add2(
385 drag,
386 scale2(sub2(q.vel, p.vel), q.life * poly6(r * r) / q.rho),
387 );
388 });
389 let shape = region(&target, p.pos, time, calm);
390 let out = [p.pos[0] - shape[1], p.pos[1] - shape[2]];
391 let dir = scale2(out, 1.0 / len2(out).max(1e-3));
392 if target.c[3] > 0.5 {
393 accel = sub2(
394 accel,
395 scale2(dir, params.contain * 0.35 * (shape[0] + target.c[3] - 1.0)),
396 );
397 if target.kind() == 2 {
398 let curve = along(&target, p.pos);
399 let keep = curve[0].clamp(target.form[2], 1.0 - target.form[2]);
400 accel = add2(
401 accel,
402 scale2(
403 [curve[1], curve[2]],
404 params.contain * 0.5 * (keep - curve[0]) * curve[3],
405 ),
406 );
407 }
408 if target.kind() == 0 && target.b[3] > 0.0 {
409 let rel = sub2(p.pos, [target.a[0], target.a[1]]);
410 let delta = rel[1].atan2(rel[0]) - target.b[2];
411 let off = delta.sin().atan2(delta.cos());
412 if off.abs() < target.b[3] {
413 let tangent = scale2([-rel[1], rel[0]], 1.0 / len2(rel).max(1.0));
414 let push = params.contain * 2.0 * sign(off) * (target.b[3] - off.abs()) * len2(rel);
415 accel = add2(accel, scale2(tangent, push));
416 }
417 }
418 } else {
419 if shape[0] > 0.0 {
420 accel = sub2(accel, scale2(dir, params.contain * shape[0]));
421 }
422 let mut hold = 1.0;
423 if target.kind() == 0 && params.tension[0] > 0.0 {
424 hold = params.tension[2]
425 + smoothstep(-params.tension[0], 0.0, shape[0]) * params.tension[1];
426 }
427 accel = sub2(accel, scale2(out, params.cohesion * target.flow[1] * hold));
428 if target.kind() == 2 && target.form[1] != 0.0 {
429 let curve = along(&target, p.pos);
430 let push = params.cohesion
431 * target.flow[1]
432 * target.form[1]
433 * (2.0 * curve[0] - 1.0)
434 * curve[3]
435 * 0.5;
436 accel = add2(accel, scale2([curve[1], curve[2]], push));
437 }
438 }
439 add2(accel, scale2(drag, params.viscosity))
440}
441
442pub struct FluidDesc {
443 pub width: u32,
444 pub height: u32,
445 pub max_particles: u32,
446 pub max_targets: usize,
447 pub params: Params,
448 pub settle: f32,
449}
450
451impl FluidDesc {
452 pub fn new(width: u32, height: u32) -> Self {
453 Self {
454 width,
455 height,
456 ..Self::default()
457 }
458 }
459}
460
461impl Default for FluidDesc {
462 fn default() -> Self {
463 Self {
464 width: 1024,
465 height: 1024,
466 max_particles: MAX_PARTICLES,
467 max_targets: MAX_TARGETS,
468 params: Params::default(),
469 settle: SETTLE,
470 }
471 }
472}
473
474struct Body {
475 key: String,
476 slot: usize,
477 start: u32,
478 count: u32,
479 seen: bool,
480 gone: f32,
481 quiet: f32,
482}
483
484struct Pipes {
485 clear_cells: wgpu::ComputePipeline,
486 count: wgpu::ComputePipeline,
487 scan_cells: wgpu::ComputePipeline,
488 scan_blocks: wgpu::ComputePipeline,
489 scatter: wgpu::ComputePipeline,
490 order: wgpu::ComputePipeline,
491 density: wgpu::ComputePipeline,
492 forces: wgpu::ComputePipeline,
493 integrate: wgpu::ComputePipeline,
494 gather: wgpu::ComputePipeline,
495}
496
497struct Grid {
498 counts: wgpu::Buffer,
499 starts: wgpu::Buffer,
500 items: wgpu::Buffer,
501 ranks: wgpu::Buffer,
502}
503
504pub struct Fluid {
505 particles: wgpu::Buffer,
506 grid: Grid,
507 targets_buffer: wgpu::Buffer,
508 uniform: wgpu::Buffer,
509 bind: wgpu::BindGroup,
510 pipes: Pipes,
511 pipeline: Pipeline,
512 height_texture: wgpu::Texture,
513 height_view: wgpu::TextureView,
514 width: u32,
515 height: u32,
516 cells: [u32; 2],
517 params: Params,
518 bodies: Vec<Body>,
519 free_slots: Vec<usize>,
520 free_ranges: Vec<(u32, u32)>,
521 high: u32,
522 targets: Vec<Target>,
523 clock: f32,
524 rest: f32,
525 still: f32,
526 settle: f32,
527 pub moved: bool,
528 stale: bool,
529 wires: Vec<[f32; 4]>,
530 last_wires: Vec<[f32; 4]>,
531 wire: Wire,
532 smoothing: Smoothing,
533 outline: Outline,
534 wire_changed: bool,
535}
536
537impl Fluid {
538 pub fn new(device: &wgpu::Device, desc: FluidDesc) -> Self {
539 let width = desc.width.max(1);
540 let height = desc.height.max(1);
541 let max_particles = desc.max_particles.max(1);
542 let max_targets = desc.max_targets.max(1);
543 let cells_usize = cells_for([width as f32, height as f32]);
544 let cells = [cells_usize[0] as u32, cells_usize[1] as u32];
545 let cell_total = (cells[0] * cells[1]) as u64;
546 let block_total = cell_total.div_ceil(BLOCK as u64);
547 let storage = |label: &str, size: u64, mapped: bool| {
548 device.create_buffer(&wgpu::BufferDescriptor {
549 label: Some(label),
550 size,
551 usage: wgpu::BufferUsages::STORAGE
552 | wgpu::BufferUsages::COPY_DST
553 | wgpu::BufferUsages::COPY_SRC,
554 mapped_at_creation: mapped,
555 })
556 };
557 let particles = storage(
558 "Particles",
559 max_particles as u64 * std::mem::size_of::<Particle>() as u64,
560 true,
561 );
562 particles
563 .slice(..)
564 .get_mapped_range_mut()
565 .expect("a mapped range")
566 .slice(..)
567 .fill(0);
568 particles.unmap();
569 let targets_buffer = storage(
570 "Targets",
571 max_targets as u64 * std::mem::size_of::<Target>() as u64,
572 true,
573 );
574 targets_buffer
575 .slice(..)
576 .get_mapped_range_mut()
577 .expect("a mapped range")
578 .slice(..)
579 .fill(0);
580 targets_buffer.unmap();
581 let grid = Grid {
582 counts: storage("Cell counts", cell_total * 4, false),
583 starts: storage("Cell starts", (cell_total + block_total) * 4, false),
584 items: storage("Cell items", max_particles as u64 * 4, false),
585 ranks: storage("Cell ranks", max_particles as u64 * 4, false),
586 };
587 let accel = storage("Accelerations", max_particles as u64 * 8, false);
588 let uniform = device.create_buffer(&wgpu::BufferDescriptor {
589 label: Some("Sim"),
590 size: std::mem::size_of::<Uniform>() as u64,
591 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
592 mapped_at_creation: false,
593 });
594 let texture = |label| {
595 device.create_texture(&wgpu::TextureDescriptor {
596 label: Some(label),
597 size: wgpu::Extent3d {
598 width,
599 height,
600 depth_or_array_layers: 1,
601 },
602 mip_level_count: 1,
603 sample_count: 1,
604 dimension: wgpu::TextureDimension::D2,
605 format: wgpu::TextureFormat::Rgba16Float,
606 usage: wgpu::TextureUsages::STORAGE_BINDING
607 | wgpu::TextureUsages::TEXTURE_BINDING
608 | wgpu::TextureUsages::COPY_SRC,
609 view_formats: &[],
610 })
611 };
612 let height_texture = texture("Liquid height");
613 let height_view = height_texture.create_view(&Default::default());
614 let raw_texture = texture("Liquid raw");
615 let raw_view = raw_texture.create_view(&Default::default());
616 let buffer_entry = |binding: u32, read_only: bool| wgpu::BindGroupLayoutEntry {
617 binding,
618 visibility: wgpu::ShaderStages::COMPUTE,
619 ty: wgpu::BindingType::Buffer {
620 ty: wgpu::BufferBindingType::Storage { read_only },
621 has_dynamic_offset: false,
622 min_binding_size: None,
623 },
624 count: None,
625 };
626 let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
627 label: Some("Sim"),
628 entries: &[
629 buffer_entry(0, false),
630 buffer_entry(1, true),
631 buffer_entry(2, false),
632 buffer_entry(3, false),
633 wgpu::BindGroupLayoutEntry {
634 binding: 4,
635 visibility: wgpu::ShaderStages::COMPUTE,
636 ty: wgpu::BindingType::Buffer {
637 ty: wgpu::BufferBindingType::Uniform,
638 has_dynamic_offset: false,
639 min_binding_size: None,
640 },
641 count: None,
642 },
643 buffer_entry(5, false),
644 wgpu::BindGroupLayoutEntry {
645 binding: 6,
646 visibility: wgpu::ShaderStages::COMPUTE,
647 ty: wgpu::BindingType::StorageTexture {
648 access: wgpu::StorageTextureAccess::WriteOnly,
649 format: wgpu::TextureFormat::Rgba16Float,
650 view_dimension: wgpu::TextureViewDimension::D2,
651 },
652 count: None,
653 },
654 buffer_entry(7, false),
655 buffer_entry(8, false),
656 ],
657 });
658 let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
659 label: Some("Sim"),
660 layout: &layout,
661 entries: &[
662 wgpu::BindGroupEntry {
663 binding: 0,
664 resource: particles.as_entire_binding(),
665 },
666 wgpu::BindGroupEntry {
667 binding: 1,
668 resource: targets_buffer.as_entire_binding(),
669 },
670 wgpu::BindGroupEntry {
671 binding: 2,
672 resource: grid.counts.as_entire_binding(),
673 },
674 wgpu::BindGroupEntry {
675 binding: 3,
676 resource: grid.items.as_entire_binding(),
677 },
678 wgpu::BindGroupEntry {
679 binding: 4,
680 resource: uniform.as_entire_binding(),
681 },
682 wgpu::BindGroupEntry {
683 binding: 5,
684 resource: accel.as_entire_binding(),
685 },
686 wgpu::BindGroupEntry {
687 binding: 6,
688 resource: wgpu::BindingResource::TextureView(&raw_view),
689 },
690 wgpu::BindGroupEntry {
691 binding: 7,
692 resource: grid.starts.as_entire_binding(),
693 },
694 wgpu::BindGroupEntry {
695 binding: 8,
696 resource: grid.ranks.as_entire_binding(),
697 },
698 ],
699 });
700 let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
701 label: Some("Sim"),
702 source: wgpu::ShaderSource::Wgsl(SPH_WGSL.into()),
703 });
704 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
705 label: Some("Sim"),
706 bind_group_layouts: &[Some(&layout)],
707 immediate_size: 0,
708 });
709 let pipe = |name: &str| {
710 device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
711 label: Some(name),
712 layout: Some(&pipeline_layout),
713 module: &module,
714 entry_point: Some(name),
715 compilation_options: Default::default(),
716 cache: None,
717 })
718 };
719 let pipeline = Pipeline::new(device, width, height, &raw_view, &height_view);
720 Self {
721 particles,
722 grid,
723 targets_buffer,
724 uniform,
725 bind,
726 pipes: Pipes {
727 clear_cells: pipe("clear_cells"),
728 count: pipe("count"),
729 scan_cells: pipe("scan_cells"),
730 scan_blocks: pipe("scan_blocks"),
731 scatter: pipe("scatter"),
732 order: pipe("order"),
733 density: pipe("density"),
734 forces: pipe("forces"),
735 integrate: pipe("integrate"),
736 gather: pipe("gather"),
737 },
738 pipeline,
739 height_texture,
740 height_view,
741 width,
742 height,
743 cells,
744 params: desc.params,
745 bodies: Vec::new(),
746 free_slots: (0..max_targets).rev().collect(),
747 free_ranges: vec![(0, max_particles)],
748 high: 0,
749 targets: vec![Target::default(); max_targets],
750 clock: 0.0,
751 rest: rest_density(),
752 still: 0.0,
753 settle: if desc.settle.is_finite() {
754 desc.settle.max(0.0)
755 } else {
756 SETTLE
757 },
758 moved: true,
759 stale: true,
760 wires: Vec::new(),
761 last_wires: Vec::new(),
762 wire: Wire::default(),
763 smoothing: Smoothing::default(),
764 outline: Outline::default(),
765 wire_changed: false,
766 }
767 }
768
769 pub fn height(&self) -> &wgpu::TextureView {
770 &self.height_view
771 }
772
773 pub fn edge_view(&self) -> &wgpu::TextureView {
774 self.pipeline.edge_view()
775 }
776
777 pub fn edge_texture(&self) -> &wgpu::Texture {
778 self.pipeline.edge_texture()
779 }
780
781 pub fn settle(&self) -> f32 {
782 self.settle
783 }
784
785 pub fn height_texture(&self) -> &wgpu::Texture {
786 &self.height_texture
787 }
788
789 pub fn particles_buffer(&self) -> &wgpu::Buffer {
790 &self.particles
791 }
792
793 pub fn cell_counts(&self) -> &wgpu::Buffer {
794 &self.grid.counts
795 }
796
797 pub fn cell_starts(&self) -> &wgpu::Buffer {
798 &self.grid.starts
799 }
800
801 pub fn cell_items(&self) -> &wgpu::Buffer {
802 &self.grid.items
803 }
804
805 pub fn particle_count(&self) -> u32 {
806 self.bodies.iter().map(|body| body.count).sum()
807 }
808
809 pub fn set_wires(&mut self, segments: &[[f32; 4]], wire: Wire) {
810 let next = &segments[..segments.len().min(256)];
811 if self.last_wires.len() != next.len()
812 || self
813 .last_wires
814 .iter()
815 .zip(next)
816 .any(|(left, right)| left.iter().zip(right).any(|(a, b)| (a - b).abs() > 0.05))
817 {
818 self.still = 0.0;
819 self.wire_changed = true;
820 self.last_wires = next.to_vec();
821 }
822 if self.wire != wire {
823 self.still = 0.0;
824 self.wire_changed = true;
825 }
826 self.wires.clear();
827 self.wires.extend_from_slice(next);
828 self.wire = wire;
829 }
830
831 pub fn set_smoothing(&mut self, smoothing: Smoothing) {
832 if self.smoothing != smoothing {
833 self.still = 0.0;
834 self.wire_changed = true;
835 }
836 self.smoothing = smoothing;
837 }
838
839 pub fn set_outline(&mut self, outline: Outline) {
840 if self.outline != outline {
841 self.still = 0.0;
842 self.wire_changed = true;
843 }
844 self.outline = outline;
845 }
846
847 pub fn set_field(
848 &mut self,
849 segments: &[[f32; 4]],
850 wire: [f32; 3],
851 smoothing: [f32; 3],
852 outline: [f32; 8],
853 ) {
854 self.set_wires(
855 segments,
856 Wire {
857 radius: wire[0],
858 lift: wire[1],
859 period: wire[2],
860 },
861 );
862 self.set_smoothing(Smoothing {
863 wide: smoothing[0],
864 fine: smoothing[1],
865 pool: smoothing[2],
866 });
867 self.set_outline(Outline {
868 shape: outline[..4].try_into().expect("outline shape"),
869 size: outline[4..].try_into().expect("outline size"),
870 });
871 }
872
873 pub fn sync(&mut self, queue: &wgpu::Queue, pours: &[Pour], dt: f32) {
874 let before = self.targets.clone();
875 let count = self.bodies.len();
876 for body in &mut self.bodies {
877 body.seen = false;
878 }
879 for pour in pours {
880 if let Some(index) = self.bodies.iter().position(|body| body.key == pour.key) {
881 let slot = self.bodies[index].slot;
882 let next = pour.target.carried(&self.targets[slot], dt);
883 let moved = target_drift(&self.targets[slot], &next) > 0.05;
884 let body = &mut self.bodies[index];
885 body.seen = true;
886 body.gone = 0.0;
887 body.quiet = if moved { 0.0 } else { body.quiet + dt };
888 self.targets[slot] = next;
889 continue;
890 }
891 let Some(slot) = self.free_slots.pop() else {
892 continue;
893 };
894 let seed = pour.key.bytes().fold(2_166_136_261u32, |hash, byte| {
895 (hash ^ byte as u32).wrapping_mul(16_777_619)
896 });
897 let particles = spawn(
898 pour.fill.as_ref().unwrap_or(&pour.target),
899 slot,
900 pour.from,
901 seed,
902 );
903 if particles.is_empty() {
904 self.free_slots.push(slot);
905 continue;
906 }
907 let count = particles.len() as u32;
908 let Some(start) = self.allocate(count) else {
909 self.free_slots.push(slot);
910 continue;
911 };
912 queue.write_buffer(
913 &self.particles,
914 start as u64 * std::mem::size_of::<Particle>() as u64,
915 bytemuck::cast_slice(&particles),
916 );
917 self.targets[slot] = pour.target;
918 self.bodies.push(Body {
919 key: pour.key.clone(),
920 slot,
921 start,
922 count,
923 seen: true,
924 gone: 0.0,
925 quiet: 0.0,
926 });
927 }
928 let mut freed = Vec::new();
929 for body in &mut self.bodies {
930 if body.seen {
931 continue;
932 }
933 self.targets[body.slot].flow[3] = 0.0;
934 self.targets[body.slot].carry[0] = 0.0;
935 self.targets[body.slot].carry[1] = 0.0;
936 body.gone += dt;
937 if body.gone > DRAIN_TIME {
938 freed.push(body.key.clone());
939 }
940 }
941 for key in freed {
942 let Some(index) = self.bodies.iter().position(|body| body.key == key) else {
943 continue;
944 };
945 let body = self.bodies.remove(index);
946 let dead = vec![
947 Particle {
948 born: -1.0,
949 ..Particle::default()
950 };
951 body.count as usize
952 ];
953 queue.write_buffer(
954 &self.particles,
955 body.start as u64 * std::mem::size_of::<Particle>() as u64,
956 bytemuck::cast_slice(&dead),
957 );
958 self.release(body.start, body.count);
959 self.free_slots.push(body.slot);
960 }
961 let changed = self.bodies.len() != count
962 || self.bodies.iter().any(|body| body.gone > 0.0)
963 || target_drift_all(&before, &self.targets) > 0.05
964 || self.wire_changed;
965 self.still = if changed { 0.0 } else { self.still + dt };
966 self.wire_changed = false;
967 queue.write_buffer(&self.targets_buffer, 0, bytemuck::cast_slice(&self.targets));
968 }
969
970 pub fn step(
971 &mut self,
972 device: &wgpu::Device,
973 queue: &wgpu::Queue,
974 dt: f32,
975 time: f32,
976 calm: f32,
977 field: bool,
978 ) {
979 let mut encoder =
980 device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("Sim") });
981 self.encode(queue, &mut encoder, dt, time, calm, field, None);
982 queue.submit(Some(encoder.finish()));
983 }
984
985 #[allow(clippy::too_many_arguments)]
986 pub fn encode(
987 &mut self,
988 queue: &wgpu::Queue,
989 encoder: &mut wgpu::CommandEncoder,
990 dt: f32,
991 time: f32,
992 calm: f32,
993 field: bool,
994 mut profiler: Option<&mut GpuProfiler>,
995 ) {
996 if !dt.is_finite() || dt < 0.0 {
997 return;
998 }
999 let resting = self.still > self.settle;
1000 if resting {
1001 self.clock = 0.0;
1002 if !(field && self.stale) {
1003 self.moved = false;
1004 return;
1005 }
1006 }
1007 self.moved = field;
1008 let asleep = self.asleep();
1009 let uniform = self.sim_uniform(asleep, time, calm);
1010 queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
1011 if field {
1012 self.pipeline
1013 .update(queue, self.smoothing, self.outline, &self.wires, self.wire);
1014 }
1015 self.clock = (self.clock + dt).min(SUBSTEP * 4.0);
1016 let particle_groups = self.high.div_ceil(256).max(1);
1017 let awake_groups = self.high.saturating_sub(asleep).div_ceil(256).max(1);
1018 let cell_groups = (self.cells[0] * self.cells[1]).div_ceil(BLOCK).max(1);
1019 {
1020 let stamp = profiler
1021 .as_deref_mut()
1022 .and_then(|profile| profile.pass("fluid sim"));
1023 let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1024 label: Some("Sim"),
1025 timestamp_writes: profiler
1026 .as_deref()
1027 .and_then(|profile| profile.compute_writes(stamp)),
1028 });
1029 pass.set_bind_group(0, &self.bind, &[]);
1030 while self.clock >= SUBSTEP {
1031 self.clock -= SUBSTEP;
1032 self.bin(&mut pass, particle_groups, cell_groups);
1033 dispatch(&mut pass, &self.pipes.density, awake_groups, 1);
1034 dispatch(&mut pass, &self.pipes.forces, awake_groups, 1);
1035 dispatch(&mut pass, &self.pipes.integrate, awake_groups, 1);
1036 }
1037 }
1038 if !field {
1039 self.stale = true;
1040 return;
1041 }
1042 self.stale = false;
1043 {
1044 let stamp = profiler
1045 .as_deref_mut()
1046 .and_then(|profile| profile.pass("fluid gather"));
1047 let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
1048 label: Some("Gather"),
1049 timestamp_writes: profiler
1050 .as_deref()
1051 .and_then(|profile| profile.compute_writes(stamp)),
1052 });
1053 pass.set_bind_group(0, &self.bind, &[]);
1054 self.bin(&mut pass, particle_groups, cell_groups);
1055 dispatch(
1056 &mut pass,
1057 &self.pipes.gather,
1058 self.width.div_ceil(8),
1059 self.height.div_ceil(8),
1060 );
1061 }
1062 self.pipeline.encode(encoder, profiler);
1063 }
1064
1065 fn sim_uniform(&self, asleep: u32, time: f32, calm: f32) -> Uniform {
1066 let mut apart = self.params.apart;
1067 apart[2] = asleep as f32;
1068 Uniform {
1069 grid: [
1070 self.cells[0] as f32,
1071 self.cells[1] as f32,
1072 KERNEL,
1073 self.high as f32,
1074 ],
1075 table: [self.width as f32, self.height as f32, self.rest, SUBSTEP],
1076 k: [
1077 self.params.pressure,
1078 self.params.contain,
1079 self.params.cohesion,
1080 self.params.damping,
1081 ],
1082 misc: [
1083 self.params.life_rate,
1084 SPACING,
1085 self.params.gather.clamp(1.0, 2.0),
1086 self.params.viscosity,
1087 ],
1088 apart,
1089 tension: self.params.tension,
1090 clock: [time, calm, 0.0, 0.0],
1091 }
1092 }
1093
1094 fn bin(&self, pass: &mut wgpu::ComputePass, particle_groups: u32, cell_groups: u32) {
1095 dispatch(pass, &self.pipes.clear_cells, cell_groups, 1);
1096 dispatch(pass, &self.pipes.count, particle_groups, 1);
1097 dispatch(pass, &self.pipes.scan_cells, cell_groups, 1);
1098 dispatch(pass, &self.pipes.scan_blocks, 1, 1);
1099 dispatch(pass, &self.pipes.scatter, particle_groups, 1);
1100 dispatch(pass, &self.pipes.order, cell_groups, 1);
1101 }
1102
1103 fn asleep(&self) -> u32 {
1104 self.bodies
1105 .iter()
1106 .find(|body| body.start == 0 && body.seen && body.quiet > self.settle)
1107 .map_or(0, |body| body.count)
1108 }
1109
1110 fn allocate(&mut self, count: u32) -> Option<u32> {
1111 let at = self.free_ranges.iter().position(|(_, len)| *len >= count)?;
1112 let (start, len) = self.free_ranges[at];
1113 if len == count {
1114 self.free_ranges.remove(at);
1115 } else {
1116 self.free_ranges[at] = (start + count, len - count);
1117 }
1118 self.high = self.high.max(start + count);
1119 Some(start)
1120 }
1121
1122 fn release(&mut self, start: u32, count: u32) {
1123 self.free_ranges.push((start, count));
1124 self.free_ranges.sort_by_key(|range| range.0);
1125 let mut merged: Vec<(u32, u32)> = Vec::new();
1126 for (start, len) in self.free_ranges.drain(..) {
1127 if let Some(last) = merged.last_mut()
1128 && last.0 + last.1 == start
1129 {
1130 last.1 += len;
1131 continue;
1132 }
1133 merged.push((start, len));
1134 }
1135 self.free_ranges = merged;
1136 }
1137}
1138
1139fn dispatch(pass: &mut wgpu::ComputePass, pipeline: &wgpu::ComputePipeline, x: u32, y: u32) {
1140 pass.set_pipeline(pipeline);
1141 pass.dispatch_workgroups(x.max(1), y.max(1), 1);
1142}
1143
1144fn target_drift(a: &Target, b: &Target) -> f32 {
1145 let left: &[f32] = bytemuck::cast_slice(std::slice::from_ref(a));
1146 let right: &[f32] = bytemuck::cast_slice(std::slice::from_ref(b));
1147 left.iter()
1148 .zip(right)
1149 .map(|(x, y)| (x - y).abs())
1150 .fold(0.0, f32::max)
1151}
1152
1153fn target_drift_all(a: &[Target], b: &[Target]) -> f32 {
1154 let left: &[f32] = bytemuck::cast_slice(a);
1155 let right: &[f32] = bytemuck::cast_slice(b);
1156 left.iter()
1157 .zip(right)
1158 .map(|(x, y)| (x - y).abs())
1159 .fold(0.0, f32::max)
1160}
1161
1162#[cfg(test)]
1163#[path = "fluid_seams_test.rs"]
1164mod seams;
1165
1166#[cfg(test)]
1167#[path = "fluid_determinism_test.rs"]
1168mod determinism;
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173 use crate::shape::Form;
1174
1175 fn reference_raise(
1176 p: [f32; 2],
1177 value: [f32; 4],
1178 segments: &[[f32; 4]],
1179 wire: Wire,
1180 ) -> [f32; 4] {
1181 let mut h = value[0];
1182 let mut ch = [
1183 value[1] / h.max(1e-3),
1184 value[2] / h.max(1e-3),
1185 value[3] / h.max(1e-3),
1186 ];
1187 let mut near = 1e9f32;
1188 let mut along = 0.0;
1189 let mut side = 0.0;
1190 let mut run = 0.0;
1191 for segment in segments.iter().take(256) {
1192 let a = [segment[0], segment[1]];
1193 let ba = [segment[2] - a[0], segment[3] - a[1]];
1194 let span = ba[0].hypot(ba[1]);
1195 let t = (dot2(sub2(p, a), ba) / (span * span).max(1e-4)).clamp(0.0, 1.0);
1196 let off = sub2(sub2(p, a), scale2(ba, t));
1197 let d = len2(off);
1198 if d < near {
1199 near = d;
1200 along = run + t * span;
1201 side = (ba[0] * off[1] - ba[1] * off[0]).signum() * d;
1202 }
1203 run += span;
1204 }
1205 if near < wire.radius + 1.0 {
1206 let across = (near / wire.radius).clamp(0.0, 1.0);
1207 let strands = 0.5
1208 + 0.5
1209 * ((along / wire.period + side / wire.radius * 0.9) * std::f32::consts::TAU)
1210 .cos();
1211 let lift =
1212 wire.lift * (1.0 - across * across).max(0.0).sqrt() * (0.82 + 0.18 * strands);
1213 let edge = 1.0 - smoothstep(wire.radius - 0.6, wire.radius + 0.6, near);
1214 if lift > h {
1215 for (channel, target) in ch.iter_mut().zip([0.0, 1.0, 12.0]) {
1216 *channel += (target - *channel) * edge;
1217 }
1218 h += (lift - h) * edge;
1219 }
1220 }
1221 [h, ch[0] * h, ch[1] * h, ch[2] * h]
1222 }
1223
1224 #[test]
1225 fn wire_raise_single_segment_and_strands() {
1226 let wire = Wire::default();
1227 let segment = [[0.0, 0.0, 20.0, 0.0]];
1228 let crest = reference_raise([0.0, 0.0], [0.0; 4], &segment, wire);
1229 let trough = reference_raise([1.6, 0.0], [0.0; 4], &segment, wire);
1230 let strands = 0.5 + 0.5 * (1.6 / wire.period * std::f32::consts::TAU).cos();
1231 assert!((crest[0] - wire.lift).abs() < 1e-5);
1232 assert!((trough[0] - wire.lift * (0.82 + 0.18 * strands)).abs() < 1e-5);
1233 assert!((crest[3] / crest[0] - 12.0).abs() < 1e-5);
1234 let edge = reference_raise([5.0, 1.8], [0.2, 0.0, 0.0, 0.0], &segment, wire);
1235 assert!(edge[0] > 0.2 && edge[0] < crest[0]);
1236 let high = reference_raise([5.0, 0.0], [10.0, 1.0, 2.0, 3.0], &segment, wire);
1237 assert_eq!(high, [10.0, 1.0, 2.0, 3.0]);
1238 }
1239
1240 #[test]
1241 fn dual_sigma_and_taps() {
1242 let smoothing = Smoothing::default();
1243 let [wide, pool] = smoothing.residuals();
1244 assert!((wide - (100.0f32 - 4.0).sqrt()).abs() < 0.0001);
1245 assert!((pool - (400.0f32 - 4.0).sqrt()).abs() < 0.0001);
1246 let sigma = 3.0f32;
1247 let reach = (sigma * 2.5).ceil() as i32;
1248 let weights: Vec<f32> = (-reach..=reach)
1249 .map(|k| (-(k * k) as f32 / (2.0 * sigma * sigma)).exp())
1250 .collect();
1251 let total: f32 = weights.iter().sum();
1252 let normalized: f32 = weights.iter().map(|w| w / total).sum();
1253 assert!((normalized - 1.0).abs() < 1e-6);
1254 let analytic: f32 = weights.iter().map(|w| w / (sigma * 2.5066283)).sum();
1255 assert!((analytic - 1.0).abs() < 0.02);
1256 }
1257
1258 #[test]
1259 fn field_shaders_validate() {
1260 for source in [SPH_WGSL, BLUR_WGSL, JFA_WGSL, EDGE_WGSL, RAISE_WGSL] {
1261 let module = naga::front::wgsl::parse_str(source).expect("parse field shader");
1262 naga::valid::Validator::new(
1263 naga::valid::ValidationFlags::all(),
1264 naga::valid::Capabilities::all(),
1265 )
1266 .validate(&module)
1267 .expect("validate field shader");
1268 }
1269 }
1270
1271 #[test]
1272 #[ignore = "needs a GPU; run under pgpu"]
1273 fn two_wires_and_small_pool_inspect() {
1274 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
1275 backends: wgpu::Backends::VULKAN,
1276 ..wgpu::InstanceDescriptor::new_without_display_handle()
1277 });
1278 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
1279 power_preference: wgpu::PowerPreference::HighPerformance,
1280 compatible_surface: None,
1281 force_fallback_adapter: false,
1282 apply_limit_buckets: false,
1283 }))
1284 .expect("adapter");
1285 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
1286 label: Some("fluid wire test"),
1287 required_features: wgpu::Features::empty(),
1288 ..Default::default()
1289 }))
1290 .expect("device");
1291 let mut fluid = Fluid::new(
1292 &device,
1293 FluidDesc {
1294 width: 64,
1295 height: 64,
1296 max_particles: 512,
1297 max_targets: 4,
1298 params: Params::default(),
1299 settle: SETTLE,
1300 },
1301 );
1302 let look = Look {
1303 tall: 1.0,
1304 active: 0.0,
1305 weight_b: 0.0,
1306 weight_a: 0.0,
1307 };
1308 let pour = Pour {
1309 key: "pool".into(),
1310 target: Target::bead([32.0, 32.0], 8.0, &look),
1311 from: None,
1312 fill: None,
1313 };
1314 let wires = [[8.0, 32.5, 56.0, 32.5], [32.5, 8.0, 32.5, 56.0]];
1315 let dt = SUBSTEP * 4.0;
1316 fluid.sync(&queue, std::slice::from_ref(&pour), dt);
1317 let wire = Wire::default();
1318 let smoothing = Smoothing::default();
1319 let outline = Outline::default();
1320 fluid.set_field(
1321 &wires,
1322 [wire.radius, wire.lift, wire.period],
1323 [smoothing.wide, smoothing.fine, smoothing.pool],
1324 [
1325 outline.shape[0],
1326 outline.shape[1],
1327 outline.shape[2],
1328 outline.shape[3],
1329 outline.size[0],
1330 outline.size[1],
1331 outline.size[2],
1332 outline.size[3],
1333 ],
1334 );
1335 for frame in 0..10 {
1336 fluid.step(&device, &queue, dt, frame as f32 * dt, 1.0, true);
1337 }
1338 let state = fluid.inspect_row(&device, &queue, 32);
1339 assert_eq!(state.live, fluid.particle_count());
1340 let (lo, hi) = state.bounds.expect("pool bounds");
1341 assert!(lo[0] > 16.0 && lo[1] > 16.0 && hi[0] < 48.0 && hi[1] < 48.0);
1342 assert!(
1343 state.height_min >= 0.0
1344 && state.height_max > wire.lift - 1.0
1345 && state.height_max < wire.lift + 1.0
1346 );
1347 assert!(state.height_mean > 0.0 && state.height_mean < 2.0);
1348 assert_eq!(state.row.as_ref().map(Vec::len), Some(64));
1349 assert!(state.channel_histogram[2].contains_key(&192));
1350 fluid.sync(&queue, std::slice::from_ref(&pour), 3.0);
1351 fluid.sync(&queue, &[pour], 3.0);
1352 let rested = fluid.inspect(&device, &queue);
1353 assert!(rested.resting);
1354 assert_eq!(rested.asleep, rested.live);
1355 }
1356
1357 #[test]
1358 fn a_bead_keeps_every_particle() {
1359 let mut fluid = Reference::bead([40.0, 40.0], 10.0, [80.0, 80.0]);
1360 let n = fluid.count();
1361 assert!(n > 8, "{n}");
1362 assert!(rest_density() > 0.0 && rest_density().is_finite());
1363 let start: Vec<[f32; 2]> = fluid.particles().iter().map(|p| p.pos).collect();
1364 for frame in 0..60 {
1365 fluid.step(SUBSTEP * 4.0, frame as f32 * SUBSTEP * 4.0, 1.0);
1366 }
1367 assert_eq!(fluid.count(), n);
1368 assert_eq!(fluid.particles().len(), n);
1369 let mut moved = false;
1370 for (particle, from) in fluid.particles().iter().zip(&start) {
1371 assert!(particle.pos[0].is_finite() && particle.pos[1].is_finite());
1372 assert!(particle.born > 0.0);
1373 let away = (particle.pos[0] - 40.0).hypot(particle.pos[1] - 40.0);
1374 assert!(away < 16.0, "{away}");
1375 if (particle.pos[0] - from[0]).abs() > 1e-3 || (particle.pos[1] - from[1]).abs() > 1e-3
1376 {
1377 moved = true;
1378 }
1379 }
1380 assert!(moved);
1381 }
1382
1383 #[test]
1384 fn the_same_bead_repeats() {
1385 let mut left = Reference::bead([40.0, 40.0], 8.0, [80.0, 80.0]);
1386 let mut right = Reference::bead([40.0, 40.0], 8.0, [80.0, 80.0]);
1387 for frame in 0..20 {
1388 let time = frame as f32 * SUBSTEP * 4.0;
1389 left.step(SUBSTEP * 4.0, time, 1.0);
1390 right.step(SUBSTEP * 4.0, time, 1.0);
1391 }
1392 for (a, b) in left.particles().iter().zip(right.particles()) {
1393 assert_eq!(a.pos, b.pos);
1394 assert_eq!(a.vel, b.vel);
1395 assert_eq!(a.life, b.life);
1396 }
1397 }
1398
1399 #[test]
1400 fn a_drop_spawns_inside_itself() {
1401 let look = Look {
1402 tall: 1.0,
1403 active: 0.2,
1404 weight_b: 0.0,
1405 weight_a: 0.1,
1406 };
1407 let form = Form {
1408 tilt: 0.2,
1409 irregular: 0.15,
1410 seed: 1.7,
1411 rim: 0.0,
1412 weight: [0.3, 0.2],
1413 };
1414 let drop = Target::drop([48.0, 48.0], [14.0, 11.0], &form, &look).organic(0.1, 0.05, 2.0);
1415 let particles = spawn(&drop, 0, None, 9);
1416 assert!(particles.len() > 4, "{}", particles.len());
1417 assert!(particles.iter().all(|particle| drop.inside(particle.pos)));
1418 let bead = Target::bead([20.0, 20.0], 6.0, &look)
1419 .pull(0.5)
1420 .mass(1.2)
1421 .shifted([1.0, -1.0]);
1422 assert_eq!(bead.kind(), 3);
1423 assert!(bead.inside(bead.origin()));
1424 assert_eq!(
1425 Target::capsule([10.0, 12.0], [28.0, 16.0], [4.0, 3.0], &look)
1426 .drifting(0.2)
1427 .kind(),
1428 1
1429 );
1430 assert_eq!(
1431 Target::curve(
1432 [[8.0, 8.0], [16.0, 18.0], [28.0, 16.0], [36.0, 10.0]],
1433 [3.0, 4.0, 3.0],
1434 &look,
1435 )
1436 .flare(1.4)
1437 .revealed(0.5)
1438 .kind(),
1439 2
1440 );
1441 assert_eq!(
1442 Target::outline(
1443 [24.0, 24.0],
1444 9.0,
1445 [
1446 [0.04, 0.2],
1447 [0.02, 0.4],
1448 [0.01, 1.0],
1449 [0.02, 0.6],
1450 [0.01, 0.3],
1451 ],
1452 &look,
1453 )
1454 .squash(0.85)
1455 .gathered([24.0, 24.0], 0.1, 0.9)
1456 .kind(),
1457 4
1458 );
1459 }
1460}