Merge pull request #11181 from Slavik-85/f3-overlay-light

F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:26:49 +00:00
commit 63c8c5bce2
8 files changed
+250 -39

No files matched your search

+16
View File
@@ -1392,6 +1392,8 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// Least time between timed frames (`set_pass_timing_gap`).
pass_timing_gap: std::time::Duration,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -4742,6 +4744,7 @@ impl Renderer {
pack_loading: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
pass_timing_gap: std::time::Duration::ZERO,
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -6826,6 +6829,15 @@ impl Renderer {
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
std::time::Duration::ZERO
} else {
gap
};
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
@@ -7401,6 +7413,7 @@ impl Renderer {
.flatten();
}
if let Some(t) = &mut self.own_timing {
t.gap = self.pass_timing_gap;
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
@@ -9038,6 +9051,9 @@ impl Renderer {
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
t.gap = self.pass_timing_gap;
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
@@ -3,13 +3,15 @@
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
//! has passed since the last.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
@@ -43,6 +45,10 @@ pub struct PassTiming {
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
/// Least time between timed frames.
pub gap: Duration,
/// When the last timed frame began.
timed_at: Option<Instant>,
}
impl PassTiming {
@@ -81,6 +87,8 @@ impl PassTiming {
log: Self::logging(),
marker: None,
reports: 0,
gap: Duration::ZERO,
timed_at: None,
})
}
@@ -123,9 +131,12 @@ impl PassTiming {
}
_ => {}
}
if matches!(self.stage, Stage::Idle) {
if matches!(self.stage, Stage::Idle)
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
{
self.names.clear();
self.stage = Stage::Recording;
self.timed_at = Some(Instant::now());
}
}
+11 -8
View File
@@ -308,12 +308,13 @@ impl DebugEntries {
pub fn is_using_profile(&self, profile: Profile) -> bool {
self.profile == Some(profile)
}
/// `getStatus`: never for an entry the statuses leave out.
/// `getStatus`: never for an entry the statuses leave out. The debug renderers ask every
/// frame, so no identifier is built for the lookup.
pub fn status(&self, path: &str) -> Status {
self.statuses
.get(&identifier(path))
.copied()
.unwrap_or(Status::Never)
.iter()
.find(|(id, _)| id.strip_prefix("minecraft:") == Some(path))
.map_or(Status::Never, |(_, status)| *status)
}
/// `setStatus`: the statuses are custom from now on, and saved.
pub fn set_status(&mut self, path: &str, status: Status) {
@@ -343,11 +344,13 @@ impl DebugEntries {
};
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
}
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw. Asked every frame, F3 closed
/// or not, so it walks the few statuses kept rather than every entry.
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
ENTRIES
.iter()
.any(|e| self.enabled(e.path, overlay, reduced))
self.statuses
.keys()
.filter_map(|id| id.strip_prefix("minecraft:"))
.any(|path| self.enabled(path, overlay, reduced))
}
}
+96 -8
View File
@@ -3,10 +3,12 @@
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
hud::HudVertex,
hud_layout::{GuiDraw, PoseOp, fill},
java_text,
text::Text,
};
use std::time::{Duration, Instant};
/// `extractLines`'s background: ARGB 0x90505050.
const BACKGROUND: u32 = 0x9050_5050;
@@ -15,6 +17,39 @@ const TEXT: u32 = 0xFFE0_E0E0;
/// `Font.lineHeight`.
const LINE_HEIGHT: i32 = 9;
/// How long the painted overlay is reused: 20 times a second, the tick rate most of its lines
/// change at or below (fps once a second, memory twice).
const REPAINT: Duration = Duration::from_millis(50);
/// Least time between frames timed for the overlay's GPU lines. Their medians still span about
/// 1.5 s of samples.
pub const GPU_TIMING_GAP: Duration = Duration::from_millis(100);
/// The overlay's painted vertices between rebuilds: copying them is far cheaper than formatting
/// its lines and laying out their glyphs each frame.
#[derive(Default)]
pub struct PaintCache {
vertices: Vec<HudVertex>,
/// When they were painted, and for what layout (see [`PaintCache::fresh`]).
painted: Option<(Instant, PaintLayout)>,
}
/// What the painted vertices depend on besides the lines: the framebuffer size, the GUI scale's
/// bits and `Font::epoch` (glyphs move in the atlas when it changes).
pub type PaintLayout = ([u32; 2], u32, u64);
impl PaintCache {
/// The vertices, while they are recent and painted for this `layout`.
pub fn fresh(&self, now: Instant, layout: PaintLayout) -> Option<&[HudVertex]> {
let (at, painted) = self.painted?;
(painted == layout && now.duration_since(at) < REPAINT).then_some(&self.vertices)
}
pub fn store(&mut self, now: Instant, layout: PaintLayout, vertices: &[HudVertex]) {
self.vertices.clear();
self.vertices.extend_from_slice(vertices);
self.painted = Some((now, layout));
}
}
/// What the enabled entries reported (`DebugScreenDisplayer`).
#[derive(Default, Debug)]
pub struct Displayer {
@@ -422,8 +457,9 @@ pub fn light_line(sky: u8, block: u8) -> String {
}
/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
/// anonymous resident memory for the heap's used part, all resident memory
/// for its committed part, the machine's memory for its maximum.
/// anonymous (on Windows, private) resident memory for the heap's used part,
/// all resident memory for its committed part, the machine's memory for its
/// maximum.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ProcessMemory {
pub used: u64,
@@ -432,14 +468,20 @@ pub struct ProcessMemory {
}
impl ProcessMemory {
/// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
/// From `/proc/self/status` and `/proc/meminfo` on Linux, the process's
/// counters on Windows; `None` elsewhere.
pub fn read() -> Option<Self> {
if !cfg!(target_os = "linux") {
return None;
if cfg!(target_os = "linux") {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
return Self::parse(&status, &meminfo);
}
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
Self::parse(&status, &meminfo)
let [used, allocated, max] = horizon_threads::process_memory::usage()?;
Some(Self {
used,
allocated,
max: max.max(1),
})
}
fn parse(status: &str, meminfo: &str) -> Option<Self> {
let kib = |text: &str, key: &str| -> Option<u64> {
@@ -510,6 +552,38 @@ mod tests {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn painted_vertices_are_reused_until_stale_or_laid_out_again() {
let vertex = HudVertex {
pos: [1.0, 2.0],
uv: [0.0; 2],
color: 7,
depth: 0.0,
glint_uv: [0.0; 2],
text: 1,
};
let layout = ([1920, 1080], 2.0f32.to_bits(), 5);
let start = Instant::now();
let mut cache = PaintCache::default();
assert!(cache.fresh(start, layout).is_none());
cache.store(start, layout, &[vertex]);
let just_before = start + Duration::from_millis(49);
assert_eq!(cache.fresh(just_before, layout).map(<[_]>::len), Some(1));
// Stale after a twentieth of a second, so the stats keep moving.
assert!(cache.fresh(start + REPAINT, layout).is_none());
// Another window size, GUI scale or glyph layout repaints at once.
for other in [
([1280, 720], layout.1, layout.2),
(layout.0, 3.0f32.to_bits(), layout.2),
(layout.0, layout.1, 6),
] {
assert!(cache.fresh(just_before, other).is_none());
}
// A smaller repaint leaves nothing of the last one behind.
cache.store(start, layout, &[]);
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
}
#[test]
fn looking_at_lines_match_vanilla() {
let props = [
@@ -611,6 +685,20 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn the_memory_lines_show_on_windows() {
let m = ProcessMemory::read().expect("Windows reports the process's memory");
assert!(
0 < m.used && m.used <= m.allocated && m.allocated <= m.max,
"{m:?}"
);
let lines = MemoryMeter::default()
.lines()
.expect("the meter reads the same counters");
assert!(lines[0].starts_with("Mem: "), "{lines:?}");
}
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
+15 -1
View File
@@ -3217,6 +3217,10 @@ struct Game<'a> {
debug_options: Option<debug_options::DebugOptionsUi>,
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
/// The F3 overlay's vertices between rebuilds (`draw_debug_overlay_under`).
debug_paint: RefCell<horizon_client::debug_overlay::PaintCache>,
/// The F3 overlay's last reading of the refresh rate, and when it was taken.
debug_refresh: std::cell::Cell<Option<(Instant, Option<u32>)>>,
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
/// CPU time, in percent.
gpu_utilization: f64,
@@ -17100,9 +17104,16 @@ impl Game<'_> {
pr.lap("pr_frame");
// Renderer encoding and GPU waiting belong to render, not prepare.
drop(prepare);
let debug_passes = self.debug_lists_gpu_passes();
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(self.debug || self.gpu_passes);
renderer.set_pass_timing(debug_passes || self.gpu_passes);
// `gpu_passes` is a measurement: it takes every sample it can.
renderer.set_pass_timing_gap(if self.gpu_passes {
Duration::ZERO
} else {
horizon_client::debug_overlay::GPU_TIMING_GAP
});
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
@@ -18159,6 +18170,7 @@ impl ApplicationHandler for Game<'_> {
if self.window.is_some() {
return;
}
debug_keys::warm_debug_cpu();
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
@@ -20396,6 +20408,8 @@ impl<'a> Game<'a> {
),
debug_options: None,
memory_meter: Default::default(),
debug_paint: Default::default(),
debug_refresh: Default::default(),
gpu_utilization: 0.0,
rendered_mobs: Default::default(),
screen_blur_requested: Default::default(),
+61 -12
View File
@@ -15,12 +15,27 @@ use horizon_client::{
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
use horizon_world::nbt::Tag;
use std::time::{Duration, Instant};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
const YELLOW: u32 = 0xFF_FF55;
const RED: u32 = 0xFF_5555;
/// The overlay's CPU name. Reading it can start a process (`reg`, `sysctl`), which the render
/// thread shouldn't wait for, so [`warm_debug_cpu`] reads it ahead.
static DEBUG_CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Reads the overlay's CPU name in the background when the window opens, for F3's first frame.
pub(super) fn warm_debug_cpu() {
// A thread that can't start only leaves the read to that first frame.
let _ = std::thread::Builder::new()
.name("debug-cpu-name".into())
.spawn(|| {
DEBUG_CPU.get_or_init(horizon_client::crash::cpu);
});
}
/// The game as `KeyboardHandler` sees it while it runs one key event.
struct Host<'g, 'a> {
game: &'g mut Game<'a>,
@@ -289,7 +304,8 @@ impl Game<'_> {
}
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
/// the overlay is open. F1 hides it unless a screen is open.
/// the overlay is open. F1 hides it unless a screen is open. Horizon paints
/// it 20 times a second and copies those vertices in between.
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
let screen_open = self.screen != Screen::Playing;
let reduced = self.reduced_debug_info();
@@ -297,8 +313,19 @@ impl Game<'_> {
|| !self.debug_entries.any_enabled(self.debug, reduced)
|| (self.ui.hide_gui && !screen_open)
{
// Nothing reads the server's lines now: it stops computing them each tick.
if let Some(server) = &self.integrated {
server.request_debug(None);
}
return;
}
let now = Instant::now();
let layout = (size, batch.scale.to_bits(), self.gui.atlas.font.epoch());
if let Some(vertices) = self.debug_paint.borrow().fresh(now, layout) {
batch.vertices.extend_from_slice(vertices);
return;
}
let first_vertex = batch.vertices.len();
let framebuffer = size.map(|v| v as i32);
let (scale, [width, height]) = debug_overlay::scale(
framebuffer,
@@ -343,6 +370,33 @@ impl Game<'_> {
draws.push(hud_layout::GuiDraw::Pop);
}
hud_layout::paint(batch, atlas, &draws);
self.debug_paint
.borrow_mut()
.store(now, layout, &batch.vertices[first_vertex..]);
}
/// Whether the overlay is listing GPU pass times (its `system_specs` lines), the only reason
/// it has to time passes.
pub(super) fn debug_lists_gpu_passes(&self) -> bool {
self.debug
&& self.title.is_none()
&& !(self.ui.hide_gui && self.screen == Screen::Playing)
&& self.debug_entry("system_specs")
}
/// The monitor's refresh rate in millihertz, asked of the OS at most once a second (on
/// Windows, winit calls `EnumDisplaySettingsExW` each time).
fn debug_refresh_rate(&self) -> Option<u32> {
if let Some((at, rate)) = self.debug_refresh.get()
&& at.elapsed() < Duration::from_secs(1)
{
return rate;
}
let rate = self
.window
.as_ref()
.and_then(|w| w.current_monitor())
.and_then(|m| m.refresh_rate_millihertz());
self.debug_refresh.set(Some((Instant::now(), rate)));
rate
}
/// `DebugEntryFps`.
pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
@@ -357,14 +411,9 @@ impl Game<'_> {
wgpu::PresentMode::FifoRelaxed => " (fifo relaxed)",
_ => "",
});
let refresh = self
.window
.as_ref()
.and_then(|w| w.current_monitor())
.and_then(|m| m.refresh_rate_millihertz())
.map_or("0".into(), |mhz| {
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
});
let refresh = self.debug_refresh_rate().map_or("0".into(), |mhz| {
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
});
// Horizon: with frame generation running, the frames reaching the screen too.
let generated = self
.renderer
@@ -420,12 +469,11 @@ impl Game<'_> {
framebuffer: [i32; 2],
) {
if let Some(r) = self.renderer.as_ref() {
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
d.group(
"system",
[format!(
"CPU: {}",
CPU.get_or_init(horizon_client::crash::cpu)
DEBUG_CPU.get_or_init(horizon_client::crash::cpu)
)],
);
let info = &r.info;
@@ -445,7 +493,8 @@ impl Game<'_> {
);
if let Some(w) = self.window.as_ref() {
let density = w.scale_factor();
let logical = w.inner_size().to_logical::<f64>(density);
let logical = winit::dpi::PhysicalSize::new(framebuffer[0], framebuffer[1])
.to_logical::<f64>(density);
d.group(
"system",
[format!(
+1 -1
View File
@@ -14,7 +14,7 @@ libmimalloc-sys = { version = "0.1.49", default-features = false, features = ["e
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_IO", "Win32_System_ProcessStatus", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_WindowsAndMessaging"] }
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_Console", "Win32_System_IO", "Win32_System_ProcessStatus", "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_WindowsProgramming", "Win32_UI_WindowsAndMessaging"] }
[features]
# Counts live heap bytes by startup step and thread (`CensusAllocator`); for measuring only.
+37 -7
View File
@@ -1,18 +1,20 @@
//! Read-only process memory diagnostics.
/// The process's memory counters.
#[cfg(windows)]
fn counters() -> Option<windows_sys::Win32::System::ProcessStatus::PROCESS_MEMORY_COUNTERS> {
fn counters() -> Option<windows_sys::Win32::System::ProcessStatus::PROCESS_MEMORY_COUNTERS_EX2> {
use windows_sys::Win32::System::ProcessStatus::{
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS,
K32GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX2,
};
use windows_sys::Win32::System::Threading::GetCurrentProcess;
let size = std::mem::size_of::<PROCESS_MEMORY_COUNTERS>() as u32;
// SAFETY: all-zero is a valid value of this plain C struct, and the call writes at most `size`
// bytes into it; the current process's pseudo-handle needs no closing.
let size = std::mem::size_of::<PROCESS_MEMORY_COUNTERS_EX2>() as u32;
// SAFETY: all-zero is a valid value of this plain C struct, which starts with the fields of the
// one the call is declared with, and the call writes at most `size` bytes into it; the current
// process's pseudo-handle needs no closing.
unsafe {
let mut counters: PROCESS_MEMORY_COUNTERS = std::mem::zeroed();
let mut counters: PROCESS_MEMORY_COUNTERS_EX2 = std::mem::zeroed();
counters.cb = size;
(K32GetProcessMemoryInfo(GetCurrentProcess(), &mut counters, size) != 0).then_some(counters)
(K32GetProcessMemoryInfo(GetCurrentProcess(), (&raw mut counters).cast(), size) != 0)
.then_some(counters)
}
}
@@ -22,6 +24,34 @@ pub fn resident_bytes() -> Option<u64> {
Some(counters()?.WorkingSetSize as u64)
}
/// The private memory this process holds in RAM, all it holds in RAM and the machine's RAM, in
/// bytes.
#[cfg(windows)]
pub fn usage() -> Option<[u64; 3]> {
use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
let counters = counters()?;
let resident = counters.WorkingSetSize as u64;
let private = match counters.PrivateWorkingSetSize {
// Windows before 10 22H2 leaves it empty: the private commit is the nearest it has.
0 => (counters.PrivateUsage as u64).min(resident),
bytes => bytes as u64,
};
// SAFETY: all-zero is a valid value of this plain C struct, its length is set as the call
// requires, and the call only writes into it.
let ram = unsafe {
let mut status: MEMORYSTATUSEX = std::mem::zeroed();
status.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
(GlobalMemoryStatusEx(&mut status) != 0).then_some(status.ullTotalPhys)
}?;
Some([private, resident, ram])
}
/// [`usage`]'s counters: not read on this system.
#[cfg(not(windows))]
pub fn usage() -> Option<[u64; 3]> {
None
}
/// The memory this process holds in RAM, in bytes.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn resident_bytes() -> Option<u64> {