add frame_time graph to fps_overlay

This commit is contained in:
IceSentry 2025-05-18 02:30:38 -04:00 committed by Daniel Skates
parent 45ba5b9f03
commit fce9ec389e
5 changed files with 299 additions and 9 deletions

View File

@ -1,7 +1,7 @@
//! Module containing logic for FPS overlay.
use bevy_app::{Plugin, Startup, Update};
use bevy_asset::Handle;
use bevy_asset::{Assets, Handle};
use bevy_color::Color;
use bevy_diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy_ecs::{
@ -12,22 +12,30 @@ use bevy_ecs::{
query::With,
resource::Resource,
schedule::{common_conditions::resource_changed, IntoScheduleConfigs},
system::{Commands, Query, Res},
system::{Commands, Query, Res, ResMut},
};
use bevy_render::view::Visibility;
use bevy_render::{storage::ShaderStorageBuffer, view::Visibility};
use bevy_text::{Font, TextColor, TextFont, TextSpan};
use bevy_time::Time;
use bevy_ui::{
widget::{Text, TextUiWriter},
GlobalZIndex, Node, PositionType,
FlexDirection, GlobalZIndex, MaterialNode, Node, PositionType, Val,
};
use core::time::Duration;
use crate::frame_time_graph::{
FrameTimeGraphConfigUniform, FrameTimeGraphPlugin, FrametimeGraphMaterial,
};
/// [`GlobalZIndex`] used to render the fps overlay.
///
/// We use a number slightly under `i32::MAX` so you can render on top of it if you really need to.
pub const FPS_OVERLAY_ZINDEX: i32 = i32::MAX - 32;
// Used to scale the frame time graph based on the fps text size
const FRAME_TIME_GRAPH_WIDTH_SCALE: f32 = 6.0;
const FRAME_TIME_GRAPH_HEIGHT_SCALE: f32 = 2.0;
/// A plugin that adds an FPS overlay to the Bevy application.
///
/// This plugin will add the [`FrameTimeDiagnosticsPlugin`] if it wasn't added before.
@ -47,12 +55,18 @@ impl Plugin for FpsOverlayPlugin {
if !app.is_plugin_added::<FrameTimeDiagnosticsPlugin>() {
app.add_plugins(FrameTimeDiagnosticsPlugin::default());
}
if !app.is_plugin_added::<FrameTimeGraphPlugin>() {
app.add_plugins(FrameTimeGraphPlugin);
}
app.insert_resource(self.config.clone())
.add_systems(Startup, setup)
.add_systems(
Update,
(
(customize_text, toggle_display).run_if(resource_changed::<FpsOverlayConfig>),
(toggle_display, customize_overlay)
.run_if(resource_changed::<FpsOverlayConfig>),
update_text,
),
);
@ -72,6 +86,8 @@ pub struct FpsOverlayConfig {
///
/// Defaults to once every 100 ms.
pub refresh_interval: Duration,
/// Configuration of the frame time graph
pub frame_time_graph_config: FrameTimeGraphConfig,
}
impl Default for FpsOverlayConfig {
@ -85,6 +101,43 @@ impl Default for FpsOverlayConfig {
text_color: Color::WHITE,
enabled: true,
refresh_interval: Duration::from_millis(100),
// TODO set this to display refresh rate if possible
frame_time_graph_config: FrameTimeGraphConfig::target_fps(60.0),
}
}
}
/// Configuration of the frame time graph
#[derive(Clone, Copy)]
pub struct FrameTimeGraphConfig {
/// Is the graph visible
pub enabled: bool,
/// The minimum acceptable FPS
///
/// Anything bellow this will show a red bar
pub min_fps: f32,
/// The target FPS
///
/// Anything above this will show a green bar
pub target_fps: f32,
}
impl FrameTimeGraphConfig {
/// Constructs a default config for a given target fps
pub fn target_fps(target_fps: f32) -> Self {
Self {
target_fps,
..Self::default()
}
}
}
impl Default for FrameTimeGraphConfig {
fn default() -> Self {
Self {
enabled: true,
min_fps: 30.0,
target_fps: 60.0,
}
}
}
@ -92,12 +145,21 @@ impl Default for FpsOverlayConfig {
#[derive(Component)]
struct FpsText;
fn setup(mut commands: Commands, overlay_config: Res<FpsOverlayConfig>) {
#[derive(Component)]
struct FrameTimeGraph;
fn setup(
mut commands: Commands,
overlay_config: Res<FpsOverlayConfig>,
mut frame_time_graph_materials: ResMut<Assets<FrametimeGraphMaterial>>,
mut buffers: ResMut<Assets<ShaderStorageBuffer>>,
) {
commands
.spawn((
Node {
// We need to make sure the overlay doesn't affect the position of other UI nodes
position_type: PositionType::Absolute,
flex_direction: FlexDirection::Column,
..Default::default()
},
// Render overlay on top of everything
@ -111,6 +173,29 @@ fn setup(mut commands: Commands, overlay_config: Res<FpsOverlayConfig>) {
FpsText,
))
.with_child((TextSpan::default(), overlay_config.text_config.clone()));
let font_size = overlay_config.text_config.font_size;
p.spawn((
Node {
width: Val::Px(font_size * FRAME_TIME_GRAPH_WIDTH_SCALE),
height: Val::Px(font_size * FRAME_TIME_GRAPH_HEIGHT_SCALE),
display: if overlay_config.frame_time_graph_config.enabled {
bevy_ui::Display::DEFAULT
} else {
bevy_ui::Display::None
},
..Default::default()
},
MaterialNode::from(frame_time_graph_materials.add(FrametimeGraphMaterial {
values: buffers.add(ShaderStorageBuffer::default()),
config: FrameTimeGraphConfigUniform::new(
overlay_config.frame_time_graph_config.target_fps,
overlay_config.frame_time_graph_config.min_fps,
true,
),
})),
FrameTimeGraph,
));
});
}
@ -135,7 +220,7 @@ fn update_text(
}
}
fn customize_text(
fn customize_overlay(
overlay_config: Res<FpsOverlayConfig>,
query: Query<Entity, With<FpsText>>,
mut writer: TextUiWriter,
@ -151,6 +236,7 @@ fn customize_text(
fn toggle_display(
overlay_config: Res<FpsOverlayConfig>,
mut query: Query<&mut Visibility, With<FpsText>>,
mut graph_style: Query<&mut Node, With<FrameTimeGraph>>,
) {
for mut visibility in &mut query {
visibility.set_if_neq(match overlay_config.enabled {
@ -158,4 +244,17 @@ fn toggle_display(
false => Visibility::Hidden,
});
}
if let Ok(mut graph_style) = graph_style.single_mut() {
if overlay_config.frame_time_graph_config.enabled {
// Scale the frame time graph based on the font size of the overlay
let font_size = overlay_config.text_config.font_size;
graph_style.width = Val::Px(font_size * FRAME_TIME_GRAPH_WIDTH_SCALE);
graph_style.height = Val::Px(font_size * FRAME_TIME_GRAPH_HEIGHT_SCALE);
graph_style.display = bevy_ui::Display::DEFAULT;
} else {
graph_style.display = bevy_ui::Display::None;
}
}
}

View File

@ -0,0 +1,68 @@
#import bevy_ui::ui_vertex_output::UiVertexOutput
@group(1) @binding(0) var<storage> values: array<f32>;
struct Config {
dt_min: f32,
dt_max: f32,
dt_min_log2: f32,
dt_max_log2: f32,
proportional_width: u32,
}
@group(1) @binding(1) var<uniform> config: Config;
const RED: vec4<f32> = vec4(1.0, 0.0, 0.0, 1.0);
const GREEN: vec4<f32> = vec4(0.0, 1.0, 0.0, 1.0);
// Gets a color based on the delta time
// TODO use customizable gradient
fn color_from_dt(dt: f32) -> vec4<f32> {
return mix(GREEN, RED, dt / config.dt_max);
}
// Draw an SDF square
fn sdf_square(pos: vec2<f32>, half_size: vec2<f32>, offset: vec2<f32>) -> f32 {
let p = pos - offset;
let dist = abs(p) - half_size;
let outside_dist = length(max(dist, vec2<f32>(0.0, 0.0)));
let inside_dist = min(max(dist.x, dist.y), 0.0);
return outside_dist + inside_dist;
}
@fragment
fn fragment(in: UiVertexOutput) -> @location(0) vec4<f32> {
let dt_min = config.dt_min;
let dt_max = config.dt_max;
let dt_min_log2 = config.dt_min_log2;
let dt_max_log2 = config.dt_max_log2;
// The general algorithm is highly inspired by
// <https://asawicki.info/news_1758_an_idea_for_visualization_of_frame_times>
let len = arrayLength(&values);
var graph_width = 0.0;
for (var i = 0u; i <= len; i += 1u) {
let dt = values[len - i];
var frame_width: f32;
if config.proportional_width == 1u {
frame_width = (dt / dt_min) / f32(len);
} else {
frame_width = 0.015;
}
let frame_height_factor = (log2(dt) - dt_min_log2) / (dt_max_log2 - dt_min_log2);
let frame_height_factor_norm = min(max(0.0, frame_height_factor), 1.0);
let frame_height = mix(0.0, 1.0, frame_height_factor_norm);
let size = vec2(frame_width, frame_height) / 2.0;
let offset = vec2(1.0 - graph_width - size.x, 1. - size.y);
if (sdf_square(in.uv, size, offset) < 0.0) {
return color_from_dt(dt);
}
graph_width += frame_width;
}
return vec4(0.0, 0.0, 0.0, 0.5);
}

View File

@ -0,0 +1,111 @@
//! Module containing logic for the frame time graph
use bevy_app::{Plugin, Update};
use bevy_asset::{load_internal_asset, weak_handle, Asset, Assets, Handle};
use bevy_diagnostic::{DiagnosticsStore, FrameTimeDiagnosticsPlugin};
use bevy_ecs::system::{Res, ResMut};
use bevy_reflect::TypePath;
use bevy_render::{
render_resource::{AsBindGroup, Shader, ShaderRef, ShaderType},
storage::ShaderStorageBuffer,
};
use bevy_ui::{UiMaterial, UiMaterialPlugin};
use crate::fps_overlay::FpsOverlayConfig;
const FRAME_TIME_GRAPH_SHADER_HANDLE: Handle<Shader> =
weak_handle!("4e38163a-5782-47a5-af52-d9161472ab59");
/// Plugin that sets up everything to render the frame time graph material
pub struct FrameTimeGraphPlugin;
impl Plugin for FrameTimeGraphPlugin {
fn build(&self, app: &mut bevy_app::App) {
load_internal_asset!(
app,
FRAME_TIME_GRAPH_SHADER_HANDLE,
"frame_time_graph.wgsl",
Shader::from_wgsl
);
// TODO: Use plugin dependencies, see https://github.com/bevyengine/bevy/issues/69
if !app.is_plugin_added::<FrameTimeDiagnosticsPlugin>() {
panic!("Requires FrameTimeDiagnosticsPlugin");
// app.add_plugins(FrameTimeDiagnosticsPlugin);
}
app.add_plugins(UiMaterialPlugin::<FrametimeGraphMaterial>::default())
.add_systems(Update, update_frame_time_values);
}
}
/// The config values sent to the frame time graph shader
#[derive(Debug, Clone, Copy, ShaderType)]
pub struct FrameTimeGraphConfigUniform {
// minimum expected delta time
dt_min: f32,
// maximum expected delta time
dt_max: f32,
dt_min_log2: f32,
dt_max_log2: f32,
// controls whether or not the bars width are proportional to their delta time
proportional_width: u32,
}
impl FrameTimeGraphConfigUniform {
/// `proportional_width`: controls whether or not the bars width are proportional to their delta time
pub fn new(target_fps: f32, min_fps: f32, proportional_width: bool) -> Self {
// we want an upper limit that is above the target otherwise the bars will disappear
let dt_min = 1. / (target_fps * 1.2);
let dt_max = 1. / min_fps;
Self {
dt_min,
dt_max,
dt_min_log2: dt_min.log2(),
dt_max_log2: dt_max.log2(),
proportional_width: u32::from(proportional_width),
}
}
}
/// The material used to render the frame time graph ui node
#[derive(AsBindGroup, Asset, TypePath, Debug, Clone)]
pub struct FrametimeGraphMaterial {
/// The history of the previous frame times value.
///
/// This should be updated every frame to match the frame time history from the [`DiagnosticsStore`]
#[storage(0, read_only)]
pub values: Handle<ShaderStorageBuffer>, // Vec<f32>,
/// The configuration values used by the shader to control how the graph is rendered
#[uniform(1)]
pub config: FrameTimeGraphConfigUniform,
}
impl UiMaterial for FrametimeGraphMaterial {
fn fragment_shader() -> ShaderRef {
FRAME_TIME_GRAPH_SHADER_HANDLE.into()
}
}
/// A system that updates the frame time values sent to the frame time graph
fn update_frame_time_values(
mut frame_time_graph_materials: ResMut<Assets<FrametimeGraphMaterial>>,
mut buffers: ResMut<Assets<ShaderStorageBuffer>>,
diagnostics_store: Res<DiagnosticsStore>,
config: Option<Res<FpsOverlayConfig>>,
) {
if !config.map_or(true, |c| c.frame_time_graph_config.enabled) {
return;
}
let Some(frame_time) = diagnostics_store.get(&FrameTimeDiagnosticsPlugin::FRAME_TIME) else {
return;
};
let frame_times = frame_time
.values()
// convert to millis
.map(|x| *x as f32 / 1000.0)
.collect::<Vec<_>>();
for (_, material) in frame_time_graph_materials.iter_mut() {
let buffer = buffers.get_mut(&material.values).unwrap();
buffer.set_data(frame_times.clone().as_slice());
}
}

View File

@ -14,6 +14,7 @@ use bevy_app::prelude::*;
pub mod ci_testing;
pub mod fps_overlay;
pub mod frame_time_graph;
pub mod picking_debug;

View File

@ -1,7 +1,7 @@
//! Showcase how to use and configure FPS overlay.
use bevy::{
dev_tools::fps_overlay::{FpsOverlayConfig, FpsOverlayPlugin},
dev_tools::fps_overlay::{FpsOverlayConfig, FpsOverlayPlugin, FrameTimeGraphConfig},
prelude::*,
text::FontSmoothing,
};
@ -33,6 +33,13 @@ fn main() {
// We can also set the refresh interval for the FPS counter
refresh_interval: core::time::Duration::from_millis(100),
enabled: true,
frame_time_graph_config: FrameTimeGraphConfig {
enabled: true,
// The minimum acceptable fps
min_fps: 30.0,
// The target fps
target_fps: 144.0,
},
},
},
))
@ -52,7 +59,8 @@ fn setup(mut commands: Commands) {
"Press 1 to toggle the overlay color.\n",
"Press 2 to decrease the overlay size.\n",
"Press 3 to increase the overlay size.\n",
"Press 4 to toggle the overlay visibility."
"Press 4 to toggle the text visibility.\n",
"Press 5 to toggle the frame time graph."
)),
Node {
position_type: PositionType::Absolute,
@ -81,4 +89,7 @@ fn customize_config(input: Res<ButtonInput<KeyCode>>, mut overlay: ResMut<FpsOve
if input.just_pressed(KeyCode::Digit4) {
overlay.enabled = !overlay.enabled;
}
if input.just_released(KeyCode::Digit5) {
overlay.frame_time_graph_config.enabled = !overlay.frame_time_graph_config.enabled;
}
}