bevy_dev_tools/
fps_overlay.rs

1//! Module containing logic for FPS overlay.
2
3use bevy_app::{Plugin, Startup, Update};
4use bevy_asset::{Assets, Handle};
5use bevy_color::Color;
6use bevy_diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
7use bevy_ecs::{
8    component::Component,
9    entity::Entity,
10    prelude::Local,
11    query::{With, Without},
12    resource::Resource,
13    schedule::{common_conditions::resource_changed, IntoScheduleConfigs},
14    system::{Commands, Query, Res, ResMut, Single},
15};
16use bevy_picking::Pickable;
17use bevy_render::storage::ShaderStorageBuffer;
18use bevy_text::{Font, TextColor, TextFont, TextSpan};
19use bevy_time::Time;
20use bevy_ui::{
21    widget::{Text, TextUiWriter},
22    FlexDirection, GlobalZIndex, Node, PositionType, Val,
23};
24use bevy_ui_render::prelude::MaterialNode;
25use core::time::Duration;
26
27use crate::frame_time_graph::{
28    FrameTimeGraphConfigUniform, FrameTimeGraphPlugin, FrametimeGraphMaterial,
29};
30
31/// [`GlobalZIndex`] used to render the fps overlay.
32///
33/// We use a number slightly under `i32::MAX` so you can render on top of it if you really need to.
34pub const FPS_OVERLAY_ZINDEX: i32 = i32::MAX - 32;
35
36// Used to scale the frame time graph based on the fps text size
37const FRAME_TIME_GRAPH_WIDTH_SCALE: f32 = 6.0;
38const FRAME_TIME_GRAPH_HEIGHT_SCALE: f32 = 2.0;
39
40/// A plugin that adds an FPS overlay to the Bevy application.
41///
42/// This plugin will add the [`FrameTimeDiagnosticsPlugin`] if it wasn't added before.
43///
44/// Note: It is recommended to use native overlay of rendering statistics when possible for lower overhead and more accurate results.
45/// The correct way to do this will vary by platform:
46/// - **Metal**: setting env variable `MTL_HUD_ENABLED=1`
47#[derive(Default)]
48pub struct FpsOverlayPlugin {
49    /// Starting configuration of overlay, this can be later be changed through [`FpsOverlayConfig`] resource.
50    pub config: FpsOverlayConfig,
51}
52
53impl Plugin for FpsOverlayPlugin {
54    fn build(&self, app: &mut bevy_app::App) {
55        // TODO: Use plugin dependencies, see https://github.com/bevyengine/bevy/issues/69
56        if !app.is_plugin_added::<FrameTimeDiagnosticsPlugin>() {
57            app.add_plugins(FrameTimeDiagnosticsPlugin::default());
58        }
59
60        if !app.is_plugin_added::<FrameTimeGraphPlugin>() {
61            app.add_plugins(FrameTimeGraphPlugin);
62        }
63
64        app.insert_resource(self.config.clone())
65            .add_systems(Startup, setup)
66            .add_systems(
67                Update,
68                (
69                    (toggle_display, customize_overlay)
70                        .run_if(resource_changed::<FpsOverlayConfig>),
71                    update_text,
72                ),
73            );
74    }
75}
76
77/// Configuration options for the FPS overlay.
78#[derive(Resource, Clone)]
79pub struct FpsOverlayConfig {
80    /// Configuration of text in the overlay.
81    pub text_config: TextFont,
82    /// Color of text in the overlay.
83    pub text_color: Color,
84    /// Displays the FPS overlay if true.
85    pub enabled: bool,
86    /// The period after which the FPS overlay re-renders.
87    ///
88    /// Defaults to once every 100 ms.
89    pub refresh_interval: Duration,
90    /// Configuration of the frame time graph
91    pub frame_time_graph_config: FrameTimeGraphConfig,
92}
93
94impl Default for FpsOverlayConfig {
95    fn default() -> Self {
96        FpsOverlayConfig {
97            text_config: TextFont {
98                font: Handle::<Font>::default(),
99                font_size: 32.0,
100                ..Default::default()
101            },
102            text_color: Color::WHITE,
103            enabled: true,
104            refresh_interval: Duration::from_millis(100),
105            // TODO set this to display refresh rate if possible
106            frame_time_graph_config: FrameTimeGraphConfig::target_fps(60.0),
107        }
108    }
109}
110
111/// Configuration of the frame time graph
112#[derive(Clone, Copy)]
113pub struct FrameTimeGraphConfig {
114    /// Is the graph visible
115    pub enabled: bool,
116    /// The minimum acceptable FPS
117    ///
118    /// Anything below this will show a red bar
119    pub min_fps: f32,
120    /// The target FPS
121    ///
122    /// Anything above this will show a green bar
123    pub target_fps: f32,
124}
125
126impl FrameTimeGraphConfig {
127    /// Constructs a default config for a given target fps
128    pub fn target_fps(target_fps: f32) -> Self {
129        Self {
130            target_fps,
131            ..Self::default()
132        }
133    }
134}
135
136impl Default for FrameTimeGraphConfig {
137    fn default() -> Self {
138        Self {
139            enabled: true,
140            min_fps: 30.0,
141            target_fps: 60.0,
142        }
143    }
144}
145
146#[derive(Component)]
147struct FpsText;
148
149#[derive(Component)]
150struct FrameTimeGraph;
151
152fn setup(
153    mut commands: Commands,
154    overlay_config: Res<FpsOverlayConfig>,
155    mut frame_time_graph_materials: ResMut<Assets<FrametimeGraphMaterial>>,
156    mut buffers: ResMut<Assets<ShaderStorageBuffer>>,
157) {
158    commands
159        .spawn((
160            Node {
161                // We need to make sure the overlay doesn't affect the position of other UI nodes
162                position_type: PositionType::Absolute,
163                flex_direction: FlexDirection::Column,
164                ..Default::default()
165            },
166            // Render overlay on top of everything
167            GlobalZIndex(FPS_OVERLAY_ZINDEX),
168            Pickable::IGNORE,
169        ))
170        .with_children(|p| {
171            p.spawn((
172                Text::new("FPS: "),
173                overlay_config.text_config.clone(),
174                TextColor(overlay_config.text_color),
175                FpsText,
176                Pickable::IGNORE,
177            ))
178            .with_child((TextSpan::default(), overlay_config.text_config.clone()));
179
180            let font_size = overlay_config.text_config.font_size;
181            p.spawn((
182                Node {
183                    width: Val::Px(font_size * FRAME_TIME_GRAPH_WIDTH_SCALE),
184                    height: Val::Px(font_size * FRAME_TIME_GRAPH_HEIGHT_SCALE),
185                    display: if overlay_config.frame_time_graph_config.enabled {
186                        bevy_ui::Display::DEFAULT
187                    } else {
188                        bevy_ui::Display::None
189                    },
190                    ..Default::default()
191                },
192                Pickable::IGNORE,
193                MaterialNode::from(frame_time_graph_materials.add(FrametimeGraphMaterial {
194                    values: buffers.add(ShaderStorageBuffer {
195                        // Initialize with dummy data because the default (`data: None`) will
196                        // cause a panic in the shader if the frame time graph is constructed
197                        // with `enabled: false`.
198                        data: Some(vec![0, 0, 0, 0]),
199                        ..Default::default()
200                    }),
201                    config: FrameTimeGraphConfigUniform::new(
202                        overlay_config.frame_time_graph_config.target_fps,
203                        overlay_config.frame_time_graph_config.min_fps,
204                        true,
205                    ),
206                })),
207                FrameTimeGraph,
208            ));
209        });
210}
211
212fn update_text(
213    diagnostic: Res<DiagnosticsStore>,
214    query: Query<Entity, With<FpsText>>,
215    mut writer: TextUiWriter,
216    time: Res<Time>,
217    config: Res<FpsOverlayConfig>,
218    mut time_since_rerender: Local<Duration>,
219) {
220    *time_since_rerender += time.delta();
221    if *time_since_rerender >= config.refresh_interval {
222        *time_since_rerender = Duration::ZERO;
223        for entity in &query {
224            if let Some(fps) = diagnostic.get(&FrameTimeDiagnosticsPlugin::FPS)
225                && let Some(value) = fps.smoothed()
226            {
227                *writer.text(entity, 1) = format!("{value:.2}");
228            }
229        }
230    }
231}
232
233fn customize_overlay(
234    overlay_config: Res<FpsOverlayConfig>,
235    query: Query<Entity, With<FpsText>>,
236    mut writer: TextUiWriter,
237) {
238    for entity in &query {
239        writer.for_each_font(entity, |mut font| {
240            *font = overlay_config.text_config.clone();
241        });
242        writer.for_each_color(entity, |mut color| color.0 = overlay_config.text_color);
243    }
244}
245
246fn toggle_display(
247    overlay_config: Res<FpsOverlayConfig>,
248    mut text_node: Single<&mut Node, (With<FpsText>, Without<FrameTimeGraph>)>,
249    mut graph_node: Single<&mut Node, (With<FrameTimeGraph>, Without<FpsText>)>,
250) {
251    if overlay_config.enabled {
252        text_node.display = bevy_ui::Display::DEFAULT;
253    } else {
254        text_node.display = bevy_ui::Display::None;
255    }
256
257    if overlay_config.frame_time_graph_config.enabled {
258        // Scale the frame time graph based on the font size of the overlay
259        let font_size = overlay_config.text_config.font_size;
260        graph_node.width = Val::Px(font_size * FRAME_TIME_GRAPH_WIDTH_SCALE);
261        graph_node.height = Val::Px(font_size * FRAME_TIME_GRAPH_HEIGHT_SCALE);
262
263        graph_node.display = bevy_ui::Display::DEFAULT;
264    } else {
265        graph_node.display = bevy_ui::Display::None;
266    }
267}