1use iced::{
2 Color, Element, Event, Length, Point, Rectangle, Subscription, Task, Theme,
3 mouse::{Cursor, Interaction},
4 widget::canvas::{self, Canvas, Frame, Geometry, Path},
5};
6use ndarray::{Array1, Array2};
7use ndarray_rand::{RandomExt, rand_distr::Normal};
8use rayon::prelude::*;
9use std::f32::consts::PI;
10
11pub const WIDTH: f32 = 1500.0;
12pub const HEIGHT: f32 = 1500.0;
13const D: usize = 3; const N: usize = 1000;
15const DT: f32 = 0.1;
16
17#[derive(Debug, Clone)]
19pub enum Message {
20 Tick,
21 Zoom(f32), NoOp,
23}
24
25pub struct ParticleLenia {
27 particles: Array2<f32>,
28 energies: Vec<f32>,
29 cache: canvas::Cache,
30 r: f32,
32 w: f32,
33 m: f32,
34 s: f32,
35 c_rep: f32,
36 kernel_sum: f32,
37 zoom: f32,
38}
39
40impl ParticleLenia {
41 pub fn new() -> Self {
43 let r = 2.0;
45 let w = 0.64;
46 let m = 0.72;
47 let s = 0.26;
48 let c_rep = 1.0;
49 let kernel_sum = compute_kernel_sum(D, r, w);
50
51 let particles = Array2::random((N, D), Normal::new(0.0, 1.0).unwrap());
53 let energies = vec![0.0; N];
54
55 Self {
56 particles,
57 energies,
58 cache: canvas::Cache::new(),
59 r,
60 w,
61 m,
62 s,
63 c_rep,
64 kernel_sum,
65 zoom: 0.33,
66 }
67 }
68
69 fn step(&mut self) {
71 let x_prev = self.particles.clone();
72 let kernel_sum = self.kernel_sum;
74 let r = self.r;
75 let w = self.w;
76 let m = self.m;
77 let s = self.s;
78 let c_rep = self.c_rep;
79
80 let updates: Vec<Array1<f32>> = (0..N)
82 .into_par_iter()
83 .map(|i| {
84 let xi = x_prev.row(i).to_owned();
85 let grad = numerical_gradient(&x_prev, &xi, kernel_sum, r, w, m, s, c_rep, 1e-4);
86 xi - grad * DT
87 })
88 .collect();
89
90 let new_positions = Array2::from_shape_fn((N, D), |(i, j)| updates[i][j]);
91 self.particles.assign(&new_positions);
92
93 let new_energies: Vec<f32> = (0..N)
95 .into_par_iter()
96 .map(|i| {
97 let xi = new_positions.row(i).to_owned();
98 energy(&new_positions, &xi, kernel_sum, r, w, m, s, c_rep)
99 })
100 .collect();
101
102 self.energies = new_energies;
103 }
104}
105
106pub fn update(state: &mut ParticleLenia, message: Message) -> Task<Message> {
108 match message {
109 Message::Tick => {
110 state.step();
111 state.cache.clear();
112 }
113 Message::Zoom(delta) => {
114 state.zoom += 0.01 * delta;
116 state.zoom = state.zoom.clamp(0.1, 10.0);
117 state.cache.clear();
118 }
119 Message::NoOp => {}
120 }
121
122 Task::none()
123}
124
125pub fn view(state: &ParticleLenia) -> Element<Message> {
126 Canvas::new(state)
127 .width(Length::Fill)
128 .height(Length::Fill)
129 .into()
130}
131
132pub fn subscription(_state: &ParticleLenia) -> Subscription<Message> {
133 Subscription::batch(vec![
134 iced::time::every(std::time::Duration::from_millis(16)).map(|_| Message::Tick),
135 iced_futures::event::listen().map(|event| {
136 if let Event::Mouse(iced::mouse::Event::WheelScrolled { delta }) = event {
137 let zoom_delta = match delta {
139 iced::mouse::ScrollDelta::Lines { y, .. } => y,
140 iced::mouse::ScrollDelta::Pixels { y, .. } => y,
141 };
142 Message::Zoom(zoom_delta)
143 } else {
144 Message::NoOp
145 }
146 }),
147 ])
148}
149
150pub fn main() -> iced::Result {
152 iced::application("Particle Lenia Simulation", update, view)
153 .subscription(subscription)
154 .run_with(|| {
155 let state = ParticleLenia::new();
157 (state, Task::none())
158 })
159}
160
161impl<Message> canvas::Program<Message, Theme> for ParticleLenia {
163 type State = ();
164
165 fn draw(
166 &self,
167 _state: &Self::State,
168 renderer: &iced::Renderer,
169 _theme: &Theme,
170 bounds: Rectangle,
171 _cursor: Cursor,
172 ) -> Vec<Geometry> {
173 let min_energy = self.energies.iter().cloned().fold(f32::INFINITY, f32::min);
175 let max_energy = self
176 .energies
177 .iter()
178 .cloned()
179 .fold(f32::NEG_INFINITY, f32::max);
180
181 let geometry = self
182 .cache
183 .draw(renderer, bounds.size(), |frame: &mut Frame| {
184 frame.fill_rectangle(Point::ORIGIN, frame.size(), Color::BLACK);
186
187 let camera_distance = 400.0;
189
190 for (i, particle) in self.particles.rows().into_iter().enumerate() {
192 let point = [particle[0], particle[1], particle[2]];
194
195 let (proj_x, proj_y) = project_point(&point, camera_distance);
197
198 let x = (bounds.width / 2.0) + proj_x * (bounds.width / 4.0) * self.zoom;
200 let y = (bounds.height / 2.0) + proj_y * (bounds.height / 4.0) * self.zoom;
201
202 let energy_value = self.energies[i];
204 let factor = if max_energy - min_energy > 0.0 {
205 (energy_value - min_energy) / (max_energy - min_energy)
206 } else {
207 0.5
208 };
209
210 let circle_color = Color::from_rgb(1.0 - factor, factor, 0.5);
212
213 let circle = Path::circle(Point::new(x, y), 10.0);
215 frame.fill(&circle, circle_color);
216 }
217 });
218
219 vec![geometry]
220 }
221
222 fn mouse_interaction(
223 &self,
224 _state: &Self::State,
225 _bounds: Rectangle,
226 _cursor: Cursor,
227 ) -> Interaction {
228 Interaction::default()
229 }
230}
231
232fn project_point(point: &[f32; 3], camera_distance: f32) -> (f32, f32) {
235 let factor = camera_distance / (camera_distance - point[2]);
237 let x_proj = point[0] * factor;
238 let y_proj = point[1] * factor;
239 (x_proj, y_proj)
240}
241
242fn bell(x: f32, m: f32, s: f32) -> f32 {
244 (-((x - m) / s).powi(2)).exp()
245}
246
247fn repulse(x: f32) -> f32 {
249 (1.0 - x).max(0.0).powi(2)
250}
251
252fn compute_kernel_sum(d: usize, r: f32, w: f32) -> f32 {
255 let lower = (r - 4.0 * w).max(0.0);
256 let upper = r + 4.0 * w;
257 let steps = 51;
258 let delta = (upper - lower) / (steps - 1) as f32;
259
260 let dimension_factor = match d {
261 2 => 2.0 * PI, 3 => 4.0 * PI, _ => panic!("compute_kernel_sum: only d=2 or d=3 is implemented."),
264 };
265
266 let mut sum = 0.0;
267 let mut last_val = None;
268
269 for i in 0..steps {
270 let dist = lower + (i as f32) * delta;
271 let val = bell(dist, r, w) * dimension_factor * dist.powi((d - 1) as i32);
272
273 if let Some(prev) = last_val {
274 sum += 0.5 * (val + prev) * delta; }
276
277 last_val = Some(val);
278 }
279
280 sum
281}
282
283fn energy(
285 #[allow(non_snake_case)] X: &Array2<f32>,
286 x_i: &Array1<f32>,
287 kernel_sum: f32,
288 r: f32,
289 w: f32,
290 m: f32,
291 s: f32,
292 c_rep: f32,
293) -> f32 {
294 let distances = (X - x_i)
295 .mapv(|v| v.powi(2))
296 .sum_axis(ndarray::Axis(1))
297 .mapv(f32::sqrt)
298 .mapv(|val| val.max(1e-10));
299
300 let u = distances.mapv(|d| bell(d, r, w)).sum() / kernel_sum;
301 let g = bell(u, m, s);
302 let r_ener = distances.mapv(repulse).sum() * c_rep / 2.0;
303
304 r_ener - g
305}
306
307fn numerical_gradient(
309 #[allow(non_snake_case)] X: &Array2<f32>,
310 xi: &Array1<f32>,
311 kernel_sum: f32,
312 r: f32,
313 w: f32,
314 m: f32,
315 s: f32,
316 c_rep: f32,
317 h: f32,
318) -> Array1<f32> {
319 let mut grad = Array1::zeros(D);
320
321 for dim in 0..D {
322 let mut x_plus = xi.clone();
323 x_plus[dim] += h;
324 let f_plus = energy(X, &x_plus, kernel_sum, r, w, m, s, c_rep);
325
326 let mut x_minus = xi.clone();
327 x_minus[dim] -= h;
328 let f_minus = energy(X, &x_minus, kernel_sum, r, w, m, s, c_rep);
329
330 grad[dim] = (f_plus - f_minus) / (2.0 * h);
331 }
332
333 grad
334}