Skip to content

Add frustum gizmo#10038

Closed
tim-blackbird wants to merge 5 commits intobevyengine:mainfrom
tim-blackbird:frustum-gizmo
Closed

Add frustum gizmo#10038
tim-blackbird wants to merge 5 commits intobevyengine:mainfrom
tim-blackbird:frustum-gizmo

Conversation

@tim-blackbird
Copy link
Contributor

@tim-blackbird tim-blackbird commented Oct 6, 2023

Adds the FrustumGizmo which works in a similar fashion to the previously added AabbGizmo.

Closes #4082

Preventing cameras from drawing their own frustums made this a lot messier but as a result this is now technically the first retained gizmo q:

Here's a screenshot of a modified split_screen example showing the frustum of one view in the other.
image

Changelog

Added the FrustumGizmo component for drawing the frustums of cameras with the relevant settings added to the GizmoConfig resource.


for (entity, handle) in &line_gizmos {
// The frustum gizmo adds a linegizmo to the same entity.
// We can use that here to prevent views from rendering their own frustum.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice.

Copy link
Contributor

@superdump superdump left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some minor comments, and I think the frustum corner calculations could maybe be on the Frustum impl.

@alice-i-cecile alice-i-cecile added C-Feature A new feature, making something new possible A-Gizmos Visual editor and debug gizmos labels Oct 9, 2023
tim-blackbird and others added 2 commits October 10, 2023 15:37
Co-authored-by: Robert Swain <robert.swain@gmail.com>
@tim-blackbird
Copy link
Contributor Author

Thanks for the comments :)

I took a look at the frusta on the lights after reading the comments on discord and noticed that spotlights already have a Frustum component. That means this PR should have just workedtm but unfortunately the halfspace intersection tests to determine the corners are failing for the spotlight frusta.

@torsteingrindvik
Copy link
Contributor

I'd use this feature if it was in.

@irate-devil any blockers on this?

github-merge-queue bot pushed a commit that referenced this pull request Jan 18, 2024
# Objective

This PR aims to implement multiple configs for gizmos as discussed in
#9187.

## Solution

Configs for the new `GizmoConfigGroup`s are stored in a
`GizmoConfigStore` resource and can be accesses using a type based key
or iterated over. This type based key doubles as a standardized location
where plugin authors can put their own configuration not covered by the
standard `GizmoConfig` struct. For example the `AabbGizmoGroup` has a
default color and toggle to show all AABBs. New configs can be
registered using `app.init_gizmo_group::<T>()` during startup.

When requesting the `Gizmos<T>` system parameter the generic type
determines which config is used. The config structs are available
through the `Gizmos` system parameter allowing for easy access while
drawing your gizmos.

Internally, resources and systems used for rendering (up to an including
the extract system) are generic over the type based key and inserted on
registering a new config.

## Alternatives

