├── .github ├── FUNDING.yml └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── assets ├── environment_maps │ ├── diffuse_rgb9e5_zstd.ktx2 │ └── specular_rgb9e5_zstd.ktx2 └── models │ └── PlaneEngine │ ├── license.txt │ ├── scene.bin │ ├── scene.gltf │ └── textures │ └── AppC7_baseColor.jpeg ├── examples ├── cad.rs ├── floating_origin.rs ├── map.rs ├── minimal.rs ├── ortho.rs ├── split_screen.rs └── zoom_limits.rs └── src ├── controller ├── component.rs ├── inputs.rs ├── mod.rs ├── momentum.rs ├── motion.rs ├── projections.rs ├── smoothing.rs └── zoom.rs ├── extensions ├── anchor_indicator.rs ├── dolly_zoom.rs ├── independent_skybox.rs ├── look_to.rs └── mod.rs ├── input.rs └── lib.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: aevyrie 4 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | format: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: dtolnay/rust-toolchain@stable 13 | - uses: Swatinem/rust-cache@v2.7.0 14 | - run: rustup component add rustfmt 15 | - run: cargo fmt --all -- --check 16 | 17 | check: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: dtolnay/rust-toolchain@stable 22 | - uses: Swatinem/rust-cache@v2.7.0 23 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 24 | - run: cargo check --workspace --all-features --all-targets 25 | 26 | check-no-defaults: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: dtolnay/rust-toolchain@stable 31 | - uses: Swatinem/rust-cache@v2.7.0 32 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 33 | - run: cargo check --workspace --no-default-features 34 | 35 | clippy: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v3 39 | - uses: dtolnay/rust-toolchain@stable 40 | - uses: Swatinem/rust-cache@v2.7.0 41 | - run: rustup component add clippy 42 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 43 | - run: cargo clippy --workspace --all-features --all-targets -- -D warnings 44 | 45 | doc: 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/checkout@v3 49 | - uses: dtolnay/rust-toolchain@stable 50 | - uses: Swatinem/rust-cache@v2.7.0 51 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 52 | - run: cargo doc --no-deps --workspace --all-features 53 | env: 54 | RUSTDOCFLAGS: -D warnings 55 | 56 | test: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v3 60 | - uses: dtolnay/rust-toolchain@stable 61 | - uses: Swatinem/rust-cache@v2.7.0 62 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 63 | - run: cargo test --workspace --all-targets --all-features 64 | 65 | test-doc: 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v3 69 | - uses: dtolnay/rust-toolchain@stable 70 | - uses: Swatinem/rust-cache@v2.7.0 71 | - run: sudo apt-get install -yq --no-install-recommends libudev-dev libasound2-dev libxcb-composite0-dev 72 | - run: cargo test --workspace --doc 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | /assets/models/scene -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bevy_editor_cam" 3 | version = "0.5.0" 4 | edition = "2021" 5 | description = "A camera controller for editors and CAD." 6 | license = "MIT OR Apache-2.0" 7 | keywords = ["controller", "camera", "bevy", "CAD"] 8 | repository = "https://github.com/aevyrie/bevy_editor_cam" 9 | documentation = "https://docs.rs/crate/bevy_editor_cam/latest" 10 | exclude = ["assets/"] 11 | 12 | [features] 13 | default = ["extension_anchor_indicator", "extension_independent_skybox"] 14 | extension_anchor_indicator = ["bevy_gizmos"] 15 | extension_independent_skybox = ["bevy_asset", "bevy_core_pipeline"] 16 | 17 | [dependencies] 18 | bevy_app = "0.15.0" 19 | bevy_color = "0.15.0" 20 | bevy_derive = "0.15.0" 21 | bevy_ecs = "0.15.0" 22 | bevy_image = "0.15.0" 23 | bevy_input = "0.15.0" 24 | bevy_log = "0.15.0" 25 | bevy_math = "0.15.0" 26 | bevy_picking = "0.15.0" 27 | bevy_reflect = "0.15.0" 28 | bevy_render = "0.15.0" 29 | bevy_time = "0.15.0" 30 | bevy_transform = "0.15.0" 31 | bevy_utils = "0.15.0" 32 | bevy_window = "0.15.0" 33 | # Optional 34 | bevy_asset = { version = "0.15.0", optional = true } 35 | bevy_core_pipeline = { version = "0.15.0", optional = true } 36 | bevy_gizmos = { version = "0.15.0", optional = true } 37 | 38 | [dev-dependencies] 39 | bevy_framepace = "0.18" 40 | big_space = "0.8.0" 41 | indoc = "2.0.5" 42 | rand = "0.8" 43 | 44 | [dev-dependencies.bevy] 45 | version = "0.15.0" 46 | features = [ 47 | "bevy_gizmos", 48 | "bevy_gltf", 49 | "bevy_scene", 50 | "bevy_text", 51 | "bevy_ui", 52 | "bevy_winit", 53 | "default_font", 54 | "multi_threaded", 55 | "jpeg", 56 | "ktx2", 57 | "tonemapping_luts", 58 | "x11", 59 | "zstd", 60 | ] 61 | # TODO: workaround for https://github.com/bevyengine/bevy/issues/16562 62 | default-features = true 63 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Aevyrie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # bevy_editor_cam 4 | 5 | A production-ready camera controller for 2D/3D editors and CAD. 6 | 7 | [![CI](https://github.com/aevyrie/bevy_editor_cam/actions/workflows/rust.yml/badge.svg?branch=main)](https://github.com/aevyrie/bevy_editor_cam/actions/workflows/rust.yml) 8 | [![docs.rs](https://docs.rs/bevy_editor_cam/badge.svg)](https://docs.rs/bevy_editor_cam) 9 | [![crates.io](https://img.shields.io/crates/v/bevy_editor_cam)](https://crates.io/crates/bevy_editor_cam) 10 | 11 | https://github.com/user-attachments/assets/58b270a9-7ae8-4466-9a8f-1fc8f0896590 12 | 13 |
14 | 15 |
16 |

Bevy Version Support

