use concinnity_core::components::StatHud;
use concinnity_core::components::TextLabel;
use concinnity_core::ecs::Ref;
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::ecs::{Access, FrameTime, HudPrefs, PipelineContext, StepResult, System};
use super::rate_window::RateWindow;
const EMIT_INTERVAL_SECS: f32 = 0.5;
fn fps_text(frames: u32, elapsed_secs: f32) -> String {
let fps = if elapsed_secs > 0.0 {
frames as f32 / elapsed_secs
} else {
0.0
};
format!("FPS {fps:.0}")
}
fn gpu_wait_text(micros: u32) -> String {
if micros < 1000 {
format!("GPU WAIT {micros} us")
} else {
format!("GPU WAIT {:.1} ms", micros as f32 / 1000.0)
}
}
fn vram_text(bytes: u64) -> String {
format!("VRAM {} MB", bytes / (1024 * 1024))
}
fn ram_text(rss: Option<u64>, budget_mib: Option<u64>) -> String {
let Some(rss) = rss else {
return String::new();
};
let rss_mib = rss / (1024 * 1024);
match budget_mib {
Some(budget) => format!("RAM {rss_mib} / {budget} MB"),
None => format!("RAM {rss_mib} MB"),
}
}
fn ev_text(ev: Option<f32>) -> String {
match ev {
Some(v) if v.is_finite() => format!("EV {v:+.2}"),
_ => String::new(),
}
}
fn edr_text(max_edr: Option<f32>) -> String {
match max_edr {
Some(v) if v.is_finite() && v > 0.0 => format!("EDR x{v:.1}"),
_ => String::new(),
}
}
#[derive(Debug)]
pub(crate) struct StatHudSystem {
fps_label: Option<AssetId>,
gpu_wait_label: Option<AssetId>,
vram_label: Option<AssetId>,
ram_label: Option<AssetId>,
ev_label: Option<AssetId>,
edr_label: Option<AssetId>,
window: RateWindow,
gpu_wait_us: u32,
vram_bytes: u64,
ram_bytes: Option<u64>,
ev: Option<f32>,
max_edr: Option<f32>,
}
impl StatHudSystem {
pub(crate) fn new(config: StatHud) -> Self {
Self {
fps_label: config.fps_label.map(Ref::id),
gpu_wait_label: config.gpu_wait_label.map(Ref::id),
vram_label: config.vram_label.map(Ref::id),
ram_label: config.ram_label.map(Ref::id),
ev_label: config.ev_label.map(Ref::id),
edr_label: config.edr_label.map(Ref::id),
window: RateWindow::default(),
gpu_wait_us: 0,
vram_bytes: 0,
ram_bytes: None,
ev: None,
max_edr: None,
}
}
fn write_chip(ctx: &mut PipelineContext, id: Option<AssetId>, text: String) {
crate::ecs::by_asset_id::update::<TextLabel>(ctx, id, |l| l.content = text);
}
}
impl System for StatHudSystem {
fn access(&self) -> Access {
Access::new()
.writes_components(crate::component_mask![TextLabel])
.reads_resources(crate::resource_mask![
concinnity_core::ecs::EntityById,
HudPrefs,
FrameTime,
crate::app::budget::MemoryBudget,
])
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let (show_fps, show_vram) = ctx
.resource::<HudPrefs>()
.map_or((true, true), |p| (p.show_fps, p.show_vram));
self.gpu_wait_us = ctx.profile.render.gpu_wait_us;
self.vram_bytes = ctx.profile.render.vram_bytes;
self.ev = ctx.profile.render.auto_exposure_ev;
self.max_edr = ctx.profile.render.max_edr;
let dt = ctx.resource::<FrameTime>().copied().unwrap_or_default().dt;
if let Some((frames, elapsed)) = self.window.tick(dt, EMIT_INTERVAL_SECS) {
self.ram_bytes = crate::app::sysmem::process_resident_bytes();
let budget_mib = ctx
.resource::<crate::app::budget::MemoryBudget>()
.map(|b| b.budget_mib());
Self::write_chip(
ctx,
self.fps_label,
if show_fps {
fps_text(frames, elapsed)
} else {
String::new()
},
);
Self::write_chip(
ctx,
self.vram_label,
if show_vram {
vram_text(self.vram_bytes)
} else {
String::new()
},
);
Self::write_chip(
ctx,
self.gpu_wait_label,
if show_fps {
gpu_wait_text(self.gpu_wait_us)
} else {
String::new()
},
);
Self::write_chip(ctx, self.ram_label, ram_text(self.ram_bytes, budget_mib));
Self::write_chip(ctx, self.ev_label, ev_text(self.ev));
Self::write_chip(ctx, self.edr_label, edr_text(self.max_edr));
}
StepResult::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::SYSTEMS;
use concinnity_core::ecs::Ref;
use concinnity_core::ecs::World;
#[test]
fn fps_text_averages_frames_over_window() {
assert_eq!(fps_text(60, 1.0), "FPS 60");
assert_eq!(fps_text(75, 0.5), "FPS 150");
}
#[test]
fn fps_text_handles_zero_window() {
assert_eq!(fps_text(0, 0.0), "FPS 0");
}
#[test]
fn vram_text_reports_whole_megabytes() {
assert_eq!(vram_text(0), "VRAM 0 MB");
assert_eq!(vram_text(512 * 1024 * 1024), "VRAM 512 MB");
assert_eq!(vram_text(1024 * 1024 + 1), "VRAM 1 MB");
}
#[test]
fn ram_text_reports_rss_with_budget_when_known() {
assert_eq!(
ram_text(Some(512 * 1024 * 1024), Some(16384)),
"RAM 512 / 16384 MB"
);
}
#[test]
fn ram_text_reports_bare_rss_without_a_budget() {
assert_eq!(ram_text(Some(256 * 1024 * 1024), None), "RAM 256 MB");
}
#[test]
fn ram_text_blanks_when_rss_unavailable() {
assert_eq!(ram_text(None, Some(16384)), "");
assert_eq!(ram_text(None, None), "");
}
#[test]
fn ev_text_formats_signed_value_with_two_decimals() {
assert_eq!(ev_text(Some(1.25)), "EV +1.25");
assert_eq!(ev_text(Some(-0.5)), "EV -0.50");
assert_eq!(ev_text(Some(0.0)), "EV +0.00");
}
#[test]
fn ev_text_blanks_when_auto_exposure_off() {
assert_eq!(ev_text(None), "");
}
#[test]
fn ev_text_blanks_on_non_finite_values() {
assert_eq!(ev_text(Some(f32::NAN)), "");
assert_eq!(ev_text(Some(f32::INFINITY)), "");
}
#[test]
fn edr_text_formats_multiplier_with_one_decimal() {
assert_eq!(edr_text(Some(2.0)), "EDR x2.0");
assert_eq!(edr_text(Some(8.5)), "EDR x8.5");
}
#[test]
fn edr_text_blanks_when_sdr() {
assert_eq!(edr_text(None), "");
}
#[test]
fn edr_text_blanks_on_invalid_values() {
assert_eq!(edr_text(Some(f32::NAN)), "");
assert_eq!(edr_text(Some(f32::INFINITY)), "");
assert_eq!(edr_text(Some(0.0)), "");
assert_eq!(edr_text(Some(-1.0)), "");
}
#[test]
fn stat_hud_component_spawns_internal_system() {
use concinnity_core::components::StatHud;
use concinnity_core::ecs::World;
let mut world = World::new();
world.add_component(StatHud::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["StatHud"]);
}
#[test]
fn no_stat_hud_no_system() {
use concinnity_core::ecs::World;
let mut world = World::new();
world.start(SYSTEMS).unwrap();
assert!(world.systems().is_empty());
}
fn hud_world() -> World {
let mut world = World::new();
world.add_component(StatHud {
fps_label: Some(Ref::new(AssetId(1))),
vram_label: Some(Ref::new(AssetId(2))),
ram_label: Some(Ref::new(AssetId(3))),
gpu_wait_label: Some(Ref::new(AssetId(4))),
..StatHud::default()
});
for id in [1u32, 2, 3, 4] {
world.push_identified(AssetId(id), TextLabel::default());
}
world
}
fn chip(world: &World, id: u32) -> String {
world
.get_by_id::<TextLabel>(AssetId(id))
.map(|l| l.content.clone())
.unwrap_or_default()
}
#[test]
fn emit_window_writes_fps_and_vram_chips() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(FrameTime {
dt: 1.0,
elapsed: 0.0,
});
world.step();
assert!(chip(&world, 1).starts_with("FPS "), "{}", chip(&world, 1));
assert_eq!(chip(&world, 2), "VRAM 0 MB");
}
#[test]
fn emit_waits_for_the_window_and_averages_its_frames() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(FrameTime {
dt: 0.125,
elapsed: 0.0,
});
for _ in 0..3 {
world.step();
}
assert_eq!(chip(&world, 1), "", "no emit before the window closes");
world.step();
assert_eq!(chip(&world, 1), "FPS 8");
}
#[test]
fn emit_window_writes_ram_chip_with_budget() {
use crate::app::budget::MemoryBudget;
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
let budget = MemoryBudget::compute(Some(16 * 1024 * 1024 * 1024), 0);
world.insert_resource(budget);
world.insert_resource(FrameTime {
dt: 1.0,
elapsed: 0.0,
});
world.step();
if cfg!(any(
target_os = "macos",
target_os = "linux",
target_os = "windows"
)) {
let ram = chip(&world, 3);
assert!(ram.starts_with("RAM "), "{ram}");
assert!(
ram.ends_with(&format!(" / {} MB", budget.budget_mib())),
"{ram}"
);
}
}
#[test]
fn hud_prefs_hide_fps_and_vram_chips() {
use concinnity_core::ecs::HudPrefs;
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(HudPrefs {
show_fps: false,
show_vram: false,
});
world.insert_resource(FrameTime {
dt: 1.0,
elapsed: 0.0,
});
world.step();
assert_eq!(chip(&world, 1), "", "fps chip hidden");
assert_eq!(chip(&world, 2), "", "vram chip hidden");
assert_eq!(chip(&world, 4), "", "gpu wait chip hidden");
}
#[test]
fn gpu_wait_text_switches_unit_at_a_millisecond() {
assert_eq!(gpu_wait_text(0), "GPU WAIT 0 us");
assert_eq!(gpu_wait_text(999), "GPU WAIT 999 us");
assert_eq!(gpu_wait_text(12_400), "GPU WAIT 12.4 ms");
}
#[test]
fn emit_window_writes_the_gpu_wait_chip() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(FrameTime {
dt: 1.0,
elapsed: 0.0,
});
world.step();
assert_eq!(chip(&world, 4), "GPU WAIT 0 us");
}
}