use bevy::math::{Vec2, Vec3, Vec4};
use std::f32::consts::{PI, TAU};
use crate::growth::GrowthParams;
use crate::mesh::MeshData;
use crate::params::{FoliageParams, FoliagePlacement, LeafOrientation, LeafStyle, TrunkParams};
use crate::rng::SeededRng;
use crate::skeleton::{Node, NodeKind, Skeleton, trunk_depth};
#[cfg(feature = "textures")]
use crate::textures::leaves::LEAF_SETS;
const LEAF_TINT_SALT: i32 = 0x7135_1EAF;
const LEAF_HUE_SALT: i32 = 0x4E0E_1EAF;
#[derive(Clone, Debug, PartialEq)]
pub struct LeafPoint {
pub position: Vec3,
pub direction: Vec3,
pub size: f32,
pub rotation: f32,
pub tint: f32,
pub hue: f32,
}
pub fn collect_leaf_points(
skeleton: &Skeleton,
foliage: &FoliageParams,
growth: &GrowthParams,
trunk: &TrunkParams,
seed: i32,
) -> Vec<LeafPoint> {
let mut points = Vec::new();
if !foliage.enabled {
return points;
}
for i in 0..skeleton.nodes.len() {
leaf_points_for_node(skeleton, i, foliage, growth, trunk, seed, &mut points);
}
points
}
pub(crate) fn node_leaf_scale(node: &Node, growth: &GrowthParams) -> f32 {
if growth.leaf_threshold >= 1.0 {
if node.progress >= 1.0 { 1.0 } else { 0.0 }
} else {
((node.progress - growth.leaf_threshold) / (1.0 - growth.leaf_threshold)).clamp(0.0, 1.0)
}
}
pub(crate) fn distributed_leaf_count(foliage: &FoliageParams, length: f32, height_ratio: f32) -> i32 {
let height_mod = height_modifier(height_ratio, foliage);
((foliage.density * length * height_mod) as i32).max(1)
}
#[allow(clippy::too_many_arguments)]
pub fn leaf_points_for_node(
skeleton: &Skeleton,
i: usize,
foliage: &FoliageParams,
growth: &GrowthParams,
trunk: &TrunkParams,
seed: i32,
points: &mut Vec<LeafPoint>,
) {
let node = &skeleton.nodes[i];
if node.kind == NodeKind::Trunk || node.length <= 0.0 {
return;
}
let leaf_scale = node_leaf_scale(node, growth);
if leaf_scale <= 0.0 {
return;
}
let full_tip_radius = if node.radius_progress > 0.0 {
node.tip_radius / node.radius_progress
} else {
node.tip_radius
};
if trunk.radius > 0.0
&& full_tip_radius / trunk.radius > foliage.radius_threshold
&& foliage.placement != FoliagePlacement::AllBranches
{
return;
}
let mut rng = SeededRng::from_parts(seed, node.id);
let first = points.len();
let ctx = NodeCtx {
wood: node,
end: skeleton.end(i),
direction: node.direction,
length: node.length,
height_ratio: node.height_ratio,
trunk,
seed,
leaf_scale,
};
match foliage.placement {
FoliagePlacement::TerminalBranches => {
if skeleton.is_terminal(i) {
add_terminal_leaves(points, &ctx, foliage, &mut rng);
}
}
FoliagePlacement::AllBranches => add_distributed_leaves(points, &ctx, foliage, &mut rng),
FoliagePlacement::TipClusters => {
if skeleton.is_terminal(i) {
add_cluster_leaves(points, &ctx, foliage, &mut rng);
}
}
}
let mut tint_rng = SeededRng::from_parts(seed ^ LEAF_TINT_SALT, node.id);
for p in &mut points[first..] {
p.tint = tint_rng.range(-1.0, 1.0);
}
let mut hue_rng = SeededRng::from_parts(seed ^ LEAF_HUE_SALT, node.id);
for p in &mut points[first..] {
p.hue = hue_rng.range(-1.0, 1.0);
}
if leaf_scale < 1.0 {
for p in &mut points[first..] {
p.size *= leaf_scale;
}
}
}
struct NodeCtx<'a> {
wood: &'a Node,
trunk: &'a TrunkParams,
seed: i32,
end: Vec3,
direction: Vec3,
length: f32,
height_ratio: f32,
leaf_scale: f32,
}
fn add_terminal_leaves(
points: &mut Vec<LeafPoint>,
node: &NodeCtx,
foliage: &FoliageParams,
rng: &mut SeededRng,
) {
let height_mod = height_modifier(node.height_ratio, foliage);
let count = ((foliage.density * height_mod) as i32).max(1);
for _ in 0..count {
let size = foliage.leaf_size
* (1.0 + rng.range(-foliage.leaf_size_variation, foliage.leaf_size_variation));
let direction = leaf_direction(node, foliage, rng);
let offset = rng.range(0.0, node.length * 0.2);
let along = if node.length > 0.0 { 1.0 - offset / node.length } else { 1.0 };
points.push(LeafPoint {
position: node.wood.point_at(along),
direction,
size,
rotation: rng.range(0.0, TAU) * foliage.rotation_variation,
tint: 0.0,
hue: 0.0,
});
}
}
fn add_distributed_leaves(
points: &mut Vec<LeafPoint>,
node: &NodeCtx,
foliage: &FoliageParams,
rng: &mut SeededRng,
) {
let count = distributed_leaf_count(foliage, node.length, node.height_ratio);
for i in 0..count {
let t = (i as f32 + rng.range(0.0, 1.0)) / count as f32;
let biased_t = t * t;
let position = node.wood.point_at(0.3 + biased_t * 0.7);
let size = foliage.leaf_size
* (1.0 + rng.range(-foliage.leaf_size_variation, foliage.leaf_size_variation));
let direction = leaf_direction(node, foliage, rng);
points.push(LeafPoint {
position,
direction,
size,
rotation: rng.range(0.0, TAU) * foliage.rotation_variation,
tint: 0.0,
hue: 0.0,
});
}
}
fn add_cluster_leaves(
points: &mut Vec<LeafPoint>,
node: &NodeCtx,
foliage: &FoliageParams,
rng: &mut SeededRng,
) {
let height_mod = height_modifier(node.height_ratio, foliage);
let count = ((foliage.cluster_size as f32 * height_mod) as i32).max(1);
let cluster_radius = foliage.leaf_size * 0.75 * node.leaf_scale;
for _ in 0..count {
let theta = rng.range(0.0, TAU);
let phi = rng.range(0.0, PI);
let r = cluster_radius * rng.range(0.0, 1.0);
let mut offset = Vec3::new(
r * phi.sin() * theta.cos(),
r * phi.sin() * theta.sin(),
r * phi.cos(),
);
if trunk_depth(node.trunk, node.seed, node.end + offset) > 0.0 {
offset = -offset;
}
let size = foliage.leaf_size
* (1.0 + rng.range(-foliage.leaf_size_variation, foliage.leaf_size_variation));
points.push(LeafPoint {
position: node.end + offset,
direction: offset.normalize_or_zero(),
size,
rotation: rng.range(0.0, TAU) * foliage.rotation_variation,
tint: 0.0,
hue: 0.0,
});
}
}
fn height_modifier(height_ratio: f32, foliage: &FoliageParams) -> f32 {
if !foliage.use_crown_density {
return 1.0;
}
let base = 1.0 - foliage.height_falloff;
base + height_ratio * foliage.height_falloff
}
fn leaf_direction(node: &NodeCtx, foliage: &FoliageParams, rng: &mut SeededRng) -> Vec3 {
let base_dir = match foliage.orientation {
LeafOrientation::RadialOutward => {
let horizontal = Vec3::new(node.end.x, 0.0, node.end.z);
if horizontal.length_squared() > 0.001 {
horizontal.normalize()
} else {
Vec3::X
}
}
LeafOrientation::FollowBranch => node.direction,
LeafOrientation::RandomUpward => Vec3::new(
rng.range(-1.0, 1.0),
rng.range(0.2, 1.0),
rng.range(-1.0, 1.0),
)
.normalize_or_zero(),
LeafOrientation::HorizontalSpread => {
let angle = rng.range(0.0, TAU);
Vec3::new(angle.cos(), 0.0, angle.sin())
}
};
Vec3::new(base_dir.x, base_dir.y - foliage.droop, base_dir.z).normalize_or_zero()
}
pub fn foliage_mesh(points: &[LeafPoint], foliage: &FoliageParams) -> MeshData {
foliage_mesh_with(points, foliage, None)
}
pub(crate) fn foliage_mesh_with(
points: &[LeafPoint],
foliage: &FoliageParams,
firsts: Option<&mut Vec<u32>>,
) -> MeshData {
build_foliage(points, foliage, firsts, false)
}
pub(crate) fn foliage_mesh_coarse(points: &[LeafPoint], foliage: &FoliageParams) -> MeshData {
build_foliage(points, foliage, None, true)
}
fn build_foliage(
points: &[LeafPoint],
foliage: &FoliageParams,
mut firsts: Option<&mut Vec<u32>>,
coarse: bool,
) -> MeshData {
let mut mesh = MeshData::default();
if points.is_empty() {
return mesh;
}
let variation = foliage.color_variation.clamp(0.0, 0.5);
let hue_span = foliage.hue_variation.clamp(0.0, 0.5);
let mut ranges = Vec::with_capacity(points.len());
for point in points {
let first = mesh.positions.len();
if let Some(firsts) = firsts.as_deref_mut() {
firsts.push(first as u32);
}
let card = scanned_card(foliage, point, coarse);
match foliage.style {
LeafStyle::CrossedPlanes => crossed_planes(&mut mesh, point, card.as_ref()),
LeafStyle::SingleQuad => single_quad(&mut mesh, point, card.as_ref()),
LeafStyle::ClusterSphere => cluster_sphere(&mut mesh, point),
LeafStyle::StarBurst => star_burst(&mut mesh, point, card.as_ref()),
LeafStyle::NeedleCluster => needle_cluster(&mut mesh, point),
}
let end = mesh.positions.len();
ranges.push((first, end));
let factor = 1.0 + point.tint * variation;
let hue = point.hue * hue_span;
mesh.colors.resize(
end,
Vec4::new(
factor * (1.0 + 0.6 * hue),
factor * (1.0 + 0.1 * hue),
factor * (1.0 - 0.6 * hue),
1.0,
),
);
mesh.uv1.extend(
mesh.normals[first..end]
.iter()
.map(|&n| Vec2::new(if is_upper(n) { 1.0 } else { 0.0 }, 1.0)),
);
let (_, _, forward) = build_basis(point.direction, point.rotation);
let phase = (point.position.dot(Vec3::new(12.9898, 78.233, 37.719)).sin() * 43_758.547)
.fract()
.abs();
let rigid = foliage.style == LeafStyle::ClusterSphere;
for &vertex in &mesh.positions[first..end] {
let lever = if rigid { point.size * 0.4 } else { (vertex - point.position).length() };
mesh.sway.push((forward * lever).extend(phase));
}
}
let center = points.iter().map(|p| p.position).sum::<Vec3>() / points.len() as f32;
if points.len() >= 2 {
let occlusion = foliage.occlusion.clamp(0.0, 1.0);
let extent = points
.iter()
.fold(Vec3::ZERO, |m, p| m.max((p.position - center).abs()))
.max(Vec3::splat(1e-3));
for (point, &(first, end)) in points.iter().zip(&ranges) {
let rel = (point.position - center) / extent;
let d = rel.length().min(1.0);
let up = rel.y.clamp(-1.0, 1.0);
let openness = (0.8 * d + 0.2 * (0.5 * up + 0.5)).clamp(0.0, 1.0);
let visibility = 1.0 - occlusion * (1.0 - openness);
for uv in &mut mesh.uv1[first..end] {
uv.y = visibility;
}
}
}
let bend = foliage.normal_bend.clamp(0.0, 1.0);
if bend > 0.0 {
for (normal, &position) in mesh.normals.iter_mut().zip(&mesh.positions) {
let out = (position - center).normalize_or(Vec3::Y);
*normal = normal.lerp(out, bend).normalize_or(out);
}
}
mesh
}
fn crossed_planes(mesh: &mut MeshData, point: &LeafPoint, card: Option<&Card>) {
let half = point.size * 0.5;
let (right, up, forward) = build_basis(point.direction, point.rotation);
let center = point.position + up * half;
add_double_sided_leaf(mesh, center, right * half, up * half, card);
add_double_sided_leaf(mesh, center, forward * half, up * half, card);
}
fn single_quad(mesh: &mut MeshData, point: &LeafPoint, card: Option<&Card>) {
let half = point.size * 0.5;
let (right, up, _) = build_basis(point.direction, point.rotation);
add_double_sided_leaf(mesh, point.position + up * half, right * half, up * half, card);
}
fn cluster_sphere(mesh: &mut MeshData, point: &LeafPoint) {
let size = point.size * 0.4;
let c = point.position;
let top = c + Vec3::Y * size;
let bottom = c - Vec3::Y * size;
let front = c + Vec3::NEG_Z * size;
let back = c + Vec3::Z * size;
let left = c + Vec3::NEG_X * size;
let right = c + Vec3::X * size;
add_triangle_outward(mesh, c, top, front, right);
add_triangle_outward(mesh, c, top, right, back);
add_triangle_outward(mesh, c, top, back, left);
add_triangle_outward(mesh, c, top, left, front);
add_triangle_outward(mesh, c, bottom, right, front);
add_triangle_outward(mesh, c, bottom, back, right);
add_triangle_outward(mesh, c, bottom, left, back);
add_triangle_outward(mesh, c, bottom, front, left);
}
fn star_burst(mesh: &mut MeshData, point: &LeafPoint, card: Option<&Card>) {
let half = point.size * 0.5;
let (_, up, _) = build_basis(point.direction, point.rotation);
for i in 0..3 {
let angle = (i as f32 / 3.0) * TAU + point.rotation;
let right = Vec3::new(angle.cos(), 0.0, angle.sin());
add_double_sided_leaf(mesh, point.position, right * half, up * half, card);
}
}
fn needle_cluster(mesh: &mut MeshData, point: &LeafPoint) {
let length = point.size;
let width = point.size * 0.1;
let (_, _, forward) = build_basis(point.direction, point.rotation);
for i in 0..6 {
let angle = (i as f32 / 6.0) * TAU + point.rotation;
let needle_dir = Vec3::new(angle.cos(), 0.3, angle.sin()).normalize();
let needle_right = forward.cross(needle_dir).normalize_or_zero();
add_double_sided_quad(
mesh,
point.position + needle_dir * length * 0.5,
needle_right * width,
needle_dir * length * 0.5,
);
}
}
fn build_basis(direction: Vec3, rotation: f32) -> (Vec3, Vec3, Vec3) {
let world_up = if direction.y.abs() > 0.9 { Vec3::NEG_Z } else { Vec3::Y };
let right = direction.cross(world_up).normalize_or_zero();
let up = right.cross(direction).normalize_or_zero();
let (sin_r, cos_r) = rotation.sin_cos();
(right * cos_r + up * sin_r, up * cos_r - right * sin_r, direction)
}
fn is_upper(n: Vec3) -> bool {
[n.y, n.x, n.z].into_iter().find(|c| c.abs() > 1e-4).is_none_or(|c| c > 0.0)
}
fn add_double_sided_quad(mesh: &mut MeshData, center: Vec3, half_right: Vec3, half_up: Vec3) {
let v0 = center - half_right - half_up;
let v1 = center + half_right - half_up;
let v2 = center + half_right + half_up;
let v3 = center - half_right + half_up;
let normal = half_right.cross(half_up).normalize_or_zero();
let uvs = [
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(0.0, 1.0),
];
let front = mesh.positions.len() as u32;
mesh.positions.extend_from_slice(&[v0, v1, v2, v3]);
mesh.normals.extend_from_slice(&[normal; 4]);
mesh.uvs.extend_from_slice(&uvs);
mesh.indices
.extend_from_slice(&[front, front + 1, front + 2, front, front + 2, front + 3]);
let back = mesh.positions.len() as u32;
mesh.positions.extend_from_slice(&[v0, v1, v2, v3]);
mesh.normals.extend_from_slice(&[-normal; 4]);
mesh.uvs.extend_from_slice(&uvs);
mesh.indices
.extend_from_slice(&[back, back + 2, back + 1, back, back + 3, back + 2]);
}
pub(crate) const LEAF_OUTLINE: [Vec2; 8] = [
Vec2::new(0.0, -1.0),
Vec2::new(0.55, -0.6),
Vec2::new(0.8, -0.05),
Vec2::new(0.6, 0.5),
Vec2::new(0.0, 1.0),
Vec2::new(-0.6, 0.5),
Vec2::new(-0.8, -0.05),
Vec2::new(-0.55, -0.6),
];
#[cfg_attr(not(feature = "textures"), allow(dead_code))]
struct Card {
aspect: f32,
outline: &'static [[f32; 2]],
uv: &'static [[f32; 2]],
triangles: &'static [u16],
}
pub(crate) fn uses_scan(foliage: &FoliageParams) -> bool {
cfg!(feature = "textures")
&& foliage.scan.is_some()
&& matches!(foliage.style, LeafStyle::CrossedPlanes | LeafStyle::SingleQuad | LeafStyle::StarBurst)
}
#[cfg(feature = "textures")]
fn scanned_card(foliage: &FoliageParams, point: &LeafPoint, coarse: bool) -> Option<Card> {
if !uses_scan(foliage) {
return None;
}
let hash = point.tint.to_bits().wrapping_mul(0x9E37_79B1) ^ point.hue.to_bits().wrapping_mul(0x85EB_CA6B);
let cell = ((hash ^ (hash >> 15)) % 4) as usize;
let leaf = &LEAF_SETS[foliage.scan? as usize].leaves[cell];
Some(if coarse {
Card { aspect: leaf.aspect, outline: leaf.coarse_outline, uv: leaf.coarse_uv, triangles: leaf.coarse_triangles }
} else {
Card { aspect: leaf.aspect, outline: leaf.outline, uv: leaf.uv, triangles: leaf.triangles }
})
}
#[cfg(not(feature = "textures"))]
fn scanned_card(_foliage: &FoliageParams, _point: &LeafPoint, _coarse: bool) -> Option<Card> {
None
}
fn add_double_sided_leaf(mesh: &mut MeshData, center: Vec3, half_right: Vec3, half_up: Vec3, card: Option<&Card>) {
let normal = half_right.cross(half_up).normalize_or_zero();
if let Some(card) = card {
let half_right = half_right * card.aspect;
for (side_normal, front) in [(normal, true), (-normal, false)] {
let base = mesh.positions.len() as u32;
for (p, uv) in card.outline.iter().zip(card.uv) {
mesh.positions.push(center + half_right * p[0] + half_up * p[1]);
mesh.uvs.push(Vec2::from_array(*uv));
}
mesh.normals.extend(std::iter::repeat_n(side_normal, card.outline.len()));
for triangle in card.triangles.as_chunks::<3>().0 {
let [a, b, c] = triangle.map(|i| base + u32::from(i));
if front {
mesh.indices.extend_from_slice(&[a, b, c]);
} else {
mesh.indices.extend_from_slice(&[a, c, b]);
}
}
}
return;
}
let n = LEAF_OUTLINE.len() as u32;
for (side_normal, front) in [(normal, true), (-normal, false)] {
let base = mesh.positions.len() as u32;
mesh.positions.push(center);
mesh.uvs.push(Vec2::splat(0.5));
for p in LEAF_OUTLINE {
mesh.positions.push(center + half_right * p.x + half_up * p.y);
mesh.uvs.push((p + Vec2::ONE) * 0.5);
}
mesh.normals
.extend(std::iter::repeat_n(side_normal, n as usize + 1));
for i in 0..n {
let a = base + 1 + i;
let b = base + 1 + (i + 1) % n;
if front {
mesh.indices.extend_from_slice(&[base, a, b]);
} else {
mesh.indices.extend_from_slice(&[base, b, a]);
}
}
}
}
fn add_triangle_outward(mesh: &mut MeshData, center: Vec3, a: Vec3, b: Vec3, c: Vec3) {
let geometric = (b - a).cross(c - a);
let outward = (a + b + c) / 3.0 - center;
let (v1, v2) = if geometric.dot(outward) >= 0.0 { (b, c) } else { (c, b) };
let normal = (v1 - a).cross(v2 - a).normalize_or_zero();
let base = mesh.positions.len() as u32;
mesh.positions.extend_from_slice(&[a, v1, v2]);
mesh.normals.extend_from_slice(&[normal; 3]);
mesh.uvs.extend_from_slice(&[
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 0.0),
Vec2::new(0.5, 1.0),
]);
mesh.indices.extend_from_slice(&[base, base + 1, base + 2]);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesh::rings::tests::{assert_faces_outward, assert_indices_valid};
use crate::params::TreeParams;
use crate::presets::TreePreset;
use crate::skeleton::generate_skeleton;
fn point() -> LeafPoint {
LeafPoint {
position: Vec3::ZERO,
direction: Vec3::Y,
size: 1.0,
rotation: 0.0,
tint: 0.0,
hue: 0.0,
}
}
fn flat(style: LeafStyle) -> FoliageParams {
FoliageParams {
style,
normal_bend: 0.0,
color_variation: 0.0,
hue_variation: 0.0,
..FoliageParams::default()
}
}
#[test]
fn build_basis_produces_orthonormal() {
for dir in [Vec3::Y, Vec3::X, Vec3::new(0.3, 0.9, 0.1).normalize()] {
let (right, up, forward) = build_basis(dir, 0.7);
assert!((right.length() - 1.0).abs() < 1e-5);
assert!((up.length() - 1.0).abs() < 1e-5);
assert!(right.dot(up).abs() < 1e-5);
assert!(right.dot(forward).abs() < 1e-5);
assert!(up.dot(forward).abs() < 1e-5);
}
}
fn vertices_per_leaf(points: &[LeafPoint], foliage: &FoliageParams) -> Vec<usize> {
let mut firsts = Vec::new();
let mesh = foliage_mesh_with(points, foliage, Some(&mut firsts));
let ends = firsts.iter().skip(1).copied().chain([mesh.positions.len() as u32]);
firsts.iter().zip(ends).map(|(&a, b)| (b - a) as usize).collect()
}
#[cfg(feature = "textures")]
#[test]
fn scanned_cards_use_the_scans_outline_atlas_coordinates_and_all_four_leaves() {
let (mut params, points) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
params.foliage.scan = Some(crate::params::LeafScan::Oak);
let foliage = FoliageParams { normal_bend: 0.0, ..params.foliage.clone() };
assert!(uses_scan(&foliage));
let mesh = foliage_mesh(&points, &foliage);
assert_indices_valid(&mesh, "scanned oak");
let counts = vertices_per_leaf(&points, &foliage);
let outline_counts: std::collections::BTreeSet<usize> = crate::textures::leaves::LEAF_SETS
[crate::params::LeafScan::Oak as usize]
.leaves
.iter()
.map(|leaf| 2 * 2 * leaf.outline.len())
.collect();
assert!(counts.iter().all(|c| outline_counts.contains(c)), "leaf vertex counts {counts:?} vs {outline_counts:?}");
let used: std::collections::BTreeSet<usize> = counts.iter().copied().collect();
assert_eq!(used, outline_counts, "every one of the set's leaves is used");
assert!(mesh.uvs.iter().all(|uv| (0.0..=1.0).contains(&uv.x) && (0.0..=1.0).contains(&uv.y)));
let procedural = foliage_mesh(&points, &FoliageParams { scan: None, ..foliage.clone() });
assert_ne!(mesh.uvs[0], procedural.uvs[0]);
}
#[cfg(feature = "textures")]
#[test]
fn a_leaf_keeps_its_scanned_shape_as_the_tree_ages() {
let (mut params, mature) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
params.foliage.scan = Some(crate::params::LeafScan::Maple);
let foliage = FoliageParams { normal_bend: 0.0, ..params.foliage.clone() };
let skeleton = generate_skeleton(¶ms).aged(6.0, ¶ms.growth);
let young = collect_leaf_points(&skeleton, &foliage, ¶ms.growth, ¶ms.trunk, params.seed);
assert!(!young.is_empty() && young.len() < mature.len());
let by_tint: std::collections::HashMap<(u32, u32), usize> = mature
.iter()
.zip(vertices_per_leaf(&mature, &foliage))
.map(|(p, n)| ((p.tint.to_bits(), p.hue.to_bits()), n))
.collect();
for (p, n) in young.iter().zip(vertices_per_leaf(&young, &foliage)) {
assert_eq!(by_tint.get(&(p.tint.to_bits(), p.hue.to_bits())), Some(&n), "a leaf changed shape with age");
}
}
#[test]
fn only_leaf_shaped_styles_use_a_scan() {
let mut foliage = FoliageParams { scan: Some(crate::params::LeafScan::Birch), ..FoliageParams::default() };
for (style, uses) in [
(LeafStyle::CrossedPlanes, true),
(LeafStyle::SingleQuad, true),
(LeafStyle::StarBurst, true),
(LeafStyle::ClusterSphere, false),
(LeafStyle::NeedleCluster, false),
] {
foliage.style = style;
assert_eq!(uses_scan(&foliage), uses && cfg!(feature = "textures"), "{style:?}");
}
foliage.style = LeafStyle::CrossedPlanes;
foliage.scan = None;
assert!(!uses_scan(&foliage));
}
#[test]
fn leaf_vertex_counts() {
let p = point();
let count = |style| foliage_mesh(std::slice::from_ref(&p), &flat(style)).positions.len();
assert_eq!(count(LeafStyle::CrossedPlanes), 36);
assert_eq!(count(LeafStyle::SingleQuad), 18);
assert_eq!(count(LeafStyle::ClusterSphere), 24);
assert_eq!(count(LeafStyle::StarBurst), 54);
assert_eq!(count(LeafStyle::NeedleCluster), 48);
}
#[test]
fn leaf_meshes_face_outward() {
let p = LeafPoint {
position: Vec3::new(1.0, 2.0, -0.5),
direction: Vec3::new(0.4, -0.3, 0.7).normalize(),
size: 0.8,
rotation: 1.1,
tint: 0.0,
hue: 0.0,
};
for style in [
LeafStyle::CrossedPlanes,
LeafStyle::SingleQuad,
LeafStyle::ClusterSphere,
LeafStyle::StarBurst,
LeafStyle::NeedleCluster,
] {
let mesh = foliage_mesh(std::slice::from_ref(&p), &flat(style));
assert_indices_valid(&mesh, &format!("{style:?}"));
assert_faces_outward(&mesh, &format!("{style:?}"));
}
}
#[test]
fn leaf_cards_grow_out_of_their_attachment_point() {
let p = LeafPoint {
position: Vec3::new(1.0, 2.0, -0.5),
direction: Vec3::new(0.4, -0.3, 0.7).normalize(),
size: 0.8,
rotation: 1.1,
tint: 0.0,
hue: 0.0,
};
let (_, up, _) = build_basis(p.direction, p.rotation);
for style in [LeafStyle::CrossedPlanes, LeafStyle::SingleQuad] {
let mesh = foliage_mesh(std::slice::from_ref(&p), &flat(style));
let (min, max) = mesh
.positions
.iter()
.map(|v| (*v - p.position).dot(up))
.fold((f32::MAX, f32::MIN), |(lo, hi), a| (lo.min(a), hi.max(a)));
assert!(min > -1e-5, "{style:?}: card reaches {min} m behind the leaf point");
assert!((max - p.size).abs() < 1e-5, "{style:?}: tip {max} m out, leaf size {}", p.size);
}
}
fn oak_leaf_points(placement: FoliagePlacement, age: f32) -> (TreeParams, Vec<LeafPoint>) {
let mut params = TreePreset::Oak.params();
params.foliage.scan = None;
params.foliage.placement = placement;
let skeleton = generate_skeleton(¶ms).aged(age, ¶ms.growth);
let points = collect_leaf_points(&skeleton, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed);
(params, points)
}
#[test]
fn bent_normals_point_out_of_the_crown() {
let (params, points) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
assert!(!points.is_empty());
let center = points.iter().map(|p| p.position).sum::<Vec3>() / points.len() as f32;
for normal_bend in [params.foliage.normal_bend, 1.0] {
let foliage = FoliageParams { normal_bend, ..params.foliage.clone() };
let mesh = foliage_mesh(&points, &foliage);
assert_indices_valid(&mesh, "oak");
for (position, normal) in mesh.positions.iter().zip(&mesh.normals) {
assert!((normal.length() - 1.0).abs() < 1e-4, "bend {normal_bend}: non-unit normal {normal:?}");
assert!(
normal.dot(*position - center) > 0.0,
"bend {normal_bend}: normal {normal:?} at {position:?} points into the crown"
);
}
}
}
#[test]
fn leaf_colors_vary_per_leaf_within_bounds() {
let (params, points) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
assert_eq!(params.foliage.style, LeafStyle::CrossedPlanes);
let leaf_vertices = 36;
let varied = FoliageParams { color_variation: 0.2, hue_variation: 0.0, ..params.foliage.clone() };
let mesh = foliage_mesh(&points, &varied);
assert_eq!(mesh.colors.len(), mesh.positions.len());
assert_eq!(mesh.colors.len(), points.len() * leaf_vertices);
let mut distinct = std::collections::BTreeSet::new();
for leaf in mesh.colors.chunks(leaf_vertices) {
assert!(leaf.iter().all(|c| *c == leaf[0]), "one leaf, several colours");
let c = leaf[0];
assert!((0.8 - 1e-5..=1.2 + 1e-5).contains(&c.x), "factor {} out of bounds", c.x);
assert!(c.x == c.y && c.y == c.z && c.w == 1.0, "tint must be grey and opaque: {c:?}");
distinct.insert(c.x.to_bits());
}
assert!(distinct.len() > 1, "all leaves share one colour");
let plain = FoliageParams { color_variation: 0.0, hue_variation: 0.0, ..params.foliage.clone() };
let mesh = foliage_mesh(&points, &plain);
assert!(mesh.colors.iter().all(|c| *c == Vec4::ONE));
}
#[test]
fn leaf_hues_vary_per_leaf() {
let (params, points) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
let foliage = FoliageParams { hue_variation: 0.5, color_variation: 0.0, ..params.foliage.clone() };
let mesh = foliage_mesh(&points, &foliage);
let leaf_vertices = 36;
let (mut warm, mut cool) = (false, false);
for leaf in mesh.colors.chunks(leaf_vertices) {
assert!(leaf.iter().all(|c| *c == leaf[0]), "one leaf, several colours");
let c = leaf[0];
assert!((c.x - 1.0).abs() <= 0.3 + 1e-5, "red {} out of bounds", c.x);
warm |= c.x > c.z;
cool |= c.x < c.z;
}
assert!(warm && cool, "hues do not lean both ways");
}
#[test]
fn upper_flag_marks_the_face_that_looks_up() {
let tilted = LeafPoint {
position: Vec3::new(1.0, 2.0, -0.5),
direction: Vec3::new(0.4, -0.3, 0.7).normalize(),
size: 0.8,
rotation: 1.1,
..point()
};
let sideways = LeafPoint { direction: Vec3::X, ..point() };
for style in [
LeafStyle::CrossedPlanes,
LeafStyle::SingleQuad,
LeafStyle::ClusterSphere,
LeafStyle::StarBurst,
LeafStyle::NeedleCluster,
] {
for p in [point(), tilted.clone(), sideways.clone()] {
let mesh = foliage_mesh(std::slice::from_ref(&p), &flat(style));
assert_eq!(mesh.uv1.len(), mesh.positions.len());
for (uv, &normal) in mesh.uv1.iter().zip(&mesh.normals) {
assert!(uv.x == 0.0 || uv.x == 1.0, "{style:?}: flag {}", uv.x);
assert_eq!(uv.x == 1.0, is_upper(normal), "{style:?}: flag against normal {normal:?}");
}
let face = match style {
LeafStyle::ClusterSphere => continue,
LeafStyle::NeedleCluster => 4,
_ => 9,
};
for card in mesh.uv1.chunks(2 * face) {
assert!(
card[..face].iter().all(|uv| uv.x != card[face].x)
&& card[..face].iter().all(|uv| uv.x == card[0].x)
&& card[face..].iter().all(|uv| uv.x == card[face].x),
"{style:?}: a card's two faces share a side"
);
}
}
}
}
#[test]
fn crown_occlusion_is_darkest_inside() {
let (params, points) = oak_leaf_points(FoliagePlacement::AllBranches, f32::INFINITY);
let leaf_vertices = 36;
let center = points.iter().map(|p| p.position).sum::<Vec3>() / points.len() as f32;
let by_distance = |a: &&(usize, &LeafPoint), b: &&(usize, &LeafPoint)| {
(a.1.position - center).length().total_cmp(&(b.1.position - center).length())
};
let indexed: Vec<_> = points.iter().enumerate().collect();
let nearest = indexed.iter().min_by(by_distance).unwrap().0;
let farthest = indexed.iter().max_by(by_distance).unwrap().0;
let dark = FoliageParams { occlusion: 0.6, ..params.foliage.clone() };
let mesh = foliage_mesh(&points, &dark);
assert!(mesh.uv1.iter().all(|uv| (0.4 - 1e-5..=1.0).contains(&uv.y)), "visibility out of [0.4, 1]");
assert!(
mesh.uv1[nearest * leaf_vertices].y < mesh.uv1[farthest * leaf_vertices].y,
"the leaf at the crown's heart is not darker than the outermost one"
);
let open = FoliageParams { occlusion: 0.0, ..params.foliage.clone() };
assert!(foliage_mesh(&points, &open).uv1.iter().all(|uv| uv.y == 1.0));
}
#[test]
fn leaf_sway_is_one_motion_per_leaf() {
let a = LeafPoint {
position: Vec3::new(1.0, 2.0, -0.5),
direction: Vec3::new(0.4, -0.3, 0.7).normalize(),
size: 0.8,
rotation: 1.1,
..point()
};
let b = LeafPoint { position: Vec3::new(-2.0, 3.0, 1.0), ..a.clone() };
for style in [
LeafStyle::CrossedPlanes,
LeafStyle::SingleQuad,
LeafStyle::ClusterSphere,
LeafStyle::StarBurst,
LeafStyle::NeedleCluster,
] {
let points = [a.clone(), b.clone()];
let mesh = foliage_mesh(&points, &flat(style));
assert_eq!(mesh.sway.len(), mesh.positions.len());
let per_leaf = mesh.positions.len() / 2;
let mut phases = Vec::new();
for (i, p) in points.iter().enumerate() {
let range = i * per_leaf..(i + 1) * per_leaf;
let (_, _, forward) = build_basis(p.direction, p.rotation);
let phase = mesh.sway[range.start].w;
assert!((0.0..1.0).contains(&phase), "{style:?}: phase {phase}");
phases.push(phase);
for (sway, &vertex) in mesh.sway[range.clone()].iter().zip(&mesh.positions[range]) {
assert_eq!(sway.w, phase, "{style:?}: a leaf with two phases");
let offset = sway.truncate();
assert!(offset.cross(forward).length() < 1e-4, "{style:?}: flutter not along the leaf's direction");
match style {
LeafStyle::CrossedPlanes | LeafStyle::SingleQuad => {
assert!((offset.length() - (vertex - p.position).length()).abs() < 1e-5, "{style:?}: lever");
}
LeafStyle::ClusterSphere => {
assert!((offset.length() - p.size * 0.4).abs() < 1e-5, "{style:?}: a cluster must move as one");
}
_ => {}
}
}
}
assert_ne!(phases[0], phases[1], "{style:?}: two leaves flutter in step");
}
}
#[test]
fn branches_shortened_into_the_trunk_carry_no_leaves() {
let mut params = crate::presets::TreePreset::Cypress.params();
params.seed = 1;
params.trunk.randomness = 0.5;
params.trunk.twist = 180.0;
params.branches.twist = 90.0;
let s = crate::skeleton::generate_skeleton(¶ms);
let points = collect_leaf_points(&s, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed);
for p in &points {
let depth = trunk_depth(¶ms.trunk, params.seed, p.position);
assert!(depth <= p.size * 0.5, "leaf {depth} m inside the trunk at {:?}", p.position);
}
}
#[test]
fn leaf_tints_never_reroll_as_the_tree_ages() {
let tints = |age: f32| -> std::collections::BTreeSet<u32> {
let (_, points) = oak_leaf_points(FoliagePlacement::TerminalBranches, age);
points.iter().map(|p| p.tint.to_bits()).collect()
};
let young = tints(10.0);
let mature = tints(f32::INFINITY);
assert!(young.len() > 1, "no tints drawn at age 10");
assert!(young.is_subset(&mature), "a leaf's tint changed while the tree aged");
}
#[test]
fn collect_leaf_points_disabled() {
let params = TreePreset::Oak.params();
let skeleton = generate_skeleton(¶ms);
let foliage = FoliageParams {
enabled: false,
..params.foliage.clone()
};
let points = collect_leaf_points(&skeleton, &foliage, ¶ms.growth, ¶ms.trunk, 42);
assert!(points.is_empty());
}
#[test]
fn leaves_scale_with_age() {
let params = TreePreset::Oak.params();
let full = generate_skeleton(¶ms);
let leaves_at = |age: f32| {
let aged = full.aged(age, ¶ms.growth);
collect_leaf_points(&aged, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed)
};
let young = leaves_at(2.0);
let mid = leaves_at(params.growth.maturity_age * 0.3);
let mature = leaves_at(f32::INFINITY);
assert!(!mature.is_empty());
assert!(young.len() < mature.len(), "{} vs {}", young.len(), mature.len());
let max_size = |pts: &[LeafPoint]| pts.iter().map(|p| p.size).fold(0.0, f32::max);
assert!(max_size(&mid) < max_size(&mature));
}
#[test]
fn leaf_points_stable_under_aging() {
let mut params = TreePreset::Oak.params();
params.foliage.placement = FoliagePlacement::AllBranches;
params.foliage.orientation = LeafOrientation::FollowBranch;
let full = generate_skeleton(¶ms);
let (idx, primary) = full
.nodes
.iter()
.enumerate()
.find(|(_, n)| n.kind == NodeKind::Primary)
.unwrap();
let id = primary.id;
let relative_leaves = |age: f32| {
let aged = full.aged(age, ¶ms.growth);
let i = aged.nodes.iter().position(|n| n.id == id).expect("primary alive");
let mut pts = Vec::new();
leaf_points_for_node(&aged, i, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed, &mut pts);
let end = aged.end(i);
pts.into_iter()
.map(|p| (p.position - end, p.direction, p.size, p.rotation))
.collect::<Vec<_>>()
};
let a = relative_leaves(primary.birth + primary.duration);
let b = relative_leaves(f32::INFINITY);
assert!(!a.is_empty(), "node {idx} should have leaves");
assert_eq!(a.len(), b.len());
for (x, y) in a.iter().zip(&b) {
assert!(x.0.distance(y.0) < 1e-4);
assert_eq!(x.1, y.1);
assert_eq!(x.2, y.2);
assert_eq!(x.3, y.3);
}
}
#[test]
fn leaves_sit_on_bent_wood() {
let mut params = TreePreset::Oak.params();
params.branches.gravity_strength = 0.8;
params.branches.stiffness = 0.1;
let skeleton = generate_skeleton(¶ms);
let axes: Vec<Vec<Vec3>> = skeleton
.nodes
.iter()
.skip(1)
.map(|n| n.axis(n.axis_points()).into_iter().map(|(c, _)| c).collect())
.collect();
let off_wood = |p: Vec3| {
axes.iter()
.flat_map(|axis| axis.windows(2))
.map(|w| {
let ab = w[1] - w[0];
let t = ((p - w[0]).dot(ab) / ab.length_squared().max(f32::MIN_POSITIVE)).clamp(0.0, 1.0);
p.distance(w[0] + ab * t)
})
.fold(f32::MAX, f32::min)
};
for placement in [FoliagePlacement::AllBranches, FoliagePlacement::TerminalBranches] {
params.foliage.placement = placement;
let points = collect_leaf_points(&skeleton, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed);
assert!(!points.is_empty());
for p in &points {
let d = off_wood(p.position);
assert!(d < 1e-4, "{placement:?}: leaf at {:?} is {d} off the bent wood", p.position);
}
}
}
#[test]
fn no_leaf_inside_the_trunk() {
for preset in [TreePreset::Cypress, TreePreset::Poplar, TreePreset::Oak] {
for placement in [FoliagePlacement::TipClusters, FoliagePlacement::TerminalBranches, FoliagePlacement::AllBranches] {
let mut params = preset.params();
params.foliage.placement = placement;
let skeleton = generate_skeleton(¶ms);
let points = collect_leaf_points(&skeleton, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed);
for p in &points {
let depth = trunk_depth(¶ms.trunk, params.seed, p.position);
assert!(depth <= 0.0, "{} {placement:?}: leaf {depth} m inside the trunk", preset.name());
}
}
}
}
#[test]
fn young_tip_clusters_stay_on_their_tips() {
for preset in [TreePreset::DragonTree, TreePreset::Oak, TreePreset::Cypress] {
let mut params = preset.params();
params.foliage.placement = FoliagePlacement::TipClusters;
let mature = generate_skeleton(¶ms);
for age in [4.0, 6.0, 9.0, 12.0] {
let skeleton = mature.aged(age, ¶ms.growth);
let tips: Vec<Vec3> = (0..skeleton.nodes.len())
.filter(|&i| skeleton.nodes[i].kind != NodeKind::Trunk && skeleton.is_terminal(i))
.map(|i| skeleton.end(i))
.collect();
let points = collect_leaf_points(&skeleton, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed);
let max_ratio = 0.75 / (1.0 - params.foliage.leaf_size_variation) + 1e-3;
for p in &points {
let d = tips.iter().map(|t| t.distance(p.position)).fold(f32::MAX, f32::min);
assert!(
d <= p.size * max_ratio + 1e-5,
"{} age {age}: leaf of size {} sits {d} m from the nearest tip",
preset.name(),
p.size
);
}
}
}
}
#[test]
fn leaves_never_disappear_as_a_tree_ages() {
for preset in TreePreset::ALL {
for placement in [FoliagePlacement::TerminalBranches, FoliagePlacement::TipClusters] {
let mut params = preset.params();
params.foliage.placement = placement;
let mature = generate_skeleton(¶ms);
let mut previous = 0;
for step in 0..=40 {
let age = step as f32 * 0.5;
let skeleton = mature.aged(age, ¶ms.growth);
let count = collect_leaf_points(&skeleton, ¶ms.foliage, ¶ms.growth, ¶ms.trunk, params.seed).len();
assert!(
count >= previous,
"{} {placement:?}: {previous} leaves before age {age}, {count} at it",
preset.name()
);
previous = count;
}
}
}
}
}