diff --git a/Cargo.toml b/Cargo.toml index b3c30eef8d456..fae29567b7bb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2970,6 +2970,18 @@ description = "Shows off rendering for all math primitives as both Meshes and Gi category = "Math" wasm = true +# Math +[[example]] +name = "sampling_primitives" +path = "examples/math/sampling_primitives.rs" +doc-scrape-examples = true + +[package.metadata.example.sampling_primitives] +name = "Sampling Primitives" +description = "Demonstrates all the primitives which can be sampled." +category = "Math" +wasm = true + [[example]] name = "random_sampling" path = "examples/math/random_sampling.rs" diff --git a/examples/README.md b/examples/README.md index 89a88c9a5b56c..661c9c6a78d0c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -322,6 +322,7 @@ Example | Description --- | --- [Random Sampling](../examples/math/random_sampling.rs) | Demonstrates how to sample random points from mathematical primitives [Rendering Primitives](../examples/math/render_primitives.rs) | Shows off rendering for all math primitives as both Meshes and Gizmos +[Sampling Primitives](../examples/math/sampling_primitives.rs) | Demonstrates all the primitives which can be sampled. ## Reflection diff --git a/examples/math/sampling_primitives.rs b/examples/math/sampling_primitives.rs new file mode 100644 index 0000000000000..eae69a5135d07 --- /dev/null +++ b/examples/math/sampling_primitives.rs @@ -0,0 +1,699 @@ +//! This example shows how to sample random points from primitive shapes. + +use std::f32::consts::PI; + +use bevy::{ + core_pipeline::{bloom::BloomSettings, tonemapping::Tonemapping}, + input::mouse::{MouseButtonInput, MouseMotion, MouseWheel}, + math::prelude::*, + prelude::*, +}; +use rand::seq::SliceRandom; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .insert_resource(SampledShapes::new()) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + handle_mouse, + handle_keypress, + spawn_points, + despawn_points, + animate_spawning, + animate_despawning, + update_camera, + update_lights, + ), + ) + .run(); +} + +// Constants + +/// Maximum distance of the camera from its target. (meters) +/// Should be set such that it is possible to look at all objects +const MAX_CAMERA_DISTANCE: f32 = 12.0; + +/// Minimum distance of the camera from its target. (meters) +/// Should be set such that it is not possible to clip into objects +const MIN_CAMERA_DISTANCE: f32 = 1.0; + +/// Offset to be placed between the shapes +const DISTANCE_BETWEEN_SHAPES: Vec3 = Vec3::new(2.0, 0.0, 0.0); + +/// Maximum amount of points allowed to be present. +/// Should be set such that it does not cause large amounts of lag when reached. +const MAX_POINTS: usize = 3000; // TODO: Test wasm and add a wasm-specific-bound + +/// How many points should be spawned each frame +const POINTS_PER_FRAME: usize = 3; + +/// Color used for the inside points +const INSIDE_POINT_COLOR: LinearRgba = LinearRgba::rgb(0.855, 1.1, 0.01); +/// Color used for the points on the boundary +const BOUNDARY_POINT_COLOR: LinearRgba = LinearRgba::rgb(0.08, 0.2, 0.90); + +/// Time (in seconds) for the spawning/despawning animation +const ANIMATION_TIME: f32 = 1.0; + +/// Color for the sky and the sky-light +const SKY_COLOR: Color = Color::srgb(0.02, 0.06, 0.15); + +const SMALL_3D: f32 = 0.5; +const BIG_3D: f32 = 1.0; + +// primitives + +const CUBOID: Cuboid = Cuboid { + half_size: Vec3::new(SMALL_3D, BIG_3D, SMALL_3D), +}; + +const SPHERE: Sphere = Sphere { + radius: 1.5 * SMALL_3D, +}; + +const TRIANGLE_3D: Triangle3d = Triangle3d { + vertices: [ + Vec3::new(BIG_3D, -BIG_3D * 0.5, 0.0), + Vec3::new(0.0, BIG_3D, 0.0), + Vec3::new(-BIG_3D, -BIG_3D * 0.5, 0.0), + ], +}; + +const CAPSULE_3D: Capsule3d = Capsule3d { + radius: SMALL_3D, + half_length: SMALL_3D, +}; + +const CYLINDER: Cylinder = Cylinder { + radius: SMALL_3D, + half_height: SMALL_3D, +}; + +const TETRAHEDRON: Tetrahedron = Tetrahedron { + vertices: [ + Vec3::new(-BIG_3D, -BIG_3D * 0.67, BIG_3D * 0.5), + Vec3::new(BIG_3D, -BIG_3D * 0.67, BIG_3D * 0.5), + Vec3::new(0.0, -BIG_3D * 0.67, -BIG_3D * 1.17), + Vec3::new(0.0, BIG_3D, 0.0), + ], +}; + +// Components, Resources + +/// Resource for the random sampling mode, telling whether to sample the interior or the boundary. +#[derive(Resource)] +enum SamplingMode { + Interior, + Boundary, +} + +/// Resource for storing whether points should spawn by themselves +#[derive(Resource)] +enum SpawningMode { + Manual, + Automatic, +} + +/// Resource for tracking how many points should be spawned +#[derive(Resource)] +struct SpawnQueue(usize); + +#[derive(Resource)] +struct PointCounter(usize); + +/// Resource storing the shapes being sampled and their translations. +#[derive(Resource)] +struct SampledShapes(Vec<(Shape, Vec3)>); + +impl SampledShapes { + fn new() -> Self { + let shapes = Shape::list_all_shapes(); + + let n_shapes = shapes.len(); + + let translations = + (0..n_shapes).map(|i| (i as f32 - n_shapes as f32 / 2.0) * DISTANCE_BETWEEN_SHAPES); + + SampledShapes(shapes.into_iter().zip(translations).collect()) + } +} + +/// Enum listing the shapes that can be sampled +#[derive(Clone, Copy)] +enum Shape { + Cuboid, + Sphere, + Capsule, + Cylinder, + Tetrahedron, + Triangle, +} + +impl Shape { + /// Return a vector containing all implemented shapes + fn list_all_shapes() -> Vec { + vec![ + Shape::Cuboid, + Shape::Sphere, + Shape::Capsule, + Shape::Cylinder, + Shape::Tetrahedron, + Shape::Triangle, + ] + } +} + +impl ShapeSample for Shape { + type Output = Vec3; + fn sample_interior(&self, rng: &mut R) -> Vec3 { + match self { + Shape::Cuboid => CUBOID.sample_interior(rng), + Shape::Sphere => SPHERE.sample_interior(rng), + Shape::Capsule => CAPSULE_3D.sample_interior(rng), + Shape::Cylinder => CYLINDER.sample_interior(rng), + Shape::Tetrahedron => TETRAHEDRON.sample_interior(rng), + Shape::Triangle => TRIANGLE_3D.sample_interior(rng), + } + } + + fn sample_boundary(&self, rng: &mut R) -> Self::Output { + match self { + Shape::Cuboid => CUBOID.sample_boundary(rng), + Shape::Sphere => SPHERE.sample_boundary(rng), + Shape::Capsule => CAPSULE_3D.sample_boundary(rng), + Shape::Cylinder => CYLINDER.sample_boundary(rng), + Shape::Tetrahedron => TETRAHEDRON.sample_boundary(rng), + Shape::Triangle => TRIANGLE_3D.sample_boundary(rng), + } + } +} + +impl Meshable for Shape { + type Output = Mesh; + + fn mesh(&self) -> Self::Output { + match self { + Shape::Cuboid => CUBOID.mesh(), + Shape::Sphere => SPHERE.mesh().into(), + Shape::Capsule => CAPSULE_3D.mesh().into(), + Shape::Cylinder => CYLINDER.mesh().into(), + Shape::Tetrahedron => TETRAHEDRON.mesh(), + Shape::Triangle => TRIANGLE_3D.mesh(), + } + } +} + +/// The source of randomness used by this example. +#[derive(Resource)] +struct RandomSource(ChaCha8Rng); + +/// A container for the handle storing the mesh used to display sampled points as spheres. +#[derive(Resource)] +struct PointMesh(Handle); + +/// A container for the handle storing the material used to display sampled points. +#[derive(Resource)] +struct PointMaterial { + interior: Handle, + boundary: Handle, +} + +/// Marker component for sampled points. +#[derive(Component)] +struct SamplePoint; + +/// Component for animating the spawn animation of lights. +#[derive(Component)] +struct SpawningPoint { + progress: f32, +} + +/// Marker component for lights which should change intensity. +#[derive(Component)] +struct DespawningPoint { + progress: f32, +} + +/// Marker component for lights which should change intensity. +#[derive(Component)] +struct FireflyLights; + +/// The pressed state of the mouse, used for camera motion. +#[derive(Resource)] +struct MousePressed(bool); + +/// Camera movement component. +#[derive(Component)] +struct CameraRig { + /// Rotation around the vertical axis of the camera (radians). + /// Positive changes makes the camera look more from the right. + pub yaw: f32, + /// Rotation around the horizontal axis of the camera (radians) (-pi/2; pi/2). + /// Positive looks down from above. + pub pitch: f32, + /// Distance from the center, smaller distance causes more zoom. + pub distance: f32, + /// Location in 3D space at which the camera is looking and around which it is orbiting. + pub target: Vec3, +} + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + shapes: Res, +) { + // Use seeded rng and store it in a resource; this makes the random output reproducible. + let seeded_rng = ChaCha8Rng::seed_from_u64(4); // Chosen by a fair die roll, guaranteed to be random. + commands.insert_resource(RandomSource(seeded_rng)); + + // Make a plane for establishing space. + commands.spawn(PbrBundle { + mesh: meshes.add(Plane3d::default().mesh().size(20.0, 20.0)), + material: materials.add(StandardMaterial { + base_color: Color::srgb(0.3, 0.5, 0.3), + perceptual_roughness: 0.95, + metallic: 0.0, + ..default() + }), + transform: Transform::from_xyz(0.0, -2.5, 0.0), + ..default() + }); + + let shape_material = materials.add(StandardMaterial { + base_color: Color::srgba(0.2, 0.1, 0.6, 0.3), + reflectance: 0.0, + alpha_mode: AlphaMode::Blend, + cull_mode: None, + ..default() + }); + + // Spawn shapes to be sampled + for (shape, translation) in shapes.0.iter() { + // The sampled shape shown transparently: + commands.spawn(PbrBundle { + mesh: meshes.add(shape.mesh()), + material: shape_material.clone(), + transform: Transform::from_translation(*translation), + ..default() + }); + + // Lights which work as the bulk lighting of the fireflies: + commands.spawn(( + PointLightBundle { + point_light: PointLight { + range: 4.0, + radius: 0.6, + intensity: 1.0, + shadows_enabled: false, + color: Color::LinearRgba(INSIDE_POINT_COLOR), + ..default() + }, + transform: Transform::from_translation(*translation), + ..default() + }, + FireflyLights, + )); + } + + // Global light: + commands.spawn(PointLightBundle { + point_light: PointLight { + color: SKY_COLOR, + intensity: 2_000.0, + shadows_enabled: false, + ..default() + }, + transform: Transform::from_xyz(4.0, 8.0, 4.0), + ..default() + }); + + // A camera: + commands.spawn(( + Camera3dBundle { + camera: Camera { + hdr: true, // HDR is required for bloom + clear_color: ClearColorConfig::Custom(SKY_COLOR), + ..default() + }, + tonemapping: Tonemapping::TonyMcMapface, + transform: Transform::from_xyz(-2.0, 3.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y), + ..default() + }, + BloomSettings::NATURAL, + CameraRig { + yaw: 0.56, + pitch: 0.45, + distance: 8.0, + target: Vec3::ZERO, + }, + )); + + // Store the mesh and material for sample points in resources: + commands.insert_resource(PointMesh( + meshes.add(Sphere::new(0.03).mesh().ico(1).unwrap()), + )); + commands.insert_resource(PointMaterial { + interior: materials.add(StandardMaterial { + base_color: Color::BLACK, + reflectance: 0.05, + emissive: 2.5 * INSIDE_POINT_COLOR, + ..default() + }), + boundary: materials.add(StandardMaterial { + base_color: Color::BLACK, + reflectance: 0.05, + emissive: 1.5 * BOUNDARY_POINT_COLOR, + ..default() + }), + }); + + // Instructions for the example: + commands.spawn( + TextBundle::from_section( + "Controls:\n\ + M: Toggle between sampling boundary and interior.\n\ + A: Toggle automatic spawning & despawning of points.\n\ + R: Restart (erase all samples).\n\ + S: Add one random sample.\n\ + D: Add 100 random samples.\n\ + Rotate camera by panning via mouse.\n\ + Zoom camera by scrolling via mouse or +/-.\n\ + Move camera by L/R arrow keys.\n\ + Tab: Toggle this text", + TextStyle { + font_size: 20., + ..default() + }, + ) + .with_style(Style { + position_type: PositionType::Absolute, + top: Val::Px(12.0), + left: Val::Px(12.0), + ..default() + }), + ); + + // No points are scheduled to spawn initially. + commands.insert_resource(SpawnQueue(0)); + + // No points have been spawned initially. + commands.insert_resource(PointCounter(0)); + + // The mode starts with interior points. + commands.insert_resource(SamplingMode::Interior); + + // Points spawn automatically by default. + commands.insert_resource(SpawningMode::Automatic); + + // Starting mouse-pressed state is false. + commands.insert_resource(MousePressed(false)); +} + +// Handle user inputs from the keyboard: +#[allow(clippy::too_many_arguments)] +fn handle_keypress( + mut commands: Commands, + keyboard: Res>, + mut mode: ResMut, + mut spawn_mode: ResMut, + samples: Query>, + shapes: Res, + mut spawn_queue: ResMut, + mut counter: ResMut, + mut text_menus: Query<&mut Visibility, With>, + mut camera: Query<&mut CameraRig>, +) { + // R => restart, deleting all samples + if keyboard.just_pressed(KeyCode::KeyR) { + // Don't forget to zero out the counter! + counter.0 = 0; + for entity in &samples { + commands.entity(entity).despawn(); + } + } + + // S => sample once + if keyboard.just_pressed(KeyCode::KeyS) { + spawn_queue.0 += 1; + } + + // D => sample a hundred + if keyboard.just_pressed(KeyCode::KeyD) { + spawn_queue.0 += 100; + } + + // M => toggle mode between interior and boundary. + if keyboard.just_pressed(KeyCode::KeyM) { + match *mode { + SamplingMode::Interior => *mode = SamplingMode::Boundary, + SamplingMode::Boundary => *mode = SamplingMode::Interior, + } + } + + // A => toggle spawning mode between automatic and manual. + if keyboard.just_pressed(KeyCode::KeyA) { + match *spawn_mode { + SpawningMode::Manual => *spawn_mode = SpawningMode::Automatic, + SpawningMode::Automatic => *spawn_mode = SpawningMode::Manual, + } + } + + // Tab => toggle help menu. + if keyboard.just_pressed(KeyCode::Tab) { + for mut visibility in text_menus.iter_mut() { + *visibility = match *visibility { + Visibility::Hidden => Visibility::Visible, + _ => Visibility::Hidden, + }; + } + } + + let mut camera_rig = camera.single_mut(); + + // +/- => zoom camera. + if keyboard.just_pressed(KeyCode::NumpadSubtract) || keyboard.just_pressed(KeyCode::Minus) { + camera_rig.distance += MAX_CAMERA_DISTANCE / 15.0; + camera_rig.distance = camera_rig + .distance + .clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); + } + + if keyboard.just_pressed(KeyCode::NumpadAdd) { + camera_rig.distance -= MAX_CAMERA_DISTANCE / 15.0; + camera_rig.distance = camera_rig + .distance + .clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); + } + + // Arrows => Move camera focus + let left = keyboard.just_pressed(KeyCode::ArrowLeft); + let right = keyboard.just_pressed(KeyCode::ArrowRight); + + if left || right { + let mut closest = 0; + let mut closest_distance = f32::MAX; + for (i, (_, position)) in shapes.0.iter().enumerate() { + let distance = camera_rig.target.distance(*position); + if distance < closest_distance { + closest = i; + closest_distance = distance; + } + } + if closest > 0 && left { + camera_rig.target = shapes.0[closest - 1].1; + } + if closest < shapes.0.len() - 1 && right { + camera_rig.target = shapes.0[closest + 1].1; + } + } +} + +// Handle user mouse input for panning the camera around: +fn handle_mouse( + mut button_events: EventReader, + mut motion_events: EventReader, + mut scroll_events: EventReader, + mut camera: Query<&mut CameraRig>, + mut mouse_pressed: ResMut, +) { + // Store left-pressed state in the MousePressed resource + for button_event in button_events.read() { + if button_event.button != MouseButton::Left { + continue; + } + *mouse_pressed = MousePressed(button_event.state.is_pressed()); + } + + let mut camera_rig = camera.single_mut(); + + let mouse_scroll = scroll_events + .read() + .fold(0.0, |acc, scroll_event| acc + scroll_event.y); + camera_rig.distance -= mouse_scroll / 15.0 * MAX_CAMERA_DISTANCE; + camera_rig.distance = camera_rig + .distance + .clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); + + // If the mouse is not pressed, just ignore motion events + if !mouse_pressed.0 { + return; + } + let displacement = motion_events + .read() + .fold(Vec2::ZERO, |acc, mouse_motion| acc + mouse_motion.delta); + camera_rig.yaw += displacement.x / 90.; + camera_rig.pitch += displacement.y / 90.; + // The extra 0.01 is to disallow weird behaviour at the poles of the rotation + camera_rig.pitch = camera_rig.pitch.clamp(-PI / 2.01, PI / 2.01); +} + +#[allow(clippy::too_many_arguments)] +fn spawn_points( + mut commands: Commands, + mode: ResMut, + shapes: Res, + mut random_source: ResMut, + sample_mesh: Res, + sample_material: Res, + mut spawn_queue: ResMut, + mut counter: ResMut, + spawn_mode: ResMut, +) { + if let SpawningMode::Automatic = *spawn_mode { + spawn_queue.0 += POINTS_PER_FRAME; + } + + if spawn_queue.0 == 0 { + return; + } + + let rng = &mut random_source.0; + + // Don't go crazy + for _ in 0..1000 { + if spawn_queue.0 == 0 { + break; + } + spawn_queue.0 -= 1; + counter.0 += 1; + + let (shape, offset) = shapes.0.choose(rng).expect("There is at least one shape"); + + // Get a single random Vec3: + let sample: Vec3 = *offset + + match *mode { + SamplingMode::Interior => shape.sample_interior(rng), + SamplingMode::Boundary => shape.sample_boundary(rng), + }; + + // Spawn a sphere at the random location: + commands.spawn(( + PbrBundle { + mesh: sample_mesh.0.clone(), + material: match *mode { + SamplingMode::Interior => sample_material.interior.clone(), + SamplingMode::Boundary => sample_material.boundary.clone(), + }, + transform: Transform::from_translation(sample).with_scale(Vec3::ZERO), + ..default() + }, + SamplePoint, + SpawningPoint { progress: 0.0 }, + )); + } +} + +fn despawn_points( + mut commands: Commands, + samples: Query>, + spawn_mode: Res, + mut counter: ResMut, + mut random_source: ResMut, +) { + // Do not despawn automatically in manual mode + if let SpawningMode::Manual = *spawn_mode { + return; + } + + if counter.0 < MAX_POINTS { + return; + } + + let rng = &mut random_source.0; + // Skip a random amount of points to ensure random despawning + let skip = rng.gen_range(0..counter.0); + let despawn_amount = (counter.0 - MAX_POINTS).min(100); + counter.0 -= samples + .iter() + .skip(skip) + .take(despawn_amount) + .map(|entity| { + commands + .entity(entity) + .insert(DespawningPoint { progress: 0.0 }) + .remove::() + .remove::(); + }) + .count(); +} + +fn animate_spawning( + mut commands: Commands, + time: Res