From 057d74d44ce8c08cefb56c491edde04e4298a8da Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 24 Jun 2026 21:18:32 +0800 Subject: [PATCH] perf(terminal): cache webgl row vertex templates --- TERMINAL_ROADMAP.md | 3 + app/src/pages/terminal.rs | 6 + app/src/terminal/renderer.rs | 267 ++++++++++++++++++++++++++++------- scripts/terminal-bench.mjs | 2 + 4 files changed, 225 insertions(+), 53 deletions(-) diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 95a951d..dd45ff1 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -263,6 +263,9 @@ - WebGL renderer state 增加可见行 glyph 拆分缓存,相同行内容 / 样式 / theme 在滚动和连续输出时复用 grapheme 拆分结果。 - `GlyphKey.text` 改为 `Rc`,缓存命中后 materialize 行 glyph cell 时避免反复复制 glyph 字符串。 - benchmark `rendererStats` 新增 `rowCacheHits` / `rowCacheMisses`,用于判断 `collectMs` 是否被 cache 命中率拉低。 +8. **WebGL row vertex template cache** ✅ + - atlas 覆盖后按行缓存 glyph vertex template,后续帧只按当前 row y 偏移 materialize,减少每帧 atlas lookup / UV 计算 / quad 构建。 + - benchmark `rendererStats` 新增 `vertexCacheHits` / `vertexCacheMisses`,用于判断 `vertexBuildMs` 是否主要来自 cache miss。 --- diff --git a/app/src/pages/terminal.rs b/app/src/pages/terminal.rs index 433cb95..60397d4 100644 --- a/app/src/pages/terminal.rs +++ b/app/src/pages/terminal.rs @@ -187,6 +187,12 @@ fn append_webgl_stats(probe: &wasm_bindgen::JsValue, stats: crate::terminal::Web 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, "vertexCacheHits", stats.vertex_cache_hits as f64); + set_f64( + &frame, + "vertexCacheMisses", + stats.vertex_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 046eedc..3796fa9 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -381,6 +381,8 @@ pub struct WebGlRenderStats { pub cells: usize, pub row_cache_hits: usize, pub row_cache_misses: usize, + pub vertex_cache_hits: usize, + pub vertex_cache_misses: usize, pub atlas_entries: usize, pub atlas_insertions: usize, pub atlas_resets: usize, @@ -569,6 +571,13 @@ struct GlyphCell { fg: [f32; 4], } +#[derive(Clone, Debug, PartialEq)] +struct VisibleGlyphRow { + cache_key: u64, + row: usize, + cell_count: usize, +} + #[derive(Clone, Debug, PartialEq)] struct CachedGlyphCell { key: GlyphKey, @@ -580,6 +589,12 @@ struct CachedGlyphCell { struct CachedRowGlyphs { key: u64, cells: Vec, + vertex_template: Option, +} + +#[derive(Clone, Debug)] +struct CachedRowVertexTemplate { + vertices: Vec, } #[derive(Debug, Default)] @@ -591,6 +606,8 @@ struct WebGlRowCache { struct WebGlRowCacheStats { hits: usize, misses: usize, + vertex_hits: usize, + vertex_misses: usize, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -655,24 +672,26 @@ fn split_segment_glyphs( } } -fn collect_glyph_cells( +fn collect_glyph_rows( visible: &[TerminalRow], options: &ResolvedTerminalOptions, cache: &mut WebGlRowCache, -) -> Option<(Vec, WebGlRowCacheStats)> { - let mut cells = Vec::new(); +) -> Option<(Vec, usize, WebGlRowCacheStats)> { + let mut rows = Vec::new(); + let mut cell_count = 0usize; let mut stats = WebGlRowCacheStats::default(); for (row_idx, row) in visible.iter().enumerate() { - let cached = cache.glyphs_for_row(row, options, &mut stats)?; - cells.extend(cached.iter().map(|cell| GlyphCell { - key: cell.key.clone(), + let (cache_key, cached) = cache.glyphs_for_row(row, options, &mut stats)?; + let row_cell_count = cached.len(); + cell_count += row_cell_count; + rows.push(VisibleGlyphRow { + cache_key, row: row_idx, - col: cell.col, - fg: cell.fg, - })); + cell_count: row_cell_count, + }); } cache.retain_recent(visible.len()); - Some((cells, stats)) + Some((rows, cell_count, stats)) } impl WebGlRowCache { @@ -681,7 +700,7 @@ impl WebGlRowCache { row: &TerminalRow, options: &ResolvedTerminalOptions, stats: &mut WebGlRowCacheStats, - ) -> Option<&[CachedGlyphCell]> { + ) -> Option<(u64, &[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; @@ -689,13 +708,20 @@ impl WebGlRowCache { let cached = self.glyph_rows.remove(index); self.glyph_rows.insert(0, cached); } - return Some(&self.glyph_rows[0].cells); + return Some((key, &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) + self.glyph_rows.insert( + 0, + CachedRowGlyphs { + key, + cells, + vertex_template: None, + }, + ); + Some((key, &self.glyph_rows[0].cells)) } fn retain_recent(&mut self, visible_rows: usize) { @@ -704,6 +730,27 @@ impl WebGlRowCache { self.glyph_rows.truncate(max_rows); } } + + fn cells_for_row(&self, key: u64) -> Option<&[CachedGlyphCell]> { + self.glyph_rows + .iter() + .find(|row| row.key == key) + .map(|row| row.cells.as_slice()) + } + + fn vertex_template(&self, key: u64) -> Option<&[f32]> { + self.glyph_rows + .iter() + .find(|row| row.key == key) + .and_then(|row| row.vertex_template.as_ref()) + .map(|template| template.vertices.as_slice()) + } + + fn store_vertex_template(&mut self, key: u64, template: CachedRowVertexTemplate) { + if let Some(row) = self.glyph_rows.iter_mut().find(|row| row.key == key) { + row.vertex_template = Some(template); + } + } } fn collect_row_glyph_cells( @@ -923,8 +970,8 @@ pub(super) fn render_webgl( reset_webgl_caches_if_needed(state, frame); let collect_start = perf_now(); - let Some((cells, row_cache_stats)) = - collect_glyph_cells(frame.visible, frame.options, &mut state.row_cache) + let Some((glyph_rows, cell_count, row_cache_stats)) = + collect_glyph_rows(frame.visible, frame.options, &mut state.row_cache) else { stats.collect_ms = perf_now() - collect_start; render_webgl_canvas_fallback(state, frame, &mut stats)?; @@ -938,14 +985,14 @@ pub(super) fn render_webgl( return Ok(stats); }; stats.collect_ms = perf_now() - collect_start; - stats.cells = cells.len(); + stats.cells = cell_count; 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)?; stats.backdrop_ms = perf_now() - backdrop_start; - if draw_webgl_glyphs(state, frame, &cells, &mut stats).is_err() { + if draw_webgl_glyphs(state, frame, &glyph_rows, &mut stats).is_err() { stats.glyph_draw_failed = true; render_webgl_canvas_fallback(state, frame, &mut stats)?; } @@ -1323,11 +1370,11 @@ fn draw_webgl_backdrop_quads( fn draw_webgl_glyphs( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, - cells: &[GlyphCell], + rows: &[VisibleGlyphRow], stats: &mut WebGlRenderStats, ) -> Result<(), String> { let atlas_start = perf_now(); - ensure_glyphs_in_atlas(state, frame, cells, stats)?; + ensure_glyph_rows_in_atlas(state, frame, rows, stats)?; stats.atlas_ms += perf_now() - atlas_start; if state.atlas.dirty { let upload_start = perf_now(); @@ -1351,26 +1398,8 @@ fn draw_webgl_glyphs( } let vertex_start = perf_now(); - let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); - for cell in cells { - let Some(entry) = state.atlas.get(&cell.key) else { - continue; - }; - let x = cell.col as f64 * frame.cell_size.0; - let y = frame.content_top_px + cell.row as f64 * frame.cell_size.1; - push_quad( - &mut vertices, - GlyphQuad { - x, - y, - width: entry.cols as f64 * frame.cell_size.0, - height: frame.cell_size.1, - uv: glyph_uv(entry, state.atlas.size), - color: cell.fg, - }, - frame.dom_state, - ); - } + let mut vertices = Vec::with_capacity(stats.cells * WEBGL_VERTEX_FLOATS * 6); + materialize_glyph_row_vertices(state, frame, rows, &mut vertices, stats)?; stats.vertex_build_ms += perf_now() - vertex_start; if vertices.is_empty() { return Ok(()); @@ -1398,10 +1427,79 @@ fn draw_webgl_glyphs( Ok(()) } -fn ensure_glyphs_in_atlas( +fn materialize_glyph_row_vertices( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, - cells: &[GlyphCell], + rows: &[VisibleGlyphRow], + vertices: &mut Vec, + stats: &mut WebGlRenderStats, +) -> Result<(), String> { + for row in rows { + let y = frame.content_top_px + row.row as f64 * frame.cell_size.1; + if let Some(template) = state.row_cache.vertex_template(row.cache_key) { + stats.vertex_cache_hits += 1; + append_row_vertex_template(vertices, template, y, frame.dom_state); + continue; + } + + stats.vertex_cache_misses += 1; + let Some(cells) = state.row_cache.cells_for_row(row.cache_key) else { + return Err("missing cached glyph row".to_owned()); + }; + let template = build_row_vertex_template(&state.atlas, frame, cells)?; + append_row_vertex_template(vertices, &template.vertices, y, frame.dom_state); + state + .row_cache + .store_vertex_template(row.cache_key, template); + } + Ok(()) +} + +fn build_row_vertex_template( + atlas: &GlyphAtlas, + frame: CanvasSurfaceFrame<'_>, + cells: &[CachedGlyphCell], +) -> Result { + let mut vertices = Vec::with_capacity(cells.len() * WEBGL_VERTEX_FLOATS * 6); + for cell in cells { + let Some(entry) = atlas.get(&cell.key) else { + return Err("missing glyph atlas entry".to_owned()); + }; + let x = cell.col as f64 * frame.cell_size.0; + push_quad( + &mut vertices, + GlyphQuad { + x, + y: 0.0, + width: entry.cols as f64 * frame.cell_size.0, + height: frame.cell_size.1, + uv: glyph_uv(entry, atlas.size), + color: cell.fg, + }, + frame.dom_state, + ); + } + Ok(CachedRowVertexTemplate { vertices }) +} + +fn append_row_vertex_template( + vertices: &mut Vec, + template: &[f32], + y: f64, + dom_state: CanvasDomState, +) { + let y_offset = (-y / dom_state.css_height as f64 * 2.0) as f32; + let start = vertices.len(); + vertices.extend_from_slice(template); + for index in (start..vertices.len()).step_by(WEBGL_VERTEX_FLOATS) { + vertices[index + 1] += y_offset; + } +} + +fn ensure_glyph_rows_in_atlas( + state: &mut WebGlRendererState, + frame: CanvasSurfaceFrame<'_>, + rows: &[VisibleGlyphRow], stats: &mut WebGlRenderStats, ) -> Result<(), String> { for attempt in 0..2 { @@ -1415,11 +1513,11 @@ fn ensure_glyphs_in_atlas( f64::from(state.atlas.size), ); } + let missing = + missing_glyph_keys(state, rows).ok_or_else(|| "missing cached glyph row".to_owned())?; let mut ok = true; - for cell in cells { - if state.atlas.get(&cell.key).is_none() - && draw_glyph_into_atlas(state, frame, &cell.key, stats)?.is_none() - { + for key in missing { + if draw_glyph_into_atlas(state, frame, &key, stats)?.is_none() { ok = false; break; } @@ -1431,6 +1529,22 @@ fn ensure_glyphs_in_atlas( Err("glyph atlas is full".to_owned()) } +fn missing_glyph_keys( + state: &WebGlRendererState, + rows: &[VisibleGlyphRow], +) -> Option> { + let mut missing = Vec::new(); + for row in rows { + let cells = state.row_cache.cells_for_row(row.cache_key)?; + for cell in cells { + if state.atlas.get(&cell.key).is_none() { + missing.push(cell.key.clone()); + } + } + } + Some(missing) +} + fn draw_glyph_into_atlas( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, @@ -1767,11 +1881,13 @@ mod tests { let first = cache .glyphs_for_row(&row, &options, &mut stats) .expect("first row") + .1 .to_vec(); assert_eq!(cache.glyph_rows.len(), 1); let second = cache .glyphs_for_row(&row, &options, &mut stats) .expect("cached row") + .1 .to_vec(); assert_eq!(cache.glyph_rows.len(), 1); assert_eq!(first, second); @@ -1782,6 +1898,7 @@ mod tests { let changed = cache .glyphs_for_row(&row, &options, &mut stats) .expect("theme changed row") + .1 .to_vec(); assert_eq!(cache.glyph_rows.len(), 2); assert_ne!(first[0].fg, changed[0].fg); @@ -1803,15 +1920,59 @@ mod tests { let mut cache = WebGlRowCache::default(); let rows = vec![row.clone(), row]; - let (cells, stats) = collect_glyph_cells(&rows, &options, &mut cache).expect("cells"); + let (glyph_rows, cell_count, stats) = + collect_glyph_rows(&rows, &options, &mut cache).expect("cells"); - assert_eq!(cells.len(), 4); - assert_eq!(cells[0].row, 0); - assert_eq!(cells[1].row, 0); - assert_eq!(cells[2].row, 1); - assert_eq!(cells[3].row, 1); + assert_eq!(glyph_rows.len(), 2); + assert_eq!(cell_count, 4); + assert_eq!(glyph_rows[0].row, 0); + assert_eq!(glyph_rows[0].cell_count, 2); + assert_eq!(glyph_rows[1].row, 1); + assert_eq!(glyph_rows[1].cell_count, 2); assert_eq!(cache.glyph_rows.len(), 1); - assert_eq!(stats, WebGlRowCacheStats { hits: 1, misses: 1 }); + assert_eq!( + stats, + WebGlRowCacheStats { + hits: 1, + misses: 1, + vertex_hits: 0, + vertex_misses: 0 + } + ); + } + + #[test] + fn row_vertex_template_offsets_y_without_touching_x_or_uv() { + let dom = CanvasDomState { + backing_width: 200, + backing_height: 100, + css_width: 100, + css_height: 50, + top_px: 0.0, + }; + let mut template = Vec::new(); + push_quad( + &mut template, + GlyphQuad { + x: 10.0, + y: 0.0, + width: 10.0, + height: 10.0, + uv: [0.1, 0.2, 0.3, 0.4], + color: [1.0, 0.5, 0.25, 1.0], + }, + dom, + ); + let mut vertices = Vec::new(); + + append_row_vertex_template(&mut vertices, &template, 5.0, dom); + + assert_eq!(vertices.len(), template.len()); + assert_eq!(vertices[0], template[0]); + assert_eq!(vertices[2], template[2]); + assert_eq!(vertices[4], template[4]); + assert_eq!(vertices[1], template[1] - 0.2); + assert_eq!(vertices[9], template[9] - 0.2); } #[test] diff --git a/scripts/terminal-bench.mjs b/scripts/terminal-bench.mjs index 4ab95e1..8026ad3 100644 --- a/scripts/terminal-bench.mjs +++ b/scripts/terminal-bench.mjs @@ -311,6 +311,8 @@ export function summarizeRendererStats(frames) { "cells", "rowCacheHits", "rowCacheMisses", + "vertexCacheHits", + "vertexCacheMisses", "atlasEntries", "atlasInsertions", "atlasResets",