Skip to main content

kvas_engine/
particles.rs

1//! Particle emitters: fire, smoke, sparks, rain, snow, magic dust.
2//!
3//! A [`ParticleEmitter`] simulates its particles on the CPU and draws them as camera-facing quads
4//! in one `DynamicMesh` (one draw call per emitter). Particles live in world space, so they trail
5//! behind a moving emitter. Colours and fading are done by the built-in particle shader.
6//!
7//! ```
8//! use kvas_engine::{prelude::*, scene::Scene, runtime::Runtime};
9//! let mut scene = Scene::default();
10//! let fire = scene.spawn("Campfire");
11//! scene.world.insert(fire, ParticleEmitter::preset("fire"));
12//! let mut runtime = Runtime::new(scene, None);
13//! for _ in 0..30 { runtime.tick(1.0 / 30.0); }
14//! assert!(runtime.scene.world.get::<ParticleEmitter>(fire).unwrap().alive() > 0);
15//! ```
16
17use crate::components::*;
18use crate::mesh::{MeshData, Vertex};
19use crate::procedural::Rng;
20use crate::reflect::{AssetKind, Field};
21use glam::{Mat4, Vec3};
22use kvas_ecs::World;
23use serde::{Deserialize, Serialize};
24use std::sync::Arc;
25
26crate::choice_enum!(
27    /// Where new particles appear.
28    EmitterShape { Point, Sphere, Box, Cone, Disc } default Point
29);
30
31/// A particle emitter.
32#[derive(Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct ParticleEmitter {
35    /// Particles per second.
36    pub rate: f32,
37    /// Particles released at once when the emitter starts.
38    pub burst: i32,
39    /// Seconds a particle lives.
40    pub lifetime: f32,
41    /// Random +/- on the lifetime, 0..1.
42    pub lifetime_variation: f32,
43    /// Initial speed in metres per second.
44    pub speed: f32,
45    /// Random +/- on the speed, 0..1.
46    pub speed_variation: f32,
47    /// Spread of the launch direction around "up" (the emitter's local Y), in degrees.
48    pub spread: f32,
49    /// Acceleration in m/s^2 (negative Y falls, positive Y rises like smoke).
50    pub gravity: f32,
51    /// Air resistance: velocity lost per second, 0..1+.
52    pub drag: f32,
53    /// Size of a particle at birth, metres.
54    pub size_start: f32,
55    /// Size at the end of its life.
56    pub size_end: f32,
57    /// Colour at birth (RGBA).
58    pub color_start: [f32; 4],
59    /// Colour at the end of life (RGBA).
60    pub color_end: [f32; 4],
61    /// Brightness multiplier (glow), values above 1 make them shine.
62    pub glow: f32,
63    /// Where particles are born.
64    pub shape: EmitterShape,
65    /// Size of the emission area (radius of a sphere / disc / cone base, half-extent of a box).
66    pub shape_size: f32,
67    /// Particle image (a soft puff or spark with alpha). Empty = a soft round dot.
68    pub texture: String,
69    /// Keep emitting forever; off = one run of `duration` seconds.
70    pub looping: bool,
71    /// Length of one run when not looping.
72    pub duration: f32,
73    /// Upper limit of simultaneous particles.
74    pub max_particles: i32,
75    /// Random seed.
76    pub seed: i32,
77    /// Stretches particles along their motion (rain streaks, sparks): extra length per m/s.
78    pub stretch: f32,
79    /// Particles die when they fall below `floor_height` (world Y).
80    pub collide_floor: bool,
81    /// World height of the floor for `collide_floor`.
82    pub floor_height: f32,
83    /// Little splash particles born where a particle hits the floor.
84    pub splash: i32,
85    /// The emitter keeps itself above the active camera (rain or snow around the player).
86    pub follow_camera: bool,
87    /// Soft fade where particles cut into surfaces, in metres (0 = hard edges).
88    pub soft: f32,
89    #[serde(skip)]
90    #[doc(hidden)]
91    pub state: Option<ParticleState>,
92}
93
94impl Default for ParticleEmitter {
95    fn default() -> Self {
96        Self {
97            rate: 30.0,
98            burst: 0,
99            lifetime: 2.0,
100            lifetime_variation: 0.3,
101            speed: 2.0,
102            speed_variation: 0.3,
103            spread: 25.0,
104            gravity: 0.0,
105            drag: 0.2,
106            size_start: 0.3,
107            size_end: 0.05,
108            color_start: [1.0, 0.8, 0.4, 1.0],
109            color_end: [1.0, 0.2, 0.05, 0.0],
110            glow: 1.5,
111            shape: EmitterShape::Point,
112            shape_size: 0.2,
113            texture: String::new(),
114            looping: true,
115            duration: 5.0,
116            max_particles: 600,
117            seed: 7,
118            stretch: 0.0,
119            collide_floor: false,
120            floor_height: 0.0,
121            splash: 0,
122            follow_camera: false,
123            soft: 0.0,
124            state: None,
125        }
126    }
127}
128
129impl Component for ParticleEmitter {
130    fn fields(&mut self) -> Vec<Field<'_>> {
131        vec![
132            Field::f32("rate", &mut self.rate),
133            Field::i32("burst", &mut self.burst),
134            Field::f32("lifetime", &mut self.lifetime),
135            Field::f32("lifetime_variation", &mut self.lifetime_variation),
136            Field::f32("speed", &mut self.speed),
137            Field::f32("speed_variation", &mut self.speed_variation),
138            Field::f32("spread", &mut self.spread),
139            Field::f32("gravity", &mut self.gravity),
140            Field::f32("drag", &mut self.drag),
141            Field::f32("size_start", &mut self.size_start),
142            Field::f32("size_end", &mut self.size_end),
143            Field::color("color_start", &mut self.color_start),
144            Field::color("color_end", &mut self.color_end),
145            Field::f32("glow", &mut self.glow),
146            Field::choice("shape", &mut self.shape),
147            Field::f32("shape_size", &mut self.shape_size),
148            Field::asset("texture", &mut self.texture, AssetKind::Texture),
149            Field::bool("looping", &mut self.looping),
150            Field::f32("duration", &mut self.duration),
151            Field::i32("max_particles", &mut self.max_particles),
152            Field::i32("seed", &mut self.seed),
153            Field::f32("stretch", &mut self.stretch),
154            Field::bool("collide_floor", &mut self.collide_floor),
155            Field::f32("floor_height", &mut self.floor_height),
156            Field::i32("splash", &mut self.splash),
157            Field::bool("follow_camera", &mut self.follow_camera),
158            Field::f32("soft", &mut self.soft),
159        ]
160    }
161}
162
163impl ParticleEmitter {
164    /// Ready-made emitters: "fire", "smoke", "sparks", "snow", "rain", "heavy_rain", "drizzle", "magic", "dust", "explosion",
165    /// "fog", "mist", "steam", "embers", "fireflies", "leaves", "bubbles", "campfire_smoke", "dust_storm".
166    pub fn preset(name: &str) -> Self {
167        let base = Self::default();
168        match name {
169            "fire" => Self { rate: 60.0, lifetime: 1.1, speed: 1.4, spread: 12.0, gravity: 2.5, drag: 0.4, size_start: 0.35, size_end: 0.05, shape: EmitterShape::Disc, shape_size: 0.25, color_start: [1.0, 0.75, 0.25, 0.9], color_end: [0.9, 0.1, 0.02, 0.0], glow: 3.0, ..base },
170            "smoke" => Self { rate: 14.0, lifetime: 4.5, speed: 0.9, spread: 18.0, gravity: 0.6, drag: 0.5, size_start: 0.4, size_end: 2.2, shape: EmitterShape::Disc, shape_size: 0.2, color_start: [0.35, 0.35, 0.38, 0.55], color_end: [0.6, 0.6, 0.65, 0.0], glow: 0.4, ..base },
171            "sparks" => Self { rate: 40.0, lifetime: 0.9, speed: 4.5, spread: 55.0, gravity: -9.0, drag: 0.1, size_start: 0.06, size_end: 0.02, color_start: [1.0, 0.85, 0.4, 1.0], color_end: [1.0, 0.3, 0.05, 0.0], glow: 4.0, ..base },
172            "snow" => Self { rate: 120.0, lifetime: 7.0, speed: 0.2, spread: 180.0, gravity: -1.1, drag: 0.8, size_start: 0.07, size_end: 0.07, shape: EmitterShape::Box, shape_size: 10.0, color_start: [1.0, 1.0, 1.0, 0.9], color_end: [1.0, 1.0, 1.0, 0.0], glow: 1.2, max_particles: 1500, ..base },
173            "rain" => Self { rate: 400.0, lifetime: 1.2, speed: 0.0, spread: 0.0, gravity: -18.0, drag: 0.0, size_start: 0.03, size_end: 0.03, shape: EmitterShape::Box, shape_size: 10.0, color_start: [0.7, 0.8, 1.0, 0.5], color_end: [0.7, 0.8, 1.0, 0.1], glow: 1.0, max_particles: 2500, ..base },
174            "heavy_rain" => Self { rate: 2600.0, lifetime: 1.0, lifetime_variation: 0.1, speed: 9.0, speed_variation: 0.15, spread: 4.0, gravity: -14.0, drag: 0.0, size_start: 0.012, size_end: 0.012, shape: EmitterShape::Box, shape_size: 14.0, color_start: [0.75, 0.82, 0.95, 0.35], color_end: [0.75, 0.82, 0.95, 0.25], glow: 1.6, max_particles: 6000, stretch: 0.045, collide_floor: true, splash: 2, follow_camera: true, ..base },
175            "drizzle" => Self { rate: 700.0, lifetime: 1.6, speed: 5.0, spread: 6.0, gravity: -8.0, drag: 0.0, size_start: 0.008, size_end: 0.008, shape: EmitterShape::Box, shape_size: 12.0, color_start: [0.8, 0.85, 0.95, 0.25], color_end: [0.8, 0.85, 0.95, 0.15], glow: 1.4, max_particles: 2400, stretch: 0.03, collide_floor: true, follow_camera: true, ..base },
176            "magic" => Self { rate: 35.0, lifetime: 2.2, speed: 0.7, spread: 180.0, gravity: 0.4, drag: 0.7, size_start: 0.12, size_end: 0.0, shape: EmitterShape::Sphere, shape_size: 0.6, color_start: [0.5, 0.8, 1.0, 1.0], color_end: [0.8, 0.3, 1.0, 0.0], glow: 4.0, ..base },
177            "dust" => Self { rate: 25.0, lifetime: 6.0, speed: 0.15, spread: 180.0, gravity: 0.0, drag: 0.9, size_start: 0.04, size_end: 0.04, shape: EmitterShape::Box, shape_size: 4.0, color_start: [1.0, 0.95, 0.8, 0.5], color_end: [1.0, 0.95, 0.8, 0.0], glow: 1.5, ..base },
178            "fog" => Self { rate: 6.0, lifetime: 14.0, lifetime_variation: 0.4, speed: 0.12, spread: 180.0, gravity: 0.0, drag: 0.3, size_start: 4.0, size_end: 7.0, shape: EmitterShape::Box, shape_size: 9.0, color_start: [0.78, 0.82, 0.88, 0.0], color_end: [0.78, 0.82, 0.88, 0.0], glow: 0.9, max_particles: 160, ..base },
179            "mist" => Self { rate: 10.0, lifetime: 10.0, speed: 0.2, spread: 90.0, gravity: 0.0, drag: 0.4, size_start: 2.5, size_end: 4.5, shape: EmitterShape::Disc, shape_size: 7.0, color_start: [0.85, 0.9, 0.95, 0.18], color_end: [0.85, 0.9, 0.95, 0.0], glow: 1.0, max_particles: 220, ..base },
180            "steam" => Self { rate: 22.0, lifetime: 2.4, speed: 1.2, spread: 14.0, gravity: 1.2, drag: 0.6, size_start: 0.2, size_end: 1.1, shape: EmitterShape::Disc, shape_size: 0.12, color_start: [0.95, 0.95, 0.97, 0.45], color_end: [0.95, 0.95, 0.97, 0.0], glow: 1.1, ..base },
181            "embers" => Self { rate: 18.0, lifetime: 3.4, speed: 1.1, spread: 40.0, gravity: 1.4, drag: 0.35, size_start: 0.05, size_end: 0.01, shape: EmitterShape::Disc, shape_size: 0.5, color_start: [1.0, 0.55, 0.12, 1.0], color_end: [1.0, 0.1, 0.0, 0.0], glow: 5.0, ..base },
182            "fireflies" => Self { rate: 6.0, lifetime: 7.0, speed: 0.25, spread: 180.0, gravity: 0.0, drag: 0.2, size_start: 0.07, size_end: 0.07, shape: EmitterShape::Box, shape_size: 6.0, color_start: [0.8, 1.0, 0.4, 0.9], color_end: [0.8, 1.0, 0.4, 0.0], glow: 6.0, max_particles: 80, ..base },
183            "leaves" => Self { rate: 7.0, lifetime: 8.0, speed: 0.5, spread: 70.0, gravity: -0.8, drag: 1.2, size_start: 0.14, size_end: 0.14, shape: EmitterShape::Box, shape_size: 6.0, color_start: [0.8, 0.55, 0.15, 0.95], color_end: [0.6, 0.35, 0.1, 0.0], glow: 1.0, max_particles: 160, ..base },
184            "bubbles" => Self { rate: 8.0, lifetime: 4.0, speed: 0.7, spread: 18.0, gravity: 0.5, drag: 0.3, size_start: 0.08, size_end: 0.16, shape: EmitterShape::Disc, shape_size: 0.4, color_start: [0.8, 0.95, 1.0, 0.5], color_end: [0.8, 0.95, 1.0, 0.0], glow: 1.4, ..base },
185            "campfire_smoke" => Self { rate: 18.0, lifetime: 6.0, speed: 1.1, spread: 12.0, gravity: 0.7, drag: 0.35, size_start: 0.35, size_end: 2.6, shape: EmitterShape::Disc, shape_size: 0.25, color_start: [0.22, 0.22, 0.24, 0.5], color_end: [0.5, 0.5, 0.54, 0.0], glow: 0.6, ..base },
186            "dust_storm" => Self { rate: 60.0, lifetime: 5.0, speed: 6.0, spread: 20.0, gravity: 0.0, drag: 0.2, size_start: 1.2, size_end: 3.0, shape: EmitterShape::Box, shape_size: 8.0, color_start: [0.85, 0.7, 0.45, 0.28], color_end: [0.85, 0.7, 0.45, 0.0], glow: 1.0, max_particles: 600, ..base },
187            "explosion" => Self { rate: 0.0, burst: 140, lifetime: 1.4, speed: 7.0, spread: 180.0, gravity: -2.0, drag: 1.6, size_start: 0.6, size_end: 0.1, shape: EmitterShape::Sphere, shape_size: 0.2, color_start: [1.0, 0.7, 0.2, 1.0], color_end: [0.3, 0.1, 0.05, 0.0], glow: 3.5, looping: false, duration: 0.1, ..base },
188            _ => base,
189        }
190    }
191
192    /// Particles alive right now.
193    pub fn alive(&self) -> usize {
194        self.state.as_ref().map_or(0, |s| s.particles.len())
195    }
196
197    /// Restarts the emitter (a new burst, a new run).
198    pub fn restart(&mut self) {
199        self.state = None;
200    }
201}
202
203#[derive(Clone)]
204struct Particle {
205    pos: Vec3,
206    vel: Vec3,
207    age: f32,
208    life: f32,
209    /// A splash droplet (short, no further splashes, not stretched).
210    splash: bool,
211}
212
213/// Simulation state (internal).
214#[doc(hidden)]
215#[derive(Clone)]
216pub struct ParticleState {
217    particles: Vec<Particle>,
218    rng: Rng,
219    carry: f32,
220    elapsed: f32,
221    last_time: f32,
222    version: u64,
223}
224
225impl ParticleState {
226    fn new(seed: i32) -> Self {
227        Self { particles: Vec::new(), rng: Rng::new(seed as u64), carry: 0.0, elapsed: 0.0, last_time: f32::NAN, version: 0 }
228    }
229}
230
231fn random_in_cone(rng: &mut Rng, axis: Vec3, spread_deg: f32) -> Vec3 {
232    let spread = spread_deg.clamp(0.0, 180.0).to_radians();
233    let cos_t = 1.0 - rng.f32() * (1.0 - spread.cos());
234    let sin_t = (1.0 - cos_t * cos_t).max(0.0).sqrt();
235    let phi = rng.f32() * std::f32::consts::TAU;
236    let u = axis.any_orthonormal_vector();
237    let v = axis.cross(u);
238    (axis * cos_t + (u * phi.cos() + v * phi.sin()) * sin_t).normalize_or_zero()
239}
240
241fn spawn(e: &ParticleEmitter, st: &mut ParticleState, world: &Mat4) {
242    if st.particles.len() >= e.max_particles.clamp(1, 20_000) as usize {
243        return;
244    }
245    let rng = &mut st.rng;
246    let size = e.shape_size.max(0.0);
247    let local = match e.shape {
248        EmitterShape::Point => Vec3::ZERO,
249        EmitterShape::Sphere => random_in_cone(rng, Vec3::Y, 180.0) * size * rng.f32().cbrt(),
250        EmitterShape::Box => Vec3::new(rng.f32() * 2.0 - 1.0, rng.f32() * 2.0 - 1.0, rng.f32() * 2.0 - 1.0) * size,
251        EmitterShape::Cone => {
252            let a = rng.f32() * std::f32::consts::TAU;
253            let r = rng.f32().sqrt() * size;
254            Vec3::new(a.cos() * r, 0.0, a.sin() * r)
255        }
256        EmitterShape::Disc => {
257            let a = rng.f32() * std::f32::consts::TAU;
258            let r = rng.f32().sqrt() * size;
259            Vec3::new(a.cos() * r, 0.0, a.sin() * r)
260        }
261    };
262    let dir_local = random_in_cone(rng, Vec3::Y, e.spread);
263    let speed = e.speed * (1.0 + (rng.f32() * 2.0 - 1.0) * e.speed_variation.clamp(0.0, 1.0));
264    let life = (e.lifetime * (1.0 + (rng.f32() * 2.0 - 1.0) * e.lifetime_variation.clamp(0.0, 1.0))).max(0.05);
265    st.particles.push(Particle { pos: world.transform_point3(local), vel: world.transform_vector3(dir_local).normalize_or_zero() * speed, age: 0.0, life, splash: false });
266}
267
268fn step(e: &ParticleEmitter, st: &mut ParticleState, world: &Mat4, dt: f32) {
269    st.elapsed += dt;
270    let drag = (1.0 - e.drag.max(0.0) * dt).max(0.0);
271    let mut hits = Vec::new();
272    for p in &mut st.particles {
273        p.age += dt;
274        p.vel.y += if p.splash { -9.0 } else { e.gravity } * dt;
275        p.vel *= drag;
276        p.pos += p.vel * dt;
277        if e.collide_floor && p.pos.y < e.floor_height && p.age < p.life {
278            if !p.splash {
279                hits.push(Vec3::new(p.pos.x, e.floor_height + 0.01, p.pos.z));
280            }
281            p.age = p.life;
282        }
283    }
284    st.particles.retain(|p| p.age < p.life);
285    // Splashes: a few droplets thrown up where a particle landed.
286    let budget = e.max_particles.clamp(1, 20_000) as usize;
287    for at in hits {
288        for _ in 0..e.splash.clamp(0, 8) {
289            if st.particles.len() >= budget {
290                break;
291            }
292            let a = st.rng.f32() * std::f32::consts::TAU;
293            let up = 0.8 + st.rng.f32() * 1.2;
294            let out = 0.3 + st.rng.f32() * 0.6;
295            st.particles.push(Particle { pos: at, vel: Vec3::new(a.cos() * out, up, a.sin() * out), age: 0.0, life: 0.18 + st.rng.f32() * 0.12, splash: true });
296        }
297    }
298    let emitting = e.looping || st.elapsed <= e.duration;
299    if emitting && e.rate > 0.0 {
300        st.carry += e.rate * dt;
301        while st.carry >= 1.0 {
302            st.carry -= 1.0;
303            spawn(e, st, world);
304        }
305    }
306}
307
308fn camera_position(world: &World) -> Option<Vec3> {
309    let entity = crate::components::active_camera(world)?;
310    Some(world.get::<GlobalTransform>(entity)?.0.w_axis.truncate())
311}
312
313/// Quads are turned towards the viewer by the particle shader, so they face whichever camera
314/// draws them (the game camera, the editor view, a preview). Each vertex carries the particle
315/// centre, `normal = (corner x, corner y, life)` and `uv = (half size in metres, mode)`:
316/// mode -1 faces the camera, mode -2 is a streak whose length is already in the position
317/// (along the motion) and only its width is turned to the camera.
318fn build_mesh(e: &ParticleEmitter, st: &ParticleState, to_local: &Mat4) -> MeshData {
319    let mut m = MeshData::default();
320    m.vertices.reserve(st.particles.len() * 4);
321    m.indices.reserve(st.particles.len() * 6);
322    for p in &st.particles {
323        let t = (p.age / p.life).clamp(0.0, 1.0);
324        let size = if p.splash { e.size_start.max(0.006) * 0.8 } else { e.size_start + (e.size_end - e.size_start) * t };
325        let center = to_local.transform_point3(p.pos);
326        // Stretched particles (rain): the length follows the motion.
327        let streak = (e.stretch > 0.0 && !p.splash && p.vel.length_squared() > 1e-4)
328            .then(|| to_local.transform_vector3(p.vel.normalize()) * (p.vel.length() * e.stretch).max(size));
329        let base = m.vertices.len() as u32;
330        for (qx, qy) in [(-1.0f32, -1.0f32), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
331            let (pos, mode) = match streak {
332                Some(along) => (center + along * qy, -2.0),
333                None => (center, -1.0),
334            };
335            m.vertices.push(Vertex { pos: pos.into(), normal: [qx, qy, t], uv: [size, mode] });
336        }
337        m.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
338    }
339    m
340}
341
342/// Simulates every emitter and refreshes its mesh and material. `simulate` is false in the editor
343/// outside Play: emitters then animate from wall-clock `time` so they can be previewed.
344pub fn update(world: &mut World, time: f32, dt: f32, simulate: bool) {
345    for entity in world.entities_with::<ParticleEmitter>() {
346        let Some(mut emitter) = world.get::<ParticleEmitter>(entity).cloned() else { continue };
347        let mut global = world.get::<GlobalTransform>(entity).map_or(Mat4::IDENTITY, |g| g.0);
348        let eye = camera_position(world);
349        if emitter.follow_camera {
350            if let Some(c) = eye {
351                // Keep the emitter's height, centre it over the camera.
352                let (s, r, t) = global.to_scale_rotation_translation();
353                global = Mat4::from_scale_rotation_translation(s, r, Vec3::new(c.x, t.y.max(c.y + 4.0), c.z));
354            }
355        }
356        let mut st = emitter.state.take().unwrap_or_else(|| ParticleState::new(emitter.seed));
357        let first = st.last_time.is_nan();
358        let delta = if simulate { dt } else if first { 0.0 } else { (time - st.last_time).clamp(0.0, 0.1) };
359        st.last_time = time;
360        if first {
361            for _ in 0..emitter.burst.clamp(0, 5000) {
362                spawn(&emitter, &mut st, &global);
363            }
364        }
365        // Several small steps keep fast particles smooth at low frame rates.
366        let steps = ((delta / 0.033).ceil() as usize).max(1);
367        for _ in 0..steps {
368            step(&emitter, &mut st, &global, delta / steps as f32);
369        }
370        st.version += 1;
371        let inverse = if global.determinant().abs() > 1e-9 { global.inverse() } else { Mat4::IDENTITY };
372        let real = world.get::<GlobalTransform>(entity).map_or(Mat4::IDENTITY, |g| g.0);
373        let inverse = if emitter.follow_camera && real.determinant().abs() > 1e-9 { real.inverse() } else { inverse };
374        let mesh = build_mesh(&emitter, &st, &inverse);
375        let version = st.version;
376        emitter.state = Some(st);
377        if let Some(slot) = world.get_mut::<ParticleEmitter>(entity) {
378            *slot = emitter.clone();
379        }
380        world.insert(entity, DynamicMesh { data: Arc::new(mesh), version });
381        if !world.has::<MeshRenderer>(entity) {
382            world.insert(entity, MeshRenderer::default());
383        }
384        let m = world.get_mut::<MeshRenderer>(entity).unwrap();
385        m.shader = "builtin:particle".into();
386        m.cast_shadows = false;
387        m.color = [1.0, 1.0, 1.0, 0.99];
388        m.texture = emitter.texture.clone();
389        m.emissive = emitter.glow;
390        m.roughness = emitter.soft;
391        m.user = [
392            emitter.color_start[0], emitter.color_start[1], emitter.color_start[2], emitter.color_start[3],
393            emitter.color_end[0], emitter.color_end[1], emitter.color_end[2], emitter.color_end[3],
394        ];
395    }
396}