use std::path::PathBuf;
use std::time::{Duration, Instant};
use bevy::app::AppExit;
use bevy::camera::RenderTarget;
use bevy::light::{CascadeShadowConfig, CascadeShadowConfigBuilder};
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy::render::view::screenshot::{Screenshot, save_to_disk};
use bevy::window::{PresentMode, WindowResolution};
use bevy::asset::RenderAssetUsages;
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy_flora::{
BackgroundPools, BuildInBackground, FloraFire, FloraGround, FloraPlugin, FloraSeason, FloraShadows, FloraWetness, FloraWind,
FoliagePusher,
GpuGrowth, Growing,
GustFront, LodDistances, LodSettings, PoolColors, PooledLook, PooledTree, ProceduralTree, RecolorPool, RenderStyle,
Scatter, SeededRng, SeeThroughTrees, TreeAge, TreeCondition, TreeLod, TreeParams, TreePool, TreePreset, TreeShake, TreeShakes,
build_pool,
};
const WINDOW: UVec2 = UVec2::new(1920, 1080);
const VARIANTS: u32 = 8;
const CAMERA_HEIGHT: f32 = 30.0;
const UNDERGROWTH_LOD_SCALE: f32 = 0.5;
const MAX_SLOPE_DEGREES: f32 = 30.0;
const LOOKS: [(PoolColors, PoolColors); 2] = [
(
PoolColors { wood: Color::srgb(0.38, 0.17, 0.08), leaves: Color::srgb(0.85, 0.22, 0.08), unlit: false },
PoolColors { wood: Color::srgb(0.25, 0.10, 0.30), leaves: Color::srgb(0.55, 0.15, 0.75), unlit: false },
),
(
PoolColors { wood: Color::srgb(0.12, 0.10, 0.40), leaves: Color::srgb(0.30, 0.90, 0.80), unlit: true },
PoolColors { wood: Color::srgb(0.55, 0.40, 0.08), leaves: Color::srgb(1.00, 0.85, 0.15), unlit: true },
),
];
fn look_at(x: f32, extent: f32, looks: bool) -> u32 {
if !looks {
return 0;
}
(((x / extent.max(1.0) + 0.5) * 3.0).floor() as i32).clamp(0, 2) as u32
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Naive,
Pool,
Gpu,
GpuLive,
CpuLive,
}
impl Mode {
fn name(self) -> &'static str {
match self {
Mode::Naive => "naive",
Mode::Pool => "pool",
Mode::Gpu => "gpu",
Mode::GpuLive => "gpu-live",
Mode::CpuLive => "cpu-live",
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Shadows {
Default,
Off,
Reach(f32, usize),
}
#[derive(Clone, Copy, Debug)]
enum FireStart {
Armed,
At(Vec2, f32),
}
struct Args {
trees: u32,
mode: Mode,
preset: TreePreset,
style: RenderStyle,
frames: usize,
warmup: Duration,
spacing: f32,
impostors: bool,
lod: Option<[f32; 3]>,
shadows: Shadows,
sun: Vec3,
eye: Option<Vec3>,
at: Option<Vec3>,
fov: f32,
ortho: Option<f32>,
impostor_elevation: f32,
impostor_shadows: bool,
background_pools: bool,
wind: Option<f32>,
wind_dir: Option<Vec2>,
gust: Option<f32>,
wind_animate: bool,
shake_animate: bool,
leaf_fall_animate: Option<f32>,
fire: Option<FireStart>,
push: Option<Vec2>,
deterministic: bool,
background: bool,
tree_lod: bool,
offscreen: bool,
screenshot: Option<PathBuf>,
season: FloraSeason,
wet: f32,
scatter: Option<Vec<(TreePreset, f32)>>,
grove: f32,
clearing: f32,
stand: Option<(Vec2, f32)>,
shadow_radius: Option<f32>,
undergrowth: Option<(TreePreset, f32)>,
terrain: Option<f32>,
see_through: Option<(Vec3, f32)>,
prepass: u8,
looks: bool,
recolor: bool,
glide: f32,
ages: u32,
ill: (f32, f32),
}
impl Args {
fn is_offscreen(&self) -> bool {
self.offscreen || self.screenshot.is_some()
}
}
fn usage() -> ! {
eprintln!(
"usage: forest [--trees <n>] [--mode <naive|pool|gpu|gpu-live|cpu-live>] [--preset <name>] [--frames <n>] \
[--warmup-secs <s>] [--spacing <m>] [--no-impostors] [--lod <a,b,c>] \
[--shadows <off|distance,cascades>] [--sun <x,y,z>] [--wind <strength>] \
[--eye <x,y,z>] [--at <x,y,z>] [--fov <deg>] [--ortho <view height>] \
[--impostor-elevation <deg>] [--impostor-shadows] [--background-pools] \
[--wind-dir <x,z>] [--gust <speed>] [--wind-animate] [--shake-animate] [--leaf-fall-animate <s>] [--deterministic] \
[--offscreen] [--screenshot <path>] [--style <natural|painterly|toon>] \
[--season <autumn>,<leaf fall>[,<snow>[,<blossom>]]] [--wet <0..1>] \
[--scatter <preset[:weight]>,... [--grove <m>] [--clearing <m>] [--stand <x,z,radius>]] \
[--undergrowth <preset>[:<spacing>]] [--terrain <relief>] [--background] [--tree-lod] [--shadow-radius <m>] \
[--see-through <x,y,z>[,<radius>]] [--prepass <none|depth|all>] [--fire <x,z[,radius]|armed>] [--push <x,z>] \
[--looks [--recolor [--glide <s>]]] [--ages <n>] [--sick <share>] [--dying <share>]"
);
std::process::exit(2);
}
fn parse_args() -> Args {
let mut args = Args {
trees: 10_000,
mode: Mode::Pool,
preset: TreePreset::Oak,
style: RenderStyle::Natural,
frames: 600,
warmup: Duration::from_secs(5),
spacing: 8.0,
impostors: true,
lod: None,
shadows: Shadows::Default,
sun: Vec3::new(-20.0, 25.0, 20.0),
eye: None,
at: None,
fov: 45.0,
ortho: None,
impostor_elevation: 10.0,
impostor_shadows: false,
background_pools: false,
wind: None,
wind_dir: None,
gust: None,
wind_animate: false,
shake_animate: false,
leaf_fall_animate: None,
fire: None,
push: None,
deterministic: false,
background: false,
tree_lod: false,
offscreen: false,
screenshot: None,
season: FloraSeason::SUMMER,
wet: 0.0,
scatter: None,
grove: 50.0,
clearing: 0.0,
stand: None,
shadow_radius: None,
undergrowth: None,
terrain: None,
see_through: None,
prepass: 0,
looks: false,
recolor: false,
glide: 0.0,
ages: 1,
ill: (0.0, 0.0),
};
let mut it = std::env::args().skip(1);
fn value<T: std::str::FromStr>(it: &mut impl Iterator<Item = String>) -> T {
it.next()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| usage())
}
while let Some(arg) = it.next() {
match arg.as_str() {
"--trees" => args.trees = value(&mut it),
"--mode" => {
args.mode = match it.next().as_deref() {
Some("naive") => Mode::Naive,
Some("pool") => Mode::Pool,
Some("gpu") => Mode::Gpu,
Some("gpu-live") => Mode::GpuLive,
Some("cpu-live") => Mode::CpuLive,
_ => usage(),
}
}
"--preset" => {
let name = it.next().unwrap_or_else(|| usage());
args.preset = TreePreset::ALL
.into_iter()
.find(|p| p.name() == name)
.unwrap_or_else(|| usage());
}
"--style" => {
args.style = match it.next().as_deref() {
Some("natural") => RenderStyle::Natural,
Some("painterly") => RenderStyle::Painterly,
Some("toon") => RenderStyle::Toon,
_ => usage(),
}
}
"--frames" => args.frames = value(&mut it),
"--warmup-secs" => args.warmup = Duration::from_secs_f32(value(&mut it)),
"--spacing" => args.spacing = value(&mut it),
"--no-impostors" => args.impostors = false,
"--offscreen" => args.offscreen = true,
"--shadows" => {
args.shadows = match it.next().as_deref() {
Some("off") => Shadows::Off,
Some(reach) => {
let parts: Vec<&str> = reach.split(',').collect();
match parts[..] {
[distance, cascades] => Shadows::Reach(
distance.parse().unwrap_or_else(|_| usage()),
cascades.parse().unwrap_or_else(|_| usage()),
),
_ => usage(),
}
}
None => usage(),
}
}
"--sun" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
let [x, y, z]: [f32; 3] = parts.try_into().unwrap_or_else(|_| usage());
args.sun = Vec3::new(x, y, z);
}
"--eye" | "--at" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
let [x, y, z]: [f32; 3] = parts.try_into().unwrap_or_else(|_| usage());
if arg == "--eye" {
args.eye = Some(Vec3::new(x, y, z));
} else {
args.at = Some(Vec3::new(x, y, z));
}
}
"--fov" => args.fov = value(&mut it),
"--ortho" => args.ortho = Some(value(&mut it)),
"--impostor-elevation" => args.impostor_elevation = value(&mut it),
"--impostor-shadows" => args.impostor_shadows = true,
"--background-pools" => args.background_pools = true,
"--wind" => args.wind = Some(value(&mut it)),
"--wind-dir" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
let [x, z]: [f32; 2] = parts.try_into().unwrap_or_else(|_| usage());
args.wind_dir = Some(Vec2::new(x, z));
}
"--gust" => args.gust = Some(value(&mut it)),
"--wind-animate" => args.wind_animate = true,
"--shake-animate" => args.shake_animate = true,
"--leaf-fall-animate" => args.leaf_fall_animate = Some(value(&mut it)),
"--deterministic" => args.deterministic = true,
"--background" => args.background = true,
"--tree-lod" => args.tree_lod = true,
"--scatter" => {
let list = it.next().unwrap_or_else(|| usage());
let species = list
.split(',')
.map(|item| {
let (name, weight) = item.split_once(':').unwrap_or((item, "1"));
let preset = TreePreset::ALL.into_iter().find(|p| p.name() == name).unwrap_or_else(|| usage());
(preset, weight.parse::<f32>().unwrap_or_else(|_| usage()))
})
.collect();
args.scatter = Some(species);
}
"--grove" => args.grove = value(&mut it),
"--clearing" => args.clearing = value(&mut it),
"--stand" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
args.stand = match parts[..] {
[x, z, radius] => Some((Vec2::new(x, z), radius)),
_ => usage(),
};
}
"--shadow-radius" => args.shadow_radius = Some(value(&mut it)),
"--undergrowth" => {
let item = it.next().unwrap_or_else(|| usage());
let (name, spacing) = item.split_once(':').unwrap_or((&item, "2.5"));
let preset = TreePreset::ALL.into_iter().find(|p| p.name() == name).unwrap_or_else(|| usage());
args.undergrowth = Some((preset, spacing.parse::<f32>().unwrap_or_else(|_| usage())));
}
"--terrain" => args.terrain = Some(value(&mut it)),
"--see-through" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
args.see_through = match parts[..] {
[x, y, z] => Some((Vec3::new(x, y, z), 1.5)),
[x, y, z, radius] => Some((Vec3::new(x, y, z), radius)),
_ => usage(),
};
}
"--prepass" => {
args.prepass = match it.next().as_deref() {
Some("none") => 0,
Some("depth") => 1,
Some("all") => 2,
_ => usage(),
};
}
"--push" => {
let item = it.next().unwrap_or_else(|| usage());
let v: Vec<f32> = item.split(',').map(|p| p.parse().unwrap_or_else(|_| usage())).collect();
let [x, z]: [f32; 2] = v.try_into().unwrap_or_else(|_| usage());
args.push = Some(Vec2::new(x, z));
}
"--fire" => {
let item = it.next().unwrap_or_else(|| usage());
args.fire = Some(if item == "armed" {
FireStart::Armed
} else {
let v: Vec<f32> = item.split(',').map(|p| p.parse().unwrap_or_else(|_| usage())).collect();
match v[..] {
[x, z] => FireStart::At(Vec2::new(x, z), 4.0),
[x, z, radius] => FireStart::At(Vec2::new(x, z), radius),
_ => usage(),
}
});
}
"--season" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
let (autumn, leaf_fall, snow, blossom) = match parts[..] {
[autumn, leaf_fall] => (autumn, leaf_fall, 0.0, 0.0),
[autumn, leaf_fall, snow] => (autumn, leaf_fall, snow, 0.0),
[autumn, leaf_fall, snow, blossom] => (autumn, leaf_fall, snow, blossom),
_ => usage(),
};
args.season = FloraSeason { autumn, leaf_fall, snow, blossom };
}
"--wet" => args.wet = it.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| usage()),
"--lod" => {
let list = it.next().unwrap_or_else(|| usage());
let parts: Vec<f32> = list.split(',').filter_map(|p| p.parse().ok()).collect();
args.lod = Some(parts.try_into().unwrap_or_else(|_| usage()));
}
"--looks" => args.looks = true,
"--recolor" => args.recolor = true,
"--glide" => args.glide = value(&mut it),
"--ages" => args.ages = value(&mut it),
"--sick" => args.ill.0 = value(&mut it),
"--dying" => args.ill.1 = value(&mut it),
"--screenshot" => args.screenshot = Some(it.next().unwrap_or_else(|| usage()).into()),
_ => usage(),
}
}
if args.trees == 0 || args.frames == 0 || args.spacing <= 0.0 {
usage();
}
args
}
#[derive(PartialEq, Eq)]
enum Phase {
Loading,
Warmup,
Measuring,
Capturing,
Done,
}
#[derive(Resource)]
struct Bench {
args: Args,
phase: Phase,
loaded: Instant,
loading_frame: Instant,
longest_loading: f32,
last: Option<Instant>,
cpu_start: Option<f64>,
warm_frames: u32,
frames: Vec<f32>,
target: Handle<Image>,
extent: f32,
placed: u32,
recolored: bool,
}
#[derive(Component)]
struct ForestTree;
fn main() {
let args = parse_args();
let mut app = App::new();
let window = if args.is_offscreen() {
WindowPlugin {
primary_window: None,
exit_condition: bevy::window::ExitCondition::DontExit,
..default()
}
} else {
WindowPlugin {
primary_window: Some(Window {
resolution: WindowResolution::new(WINDOW.x, WINDOW.y)
.with_scale_factor_override(1.0),
present_mode: PresentMode::AutoNoVsync,
title: "bevy_flora forest".to_string(),
..default()
}),
..default()
}
};
if args.wind.is_some() || args.wind_dir.is_some() {
let base = FloraWind::default();
app.insert_resource(FloraWind {
strength: args.wind.unwrap_or(base.strength),
direction: args.wind_dir.unwrap_or(base.direction),
..base
});
}
if args.deterministic {
app.insert_resource(bevy::time::TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(1.0 / 60.0)));
}
app.insert_resource(args.season).insert_resource(FloraWetness(args.wet));
let leaf_fall = args.leaf_fall_animate.is_some();
let fire = args.fire.is_some();
let settings = if args.is_offscreen() {
let flat_out = bevy::winit::UpdateMode::reactive(Duration::ZERO);
bevy::winit::WinitSettings { focused_mode: flat_out, unfocused_mode: flat_out }
} else {
bevy::winit::WinitSettings::continuous()
};
app.insert_resource(settings)
.add_plugins((DefaultPlugins.set(window), 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(Bench {
args,
phase: Phase::Loading,
loaded: Instant::now(),
loading_frame: Instant::now(),
longest_loading: 0.0,
last: None,
frames: Vec::new(),
cpu_start: None,
warm_frames: 0,
target: Handle::default(),
extent: 0.0,
placed: 0,
recolored: false,
})
.add_systems(Startup, setup)
.add_systems(Update, drive);
if leaf_fall {
app.add_systems(Update, animate_leaf_fall);
}
if fire {
app.add_systems(Update, light_fire);
}
app.run();
}
fn grid(trees: u32, spacing: f32) -> (u32, f32) {
let side = (trees as f32).sqrt().ceil() as u32;
(side, side as f32 * spacing)
}
#[allow(clippy::too_many_arguments)]
fn setup(
mut commands: Commands,
mut bench: ResMut<Bench>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut images: ResMut<Assets<Image>>,
mut pools: ResMut<Assets<TreePool>>,
mut background: BackgroundPools,
) {
let placing = Instant::now();
let ground = ground(&bench.args);
let (trees, extent) = placements(&bench.args, &ground);
let placing = placing.elapsed().as_secs_f32() * 1000.0;
bench.extent = extent;
let half = extent * 0.5;
if bench.args.fire.is_some() {
let cells = (extent / 2.0).ceil().clamp(2.0, FloraFire::MAX_CELLS as f32) as u32;
commands.insert_resource(FloraFire::with_cells(Vec2::ZERO, extent + 8.0, cells));
}
if let Some(at) = bench.args.push {
commands.spawn((Transform::from_xyz(at.x, ground.height(at) + 0.9, at.y), FoliagePusher::default()));
}
commands.spawn((
DirectionalLight {
shadow_maps_enabled: bench.args.shadows != Shadows::Off,
illuminance: 10_000.0,
..default()
},
match bench.args.shadows {
Shadows::Reach(maximum_distance, num_cascades) => CascadeShadowConfigBuilder {
maximum_distance,
num_cascades,
..default()
}
.build(),
_ => CascadeShadowConfig::default(),
},
Transform::from_translation(bench.args.sun).looking_at(Vec3::ZERO, Vec3::Y),
));
let side = extent * 1.2 + 200.0;
let ground_mesh = match bench.args.terrain {
Some(_) => terrain_mesh(&ground, side),
None => Plane3d::default().mesh().size(side, side).build(),
};
if bench.args.terrain.is_some() {
commands.insert_resource(ground.clone());
}
commands.spawn((
Mesh3d(meshes.add(ground_mesh)),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: {
let grass = Vec3::new(0.35, 0.45, 0.30);
let c = grass.lerp(Vec3::new(0.86, 0.88, 0.92), bench.args.season.snow);
Color::srgb(c.x, c.y, c.z)
},
perceptual_roughness: 1.0,
..default()
})),
));
let view = {
let lift = |p: Vec3| p + Vec3::Y * ground.height(p.xz());
let eye = lift(bench.args.eye.unwrap_or(Vec3::new(-half, CAMERA_HEIGHT, -half)));
let at = lift(bench.args.at.unwrap_or(Vec3::new(0.0, 8.0, 0.0)));
Transform::from_translation(eye).looking_at(at, Vec3::Y)
};
let mut camera = commands.spawn((
Camera3d::default(),
match bench.args.ortho {
Some(height) => Projection::Orthographic(OrthographicProjection {
scaling_mode: bevy::camera::ScalingMode::FixedVertical { viewport_height: height },
far: extent * 2.0 + 500.0,
..OrthographicProjection::default_3d()
}),
None => Projection::Perspective(PerspectiveProjection {
fov: bench.args.fov.to_radians(),
far: extent * 2.0 + 500.0,
..default()
}),
},
view,
));
if bench.args.is_offscreen() {
bench.target = images.add(Image::new_target_texture(
WINDOW.x,
WINDOW.y,
TextureFormat::Rgba8Unorm,
Some(TextureFormat::Rgba8UnormSrgb),
));
camera.insert(RenderTarget::Image(bench.target.clone().into()));
}
if bench.args.prepass >= 1 {
camera.insert(bevy::core_pipeline::prepass::DepthPrepass);
}
if bench.args.prepass >= 2 {
camera.insert((bevy::core_pipeline::prepass::NormalPrepass, bevy::core_pipeline::prepass::MotionVectorPrepass));
}
if let Some((target, radius)) = bench.args.see_through {
let target = target + Vec3::Y * ground.height(target.xz());
camera.insert(SeeThroughTrees { target, radius, ..default() });
commands.spawn((
Mesh3d(meshes.add(Capsule3d::new(0.3, 1.1))),
MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgb(0.85, 0.12, 0.08), ..default() })),
Transform::from_translation(target),
));
}
let bases: Vec<TreeParams> = bench
.args
.scatter
.as_ref()
.map_or_else(|| vec![bench.args.preset], |species| species.iter().map(|&(preset, _)| preset).collect())
.into_iter()
.chain(bench.args.undergrowth.map(|(preset, _)| preset))
.map(|preset| {
let mut base = preset.params();
base.seed = 1;
base.style = bench.args.style;
base
})
.collect();
let undergrowth = bench.args.undergrowth.map(|_| bases.len() - 1);
let lods: Vec<LodSettings> = (0..bases.len())
.map(|i| {
let mut lod = LodSettings::default();
if let Some([lod1, lod2, impostor]) = bench.args.lod {
lod.distances = LodDistances { lod1, lod2, impostor };
}
if !bench.args.impostors {
lod.distances.impostor = f32::INFINITY;
}
lod.impostor_elevation = bench.args.impostor_elevation;
lod.impostor_shadows = bench.args.impostor_shadows;
if Some(i) == undergrowth {
let d = lod.distances;
let near = UNDERGROWTH_LOD_SCALE;
lod.distances = LodDistances { lod1: d.lod1 * near, lod2: d.lod2 * near, impostor: d.impostor * near };
}
lod
})
.collect();
let pools: Vec<Vec<Handle<TreePool>>> = if bench.args.mode == Mode::Pool {
let started = Instant::now();
let in_background = bench.args.background_pools;
let ages: Vec<f32> = (1..=bench.args.ages.max(1))
.map(|k| if k == bench.args.ages.max(1) { f32::INFINITY } else { 4.0 * k as f32 })
.collect();
let built: Vec<Vec<_>> = bases
.iter()
.zip(&lods)
.map(|(base, lod)| {
ages.iter()
.map(|&age| {
if in_background {
background.build(base, VARIANTS, age, lod)
} else {
let mut pool = build_pool(base, VARIANTS, age, lod, &mut meshes);
if bench.args.looks {
for (colors, _) in &LOOKS {
pool.add_look(*colors);
}
}
pools.add(pool)
}
})
.collect()
})
.collect();
eprintln!(
"forest: {} {} pool(s) of {VARIANTS} variants in {:.2} s",
if in_background { "started" } else { "built" },
built.iter().map(Vec::len).sum::<usize>(),
started.elapsed().as_secs_f32()
);
built
} else {
Vec::new()
};
bench.placed = trees.len() as u32;
eprintln!("forest: placed {} trees over {extent:.0} m in {placing:.1} ms", trees.len());
if let Some(radius) = bench.args.shadow_radius {
commands.insert_resource(FloraShadows { radius });
}
let (sick, dying) = bench.args.ill;
let condition = |n: usize| {
let u = (n as u32).wrapping_mul(2_654_435_761) >> 8;
let u = u as f32 / (1u32 << 24) as f32;
if u < dying {
Some(TreeCondition::Dying)
} else if u < dying + sick {
Some(TreeCondition::Sick)
} else {
None
}
};
for (n, (transform, species, variant)) in trees.into_iter().enumerate() {
let base = &bases[species];
if let Some(pool) = pools.get(species).map(|ages| &ages[variant as usize % ages.len()]) {
let look = PooledLook(look_at(transform.translation.x, bench.extent, bench.args.looks));
let mut tree = commands.spawn((PooledTree { pool: pool.clone(), variant }, look, transform, ForestTree));
if let Some(condition) = condition(n) {
tree.insert(condition);
}
continue;
}
let mut params: TreeParams = base.clone();
params.seed = base.seed.wrapping_add(variant as i32);
let mut tree = commands.spawn((ProceduralTree(params), transform, ForestTree));
if let Some(condition) = condition(n) {
tree.insert(condition);
}
match bench.args.mode {
Mode::Gpu => {
tree.insert(GpuGrowth);
}
Mode::GpuLive => {
tree.insert((GpuGrowth, TreeAge(3.0), Growing { rate: 0.01 }));
}
Mode::CpuLive => {
tree.insert((TreeAge(3.0), Growing { rate: 0.01 }));
}
_ => {}
}
if bench.args.background {
tree.insert(BuildInBackground);
}
if bench.args.tree_lod {
tree.insert(TreeLod(lods[species]));
}
}
}
fn ground(args: &Args) -> FloraGround {
match args.terrain {
None => FloraGround::default(),
Some(relief) => FloraGround::new(move |at: Vec2| hills(at, relief)),
}
}
fn hills(at: Vec2, relief: f32) -> f32 {
let q = at + Vec2::new((at.y * 0.011).sin(), (at.x * 0.013).cos()) * 25.0;
relief
* (0.5 * (q.x * 0.019).sin() * (q.y * 0.016).cos()
+ 0.3 * (q.x * 0.041 + 1.3).sin() * (q.y * 0.037 + 0.4).sin()
+ 0.2 * ((q.x + q.y) * 0.083).sin())
}
fn terrain_mesh(ground: &FloraGround, side: f32) -> Mesh {
let cells = ((side / 2.0).ceil() as u32).clamp(1, 600);
let step = side / cells as f32;
let n = cells + 1;
let mut positions = Vec::with_capacity((n * n) as usize);
let mut normals = Vec::with_capacity((n * n) as usize);
let mut uvs = Vec::with_capacity((n * n) as usize);
for j in 0..n {
for i in 0..n {
let at = Vec2::new(i as f32 * step, j as f32 * step) - side * 0.5;
positions.push([at.x, ground.height(at), at.y]);
normals.push(ground.normal(at).to_array());
uvs.push([i as f32 / cells as f32, j as f32 / cells as f32]);
}
}
let mut indices = Vec::with_capacity((cells * cells * 6) as usize);
for j in 0..cells {
for i in 0..cells {
let a = j * n + i;
let (b, c) = (a + 1, a + n);
indices.extend([a, c, b, b, c, c + 1]);
}
}
Mesh::new(PrimitiveTopology::TriangleList, RenderAssetUsages::RENDER_WORLD)
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions)
.with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals)
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, uvs)
.with_inserted_indices(Indices::U32(indices))
}
fn placements(args: &Args, ground: &FloraGround) -> (Vec<(Transform, usize, u32)>, f32) {
let (mut trees, extent) = tree_placements(args, ground);
let Some((shrub, spacing)) = args.undergrowth else { return (trees, extent) };
const CELL: f32 = 2.0;
const TRUNK_CLEARANCE: f32 = 1.5;
let cell = |p: Vec2| ((p.x / CELL).floor() as i32, (p.y / CELL).floor() as i32);
let mut trunks: std::collections::HashMap<(i32, i32), Vec<Vec2>> = Default::default();
for (transform, ..) in &trees {
let at = transform.translation.xz();
trunks.entry(cell(at)).or_default().push(at);
}
let near_a_trunk = |at: Vec2| {
let (cx, cz) = cell(at);
(cx - 1..=cx + 1).any(|x| {
(cz - 1..=cz + 1).any(|z| {
trunks.get(&(x, z)).is_some_and(|ps| ps.iter().any(|p| p.distance(at) < TRUNK_CLEARANCE))
})
})
};
let species = args.scatter.as_ref().map_or(1, Vec::len);
let half = extent * 0.5;
let clearing = args.clearing * 0.75;
let patches = |at: Vec2| {
let wave = (at.x * 0.19 + (at.y * 0.13).sin() * 2.0).sin() * (at.y * 0.17 + (at.x * 0.11).cos() * 2.0).cos();
let t = ((wave + 0.6) / 1.2).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
};
let shrub_params = shrub.params();
let steep = |at: Vec2| args.terrain.is_some() && ground.slope(at) > MAX_SLOPE_DEGREES.to_radians();
let shrubs = Scatter { seed: 2, ..Scatter::new(Rect::new(-half, -half, half, half), spacing) }
.points_where(|at| if at.length() < clearing || near_a_trunk(at) || steep(at) { 0.0 } else { patches(at) });
trees.extend(
shrubs.into_iter().map(|shrub| (shrub.transform_on(ground, &shrub_params), species, shrub.variant % VARIANTS)),
);
(trees, extent)
}
fn tree_placements(args: &Args, ground: &FloraGround) -> (Vec<(Transform, usize, u32)>, f32) {
let spacing = args.spacing;
let steep = |at: Vec2| args.terrain.is_some() && ground.slope(at) > MAX_SLOPE_DEGREES.to_radians();
let Some(species) = &args.scatter else {
let (side, extent) = grid(args.trees, spacing);
let radius = args.preset.params().base_radius();
let half = extent * 0.5;
let trees = (0..args.trees)
.map(|i| {
let mut rng = SeededRng::new(i as i32);
let jitter = spacing / 3.0;
let x = (i % side) as f32 * spacing - half + rng.range(-jitter, jitter);
let z = (i / side) as f32 * spacing - half + rng.range(-jitter, jitter);
let yaw = rng.range(0.0, std::f32::consts::TAU);
let y = ground.footing(Vec2::new(x, z), radius);
(Transform::from_xyz(x, y, z).with_rotation(Quat::from_rotation_y(yaw)), 0, i % VARIANTS)
})
.collect();
return (trees, extent);
};
let extent = (args.trees as f32 / 0.68).sqrt() * spacing;
let half = extent * 0.5;
let scatter = Scatter {
seed: 1,
species: species.iter().map(|&(_, weight)| weight).collect(),
grove_size: args.grove,
..Scatter::new(Rect::new(-half, -half, half, half), spacing)
};
let clearing = args.clearing;
let params: Vec<TreeParams> = species.iter().map(|&(preset, _)| preset.params()).collect();
let last = species.len() - 1;
let stand = |at: Vec2, kind: usize| match args.stand {
None => 1.0,
Some((centre, radius)) => {
let out = ((at.distance(centre) - 0.75 * radius) / (0.5 * radius)).clamp(0.0, 1.0);
if kind == last { 1.0 - out } else { out }
}
};
let trees = scatter
.points_with(|at| if at.length() < clearing || steep(at) { 0.0 } else { 1.0 }, stand)
.into_iter()
.map(|tree| (tree.transform_on(ground, ¶ms[tree.species]), tree.species, tree.variant % VARIANTS))
.collect();
(trees, extent)
}
fn light_fire(bench: Res<Bench>, fire: Option<ResMut<FloraFire>>, mut lit: Local<bool>) {
if *lit || bench.phase == Phase::Loading {
return;
}
*lit = true;
if let (Some(FireStart::At(at, radius)), Some(mut fire)) = (bench.args.fire, fire) {
fire.ignite(Vec3::new(at.x, 0.0, at.y), radius);
}
}
fn animate_leaf_fall(bench: Res<Bench>, time: Res<Time>, mut season: ResMut<FloraSeason>) {
if let Some(period) = bench.args.leaf_fall_animate
&& bench.phase != Phase::Loading
{
season.autumn = 1.0;
season.leaf_fall = (time.elapsed_secs() / period.max(0.1)).fract();
}
}
#[allow(clippy::too_many_arguments)]
fn drive(
mut commands: Commands,
mut bench: ResMut<Bench>,
unfinished: Query<(), (With<ForestTree>, Without<Children>)>,
screenshots: Query<(), With<Screenshot>>,
pools: Res<Assets<TreePool>>,
mut exit: MessageWriter<AppExit>,
mut recolor: MessageWriter<RecolorPool>,
mut wind: ResMut<FloraWind>,
time: Res<Time>,
mut shakes: ResMut<TreeShakes>,
mut knocks: Local<u32>,
) {
let bench = &mut *bench;
if bench.args.wind_animate && bench.phase != Phase::Loading {
wind.strength = 0.6 + 0.3 * (time.elapsed_secs() * 1.7).sin();
}
if bench.args.shake_animate && bench.phase != Phase::Loading {
*knocks += 1;
if knocks.is_multiple_of(6) {
let a = *knocks as f32 * 0.37;
let at = Vec2::new(a.cos(), a.sin()) * (bench.extent * 0.15);
shakes.shake(TreeShake { at, radius: 20.0, strength: 1.5, duration: 3.0, ..default() }, &time);
}
}
match bench.phase {
Phase::Loading => {
let now = Instant::now();
bench.longest_loading = bench.longest_loading.max((now - bench.loading_frame).as_secs_f32());
bench.loading_frame = now;
if unfinished.is_empty() {
eprintln!(
"forest: {} trees ready after {:.1} s, the longest frame {:.0} ms",
bench.placed,
bench.loaded.elapsed().as_secs_f32(),
bench.longest_loading * 1000.0
);
bench.loaded = Instant::now();
bench.phase = Phase::Warmup;
if let Some(speed) = bench.args.gust {
let extent = bench.extent;
wind.launch_gust(
GustFront {
origin: Vec2::new(-extent * 0.5, 0.0),
direction: Vec2::X,
speed,
width: 20.0,
strength: 1.5,
..default()
},
&time,
);
}
}
}
Phase::Warmup => {
bench.warm_frames += 1;
let warm = if bench.args.deterministic {
(bench.warm_frames as f32 / 60.0) < bench.args.warmup.as_secs_f32()
} else {
bench.loaded.elapsed() < bench.args.warmup
};
if warm {
return;
}
if bench.args.recolor && !bench.recolored {
bench.recolored = true;
for (id, _) in pools.iter() {
for (look, (_, colors)) in LOOKS.iter().enumerate() {
recolor.write(RecolorPool { pool: id, look: look as u32 + 1, colors: *colors, glide: bench.args.glide });
}
}
eprintln!("forest: recoloured looks 1 and 2");
bench.warm_frames = 0;
bench.loaded = Instant::now();
return;
}
if bench.args.mode == Mode::Pool && bench.args.impostors {
let baked = pools
.iter()
.all(|(_, pool)| pool.variants.iter().all(|v| v.impostor.is_some()));
if !baked {
eprintln!("forest: the impostors were not baked");
exit.write(AppExit::error());
return;
}
}
if let Some(path) = bench.args.screenshot.clone() {
commands
.spawn(Screenshot::image(bench.target.clone()))
.observe(save_to_disk(path));
bench.phase = Phase::Capturing;
} else {
bench.phase = Phase::Measuring;
bench.cpu_start = cpu_seconds();
}
}
Phase::Measuring => {
let now = Instant::now();
if let Some(last) = bench.last.replace(now) {
bench.frames.push((now - last).as_secs_f32());
}
if bench.frames.len() >= bench.args.frames {
report(bench);
bench.phase = Phase::Done;
exit.write(AppExit::Success);
}
}
Phase::Capturing => {
if screenshots.is_empty() {
exit.write(AppExit::Success);
}
}
Phase::Done => {}
}
}
fn report(bench: &Bench) {
let mut sorted = bench.frames.clone();
sorted.sort_by(f32::total_cmp);
let mean = bench.frames.iter().sum::<f32>() / bench.frames.len() as f32;
let p95 = sorted[((sorted.len() as f32 * 0.95) as usize).min(sorted.len() - 1)];
let median = sorted[sorted.len() / 2];
let cpu = match (bench.cpu_start, cpu_seconds()) {
(Some(start), Some(end)) => format!("{:.2}", (end - start) * 1000.0 / bench.frames.len() as f64),
_ => "?".to_string(),
};
println!(
"forest {} {} {} trees: {:.2} ms/frame ({:.1} fps), p95 {:.2} ms, median {:.2} ms, min {:.2} ms, cpu {} ms/frame",
bench.args.mode.name(),
bench.args.preset.name(),
bench.placed,
mean * 1000.0,
1.0 / mean,
p95 * 1000.0,
median * 1000.0,
sorted[0] * 1000.0,
cpu,
);
}
#[cfg(target_os = "linux")]
fn cpu_seconds() -> Option<f64> {
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
let after_comm = stat.get(stat.rfind(')')? + 1..)?;
let mut fields = after_comm.split_whitespace().skip(11);
let utime: f64 = fields.next()?.parse().ok()?;
let stime: f64 = fields.next()?.parse().ok()?;
Some((utime + stime) / 100.0)
}
#[cfg(not(target_os = "linux"))]
fn cpu_seconds() -> Option<f64> {
let out = std::process::Command::new("ps")
.args(["-o", "cputime=", "-p", &std::process::id().to_string()])
.output()
.ok()?;
let text = String::from_utf8(out.stdout).ok()?;
let mut seconds = 0.0;
for part in text.trim().split(':') {
seconds = seconds * 60.0 + part.parse::<f64>().ok()?;
}
Some(seconds)
}
#[cfg(test)]
mod tests {
use super::cpu_seconds;
#[test]
fn cpu_time_resolves_a_fraction_of_a_second() {
let start = cpu_seconds().expect("cpu time");
let spin = std::time::Instant::now();
while spin.elapsed() < std::time::Duration::from_millis(300) {
std::hint::black_box(0u64);
}
let used = cpu_seconds().expect("cpu time") - start;
assert!((0.15..0.6).contains(&used), "300 ms of spinning measured as {used} s");
}
}