From fb54acf88b0bb1bfc350806263b2065a073ec297 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 24 Jun 2026 20:16:58 +0800 Subject: [PATCH] perf(terminal): cache webgl row glyph collection --- TERMINAL_ROADMAP.md | 4 + app/src/pages/terminal.rs | 2 + app/src/terminal/renderer.rs | 249 ++++++++++++++++++++++++++++++++--- scripts/terminal-bench.mjs | 2 + 4 files changed, 238 insertions(+), 19 deletions(-) diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 1c903ee..95a951d 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -259,6 +259,10 @@ - 正常 WebGL 路径不再每帧把 Canvas backdrop 上传为整屏 texture;背景、underline、cursor 背景改为 1×1 白色纹理 + 纯色 quad 批量绘制。 - `backdropTextureMs` 只在复杂 glyph / atlas 失败等 full-canvas fallback 路径产生,用于继续观察 fallback 是否被真实内容触发。 - 页面切换、`?renderer=webgl` 和 `--renderer webgl` 保留,下一轮基准可直接对比这次优化后 WebGL 是否追近 Canvas。 +7. **WebGL glyph collect cache** ✅ + - WebGL renderer state 增加可见行 glyph 拆分缓存,相同行内容 / 样式 / theme 在滚动和连续输出时复用 grapheme 拆分结果。 + - `GlyphKey.text` 改为 `Rc`,缓存命中后 materialize 行 glyph cell 时避免反复复制 glyph 字符串。 + - benchmark `rendererStats` 新增 `rowCacheHits` / `rowCacheMisses`,用于判断 `collectMs` 是否被 cache 命中率拉低。 --- diff --git a/app/src/pages/terminal.rs b/app/src/pages/terminal.rs index 2de1ef5..433cb95 100644 --- a/app/src/pages/terminal.rs +++ b/app/src/pages/terminal.rs @@ -185,6 +185,8 @@ fn append_webgl_stats(probe: &wasm_bindgen::JsValue, stats: crate::terminal::Web set_f64(&frame, "vertexUploadMs", stats.vertex_upload_ms); set_f64(&frame, "drawMs", stats.draw_ms); set_f64(&frame, "cells", stats.cells as f64); + set_f64(&frame, "rowCacheHits", stats.row_cache_hits as f64); + set_f64(&frame, "rowCacheMisses", stats.row_cache_misses as f64); set_f64(&frame, "atlasEntries", stats.atlas_entries as f64); set_f64(&frame, "atlasInsertions", stats.atlas_insertions as f64); set_f64(&frame, "atlasResets", stats.atlas_resets as f64); diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs index c5d0eec..046eedc 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::{HashMap, hash_map::DefaultHasher}; +use std::hash::{Hash, Hasher}; use std::rc::Rc; use js_sys::Float32Array; @@ -378,6 +379,8 @@ pub struct WebGlRenderStats { pub vertex_upload_ms: f64, pub draw_ms: f64, pub cells: usize, + pub row_cache_hits: usize, + pub row_cache_misses: usize, pub atlas_entries: usize, pub atlas_insertions: usize, pub atlas_resets: usize, @@ -466,11 +469,13 @@ pub(super) struct WebGlRendererState { tex_coord_location: u32, color_location: u32, sampler_location: Option, + atlas_signature: u64, + row_cache: WebGlRowCache, } #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct GlyphKey { - text: String, + text: Rc, bold: bool, italic: bool, cols: usize, @@ -564,6 +569,30 @@ struct GlyphCell { fg: [f32; 4], } +#[derive(Clone, Debug, PartialEq)] +struct CachedGlyphCell { + key: GlyphKey, + col: usize, + fg: [f32; 4], +} + +#[derive(Clone, Debug)] +struct CachedRowGlyphs { + key: u64, + cells: Vec, +} + +#[derive(Debug, Default)] +struct WebGlRowCache { + glyph_rows: Vec, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct WebGlRowCacheStats { + hits: usize, + misses: usize, +} + #[derive(Clone, Copy, Debug, PartialEq)] struct GlyphQuad { x: f64, @@ -592,6 +621,11 @@ fn split_segment_glyphs( let mut cells = Vec::new(); let mut col = start_col; let mut used_cols = 0usize; + let fg = if segment.is_cursor && options.cursor.style == TerminalCursorStyle::Block { + parse_css_rgb(&options.theme.cursor_foreground)? + } else { + parse_css_rgb(themed_color(&segment.style.color, options))? + }; for grapheme in UnicodeSegmentation::graphemes(segment.text.as_str(), true) { let mut cols = UnicodeWidthStr::width(grapheme); if cols == 0 { @@ -600,14 +634,9 @@ fn split_segment_glyphs( if used_cols + cols > segment.cols { return None; } - let fg = if segment.is_cursor && options.cursor.style == TerminalCursorStyle::Block { - parse_css_rgb(&options.theme.cursor_foreground)? - } else { - parse_css_rgb(themed_color(&segment.style.color, options))? - }; cells.push(GlyphCell { key: GlyphKey { - text: grapheme.to_owned(), + text: Rc::::from(grapheme), bold: segment.style.bold, italic: segment.style.italic, cols, @@ -629,18 +658,100 @@ fn split_segment_glyphs( fn collect_glyph_cells( visible: &[TerminalRow], options: &ResolvedTerminalOptions, -) -> Option> { + cache: &mut WebGlRowCache, +) -> Option<(Vec, WebGlRowCacheStats)> { let mut cells = Vec::new(); + let mut stats = WebGlRowCacheStats::default(); for (row_idx, row) in visible.iter().enumerate() { - let mut col = 0usize; - for segment in &row.segments { - cells.extend(split_segment_glyphs(segment, row_idx, col, options)?); - col += segment.cols; + let cached = cache.glyphs_for_row(row, options, &mut stats)?; + cells.extend(cached.iter().map(|cell| GlyphCell { + key: cell.key.clone(), + row: row_idx, + col: cell.col, + fg: cell.fg, + })); + } + cache.retain_recent(visible.len()); + Some((cells, stats)) +} + +impl WebGlRowCache { + fn glyphs_for_row( + &mut self, + row: &TerminalRow, + options: &ResolvedTerminalOptions, + stats: &mut WebGlRowCacheStats, + ) -> Option<&[CachedGlyphCell]> { + let key = row_glyph_cache_key(row, options); + if let Some(index) = self.glyph_rows.iter().position(|cached| cached.key == key) { + stats.hits += 1; + if index != 0 { + let cached = self.glyph_rows.remove(index); + self.glyph_rows.insert(0, cached); + } + return Some(&self.glyph_rows[0].cells); + } + + let cells = collect_row_glyph_cells(row, options)?; + stats.misses += 1; + self.glyph_rows.insert(0, CachedRowGlyphs { key, cells }); + Some(&self.glyph_rows[0].cells) + } + + fn retain_recent(&mut self, visible_rows: usize) { + let max_rows = visible_rows.saturating_mul(4).clamp(64, 512); + if self.glyph_rows.len() > max_rows { + self.glyph_rows.truncate(max_rows); } } +} + +fn collect_row_glyph_cells( + row: &TerminalRow, + options: &ResolvedTerminalOptions, +) -> Option> { + let mut cells = Vec::new(); + let mut col = 0usize; + for segment in &row.segments { + cells.extend( + split_segment_glyphs(segment, 0, col, options)? + .into_iter() + .map(|cell| CachedGlyphCell { + key: cell.key, + col: cell.col, + fg: cell.fg, + }), + ); + col += segment.cols; + } Some(cells) } +fn row_glyph_cache_key(row: &TerminalRow, options: &ResolvedTerminalOptions) -> u64 { + let mut hasher = DefaultHasher::new(); + cursor_style_cache_value(options.cursor.style).hash(&mut hasher); + options.theme.foreground.hash(&mut hasher); + options.theme.background.hash(&mut hasher); + options.theme.cursor_foreground.hash(&mut hasher); + for segment in &row.segments { + segment.text.hash(&mut hasher); + segment.cols.hash(&mut hasher); + segment.style.color.hash(&mut hasher); + segment.style.bold.hash(&mut hasher); + segment.style.italic.hash(&mut hasher); + segment.is_cursor.hash(&mut hasher); + } + hasher.finish() +} + +fn cursor_style_cache_value(style: TerminalCursorStyle) -> u8 { + match style { + TerminalCursorStyle::Block => 0, + TerminalCursorStyle::Underline => 1, + TerminalCursorStyle::Bar => 2, + } +} + fn collect_backdrop_quads(frame: CanvasSurfaceFrame<'_>) -> Option> { let (cell_w, cell_h) = frame.cell_size; let mut quads = Vec::new(); @@ -809,9 +920,12 @@ pub(super) fn render_webgl( state_ref.take(); return Err("WebGL context is lost".to_owned()); } + reset_webgl_caches_if_needed(state, frame); let collect_start = perf_now(); - let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { + let Some((cells, row_cache_stats)) = + collect_glyph_cells(frame.visible, frame.options, &mut state.row_cache) + else { stats.collect_ms = perf_now() - collect_start; render_webgl_canvas_fallback(state, frame, &mut stats)?; stats.total_ms = perf_now() - total_start; @@ -825,6 +939,8 @@ pub(super) fn render_webgl( }; stats.collect_ms = perf_now() - collect_start; stats.cells = cells.len(); + stats.row_cache_hits = row_cache_stats.hits; + stats.row_cache_misses = row_cache_stats.misses; let backdrop_start = perf_now(); draw_webgl_backdrop_quads(state, frame.dom_state, &backdrop_quads)?; @@ -857,6 +973,32 @@ fn render_webgl_canvas_fallback( Ok(()) } +fn reset_webgl_caches_if_needed(state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>) { + let signature = webgl_atlas_signature(frame); + if state.atlas_signature == signature { + return; + } + state.atlas_signature = signature; + state.atlas.reset(); + state.row_cache.glyph_rows.clear(); + state.atlas_ctx.clear_rect( + 0.0, + 0.0, + f64::from(state.atlas.size), + f64::from(state.atlas.size), + ); +} + +fn webgl_atlas_signature(frame: CanvasSurfaceFrame<'_>) -> u64 { + let mut hasher = DefaultHasher::new(); + frame.options.font_family.hash(&mut hasher); + frame.options.font_size_px.to_bits().hash(&mut hasher); + frame.dpr.to_bits().hash(&mut hasher); + frame.cell_size.0.to_bits().hash(&mut hasher); + frame.cell_size.1.to_bits().hash(&mut hasher); + hasher.finish() +} + fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result { let gl = canvas .get_context("webgl2") @@ -958,6 +1100,8 @@ fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result::from("a"), bold: false, italic: false, cols: 1, @@ -1617,7 +1828,7 @@ mod tests { assert_eq!(first, reused); let key_b = GlyphKey { - text: "b".to_owned(), + text: Rc::::from("b"), bold: false, italic: false, cols: 1, diff --git a/scripts/terminal-bench.mjs b/scripts/terminal-bench.mjs index ac0a3f7..4ab95e1 100644 --- a/scripts/terminal-bench.mjs +++ b/scripts/terminal-bench.mjs @@ -309,6 +309,8 @@ export function summarizeRendererStats(frames) { "vertexUploadMs", "drawMs", "cells", + "rowCacheHits", + "rowCacheMisses", "atlasEntries", "atlasInsertions", "atlasResets",