test(terminal): profile webgl render stages
This commit is contained in:
@@ -63,6 +63,7 @@ web-sys = { version = "0.3", default-features = false, features = [
|
||||
"MessageEvent",
|
||||
"MouseEvent",
|
||||
"Navigator",
|
||||
"Performance",
|
||||
"ResizeObserver",
|
||||
"ResizeObserverEntry",
|
||||
"WebGl2RenderingContext",
|
||||
|
||||
@@ -251,6 +251,10 @@
|
||||
- `240x100`:WebGL `92.48 rows/sec`,frame p95 `399.9ms`;Canvas `157.65 rows/sec`,frame p95 `116.8ms`。
|
||||
- 两种 renderer 的 pixel smoke 均通过;Canvas 可直接读取 canvas 像素,WebGL 走 screenshot smoke。
|
||||
- 当前策略:默认 Canvas;WebGL 继续通过页面切换、`?renderer=webgl` 和 `--renderer webgl` 保留优化回归入口。
|
||||
5. **WebGL renderer profiling / 慢路径拆解** ✅
|
||||
- WebGL render path 记录阶段耗时:DOM/canvas 同步、backdrop 绘制、glyph collect、backdrop texture pass、glyph atlas、atlas upload、vertex build/upload、draw call。
|
||||
- 记录计数:cells、atlas entries、atlas insertions/resets、full-canvas fallback 次数、glyph draw failed 次数。
|
||||
- `window.__terminalBench.webglFrames` 暴露每帧采样,`scripts/terminal-bench.mjs` 汇总为 `rendererStats`,用于判断慢点是整屏 backdrop texture、atlas、vertex buffer,还是 fallback。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
|
||||
"history"
|
||||
};
|
||||
let renderer = format!("{:?}", event.renderer);
|
||||
let webgl_stats = event.webgl_stats;
|
||||
|
||||
let _ = js_sys::Reflect::set(
|
||||
&probe,
|
||||
@@ -152,6 +153,61 @@ fn update_terminal_bench_probe(event: TerminalRenderEvent) {
|
||||
&JsValue::from_str("screenRows"),
|
||||
&JsValue::from_f64(event.screen_rows as f64),
|
||||
);
|
||||
if let Some(stats) = webgl_stats {
|
||||
append_webgl_stats(&probe, stats);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn append_webgl_stats(probe: &wasm_bindgen::JsValue, stats: crate::terminal::WebGlRenderStats) {
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
let frames = match js_sys::Reflect::get(probe, &JsValue::from_str("webglFrames")) {
|
||||
Ok(value) if js_sys::Array::is_array(&value) => js_sys::Array::from(&value),
|
||||
_ => {
|
||||
let array = js_sys::Array::new();
|
||||
let _ = js_sys::Reflect::set(probe, &JsValue::from_str("webglFrames"), &array);
|
||||
array
|
||||
}
|
||||
};
|
||||
|
||||
let frame = js_sys::Object::new();
|
||||
set_f64(&frame, "totalMs", stats.total_ms);
|
||||
set_f64(&frame, "syncDomMs", stats.sync_dom_ms);
|
||||
set_f64(&frame, "backdropMs", stats.backdrop_ms);
|
||||
set_f64(&frame, "collectMs", stats.collect_ms);
|
||||
set_f64(&frame, "backdropTextureMs", stats.backdrop_texture_ms);
|
||||
set_f64(&frame, "fallbackCanvasMs", stats.fallback_canvas_ms);
|
||||
set_f64(&frame, "atlasMs", stats.atlas_ms);
|
||||
set_f64(&frame, "atlasUploadMs", stats.atlas_upload_ms);
|
||||
set_f64(&frame, "vertexBuildMs", stats.vertex_build_ms);
|
||||
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, "atlasEntries", stats.atlas_entries as f64);
|
||||
set_f64(&frame, "atlasInsertions", stats.atlas_insertions as f64);
|
||||
set_f64(&frame, "atlasResets", stats.atlas_resets as f64);
|
||||
set_bool(&frame, "fallbackFullCanvas", stats.fallback_full_canvas);
|
||||
set_bool(&frame, "glyphDrawFailed", stats.glyph_draw_failed);
|
||||
|
||||
frames.push(&frame);
|
||||
while frames.length() > 720 {
|
||||
frames.shift();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn set_f64(target: &js_sys::Object, key: &str, value: f64) {
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_f64(value));
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn set_bool(target: &js_sys::Object, key: &str, value: bool) {
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
let _ = js_sys::Reflect::set(target, &JsValue::from_str(key), &JsValue::from_bool(value));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -28,7 +28,7 @@ use super::mouse::{
|
||||
use super::protocol::{ClientTerminalMessage, ServerTerminalMessage};
|
||||
use super::renderer::{
|
||||
CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode,
|
||||
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRendererState,
|
||||
RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRenderStats, WebGlRendererState,
|
||||
attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl,
|
||||
set_canvas_surface_mode, webgl_surface_mode,
|
||||
};
|
||||
@@ -235,7 +235,7 @@ pub struct TerminalSelectionEvent {
|
||||
pub end: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct TerminalRenderEvent {
|
||||
pub cols: u16,
|
||||
pub rows: u16,
|
||||
@@ -243,6 +243,7 @@ pub struct TerminalRenderEvent {
|
||||
pub screen_rows: usize,
|
||||
pub mode: ViewMode,
|
||||
pub renderer: Renderer,
|
||||
pub webgl_stats: Option<WebGlRenderStats>,
|
||||
}
|
||||
|
||||
type SerializeFn = Arc<dyn Fn() -> String + Send + Sync>;
|
||||
@@ -514,6 +515,7 @@ pub fn TerminalPanel(
|
||||
let selecting = RwSignal::new(false);
|
||||
let search_match = RwSignal::new(None::<TerminalSearchMatch>);
|
||||
let search_query = RwSignal::new(String::new());
|
||||
let last_webgl_stats = RwSignal::new(None::<WebGlRenderStats>);
|
||||
|
||||
// IME composition state: when true, regular `input` events are ignored and
|
||||
// the final composed string is sent on `compositionend`.
|
||||
@@ -624,6 +626,7 @@ pub fn TerminalPanel(
|
||||
screen_rows: core.screen_rows().len(),
|
||||
mode,
|
||||
renderer,
|
||||
webgl_stats: last_webgl_stats.get_untracked(),
|
||||
}) {
|
||||
on_render.run(event);
|
||||
}
|
||||
@@ -921,15 +924,14 @@ pub fn TerminalPanel(
|
||||
};
|
||||
match renderer {
|
||||
Renderer::WebGl => {
|
||||
if render_webgl(
|
||||
if let Ok(stats) = render_webgl(
|
||||
canvas_el,
|
||||
frame,
|
||||
&canvas_state,
|
||||
&webgl_state,
|
||||
&webgl_context_lost,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
) {
|
||||
last_webgl_stats.set(Some(stats));
|
||||
set_canvas_surface_mode(
|
||||
canvas_el,
|
||||
fallback_canvas_el,
|
||||
@@ -941,6 +943,7 @@ pub fn TerminalPanel(
|
||||
fallback_canvas_el,
|
||||
webgl_surface_mode(false),
|
||||
);
|
||||
last_webgl_stats.set(None);
|
||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||
canvas_el: fallback_canvas_el,
|
||||
frame,
|
||||
@@ -949,6 +952,7 @@ pub fn TerminalPanel(
|
||||
}
|
||||
}
|
||||
Renderer::Canvas => {
|
||||
last_webgl_stats.set(None);
|
||||
set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl);
|
||||
render_canvas_surface(RenderCanvasSurfaceRequest {
|
||||
canvas_el,
|
||||
@@ -956,7 +960,9 @@ pub fn TerminalPanel(
|
||||
canvas_state: &canvas_state,
|
||||
});
|
||||
}
|
||||
Renderer::Dom => {}
|
||||
Renderer::Dom => {
|
||||
last_webgl_stats.set(None);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -14,3 +14,4 @@ pub use component::{
|
||||
TerminalSelectionEvent, TerminalTheme,
|
||||
};
|
||||
pub use core::{SearchDirection, TerminalLink, TerminalSearchMatch};
|
||||
pub use renderer::WebGlRenderStats;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use js_sys::Float32Array;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys::Float32Array;
|
||||
use web_sys::{
|
||||
CanvasRenderingContext2d, Event, HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer,
|
||||
WebGlProgram, WebGlShader, WebGlTexture, WebGlUniformLocation,
|
||||
@@ -364,6 +364,40 @@ pub(super) fn attach_webgl_context_lifecycle(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct WebGlRenderStats {
|
||||
pub total_ms: f64,
|
||||
pub sync_dom_ms: f64,
|
||||
pub backdrop_ms: f64,
|
||||
pub collect_ms: f64,
|
||||
pub backdrop_texture_ms: f64,
|
||||
pub fallback_canvas_ms: f64,
|
||||
pub atlas_ms: f64,
|
||||
pub atlas_upload_ms: f64,
|
||||
pub vertex_build_ms: f64,
|
||||
pub vertex_upload_ms: f64,
|
||||
pub draw_ms: f64,
|
||||
pub cells: usize,
|
||||
pub atlas_entries: usize,
|
||||
pub atlas_insertions: usize,
|
||||
pub atlas_resets: usize,
|
||||
pub fallback_full_canvas: bool,
|
||||
pub glyph_draw_failed: bool,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn perf_now() -> f64 {
|
||||
web_sys::window()
|
||||
.and_then(|window| window.performance())
|
||||
.map(|performance| performance.now())
|
||||
.unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn perf_now() -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
const WEBGL_VERTEX_SHADER: &str = r#"
|
||||
attribute vec2 a_position;
|
||||
attribute vec2 a_tex_coord;
|
||||
@@ -660,11 +694,15 @@ pub(super) fn render_webgl(
|
||||
canvas_state: &Rc<std::cell::RefCell<Option<CanvasDomState>>>,
|
||||
webgl_state: &Rc<std::cell::RefCell<Option<WebGlRendererState>>>,
|
||||
context_lost: &Rc<std::cell::Cell<bool>>,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<WebGlRenderStats, String> {
|
||||
let total_start = perf_now();
|
||||
let mut stats = WebGlRenderStats::default();
|
||||
if !should_attempt_webgl_render(context_lost.get()) {
|
||||
return Err("WebGL context is lost".to_owned());
|
||||
}
|
||||
let sync_start = perf_now();
|
||||
sync_canvas_dom(canvas, frame.dom_state, canvas_state);
|
||||
stats.sync_dom_ms += perf_now() - sync_start;
|
||||
let mut state_ref = webgl_state.borrow_mut();
|
||||
if state_ref.is_none() {
|
||||
*state_ref = Some(create_webgl_renderer(canvas)?);
|
||||
@@ -678,23 +716,45 @@ pub(super) fn render_webgl(
|
||||
return Err("WebGL context is lost".to_owned());
|
||||
}
|
||||
|
||||
let backdrop_start = perf_now();
|
||||
sync_canvas_bitmap(&state.source_canvas, frame.dom_state);
|
||||
let _ = state
|
||||
.source_ctx
|
||||
.set_transform(frame.dpr, 0.0, 0.0, frame.dpr, 0.0, 0.0);
|
||||
render_canvas_backdrop(&state.source_ctx, frame);
|
||||
let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else {
|
||||
render_canvas(&state.source_ctx, frame);
|
||||
draw_webgl_texture_frame(state, frame.dom_state)?;
|
||||
return Ok(());
|
||||
};
|
||||
stats.backdrop_ms = perf_now() - backdrop_start;
|
||||
|
||||
draw_webgl_texture_frame(state, frame.dom_state)?;
|
||||
if draw_webgl_glyphs(state, frame, &cells).is_err() {
|
||||
let collect_start = perf_now();
|
||||
let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else {
|
||||
stats.collect_ms = perf_now() - collect_start;
|
||||
stats.fallback_full_canvas = true;
|
||||
let fallback_canvas_start = perf_now();
|
||||
render_canvas(&state.source_ctx, frame);
|
||||
stats.fallback_canvas_ms += perf_now() - fallback_canvas_start;
|
||||
let texture_start = perf_now();
|
||||
draw_webgl_texture_frame(state, frame.dom_state)?;
|
||||
stats.backdrop_texture_ms += perf_now() - texture_start;
|
||||
stats.total_ms = perf_now() - total_start;
|
||||
return Ok(stats);
|
||||
};
|
||||
stats.collect_ms = perf_now() - collect_start;
|
||||
stats.cells = cells.len();
|
||||
|
||||
let texture_start = perf_now();
|
||||
draw_webgl_texture_frame(state, frame.dom_state)?;
|
||||
stats.backdrop_texture_ms += perf_now() - texture_start;
|
||||
if draw_webgl_glyphs(state, frame, &cells, &mut stats).is_err() {
|
||||
stats.glyph_draw_failed = true;
|
||||
let fallback_canvas_start = perf_now();
|
||||
render_canvas(&state.source_ctx, frame);
|
||||
stats.fallback_canvas_ms += perf_now() - fallback_canvas_start;
|
||||
let texture_start = perf_now();
|
||||
draw_webgl_texture_frame(state, frame.dom_state)?;
|
||||
stats.backdrop_texture_ms += perf_now() - texture_start;
|
||||
}
|
||||
Ok(())
|
||||
stats.atlas_entries = state.atlas.entries.len();
|
||||
stats.total_ms = perf_now() - total_start;
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result<WebGlRendererState, String> {
|
||||
@@ -957,9 +1017,13 @@ fn draw_webgl_glyphs(
|
||||
state: &mut WebGlRendererState,
|
||||
frame: CanvasSurfaceFrame<'_>,
|
||||
cells: &[GlyphCell],
|
||||
stats: &mut WebGlRenderStats,
|
||||
) -> Result<(), String> {
|
||||
ensure_glyphs_in_atlas(state, frame, cells)?;
|
||||
let atlas_start = perf_now();
|
||||
ensure_glyphs_in_atlas(state, frame, cells, stats)?;
|
||||
stats.atlas_ms += perf_now() - atlas_start;
|
||||
if state.atlas.dirty {
|
||||
let upload_start = perf_now();
|
||||
state.gl.bind_texture(
|
||||
WebGl2RenderingContext::TEXTURE_2D,
|
||||
Some(&state.atlas_texture),
|
||||
@@ -976,8 +1040,10 @@ fn draw_webgl_glyphs(
|
||||
)
|
||||
.map_err(|_| "failed to upload glyph atlas texture".to_owned())?;
|
||||
state.atlas.dirty = false;
|
||||
stats.atlas_upload_ms += perf_now() - upload_start;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -998,6 +1064,7 @@ fn draw_webgl_glyphs(
|
||||
frame.dom_state,
|
||||
);
|
||||
}
|
||||
stats.vertex_build_ms += perf_now() - vertex_start;
|
||||
if vertices.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1005,18 +1072,22 @@ fn draw_webgl_glyphs(
|
||||
let gl = &state.gl;
|
||||
gl.use_program(Some(&state.program));
|
||||
bind_webgl_vertex_layout(gl, state);
|
||||
let upload_start = perf_now();
|
||||
upload_webgl_vertices(gl, &vertices);
|
||||
stats.vertex_upload_ms += perf_now() - upload_start;
|
||||
gl.active_texture(WebGl2RenderingContext::TEXTURE0);
|
||||
gl.bind_texture(
|
||||
WebGl2RenderingContext::TEXTURE_2D,
|
||||
Some(&state.atlas_texture),
|
||||
);
|
||||
gl.uniform1i(state.sampler_location.as_ref(), 0);
|
||||
let draw_start = perf_now();
|
||||
gl.draw_arrays(
|
||||
WebGl2RenderingContext::TRIANGLES,
|
||||
0,
|
||||
(vertices.len() / WEBGL_VERTEX_FLOATS) as i32,
|
||||
);
|
||||
stats.draw_ms += perf_now() - draw_start;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1024,10 +1095,12 @@ fn ensure_glyphs_in_atlas(
|
||||
state: &mut WebGlRendererState,
|
||||
frame: CanvasSurfaceFrame<'_>,
|
||||
cells: &[GlyphCell],
|
||||
stats: &mut WebGlRenderStats,
|
||||
) -> Result<(), String> {
|
||||
for attempt in 0..2 {
|
||||
if attempt == 1 {
|
||||
state.atlas.reset();
|
||||
stats.atlas_resets += 1;
|
||||
state.atlas_ctx.clear_rect(
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -1038,7 +1111,7 @@ fn ensure_glyphs_in_atlas(
|
||||
let mut ok = true;
|
||||
for cell in cells {
|
||||
if state.atlas.get(&cell.key).is_none()
|
||||
&& draw_glyph_into_atlas(state, frame, &cell.key)?.is_none()
|
||||
&& draw_glyph_into_atlas(state, frame, &cell.key, stats)?.is_none()
|
||||
{
|
||||
ok = false;
|
||||
break;
|
||||
@@ -1055,6 +1128,7 @@ fn draw_glyph_into_atlas(
|
||||
state: &mut WebGlRendererState,
|
||||
frame: CanvasSurfaceFrame<'_>,
|
||||
key: &GlyphKey,
|
||||
stats: &mut WebGlRenderStats,
|
||||
) -> Result<Option<GlyphAtlasEntry>, String> {
|
||||
let dpr = frame.dpr.max(1.0);
|
||||
let width = (key.cols as f64 * frame.cell_size.0 * dpr).ceil() as u32;
|
||||
@@ -1096,6 +1170,7 @@ fn draw_glyph_into_atlas(
|
||||
ctx.fill_text(&key.text, f64::from(entry.x), f64::from(entry.y))
|
||||
.map_err(|_| "failed to draw glyph into atlas".to_owned())?;
|
||||
ctx.restore();
|
||||
stats.atlas_insertions += 1;
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
|
||||
@@ -222,6 +222,9 @@ async function runScenario(browser, options, viewport) {
|
||||
frameSamples.push(
|
||||
...(await page.evaluate(() => window.__terminalBenchFrames || [])),
|
||||
);
|
||||
const rendererStats = await page.evaluate(() => window.__terminalBench?.webglFrames || []);
|
||||
const cleanProbe = { ...probe };
|
||||
delete cleanProbe.webglFrames;
|
||||
const canvasPixelSmoke = await page.evaluate(() => {
|
||||
const canvas = document.querySelector("canvas.terminal-canvas:not([hidden])");
|
||||
if (!canvas) {
|
||||
@@ -279,7 +282,8 @@ async function runScenario(browser, options, viewport) {
|
||||
elapsedMs,
|
||||
rowsPerSecond: round(options.lines / (elapsedMs / 1000)),
|
||||
frameMs: summarizeSamples(frameSamples),
|
||||
probe,
|
||||
rendererStats: summarizeRendererStats(rendererStats),
|
||||
probe: cleanProbe,
|
||||
pixelSmoke: pixelResult,
|
||||
screenshotPath,
|
||||
};
|
||||
@@ -288,6 +292,43 @@ async function runScenario(browser, options, viewport) {
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeRendererStats(frames) {
|
||||
if (!Array.isArray(frames) || frames.length === 0) {
|
||||
return { count: 0 };
|
||||
}
|
||||
const numericFields = [
|
||||
"totalMs",
|
||||
"syncDomMs",
|
||||
"backdropMs",
|
||||
"collectMs",
|
||||
"backdropTextureMs",
|
||||
"fallbackCanvasMs",
|
||||
"atlasMs",
|
||||
"atlasUploadMs",
|
||||
"vertexBuildMs",
|
||||
"vertexUploadMs",
|
||||
"drawMs",
|
||||
"cells",
|
||||
"atlasEntries",
|
||||
"atlasInsertions",
|
||||
"atlasResets",
|
||||
];
|
||||
const summary = { count: frames.length };
|
||||
for (const field of numericFields) {
|
||||
const samples = frames
|
||||
.map((frame) => Number(frame[field] || 0))
|
||||
.filter((value) => Number.isFinite(value));
|
||||
summary[field] = summarizeSamples(samples);
|
||||
}
|
||||
summary.fallbackFullCanvasCount = frames.filter(
|
||||
(frame) => Boolean(frame.fallbackFullCanvas),
|
||||
).length;
|
||||
summary.glyphDrawFailedCount = frames.filter(
|
||||
(frame) => Boolean(frame.glyphDrawFailed),
|
||||
).length;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function benchUrl(options) {
|
||||
const url = new URL(options.url);
|
||||
url.searchParams.set("renderer", options.renderer);
|
||||
|
||||
Reference in New Issue
Block a user