Skip to main content

kvas_engine/
voxel.rs

1//! Voxel effects built on one fast mesher: smoke that rises and swirls, and drifting clouds.
2//!
3//! Both produce a single `DynamicMesh` (one draw call). Hidden faces between touching voxels are
4//! never emitted, so a large cloud costs only its visible surface.
5
6use crate::components::*;
7use crate::mesh::{MeshData, Vertex};
8use crate::noise::{Noise, NoiseKind};
9use crate::procedural::signature;
10use crate::reflect::Field;
11use glam::Vec3;
12use kvas_ecs::World;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15pub use crate::voxel_mesh::mesh_grid_greedy;
16
17const FACES: [(Vec3, Vec3, Vec3); 6] = [
18    (Vec3::X, Vec3::Y, Vec3::Z),
19    (Vec3::NEG_X, Vec3::Z, Vec3::Y),
20    (Vec3::Y, Vec3::Z, Vec3::X),
21    (Vec3::NEG_Y, Vec3::X, Vec3::Z),
22    (Vec3::Z, Vec3::X, Vec3::Y),
23    (Vec3::NEG_Z, Vec3::Y, Vec3::X),
24];
25
26/// Meshes a voxel grid. `density(x, y, z)` > `threshold` means filled; `uv.x` carries the density.
27/// With `shrink` every voxel is scaled by its density (soft puffs, all faces emitted); otherwise
28/// full cubes are merged and faces between neighbours are culled.
29pub fn mesh_grid(
30    dim: [usize; 3],
31    cell: f32,
32    origin: Vec3,
33    threshold: f32,
34    shrink: bool,
35    density: impl Fn(usize, usize, usize) -> f32,
36) -> MeshData {
37    if !shrink { return mesh_grid_greedy(dim, cell, origin, threshold, density); }
38    let filled = |x: i64, y: i64, z: i64| -> f32 {
39        if x < 0 || y < 0 || z < 0 || x >= dim[0] as i64 || y >= dim[1] as i64 || z >= dim[2] as i64 {
40            return 0.0;
41        }
42        density(x as usize, y as usize, z as usize)
43    };
44    let mut m = MeshData::default();
45    for z in 0..dim[2] as i64 {
46        for y in 0..dim[1] as i64 {
47            for x in 0..dim[0] as i64 {
48                let d = filled(x, y, z);
49                if d <= threshold {
50                    continue;
51                }
52                let center = origin + (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * cell;
53                let half = cell * 0.5 * if shrink { 0.35 + 0.65 * d.clamp(0.0, 1.0) } else { 1.0 };
54                for (n, u, v) in FACES {
55                    if !shrink {
56                        let o = n.as_ivec3();
57                        if filled(x + o.x as i64, y + o.y as i64, z + o.z as i64) > threshold {
58                            continue;
59                        }
60                    }
61                    let base = m.vertices.len() as u32;
62                    for (su, sv, uvc) in [(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)] {
63                        let _ = uvc;
64                        let p = center + n * half + u * (half * su) + v * (half * sv);
65                        m.vertices.push(Vertex { pos: p.into(), normal: n.into(), uv: [d.clamp(0.0, 1.0), 0.5] });
66                    }
67                    m.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
68                }
69            }
70        }
71    }
72    m
73}
74
75// ------------------------------------------------------------------------------------ smoke
76
77/// Voxel smoke emitter and simulation parameters. Runtime density is not serialized.
78#[derive(Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct VoxelSmoke {
81    /// Grid cells along each axis.
82    pub grid: i32,
83    /// Edge length of one cell in metres.
84    pub cell: f32,
85    /// Upward speed, cells per second.
86    pub rise: f32,
87    /// Strength of seeded lateral noise in the smoke velocity field.
88    pub turbulence: f32,
89    /// Density added at the source per second.
90    pub emit: f32,
91    /// Fraction of density lost per second.
92    pub dissipation: f32,
93    /// Minimum density at which a voxel contributes visible geometry.
94    pub threshold: f32,
95    /// Deterministic turbulence seed.
96    pub seed: i32,
97    #[serde(skip)]
98    #[doc(hidden)]
99    pub state: Option<SmokeState>,
100    #[serde(skip)]
101    #[doc(hidden)]
102    pub sig: u64,
103}
104
105impl Default for VoxelSmoke {
106    fn default() -> Self {
107        Self {
108            grid: 20,
109            cell: 0.22,
110            rise: 3.0,
111            turbulence: 2.2,
112            emit: 3.5,
113            dissipation: 0.28,
114            threshold: 0.07,
115            seed: 3,
116            state: None,
117            sig: 0,
118        }
119    }
120}
121
122impl Component for VoxelSmoke {
123    fn fields(&mut self) -> Vec<Field<'_>> {
124        vec![
125            Field::i32("grid", &mut self.grid),
126            Field::f32("cell", &mut self.cell),
127            Field::f32("rise", &mut self.rise),
128            Field::f32("turbulence", &mut self.turbulence),
129            Field::f32("emit", &mut self.emit),
130            Field::f32("dissipation", &mut self.dissipation),
131            Field::f32("threshold", &mut self.threshold),
132            Field::i32("seed", &mut self.seed),
133        ]
134    }
135}
136
137/// Simulation state (internal).
138#[doc(hidden)]
139#[derive(Clone)]
140pub struct SmokeState {
141    n: usize,
142    density: Vec<f32>,
143    scratch: Vec<f32>,
144    time: f32,
145    version: u64,
146}
147
148impl SmokeState {
149    fn at(&self, x: i64, y: i64, z: i64) -> f32 {
150        let n = self.n as i64;
151        if x < 0 || y < 0 || z < 0 || x >= n || y >= n || z >= n {
152            return 0.0;
153        }
154        self.density[(z as usize * self.n + y as usize) * self.n + x as usize]
155    }
156
157    fn sample(&self, p: Vec3) -> f32 {
158        let (f, i) = (p - p.floor(), p.floor());
159        let (x, y, z) = (i.x as i64, i.y as i64, i.z as i64);
160        let l = |a: f32, b: f32, t: f32| a + (b - a) * t;
161        l(
162            l(l(self.at(x, y, z), self.at(x + 1, y, z), f.x), l(self.at(x, y + 1, z), self.at(x + 1, y + 1, z), f.x), f.y),
163            l(l(self.at(x, y, z + 1), self.at(x + 1, y, z + 1), f.x), l(self.at(x, y + 1, z + 1), self.at(x + 1, y + 1, z + 1), f.x), f.y),
164            f.z,
165        )
166    }
167
168    /// One semi-Lagrangian step: every cell looks back along the flow to see what arrives here.
169    fn step(&mut self, s: &VoxelSmoke, dt: f32) {
170        let n = self.n;
171        let noise = Noise::new(NoiseKind::Perlin).with_seed(s.seed as u32).with_octaves(2);
172        self.time += dt;
173        let t = self.time * 0.6;
174        let source = (n as f32 * 0.5, 1.0, n as f32 * 0.5);
175        for z in 0..n {
176            for y in 0..n {
177                for x in 0..n {
178                    let p = Vec3::new(x as f32, y as f32, z as f32);
179                    let flow = Vec3::new(
180                        noise.sample3(p.x * 0.18, p.y * 0.18 - t, p.z * 0.18 + 5.0),
181                        0.0,
182                        noise.sample3(p.x * 0.18 + 9.0, p.y * 0.18 - t, p.z * 0.18),
183                    ) * s.turbulence
184                        + Vec3::Y * s.rise * (0.6 + 0.4 * (y as f32 / n as f32));
185                    let back = p + Vec3::splat(0.5) - flow * dt;
186                    let mut d = self.sample(back - Vec3::splat(0.5)) * (1.0 - s.dissipation * dt).max(0.0);
187                    let dx = p.x + 0.5 - source.0;
188                    let dz = p.z + 0.5 - source.2;
189                    if y < 2 && dx * dx + dz * dz < (n as f32 * 0.12).powi(2) + 1.0 {
190                        d += s.emit * dt * (1.0 - y as f32 * 0.4);
191                    }
192                    self.scratch[(z * n + y) * n + x] = d.clamp(0.0, 1.0);
193                }
194            }
195        }
196        std::mem::swap(&mut self.density, &mut self.scratch);
197    }
198
199    fn mesh(&self, s: &VoxelSmoke) -> MeshData {
200        let n = self.n;
201        let origin = Vec3::new(-(n as f32) * 0.5 * s.cell, 0.0, -(n as f32) * 0.5 * s.cell);
202        mesh_grid([n, n, n], s.cell, origin, s.threshold, true, |x, y, z| self.density[(z * n + y) * n + x])
203    }
204}
205
206/// Creates/rebuilds smoke meshes and optionally advances the density simulation by dt seconds.
207pub fn update_smoke(world: &mut World, dt: f32, simulate: bool) {
208    for e in world.entities_with::<VoxelSmoke>() {
209        let s = world.get_mut::<VoxelSmoke>(e).unwrap();
210        let sig = signature(&(s.grid, s.seed));
211        if s.state.is_none() || s.sig != sig {
212            let n = s.grid.clamp(6, 40) as usize;
213            let mut st = SmokeState { n, density: vec![0.0; n * n * n], scratch: vec![0.0; n * n * n], time: 0.0, version: 0 };
214            let params = s.clone();
215            for _ in 0..90 {
216                st.step(&params, 1.0 / 30.0); // pre-warm so the editor shows a plume right away
217            }
218            s.state = Some(st);
219            s.sig = sig;
220        }
221        let mut st = s.state.take().unwrap();
222        if simulate && dt > 0.0 {
223            st.step(s, dt.min(0.05));
224        }
225        st.version += 1;
226        let (mesh, version) = (st.mesh(s), st.version);
227        s.state = Some(st);
228        world.insert(e, DynamicMesh { data: Arc::new(mesh), version });
229        if !world.has::<MeshRenderer>(e) {
230            world.insert(e, MeshRenderer::default());
231        }
232        let m = world.get_mut::<MeshRenderer>(e).unwrap();
233        m.shader = "builtin:smoke".into();
234        m.cast_shadows = false;
235        if m.color[3] > 0.95 {
236            m.color = [0.78, 0.78, 0.8, 0.9];
237        }
238    }
239}
240
241// ------------------------------------------------------------------------------------ clouds
242
243/// Volumetric clouds: a voxel density grid (generated from fractal noise) that the GPU ray-marches
244/// with sun scattering and self-shadowing. The entity draws only an inverted bounding box.
245#[derive(Clone, Serialize, Deserialize)]
246#[serde(default)]
247pub struct VoxelClouds {
248    /// Extent in metres (x, y, z).
249    pub size: Vec3,
250    /// Horizontal voxel size in metres (smaller = finer detail, bigger texture).
251    pub cell: f32,
252    /// 0..1, how much of the volume is cloud.
253    pub coverage: f32,
254    /// Noise features per 100 m.
255    pub scale: f32,
256    /// Deterministic density-field seed.
257    pub seed: i32,
258    /// Wind drift in metres per second (the density scrolls through the box).
259    pub drift: Vec3,
260    /// Opacity multiplier.
261    pub density: f32,
262    /// Bends light rays along the density gradient: brighter, glassier edges (0 = off).
263    pub refraction: f32,
264    #[serde(skip)]
265    #[doc(hidden)]
266    pub sig: u64,
267}
268
269impl Default for VoxelClouds {
270    fn default() -> Self {
271        Self {
272            size: Vec3::new(240.0, 36.0, 240.0),
273            cell: 3.5,
274            coverage: 0.36,
275            scale: 3.2,
276            seed: 7,
277            drift: Vec3::new(2.0, 0.0, 0.6),
278            density: 0.8,
279            refraction: 0.25,
280            sig: 0,
281        }
282    }
283}
284
285impl Component for VoxelClouds {
286    fn fields(&mut self) -> Vec<Field<'_>> {
287        vec![
288            Field::vec3("size", &mut self.size),
289            Field::f32("cell", &mut self.cell),
290            Field::f32("coverage", &mut self.coverage),
291            Field::f32("scale", &mut self.scale),
292            Field::i32("seed", &mut self.seed),
293            Field::vec3("drift", &mut self.drift),
294            Field::f32("density", &mut self.density),
295            Field::f32("refraction", &mut self.refraction),
296        ]
297    }
298}
299
300/// Prefix for procedural three-dimensional cloud density texture keys.
301pub const CLOUD_VOLUME_PREFIX: &str = "cloudvol:";
302
303/// Describes the density grid of a cloud layer; its `key()` goes into `MeshRenderer.texture`.
304#[derive(Clone, Copy, Debug, PartialEq)]
305pub struct CloudVolume {
306    /// Full world-space box extents in metres.
307    pub size: Vec3,
308    /// Horizontal density sampling interval in metres.
309    pub cell: f32,
310    /// Cloud occupancy fraction, clamped to 0..1 during generation.
311    pub coverage: f32,
312    /// Noise feature scale multiplier per 100 metres.
313    pub scale: f32,
314    /// Deterministic density-field seed.
315    pub seed: i32,
316}
317
318impl CloudVolume {
319    /// Copies density parameters from a cloud component.
320    pub fn of(c: &VoxelClouds) -> Self {
321        Self { size: c.size, cell: c.cell, coverage: c.coverage, scale: c.scale, seed: c.seed }
322    }
323
324    /// Encodes the density parameters as a renderer texture cache key.
325    pub fn key(&self) -> String {
326        format!(
327            "{CLOUD_VOLUME_PREFIX}{},{},{},{},{},{},{}",
328            self.size.x, self.size.y, self.size.z, self.cell, self.coverage, self.scale, self.seed
329        )
330    }
331
332    /// Parses a cloud-volume cache key, or returns None for malformed input.
333    pub fn parse(key: &str) -> Option<CloudVolume> {
334        let n: Vec<&str> = key.strip_prefix(CLOUD_VOLUME_PREFIX)?.split(',').collect();
335        if n.len() != 7 {
336            return None;
337        }
338        let real = |i: usize| n[i].parse::<f32>().ok().filter(|v| v.is_finite());
339        Some(CloudVolume {
340            size: Vec3::new(real(0)?, real(1)?, real(2)?),
341            cell: real(3)?,
342            coverage: real(4)?,
343            scale: real(5)?,
344            seed: n[6].parse().ok()?,
345        })
346    }
347
348    /// Grid resolution (x, y, z). Vertical resolution is finer than horizontal so cloud tops stay round.
349    pub fn dims(&self) -> [u32; 3] {
350        let cell = self.cell.max(0.5);
351        [
352            (self.size.x / cell).ceil().clamp(8.0, 160.0) as u32,
353            (self.size.y / (cell * 0.5)).ceil().clamp(6.0, 64.0) as u32,
354            (self.size.z / cell).ceil().clamp(8.0, 160.0) as u32,
355        ]
356    }
357
358    /// 8-bit density per voxel, x fastest, then y, then z.
359    pub fn generate(&self) -> ([u32; 3], Vec<u8>) {
360        let dim = self.dims();
361        let base = Noise::new(NoiseKind::Perlin).with_seed(self.seed as u32).with_octaves(5);
362        let erode = Noise::new(NoiseKind::Worley).with_seed((self.seed as u32).wrapping_add(1)).with_octaves(2);
363        let freq = self.scale / 100.0;
364        let threshold = 1.0 - self.coverage.clamp(0.0, 1.0);
365        let mut out = Vec::with_capacity((dim[0] * dim[1] * dim[2]) as usize);
366        for z in 0..dim[2] {
367            for y in 0..dim[1] {
368                for x in 0..dim[0] {
369                    let (fx, fy, fz) = (x as f32 / dim[0] as f32, y as f32 / dim[1] as f32, z as f32 / dim[2] as f32);
370                    let p = Vec3::new(fx * self.size.x, fy * self.size.y, fz * self.size.z) * freq;
371                    // Flat bottoms, billowing tops.
372                    let bottom = (fy / 0.18).clamp(0.0, 1.0);
373                    let top = (1.0 - (fy - 0.45) / 0.55).clamp(0.0, 1.0).powf(0.8);
374                    let shape = bottom * top;
375                    let n = base.sample01(p.x, p.y * 1.7, p.z) * 0.8 + erode.sample01(p.x * 3.1, p.y * 3.1, p.z * 3.1) * 0.2;
376                    let d = ((n * shape - threshold * 0.55) * 2.6).clamp(0.0, 1.0);
377                    out.push((d * 255.0) as u8);
378                }
379            }
380        }
381        (dim, out)
382    }
383}
384
385/// Inverted box mesh: its inside faces face the camera whether it is outside or inside the volume.
386fn inverted_box(half: Vec3) -> MeshData {
387    let mut m = MeshData::primitive("cube").unwrap_or_default();
388    for v in &mut m.vertices {
389        v.pos = (Vec3::from(v.pos) * half * 2.0).into();
390    }
391    for tri in m.indices.as_chunks_mut::<3>().0.iter_mut() {
392        tri.swap(1, 2);
393    }
394    m
395}
396
397/// Refreshes cloud proxy geometry and shader parameters; drift advances only while simulating.
398pub fn update_clouds(world: &mut World, dt: f32, simulate: bool) {
399    let _ = (dt, simulate);
400    for e in world.entities_with::<VoxelClouds>() {
401        let c = world.get::<VoxelClouds>(e).unwrap().clone();
402        let volume = CloudVolume::of(&c);
403        let sig = signature(&(c.size.to_array(), c.cell.to_bits(), c.coverage.to_bits(), c.scale.to_bits(), c.seed));
404        if world.get::<VoxelClouds>(e).unwrap().sig != sig || !world.has::<DynamicMesh>(e) {
405            world.insert(e, DynamicMesh { data: Arc::new(inverted_box(c.size * 0.5)), version: sig });
406            world.get_mut::<VoxelClouds>(e).unwrap().sig = sig;
407        }
408        if !world.has::<MeshRenderer>(e) {
409            world.insert(e, MeshRenderer::default());
410        }
411        let m = world.get_mut::<MeshRenderer>(e).unwrap();
412        m.shader = "builtin:clouds".into();
413        m.texture = volume.key();
414        m.cast_shadows = false;
415        m.color = [1.0, 1.0, 1.0, 0.9]; // below 1 so the box is drawn in the transparent pass
416        let half = c.size * 0.5;
417        m.user = [half.x, half.y, half.z, c.density, c.drift.x, c.drift.y, c.drift.z, c.refraction];
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn a_single_voxel_is_a_cube_and_touching_voxels_hide_their_shared_faces() {
427        let one = mesh_grid([1, 1, 1], 1.0, Vec3::ZERO, 0.5, false, |_, _, _| 1.0);
428        assert_eq!(one.indices.len() / 3, 12);
429        let two = mesh_grid([2, 1, 1], 1.0, Vec3::ZERO, 0.5, false, |_, _, _| 1.0);
430        assert_eq!(two.indices.len() / 3, 12, "coplanar outer faces merge into six quads");
431    }
432
433    #[test]
434    fn solid_block_only_pays_for_its_surface() {
435        let n = 10;
436        let m = mesh_grid([n, n, n], 1.0, Vec3::ZERO, 0.5, false, |_, _, _| 1.0);
437        assert_eq!(m.indices.len() / 3, 12, "the entire shell merges into six quads");
438    }
439
440    #[test]
441    fn smoke_rises_and_fades() {
442        let mut s = VoxelSmoke::default();
443        s.dissipation = 0.5;
444        let mut st = SmokeState { n: 20, density: vec![0.0; 8000], scratch: vec![0.0; 8000], time: 0.0, version: 0 };
445        for _ in 0..180 {
446            st.step(&s, 1.0 / 30.0);
447        }
448        let n = st.n;
449        let mass_at = |y0: usize, y1: usize| {
450            (0..n)
451                .flat_map(|z| (y0..y1).flat_map(move |y| (0..n).map(move |x| (x, y, z))))
452                .map(|(x, y, z)| st.density[(z * n + y) * n + x])
453                .sum::<f32>()
454        };
455        assert!(mass_at(0, 4) > 1.0, "smoke exists near the source");
456        assert!(mass_at(8, 20) > 0.5, "and has risen into the upper grid");
457        assert!(st.density.iter().all(|d| (0.0..=1.0).contains(d)));
458    }
459
460    #[test]
461    fn cloud_volume_has_clouds_and_gaps_and_roundtrips_its_key() {
462        let vol = CloudVolume::of(&VoxelClouds { size: Vec3::new(120.0, 30.0, 120.0), cell: 6.0, ..Default::default() });
463        assert_eq!(CloudVolume::parse(&vol.key()), Some(vol));
464        let (dim, data) = vol.generate();
465        assert_eq!(data.len(), (dim[0] * dim[1] * dim[2]) as usize);
466        let filled = data.iter().filter(|&&d| d > 40).count() as f32 / data.len() as f32;
467        assert!(filled > 0.03 && filled < 0.7, "a mix of cloud and clear sky: {filled}");
468        // Cloud bases are flat: the lowest layer is nearly empty, the middle is denser.
469        let layer = |y: u32| {
470            (0..dim[2])
471                .flat_map(|z| (0..dim[0]).map(move |x| (x, z)))
472                .map(|(x, z)| data[((z * dim[1] + y) * dim[0] + x) as usize] as u32)
473                .sum::<u32>()
474        };
475        assert!(layer(0) < layer(dim[1] / 3), "bottom layer is thinner than the body");
476    }
477
478    #[test]
479    fn cloud_box_faces_inwards() {
480        let m = inverted_box(Vec3::new(5.0, 2.0, 5.0));
481        let tri = &m.indices[0..3];
482        let p = |i: u32| Vec3::from(m.vertices[i as usize].pos);
483        let n = (p(tri[1]) - p(tri[0])).cross(p(tri[2]) - p(tri[0]));
484        let center_to_face = p(tri[0]);
485        assert!(n.dot(center_to_face) < 0.0, "winding faces the centre of the box");
486    }
487}