use crate::components::{Camera3D, DebugHud, FrameInput, TextLabel};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{PipelineContext, StepResult, System};
use crate::gfx::profile::PassTiming;
use std::time::Instant;
const RSS_INTERVAL_SECS: f32 = 0.5;
const PASSES_CHIP_TOP_N: usize = 6;
fn passes_text(slots: &[PassTiming]) -> String {
let mut entries: Vec<(&'static str, u32)> = slots
.iter()
.copied()
.filter(|(name, micros)| !name.is_empty() && *micros > 0)
.collect();
if entries.is_empty() {
return String::new();
}
entries.sort_by_key(|e| std::cmp::Reverse(e.1));
entries.truncate(PASSES_CHIP_TOP_N);
let mut out = String::from("PASSES");
for (name, micros) in entries {
out.push('\n');
out.push_str(name);
out.push(' ');
if micros < 1000 {
out.push_str(&format!("{micros} us"));
} else {
out.push_str(&format!("{:.1} ms", micros as f32 / 1000.0));
}
}
out
}
fn mouse_text(x: f32, y: f32) -> String {
format!("MOUSE {x:.0}, {y:.0}")
}
fn camera_text(pose: Option<([f32; 3], f32, f32)>) -> String {
match pose {
Some((p, yaw, pitch)) => {
format!(
"CAM {:.2} {:.2} {:.2}\nyaw {yaw:.3} pitch {pitch:.3}",
p[0], p[1], p[2]
)
}
None => String::new(),
}
}
fn sys_text(
threads: Option<(usize, usize)>,
rss: Option<u64>,
budget_mib: Option<u64>,
frame_allocs: Option<u32>,
) -> String {
let threads_part = match threads {
Some((job, cores)) => format!("threads {job}/{cores}"),
None => "threads --".to_string(),
};
let mem_part = match (rss, budget_mib) {
(Some(rss), Some(budget)) => format!("mem {}/{} MB", rss / (1024 * 1024), budget),
(Some(rss), None) => format!("mem {} MB", rss / (1024 * 1024)),
(None, _) => "mem -- MB".to_string(),
};
match frame_allocs {
Some(allocs) => format!("{threads_part} | {mem_part} | alloc {allocs}/f"),
None => format!("{threads_part} | {mem_part}"),
}
}
#[derive(Debug)]
pub(crate) struct DebugHudSystem {
passes_label: Option<AssetId>,
mouse_label: Option<AssetId>,
camera_label: Option<AssetId>,
sys_label: Option<AssetId>,
visible: bool,
pass_times: Vec<PassTiming>,
mouse_pos: (f32, f32),
camera_pose: Option<([f32; 3], f32, f32)>,
rss: Option<u64>,
last_rss_sample: Option<Instant>,
}
impl DebugHudSystem {
pub(crate) fn new(config: DebugHud) -> Self {
Self {
passes_label: config.passes_label,
mouse_label: config.mouse_label,
camera_label: config.camera_label,
sys_label: config.sys_label,
visible: false,
pass_times: Vec::new(),
mouse_pos: (0.0, 0.0),
camera_pose: None,
rss: None,
last_rss_sample: 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 DebugHudSystem {
fn access(&self) -> crate::ecs::Access {
crate::ecs::Access::new()
.reads_components(crate::component_mask![crate::components::Camera3D])
.writes_components(crate::component_mask![crate::components::TextLabel])
.reads_resources(crate::resource_mask![
crate::components::FrameInput,
crate::app::budget::ThreadBudget,
crate::app::budget::MemoryBudget,
])
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let frame_input = ctx.resource::<FrameInput>();
let toggled = frame_input.is_some_and(|input| input.hud_toggle);
if let Some(input) = frame_input {
self.mouse_pos = (input.mouse_x, input.mouse_y);
}
if toggled {
self.visible = !self.visible;
}
if !self.visible {
Self::write_chip(ctx, self.passes_label, String::new());
Self::write_chip(ctx, self.mouse_label, String::new());
Self::write_chip(ctx, self.camera_label, String::new());
Self::write_chip(ctx, self.sys_label, String::new());
return StepResult::Continue;
}
self.pass_times.clear();
self.pass_times
.extend_from_slice(&ctx.profile.render.pass_times_us);
self.camera_pose = ctx
.query::<Camera3D>()
.next()
.map(|c| (c.position, c.yaw, c.pitch));
let threads = ctx
.resource::<crate::app::budget::ThreadBudget>()
.map(|t| (t.job_threads, t.total_cores));
let budget_mib = ctx
.resource::<crate::app::budget::MemoryBudget>()
.map(|b| b.budget_mib());
let now = Instant::now();
if self
.last_rss_sample
.is_none_or(|t| now.duration_since(t).as_secs_f32() >= RSS_INTERVAL_SECS)
{
self.rss = crate::app::sysmem::process_resident_bytes();
self.last_rss_sample = Some(now);
}
let rss = self.rss;
let frame_allocs = ctx.profile.frame_allocs();
Self::write_chip(ctx, self.passes_label, passes_text(&self.pass_times));
Self::write_chip(
ctx,
self.mouse_label,
mouse_text(self.mouse_pos.0, self.mouse_pos.1),
);
Self::write_chip(ctx, self.camera_label, camera_text(self.camera_pose));
Self::write_chip(
ctx,
self.sys_label,
sys_text(threads, rss, budget_mib, frame_allocs),
);
StepResult::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::SYSTEMS;
#[test]
fn passes_text_blanks_on_all_zero_slots() {
let slots = vec![("", 0u32); 8];
assert_eq!(passes_text(&slots), "");
}
#[test]
fn passes_text_lists_top_entries_descending() {
let slots = vec![
("shadow", 380),
("", 0),
("main", 1400),
("ssao_kernel", 120),
("composite", 60),
("ssr_resolve", 800),
("", 0),
];
let out = passes_text(&slots);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "PASSES");
assert_eq!(lines[1], "main 1.4 ms");
assert_eq!(lines[2], "ssr_resolve 800 us");
assert_eq!(lines[3], "shadow 380 us");
assert_eq!(lines[4], "ssao_kernel 120 us");
assert_eq!(lines[5], "composite 60 us");
assert_eq!(lines.len(), 6);
}
#[test]
fn passes_text_truncates_to_top_n() {
let slots: Vec<PassTiming> = vec![
("a", 80),
("b", 70),
("c", 60),
("d", 50),
("e", 40),
("f", 30),
("g", 20),
("h", 10),
];
let out = passes_text(&slots);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 1 + PASSES_CHIP_TOP_N);
assert!(!out.contains("g "));
assert!(!out.contains("h "));
}
#[test]
fn passes_text_formats_microseconds_below_one_ms() {
let slots = vec![
("a", 999u32),
("b", 1000u32),
("c", 1499u32),
("d", 1500u32),
];
let out = passes_text(&slots);
assert!(out.contains("a 999 us"));
assert!(out.contains("b 1.0 ms"));
assert!(out.contains("c 1.5 ms"));
assert!(out.contains("d 1.5 ms"));
}
#[test]
fn mouse_text_rounds_to_whole_pixels() {
assert_eq!(mouse_text(0.0, 0.0), "MOUSE 0, 0");
assert_eq!(mouse_text(640.4, 360.6), "MOUSE 640, 361");
}
#[test]
fn camera_text_formats_pose_in_camera_set_form() {
let out = camera_text(Some(([3.0, 1.6, 20.0], 1.2, -0.1)));
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "CAM 3.00 1.60 20.00");
assert_eq!(lines[1], "yaw 1.200 pitch -0.100");
}
#[test]
fn camera_text_blanks_without_camera() {
assert_eq!(camera_text(None), "");
}
#[test]
fn sys_text_reports_threads_and_memory_when_known() {
assert_eq!(
sys_text(Some((11, 12)), Some(512 * 1024 * 1024), Some(16384), None),
"threads 11/12 | mem 512/16384 MB"
);
}
#[test]
fn sys_text_degrades_each_half_independently() {
assert_eq!(
sys_text(None, Some(256 * 1024 * 1024), Some(8192), None),
"threads -- | mem 256/8192 MB"
);
assert_eq!(
sys_text(Some((3, 4)), Some(256 * 1024 * 1024), None, None),
"threads 3/4 | mem 256 MB"
);
assert_eq!(
sys_text(Some((3, 4)), None, Some(8192), None),
"threads 3/4 | mem -- MB"
);
assert_eq!(sys_text(None, None, None, None), "threads -- | mem -- MB");
}
#[test]
fn sys_text_appends_frame_allocs_only_when_sampled() {
assert_eq!(
sys_text(Some((3, 4)), Some(256 * 1024 * 1024), Some(8192), Some(29)),
"threads 3/4 | mem 256/8192 MB | alloc 29/f"
);
}
#[test]
fn debug_hud_component_spawns_internal_system() {
use crate::ecs::World;
let mut world = World::new();
world.add_component(DebugHud::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["DebugHud"]);
}
fn hud_world() -> crate::ecs::World {
let mut world = crate::ecs::World::new();
world.add_component(DebugHud {
passes_label: Some(AssetId(1)),
mouse_label: Some(AssetId(2)),
camera_label: Some(AssetId(3)),
sys_label: Some(AssetId(4)),
});
for id in [1u32, 2, 3, 4] {
world.add_component(TextLabel {
asset_id: AssetId(id),
content: "stale".to_string(),
..Default::default()
});
}
world.add_component(Camera3D {
fov_y_degrees: 75.0,
near: 0.05,
far: 200.0,
view_matrix: [[0.0; 4]; 4],
position: [1.0, 2.0, 3.0],
yaw: 0.5,
pitch: -0.2,
desired_move: [0.0; 3],
jump_requested: false,
interact_requested: false,
controller: None,
});
world
}
fn chip(world: &crate::ecs::World, id: u32) -> String {
world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(id))
.map(|l| l.content.clone())
.unwrap_or_default()
}
#[test]
fn hidden_hud_blanks_all_chips() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.step();
assert_eq!(chip(&world, 1), "");
assert_eq!(chip(&world, 2), "");
assert_eq!(chip(&world, 3), "");
assert_eq!(chip(&world, 4), "");
}
#[test]
fn toggle_reveals_mouse_and_camera_chips() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(FrameInput {
hud_toggle: true,
mouse_x: 640.4,
mouse_y: 360.6,
..Default::default()
});
world.step();
assert_eq!(chip(&world, 2), "MOUSE 640, 361");
assert_eq!(
chip(&world, 3),
"CAM 1.00 2.00 3.00\nyaw 0.500 pitch -0.200"
);
}
#[test]
fn toggle_reveals_sys_chip_from_budgets() {
use crate::app::budget::{MemoryBudget, ThreadBudget};
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(ThreadBudget {
total_cores: 12,
job_threads: 11,
});
let budget = MemoryBudget::compute(Some(16 * 1024 * 1024 * 1024), 0);
world.insert_resource(budget);
world.insert_resource(FrameInput {
hud_toggle: true,
..Default::default()
});
world.step();
let sys = chip(&world, 4);
assert!(sys.starts_with("threads 11/12 | mem "), "{sys}");
if cfg!(any(
target_os = "macos",
target_os = "linux",
target_os = "windows"
)) {
assert!(
sys.ends_with(&format!("/{} MB", budget.budget_mib())),
"{sys}"
);
} else {
assert!(sys.ends_with("mem -- MB"), "{sys}");
}
}
}