17 | 18 | | bevy | bevy_editor_cam | 19 | | ---- | ---------------- | 20 | | 0.15 | 0.5 | 21 | | 0.14 | 0.3, 0.4 | 22 | | 0.13 | 0.2 | 23 | | 0.12 | 0.1 | 24 |
25 | -------------------------------------------------------------------------------- /assets/environment_maps/diffuse_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aevyrie/bevy_editor_cam/e2bf53593c3745d6db610bdbc1d0fb9039c5cadf/assets/environment_maps/diffuse_rgb9e5_zstd.ktx2 -------------------------------------------------------------------------------- /assets/environment_maps/specular_rgb9e5_zstd.ktx2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aevyrie/bevy_editor_cam/e2bf53593c3745d6db610bdbc1d0fb9039c5cadf/assets/environment_maps/specular_rgb9e5_zstd.ktx2 -------------------------------------------------------------------------------- /assets/models/PlaneEngine/license.txt: -------------------------------------------------------------------------------- 1 | Model Information: 2 | * title: Plane Engine 3 | * source: https://sketchfab.com/3d-models/plane-engine-3a3e71ec7f6e4f24963f63cf6bd22358 4 | * author: T-FLEX CAD ST (Free) (https://sketchfab.com/tflexcad) 5 | 6 | Model License: 7 | * license type: CC-BY-NC-4.0 (http://creativecommons.org/licenses/by-nc/4.0/) 8 | * requirements: Author must be credited. No commercial use. 9 | 10 | If you use this 3D model in your project be sure to copy paste this credit wherever you share it: 11 | This work is based on "Plane Engine" (https://sketchfab.com/3d-models/plane-engine-3a3e71ec7f6e4f24963f63cf6bd22358) by T-FLEX CAD ST (Free) (https://sketchfab.com/tflexcad) licensed under CC-BY-NC-4.0 (http://creativecommons.org/licenses/by-nc/4.0/) -------------------------------------------------------------------------------- /assets/models/PlaneEngine/scene.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aevyrie/bevy_editor_cam/e2bf53593c3745d6db610bdbc1d0fb9039c5cadf/assets/models/PlaneEngine/scene.bin -------------------------------------------------------------------------------- /assets/models/PlaneEngine/textures/AppC7_baseColor.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aevyrie/bevy_editor_cam/e2bf53593c3745d6db610bdbc1d0fb9039c5cadf/assets/models/PlaneEngine/textures/AppC7_baseColor.jpeg -------------------------------------------------------------------------------- /examples/cad.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use bevy::{ 4 | core_pipeline::{bloom::Bloom, tonemapping::Tonemapping}, 5 | pbr::ScreenSpaceAmbientOcclusion, 6 | prelude::*, 7 | render::primitives::Aabb, 8 | utils::Instant, 9 | window::RequestRedraw, 10 | }; 11 | use bevy_core_pipeline::smaa::Smaa; 12 | use bevy_editor_cam::{ 13 | extensions::{dolly_zoom::DollyZoomTrigger, look_to::LookToTrigger}, 14 | prelude::*, 15 | }; 16 | 17 | fn main() { 18 | App::new() 19 | .add_plugins(( 20 | DefaultPlugins, 21 | DefaultEditorCamPlugins, 22 | MeshPickingPlugin, 23 | bevy_framepace::FramepacePlugin, 24 | )) 25 | // The camera controller works with reactive rendering: 26 | // .insert_resource(bevy::winit::WinitSettings::desktop_app()) 27 | .insert_resource(AmbientLight::NONE) 28 | .add_systems(Startup, setup) 29 | .add_systems( 30 | Update, 31 | ( 32 | toggle_projection, 33 | toggle_constraint, 34 | explode, 35 | switch_direction, 36 | ) 37 | .chain(), 38 | ) 39 | .run(); 40 | } 41 | 42 | fn setup(mut commands: Commands, asset_server: Res) { 43 | let diffuse_map = asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"); 44 | let specular_map = asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"); 45 | 46 | commands.spawn(( 47 | SceneRoot(asset_server.load("models/PlaneEngine/scene.gltf#Scene0")), 48 | Transform::from_scale(Vec3::splat(2.0)), 49 | )); 50 | 51 | let cam_trans = Transform::from_xyz(2.0, 2.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y); 52 | let camera = commands 53 | .spawn(( 54 | Camera3d::default(), 55 | Camera { 56 | hdr: true, 57 | ..Default::default() 58 | }, 59 | cam_trans, 60 | Tonemapping::AcesFitted, 61 | Bloom::default(), 62 | EnvironmentMapLight { 63 | intensity: 1000.0, 64 | diffuse_map: diffuse_map.clone(), 65 | specular_map: specular_map.clone(), 66 | ..Default::default() 67 | }, 68 | EditorCam { 69 | orbit_constraint: OrbitConstraint::Free, 70 | last_anchor_depth: -cam_trans.translation.length() as f64, 71 | orthographic: projections::OrthographicSettings { 72 | scale_to_near_clip: 1_000_f32, // Needed for SSAO to work in ortho 73 | ..Default::default() 74 | }, 75 | ..Default::default() 76 | }, 77 | ScreenSpaceAmbientOcclusion::default(), 78 | Smaa::default(), 79 | Msaa::Off, 80 | )) 81 | .id(); 82 | 83 | setup_ui(commands, camera); 84 | } 85 | 86 | fn toggle_projection( 87 | keys: Res>, 88 | mut dolly: EventWriter, 89 | cam: Query>, 90 | mut toggled: Local, 91 | ) { 92 | if keys.just_pressed(KeyCode::KeyP) { 93 | *toggled = !*toggled; 94 | let target_projection = if *toggled { 95 | Projection::Orthographic(OrthographicProjection::default_3d()) 96 | } else { 97 | Projection::Perspective(PerspectiveProjection::default()) 98 | }; 99 | dolly.send(DollyZoomTrigger { 100 | target_projection, 101 | camera: cam.single(), 102 | }); 103 | } 104 | } 105 | 106 | fn toggle_constraint( 107 | keys: Res>, 108 | mut cam: Query<(Entity, &Transform, &mut EditorCam)>, 109 | mut look_to: EventWriter, 110 | ) { 111 | if keys.just_pressed(KeyCode::KeyC) { 112 | let (entity, transform, mut editor) = cam.single_mut(); 113 | match editor.orbit_constraint { 114 | OrbitConstraint::Fixed { .. } => editor.orbit_constraint = OrbitConstraint::Free, 115 | OrbitConstraint::Free => { 116 | editor.orbit_constraint = OrbitConstraint::Fixed { 117 | up: Vec3::Y, 118 | can_pass_tdc: false, 119 | }; 120 | 121 | look_to.send(LookToTrigger::auto_snap_up_direction( 122 | transform.forward(), 123 | entity, 124 | transform, 125 | editor.as_ref(), 126 | )); 127 | } 128 | }; 129 | } 130 | } 131 | 132 | fn switch_direction( 133 | keys: Res>, 134 | mut look_to: EventWriter, 135 | cam: Query<(Entity, &Transform, &EditorCam)>, 136 | ) { 137 | let (camera, transform, editor) = cam.single(); 138 | if keys.just_pressed(KeyCode::Digit1) { 139 | look_to.send(LookToTrigger::auto_snap_up_direction( 140 | Dir3::X, 141 | camera, 142 | transform, 143 | editor, 144 | )); 145 | } 146 | if keys.just_pressed(KeyCode::Digit2) { 147 | look_to.send(LookToTrigger::auto_snap_up_direction( 148 | Dir3::Z, 149 | camera, 150 | transform, 151 | editor, 152 | )); 153 | } 154 | if keys.just_pressed(KeyCode::Digit3) { 155 | look_to.send(LookToTrigger::auto_snap_up_direction( 156 | Dir3::NEG_X, 157 | camera, 158 | transform, 159 | editor, 160 | )); 161 | } 162 | if keys.just_pressed(KeyCode::Digit4) { 163 | look_to.send(LookToTrigger::auto_snap_up_direction( 164 | Dir3::NEG_Z, 165 | camera, 166 | transform, 167 | editor, 168 | )); 169 | } 170 | if keys.just_pressed(KeyCode::Digit5) { 171 | look_to.send(LookToTrigger::auto_snap_up_direction( 172 | Dir3::Y, 173 | camera, 174 | transform, 175 | editor, 176 | )); 177 | } 178 | if keys.just_pressed(KeyCode::Digit6) { 179 | look_to.send(LookToTrigger::auto_snap_up_direction( 180 | Dir3::NEG_Y, 181 | camera, 182 | transform, 183 | editor, 184 | )); 185 | } 186 | } 187 | 188 | fn setup_ui(mut commands: Commands, camera: Entity) { 189 | let text = indoc::indoc! {" 190 | Left Mouse - Pan 191 | Right Mouse - Orbit 192 | Scroll - Zoom 193 | P - Toggle projection 194 | C - Toggle orbit constraint 195 | E - Toggle explode 196 | 1-6 - Switch direction 197 | "}; 198 | commands.spawn(( 199 | Text::new(text), 200 | TextFont { 201 | font_size: 20.0, 202 | ..default() 203 | }, 204 | Node { 205 | margin: UiRect::all(Val::Px(20.0)), 206 | ..Default::default() 207 | }, 208 | TargetCamera(camera), 209 | )); 210 | } 211 | 212 | #[derive(Component)] 213 | struct StartPos(f32); 214 | 215 | #[allow(clippy::type_complexity)] 216 | fn explode( 217 | mut commands: Commands, 218 | keys: Res>, 219 | mut toggle: Local>, 220 | mut explode_amount: Local, 221 | mut redraw: EventWriter, 222 | mut parts: Query<(Entity, &mut Transform, &Aabb, Option<&StartPos>), With>, 223 | mut matls: ResMut>, 224 | ) { 225 | let animation = Duration::from_millis(2000); 226 | if keys.just_pressed(KeyCode::KeyE) { 227 | let new = if let Some((last, ..)) = *toggle { 228 | !last 229 | } else { 230 | true 231 | }; 232 | *toggle = Some((new, Instant::now(), *explode_amount)); 233 | } 234 | if let Some((toggled, start, start_amount)) = *toggle { 235 | let goal_amount = toggled as usize as f32; 236 | let t = (start.elapsed().as_secs_f32() / animation.as_secs_f32()).clamp(0.0, 1.0); 237 | let progress = CubicSegment::new_bezier((0.25, 0.1), (0.25, 1.0)).ease(t); 238 | *explode_amount = start_amount + (goal_amount - start_amount) * progress; 239 | for (part, mut transform, aabb, start) in &mut parts { 240 | let start = if let Some(start) = start { 241 | start.0 242 | } else { 243 | let start = aabb.max().y; 244 | commands.entity(part).insert(StartPos(start)); 245 | start 246 | }; 247 | transform.translation.y = *explode_amount * (start) * 2.0; 248 | } 249 | if t < 1.0 { 250 | redraw.send(RequestRedraw); 251 | } 252 | } 253 | for (_, matl) in matls.iter_mut() { 254 | matl.perceptual_roughness = matl.perceptual_roughness.clamp(0.3, 1.0) 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /examples/floating_origin.rs: -------------------------------------------------------------------------------- 1 | use bevy::{color::palettes, prelude::*}; 2 | use bevy_editor_cam::{ 3 | controller::component::EditorCam, 4 | prelude::{projections::PerspectiveSettings, zoom::ZoomLimits}, 5 | DefaultEditorCamPlugins, 6 | }; 7 | use big_space::{ 8 | commands::BigSpaceCommands, 9 | reference_frame::{local_origin::ReferenceFrames, ReferenceFrame}, 10 | world_query::{GridTransformReadOnly, GridTransformReadOnlyItem}, 11 | FloatingOrigin, GridCell, 12 | }; 13 | 14 | fn main() { 15 | App::new() 16 | .add_plugins(( 17 | DefaultPlugins.build().disable::(), 18 | MeshPickingPlugin, 19 | big_space::BigSpacePlugin::::default(), 20 | big_space::debug::FloatingOriginDebugPlugin::::default(), 21 | bevy_framepace::FramepacePlugin, 22 | )) 23 | .add_plugins(DefaultEditorCamPlugins) 24 | .insert_resource(ClearColor(Color::BLACK)) 25 | .insert_resource(AmbientLight { 26 | color: Color::WHITE, 27 | brightness: 20.0, 28 | }) 29 | .add_systems(Startup, (setup, ui_setup)) 30 | .add_systems(PreUpdate, ui_text_system) 31 | .run(); 32 | } 33 | 34 | fn setup( 35 | mut commands: Commands, 36 | mut meshes: ResMut>, 37 | mut materials: ResMut>, 38 | ) { 39 | commands.spawn_big_space(ReferenceFrame::::default(), |root| { 40 | root.spawn_spatial(( 41 | Camera3d::default(), 42 | Transform::from_xyz(0.0, 0.0, 8.0).looking_at(Vec3::new(0.0, 0.0, 0.0), Vec3::Y), 43 | Projection::Perspective(PerspectiveProjection { 44 | near: 1e-18, 45 | ..default() 46 | }), 47 | FloatingOrigin, // Important: marks the floating origin entity for rendering. 48 | EditorCam { 49 | zoom_limits: ZoomLimits { 50 | min_size_per_pixel: 1e-20, 51 | ..Default::default() 52 | }, 53 | perspective: PerspectiveSettings { 54 | near_clip_limits: 1e-20..0.1, 55 | ..Default::default() 56 | }, 57 | ..Default::default() 58 | }, 59 | )); 60 | 61 | let mesh_handle = meshes.add(Sphere::new(0.5).mesh().ico(32).unwrap()); 62 | let matl_handle = materials.add(StandardMaterial { 63 | base_color: Color::Srgba(palettes::basic::BLUE), 64 | perceptual_roughness: 0.8, 65 | reflectance: 1.0, 66 | ..default() 67 | }); 68 | 69 | let mut translation = Vec3::ZERO; 70 | for i in -16..=27 { 71 | let j = 10_f32.powf(i as f32); 72 | let k = 10_f32.powf((i - 1) as f32); 73 | translation.x += j / 2.0 + k; 74 | translation.y = j / 2.0; 75 | 76 | root.spawn_spatial(( 77 | Mesh3d(mesh_handle.clone()), 78 | MeshMaterial3d(matl_handle.clone()), 79 | Transform::from_scale(Vec3::splat(j)).with_translation(translation), 80 | )); 81 | } 82 | 83 | // light 84 | root.spawn_spatial(DirectionalLight { 85 | illuminance: 10_000.0, 86 | ..default() 87 | }); 88 | }); 89 | } 90 | 91 | #[derive(Component, Reflect)] 92 | pub struct BigSpaceDebugText; 93 | 94 | #[derive(Component, Reflect)] 95 | pub struct FunFactText; 96 | 97 | fn ui_setup(mut commands: Commands) { 98 | commands.spawn(( 99 | Text::new(""), 100 | TextFont { 101 | font_size: 18.0, 102 | ..default() 103 | }, 104 | Node { 105 | margin: UiRect::all(Val::Px(20.0)), 106 | ..Default::default() 107 | }, 108 | TextColor(Color::WHITE), 109 | BigSpaceDebugText, 110 | )); 111 | commands.spawn(( 112 | Text::new(""), 113 | TextFont { 114 | font_size: 52.0, 115 | ..default() 116 | }, 117 | Node { 118 | margin: UiRect::all(Val::Px(20.0)), 119 | ..Default::default() 120 | }, 121 | TextColor(Color::WHITE), 122 | FunFactText, 123 | )); 124 | } 125 | 126 | #[allow(clippy::type_complexity)] 127 | fn ui_text_system( 128 | mut debug_text: Query< 129 | (&mut Text, &GlobalTransform), 130 | (With, Without), 131 | >, 132 | ref_frames: ReferenceFrames, 133 | origin: Query<(Entity, GridTransformReadOnly), With>, 134 | ) { 135 | let (origin_entity, origin_pos) = origin.single(); 136 | let Some(ref_frame) = ref_frames.parent_frame(origin_entity) else { 137 | return; 138 | }; 139 | 140 | let mut debug_text = debug_text.single_mut(); 141 | *debug_text.0 = Text::new(ui_text(ref_frame, &origin_pos)); 142 | } 143 | 144 | fn ui_text( 145 | ref_frame: &ReferenceFrame, 146 | origin_pos: &GridTransformReadOnlyItem, 147 | ) -> String { 148 | let GridCell { 149 | x: cx, 150 | y: cy, 151 | z: cz, 152 | } = origin_pos.cell; 153 | let [tx, ty, tz] = origin_pos.transform.translation.into(); 154 | let [dx, dy, dz] = ref_frame 155 | .grid_position_double(origin_pos.cell, origin_pos.transform) 156 | .into(); 157 | let [sx, sy, sz] = [dx as f32, dy as f32, dz as f32]; 158 | 159 | indoc::formatdoc! {" 160 | GridCell: {cx}x, {cy}y, {cz}z 161 | Transform: {tx}x, {ty}y, {tz}z 162 | Combined (f64): {dx}x, {dy}y, {dz}z 163 | Combined (f32): {sx}x, {sy}y, {sz}z 164 | "} 165 | } 166 | -------------------------------------------------------------------------------- /examples/map.rs: -------------------------------------------------------------------------------- 1 | use bevy::{color::palettes, prelude::*}; 2 | use bevy_editor_cam::{extensions::dolly_zoom::DollyZoomTrigger, prelude::*}; 3 | use rand::Rng; 4 | 5 | fn main() { 6 | App::new() 7 | .add_plugins(( 8 | DefaultPlugins, 9 | MeshPickingPlugin, 10 | DefaultEditorCamPlugins, 11 | bevy_framepace::FramepacePlugin, 12 | )) 13 | .add_systems(Startup, (setup, setup_ui)) 14 | .add_systems(Update, toggle_projection) 15 | .run(); 16 | } 17 | 18 | fn setup( 19 | mut commands: Commands, 20 | asset_server: Res, 21 | mut meshes: ResMut>, 22 | mut matls: ResMut>, 23 | ) { 24 | spawn_buildings(&mut commands, &mut meshes, &mut matls, 20.0); 25 | 26 | let diffuse_map = asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"); 27 | let specular_map = asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"); 28 | let translation = Vec3::new(7.0, 7.0, 7.0); 29 | 30 | commands.spawn(( 31 | Camera3d::default(), 32 | Transform::from_translation(translation).looking_at(Vec3::ZERO, Vec3::Y), 33 | EnvironmentMapLight { 34 | intensity: 1000.0, 35 | diffuse_map: diffuse_map.clone(), 36 | specular_map: specular_map.clone(), 37 | rotation: default(), 38 | }, 39 | EditorCam { 40 | orbit_constraint: OrbitConstraint::Fixed { 41 | up: Vec3::Y, 42 | can_pass_tdc: false, 43 | }, 44 | last_anchor_depth: -translation.length() as f64, 45 | ..Default::default() 46 | }, 47 | bevy_editor_cam::extensions::independent_skybox::IndependentSkybox::new( 48 | diffuse_map, 49 | 1000.0, 50 | default(), 51 | ), 52 | )); 53 | } 54 | 55 | fn spawn_buildings( 56 | commands: &mut Commands, 57 | meshes: &mut Assets, 58 | matls: &mut Assets, 59 | half_width: f32, 60 | ) { 61 | commands.spawn(( 62 | Mesh3d(meshes.add(Plane3d::new(Vec3::Y, Vec2::splat(half_width * 20.0)))), 63 | MeshMaterial3d(matls.add(StandardMaterial { 64 | base_color: Color::Srgba(palettes::css::DARK_GRAY), 65 | ..Default::default() 66 | })), 67 | Transform::from_xyz(0.0, 0.0, 0.0), 68 | )); 69 | 70 | let mut rng = rand::thread_rng(); 71 | let mesh = meshes.add(Cuboid::default()); 72 | let material = [ 73 | matls.add(Color::Srgba(palettes::css::GRAY)), 74 | matls.add(Color::srgb(0.3, 0.6, 0.8)), 75 | matls.add(Color::srgb(0.55, 0.4, 0.8)), 76 | matls.add(Color::srgb(0.8, 0.45, 0.5)), 77 | ]; 78 | 79 | let w = half_width as isize; 80 | for x in -w..=w { 81 | for z in -w..=w { 82 | let x = x as f32 + rng.gen::() - 0.5; 83 | let z = z as f32 + rng.gen::() - 0.5; 84 | let y = rng.gen::() * rng.gen::() * rng.gen::() * rng.gen::(); 85 | let y_scale = 1.02f32.powf(100.0 * y); 86 | 87 | commands.spawn(( 88 | Mesh3d(mesh.clone()), 89 | MeshMaterial3d(material[rng.gen_range(0..material.len())].clone()), 90 | Transform::from_xyz(x, y_scale / 2.0, z).with_scale(Vec3::new( 91 | (rng.gen::() + 0.5) * 0.3, 92 | y_scale, 93 | (rng.gen::() + 0.5) * 0.3, 94 | )), 95 | )); 96 | } 97 | } 98 | } 99 | 100 | fn toggle_projection( 101 | keys: Res>, 102 | mut dolly: EventWriter, 103 | cam: Query>, 104 | mut toggled: Local, 105 | ) { 106 | if keys.just_pressed(KeyCode::KeyP) { 107 | *toggled = !*toggled; 108 | let target_projection = if *toggled { 109 | Projection::Orthographic(OrthographicProjection::default_3d()) 110 | } else { 111 | Projection::Perspective(PerspectiveProjection::default()) 112 | }; 113 | dolly.send(DollyZoomTrigger { 114 | target_projection, 115 | camera: cam.single(), 116 | }); 117 | } 118 | } 119 | 120 | fn setup_ui(mut commands: Commands) { 121 | let text = indoc::indoc! {" 122 | Left Mouse - Pan 123 | Right Mouse - Orbit 124 | Scroll - Zoom 125 | P - Toggle projection 126 | "}; 127 | commands.spawn(( 128 | Text::new(text), 129 | TextFont { 130 | font_size: 20.0, 131 | ..default() 132 | }, 133 | Node { 134 | margin: UiRect::all(Val::Px(20.0)), 135 | ..Default::default() 136 | }, 137 | )); 138 | } 139 | -------------------------------------------------------------------------------- /examples/minimal.rs: -------------------------------------------------------------------------------- 1 | //! A minimal example showing the steps needed to get started with the plugin. 2 | 3 | use bevy::prelude::*; 4 | use bevy_editor_cam::prelude::*; 5 | 6 | fn main() { 7 | App::new() 8 | .add_plugins(( 9 | DefaultPlugins, 10 | MeshPickingPlugin, // Step 0: enable some picking backends for hit detection 11 | DefaultEditorCamPlugins, // Step 1: Add camera controller plugin 12 | )) 13 | .add_systems(Startup, setup) 14 | .run(); 15 | } 16 | 17 | fn setup(mut commands: Commands, asset_server: Res) { 18 | commands.spawn(( 19 | Camera3d::default(), 20 | EditorCam::default(), // Step 2: add camera controller component to any cameras 21 | EnvironmentMapLight { 22 | diffuse_map: asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"), 23 | specular_map: asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"), 24 | intensity: 500.0, 25 | ..default() 26 | }, 27 | Transform::from_xyz(0.0, 0.0, 1.0), 28 | )); 29 | commands.spawn(SceneRoot( 30 | asset_server.load("models/PlaneEngine/scene.gltf#Scene0"), 31 | )); 32 | } 33 | -------------------------------------------------------------------------------- /examples/ortho.rs: -------------------------------------------------------------------------------- 1 | use bevy::prelude::*; 2 | use bevy_editor_cam::prelude::*; 3 | 4 | fn main() { 5 | App::new() 6 | .add_plugins(( 7 | DefaultPlugins, 8 | MeshPickingPlugin, 9 | DefaultEditorCamPlugins, 10 | bevy_framepace::FramepacePlugin, 11 | )) 12 | .add_systems(Startup, (setup, setup_ui)) 13 | .run(); 14 | } 15 | 16 | fn setup(mut commands: Commands, asset_server: Res) { 17 | let diffuse_map = asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"); 18 | let specular_map = asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"); 19 | let translation = Vec3::new(10.0, 10.0, 10.0); 20 | 21 | commands.spawn(( 22 | Camera3d::default(), 23 | Transform::from_translation(translation).looking_at(Vec3::ZERO, Vec3::Y), 24 | Projection::Orthographic(OrthographicProjection { 25 | scale: 0.01, 26 | ..OrthographicProjection::default_3d() 27 | }), 28 | EnvironmentMapLight { 29 | intensity: 1000.0, 30 | diffuse_map: diffuse_map.clone(), 31 | specular_map: specular_map.clone(), 32 | rotation: default(), 33 | }, 34 | // This component makes the camera controllable with this plugin. 35 | // 36 | // Important: the `with_initial_anchor_depth` is critical for an orthographic camera. Unlike 37 | // perspective, we can't rely on distant things being small to hide precision artifacts. 38 | // This means we need to be careful with the near and far plane of the camera, especially 39 | // because in orthographic, the depth precision is linear. 40 | // 41 | // This plugin uses the anchor (the point in space the user is interested in) to set the 42 | // orthographic scale, as well as the near and far planes. This can be a bit tricky if you 43 | // are unfamiliar with orthographic projections. Consider using an pseudo-ortho projection 44 | // (see `pseudo_ortho` example) if you don't need a true ortho projection. 45 | EditorCam::default().with_initial_anchor_depth(-translation.length() as f64), 46 | // This is an extension made specifically for orthographic cameras. Because an ortho camera 47 | // projection has no field of view, a skybox can't be sensibly rendered, only a single point 48 | // on the skybox would be visible to the camera at any given time. While this is technically 49 | // correct to what the camera would see, it is not visually helpful nor appealing. It is 50 | // common for CAD software to render a skybox with a field of view that is decoupled from 51 | // the camera field of view. 52 | bevy_editor_cam::extensions::independent_skybox::IndependentSkybox::new( 53 | diffuse_map, 54 | 500.0, 55 | default(), 56 | ), 57 | )); 58 | 59 | spawn_helmets(27, &asset_server, &mut commands); 60 | } 61 | 62 | fn spawn_helmets(n: usize, asset_server: &AssetServer, commands: &mut Commands) { 63 | let half_width = (((n as f32).powf(1.0 / 3.0) - 1.0) / 2.0) as i32; 64 | let scene = asset_server.load("models/PlaneEngine/scene.gltf#Scene0"); 65 | let width = -half_width..=half_width; 66 | for x in width.clone() { 67 | for y in width.clone() { 68 | for z in width.clone() { 69 | commands.spawn(( 70 | SceneRoot(scene.clone()), 71 | Transform::from_translation(IVec3::new(x, y, z).as_vec3() * 2.0) 72 | .with_scale(Vec3::splat(1.)), 73 | )); 74 | } 75 | } 76 | } 77 | } 78 | 79 | fn setup_ui(mut commands: Commands) { 80 | let text = indoc::indoc! {" 81 | Left Mouse - Pan 82 | Right Mouse - Orbit 83 | Scroll - Zoom 84 | "}; 85 | commands.spawn(( 86 | Text::new(text), 87 | TextFont { 88 | font_size: 20.0, 89 | ..default() 90 | }, 91 | Node { 92 | margin: UiRect::all(Val::Px(20.0)), 93 | ..Default::default() 94 | }, 95 | )); 96 | } 97 | -------------------------------------------------------------------------------- /examples/split_screen.rs: -------------------------------------------------------------------------------- 1 | //! Renders two cameras to the same window to accomplish "split screen". 2 | 3 | use bevy::{ 4 | core_pipeline::tonemapping::Tonemapping, prelude::*, render::camera::Viewport, 5 | window::WindowResized, 6 | }; 7 | use bevy_editor_cam::prelude::*; 8 | 9 | fn main() { 10 | App::new() 11 | .add_plugins(( 12 | DefaultPlugins, 13 | MeshPickingPlugin, 14 | DefaultEditorCamPlugins, 15 | bevy_framepace::FramepacePlugin, 16 | )) 17 | .add_systems(Startup, setup) 18 | .add_systems(Update, set_camera_viewports) 19 | .run(); 20 | } 21 | 22 | /// set up a simple 3D scene 23 | fn setup(mut commands: Commands, asset_server: Res) { 24 | spawn_helmets(27, &asset_server, &mut commands); 25 | 26 | let diffuse_map = asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"); 27 | let specular_map = asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"); 28 | 29 | // Left Camera 30 | commands.spawn(( 31 | Camera3d::default(), 32 | Transform::from_xyz(0.0, 2.0, -1.0).looking_at(Vec3::ZERO, Vec3::Y), 33 | Camera { 34 | hdr: true, 35 | clear_color: ClearColorConfig::None, 36 | ..default() 37 | }, 38 | EnvironmentMapLight { 39 | intensity: 1000.0, 40 | diffuse_map: diffuse_map.clone(), 41 | specular_map: specular_map.clone(), 42 | rotation: default(), 43 | }, 44 | EditorCam::default(), 45 | bevy_editor_cam::extensions::independent_skybox::IndependentSkybox::new( 46 | diffuse_map.clone(), 47 | 500.0, 48 | default(), 49 | ), 50 | LeftCamera, 51 | )); 52 | 53 | // Right Camera 54 | commands.spawn(( 55 | Camera3d::default(), 56 | Transform::from_xyz(1.0, 1.0, 1.5).looking_at(Vec3::ZERO, Vec3::Y), 57 | Camera { 58 | // Renders the right camera after the left camera, which has a default priority of 0 59 | order: 10, 60 | hdr: true, 61 | // don't clear on the second camera because the first camera already cleared the window 62 | clear_color: ClearColorConfig::None, 63 | ..default() 64 | }, 65 | Projection::Orthographic(OrthographicProjection { 66 | scale: 0.01, 67 | ..OrthographicProjection::default_3d() 68 | }), 69 | Tonemapping::AcesFitted, 70 | EnvironmentMapLight { 71 | intensity: 1000.0, 72 | diffuse_map: diffuse_map.clone(), 73 | specular_map: specular_map.clone(), 74 | rotation: default(), 75 | }, 76 | EditorCam::default(), 77 | bevy_editor_cam::extensions::independent_skybox::IndependentSkybox::new( 78 | diffuse_map, 79 | 500.0, 80 | default(), 81 | ), 82 | RightCamera, 83 | )); 84 | } 85 | 86 | #[derive(Component)] 87 | struct LeftCamera; 88 | 89 | #[derive(Component)] 90 | struct RightCamera; 91 | 92 | fn set_camera_viewports( 93 | windows: Query<&Window>, 94 | mut resize_events: EventReader, 95 | mut left_camera: Query<&mut Camera, (With, Without)>, 96 | mut right_camera: Query<&mut Camera, With>, 97 | ) { 98 | // We need to dynamically resize the camera's viewports whenever the window size changes 99 | // so then each camera always takes up half the screen. 100 | // A resize_event is sent when the window is first created, allowing us to reuse this system for initial setup. 101 | for resize_event in resize_events.read() { 102 | let window = windows.get(resize_event.window).unwrap(); 103 | let mut left_camera = left_camera.single_mut(); 104 | left_camera.viewport = Some(Viewport { 105 | physical_position: UVec2::new(0, 0), 106 | physical_size: UVec2::new( 107 | window.resolution.physical_width() / 2, 108 | window.resolution.physical_height(), 109 | ), 110 | ..default() 111 | }); 112 | 113 | let mut right_camera = right_camera.single_mut(); 114 | right_camera.viewport = Some(Viewport { 115 | physical_position: UVec2::new(window.resolution.physical_width() / 2, 0), 116 | physical_size: UVec2::new( 117 | window.resolution.physical_width() / 2, 118 | window.resolution.physical_height(), 119 | ), 120 | ..default() 121 | }); 122 | } 123 | } 124 | 125 | fn spawn_helmets(n: usize, asset_server: &AssetServer, commands: &mut Commands) { 126 | let half_width = (((n as f32).powf(1.0 / 3.0) - 1.0) / 2.0) as i32; 127 | let scene = asset_server.load("models/PlaneEngine/scene.gltf#Scene0"); 128 | let width = -half_width..=half_width; 129 | for x in width.clone() { 130 | for y in width.clone() { 131 | for z in width.clone() { 132 | commands.spawn(( 133 | SceneRoot(scene.clone()), 134 | Transform::from_translation(IVec3::new(x, y, z).as_vec3() * 2.0) 135 | .with_scale(Vec3::splat(1.)), 136 | )); 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /examples/zoom_limits.rs: -------------------------------------------------------------------------------- 1 | //! A minimal example demonstrating setting zoom limits and zooming through objects. 2 | 3 | use bevy::prelude::*; 4 | use bevy_editor_cam::{extensions::dolly_zoom::DollyZoomTrigger, prelude::*}; 5 | use zoom::ZoomLimits; 6 | 7 | fn main() { 8 | App::new() 9 | .add_plugins(( 10 | DefaultPlugins, 11 | MeshPickingPlugin, 12 | DefaultEditorCamPlugins, 13 | bevy_framepace::FramepacePlugin, 14 | )) 15 | .add_systems(Startup, (setup_camera, setup_scene, setup_ui)) 16 | .add_systems(Update, (toggle_projection, toggle_zoom)) 17 | .run(); 18 | } 19 | 20 | fn setup_camera(mut commands: Commands, asset_server: Res) { 21 | commands.spawn(( 22 | Camera3d::default(), 23 | EditorCam { 24 | zoom_limits: ZoomLimits { 25 | min_size_per_pixel: 0.0001, 26 | max_size_per_pixel: 0.01, 27 | zoom_through_objects: true, 28 | }, 29 | ..default() 30 | }, 31 | EnvironmentMapLight { 32 | intensity: 1000.0, 33 | diffuse_map: asset_server.load("environment_maps/diffuse_rgb9e5_zstd.ktx2"), 34 | specular_map: asset_server.load("environment_maps/specular_rgb9e5_zstd.ktx2"), 35 | rotation: default(), 36 | }, 37 | )); 38 | } 39 | 40 | fn toggle_zoom( 41 | keys: Res>, 42 | mut cam: Query<&mut EditorCam>, 43 | mut text: Query<&mut Text>, 44 | ) { 45 | if keys.just_pressed(KeyCode::KeyZ) { 46 | let mut editor = cam.single_mut(); 47 | editor.zoom_limits.zoom_through_objects = !editor.zoom_limits.zoom_through_objects; 48 | let mut text = text.single_mut(); 49 | *text = Text::new(help_text(editor.zoom_limits.zoom_through_objects)); 50 | } 51 | } 52 | 53 | // 54 | // --- The below code is not important for the example --- 55 | // 56 | 57 | fn setup_scene( 58 | mut commands: Commands, 59 | mut meshes: ResMut>, 60 | mut materials: ResMut>, 61 | ) { 62 | let material = materials.add(Color::srgba(0.1, 0.1, 0.9, 0.5)); 63 | let mesh = meshes.add(Cuboid::from_size(Vec3::new(1.0, 1.0, 0.1))); 64 | 65 | for i in 1..5 { 66 | commands.spawn(( 67 | Mesh3d(mesh.clone()), 68 | MeshMaterial3d(material.clone()), 69 | Transform::from_xyz(0.0, 0.0, -2.0 * i as f32), 70 | )); 71 | } 72 | } 73 | 74 | fn setup_ui(mut commands: Commands) { 75 | commands.spawn(( 76 | Text::new(help_text(true)), 77 | TextFont { 78 | font_size: 20.0, 79 | ..default() 80 | }, 81 | Node { 82 | margin: UiRect::all(Val::Px(20.0)), 83 | ..Default::default() 84 | }, 85 | // TargetCamera(camera), 86 | )); 87 | } 88 | 89 | fn help_text(zoom_through: bool) -> String { 90 | indoc::formatdoc! {" 91 | Left Mouse - Pan 92 | Right Mouse - Orbit 93 | Scroll - Zoom 94 | P - Toggle projection 95 | Z - Toggle zoom through object setting 96 | Zoom Through: {zoom_through} 97 | "} 98 | } 99 | 100 | fn toggle_projection( 101 | keys: Res>, 102 | mut dolly: EventWriter, 103 | cam: Query>, 104 | mut toggled: Local, 105 | ) { 106 | if keys.just_pressed(KeyCode::KeyP) { 107 | *toggled = !*toggled; 108 | let target_projection = if *toggled { 109 | Projection::Orthographic(OrthographicProjection::default_3d()) 110 | } else { 111 | Projection::Perspective(PerspectiveProjection::default()) 112 | }; 113 | dolly.send(DollyZoomTrigger { 114 | target_projection, 115 | camera: cam.single(), 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/controller/component.rs: -------------------------------------------------------------------------------- 1 | //! The primary [`Component`] of the controller, [`EditorCam`]. 2 | 3 | use std::{ 4 | f32::consts::{FRAC_PI_2, PI}, 5 | time::Duration, 6 | }; 7 | 8 | use bevy_ecs::prelude::*; 9 | use bevy_log::prelude::*; 10 | use bevy_math::{prelude::*, DMat4, DQuat, DVec2, DVec3}; 11 | use bevy_reflect::prelude::*; 12 | use bevy_render::prelude::*; 13 | use bevy_time::prelude::*; 14 | use bevy_transform::prelude::*; 15 | use bevy_utils::Instant; 16 | use bevy_window::RequestRedraw; 17 | 18 | use super::{ 19 | inputs::MotionInputs, 20 | momentum::{Momentum, Velocity}, 21 | motion::CurrentMotion, 22 | projections::{OrthographicSettings, PerspectiveSettings}, 23 | smoothing::{InputQueue, Smoothing}, 24 | zoom::ZoomLimits, 25 | }; 26 | 27 | /// Tracks all state of a camera's controller, including its inputs, motion, and settings. 28 | /// 29 | /// See the documentation on the contained fields and types to learn more about each setting. 30 | /// 31 | /// # Moving the Camera 32 | /// 33 | /// The [`EditorCamPlugin`](crate::DefaultEditorCamPlugins) will automatically handle sending inputs 34 | /// to the camera controller using [`bevy_picking`] to compute pointer hit locations for mouse, 35 | /// touch, and pen inputs. The picking plugin allows you to specify your own picking backend, or 36 | /// choose from a variety of provided backends. This is important because this camera controller 37 | /// relies on depth information for each pointer, and using the picking plugin means it can do this 38 | /// without forcing you into using a particular hit testing backend, e.g. raycasting, which is used 39 | /// by default. 40 | /// 41 | /// To move the camera manually: 42 | /// 43 | /// 1. Start a camera motion using one of [`EditorCam::start_orbit`], [`EditorCam::start_pan`], 44 | /// [`EditorCam::start_zoom`]. 45 | /// 2. While the motion should be active, send inputs with [`EditorCam::send_screenspace_input`] and 46 | /// [`EditorCam::send_zoom_input`]. 47 | /// 3. When the motion should end, call [`EditorCam::end_move`]. 48 | #[derive(Debug, Clone, Reflect, Component)] 49 | pub struct EditorCam { 50 | /// What input motions are currently allowed? 51 | pub enabled_motion: EnabledMotion, 52 | /// The type of camera orbit to use. 53 | pub orbit_constraint: OrbitConstraint, 54 | /// Set near and far zoom limits, as well as the ability to zoom through objects. 55 | pub zoom_limits: ZoomLimits, 56 | /// Input smoothing of camera motion. 57 | pub smoothing: Smoothing, 58 | /// Input sensitivity of the camera. 59 | pub sensitivity: Sensitivity, 60 | /// Amount of camera momentum after inputs have stopped. 61 | pub momentum: Momentum, 62 | /// How long should inputs attempting to start a new motion be ignored, after the last input 63 | /// ends? This is useful to prevent accidentally killing momentum when, for example, releasing a 64 | /// two finger right click on a trackpad triggers a scroll input. 65 | pub input_debounce: Duration, 66 | /// Settings used when the camera has a perspective [`Projection`]. 67 | pub perspective: PerspectiveSettings, 68 | /// Settings used when the camera has an orthographic [`Projection`]. 69 | pub orthographic: OrthographicSettings, 70 | /// Managed by the camera controller, though you may want to change this when spawning or 71 | /// manually moving the camera. 72 | /// 73 | /// If the camera starts moving, but there is nothing under the pointer, the controller will 74 | /// rotate, pan, and zoom about a point in the direction the camera is facing, at this depth. 75 | /// This will be overwritten with the latest depth if a hit is found, to ensure the anchor point 76 | /// doesn't change suddenly if the user moves the pointer away from an object. 77 | pub last_anchor_depth: f64, 78 | /// Current camera motion. Managed by the camera controller, but exposed publicly to allow for 79 | /// overriding motion. 80 | pub current_motion: CurrentMotion, 81 | } 82 | 83 | impl Default for EditorCam { 84 | fn default() -> Self { 85 | EditorCam { 86 | orbit_constraint: Default::default(), 87 | zoom_limits: Default::default(), 88 | smoothing: Default::default(), 89 | sensitivity: Default::default(), 90 | momentum: Default::default(), 91 | input_debounce: Duration::from_millis(80), 92 | perspective: Default::default(), 93 | orthographic: Default::default(), 94 | enabled_motion: Default::default(), 95 | current_motion: Default::default(), 96 | last_anchor_depth: -2.0, 97 | } 98 | } 99 | } 100 | 101 | impl EditorCam { 102 | /// Create a new editor camera component. 103 | pub fn new( 104 | orbit: OrbitConstraint, 105 | smoothness: Smoothing, 106 | sensitivity: Sensitivity, 107 | momentum: Momentum, 108 | initial_anchor_depth: f64, 109 | ) -> Self { 110 | Self { 111 | orbit_constraint: orbit, 112 | smoothing: smoothness, 113 | sensitivity, 114 | momentum, 115 | last_anchor_depth: initial_anchor_depth.abs() * -1.0, // ensure depth is correct sign 116 | ..Default::default() 117 | } 118 | } 119 | 120 | /// Set the initial anchor depth of the camera controller. 121 | pub fn with_initial_anchor_depth(self, initial_anchor_depth: f64) -> Self { 122 | Self { 123 | last_anchor_depth: initial_anchor_depth.abs() * -1.0, // ensure depth is correct sign 124 | ..self 125 | } 126 | } 127 | 128 | /// Gets the [`MotionInputs`], if the camera is being actively moved.. 129 | pub fn motion_inputs(&self) -> Option<&MotionInputs> { 130 | match &self.current_motion { 131 | CurrentMotion::Stationary => None, 132 | CurrentMotion::Momentum { .. } => None, 133 | CurrentMotion::UserControlled { motion_inputs, .. } => Some(motion_inputs), 134 | } 135 | } 136 | 137 | /// Returns the best guess at an anchor point if none is provided. 138 | /// 139 | /// Updates the fallback value with the latest hit to ensure that if the camera starts orbiting 140 | /// again, but has no hit to anchor onto, the anchor doesn't suddenly change distance, which is 141 | /// what would happen if we used a fixed value. 142 | fn maybe_update_anchor(&mut self, anchor: Option) -> DVec3 { 143 | let anchor = anchor.unwrap_or(DVec3::new(0.0, 0.0, self.last_anchor_depth.abs() * -1.0)); 144 | self.last_anchor_depth = anchor.z; 145 | anchor 146 | } 147 | 148 | /// Get the position of the anchor in the camera's view space. 149 | pub fn anchor_view_space(&self) -> Option { 150 | if let CurrentMotion::UserControlled { anchor, .. } = &self.current_motion { 151 | Some(*anchor) 152 | } else { 153 | None 154 | } 155 | } 156 | 157 | /// Get the position of the anchor in world space. 158 | pub fn anchor_world_space(&self, camera_transform: &GlobalTransform) -> Option { 159 | self.anchor_view_space().map(|anchor_view_space| { 160 | camera_transform 161 | .compute_matrix() 162 | .as_dmat4() 163 | .transform_point3(anchor_view_space) 164 | }); 165 | 166 | self.anchor_view_space().map(|anchor_view_space| { 167 | let (_, r, t) = camera_transform.to_scale_rotation_translation(); 168 | r.as_dquat() * anchor_view_space + t.as_dvec3() 169 | }) 170 | } 171 | 172 | /// Should the camera controller prevent new motions from starting, because the user is actively 173 | /// operating the camera? 174 | /// 175 | /// This does not consider zooming as "actively controlled". This is needed because scroll input 176 | /// devices often have their own momentum, and can continue to provide inputs even when the user 177 | /// is not actively providing inputs, like a scroll wheel that keeps spinning, or a trackpad 178 | /// with smooth scrolling. Without this, the controller will feel unresponsive, as a user will 179 | /// be unable to initiate a new motion even though they are not technically providing an input. 180 | pub fn is_actively_controlled(&self) -> bool { 181 | !self.current_motion.is_zooming_only() 182 | && (self.current_motion.is_user_controlled() 183 | || self 184 | .current_motion 185 | .momentum_duration() 186 | .map(|duration| duration < self.input_debounce) 187 | .unwrap_or(false)) 188 | } 189 | 190 | /// Call this to start an orbiting motion with the optionally supplied anchor position in view 191 | /// space. See [`EditorCam`] for usage. 192 | pub fn start_orbit(&mut self, anchor: Option) { 193 | if !self.enabled_motion.orbit { 194 | return; 195 | } 196 | self.current_motion = CurrentMotion::UserControlled { 197 | anchor: self.maybe_update_anchor(anchor), 198 | motion_inputs: MotionInputs::OrbitZoom { 199 | screenspace_inputs: InputQueue::default(), 200 | zoom_inputs: InputQueue::default(), 201 | }, 202 | } 203 | } 204 | 205 | /// Call this to start an panning motion with the optionally supplied anchor position in view 206 | /// space. See [`EditorCam`] for usage. 207 | pub fn start_pan(&mut self, anchor: Option) { 208 | if !self.enabled_motion.pan { 209 | return; 210 | } 211 | self.current_motion = CurrentMotion::UserControlled { 212 | anchor: self.maybe_update_anchor(anchor), 213 | motion_inputs: MotionInputs::PanZoom { 214 | screenspace_inputs: InputQueue::default(), 215 | zoom_inputs: InputQueue::default(), 216 | }, 217 | } 218 | } 219 | 220 | /// Call this to start a zooming motion with the optionally supplied anchor position in view 221 | /// space. See [`EditorCam`] for usage. 222 | pub fn start_zoom(&mut self, anchor: Option) { 223 | if !self.enabled_motion.zoom { 224 | return; 225 | } 226 | let anchor = self.maybe_update_anchor(anchor); 227 | 228 | // Inherit current camera velocity 229 | let zoom_inputs = match self.current_motion { 230 | CurrentMotion::Stationary | CurrentMotion::Momentum { .. } => InputQueue::default(), 231 | CurrentMotion::UserControlled { 232 | ref mut motion_inputs, 233 | .. 234 | } => InputQueue(motion_inputs.zoom_inputs_mut().0.drain(..).collect()), 235 | }; 236 | self.current_motion = CurrentMotion::UserControlled { 237 | anchor, 238 | motion_inputs: MotionInputs::Zoom { zoom_inputs }, 239 | } 240 | } 241 | 242 | /// Send screen space camera inputs. This will be interpreted as panning or orbiting depending 243 | /// on the current motion. See [`EditorCam`] for usage. 244 | pub fn send_screenspace_input(&mut self, screenspace_input: Vec2) { 245 | if let CurrentMotion::UserControlled { 246 | ref mut motion_inputs, 247 | .. 248 | } = self.current_motion 249 | { 250 | match motion_inputs { 251 | MotionInputs::OrbitZoom { 252 | screenspace_inputs: ref mut movement, 253 | .. 254 | } => movement.process_input(screenspace_input, self.smoothing.orbit), 255 | MotionInputs::PanZoom { 256 | screenspace_inputs: ref mut movement, 257 | .. 258 | } => movement.process_input(screenspace_input, self.smoothing.pan), 259 | MotionInputs::Zoom { .. } => (), // When in zoom-only, we ignore pan and zoom 260 | } 261 | } 262 | } 263 | 264 | /// Send zoom inputs. See [`EditorCam`] for usage. 265 | pub fn send_zoom_input(&mut self, zoom_amount: f32) { 266 | if let CurrentMotion::UserControlled { motion_inputs, .. } = &mut self.current_motion { 267 | motion_inputs 268 | .zoom_inputs_mut() 269 | .process_input(zoom_amount, self.smoothing.zoom) 270 | } 271 | } 272 | 273 | /// End the current camera motion, allowing other motions on this camera to begin. See 274 | /// [`EditorCam`] for usage. 275 | pub fn end_move(&mut self) { 276 | let velocity = match self.current_motion { 277 | CurrentMotion::Stationary => return, 278 | CurrentMotion::Momentum { .. } => return, 279 | CurrentMotion::UserControlled { 280 | anchor, 281 | ref motion_inputs, 282 | .. 283 | } => match motion_inputs { 284 | MotionInputs::OrbitZoom { .. } => Velocity::Orbit { 285 | anchor, 286 | velocity: motion_inputs.orbit_momentum(self.momentum.init_orbit), 287 | }, 288 | MotionInputs::PanZoom { .. } => Velocity::Pan { 289 | anchor, 290 | velocity: motion_inputs.pan_momentum(self.momentum.init_pan), 291 | }, 292 | MotionInputs::Zoom { .. } => Velocity::None, 293 | }, 294 | }; 295 | let momentum_start = Instant::now(); 296 | self.current_motion = CurrentMotion::Momentum { 297 | velocity, 298 | momentum_start, 299 | }; 300 | } 301 | 302 | /// Called once every frame to compute motions and update the transforms of all [`EditorCam`]s 303 | pub fn update_camera_positions( 304 | mut cameras: Query<(&mut EditorCam, &Camera, &mut Transform, &mut Projection)>, 305 | mut event: EventWriter, 306 | time: Res