Skip to content

Adds Frustum Gizmo#22762

Merged
alice-i-cecile merged 15 commits intobevyengine:mainfrom
kfc35:frustum-gizmo
Feb 4, 2026
Merged

Adds Frustum Gizmo#22762
alice-i-cecile merged 15 commits intobevyengine:mainfrom
kfc35:frustum-gizmo

Conversation

@kfc35
Copy link
Contributor

@kfc35 kfc35 commented Feb 1, 2026

Objective

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).
Screenshot 2026-01-31 at 9 18 41 PM

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.
Screenshot 2026-01-31 at 9 36 54 PM - 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. Screenshot 2026-01-31 at 9 09 46 PM

Showcase

Modified `split_screen` example code
//! 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));
            }
        }
    }
}

@kfc35 kfc35 added C-Feature A new feature, making something new possible A-Math Fundamental domain-agnostic mathematical operations A-Gizmos Visual editor and debug gizmos D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Feb 1, 2026
#[cfg(feature = "bevy_mesh")]
use crate::skinned_mesh_bounds::SkinnedMeshBoundsGizmoPlugin;

/// A [`Plugin`] that provides an immediate mode drawing api for visual debugging.
Copy link
Member

Choose a reason for hiding this comment

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

Not for this PR: maybe this should be a plugin group?

@alice-i-cecile
Copy link
Member

Big fan of this, but I think we can improve it in a few simple ways.

@kfc35
Copy link
Contributor Author

kfc35 commented Feb 3, 2026

Added tests and expanded the module comment for frustum.rs

Addressed all comments so far; this is ready for review again

@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Feb 4, 2026
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Feb 4, 2026
Merged via the queue into bevyengine:main with commit c6ca738 Feb 4, 2026
40 checks passed
@kfc35 kfc35 mentioned this pull request Feb 4, 2026
@kfc35 kfc35 deleted the frustum-gizmo branch February 21, 2026 03:46
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 A-Math Fundamental domain-agnostic mathematical operations C-Feature A new feature, making something new possible D-Straightforward Simple bug fixes and API improvements, docs, test and examples S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bevy_render::Frustum should live in bevy_math

4 participants