The configs could be stored as components on entities with markers which
would make better use of the ECS. I also implemented this approach
([here](https://github.com/jeliag/bevy/tree/gizmo-multiconf-comp)) and
believe that the ergonomic benefits of a central config store outweigh
the decreased use of the ECS.

## Unsafe Code

Implementing system parameter by hand is unsafe but seems to be required
to access the config store once and not on every gizmo draw function
call. This is critical for performance. ~Is there a better way to do
this?~

## Future Work

New gizmos (such as #10038, and ideas from #9400) will require custom
configuration structs. Should there be a new custom config for every
gizmo type, or should we group them together in a common configuration?
(for example `EditorGizmoConfig`, or something more fine-grained)

## Changelog

- Added `GizmoConfigStore` resource and `GizmoConfigGroup` trait
- Added `init_gizmo_group` to `App`
- Added early returns to gizmo drawing increasing performance when
gizmos are disabled
- Changed `GizmoConfig` and aabb gizmos to use new `GizmoConfigStore`
- Changed `Gizmos` system parameter to use type based key to retrieve
config
- Changed resources and systems used for gizmo rendering to be generic
over type based key
- Changed examples (3d_gizmos, 2d_gizmos) to showcase new API

## Migration Guide

- `GizmoConfig` is no longer a resource and has to be accessed through
`GizmoConfigStore` resource. The default config group is
`DefaultGizmoGroup`, but consider using your own custom config group if
applicable.

---------

Co-authored-by: Nicola Papale <nicopap@users.noreply.github.com>
@JMS55 JMS55 added this to the 0.14 milestone Mar 6, 2024
@0x0539
Copy link

0x0539 commented Mar 6, 2024

It would be great to have this functionality

@alice-i-cecile alice-i-cecile added the S-Adopt-Me The original PR author has no intent to complete this work. Pick me up! label May 16, 2024
@alice-i-cecile alice-i-cecile removed this from the 0.14 milestone May 16, 2024
@alice-i-cecile alice-i-cecile added X-Uncontroversial This work is generally agreed upon D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes labels May 16, 2024
@inact1v1ty
Copy link
Contributor

I would like to adopt this! 🙂

@BenjaminBrienen
Copy link
Contributor

@inact1v1ty once you have opened the new PR, I'll close this one

@chompaa chompaa mentioned this pull request Jun 8, 2025
@theotherphil
Copy link
Contributor

@inact1v1ty are you still intending to adopt this PR?

@RCoder01
Copy link
Contributor

For anyone needing this just to visualize/debug multiple cameras, here's a single file plugin that adds Frustum gizmos: https://gist.github.com/RCoder01/1285fb3c73177b6c6784cb080bbeb051.

It's not perfect (If you get really close to another camera's frustum the lines extend in the opposite direciton) but it was good enough for me.

@kfc35 kfc35 mentioned this pull request Feb 1, 2026
github-merge-queue bot pushed a commit that referenced this pull request Feb 4, 2026
# Objective

- Adopts #10038 by @tim-blackbird 
- Half of #19468 (no camera gizmo, just frustum)
- Does Part 3 of and fixes #13878

## Solution

I stand on the shoulders of giants and have updated #10038 to main, with
the following changes:
- The frustum gizmos are now immediate gizmos, not retained
- The current view’s frustum is drawn around the border of the screen as
a color like so (note the green border at the left, bottom, and right
edges of the screen).
<img width="1286" height="752" alt="Screenshot 2026-01-31 at 9 18 41 PM"
src="https://github.com/user-attachments/assets/7ed2b4db-1710-4be1-b6ca-00725d09944f"
/>

Also thanks to @RCoder01 for their github gist attached to the original
PR; my updates basically ended up being the same although code is more
or less in its proper place now.

## Testing

- I ran some scene_viewer gltf files in the bevy repo (e.g. `cargo run
--example scene_viewer --features "free_camera" --
assets/models/cubes/Cubes.glb`). I guess none have multiple cameras
though to cycle through though as far as I could tell? But at least this
shows you that toggling the frusta shows the faint border around the
screen.
- I ran the light_gizmos example `cargo run --example light_gizmos`.
Only `SpotLight` has gizmos drawn (`PointLight` and `DirectionalLight`
have components that wrap `Frustum` but not `Frustum` itself). It’s the
yellow gizmo in the following screenshot. Since there are dedicated
light gizmos, using a Frustum gizmo on a light seems unnecessary.
<img width="1278" height="740" alt="Screenshot 2026-01-31 at 9 36 54 PM"
src="https://github.com/user-attachments/assets/6e676bb8-32d4-4d90-9225-ee3a878745a6"
/>
- Like the original author, I modified the `split_screen` example to see
a camera frustum gizmo from one player on another player’s screen. I
removed players 3 and 4, added a frustum gizmo for player 2’s camera,
and moved player 1’s camera far enough so that you can see the frustum.
<img width="1274" height="740" alt="Screenshot 2026-01-31 at 9 09 46 PM"
src="https://github.com/user-attachments/assets/fff38469-9d00-46ba-9098-fdf8418b54fa"
/>

---

## Showcase

<details>
  <summary>Modified `split_screen` example code</summary>

```rust
//! Renders four cameras to the same window to accomplish "split screen".

use std::f32::consts::PI;

use bevy::{
    camera::Viewport, light::CascadeShadowConfigBuilder, prelude::*, window::WindowResized,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, (set_camera_viewports, button_system))
        .run();
}

/// set up a simple 3D scene
fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    // plane
    commands.spawn((
        Mesh3d(meshes.add(Plane3d::default().mesh().size(100.0, 100.0))),
        MeshMaterial3d(materials.add(Color::srgb(0.3, 0.5, 0.3))),
    ));

    commands.spawn(SceneRoot(
        asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/animated/Fox.glb")),
    ));

    // Light
    commands.spawn((
        Transform::from_rotation(Quat::from_euler(EulerRot::ZYX, 0.0, 1.0, -PI / 4.)),
        DirectionalLight {
            shadow_maps_enabled: true,
            ..default()
        },
        CascadeShadowConfigBuilder {
            num_cascades: if cfg!(all(
                feature = "webgl2",
                target_arch = "wasm32",
                not(feature = "webgpu")
            )) {
                // Limited to 1 cascade in WebGL
                1
            } else {
                2
            },
            first_cascade_far_bound: 200.0,
            maximum_distance: 280.0,
            ..default()
        }
        .build(),
    ));

    // Cameras and their dedicated UI
    for (index, (camera_name, camera_pos)) in [
        ("Player 1", Vec3::new(300.0, 300.0, -150.0)),
        ("Player 2", Vec3::new(150.0, 150., 50.0)),
    ]
    .iter()
    .enumerate()
    {
        let camera = commands
            .spawn((
                Camera3d::default(),
                Transform::from_translation(*camera_pos).looking_at(Vec3::ZERO, Vec3::Y),
                Camera {
                    // Renders cameras with different priorities to prevent ambiguities
                    order: index as isize,
                    ..default()
                },
                CameraPosition {
                    pos: index as u32 % 2,
                },
                ShowFrustumGizmo {
                    color: if index == 0 { Some(Color::NONE) } else { None },
                },
            ))
            .id();

        // Set up UI
        if index == 0 {
        commands.spawn((
            UiTargetCamera(camera),
            Node {
                width: percent(100),
                height: percent(100),
                ..default()
            },
            children![
                (
                    Text::new(*camera_name),
                    Node {
                        position_type: PositionType::Absolute,
                        top: px(12),
                        left: px(12),
                        ..default()
                    },
                ),
                buttons_panel(),
            ],
        ));
        }
    }

    fn buttons_panel() -> impl Bundle {
        (
            Node {
                position_type: PositionType::Absolute,
                width: percent(100),
                height: percent(100),
                display: Display::Flex,
                flex_direction: FlexDirection::Row,
                justify_content: JustifyContent::SpaceBetween,
                align_items: AlignItems::Center,
                padding: UiRect::all(px(20)),
                ..default()
            },
            children![
                rotate_button("<", Direction::Left),
                rotate_button(">", Direction::Right),
            ],
        )
    }

    fn rotate_button(caption: &str, direction: Direction) -> impl Bundle {
        (
            RotateCamera(direction),
            Button,
            Node {
                width: px(40),
                height: px(40),
                border: UiRect::all(px(2)),
                justify_content: JustifyContent::Center,
                align_items: AlignItems::Center,
                ..default()
            },
            BorderColor::all(Color::WHITE),
            BackgroundColor(Color::srgb(0.25, 0.25, 0.25)),
            children![Text::new(caption)],
        )
    }
}

#[derive(Component)]
struct CameraPosition {
    pos: u32,
}

#[derive(Component)]
struct RotateCamera(Direction);

enum Direction {
    Left,
    Right,
}

fn set_camera_viewports(
    windows: Query<&Window>,
    mut window_resized_reader: MessageReader<WindowResized>,
    mut query: Query<(&CameraPosition, &mut Camera)>,
) {
    // We need to dynamically resize the camera's viewports whenever the window size changes
    // so then each camera always takes up half the screen.
    // A resize_event is sent when the window is first created, allowing us to reuse this system for initial setup.
    for window_resized in window_resized_reader.read() {
        let window = windows.get(window_resized.window).unwrap();
        let size = window.physical_size();

        for (camera_position, mut camera) in &mut query {
            camera.viewport = Some(Viewport {
                physical_position: camera_position.pos * size,
                physical_size: size,
                ..default()
            });
        }
    }
}

fn button_system(
    interaction_query: Query<
        (&Interaction, &ComputedUiTargetCamera, &RotateCamera),
        (Changed<Interaction>, With<Button>),
    >,
    mut camera_query: Query<&mut Transform, With<Camera>>,
) {
    for (interaction, computed_target, RotateCamera(direction)) in &interaction_query {
        if let Interaction::Pressed = *interaction {
            // Since TargetCamera propagates to the children, we can use it to find
            // which side of the screen the button is on.
            if let Some(mut camera_transform) = computed_target
                .get()
                .and_then(|camera| camera_query.get_mut(camera).ok())
            {
                let angle = match direction {
                    Direction::Left => -0.1,
                    Direction::Right => 0.1,
                };
                camera_transform.rotate_around(Vec3::ZERO, Quat::from_axis_angle(Vec3::Y, angle));
            }
        }
    }
}

```

</details>

---------

Co-authored-by: devil-ira <justthecooldude@gmail.com>
Co-authored-by: Robert Swain <robert.swain@gmail.com>
Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
@kfc35
Copy link
Contributor

kfc35 commented Feb 4, 2026

#22762 includes this PR’s changes, so closing!

@kfc35 kfc35 closed this Feb 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Gizmos Visual editor and debug gizmos C-Feature A new feature, making something new possible D-Modest A "normal" level of difficulty; suitable for simple features or challenging fixes S-Adopt-Me The original PR author has no intent to complete this work. Pick me up! X-Uncontroversial This work is generally agreed upon

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add debug wireframe rendering for Aabb, Frustum, and Sphere