use std::f32::consts::FRAC_PI_4;
use std::path::PathBuf;
use std::time::Duration;
use bevy::app::AppExit;
use bevy::camera::{RenderTarget, ScalingMode};
use bevy::input::mouse::AccumulatedMouseScroll;
use bevy::light::CascadeShadowConfigBuilder;
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy::render::view::screenshot::{Screenshot, save_to_disk};
use bevy::time::TimeUpdateStrategy;
use bevy::ui::UiTargetCamera;
use bevy_flora::prelude::*;
const YAW: f32 = FRAC_PI_4;
const PITCH: f32 = 35.0;
const BACK: f32 = 400.0;
const NEAREST: f32 = 22.0;
const WIDEST: f32 = 260.0;
const PACE: f32 = 2.6;
const CHEST: f32 = 1.2;
const START: f32 = -12.0;
const HALF: f32 = 140.0;
const SPACING: f32 = 6.5;
const PATH: f32 = 1.6;
const LEVELS: [(f32, &str); 3] = [(30.0, "drawn in full"), (80.0, "at level 1"), (200.0, "at level 2")];
#[derive(Resource, Default, Clone, Copy)]
struct Controls {
walk: Vec2,
zoom: f32,
toggle: bool,
}
#[derive(Component)]
struct Walker;
#[derive(Component)]
struct Hud;
#[derive(Resource)]
struct View {
height: f32,
see_through: bool,
anchor: Option<Vec3>,
}
#[derive(Resource)]
struct Demo {
out: PathBuf,
target: Handle<Image>,
frame: u32,
wait: u32,
in_flight: bool,
due: Option<u32>,
}
const DEMO_FRAMES: u32 = 192;
const DEMO_WARMUP: u32 = 30;
const DEMO_SETTLE: u32 = 2;
fn main() {
let mut demo = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--demo" => demo = Some(PathBuf::from("target/capture/isometric")),
"--out" => demo = Some(PathBuf::from(args.next().expect("--out <dir>"))),
_ => {
eprintln!("usage: isometric [--demo [--out <dir>]]");
std::process::exit(2);
}
}
}
let mut app = App::new();
app.add_plugins((DefaultPlugins, 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(FloraWind { strength: 0.3, ..default() })
.insert_resource(View { height: NEAREST, see_through: true, anchor: None })
.init_resource::<Controls>()
.add_systems(Startup, setup)
.add_systems(
Update,
(
(read_input.run_if(not(resource_exists::<Demo>)), (record, script).chain().run_if(resource_exists::<Demo>)),
(walk, follow, hud).chain(),
)
.chain(),
);
if let Some(out) = demo {
std::fs::create_dir_all(&out).expect("create the output directory");
let (right, _) = screen_axes();
let anchor = Some(right * (START + PACE * DEMO_FRAMES as f32 / 24.0 / 2.0) + Vec3::Y * CHEST);
app.insert_resource(Demo { out, target: Handle::default(), frame: 0, wait: DEMO_WARMUP, in_flight: false, due: None })
.insert_resource(View { height: NEAREST, see_through: true, anchor })
.insert_resource(FloraWind { strength: 0.0, ..default() })
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::ZERO));
}
app.run();
}
fn screen_axes() -> (Vec3, Vec3) {
let (sin, cos) = YAW.sin_cos();
(Vec3::new(cos, 0.0, -sin), Vec3::new(-sin, 0.0, -cos))
}
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut pools: BackgroundPools,
mut images: ResMut<Assets<Image>>,
demo: Option<ResMut<Demo>>,
) {
commands.spawn((
DirectionalLight { shadow_maps_enabled: true, illuminance: 10_000.0, ..default() },
CascadeShadowConfigBuilder {
num_cascades: 3,
minimum_distance: BACK - 60.0,
first_cascade_far_bound: BACK + 40.0,
maximum_distance: BACK + 500.0,
..default()
}
.build(),
Transform::from_xyz(-10.0, 20.0, 12.0).looking_at(Vec3::ZERO, Vec3::Y),
));
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(12.0 * HALF, 12.0 * HALF))),
MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgb(0.35, 0.45, 0.30), perceptual_roughness: 1.0, ..default() })),
));
let species = [TreePreset::Pine, TreePreset::Spruce, TreePreset::Oak, TreePreset::Maple];
let lod = LodSettings { impostor_elevation: PITCH, impostor_shadows: true, ..default() };
let pools: Vec<Handle<TreePool>> =
species.iter().map(|preset| pools.build(&preset.params(), 4, f32::INFINITY, &lod)).collect();
let forest = Scatter {
seed: 3,
species: vec![3.0, 2.0, 2.0, 1.0],
grove_size: 40.0,
..Scatter::new(Rect::new(-HALF, -HALF, HALF, HALF), SPACING)
};
let (_, up) = screen_axes();
let off_path = |at: Vec2| Vec3::new(at.x, 0.0, at.y).dot(up).abs() > PATH;
for tree in forest.points_where(|at| if off_path(at) { 1.0 } else { 0.0 }) {
commands.spawn((PooledTree { pool: pools[tree.species].clone(), variant: tree.variant }, tree.transform(0.0)));
}
let (right, _) = screen_axes();
commands.spawn((
Walker,
Mesh3d(meshes.add(Capsule3d::new(0.3, 1.2))),
MeshMaterial3d(materials.add(StandardMaterial { base_color: Color::srgb(0.85, 0.12, 0.08), ..default() })),
Transform::from_translation(right * START + Vec3::Y * 0.9),
));
let camera = commands
.spawn((
Camera3d::default(),
Projection::Orthographic(OrthographicProjection {
scaling_mode: ScalingMode::FixedVertical { viewport_height: NEAREST },
..OrthographicProjection::default_3d()
}),
Transform::default(),
))
.id();
let hud = commands
.spawn((
Hud,
Text::new(""),
TextFont { font_size: FontSize::Px(15.0), ..default() },
Node { position_type: PositionType::Absolute, top: px(10), left: px(12), ..default() },
))
.id();
if let Some(mut demo) = demo {
demo.target = images.add(Image::new_target_texture(1280, 720, TextureFormat::Rgba8Unorm, Some(TextureFormat::Rgba8UnormSrgb)));
commands.entity(camera).insert(RenderTarget::Image(demo.target.clone().into()));
commands.entity(hud).insert((UiTargetCamera(camera), TextFont { font_size: FontSize::Px(26.0), ..default() }));
}
}
fn read_input(keys: Res<ButtonInput<KeyCode>>, scroll: Res<AccumulatedMouseScroll>, mut controls: ResMut<Controls>) {
let axis = |plus: KeyCode, minus: KeyCode| keys.pressed(plus) as i32 as f32 - keys.pressed(minus) as i32 as f32;
let keyed = axis(KeyCode::Minus, KeyCode::Equal);
*controls = Controls {
walk: Vec2::new(axis(KeyCode::KeyD, KeyCode::KeyA), axis(KeyCode::KeyW, KeyCode::KeyS)),
zoom: 1.1f32.powf(keyed - scroll.delta.y.clamp(-3.0, 3.0)),
toggle: keys.just_pressed(KeyCode::KeyT),
};
}
fn demo_height(frame: u32) -> f32 {
let ramp = |from: u32, to: u32| {
let t = ((frame as f32 - from as f32) / (to - from) as f32).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
};
let out = ramp(56, 104) - ramp(128, 176);
NEAREST * (WIDEST / NEAREST).powf(out)
}
fn script(time: Res<Time>, mut demo: ResMut<Demo>, mut controls: ResMut<Controls>) {
*controls = Controls { zoom: 1.0, ..default() };
if time.delta_secs() <= 0.0 {
return;
}
let Some(frame) = demo.due.take() else { return };
controls.walk = Vec2::X;
controls.zoom = demo_height(frame) / demo_height(frame - 1);
}
fn record(
mut commands: Commands,
mut demo: ResMut<Demo>,
waiting: Query<(), (With<PooledTree>, Without<Children>)>,
screenshots: Query<(), With<Screenshot>>,
mut strategy: ResMut<TimeUpdateStrategy>,
mut exit: MessageWriter<AppExit>,
) {
*strategy = TimeUpdateStrategy::ManualDuration(Duration::ZERO);
if demo.in_flight {
if !screenshots.is_empty() {
return;
}
demo.in_flight = false;
demo.frame += 1;
if demo.frame == DEMO_FRAMES {
println!("isometric: wrote {DEMO_FRAMES} frames to {}", demo.out.display());
exit.write(AppExit::Success);
return;
}
demo.wait = DEMO_SETTLE;
demo.due = Some(demo.frame);
*strategy = TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(1.0 / 24.0));
}
if !waiting.is_empty() {
return;
}
if demo.wait > 0 {
demo.wait -= 1;
return;
}
let path = demo.out.join(format!("frame_{:04}.png", demo.frame));
commands.spawn(Screenshot::image(demo.target.clone())).observe(save_to_disk(path));
demo.in_flight = true;
}
fn walk(time: Res<Time>, controls: Res<Controls>, mut walker: Single<&mut Transform, With<Walker>>) {
let (right, up) = screen_axes();
let way = right * controls.walk.x + up * controls.walk.y;
walker.translation += way.normalize_or_zero() * PACE * time.delta_secs();
}
fn follow(
mut commands: Commands,
controls: Res<Controls>,
mut view: ResMut<View>,
walker: Single<&Transform, (With<Walker>, Without<Camera3d>)>,
camera: Single<(Entity, &mut Transform, &mut Projection), With<Camera3d>>,
) {
let (entity, mut transform, mut projection) = camera.into_inner();
let height = (view.height * controls.zoom).clamp(NEAREST, WIDEST);
if height != view.height {
view.height = height;
}
if controls.toggle {
view.see_through = !view.see_through;
}
let chest = walker.translation.with_y(CHEST);
let focus = view.anchor.unwrap_or(chest);
let (sin, cos) = YAW.sin_cos();
let pitch = PITCH.to_radians();
let back = Vec3::new(sin * pitch.cos(), pitch.sin(), cos * pitch.cos());
*transform = Transform::from_translation(focus + back * BACK).looking_at(focus, Vec3::Y);
if let Projection::Orthographic(orthographic) = &mut *projection {
orthographic.scaling_mode = ScalingMode::FixedVertical { viewport_height: height };
}
if view.see_through {
commands.entity(entity).insert(SeeThroughTrees {
target: chest,
radius: (0.12 * height).clamp(2.5, 14.0),
keep: 0.35,
inner: 0.55,
in_front: Some(1.5),
floor: Some(0.5),
});
} else {
commands.entity(entity).remove::<SeeThroughTrees>();
}
}
fn hud(view: Res<View>, mut text: Single<&mut Text, With<Hud>>) {
let seen_from = view.height / (2.0 * (FRAC_PI_4 / 2.0).tan());
let level = LEVELS.iter().find(|(distance, _)| seen_from < *distance).map_or("as impostors", |(_, name)| name);
let status = format!("orthographic view {:.0} m tall: trees {level}", view.height);
if text.0 != status {
text.0 = status;
}
}