Skip to main content

kvas_engine/
grass.rs

1//! Procedural grass. The patch is split into square chunks; each chunk is one merged mesh with its
2//! own bounds, so the renderer frustum-culls whole chunks. Wind animation and distance thinning
3//! happen on the GPU in `builtin:grass`; the meshes are built once.
4//!
5//! Chunks are `Transient` child entities: they never appear in the hierarchy or in saved scenes.
6
7use crate::components::*;
8use crate::hierarchy;
9use crate::mesh::{MeshData, Vertex};
10use crate::procedural::{signature, Rng};
11use crate::reflect::Field;
12use glam::Vec3;
13use kvas_ecs::{Entity, World};
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17/// Seeded grass patch split into transient mesh chunks for frustum culling.
18/// Wind is animated by the grass shader; geometry is rebuilt when parameters change.
19#[derive(Clone, Serialize, Deserialize)]
20#[serde(default)]
21pub struct Grass {
22    /// Blades in the whole patch.
23    pub count: i32,
24    /// Patch size in metres, centred on the entity.
25    pub width: f32,
26    /// Patch depth in metres along the local Z axis.
27    pub depth: f32,
28    /// Mean blade height in metres.
29    pub blade_height: f32,
30    /// Relative randomized variation of blade height.
31    pub height_variation: f32,
32    /// Blade base width in metres.
33    pub blade_width: f32,
34    /// Resting lean of the blades.
35    pub lean: f32,
36    /// Deterministic patch seed; identical parameters produce identical blades.
37    pub seed: i32,
38    /// RGBA tint at the blade base.
39    pub base_color: [f32; 4],
40    /// RGBA tint at the blade tip.
41    pub tip_color: [f32; 4],
42    /// Shader wind displacement multiplier.
43    pub wind_strength: f32,
44    /// Shader wind animation speed multiplier.
45    pub wind_speed: f32,
46    /// Chunk edge in metres: smaller chunks cull better, larger ones need fewer draw calls.
47    pub chunk_size: f32,
48    /// Distance where blades start thinning out, and where the grass is gone.
49    pub fade_start: f32,
50    /// World-space distance where all grass blades have faded out.
51    pub fade_end: f32,
52    /// 0..1: patchy dry / lush colour variation across the meadow.
53    pub patchiness: f32,
54    /// Fill an ellipse inside width x depth instead of the whole rectangle (soft, thinning edge).
55    pub round: bool,
56    /// Share of blades that carry a small wild flower, 0..0.3.
57    pub flowers: f32,
58    #[serde(skip)]
59    #[doc(hidden)]
60    pub sig: u64,
61}
62
63impl Default for Grass {
64    fn default() -> Self {
65        Self {
66            count: 40_000,
67            width: 30.0,
68            depth: 30.0,
69            blade_height: 0.55,
70            height_variation: 0.6,
71            blade_width: 0.085,
72            lean: 0.3,
73            seed: 1,
74            base_color: [0.04, 0.2, 0.1, 1.0],
75            tip_color: [0.72, 0.9, 0.3, 1.0],
76            wind_strength: 0.5,
77            wind_speed: 1.5,
78            chunk_size: 8.0,
79            fade_start: 28.0,
80            fade_end: 70.0,
81            patchiness: 0.7,
82            round: false,
83            flowers: 0.0,
84            sig: 0,
85        }
86    }
87}
88
89impl Component for Grass {
90    fn fields(&mut self) -> Vec<Field<'_>> {
91        vec![
92            Field::i32("count", &mut self.count),
93            Field::f32("width", &mut self.width),
94            Field::f32("depth", &mut self.depth),
95            Field::f32("blade_height", &mut self.blade_height),
96            Field::f32("height_variation", &mut self.height_variation),
97            Field::f32("blade_width", &mut self.blade_width),
98            Field::f32("lean", &mut self.lean),
99            Field::i32("seed", &mut self.seed),
100            Field::color("base_color", &mut self.base_color),
101            Field::color("tip_color", &mut self.tip_color),
102            Field::f32("patchiness", &mut self.patchiness),
103            Field::bool("round", &mut self.round),
104            Field::f32("flowers", &mut self.flowers),
105            Field::f32("wind_strength", &mut self.wind_strength),
106            Field::f32("wind_speed", &mut self.wind_speed),
107            Field::f32("chunk_size", &mut self.chunk_size),
108            Field::f32("fade_start", &mut self.fade_start),
109            Field::f32("fade_end", &mut self.fade_end),
110        ]
111    }
112}
113
114/// Vertices per blade: four rows of two plus the tip.
115pub const BLADE_VERTICES: usize = 9;
116/// Triangles per blade, both sides.
117pub const BLADE_TRIANGLES: usize = 14;
118
119/// Grid of chunks covering the patch: `(columns, rows)`.
120pub fn chunk_grid(g: &Grass) -> (usize, usize) {
121    let size = g.chunk_size.max(1.0);
122    ((g.width / size).ceil().clamp(1.0, 64.0) as usize, (g.depth / size).ceil().clamp(1.0, 64.0) as usize)
123}
124
125/// Builds the blades of chunk `(ix, iz)`; vertices are relative to the chunk centre.
126pub fn chunk_mesh(g: &Grass, ix: usize, iz: usize) -> (MeshData, Vec3) {
127    let (nx, nz) = chunk_grid(g);
128    let (cw, cd) = (g.width / nx as f32, g.depth / nz as f32);
129    let center = Vec3::new(-g.width * 0.5 + (ix as f32 + 0.5) * cw, 0.0, -g.depth * 0.5 + (iz as f32 + 0.5) * cd);
130    let share = (cw * cd) / (g.width * g.depth).max(1e-3);
131    let count = ((g.count.clamp(1, 400_000) as f32 * share).round() as usize).max(1);
132    let mut rng = Rng::new((g.seed as u64) << 20 ^ (ix as u64) << 8 ^ iz as u64);
133    let mut m = MeshData::default();
134    m.vertices.reserve(count * BLADE_VERTICES);
135    m.indices.reserve(count * BLADE_TRIANGLES * 3);
136    for _ in 0..count {
137        let (x, z) = ((rng.f32() - 0.5) * cw, (rng.f32() - 0.5) * cd);
138        let yaw = rng.f32() * std::f32::consts::TAU;
139        let mut tint = rng.f32();
140        // A few tall tufts break the uniform look.
141        let tuft = if rng.f32() > 0.93 { 1.7 } else { 1.0 };
142        let mut h = g.blade_height * tuft * (1.0 + g.height_variation * (rng.f32() * 2.0 - 1.0)).max(0.15);
143        let edge_roll = rng.f32();
144        let flower_roll = rng.f32();
145        if g.round {
146            // Inside the ellipse only; blades thin out and shorten over the outer fifth.
147            let (ex, ez) = ((center.x + x) / (g.width * 0.5).max(0.01), (center.z + z) / (g.depth * 0.5).max(0.01));
148            let r = (ex * ex + ez * ez).sqrt();
149            if r > 1.0 || (r > 0.8 && edge_roll < (r - 0.8) / 0.2) {
150                continue;
151            }
152            h *= 1.0 - ((r - 0.75) / 0.25).clamp(0.0, 1.0) * 0.55;
153        }
154        // Flower blades: taller and thinner, the shader paints their head (tint offset by 2).
155        let flower = flower_roll < g.flowers.clamp(0.0, 0.3);
156        if flower {
157            h *= 1.25;
158            tint += 2.0;
159        }
160        let lean = g.lean * (0.3 + rng.f32());
161        let (sin, cos) = yaw.sin_cos();
162        let (right, forward) = (Vec3::new(cos, 0.0, -sin), Vec3::new(sin, 0.0, cos));
163        let root = Vec3::new(x, 0.0, z);
164        let base = m.vertices.len() as u32;
165        // Blade outline: widest above the root, tapering to a curved tip.
166        for (t, width) in [(0.0f32, 0.85f32), (0.28, 1.0), (0.58, 0.72), (0.84, 0.38)] {
167            let curve = lean * h * t * t;
168            let center = root + Vec3::Y * (h * t * (1.0 - 0.12 * t)) + forward * curve;
169            let half = g.blade_width * 0.5 * width * if flower { 0.6 } else { 1.0 };
170            let normal = (Vec3::Y * 2.0 + forward * 0.5).normalize();
171            for side in [-1.0f32, 1.0] {
172                m.vertices.push(Vertex { pos: (center + right * half * side).into(), normal: normal.into(), uv: [tint, t] });
173            }
174        }
175        let tip = root + Vec3::Y * h * 0.9 + forward * (lean * h * 1.05);
176        m.vertices.push(Vertex { pos: tip.into(), normal: Vec3::Y.into(), uv: [tint, 1.0] });
177        for (a, b, c) in [(0, 1, 2), (1, 3, 2), (2, 3, 4), (3, 5, 4), (4, 5, 6), (5, 7, 6), (6, 7, 8)] {
178            m.indices.extend([base + a, base + b, base + c]);
179            m.indices.extend([base + c, base + b, base + a]);
180        }
181    }
182    (m, center)
183}
184
185/// Rebuilds changed grass patches and their transient mesh chunks.
186pub fn update(world: &mut World) {
187    // Chunks whose patch is gone.
188    let orphans: Vec<Entity> = world
189        .entities_with::<GrassChunk>()
190        .into_iter()
191        .filter(|&c| hierarchy::parent_of(world, c).is_none_or(|p| !world.is_alive(p) || !world.has::<Grass>(p)))
192        .collect();
193    for c in orphans {
194        world.despawn(c);
195    }
196    for e in world.entities_with::<Grass>() {
197        // Older scenes stored the mesh on the grass entity itself.
198        world.remove::<DynamicMesh>(e);
199        world.remove::<MeshRenderer>(e);
200        let g = world.get::<Grass>(e).unwrap().clone();
201        let sig = signature(&(
202            g.count,
203            g.width.to_bits(),
204            g.depth.to_bits(),
205            g.blade_height.to_bits(),
206            g.height_variation.to_bits(),
207            g.blade_width.to_bits(),
208            g.lean.to_bits(),
209            g.seed,
210            g.chunk_size.to_bits(),
211            g.round,
212            g.flowers.to_bits(),
213        ));
214        let chunks: Vec<Entity> = hierarchy::children_all(world, e).into_iter().filter(|&c| world.has::<GrassChunk>(c)).collect();
215        if world.get::<Grass>(e).unwrap().sig != sig || chunks.is_empty() {
216            for c in &chunks {
217                world.despawn(*c);
218            }
219            let (nx, nz) = chunk_grid(&g);
220            for iz in 0..nz {
221                for ix in 0..nx {
222                    let (mesh, center) = chunk_mesh(&g, ix, iz);
223                    let c = world.spawn();
224                    world.insert(c, Name("Grass chunk".into()));
225                    world.insert(c, Transform::at(center));
226                    world.insert(c, Parent(e));
227                    world.insert(c, Transient);
228                    world.insert(c, GrassChunk);
229                    world.insert(c, DynamicMesh { data: Arc::new(mesh), version: sig });
230                    world.insert(c, MeshRenderer::default());
231                }
232            }
233            world.get_mut::<Grass>(e).unwrap().sig = sig;
234        }
235        // Appearance is applied live to every chunk.
236        let user = [
237            g.wind_strength,
238            g.wind_speed,
239            g.fade_start,
240            g.fade_end.max(g.fade_start + 1.0),
241            g.tip_color[0],
242            g.tip_color[1],
243            g.tip_color[2],
244            g.patchiness,
245        ];
246        for c in hierarchy::children_all(world, e) {
247            if let Some(m) = world.get_mut::<MeshRenderer>(c) {
248                m.shader = "builtin:grass".into();
249                m.color = g.base_color;
250                m.cast_shadows = false;
251                m.roughness = 1.0;
252                m.user = user;
253            }
254        }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::scene::Scene;
262
263    #[test]
264    fn chunks_partition_the_patch_and_keep_the_blade_count() {
265        let g = Grass { count: 4000, width: 20.0, depth: 12.0, chunk_size: 4.0, ..Default::default() };
266        let (nx, nz) = chunk_grid(&g);
267        assert_eq!((nx, nz), (5, 3));
268        let mut blades = 0;
269        for iz in 0..nz {
270            for ix in 0..nx {
271                let (m, center) = chunk_mesh(&g, ix, iz);
272                blades += m.vertices.len() / BLADE_VERTICES;
273                let (min, max) = m.bounds();
274                assert!(
275                    min.x > -2.8 && max.x < 2.8 && min.z > -2.8 && max.z < 2.8,
276                    "chunk stays near its cell (blades may lean out a little): {min} {max}"
277                );
278                assert!(center.x.abs() <= 10.0 && center.z.abs() <= 6.0);
279            }
280        }
281        assert!((3900..=4100).contains(&blades), "{blades}");
282    }
283
284    #[test]
285    fn same_seed_same_field_and_triangle_budget() {
286        let g = Grass { count: 800, chunk_size: 100.0, ..Default::default() };
287        let (a, _) = chunk_mesh(&g, 0, 0);
288        assert_eq!(a.vertices[10].pos, chunk_mesh(&g, 0, 0).0.vertices[10].pos);
289        assert_ne!(a.vertices[10].pos, chunk_mesh(&Grass { seed: 2, ..g.clone() }, 0, 0).0.vertices[10].pos);
290        assert_eq!(a.indices.len() / 3, 800 * BLADE_TRIANGLES);
291    }
292
293    #[test]
294    fn chunks_are_transient_children_that_do_not_leak_into_scenes() {
295        let reg = crate::registry::Registry::default();
296        let mut scene = Scene::default();
297        let e = hierarchy::spawn_named(&mut scene.world, "Lawn");
298        scene.world.insert(e, Grass { count: 2000, width: 16.0, depth: 16.0, ..Default::default() });
299        update(&mut scene.world);
300        let all = hierarchy::children_all(&scene.world, e);
301        assert_eq!(all.len(), 4, "16x16 patch in 8 m chunks");
302        assert!(hierarchy::children(&scene.world, e).is_empty(), "hidden from the hierarchy");
303        let json = scene.to_json(&reg);
304        assert_eq!(json.matches("\"type\": \"Name\"").count(), 1, "only the Lawn is saved");
305        // Changing only appearance keeps the chunks; changing the layout rebuilds them.
306        scene.world.get_mut::<Grass>(e).unwrap().wind_strength = 2.0;
307        update(&mut scene.world);
308        assert_eq!(hierarchy::children_all(&scene.world, e), all);
309        scene.world.get_mut::<Grass>(e).unwrap().chunk_size = 4.0;
310        update(&mut scene.world);
311        assert_eq!(hierarchy::children_all(&scene.world, e).len(), 16);
312        scene.world.despawn(e);
313        update(&mut scene.world);
314        assert_eq!(scene.world.count::<GrassChunk>(), 0, "orphan chunks are removed");
315    }
316}