use std::f32::consts::PI;
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use bevy::app::AppExit;
use bevy::camera::{RenderTarget, Viewport};
use bevy::image::TextureFormatPixelInfo;
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy::render::view::screenshot::{Screenshot, ScreenshotCaptured, save_to_disk};
use bevy::ui::UiTargetCamera;
use bevy::window::WindowResolution;
use bevy_flora::{
CrownShape, FloraPlugin, FoliagePlacement, LeafOrientation, LeafStyle, MeshData, NodeKind,
ProceduralTree, TreeAge, TreeMeshes, TreeParams, TreePreset, TrunkTermination, build_tree,
};
const WINDOW: UVec2 = UVec2::new(1280, 960);
const VIEW: UVec2 = UVec2::new(640, 480);
const WARMUP_FRAMES: u32 = 10;
const SETTLE_FRAMES: u32 = 5;
const CAPTURE_TIMEOUT_FRAMES: u32 = 120;
const TSV_HEADER: &str = "name\tage\tseed\tnodes\ttrunk_verts\ttrunk_tris\tfoliage_verts\tfoliage_tris\tbbox_min\tbbox_max\tflags";
type Edit = fn(&mut TreeParams);
struct Scenario {
name: String,
params: TreeParams,
age: f32,
}
fn preset(preset: TreePreset) -> TreeParams {
let mut params = preset.params();
params.seed = 42;
params
}
fn scenarios() -> Vec<Scenario> {
let inf = f32::INFINITY;
let mut list = Vec::new();
let mut add = |name: String, params: TreeParams, age: f32| {
list.push(Scenario { name, params, age });
};
for p in TreePreset::ALL {
add(format!("mature_{}", p.name()), preset(p), inf);
}
for p in [TreePreset::Oak, TreePreset::Pine] {
for age in [0.0, 0.5, 2.0, 4.0, 6.0, 8.0, inf] {
add(format!("grow_{}_{age}", p.name()), preset(p), age);
}
}
for p in [TreePreset::Oak, TreePreset::Maple] {
for seed in 1..=3 {
let mut params = preset(p);
params.seed = seed;
add(format!("seed_{}_{seed}", p.name()), params, inf);
}
}
let toggles: [(&str, Edit); 12] = [
("oak_split", |p| {
p.split.enabled = true;
p.split.probability = 1.0;
p.split.radius_threshold = 0.0;
}),
("oak_pipe", |p| p.mesh.pipe_radius_enabled = true),
("oak_smooth", |p| p.mesh.smooth_iterations = 2),
("oak_adaptive", |p| p.mesh.adaptive_resolution = true),
("oak_gravity", |p| {
p.branches.gravity_strength = 0.8;
p.branches.stiffness = 0.1;
}),
("oak_wobble", |p| p.trunk.randomness = 0.4),
("oak_roots", |p| {
p.trunk.root_flare_count = 6;
p.trunk.root_flare_spread = 1.0;
}),
("oak_flatcap", |p| p.trunk.termination = TrunkTermination::FlatCap),
("oak_pointed", |p| p.trunk.termination = TrunkTermination::PointedTip),
("oak_leader", |p| p.trunk.termination = TrunkTermination::LeaderBranch),
("oak_twist", |p| {
p.trunk.twist = 180.0;
p.branches.twist = 90.0;
}),
("oak_nocollar", |p| p.mesh.collar_enabled = false),
];
for (name, edit) in toggles {
let mut params = preset(TreePreset::Oak);
edit(&mut params);
add(name.to_string(), params, inf);
}
for style in [
LeafStyle::CrossedPlanes,
LeafStyle::SingleQuad,
LeafStyle::ClusterSphere,
LeafStyle::StarBurst,
LeafStyle::NeedleCluster,
] {
let mut params = preset(TreePreset::Oak);
params.foliage.style = style;
add(format!("leaf_{style:?}"), params, inf);
}
for placement in [
FoliagePlacement::TerminalBranches,
FoliagePlacement::AllBranches,
FoliagePlacement::TipClusters,
] {
let mut params = preset(TreePreset::Oak);
params.foliage.placement = placement;
add(format!("place_{placement:?}"), params, inf);
}
let others = |skip: [TreePreset; 2]| TreePreset::ALL.into_iter().filter(move |p| !skip.contains(p));
for p in others([TreePreset::Oak, TreePreset::Pine]) {
for age in [2.0, 4.0, 6.0, 8.0, 12.0] {
add(format!("age_{}_{age}", p.name()), preset(p), age);
}
}
for p in others([TreePreset::Oak, TreePreset::Maple]) {
for seed in 1..=3 {
let mut params = preset(p);
params.seed = seed;
add(format!("seed_{}_{seed}", p.name()), params, inf);
}
}
for style in [
LeafStyle::CrossedPlanes,
LeafStyle::SingleQuad,
LeafStyle::ClusterSphere,
LeafStyle::StarBurst,
LeafStyle::NeedleCluster,
] {
for placement in [
FoliagePlacement::TerminalBranches,
FoliagePlacement::AllBranches,
FoliagePlacement::TipClusters,
] {
let mut params = preset(TreePreset::Maple);
params.foliage.style = style;
params.foliage.placement = placement;
add(format!("leafplace_{style:?}_{placement:?}"), params, inf);
}
}
let extremes: &[(&str, Edit)] = &[
("extreme_droop", |p| {
p.branches.gravity_strength = 2.0;
p.branches.stiffness = 0.0;
}),
("extreme_droop_split", |p| {
p.branches.gravity_strength = 1.0;
p.branches.stiffness = 0.0;
p.split.enabled = true;
p.split.probability = 1.0;
p.split.radius_threshold = 0.0;
}),
("extreme_droop_low", |p| {
p.branches.start = 0.05;
p.branches.gravity_strength = 2.0;
p.branches.stiffness = 0.0;
p.branches.up_attraction = -1.0;
}),
("extreme_wobble", |p| p.trunk.randomness = 1.0),
("extreme_twist", |p| {
p.trunk.twist = 720.0;
p.branches.twist = 360.0;
}),
("extreme_random", |p| p.branches.randomness = 1.0),
("extreme_recursion", |p| {
p.branches.recursion = 3;
p.branches.sub_branch_count = 4;
}),
("extreme_flat", |p| p.branches.flatness = 1.0),
("extreme_horizontal", |p| {
p.branches.angle = 90.0;
p.branches.up_attraction = 0.0;
}),
("extreme_vertical", |p| p.branches.angle = 0.0),
("extreme_mesh", |p| {
p.mesh.pipe_radius_enabled = true;
p.mesh.smooth_iterations = 3;
p.mesh.adaptive_resolution = true;
}),
("extreme_lowpoly", |p| {
p.trunk.radial_segments = 3;
p.trunk.height_segments = 1;
}),
("extreme_highpoly", |p| {
p.trunk.radial_segments = 32;
p.trunk.height_segments = 32;
}),
("extreme_noleaves", |p| p.foliage.enabled = false),
("extreme_bigcollar", |p| p.mesh.collar_length = 6.0),
];
for (name, edit) in extremes {
let mut params = preset(TreePreset::Oak);
edit(&mut params);
add(name.to_string(), params, inf);
}
for orientation in [
LeafOrientation::RadialOutward,
LeafOrientation::FollowBranch,
LeafOrientation::RandomUpward,
LeafOrientation::HorizontalSpread,
] {
let mut params = preset(TreePreset::Oak);
params.foliage.orientation = orientation;
add(format!("orient_{orientation:?}"), params, inf);
}
for shape in [
CrownShape::Cylindrical,
CrownShape::Conical,
CrownShape::Spherical,
CrownShape::Hemispherical,
CrownShape::TaperedCylindrical,
CrownShape::Flame,
CrownShape::InverseConical,
CrownShape::TendFlame,
CrownShape::Spreading,
] {
let mut params = preset(TreePreset::Oak);
params.branches.crown_shape = shape;
params.branches.crown_influence = 1.0;
add(format!("crown_{shape:?}"), params, inf);
}
for p in TreePreset::ALL.into_iter().filter(|&p| p != TreePreset::Oak) {
let mut params = preset(p);
params.split.enabled = true;
params.split.probability = 1.0;
params.split.radius_threshold = 0.0;
add(format!("split_{}", p.name()), params, inf);
}
for p in TreePreset::ALL.into_iter().filter(|&p| p != TreePreset::Oak) {
let mut params = preset(p);
params.mesh.smooth_iterations = 2;
add(format!("smooth_{}", p.name()), params, inf);
}
for p in TreePreset::ALL {
let mut params = preset(p);
params.mesh.smooth_iterations = 2;
add(format!("smoothyoung_{}_4", p.name()), params, 4.0);
}
for p in TreePreset::ALL.into_iter().filter(|&p| p != TreePreset::Oak) {
let mut params = preset(p);
params.mesh.pipe_radius_enabled = true;
add(format!("pipe_{}", p.name()), params, inf);
}
for p in [TreePreset::Willow, TreePreset::DragonTree] {
for age in [1.0, 3.0, 5.0, 7.0, 9.0, inf] {
add(format!("grow_{}_{age}", p.name()), preset(p), age);
}
}
let extremes2: &[(&str, f32, Edit)] = &[
("extreme_notaper", inf, |p| p.trunk.taper = 1.0),
("extreme_needletrunk", inf, |p| p.trunk.taper = 0.0),
("extreme_flare", inf, |p| p.trunk.flare = 3.0),
("extreme_roots12", inf, |p| {
p.trunk.root_flare_count = 12;
p.trunk.root_flare_spread = 1.5;
p.trunk.root_flare_height = 0.3;
}),
("extreme_norecursion", inf, |p| p.branches.recursion = 0),
("extreme_longbranches", inf, |p| p.branches.length = 1.5),
("extreme_dense", inf, |p| p.branches.density = 8.0),
("extreme_bigleaves", inf, |p| p.foliage.leaf_size = 1.2),
("extreme_flatleaves", inf, |p| {
p.foliage.normal_bend = 0.0;
p.foliage.color_variation = 0.0;
}),
("extreme_leafvary", inf, |p| p.foliage.color_variation = 0.5),
("extreme_tiny", inf, |p| {
p.trunk.height = 0.5;
p.trunk.radius = 0.05;
}),
("extreme_giant", inf, |p| {
p.trunk.height = 40.0;
p.trunk.radius = 3.0;
}),
("extreme_floor2", inf, |p| {
p.branches.floor_level = 2.0;
p.branches.start = 0.1;
}),
("extreme_lateleaves_6", 6.0, |p| p.growth.leaf_threshold = 1.0),
("extreme_lagthick_6", 6.0, |p| p.growth.thickening_lag = 5.0),
("extreme_fastgrow_2", 2.0, |p| {
p.growth.trunk_fraction = 0.1;
p.growth.branch_fraction = 0.1;
}),
("extreme_smooth_split", inf, |p| {
p.mesh.smooth_iterations = 3;
p.split.enabled = true;
p.split.probability = 1.0;
p.split.radius_threshold = 0.0;
}),
("extreme_droop_wobble", inf, |p| {
p.trunk.randomness = 0.8;
p.branches.gravity_strength = 1.5;
p.branches.stiffness = 0.0;
}),
];
for (name, age, edit) in extremes2 {
let mut params = preset(TreePreset::Oak);
edit(&mut params);
add(name.to_string(), params, *age);
}
list
}
struct Stats {
nodes: usize,
trunk_verts: usize,
trunk_tris: usize,
foliage_verts: usize,
foliage_tris: usize,
bbox: (Vec3, Vec3),
flags: Vec<String>,
}
fn bounds(out: &TreeMeshes) -> (Vec3, Vec3) {
let mut points = out.trunk.positions.iter().chain(&out.foliage.positions);
let Some(&first) = points.next() else {
return (Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5));
};
points.fold((first, first), |(min, max), &p| (min.min(p), max.max(p)))
}
fn inside_out(mesh: &MeshData) -> usize {
mesh.indices
.as_chunks::<3>()
.0
.iter()
.filter(|&&[a, b, c]| {
let (a, b, c) = (a as usize, b as usize, c as usize);
let geometric = (mesh.positions[b] - mesh.positions[a])
.cross(mesh.positions[c] - mesh.positions[a]);
if geometric.length_squared() < 1e-12 {
return false;
}
geometric.dot(mesh.normals[a] + mesh.normals[b] + mesh.normals[c]) <= 0.0
})
.count()
}
fn analyze(params: &TreeParams, age: f32, out: &TreeMeshes) -> Stats {
let nodes = out.skeleton.nodes.len();
let mut flags = Vec::new();
if out.trunk.is_empty() && age > 0.0 {
flags.push("EMPTY_TRUNK".to_string());
}
let finite = |m: &MeshData| {
m.positions.iter().all(|p| p.is_finite()) && m.normals.iter().all(|n| n.is_finite())
};
if !finite(&out.trunk) || !finite(&out.foliage) {
flags.push("NONFINITE".to_string());
}
if age.is_infinite() && params.foliage.enabled && out.foliage.is_empty() {
flags.push("NO_LEAVES_MATURE".to_string());
}
if nodes == 1 && age.is_infinite() {
flags.push("NO_BRANCHES".to_string());
}
if params.branches.floor_avoidance
&& out
.trunk
.positions
.iter()
.any(|p| p.y < params.branches.floor_level.min(0.0) - 0.01)
{
flags.push("BELOW_FLOOR".to_string());
}
let flipped = inside_out(&out.trunk);
if flipped > 0 {
flags.push(format!("INSIDE_OUT:{flipped}"));
}
Stats {
nodes,
trunk_verts: out.trunk.positions.len(),
trunk_tris: out.trunk.indices.len() / 3,
foliage_verts: out.foliage.positions.len(),
foliage_tris: out.foliage.indices.len() / 3,
bbox: bounds(out),
flags,
}
}
fn fmt_vec(v: Vec3) -> String {
format!("{:.2},{:.2},{:.2}", v.x, v.y, v.z)
}
fn flags_text(flags: &[String]) -> String {
if flags.is_empty() { "-".to_string() } else { flags.join(";") }
}
fn look(target: Vec3, dir: Vec3, dist: f32) -> Transform {
Transform::from_translation(target + dir * dist).looking_at(target, Vec3::Y)
}
fn view_transforms(params: &TreeParams, out: &TreeMeshes, bbox: (Vec3, Vec3)) -> [Transform; 4] {
let (min, max) = bbox;
let center = (min + max) * 0.5;
let radius = (0.5 * (max - min).length()).max(0.5);
let dist = radius / (PI / 8.0).sin() * 1.1;
let elevated = |deg: f32, yaw: Vec3| {
let e = deg.to_radians();
Vec3::new(yaw.x * e.cos(), e.sin(), yaw.z * e.cos())
};
let front = look(center, elevated(10.0, Vec3::Z), dist);
let side = look(center, elevated(10.0, Vec3::X), dist);
let top = look(center, elevated(80.0, Vec3::Z), dist);
let junction = out
.skeleton
.nodes
.iter()
.filter(|n| n.kind == NodeKind::Primary)
.min_by(|a, b| a.start.y.total_cmp(&b.start.y))
.map(|n| Vec3::new(n.anchor.x, n.start.y, n.anchor.z))
.unwrap_or(Vec3::new(0.0, out.skeleton.nodes[0].length, 0.0));
let close_dist = (params.trunk.radius * params.trunk.flare * 6.0).max(1.5);
let (yaw, pitch) = (45f32.to_radians(), 15f32.to_radians());
let close_dir = Vec3::new(yaw.sin() * pitch.cos(), pitch.sin(), yaw.cos() * pitch.cos());
let closeup = look(junction, close_dir, close_dist);
[front, side, top, closeup]
}
#[derive(Component)]
struct ViewCam(usize);
#[derive(Component)]
struct Overlay;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Phase {
Warmup,
Apply,
Settle,
Capture,
Done,
}
struct Pending {
prefix: String,
flags: Vec<String>,
}
#[derive(Resource)]
struct Gallery {
scenarios: Vec<Scenario>,
out: PathBuf,
tsv: File,
shadows: bool,
target: Handle<Image>,
blank_capture: bool,
index: usize,
phase: Phase,
frames: u32,
pending: Option<Pending>,
}
fn usage() -> ! {
eprintln!("usage: gallery [--out <dir>] [--filter <substr>]... [--no-shadows]");
std::process::exit(2);
}
fn main() {
let mut out = PathBuf::from("target/gallery/latest");
let mut filters: Vec<String> = Vec::new();
let mut shadows = true;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--out" => out = args.next().unwrap_or_else(|| usage()).into(),
"--filter" => filters.push(args.next().unwrap_or_else(|| usage())),
"--no-shadows" => shadows = false,
_ => usage(),
}
}
let mut list = scenarios();
list.retain(|s| filters.is_empty() || filters.iter().any(|f| s.name.contains(f.as_str())));
if list.is_empty() {
eprintln!("no scenarios match");
std::process::exit(1);
}
fs::create_dir_all(&out).expect("create output directory");
let mut tsv = File::create(out.join("stats.tsv")).expect("create stats.tsv");
writeln!(tsv, "{TSV_HEADER}").expect("write stats.tsv header");
App::new()
.add_plugins((
DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
resolution: WindowResolution::new(WINDOW.x, WINDOW.y)
.with_scale_factor_override(1.0),
title: "bevy_flora gallery".to_string(),
..default()
}),
..default()
}),
FloraPlugin,
))
.insert_resource(ClearColor(Color::srgb(0.55, 0.72, 0.92)))
.insert_resource(GlobalAmbientLight {
color: Color::srgb(0.6, 0.6, 0.6),
brightness: 300.0,
..default()
})
.insert_resource(Gallery {
scenarios: list,
out,
tsv,
shadows,
target: Handle::default(),
blank_capture: false,
index: 0,
phase: Phase::Warmup,
frames: 0,
pending: None,
})
.add_systems(Startup, setup)
.add_systems(Update, drive)
.run();
}
fn setup(
mut commands: Commands,
mut gallery: ResMut<Gallery>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut images: ResMut<Assets<Image>>,
) {
gallery.target = images.add(Image::new_target_texture(
WINDOW.x,
WINDOW.y,
TextureFormat::Rgba8Unorm,
Some(TextureFormat::Rgba8UnormSrgb),
));
commands.spawn((
DirectionalLight {
shadow_maps_enabled: gallery.shadows,
illuminance: 10_000.0,
..default()
},
Transform::from_xyz(10.0, 20.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y),
));
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(200.0, 200.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.35, 0.45, 0.30),
perceptual_roughness: 1.0,
..default()
})),
));
let first = &gallery.scenarios[0];
commands.spawn((ProceduralTree(first.params.clone()), TreeAge(first.age)));
let mut view0 = Entity::PLACEHOLDER;
for i in 0..4usize {
let position = UVec2::new((i as u32 % 2) * VIEW.x, (i as u32 / 2) * VIEW.y);
let camera = commands
.spawn((
Camera3d::default(),
Camera {
order: i as isize,
viewport: Some(Viewport {
physical_position: position,
physical_size: VIEW,
..default()
}),
..default()
},
RenderTarget::Image(gallery.target.clone().into()),
Transform::default(),
ViewCam(i),
))
.id();
if i == 0 {
view0 = camera;
}
}
commands.spawn((
Overlay,
Text::new(""),
TextFont::from_font_size(14.0),
TextColor(Color::BLACK),
Node {
position_type: PositionType::Absolute,
top: px(6),
left: px(6),
..default()
},
UiTargetCamera(view0),
));
}
fn check_capture(captured: On<ScreenshotCaptured>, mut gallery: ResMut<Gallery>) {
let image = &captured.image;
let pixel = image.texture_descriptor.format.pixel_size().unwrap_or(4);
gallery.blank_capture = image.data.as_deref().is_none_or(|data| {
let first = &data[..pixel.min(data.len())];
data.chunks_exact(pixel).all(|px| px == first)
});
}
fn drive(
mut commands: Commands,
mut gallery: ResMut<Gallery>,
mut tree: Single<(&mut ProceduralTree, &mut TreeAge)>,
mut cameras: Query<(&ViewCam, &mut Transform)>,
mut overlay: Single<&mut Text, With<Overlay>>,
screenshots: Query<Entity, With<Screenshot>>,
mut exit: MessageWriter<AppExit>,
) {
let gallery = &mut *gallery;
match gallery.phase {
Phase::Warmup => {
gallery.frames += 1;
if gallery.frames >= WARMUP_FRAMES {
gallery.phase = Phase::Apply;
}
}
Phase::Apply => {
let scenario = &gallery.scenarios[gallery.index];
let (params, age) = (&scenario.params, scenario.age);
let (tree_params, tree_age) = &mut *tree;
tree_params.0 = params.clone();
tree_params.set_changed();
tree_age.0 = age;
tree_age.set_changed();
let out = build_tree(params, age);
let stats = analyze(params, age, &out);
let transforms = view_transforms(params, &out, stats.bbox);
for (view, mut transform) in &mut cameras {
*transform = transforms[view.0];
}
overlay.0 = format!(
"{} ({}/{})\nnodes {} trunk {}v/{}t foliage {}v/{}t\nflags: {}",
scenario.name,
gallery.index + 1,
gallery.scenarios.len(),
stats.nodes,
stats.trunk_verts,
stats.trunk_tris,
stats.foliage_verts,
stats.foliage_tris,
flags_text(&stats.flags),
);
gallery.pending = Some(Pending {
prefix: format!(
"{}\t{age}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
scenario.name,
params.seed,
stats.nodes,
stats.trunk_verts,
stats.trunk_tris,
stats.foliage_verts,
stats.foliage_tris,
fmt_vec(stats.bbox.0),
fmt_vec(stats.bbox.1),
),
flags: stats.flags,
});
gallery.frames = 0;
gallery.phase = Phase::Settle;
}
Phase::Settle => {
gallery.frames += 1;
if gallery.frames >= SETTLE_FRAMES {
let path = gallery
.out
.join(format!("{}.png", gallery.scenarios[gallery.index].name));
gallery.blank_capture = false;
commands
.spawn(Screenshot::image(gallery.target.clone()))
.observe(save_to_disk(path))
.observe(check_capture);
gallery.frames = 0;
gallery.phase = Phase::Capture;
}
}
Phase::Capture => {
gallery.frames += 1;
let timed_out = gallery.frames >= CAPTURE_TIMEOUT_FRAMES;
if !screenshots.is_empty() && !timed_out {
return;
}
let mut pending = gallery.pending.take().expect("scenario in flight");
if timed_out && !screenshots.is_empty() {
pending.flags.push("CAPTURE_TIMEOUT".to_string());
for screenshot in &screenshots {
commands.entity(screenshot).despawn();
}
}
if gallery.blank_capture {
pending.flags.push("BLANK_CAPTURE".to_string());
}
writeln!(gallery.tsv, "{}\t{}", pending.prefix, flags_text(&pending.flags))
.expect("append stats.tsv row");
gallery.tsv.flush().expect("flush stats.tsv");
gallery.index += 1;
gallery.frames = 0;
if gallery.index == gallery.scenarios.len() {
gallery.phase = Phase::Done;
exit.write(AppExit::Success);
} else {
gallery.phase = Phase::Apply;
}
}
Phase::Done => {}
}
}