use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use std::time::Duration;
use web_time::Instant;
use crate::{
app::App,
ecs::{
plugin::Plugin,
system::{Res, ResMut, SystemOrderingExt},
},
prelude::{ColorTarget, CurrentFrame, LazyResourcePlugin, Pass, SystemStage},
wgpu::backend::WGPUBackend,
};
const HISTORY_LEN: usize = 120;
struct Samples {
history: VecDeque<Duration>,
}
impl Samples {
fn new() -> Self {
Self {
history: VecDeque::with_capacity(HISTORY_LEN),
}
}
fn push(&mut self, duration: Duration) {
self.history.push_back(duration);
if self.history.len() > HISTORY_LEN {
self.history.pop_front();
}
}
fn last(&self) -> Duration {
self.history.back().copied().unwrap_or_default()
}
fn average(&self) -> Duration {
if self.history.is_empty() {
return Duration::ZERO;
}
self.history.iter().sum::<Duration>() / self.history.len() as u32
}
}
struct ProfilerInner {
frames: Samples,
last_tick: Instant,
sections: HashMap<&'static str, Samples>,
}
pub struct Profiler {
inner: Mutex<ProfilerInner>,
}
impl Profiler {
fn new() -> Self {
Self {
inner: Mutex::new(ProfilerInner {
frames: Samples::new(),
last_tick: Instant::now(),
sections: HashMap::new(),
}),
}
}
fn tick(&self) {
let mut inner = self.inner.lock().unwrap();
let now = Instant::now();
let elapsed = now.duration_since(inner.last_tick);
inner.last_tick = now;
inner.frames.push(elapsed);
}
pub fn frame_time(&self) -> Duration {
self.inner.lock().unwrap().frames.last()
}
pub fn average_frame_time(&self) -> Duration {
self.inner.lock().unwrap().frames.average()
}
pub fn fps(&self) -> f32 {
let seconds = self.average_frame_time().as_secs_f32();
if seconds <= 0.0 { 0.0 } else { 1.0 / seconds }
}
pub fn section(&self, name: &'static str) -> SectionGuard<'_> {
SectionGuard { profiler: self, name, start: Instant::now() }
}
pub fn section_time(&self, name: &str) -> Option<Duration> {
self.inner.lock().unwrap().sections.get(name).map(Samples::last)
}
pub fn average_section_time(&self, name: &str) -> Option<Duration> {
self.inner.lock().unwrap().sections.get(name).map(Samples::average)
}
pub fn section_names(&self) -> Vec<&'static str> {
self.inner.lock().unwrap().sections.keys().copied().collect()
}
fn record_section(&self, name: &'static str, duration: Duration) {
self.inner
.lock()
.unwrap()
.sections
.entry(name)
.or_insert_with(Samples::new)
.push(duration);
}
}
#[must_use = "a Profiler::section guard does nothing until it drops — assign it to a named \
binding (`let _span = ...`), not `_`, which drops it immediately"]
pub struct SectionGuard<'a> {
profiler: &'a Profiler,
name: &'static str,
start: Instant,
}
impl Drop for SectionGuard<'_> {
fn drop(&mut self) {
self.profiler.record_section(self.name, self.start.elapsed());
}
}
struct EguiState {
ctx: egui::Context,
renderer: egui_wgpu::Renderer,
}
#[cfg(target_arch = "wasm32")]
struct WasmSendSync<T>(T);
#[cfg(target_arch = "wasm32")]
unsafe impl<T> Send for WasmSendSync<T> {}
#[cfg(target_arch = "wasm32")]
unsafe impl<T> Sync for WasmSendSync<T> {}
#[cfg(target_arch = "wasm32")]
impl<T> std::ops::Deref for WasmSendSync<T> {
type Target = T;
fn deref(&self) -> &T {
&self.0
}
}
#[cfg(target_arch = "wasm32")]
impl<T> std::ops::DerefMut for WasmSendSync<T> {
fn deref_mut(&mut self) -> &mut T {
&mut self.0
}
}
#[cfg(not(target_arch = "wasm32"))]
type EguiStateResource = EguiState;
#[cfg(target_arch = "wasm32")]
type EguiStateResource = WasmSendSync<EguiState>;
impl crate::assets::singleton_asset::LazyResource<WGPUBackend> for EguiStateResource {
type Deps<'a> = ();
fn construct<'a>(backend: &WGPUBackend, _deps: &()) -> Option<Self> {
let renderer = egui_wgpu::Renderer::new(
&backend.device,
backend.config.format,
egui_wgpu::RendererOptions::default(),
);
let state = EguiState { ctx: egui::Context::default(), renderer };
#[cfg(target_arch = "wasm32")]
let state = WasmSendSync(state);
Some(state)
}
}
pub struct ProfilerPlugin;
impl Plugin for ProfilerPlugin {
fn build(&self, app: &mut App) {
app.add_resource(Profiler::new())
.add_plugin(LazyResourcePlugin::<WGPUBackend, EguiStateResource>::new())
.add_system(SystemStage::PreUpdate, tick_profiler)
.add_system(
SystemStage::PostRender,
draw_overlay.before(crate::rendering::render_plugin::end_frame::<WGPUBackend>),
);
}
}
fn tick_profiler(profiler: Res<Profiler>) {
profiler.tick();
}
fn draw_overlay(
backend: Option<Res<WGPUBackend>>,
egui_state: Option<ResMut<EguiStateResource>>,
mut frame: ResMut<CurrentFrame<WGPUBackend>>,
profiler: Res<Profiler>,
) {
let (Some(backend), Some(mut egui_state)) = (backend, egui_state) else {
return; };
let Some(mut active) = frame.active() else {
return; };
let screen_size = egui::vec2(backend.config.width as f32, backend.config.height as f32);
let raw_input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen_size)),
..Default::default()
};
let full_output = egui_state.ctx.run_ui(raw_input, |ui| {
let ctx = ui.ctx().clone();
egui::Window::new("Pebble Profiler").show(&ctx, |ui| {
ui.label(format!("FPS: {:.1}", profiler.fps()));
ui.label(format!(
"Frame time: {:.2} ms",
profiler.average_frame_time().as_secs_f64() * 1000.0
));
let mut names = profiler.section_names();
names.sort_unstable();
if !names.is_empty() {
ui.separator();
for name in names {
if let Some(d) = profiler.average_section_time(name) {
ui.label(format!("{name}: {:.2} ms", d.as_secs_f64() * 1000.0));
}
}
}
});
});
let clipped_primitives = egui_state
.ctx
.tessellate(full_output.shapes, full_output.pixels_per_point);
let screen_descriptor = egui_wgpu::ScreenDescriptor {
size_in_pixels: [backend.config.width, backend.config.height],
pixels_per_point: full_output.pixels_per_point,
};
for (id, delta) in &full_output.textures_delta.set {
egui_state.renderer.update_texture(&backend.device, &backend.queue, *id, delta);
}
let mut encoder = backend.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("profiler-overlay-buffer-upload"),
});
egui_state
.renderer
.update_buffers(&backend.device, &backend.queue, &mut encoder, &clipped_primitives, &screen_descriptor);
backend.queue.submit(std::iter::once(encoder.finish()));
{
let pass = active.begin_pass(Pass {
colors: &[ColorTarget::default_load()],
depth: None,
});
let mut pass = pass.forget_lifetime();
egui_state.renderer.render(&mut pass, &clipped_primitives, &screen_descriptor);
}
for id in &full_output.textures_delta.free {
egui_state.renderer.free_texture(id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_fresh_profiler_reports_zero_rather_than_dividing_by_zero() {
let profiler = Profiler::new();
assert_eq!(profiler.frame_time(), Duration::ZERO);
assert_eq!(profiler.average_frame_time(), Duration::ZERO);
assert_eq!(profiler.fps(), 0.0);
assert!(profiler.section_time("never_ran").is_none());
assert!(profiler.average_section_time("never_ran").is_none());
assert!(profiler.section_names().is_empty());
}
#[test]
fn tick_records_frame_time_and_fps_follows_from_it() {
let profiler = Profiler::new();
std::thread::sleep(Duration::from_millis(10));
profiler.tick();
assert!(profiler.frame_time() >= Duration::from_millis(10));
assert!(profiler.fps() > 0.0 && profiler.fps() < 100.0);
}
#[test]
fn a_section_guard_records_on_drop_not_on_creation() {
let profiler = Profiler::new();
assert!(profiler.section_time("work").is_none());
{
let _span = profiler.section("work");
std::thread::sleep(Duration::from_millis(5));
assert!(profiler.section_time("work").is_none());
}
let recorded = profiler.section_time("work").expect("section should be recorded after the guard drops");
assert!(recorded >= Duration::from_millis(5));
}
#[test]
fn sections_nest_without_a_borrow_conflict() {
let profiler = Profiler::new();
{
let _outer = profiler.section("outer");
{
let _inner = profiler.section("inner");
}
}
assert!(profiler.section_time("outer").is_some());
assert!(profiler.section_time("inner").is_some());
}
#[test]
fn reusing_a_section_name_accumulates_history_for_averaging() {
let profiler = Profiler::new();
for _ in 0..3 {
let _span = profiler.section("repeated");
std::thread::sleep(Duration::from_millis(1));
}
assert!(profiler.average_section_time("repeated").is_some());
assert!(profiler.section_names().contains(&"repeated"));
}
}