Skip to main content

celestialsim/
scatter_mesh.rs

1//! Built-in procedural grass blade (CEL-73): the default scatter mesh when a
2//! `CesScatterLayer` has no mesh assigned. A tapered, quadratically-bent blade
3//! with a dark→light-green vertex-colour gradient, so a dense field reads as
4//! grass with zero committed assets.
5//!
6//! The look follows the hexaquo "full-geometry grass" approach, baking into the
7//! static mesh the two normal tricks that make flat blade geometry shade like a
8//! rounded 3-D blade (no custom shader needed):
9//! - **Rounded width normals** — the left/right edge normals fan outward (as if
10//!   the cross-section were a cylinder), so interpolation across the blade width
11//!   sweeps the normal and the blade catches a soft highlight down its length.
12//! - **Tip-up normals** — the forward normal lerps toward vertical from base to
13//!   tip, so tips face the sky and pick up a specular glint.
14//!
15//! What a static mesh can't carry (would need a dedicated grass shader): wind
16//! sway, view-space thickening, and per-patch colour/scale noise. Per-instance
17//! scale jitter (0.8–1.2) is applied by `ScatterPlace.slang` instead.
18
19use godot::classes::base_material_3d::{CullMode, Flags};
20use godot::classes::mesh::{ArrayType, PrimitiveType};
21use godot::classes::{ArrayMesh, StandardMaterial3D};
22use godot::prelude::*;
23
24/// Cross-section rows base→tip: `(height, half-width, forward bend)`. Height ≈ 1
25/// world unit (the placement scale sizes it). Bend is quadratic in height.
26const ROWS: [(f32, f32, f32); 5] = [
27    (0.00, 0.050, 0.000),
28    (0.35, 0.046, 0.020),
29    (0.62, 0.038, 0.065),
30    (0.84, 0.026, 0.130),
31    (1.00, 0.000, 0.220),
32];
33
34/// Base→tip vertex colours (dark root → light tip; doubles as base AO).
35const COLORS: [Color; 5] = [
36    Color::from_rgb(0.05, 0.20, 0.03),
37    Color::from_rgb(0.08, 0.28, 0.04),
38    Color::from_rgb(0.14, 0.40, 0.06),
39    Color::from_rgb(0.24, 0.52, 0.09),
40    Color::from_rgb(0.38, 0.62, 0.13),
41];
42
43/// How far the edge normals fan outward from the blade face (radians). ~40°
44/// gives a clearly rounded cross-section without the edges facing sideways.
45const ROUND_ANGLE: f32 = 0.7;
46
47/// Build the blade `ArrayMesh` with rounded + tip-up baked normals and its own
48/// double-sided vertex-colour material.
49pub fn grass_blade_mesh() -> Gd<ArrayMesh> {
50    let mut verts = PackedVector3Array::new();
51    let mut normals = PackedVector3Array::new();
52    let mut colors = PackedColorArray::new();
53
54    let (sin_a, cos_a) = ROUND_ANGLE.sin_cos();
55    let n_rows = ROWS.len();
56    for (row, &(y, hw, bend)) in ROWS.iter().enumerate() {
57        let t = row as f32 / (n_rows - 1) as f32;
58        // Face normal tilts from +Z (base) toward +Y (tip) so tips face the sky.
59        let forward = (Vector3::new(0.0, 0.0, 1.0).lerp(Vector3::new(0.0, 1.0, 0.0), t)).normalized();
60        let tangent = Vector3::new(1.0, 0.0, 0.0);
61        // Edge normals fan ±ROUND_ANGLE around the face normal (cylinder fake).
62        let left_n = (forward * cos_a - tangent * sin_a).normalized();
63        let right_n = (forward * cos_a + tangent * sin_a).normalized();
64        if row + 1 < n_rows {
65            verts.push(Vector3::new(-hw, y, bend));
66            verts.push(Vector3::new(hw, y, bend));
67            normals.push(left_n);
68            normals.push(right_n);
69            colors.push(COLORS[row]);
70            colors.push(COLORS[row]);
71        } else {
72            verts.push(Vector3::new(0.0, y, bend)); // tip
73            normals.push(forward);
74            colors.push(COLORS[row]);
75        }
76    }
77
78    // Two tris per quad band between consecutive rows; the top band fans to the
79    // single tip vertex. Row r's pair = (2r, 2r+1); tip = 2*(n_rows-1).
80    let mut indices: Vec<i32> = Vec::new();
81    for row in 0..n_rows - 1 {
82        let a = (2 * row) as i32; // left
83        let b = a + 1; // right
84        if row + 2 < n_rows {
85            let c = (2 * (row + 1)) as i32; // next left
86            let d = c + 1; // next right
87            indices.extend_from_slice(&[a, c, b, b, c, d]);
88        } else {
89            let tip = (2 * (n_rows - 1)) as i32;
90            indices.extend_from_slice(&[a, tip, b]);
91        }
92    }
93    let indices: PackedInt32Array = indices.into_iter().collect();
94
95    let mut arrays = VarArray::new();
96    arrays.resize(ArrayType::MAX.ord() as usize, &Variant::nil());
97    arrays.set(ArrayType::VERTEX.ord() as usize, &verts.to_variant());
98    arrays.set(ArrayType::NORMAL.ord() as usize, &normals.to_variant());
99    arrays.set(ArrayType::COLOR.ord() as usize, &colors.to_variant());
100    arrays.set(ArrayType::INDEX.ord() as usize, &indices.to_variant());
101
102    let mut material = StandardMaterial3D::new_gd();
103    material.set_flag(Flags::ALBEDO_FROM_VERTEX_COLOR, true);
104    material.set_cull_mode(CullMode::DISABLED);
105    material.set_roughness(0.85);
106    material.set_specular(0.3);
107
108    let mut mesh = ArrayMesh::new_gd();
109    mesh.add_surface_from_arrays(PrimitiveType::TRIANGLES, &arrays);
110    mesh.surface_set_material(0, &material.upcast::<godot::classes::Material>());
111    mesh
112}