use bevy::prelude::*;
use bevy_simple_screenshot::prelude::*;
use clap::Parser;
use std::f32::consts::PI;
const SCREENSHOT_INTERVAL: f32 = 1.3; const SCENE_DURATION: f32 = 15.0; const GRAVITY: f32 = -20.0;
const BOUNCE_HEIGHT: f32 = 3.0;
const GROUND_Y: f32 = 0.0;
const BALL_RADIUS: f32 = 0.5;
const CAMERA_DISTANCE_MIN: f32 = 5.0;
const CAMERA_DISTANCE_MAX: f32 = 12.0;
const CAMERA_HEIGHT: f32 = 4.0;
const CAMERA_ROTATION_SPEED: f32 = 0.3; const CAMERA_ZOOM_SPEED: f32 = 0.15;
#[derive(Parser, Debug)]
#[command(name = "bouncing_ball_3d")]
#[command(about = "3D bouncing ball with squash & stretch - screenshot demo")]
struct Args {
#[arg(long, short = 'e')]
entity_only: bool,
#[arg(long, default_value = "30")]
padding: u32,
}
#[derive(Resource)]
struct ScreenshotMode {
entity_only: bool,
padding: u32,
}
fn main() {
let args = Args::parse();
println!("3D Bouncing Ball with Squash & Stretch");
println!("Screenshot interval: {:.1}s", SCREENSHOT_INTERVAL);
println!("Scene duration: {:.1}s", SCENE_DURATION);
println!("Expected screenshots: ~{}", (SCENE_DURATION / SCREENSHOT_INTERVAL) as u32);
println!("Mode: {}", if args.entity_only { "entity-only" } else { "full window" });
if args.entity_only {
println!("Padding: {}px", args.padding);
}
let burn_in = if args.entity_only {
BurnInConfig::enabled()
.with_font("FiraMono-Medium.ttf")
.with_position(BurnInPosition::UpperRight)
.with_font_size(14.0)
.with_show_frame(true)
.with_show_key(false)
.with_show_description(false)
} else {
BurnInConfig::enabled()
.with_font("FiraMono-Medium.ttf")
.with_position(BurnInPosition::UpperRight)
.with_font_size(18.0)
.with_show_frame(true)
.with_show_key(true)
.with_show_description(true)
};
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "3D Bouncing Ball - Screenshot Demo".into(),
resolution: (800u32, 600u32).into(),
..default()
}),
..default()
}))
.add_plugins(ScreenshotBufferPlugin::with_config(
ScreenshotConfig::default()
.with_output_dir(".screenshots/bouncing_ball_3d")
.with_buffer_capacity(15)
.with_burn_in(burn_in),
))
.insert_resource(ScreenshotMode {
entity_only: args.entity_only,
padding: args.padding,
})
.insert_resource(SceneTimer::default())
.insert_resource(ScreenshotTimer::default())
.add_systems(Startup, setup_scene)
.add_systems(
Update,
(
update_ball_physics,
apply_squash_stretch,
rotate_camera,
take_screenshots,
check_duration,
),
)
.run();
}
#[derive(Resource, Default)]
struct SceneTimer {
elapsed: f32,
}
#[derive(Resource)]
struct ScreenshotTimer {
last_screenshot_time: f32,
screenshot_count: u32,
}
impl Default for ScreenshotTimer {
fn default() -> Self {
Self {
last_screenshot_time: -SCREENSHOT_INTERVAL, screenshot_count: 0,
}
}
}
#[derive(Component)]
struct BouncingBall {
velocity_y: f32,
contact_time: f32,
is_squashing: bool,
}
#[derive(Component)]
struct TurntableCamera {
angle: f32,
zoom_phase: f32,
}
fn setup_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
commands.spawn((
Mesh3d(meshes.add(Plane3d::default().mesh().size(10.0, 10.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.2, 0.4, 0.8), perceptual_roughness: 0.5,
..default()
})),
Transform::from_translation(Vec3::new(0.0, GROUND_Y, 0.0)),
));
let ball_mesh = meshes.add(Sphere::new(BALL_RADIUS).mesh().uv(32, 18));
let ball_material = materials.add(StandardMaterial {
base_color: Color::srgb(0.9, 0.4, 0.1), perceptual_roughness: 0.3,
..default()
});
commands.spawn((
Mesh3d(ball_mesh),
MeshMaterial3d(ball_material),
Transform::from_translation(Vec3::new(0.0, BOUNCE_HEIGHT + BALL_RADIUS, 0.0)),
BouncingBall {
velocity_y: 0.0,
contact_time: 0.0,
is_squashing: false,
},
));
commands.spawn((
SpotLight {
intensity: 800_000.0,
color: Color::WHITE,
shadows_enabled: true,
range: 20.0,
outer_angle: PI / 4.0,
inner_angle: PI / 6.0,
..default()
},
Transform::from_translation(Vec3::new(4.0, 8.0, 4.0))
.looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y),
));
commands.insert_resource(AmbientLight {
color: Color::srgb(0.5, 0.5, 0.6),
brightness: 150.0,
..default()
});
let initial_distance = (CAMERA_DISTANCE_MIN + CAMERA_DISTANCE_MAX) / 2.0;
commands.spawn((
Camera3d::default(),
Transform::from_translation(Vec3::new(initial_distance, CAMERA_HEIGHT, 0.0))
.looking_at(Vec3::new(0.0, 1.0, 0.0), Vec3::Y),
TurntableCamera {
angle: 0.0,
zoom_phase: 0.0,
},
));
}
fn update_ball_physics(time: Res<Time>, mut query: Query<(&mut Transform, &mut BouncingBall)>) {
let dt = time.delta_secs();
for (mut transform, mut ball) in &mut query {
ball.velocity_y += GRAVITY * dt;
transform.translation.y += ball.velocity_y * dt;
let ground_contact_y = GROUND_Y + BALL_RADIUS * 0.5;
if transform.translation.y <= ground_contact_y {
transform.translation.y = ground_contact_y;
ball.velocity_y = -ball.velocity_y * 0.85;
ball.is_squashing = true;
ball.contact_time = 0.0;
if ball.velocity_y < 2.0 {
ball.velocity_y = (2.0 * GRAVITY.abs() * BOUNCE_HEIGHT).sqrt();
}
}
if ball.is_squashing {
ball.contact_time += dt * 8.0; if ball.contact_time >= 1.0 {
ball.is_squashing = false;
}
}
}
}
fn apply_squash_stretch(mut query: Query<(&mut Transform, &BouncingBall)>) {
for (mut transform, ball) in &mut query {
let velocity_stretch = (ball.velocity_y.abs() / 10.0).clamp(0.0, 0.4);
let (scale_x, scale_y) = if ball.is_squashing {
let squash_factor = (ball.contact_time * PI).sin() * 0.4;
(
1.0 + squash_factor, 1.0 - squash_factor * 0.6, )
} else {
(
1.0 - velocity_stretch * 0.3, 1.0 + velocity_stretch, )
};
transform.scale = Vec3::new(scale_x, scale_y, scale_x);
}
}
fn rotate_camera(time: Res<Time>, mut query: Query<(&mut Transform, &mut TurntableCamera)>) {
for (mut transform, mut camera) in &mut query {
camera.angle += CAMERA_ROTATION_SPEED * time.delta_secs();
camera.zoom_phase += CAMERA_ZOOM_SPEED * time.delta_secs();
let zoom_t = (camera.zoom_phase * PI * 2.0).sin() * 0.5 + 0.5; let distance = CAMERA_DISTANCE_MIN + (CAMERA_DISTANCE_MAX - CAMERA_DISTANCE_MIN) * (zoom_t * 4.0);
let x = camera.angle.cos() * distance;
let z = camera.angle.sin() * distance;
transform.translation = Vec3::new(x, CAMERA_HEIGHT, z);
*transform = transform.looking_at(Vec3::new(0.0, 1.0, 0.0), Vec3::Y);
}
}
fn take_screenshots(
time: Res<Time>,
mode: Res<ScreenshotMode>,
trigger: ScreenshotTrigger,
entity_trigger: EntityScreenshotTrigger,
ball_query: Query<Entity, With<BouncingBall>>,
mut screenshot_timer: ResMut<ScreenshotTimer>,
) {
let elapsed = time.elapsed_secs();
if elapsed - screenshot_timer.last_screenshot_time >= SCREENSHOT_INTERVAL {
screenshot_timer.last_screenshot_time = elapsed;
screenshot_timer.screenshot_count += 1;
let description = format!("capture_{}", screenshot_timer.screenshot_count);
if mode.entity_only {
for ball_entity in &ball_query {
let settings = EntityScreenshotSettings::default().with_padding(mode.padding);
screenshot_entity!(&entity_trigger, ball_entity, "ball", &description, settings);
info!(
"Entity screenshot #{} taken at {:.1}s (padding={}px)",
screenshot_timer.screenshot_count, elapsed, mode.padding
);
}
} else {
screenshot!(&trigger, "bouncing_ball", &description);
info!(
"Screenshot #{} taken at {:.1}s",
screenshot_timer.screenshot_count, elapsed
);
}
}
}
#[allow(deprecated)]
fn check_duration(
time: Res<Time>,
mode: Res<ScreenshotMode>,
mut scene_timer: ResMut<SceneTimer>,
screenshot_timer: Res<ScreenshotTimer>,
mut exit: bevy::ecs::event::EventWriter<AppExit>,
) {
scene_timer.elapsed = time.elapsed_secs();
if scene_timer.elapsed >= SCENE_DURATION {
println!("\n========================================");
println!("Scene completed after {:.1} seconds", SCENE_DURATION);
println!(
"Total screenshots taken: {}",
screenshot_timer.screenshot_count
);
println!("Mode: {}", if mode.entity_only { "entity-only" } else { "full window" });
println!("Screenshots saved to: .screenshots/bouncing_ball_3d/");
println!("========================================\n");
exit.write(AppExit::Success);
}
}