diff --git a/Cargo.lock b/Cargo.lock index ed523d8..01f58e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -613,8 +613,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ - "serde", - "termcolor", "unicode-width", ] @@ -1483,29 +1481,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glow" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "gltf" version = "1.4.1" @@ -1542,15 +1517,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - [[package]] name = "gpu-allocator" version = "0.28.0" @@ -2077,23 +2043,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - [[package]] name = "kqueue" version = "1.1.1" @@ -4759,15 +4708,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminfo" version = "0.9.0" @@ -4997,6 +4937,7 @@ dependencies = [ "base64 0.22.1", "icy_sixel", "image", + "libc", "memchr", "notify", "pollster", @@ -6010,7 +5951,6 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga", "parking_lot", "portable-atomic", "profiling", @@ -6051,7 +5991,6 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -6067,15 +6006,6 @@ dependencies = [ "wgpu-hal", ] -[[package]] -name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" -dependencies = [ - "wgpu-hal", -] - [[package]] name = "wgpu-core-deps-windows-linux-android" version = "29.0.3" @@ -6100,18 +6030,13 @@ dependencies = [ "bytemuck", "cfg-if", "cfg_aliases", - "glow", - "glutin_wgl_sys", "gpu-allocator", "gpu-descriptor", "hashbrown 0.16.1", - "js-sys", - "khronos-egl", "libc", "libloading", "log", "naga", - "ndk-sys", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -6129,14 +6054,10 @@ dependencies = [ "renderdoc-sys", "smallvec", "thiserror 2.0.18", - "wasm-bindgen", - "wayland-sys", - "web-sys", "wgpu-naga-bridge", "wgpu-types", "windows", "windows-core", - "windows-result", ] [[package]] @@ -6733,12 +6654,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - [[package]] name = "xmlwriter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2a1fa97..355fc0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,10 @@ publish = false [workspace.dependencies] # Pinned exactly — validated by decision prototypes; see docs/decisions/. winit = "=0.30.13" -wgpu = "=29.0.3" +# No `gles`/`webgpu`: the renderer only ever requests `Backends::PRIMARY` +# (Vulkan / Metal / DX12), so wgpu's GL backend (glow + EGL loader) was +# compiled and linked in but unreachable, ~0.6 MB of the binary. +wgpu = { version = "=29.0.3", default-features = false, features = ["std", "parking_lot", "dx12", "metal", "vulkan", "wgsl"] } naga = { version = "29", features = ["wgsl-in", "wgsl-out", "glsl-in"] } vte = "=0.15.0" cosmic-text = "=0.19.0" @@ -106,6 +109,57 @@ lto = "thin" codegen-units = 1 strip = "symbols" +# Size-optimise cold, code-heavy dependencies: the Markdown block parsers +# and layout engines (Mermaid, LaTeX maths, SVG tree building), the glTF / +# OBJ loaders behind RGP, and petgraph (only the Wayland clipboard's MIME +# sniffing uses it). Each runs once per block / asset / paste, never per +# frame or per PTY byte, and rendering the demo Markdown document +# (diagrams + maths) measured the same at "s" as at 3. Rasterisers +# (tiny-skia, resvg), image codecs, regex engines, font parsing and +# everything on the startup path, the config TOML parser included, stay +# at opt-level 3. +[profile.release.package.mermaid-svg] +opt-level = "s" + +[profile.release.package.ratex-parser] +opt-level = "s" + +[profile.release.package.ratex-layout] +opt-level = "s" + +[profile.release.package.ratex-render] +opt-level = "s" + +[profile.release.package.ratex-types] +opt-level = "s" + +[profile.release.package.ratex-lexer] +opt-level = "s" + +[profile.release.package.ratex-font] +opt-level = "s" + +[profile.release.package.usvg] +opt-level = "s" + +[profile.release.package.svgtypes] +opt-level = "s" + +[profile.release.package.pulldown-cmark] +opt-level = "s" + +[profile.release.package.gltf] +opt-level = "s" + +[profile.release.package.gltf-json] +opt-level = "s" + +[profile.release.package.tobj] +opt-level = "s" + +[profile.release.package.petgraph] +opt-level = "s" + [profile.profiling] inherits = "release" debug = "line-tables-only" diff --git a/crates/toastty-render/src/lib.rs b/crates/toastty-render/src/lib.rs index 3793660..0261a31 100644 --- a/crates/toastty-render/src/lib.rs +++ b/crates/toastty-render/src/lib.rs @@ -40,7 +40,7 @@ use thiserror::Error; use toastty_protocols::unicode_core::cluster_cell_width; use toastty_term::Term; use wgpu::{ - BackendOptions, Backends, CompositeAlphaMode, Device, DeviceDescriptor, Instance, + Adapter, BackendOptions, Backends, CompositeAlphaMode, Device, DeviceDescriptor, Instance, InstanceDescriptor, InstanceFlags, MemoryBudgetThresholds, PowerPreference, PresentMode, Queue, RequestAdapterOptions, Surface, SurfaceConfiguration, TextureFormat, TextureUsages, }; @@ -238,6 +238,19 @@ fn normalize_preedit(text: Option<&str>) -> Option { /// snapshot tests deterministic across machines. const BUNDLED_FONT: &[u8] = include_bytes!("../fonts/FiraMono-Medium.ttf"); +pub use crate::text::glyph_rasterizer::LoadedFonts; + +/// Load the font database [`Renderer::with_font_ex`] would build for +/// `font_name` (system fonts plus the bundled fallback; `None` means the +/// bundled Fira Mono). +/// +/// Needs no window or device, so the binary calls it on a worker thread +/// during startup and hands the result to [`Renderer::with_loaded_fonts`]. +#[must_use] +pub fn load_fonts(font_name: Option<&str>) -> LoadedFonts { + LoadedFonts::load(Some(font_name.unwrap_or("Fira Mono")), Some(BUNDLED_FONT)) +} + /// Default font pixel size for the demo and snapshot tests. pub const DEFAULT_FONT_SIZE_PX: f32 = 16.0; @@ -314,6 +327,137 @@ pub fn instance_descriptor(flags: InstanceFlags) -> InstanceDescriptor { } } +/// Instance flags `Renderer` uses: validation in this crate's tests, off +/// otherwise. +fn renderer_instance_flags() -> InstanceFlags { + if cfg!(test) { + instance_flags_for_tests() + } else { + instance_flags_for_release() + } +} + +fn log_adapter(adapter: &Adapter) { + let info = adapter.get_info(); + tracing::info!( + target: "render_trace", + "wgpu adapter: name={:?} backend={:?} device_type={:?} vendor=0x{:04x} device=0x{:04x}", + info.name, info.backend, info.device_type, info.vendor, info.device, + ); +} + +/// Request the renderer's device from `adapter`. The limits depend only on +/// the adapter, not on any surface. +async fn request_device(adapter: &Adapter) -> Result<(Device, Queue), RenderError> { + Ok(adapter + .request_device(&DeviceDescriptor { + label: Some("toastty-render device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), + memory_hints: wgpu::MemoryHints::Performance, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + trace: wgpu::Trace::Off, + }) + .await?) +} + +/// Pick an adapter that can present to `surface` and open its device. +async fn open_for_surface( + instance: &Instance, + surface: &Surface<'_>, +) -> Result<(Adapter, Device, Queue), RenderError> { + let adapter = instance + .request_adapter(&RequestAdapterOptions { + power_preference: PowerPreference::LowPower, // power-friendly + force_fallback_adapter: false, + compatible_surface: Some(surface), + }) + .await + .map_err(|_| RenderError::NoAdapter)?; + log_adapter(&adapter); + let (device, queue) = request_device(&adapter).await?; + Ok((adapter, device, queue)) +} + +/// Create `window`'s surface and the device to render it with: `gpu`'s +/// adapter and device when it can present to the surface, otherwise an +/// adapter picked for the surface (see [`Renderer::with_gpu`]). +async fn surface_and_device( + gpu: Option, + window: W, +) -> Result<(Surface<'static>, Adapter, Device, Queue), RenderError> +where + W: HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, +{ + // create_surface takes `impl Into`. A `&'static W` + // that impls `HasDisplayHandle + HasWindowHandle + Send + Sync` is + // accepted via the blanket `From for SurfaceTarget` impl. + let Some(gpu) = gpu else { + let instance = Instance::new(instance_descriptor(renderer_instance_flags())); + let surface = instance.create_surface(window)?; + let (adapter, device, queue) = open_for_surface(&instance, &surface).await?; + return Ok((surface, adapter, device, queue)); + }; + let surface = gpu.instance.create_surface(window)?; + if gpu.adapter.is_surface_supported(&surface) { + log_adapter(&gpu.adapter); + return Ok((surface, gpu.adapter, gpu.device, gpu.queue)); + } + tracing::info!("pre-selected GPU adapter can't present to the window; selecting another"); + let GpuContext { + instance, + adapter, + device, + queue, + } = gpu; + // Release the unusable device before opening another. + drop((adapter, device, queue)); + let (adapter, device, queue) = open_for_surface(&instance, &surface).await?; + Ok((surface, adapter, device, queue)) +} + +/// The window-independent part of renderer setup: the wgpu instance, an +/// adapter and its device. +/// +/// Creating these is ~20 ms of startup (loading and initialising the +/// Vulkan driver, mostly) and none of it needs the window, so the binary +/// builds one on a worker thread while winit opens the display and the +/// window, then passes it to [`Renderer::with_gpu`]. The adapter is chosen +/// without a surface; `with_gpu` checks that it can present to the +/// window's surface and re-selects one exactly as [`Renderer::new`] does +/// when it can't (e.g. a multi-GPU machine whose low-power GPU doesn't +/// drive the display). +#[derive(Debug)] +pub struct GpuContext { + instance: Instance, + adapter: Adapter, + device: Device, + queue: Queue, +} + +impl GpuContext { + /// Create the instance, pick the adapter [`Renderer::new`] would prefer + /// (low power, no surface filter yet) and open its device. + pub async fn new() -> Result { + let instance = Instance::new(instance_descriptor(renderer_instance_flags())); + let adapter = instance + .request_adapter(&RequestAdapterOptions { + power_preference: PowerPreference::LowPower, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .map_err(|_| RenderError::NoAdapter)?; + let (device, queue) = request_device(&adapter).await?; + Ok(Self { + instance, + adapter, + device, + queue, + }) + } +} + /// The renderer. /// /// Owns the wgpu device, queue, surface, and current surface config. M4a @@ -328,6 +472,10 @@ pub struct Renderer { /// Text pipeline lives lazily — `with_font` initializes it; `render` /// (M4a path) tolerates its absence; `render_term` requires it. text: Option, + /// Text pipeline built by [`Renderer::prepare_pipelines`] before any + /// font was loaded; the next [`Renderer::with_loaded_fonts`] takes it + /// instead of building its own. + spare_text_pipeline: Option, /// Theme used by `render_term` when emitting instances: `base_theme`, /// or its fg/bg swap while the terminal is in reverse video /// (DECSCNM). @@ -1007,45 +1155,26 @@ impl Renderer { where W: HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, { - let flags = if cfg!(test) { - instance_flags_for_tests() - } else { - instance_flags_for_release() - }; - - let instance = Instance::new(instance_descriptor(flags)); - - // create_surface takes `impl Into`. A `&'static W` - // that impls `HasDisplayHandle + HasWindowHandle + Send + Sync` is - // accepted via the blanket `From for SurfaceTarget` impl. - let surface = instance.create_surface(window)?; - - let adapter = instance - .request_adapter(&RequestAdapterOptions { - power_preference: PowerPreference::LowPower, // power-friendly - force_fallback_adapter: false, - compatible_surface: Some(&surface), - }) - .await - .map_err(|_| RenderError::NoAdapter)?; - let info = adapter.get_info(); - tracing::info!( - target: "render_trace", - "wgpu adapter: name={:?} backend={:?} device_type={:?} vendor=0x{:04x} device=0x{:04x}", - info.name, info.backend, info.device_type, info.vendor, info.device, - ); + Self::with_gpu(None, window, size, vsync, transparent).await + } - let (device, queue) = adapter - .request_device(&DeviceDescriptor { - label: Some("toastty-render device"), - required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults() - .using_resolution(adapter.limits()), - memory_hints: wgpu::MemoryHints::Performance, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - trace: wgpu::Trace::Off, - }) - .await?; + /// [`Self::new`], reusing a [`GpuContext`] created ahead of the window + /// when `gpu` is `Some`. + /// + /// The context's adapter is kept only if it can present to `window`'s + /// surface. Otherwise an adapter is chosen for the surface the way + /// [`Self::new`] does, so the result never differs from `new`'s. + pub async fn with_gpu( + gpu: Option, + window: W, + size: (u32, u32), + vsync: bool, + transparent: bool, + ) -> Result + where + W: HasDisplayHandle + HasWindowHandle + Send + Sync + 'static, + { + let (surface, adapter, device, queue) = surface_and_device(gpu, window).await?; let caps = surface.get_capabilities(&adapter); let format = surface_format::pick(&caps.formats).ok_or(RenderError::NoSurfaceFormat)?; @@ -1091,6 +1220,7 @@ impl Renderer { config, clear_color: [0.07, 0.07, 0.09, 1.0], text: None, + spare_text_pipeline: None, theme: Theme::default_dark(), base_theme: Theme::default_dark(), reverse_video: false, @@ -1201,17 +1331,20 @@ impl Renderer { /// [`DEFAULT_LINE_HEIGHT`]. Callers loading a `toastty_config::FontConfig` /// should pass `font.line_height` here. pub fn with_font_ex(&mut self, font_name: Option<&str>, font_size_px: f32, line_height: f32) { - // Resolve the font family. We always bundle FiraMono so the - // caller can pass `None` and still get text. - let family = font_name.unwrap_or("Fira Mono"); - let rasterizer = GlyphRasterizer::new( - &self.device, - font_size_px, - line_height, - Some(family), - Some(BUNDLED_FONT), - ); - let pipeline = TextPipeline::new(&self.device, self.config.format); + // We always bundle FiraMono so the caller can pass `None` and + // still get text (see `load_fonts`). + self.with_loaded_fonts(load_fonts(font_name), font_size_px, line_height); + } + + /// [`Self::with_font_ex`] with a font database loaded ahead of time by + /// [`load_fonts`] (the family is the one `fonts` was loaded for). + pub fn with_loaded_fonts(&mut self, fonts: LoadedFonts, font_size_px: f32, line_height: f32) { + let rasterizer = + GlyphRasterizer::with_fonts(&self.device, fonts, font_size_px, line_height); + let pipeline = self + .spare_text_pipeline + .take() + .unwrap_or_else(|| TextPipeline::new(&self.device, self.config.format)); let mask_view = text::pipeline::default_view(rasterizer.mask_texture()); let color_view = text::pipeline::default_view(rasterizer.color_texture()); @@ -1224,9 +1357,29 @@ impl Renderer { rows: RowShaper::new(), instances_scratch: Vec::new(), }); - // Lazy-init the image pipeline alongside the text pipeline so - // both are ready before the first `render_term`. The image - // pipeline shares the swapchain format. + // Ready before the first `render_term`. + self.prepare_aux_pipelines(); + // Font swap invalidates the cell grid — force the next frame + // to clear. + self.needs_full_clear = true; + } + + /// Build the GPU pipelines (text, images, chips, RGP) now. They depend + /// only on the device and the surface format, not on the font, so a + /// caller still waiting for [`LoadedFonts`] from another thread can + /// compile them in the meantime (~5 ms of shader compilation at + /// startup). Optional: [`Self::with_loaded_fonts`] builds whatever is + /// missing. + pub fn prepare_pipelines(&mut self) { + if self.text.is_none() && self.spare_text_pipeline.is_none() { + self.spare_text_pipeline = Some(TextPipeline::new(&self.device, self.config.format)); + } + self.prepare_aux_pipelines(); + } + + /// Create the image, chip, UI and RGP pipelines if they don't exist yet. + /// They share the swapchain format. + fn prepare_aux_pipelines(&mut self) { if self.image_pipeline.is_none() { self.image_pipeline = Some(ImagePipeline::new(&self.device, self.config.format)); } @@ -1239,13 +1392,9 @@ impl Renderer { if self.ui_pipeline.is_none() { self.ui_pipeline = Some(UiPipeline::new(&self.device, self.config.format)); } - // Same for the RGP pipeline. if self.rgp_pipeline.is_none() { self.rgp_pipeline = Some(Rgp3dPipeline::new(&self.device, self.config.format)); } - // Font swap invalidates the cell grid — force the next frame - // to clear. - self.needs_full_clear = true; } /// Change only the font size, keeping the family, font database and diff --git a/crates/toastty-render/src/text/glyph_rasterizer.rs b/crates/toastty-render/src/text/glyph_rasterizer.rs index 42d40b5..6def5b4 100644 --- a/crates/toastty-render/src/text/glyph_rasterizer.rs +++ b/crates/toastty-render/src/text/glyph_rasterizer.rs @@ -375,6 +375,85 @@ pub struct GlyphRasterizer { slot_failed: bool, } +/// The GPU-independent, size-independent half of a [`GlyphRasterizer`]: +/// the font database (every installed face plus the bundled fallback), +/// the requested family, and the weight that family resolves to. +/// +/// Loading it is most of the rasterizer's startup cost: +/// [`FontSystem::new`] scans and parses every installed font, and the +/// first shape with the primary family's `Attrs` makes cosmic-text rank +/// every face against them. Neither needs a window, a device or the font +/// size (which depends on the monitor's scale factor), so the binary +/// builds one on a worker thread while the window and GPU come up and +/// hands it to `Renderer::with_loaded_fonts`. It is `Send` for that. +#[derive(Debug)] +pub struct LoadedFonts { + font_system: FontSystem, + font_name: Option, + primary_weight: Weight, + requested_family_available: bool, +} + +impl LoadedFonts { + /// Scan the system fonts, add `bundled_font` (a guaranteed-present + /// monospace face), and resolve `font_name` (falls back to monospace + /// if absent or unloaded). + pub fn load(font_name: Option<&str>, bundled_font: Option<&'static [u8]>) -> Self { + let mut font_system = FontSystem::new(); + + if let Some(data) = bundled_font { + // Borrow the static bytes rather than copying them into a + // `Vec`: fontdb only needs `AsRef<[u8]>`, and the copy was a + // 170 KB heap allocation held for the life of the process. + font_system + .db_mut() + .load_font_source(cosmic_text::fontdb::Source::Binary(std::sync::Arc::new( + data, + ))); + } + + let primary_weight = resolve_family_weight(&font_system, font_name); + + // Check whether the requested family is actually present in + // the font database. `db().faces()` walks every loaded face; + // we report whether any face's `families` list contains a + // case-insensitive match. The bundled fallback TTF advertises + // its own family name (e.g. "Fira Mono"), so requesting that + // family will resolve even on a host with no system fonts of + // the same name. Used by the binary to log a warning when the + // user's `font.family` config doesn't match anything. + let requested_family_available = font_name.is_none_or(|requested| { + font_system.db().faces().any(|face| { + face.families + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(requested)) + }) + }); + + // Rank the faces for the primary family's `Attrs` now, while we + // may be off the main thread. These are exactly the `Attrs` the + // cell probe (`measure_cell`) and every primary-family line shape + // with (`family_attrs` for the primary family), so the ranking is + // cached by the time the rasterizer first shapes. It is ~10 ms + // with ~1000 system faces. + let family = font_name.unwrap_or("monospace"); + font_system.get_font_matches(&family_attrs(family, family, primary_weight)); + + Self { + font_system, + font_name: font_name.map(str::to_owned), + primary_weight, + requested_family_available, + } + } + + /// The family name passed to [`Self::load`]. + #[must_use] + pub fn font_name(&self) -> Option<&str> { + self.font_name.as_deref() + } +} + impl GlyphRasterizer { /// Build a rasterizer with `font_size` in pixels, the /// line-height ratio (× `font_size`), and an optional font name @@ -385,22 +464,40 @@ impl GlyphRasterizer { /// /// Pass [`DEFAULT_LINE_HEIGHT_RATIO`] for the value the M4b snapshots /// were captured at. + /// + /// Equivalent to [`Self::with_fonts`] on + /// `LoadedFonts::load(font_name, bundled_font)`. pub fn new( device: &Device, font_size: f32, line_height_ratio: f32, font_name: Option<&str>, - bundled_font: Option<&[u8]>, + bundled_font: Option<&'static [u8]>, ) -> Self { - let mut font_system = FontSystem::new(); + Self::with_fonts( + device, + LoadedFonts::load(font_name, bundled_font), + font_size, + line_height_ratio, + ) + } - if let Some(data) = bundled_font { - font_system - .db_mut() - .load_font_source(cosmic_text::fontdb::Source::Binary(std::sync::Arc::new( - data.to_vec(), - ))); - } + /// Build a rasterizer from an already-loaded font database (see + /// [`LoadedFonts`]), at `font_size` pixels and the line-height ratio + /// (× `font_size`). + pub fn with_fonts( + device: &Device, + fonts: LoadedFonts, + font_size: f32, + line_height_ratio: f32, + ) -> Self { + let LoadedFonts { + mut font_system, + font_name, + primary_weight, + requested_family_available, + } = fonts; + let font_name = font_name.as_deref(); // Line height = `font_size * line_height_ratio`, snapped to a // whole device pixel. Per-row origins are `row * cell_h`, so a @@ -419,7 +516,6 @@ impl GlyphRasterizer { // same pixel-grid-alignment reason as the height above. The same // probe yields the constant baseline used for every glyph's // vertical placement (see [`Self::baseline_y`]). - let primary_weight = resolve_family_weight(&font_system, font_name); let (cell_w, cell_h, baseline_y) = measure_cell(&mut font_system, metrics, font_name, primary_weight); let cell_size = (cell_w, cell_h); @@ -433,7 +529,7 @@ impl GlyphRasterizer { ); // Pre-allocate the per-line buffer. - let mut buffer = Buffer::new(&mut font_system, metrics); + let mut buffer = Buffer::new_empty(metrics); buffer.set_size(Some(f32::INFINITY), Some(f32::INFINITY)); // Tell cosmic-text we want monospace-style positioning; the // cluster-width snap fixes its per-glyph rounding (decision §3). @@ -441,7 +537,7 @@ impl GlyphRasterizer { // Tiny buffer reused for single-char standalone shapes. Same // monospace-width hint so isolated glyphs land at x=0. - let mut isolation_buffer = Buffer::new(&mut font_system, metrics); + let mut isolation_buffer = Buffer::new_empty(metrics); isolation_buffer.set_size(Some(f32::INFINITY), Some(f32::INFINITY)); isolation_buffer.set_monospace_width(Some(cell_size.0)); @@ -454,22 +550,6 @@ impl GlyphRasterizer { let family_name = font_name.unwrap_or("monospace").to_string(); - // Check whether the requested family is actually present in - // the font database. `db().faces()` walks every loaded face; - // we report whether any face's `families` list contains a - // case-insensitive match. The bundled fallback TTF advertises - // its own family name (e.g. "Fira Mono"), so requesting that - // family will resolve even on a host with no system fonts of - // the same name. Used by the binary to log a warning when the - // user's `font.family` config doesn't match anything. - let requested_family_available = font_name.is_none_or(|requested| { - font_system.db().faces().any(|face| { - face.families - .iter() - .any(|(name, _)| name.eq_ignore_ascii_case(requested)) - }) - }); - Self { font_system, swash_cache, @@ -528,7 +608,7 @@ impl GlyphRasterizer { self.primary_weight, ); for buf in [&mut self.buffer, &mut self.isolation_buffer] { - *buf = Buffer::new(&mut self.font_system, metrics); + *buf = Buffer::new_empty(metrics); buf.set_size(Some(f32::INFINITY), Some(f32::INFINITY)); buf.set_monospace_width(Some(cell_w)); } @@ -1475,7 +1555,7 @@ fn measure_decorations( cell_h: f32, baseline_y: f32, ) -> DecorationMetrics { - let mut probe = Buffer::new(font_system, metrics); + let mut probe = Buffer::new_empty(metrics); probe.set_size(Some(f32::INFINITY), Some(f32::INFINITY)); let attrs = Attrs::new() .family(Family::Name(family.unwrap_or("monospace"))) @@ -1548,7 +1628,16 @@ fn measure_cell( family: Option<&str>, weight: Weight, ) -> (f32, f32, f32) { - let mut probe = Buffer::new(font_system, metrics); + // `Buffer::new_empty`, not `Buffer::new`, here and for every other + // buffer in this file: `Buffer::new` shapes an empty line with the + // default `Attrs` (sans-serif, weight 400), and the first shape of a + // given `Attrs` makes cosmic-text rank every installed face against + // it (`get_font_matches`, ~10 ms with 1000 system faces, opening each + // variable font to read its weight axis). We never shape with the + // default `Attrs`, so that ranking was pure startup cost. Every buffer + // gets `set_text` before it is shaped, so the missing empty line is + // never observed. + let mut probe = Buffer::new_empty(metrics); probe.set_size(Some(f32::INFINITY), Some(f32::INFINITY)); let fam = family.unwrap_or("monospace"); let attrs = Attrs::new().family(Family::Name(fam)).weight(weight); @@ -1912,6 +2001,41 @@ mod tests { } } + /// Fonts loaded on another thread (the binary's startup path) give a + /// rasterizer identical to one that loads its own: same family, weight, + /// metrics and glyphs. + #[test] + // Same inputs, same code: the glyph geometry must match bit for bit. + #[allow(clippy::float_cmp)] + fn fonts_loaded_on_another_thread_match_inline_load() { + let (device, queue, mut inline) = make_rasterizer(); + let mut fonts = + std::thread::spawn(|| LoadedFonts::load(Some("Fira Mono"), Some(TEST_FONT))) + .join() + .expect("font thread"); + assert_eq!(fonts.font_name(), Some("Fira Mono")); + assert!(fonts.requested_family_available); + let attrs = family_attrs("Fira Mono", "Fira Mono", fonts.primary_weight); + assert!(!fonts.font_system.get_font_matches(&attrs).is_empty()); + let mut loaded = + GlyphRasterizer::with_fonts(&device, fonts, 16.0, DEFAULT_LINE_HEIGHT_RATIO); + assert_eq!(loaded.family_name(), inline.family_name()); + assert_eq!(loaded.primary_weight, inline.primary_weight); + assert_eq!(loaded.cell_size(), inline.cell_size()); + assert!((loaded.baseline_y - inline.baseline_y).abs() < 1e-6); + assert_eq!( + loaded.requested_family_available(), + inline.requested_family_available() + ); + let a = inline.shape_line(&queue, "Hello", false); + let b = loaded.shape_line(&queue, "Hello", false); + for (col, ch) in (0u16..).zip("Hello".chars()) { + let (x, y) = (a.get(col, ch).unwrap(), b.get(col, ch).unwrap()); + assert_eq!(x.glyph_size, y.glyph_size, "{ch}"); + assert_eq!(x.glyph_offset, y.glyph_offset, "{ch}"); + } + } + /// Wide / zero-width characters must not enter `char_cache`: their /// cell-width != 1 violates invariant #3, so the fast path bails /// before consulting the cache and the slow path skips insertion. diff --git a/crates/toastty/Cargo.toml b/crates/toastty/Cargo.toml index 3caa1dd..d763dd3 100644 --- a/crates/toastty/Cargo.toml +++ b/crates/toastty/Cargo.toml @@ -46,6 +46,10 @@ toastty-render.workspace = true toastty-term.workspace = true toastty-window.workspace = true +[target.'cfg(all(target_os = "linux", target_env = "gnu"))'.dependencies] +# `malloc_trim` (see `src/heap.rs`). +libc = "0.2" + [features] pprof = ["dep:pprof"] diff --git a/crates/toastty/src/heap.rs b/crates/toastty/src/heap.rs new file mode 100644 index 0000000..9e45edb --- /dev/null +++ b/crates/toastty/src/heap.rs @@ -0,0 +1,34 @@ +//! Returning freed heap memory to the OS. +//! +//! Startup allocates and frees a lot: the grid is built at a placeholder +//! size and rebuilt once the cell size is known, and the font scan and +//! driver initialisation churn through temporaries on their threads. +//! glibc keeps the freed pages (only the very top of the main heap is +//! released automatically), so an idle terminal held ~11 MB of free heap. +//! [`release_free_memory`] hands them back. + +/// Give free heap pages back to the OS (`malloc_trim(0)`: every arena, +/// including pages in the middle of a heap). Takes ~1 ms with a 40 MB +/// heap, so call it at idle points, not per frame. A no-op on allocators +/// other than glibc's. +pub fn release_free_memory() { + #[cfg(all(target_os = "linux", target_env = "gnu"))] + // SAFETY: `malloc_trim` only inspects and releases allocator-owned + // free memory; it is thread-safe and has no preconditions. + unsafe { + libc::malloc_trim(0); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn release_keeps_live_allocations_intact() { + let live: Vec> = (0..64u8).map(|i| vec![i; 64 * 1024]).collect(); + drop((0..256).map(|_| vec![0u8; 64 * 1024]).collect::>()); + super::release_free_memory(); + for (i, v) in (0..64u8).zip(&live) { + assert!(v.iter().all(|&b| b == i)); + } + } +} diff --git a/crates/toastty/src/lib.rs b/crates/toastty/src/lib.rs index 9a96e64..75e9b60 100644 --- a/crates/toastty/src/lib.rs +++ b/crates/toastty/src/lib.rs @@ -16,6 +16,7 @@ pub mod config_watcher; pub mod focus; pub mod font_zoom; pub mod geometry; +pub mod heap; pub mod image_cmd; pub mod ingest; pub mod keyboard; @@ -25,6 +26,7 @@ pub mod md_cmd; pub mod mouse; pub mod paste; pub mod perf_stats; +pub mod preload; pub mod pty_log; pub mod shell; pub mod theme_bridge; diff --git a/crates/toastty/src/main.rs b/crates/toastty/src/main.rs index 8e11821..604d649 100644 --- a/crates/toastty/src/main.rs +++ b/crates/toastty/src/main.rs @@ -50,6 +50,7 @@ use toastty::mouse::{ }; use toastty::paste::wrap_for_paste; use toastty::perf_stats::PerfStats; +use toastty::preload::Preload; use toastty::pty_log::{Direction, PtyLogger}; use toastty::shell::resolve_command; use toastty::theme_bridge::{ @@ -233,6 +234,9 @@ fn main() -> Result<()> { if let Some(ref e) = load_err { warn!("config load failed, using defaults: {e}"); } + // GPU + font setup need no window: start them now so they overlap + // winit opening the display and the window (see `toastty::preload`). + let preload = Preload::spawn(&config.font.family); let initial_size = (config.window.width.max(1), config.window.height.max(1)); let transparent = config.theme.bg.as_array()[3] < 1.0; @@ -252,6 +256,7 @@ fn main() -> Result<()> { cli_working_dir, initial_size, load_err, + preload, ); run(opts, app).context("window run")?; Ok(()) @@ -269,6 +274,12 @@ struct Toastty { cli_working_dir: Option, window: Option, renderer: Option, + /// GPU context + fonts being built on worker threads since `main`; + /// consumed by `init_impl`. + preload: Preload, + /// The first frame has been drawn and the startup heap churn released + /// (`toastty::heap::release_free_memory`). + startup_heap_released: bool, parser: Parser, term: Term, pty: Option, @@ -484,6 +495,7 @@ impl Toastty { cli_working_dir: Option, initial_size: (u32, u32), load_err: Option, + preload: Preload, ) -> Self { let scrollback = config.scrollback.lines.try_into().unwrap_or(u16::MAX); // Start at a tiny grid; init() resizes once we know cell dimensions. @@ -504,6 +516,8 @@ impl Toastty { cli_working_dir, window: None, renderer: None, + preload, + startup_heap_released: false, parser: Parser::new(), term, pty: None, @@ -1134,19 +1148,16 @@ impl Toastty { } impl Toastty { - fn init_impl(&mut self, window: ToasttyWindow, handle: &WindowHandle) { - let size = window.physical_size(); - self.physical_size = size; - // Capture the monitor scale factor before sizing the font so the - // very first frame rasterizes at the correct physical resolution - // (rather than scale 1.0, then re-rasterizing on the first - // `Resize`). Fractional Wayland scales are included. - self.scale_factor = window.scale_factor(); - - // Build the renderer. Transparency is logged once at startup in - // `run`; here we only need the flag for surface configuration. + /// Create the renderer and load its font, from the GPU context and + /// fonts preloaded since `main` when they're ready (see + /// `toastty::preload`), else inline. `None` (logged) when no GPU + /// renderer can be created. + fn build_renderer(&mut self, window: &ToasttyWindow, size: (u32, u32)) -> Option { + // Transparency is logged once at startup in `run`; here we only + // need the flag for surface configuration. let transparent = self.config.theme.bg.as_array()[3] < 1.0; - let mut renderer = match block_on(Renderer::new( + let mut renderer = match block_on(Renderer::with_gpu( + self.preload.take_gpu(), window.clone(), size, self.config.window.vsync, @@ -1155,14 +1166,37 @@ impl Toastty { Ok(r) => r, Err(e) => { tracing::error!("renderer init failed: {e}"); - return; + // Nothing will take the preloaded fonts now; join the + // workers and free the font database instead of holding + // it for the rest of the (renderer-less) session. + self.preload = Preload::default(); + return None; } }; - renderer.with_font_ex( - Some(self.config.font.family.as_str()), - self.scaled_font_px(), - self.config.font.line_height, - ); + // Compile the shaders while the font thread may still be busy. + renderer.prepare_pipelines(); + let font_px = self.scaled_font_px(); + let line_height = self.config.font.line_height; + if let Some(fonts) = self.preload.take_fonts(&self.config.font.family) { + renderer.with_loaded_fonts(fonts, font_px, line_height); + } else { + renderer.with_font_ex(Some(self.config.font.family.as_str()), font_px, line_height); + } + Some(renderer) + } + + fn init_impl(&mut self, window: ToasttyWindow, handle: &WindowHandle) { + let size = window.physical_size(); + self.physical_size = size; + // Capture the monitor scale factor before sizing the font so the + // very first frame rasterizes at the correct physical resolution + // (rather than scale 1.0, then re-rasterizing on the first + // `Resize`). Fractional Wayland scales are included. + self.scale_factor = window.scale_factor(); + + let Some(mut renderer) = self.build_renderer(&window, size) else { + return; + }; if !renderer.font_family_available() { warn!( "configured font {:?} not found on this system; falling back to the bundled / system default", @@ -3061,6 +3095,12 @@ impl Toastty { let frame_end = Instant::now(); self.stats.frame(frame_start, frame_end); self.ingest.frame_drawn(frame_start, frame_end); + if !self.startup_heap_released { + // Startup is over once the first frame is + // up; drop its freed heap (see `heap`). + self.startup_heap_released = true; + toastty::heap::release_free_memory(); + } // Consume the per-cell damage signal so the // next render only re-emits cells that // changed. Clear the BSU timeout-flush @@ -3472,7 +3512,14 @@ mod tests { } fn headless_app() -> Toastty { - Toastty::new(Config::defaults(), None, None, (800, 600), None) + Toastty::new( + Config::defaults(), + None, + None, + (800, 600), + None, + Preload::default(), + ) } fn mouse(button: MouseButton, state: KeyState, position: (f64, f64)) -> Event { diff --git a/crates/toastty/src/preload.rs b/crates/toastty/src/preload.rs new file mode 100644 index 0000000..b39f1ff --- /dev/null +++ b/crates/toastty/src/preload.rs @@ -0,0 +1,124 @@ +//! Startup work that runs on worker threads while the main thread opens +//! the display and the window. +//! +//! Until the window exists the main thread is busy inside winit +//! (connecting to the display server, loading the keymap: tens of ms). +//! Two other slow pieces of startup need no window at all: +//! +//! - the GPU instance, adapter and device ([`GpuContext`]): loading and +//! initialising the Vulkan driver, ~20 ms; +//! - the font database ([`LoadedFonts`]): scanning and parsing every +//! installed font, then ranking the faces for the configured family, +//! ~30–40 ms. +//! +//! [`Preload::spawn`] starts both at the top of `main`. `init_impl` then +//! takes the results, joining each thread; by then they have usually +//! finished. Anything that fails or panics on a worker yields `None`, and +//! the caller falls back to doing the work inline exactly as before, so a +//! preload can only change when work happens, never what it produces. + +use std::thread::JoinHandle; + +use toastty_render::{GpuContext, LoadedFonts}; +use tracing::debug; + +/// Handles to the startup worker threads. See the module docs. +#[derive(Debug, Default)] +pub struct Preload { + gpu: Option>>, + fonts: Option>, +} + +impl Preload { + /// Start loading the GPU context and the fonts for `font_family` on two + /// worker threads. + #[must_use] + pub fn spawn(font_family: &str) -> Self { + let gpu = std::thread::Builder::new() + .name("toastty-gpu-init".into()) + .spawn(|| match pollster::block_on(GpuContext::new()) { + Ok(gpu) => Some(gpu), + Err(e) => { + debug!("GPU preload failed, retrying with the window: {e}"); + None + } + }) + .ok(); + let family = font_family.to_owned(); + let fonts = std::thread::Builder::new() + .name("toastty-font-init".into()) + .spawn(move || toastty_render::load_fonts(Some(&family))) + .ok(); + Self { gpu, fonts } + } + + /// The preloaded GPU context, waiting for it if needed. `None` when + /// it wasn't started, failed, or was already taken. + pub fn take_gpu(&mut self) -> Option { + self.gpu.take()?.join().ok().flatten() + } + + /// The preloaded fonts if they were loaded for `font_family`, waiting + /// for them if needed. `None` when they weren't started, the loader + /// panicked, were already taken, or are for another family. + pub fn take_fonts(&mut self, font_family: &str) -> Option { + let fonts = self.fonts.take()?.join().ok()?; + (fonts.font_name() == Some(font_family)).then_some(fonts) + } +} + +impl Drop for Preload { + /// Join workers whose results were never taken (startup failed before + /// `init_impl` got to them), so the process never exits while a + /// worker is still inside the GPU driver's or fontconfig's setup. + fn drop(&mut self) { + if let Some(gpu) = self.gpu.take() { + let _ = gpu.join(); + } + if let Some(fonts) = self.fonts.take() { + let _ = fonts.join(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_preload_yields_nothing() { + let mut p = Preload::default(); + assert!(p.take_gpu().is_none()); + assert!(p.take_fonts("Fira Mono").is_none()); + } + + #[test] + fn fonts_are_taken_once_and_only_for_their_family() { + let mut p = Preload { + gpu: None, + fonts: Some(std::thread::spawn(|| { + toastty_render::load_fonts(Some("Fira Mono")) + })), + }; + let fonts = p.take_fonts("Fira Mono").expect("fonts for Fira Mono"); + assert_eq!(fonts.font_name(), Some("Fira Mono")); + assert!(p.take_fonts("Fira Mono").is_none(), "taken twice"); + + let mut p = Preload { + gpu: None, + fonts: Some(std::thread::spawn(|| { + toastty_render::load_fonts(Some("Fira Mono")) + })), + }; + assert!(p.take_fonts("JetBrains Mono").is_none(), "other family"); + } + + #[test] + fn panicking_font_loader_yields_none() { + let mut p = Preload { + gpu: None, + fonts: Some(std::thread::spawn(|| panic!("font loader died"))), + }; + assert!(p.take_fonts("Fira Mono").is_none()); + } +}