use std::time::Instant;
use crate::ecs::Resource;
use crate::ui::atlas::Atlas;
use crate::ui::color::{Alpha, Color, linear_rgba};
use crate::ui::font::{self, Face};
use crate::ui::renderer2d::{NO_ICON, QuadInstance};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Lane {
#[default]
Cpu,
Gpu,
}
impl Lane {
pub const ALL: [Lane; 2] = [Lane::Cpu, Lane::Gpu];
pub fn label(self) -> &'static str {
match self {
Lane::Cpu => "CPU",
Lane::Gpu => "GPU",
}
}
pub fn palette(self) -> &'static [Color] {
match self {
Lane::Cpu => &CPU_COLORS,
Lane::Gpu => &GPU_COLORS,
}
}
pub fn color(self, index: usize) -> Color {
let palette = self.palette();
palette[index % palette.len()]
}
}
const CPU_COLORS: [Color; 5] = [
Color::srgb(0.90, 0.55, 0.20),
Color::srgb(0.85, 0.35, 0.25),
Color::srgb(0.95, 0.72, 0.30),
Color::srgb(0.75, 0.28, 0.35),
Color::srgb(0.98, 0.85, 0.45),
];
const GPU_COLORS: [Color; 5] = [
Color::srgb(0.25, 0.65, 0.85),
Color::srgb(0.30, 0.80, 0.70),
Color::srgb(0.45, 0.50, 0.90),
Color::srgb(0.20, 0.45, 0.70),
Color::srgb(0.55, 0.75, 0.95),
];
#[derive(Clone, Debug, PartialEq)]
pub struct Span {
pub name: String,
pub lane: Lane,
pub start: f64,
pub end: f64,
}
impl Span {
pub fn length(&self) -> f64 {
(self.end - self.start).max(0.0)
}
}
#[derive(Clone, Default, Debug)]
struct Frame {
spans: Vec<Span>,
total: f64,
}
#[derive(Clone, Debug)]
struct Stat {
name: String,
worst: f64,
slot: usize,
}
struct Band {
lane: Lane,
frames: Vec<Frame>,
cursor: usize,
stats: Vec<Stat>,
scale: f64,
}
impl Band {
fn new(lane: Lane, frames: usize) -> Self {
Self {
lane,
frames: vec![Frame::default(); frames.max(1)],
cursor: 0,
stats: Vec::new(),
scale: FRAME_BUDGET,
}
}
fn push(&mut self, spans: Vec<Span>) {
let total = spans.iter().map(|span| span.end).fold(0.0f64, f64::max);
let at = self.cursor;
self.frames[at] = Frame { spans, total };
self.cursor = (self.cursor + 1) % self.frames.len();
let peak = self.frames.iter().map(|frame| frame.total).fold(0.0, f64::max);
self.scale = (peak * 1.15).max(FRAME_BUDGET);
self.rebuild_stats();
}
fn current(&self) -> &Frame {
&self.frames[(self.cursor + self.frames.len() - 1) % self.frames.len()]
}
fn total(&self) -> f64 {
self.current().total
}
fn rebuild_stats(&mut self) {
let mut stats: Vec<Stat> = Vec::new();
for frame in &self.frames {
for span in &frame.spans {
match stats.iter_mut().find(|stat| stat.name == span.name) {
Some(stat) => stat.worst = stat.worst.max(span.length()),
None => stats.push(Stat {
name: span.name.clone(),
worst: span.length(),
slot: 0,
}),
}
}
}
stats.sort_by(|a, b| b.worst.total_cmp(&a.worst));
for (index, stat) in stats.iter_mut().enumerate() {
stat.slot = index;
}
self.stats = stats;
}
fn slot(&self, name: &str) -> usize {
self.stats
.iter()
.find(|stat| stat.name == name)
.map(|stat| stat.slot)
.unwrap_or(0)
}
fn color(&self, name: &str) -> Color {
self.lane.color(self.slot(name))
}
}
const FRAME_BUDGET: f64 = 1000.0 / 60.0;
const HISTORY: usize = 240;
pub struct Timing<'a> {
profiler: &'a mut Profiler,
name: &'static str,
lane: Lane,
start: Instant,
}
impl Drop for Timing<'_> {
fn drop(&mut self) {
let started = self.profiler.frame_start;
let from = self.start.duration_since(started).as_secs_f64() * 1000.0;
let to = self.start.elapsed().as_secs_f64() * 1000.0 + from;
self.profiler.record(Span {
name: self.name.to_string(),
lane: self.lane,
start: from,
end: to,
});
}
}
#[derive(Resource)]
pub struct Profiler {
pub on: bool,
bands: Vec<Band>,
pending: Vec<Span>,
frame_start: Instant,
}
impl Default for Profiler {
fn default() -> Self {
Self::new()
}
}
impl Profiler {
pub fn new() -> Self {
Self {
on: false,
bands: Lane::ALL
.into_iter()
.map(|lane| Band::new(lane, HISTORY))
.collect(),
pending: Vec::new(),
frame_start: Instant::now(),
}
}
pub fn begin_frame(&mut self) {
self.frame_start = Instant::now();
self.pending.clear();
}
pub fn end_frame(&mut self) {
if !self.on {
self.pending.clear();
return;
}
let spans = std::mem::take(&mut self.pending);
for band in &mut self.bands {
let lane = band.lane;
band.push(
spans
.iter()
.filter(|span| span.lane == lane)
.cloned()
.collect(),
);
}
}
pub fn since_frame_start(&self, at: Instant) -> f64 {
at.saturating_duration_since(self.frame_start).as_secs_f64() * 1000.0
}
pub fn time(&mut self, name: &'static str, lane: Lane) -> Timing<'_> {
let start = Instant::now();
Timing {
profiler: self,
name,
lane,
start,
}
}
pub fn record(&mut self, span: Span) {
if self.on {
self.pending.push(span);
}
}
pub fn totals(&self) -> Vec<(Lane, f64)> {
self.bands
.iter()
.map(|band| (band.lane, band.total()))
.collect()
}
fn band(&self, lane: Lane) -> &Band {
self.bands
.iter()
.find(|band| band.lane == lane)
.unwrap_or(&self.bands[0])
}
}
const MARGIN: f32 = 16.0;
const WIDTH: f32 = 420.0;
const LEGEND: f32 = 210.0;
const BAND_HEIGHT: f32 = 88.0;
const BAND_GAP: f32 = 22.0;
const PADDING: f32 = 8.0;
const COLUMN: f32 = 2.0;
const COLUMN_GAP: f32 = 0.0;
const ROW: f32 = 13.0;
const SWATCH: f32 = 8.0;
const LABEL_SIZE: f32 = 1.5;
pub fn draw(
profiler: &Profiler,
quads: &mut Vec<QuadInstance>,
atlas: &mut Atlas,
face: &Face,
width: f32,
_height: f32,
) {
if !profiler.on {
return;
}
let panel_width = WIDTH + LEGEND + PADDING * 2.0;
let panel_height =
(BAND_HEIGHT + BAND_GAP) * Lane::ALL.len() as f32 - BAND_GAP + PADDING * 2.0;
let x = width - panel_width - MARGIN;
let y = MARGIN;
quads.push(flat(
x,
y,
panel_width,
panel_height,
Color::srgba(0.04, 0.05, 0.08, 0.88),
));
for (index, lane) in Lane::ALL.into_iter().enumerate() {
let top = y + PADDING + index as f32 * (BAND_HEIGHT + BAND_GAP);
draw_band(
profiler.band(lane),
quads,
atlas,
face,
x + PADDING,
top,
WIDTH,
BAND_HEIGHT,
);
}
}
fn draw_band(
band: &Band,
quads: &mut Vec<QuadInstance>,
atlas: &mut Atlas,
face: &Face,
x: f32,
y: f32,
width: f32,
height: f32,
) {
font::push_text(
quads,
atlas,
face,
&format!("{} {:.2} MS", band.lane.label(), band.total()),
x,
y - font::text_height(LABEL_SIZE) - 3.0,
LABEL_SIZE,
band.lane.color(0),
);
quads.push(flat(x, y, width, height, Color::srgba(0.0, 0.0, 0.0, 0.35)));
let budget = 1.0 - (FRAME_BUDGET / band.scale).clamp(0.0, 1.0) as f32;
quads.push(flat(
x,
y + height * budget,
width,
1.0,
Color::srgba(0.55, 0.58, 0.66, 0.55),
));
let stride = COLUMN + COLUMN_GAP;
let columns = ((width / stride).floor().max(1.0) as usize).min(band.frames.len());
for column in 0..columns {
let age = columns - 1 - column;
let at = (band.cursor + band.frames.len() - 1 - age) % band.frames.len();
let frame = &band.frames[at];
let cx = x + column as f32 * stride;
for span in &frame.spans {
let from = (span.start / band.scale).clamp(0.0, 1.0) as f32;
let to = (span.end / band.scale).clamp(0.0, 1.0) as f32;
let top = y + height * (1.0 - to);
let bottom = y + height * (1.0 - from);
if bottom - top < 0.5 {
continue;
}
quads.push(flat(
cx,
top,
COLUMN,
bottom - top,
band.color(&span.name),
));
}
}
let legend_x = x + width + 18.0;
let rows = (height / ROW).floor().max(1.0) as usize;
let newest = x + columns.saturating_sub(1) as f32 * stride + COLUMN;
for span in &band.current().spans {
let slot = band.slot(&span.name);
if slot >= rows {
continue;
}
let row_y = y + slot as f32 * ROW;
let color = band.color(&span.name);
let middle = ((span.start + span.end) * 0.5 / band.scale).clamp(0.0, 1.0) as f32;
let from_y = y + height * (1.0 - middle);
let to_y = row_y + SWATCH * 0.5;
let elbow = newest + (legend_x - newest) * 0.5;
let faint = color.with_alpha(0.5);
quads.push(flat(newest, from_y, elbow - newest, 1.0, faint));
quads.push(flat(
elbow,
from_y.min(to_y),
1.0,
(to_y - from_y).abs().max(1.0),
faint,
));
quads.push(flat(elbow, to_y, legend_x - elbow, 1.0, faint));
quads.push(flat(legend_x, row_y + 1.0, SWATCH, SWATCH, color));
font::push_text(
quads,
atlas,
face,
&format!("{:.2} {}", span.length(), span.name),
legend_x + SWATCH + 5.0,
row_y,
LABEL_SIZE,
color,
);
}
}
fn flat(x: f32, y: f32, width: f32, height: f32, color: Color) -> QuadInstance {
QuadInstance {
pos: [x, y],
size: [width.max(0.0), height.max(0.0)],
color: linear_rgba(color),
uv: NO_ICON,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn span(name: &str, lane: Lane, start: f64, end: f64) -> Span {
Span {
name: name.to_string(),
lane,
start,
end,
}
}
fn running() -> Profiler {
let mut profiler = Profiler::new();
profiler.on = true;
profiler
}
#[test]
fn a_profiler_that_is_off_records_nothing() {
let mut profiler = Profiler::new();
profiler.begin_frame();
profiler.record(span("draw", Lane::Cpu, 0.0, 4.0));
profiler.end_frame();
assert_eq!(profiler.totals(), vec![(Lane::Cpu, 0.0), (Lane::Gpu, 0.0)]);
}
#[test]
fn a_frame_is_filed_when_it_ends() {
let mut profiler = running();
profiler.begin_frame();
profiler.record(span("update", Lane::Cpu, 0.0, 3.0));
profiler.record(span("draw", Lane::Gpu, 0.0, 7.0));
profiler.end_frame();
assert_eq!(profiler.totals(), vec![(Lane::Cpu, 3.0), (Lane::Gpu, 7.0)]);
}
#[test]
fn the_lanes_are_kept_apart_and_scaled_apart() {
let mut profiler = running();
profiler.begin_frame();
profiler.record(span("update", Lane::Cpu, 0.0, 1.0));
profiler.record(span("draw", Lane::Gpu, 0.0, 90.0));
profiler.end_frame();
let cpu = profiler.band(Lane::Cpu);
let gpu = profiler.band(Lane::Gpu);
assert_eq!(cpu.current().spans.len(), 1, "one span each");
assert_eq!(gpu.current().spans.len(), 1);
assert!(
gpu.scale > cpu.scale * 4.0,
"a ninety millisecond GPU frame must not set the CPU axis: \
cpu {} against gpu {}",
cpu.scale,
gpu.scale,
);
}
#[test]
fn the_lanes_are_coloured_apart() {
for slot in 0..8 {
assert_ne!(
Lane::Cpu.color(slot),
Lane::Gpu.color(slot),
"slot {slot} is the same colour on both sides",
);
}
for cpu in CPU_COLORS {
assert!(
!GPU_COLORS.contains(&cpu),
"{cpu:?} is in both families",
);
}
}
#[test]
fn the_axis_never_hides_the_frame_budget() {
let mut profiler = running();
profiler.begin_frame();
profiler.record(span("tiny", Lane::Cpu, 0.0, 0.01));
profiler.end_frame();
assert!(profiler.band(Lane::Cpu).scale >= FRAME_BUDGET);
}
#[test]
fn a_spike_raises_the_axis() {
let mut profiler = running();
profiler.begin_frame();
profiler.record(span("stall", Lane::Cpu, 0.0, 100.0));
profiler.end_frame();
assert!(profiler.band(Lane::Cpu).scale > 100.0);
}
#[test]
fn the_legend_orders_by_the_worst_it_has_seen() {
let mut profiler = running();
profiler.begin_frame();
profiler.record(span("fast", Lane::Cpu, 0.0, 1.0));
profiler.record(span("slow", Lane::Cpu, 1.0, 9.0));
profiler.end_frame();
profiler.begin_frame();
profiler.record(span("fast", Lane::Cpu, 0.0, 2.0));
profiler.record(span("slow", Lane::Cpu, 2.0, 2.5));
profiler.end_frame();
let band = profiler.band(Lane::Cpu);
assert_eq!(band.slot("slow"), 0);
assert_eq!(band.slot("fast"), 1);
}
#[test]
fn a_span_keeps_its_colour() {
let mut profiler = running();
for _ in 0..3 {
profiler.begin_frame();
profiler.record(span("draw", Lane::Cpu, 0.0, 5.0));
profiler.record(span("update", Lane::Cpu, 5.0, 6.0));
profiler.end_frame();
}
let band = profiler.band(Lane::Cpu);
assert_eq!(band.color("draw"), Lane::Cpu.color(0));
assert_eq!(band.color("update"), Lane::Cpu.color(1));
}
#[test]
fn the_history_wraps() {
let mut profiler = running();
for i in 0..HISTORY + 10 {
profiler.begin_frame();
profiler.record(span("draw", Lane::Cpu, 0.0, i as f64 % 5.0 + 1.0));
profiler.end_frame();
}
assert_eq!(profiler.band(Lane::Cpu).frames.len(), HISTORY);
}
#[test]
fn a_timing_files_itself_when_it_is_dropped() {
let mut profiler = running();
profiler.begin_frame();
{
let _timing = profiler.time("work", Lane::Cpu);
std::thread::sleep(std::time::Duration::from_millis(2));
}
profiler.end_frame();
let band = profiler.band(Lane::Cpu);
assert_eq!(band.current().spans.len(), 1);
assert!(
band.total() >= 1.5,
"it should have measured the sleep: {}",
band.total(),
);
}
#[test]
fn nothing_is_drawn_outside_the_chart() {
let mut profiler = running();
for _ in 0..20 {
profiler.begin_frame();
profiler.record(span("huge", Lane::Cpu, 0.0, 4000.0));
profiler.record(span("draw", Lane::Gpu, 0.0, 8.0));
profiler.end_frame();
}
let mut quads = Vec::new();
let mut atlas = Atlas::new();
let face = Face::default();
draw(&profiler, &mut quads, &mut atlas, &face, 1280.0, 720.0);
assert!(!quads.is_empty(), "it should have drawn something");
for quad in &quads {
assert!(
quad.pos[0] >= 0.0 && quad.pos[1] >= 0.0,
"a quad started off the frame: {:?}",
quad.pos,
);
assert!(
quad.pos[0] + quad.size[0] <= 1280.0 + 1.0,
"a quad ran off the right: {:?} {:?}",
quad.pos,
quad.size,
);
}
}
#[test]
fn a_profiler_that_is_off_draws_nothing() {
let profiler = Profiler::new();
let mut quads = Vec::new();
let mut atlas = Atlas::new();
draw(
&profiler,
&mut quads,
&mut atlas,
&Face::default(),
1280.0,
720.0,
);
assert!(quads.is_empty());
}
}