perf(terminal): draw webgl backdrops with quads

This commit is contained in:
zhangheng
2026-06-24 18:46:44 +08:00
parent fb1a6d5fe7
commit a1d5752d59
2 changed files with 289 additions and 58 deletions

View File

@@ -146,7 +146,7 @@
- WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。 - WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。
- `GlyphAtlas` 按 grapheme + bold/italic + cell width 缓存字形ASCII、CJK wide 字符、组合字符按 `unicode-segmentation` / `unicode-width` 拆成 cell glyph。 - `GlyphAtlas` 按 grapheme + bold/italic + cell width 缓存字形ASCII、CJK wide 字符、组合字符按 `unicode-segmentation` / `unicode-width` 拆成 cell glyph。
- 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute小字号不再走整屏纹理二次采样。 - 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute小字号不再走整屏纹理二次采样。
- 背景、underline、cursor 背景由 Canvas backdrop 绘制后作为底层 texture 合成cursor 前景文字仍进 atlas。 - 背景、underline、cursor 背景在 WebGL 正常路径中直接绘制为纯色 quadcursor 前景文字仍进 atlas。
- atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture避免真实终端内容白屏。 - atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture避免真实终端内容白屏。
- WebGL2 不可用时自动落回 Canvas 2D。 - WebGL2 不可用时自动落回 Canvas 2D。
2. **Canvas 2D 渲染器** 2. **Canvas 2D 渲染器**
@@ -255,6 +255,10 @@
- WebGL render path 记录阶段耗时DOM/canvas 同步、backdrop 绘制、glyph collect、backdrop texture pass、glyph atlas、atlas upload、vertex build/upload、draw call。 - 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 次数。 - 记录计数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。 - `window.__terminalBench.webglFrames` 暴露每帧采样,`scripts/terminal-bench.mjs` 汇总为 `rendererStats`,用于判断慢点是整屏 backdrop texture、atlas、vertex buffer还是 fallback。
6. **WebGL backdrop texture pass 拆除**
- 正常 WebGL 路径不再每帧把 Canvas backdrop 上传为整屏 texture背景、underline、cursor 背景改为 1×1 白色纹理 + 纯色 quad 批量绘制。
- `backdropTextureMs` 只在复杂 glyph / atlas 失败等 full-canvas fallback 路径产生,用于继续观察 fallback 是否被真实内容触发。
- 页面切换、`?renderer=webgl``--renderer webgl` 保留,下一轮基准可直接对比这次优化后 WebGL 是否追近 Canvas。
--- ---

View File

@@ -460,6 +460,7 @@ pub(super) struct WebGlRendererState {
program: WebGlProgram, program: WebGlProgram,
atlas_texture: WebGlTexture, atlas_texture: WebGlTexture,
fallback_texture: WebGlTexture, fallback_texture: WebGlTexture,
solid_texture: WebGlTexture,
buffer: WebGlBuffer, buffer: WebGlBuffer,
position_location: u32, position_location: u32,
tex_coord_location: u32, tex_coord_location: u32,
@@ -573,6 +574,15 @@ struct GlyphQuad {
color: [f32; 4], color: [f32; 4],
} }
#[derive(Clone, Copy, Debug, PartialEq)]
struct SolidQuad {
x: f64,
y: f64,
width: f64,
height: f64,
color: [f32; 4],
}
fn split_segment_glyphs( fn split_segment_glyphs(
segment: &TerminalSegment, segment: &TerminalSegment,
row: usize, row: usize,
@@ -631,6 +641,75 @@ fn collect_glyph_cells(
Some(cells) Some(cells)
} }
fn collect_backdrop_quads(frame: CanvasSurfaceFrame<'_>) -> Option<Vec<SolidQuad>> {
let (cell_w, cell_h) = frame.cell_size;
let mut quads = Vec::new();
for (row_idx, row) in frame.visible.iter().enumerate() {
let y = frame.content_top_px + row_idx as f64 * cell_h;
let mut col = 0usize;
for segment in &row.segments {
let x = col as f64 * cell_w;
let width = segment.cols as f64 * cell_w;
if segment.is_cursor {
let rect =
cursor_shape_rect(frame.options.cursor.style, x, y, width.max(cell_w), cell_h);
push_color_rect(
&mut quads,
rect.x,
rect.y,
rect.width,
rect.height,
&frame.options.theme.cursor_background,
)?;
} else {
push_color_rect(
&mut quads,
x,
y,
width,
cell_h,
themed_color(&segment.style.background, frame.options),
)?;
}
if segment.style.underline {
let color = if segment.is_cursor {
&frame.options.theme.cursor_foreground
} else {
themed_color(&segment.style.color, frame.options)
};
push_color_rect(&mut quads, x, y + cell_h - 1.0, width, 1.0, color)?;
}
col += segment.cols;
}
}
Some(quads)
}
fn push_color_rect(
quads: &mut Vec<SolidQuad>,
x: f64,
y: f64,
width: f64,
height: f64,
color: &str,
) -> Option<()> {
let color = parse_css_rgb(color)?;
if color[3] <= 0.0 || width <= 0.0 || height <= 0.0 {
return Some(());
}
quads.push(SolidQuad {
x,
y,
width,
height,
color,
});
Some(())
}
fn parse_css_rgb(color: &str) -> Option<[f32; 4]> { fn parse_css_rgb(color: &str) -> Option<[f32; 4]> {
let color = color.trim(); let color = color.trim();
if color == "transparent" { if color == "transparent" {
@@ -678,6 +757,21 @@ fn push_quad(vertices: &mut Vec<f32>, quad: GlyphQuad, dom_state: CanvasDomState
vertices.extend_from_slice(&data); vertices.extend_from_slice(&data);
} }
fn push_solid_quad(vertices: &mut Vec<f32>, quad: SolidQuad, dom_state: CanvasDomState) {
push_quad(
vertices,
GlyphQuad {
x: quad.x,
y: quad.y,
width: quad.width,
height: quad.height,
uv: [0.0, 0.0, 1.0, 1.0],
color: quad.color,
},
dom_state,
);
}
fn glyph_uv(entry: GlyphAtlasEntry, atlas_size: u32) -> [f64; 4] { fn glyph_uv(entry: GlyphAtlasEntry, atlas_size: u32) -> [f64; 4] {
let size = f64::from(atlas_size); let size = f64::from(atlas_size);
[ [
@@ -716,47 +810,53 @@ pub(super) fn render_webgl(
return Err("WebGL context is lost".to_owned()); 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);
stats.backdrop_ms = perf_now() - backdrop_start;
let collect_start = perf_now(); let collect_start = perf_now();
let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else { let Some(cells) = collect_glyph_cells(frame.visible, frame.options) else {
stats.collect_ms = perf_now() - collect_start; stats.collect_ms = perf_now() - collect_start;
stats.fallback_full_canvas = true; render_webgl_canvas_fallback(state, frame, &mut stats)?;
let fallback_canvas_start = perf_now(); stats.total_ms = perf_now() - total_start;
render_canvas(&state.source_ctx, frame); return Ok(stats);
stats.fallback_canvas_ms += perf_now() - fallback_canvas_start; };
let texture_start = perf_now(); let Some(backdrop_quads) = collect_backdrop_quads(frame) else {
draw_webgl_texture_frame(state, frame.dom_state)?; stats.collect_ms = perf_now() - collect_start;
stats.backdrop_texture_ms += perf_now() - texture_start; render_webgl_canvas_fallback(state, frame, &mut stats)?;
stats.total_ms = perf_now() - total_start; stats.total_ms = perf_now() - total_start;
return Ok(stats); return Ok(stats);
}; };
stats.collect_ms = perf_now() - collect_start; stats.collect_ms = perf_now() - collect_start;
stats.cells = cells.len(); stats.cells = cells.len();
let texture_start = perf_now(); let backdrop_start = perf_now();
draw_webgl_texture_frame(state, frame.dom_state)?; draw_webgl_backdrop_quads(state, frame.dom_state, &backdrop_quads)?;
stats.backdrop_texture_ms += perf_now() - texture_start; stats.backdrop_ms = perf_now() - backdrop_start;
if draw_webgl_glyphs(state, frame, &cells, &mut stats).is_err() { if draw_webgl_glyphs(state, frame, &cells, &mut stats).is_err() {
stats.glyph_draw_failed = true; stats.glyph_draw_failed = true;
let fallback_canvas_start = perf_now(); render_webgl_canvas_fallback(state, frame, &mut stats)?;
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.atlas_entries = state.atlas.entries.len(); stats.atlas_entries = state.atlas.entries.len();
stats.total_ms = perf_now() - total_start; stats.total_ms = perf_now() - total_start;
Ok(stats) Ok(stats)
} }
fn render_webgl_canvas_fallback(
state: &WebGlRendererState,
frame: CanvasSurfaceFrame<'_>,
stats: &mut WebGlRenderStats,
) -> Result<(), String> {
stats.fallback_full_canvas = true;
let fallback_canvas_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(&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(())
}
fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result<WebGlRendererState, String> { fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result<WebGlRendererState, String> {
let gl = canvas let gl = canvas
.get_context("webgl2") .get_context("webgl2")
@@ -805,6 +905,7 @@ fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result<WebGlRendererStat
.create_texture() .create_texture()
.ok_or_else(|| "failed to create WebGL fallback texture".to_owned())?; .ok_or_else(|| "failed to create WebGL fallback texture".to_owned())?;
configure_webgl_texture(&gl, &fallback_texture); configure_webgl_texture(&gl, &fallback_texture);
let solid_texture = create_webgl_solid_texture(&gl)?;
gl.pixel_storei(WebGl2RenderingContext::UNPACK_FLIP_Y_WEBGL, 1); gl.pixel_storei(WebGl2RenderingContext::UNPACK_FLIP_Y_WEBGL, 1);
gl.pixel_storei(WebGl2RenderingContext::UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1); gl.pixel_storei(WebGl2RenderingContext::UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
gl.enable(WebGl2RenderingContext::BLEND); gl.enable(WebGl2RenderingContext::BLEND);
@@ -851,6 +952,7 @@ fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result<WebGlRendererStat
program, program,
atlas_texture, atlas_texture,
fallback_texture, fallback_texture,
solid_texture,
buffer, buffer,
position_location: position_location as u32, position_location: position_location as u32,
tex_coord_location: tex_coord_location as u32, tex_coord_location: tex_coord_location as u32,
@@ -883,6 +985,27 @@ fn configure_webgl_texture(gl: &WebGl2RenderingContext, texture: &WebGlTexture)
); );
} }
fn create_webgl_solid_texture(gl: &WebGl2RenderingContext) -> Result<WebGlTexture, String> {
let texture = gl
.create_texture()
.ok_or_else(|| "failed to create WebGL solid texture".to_owned())?;
configure_webgl_texture(gl, &texture);
let white = [255, 255, 255, 255];
gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array(
WebGl2RenderingContext::TEXTURE_2D,
0,
WebGl2RenderingContext::RGBA as i32,
1,
1,
0,
WebGl2RenderingContext::RGBA,
WebGl2RenderingContext::UNSIGNED_BYTE,
Some(&white),
)
.map_err(|_| "failed to upload WebGL solid texture".to_owned())?;
Ok(texture)
}
fn compile_webgl_shader( fn compile_webgl_shader(
gl: &WebGl2RenderingContext, gl: &WebGl2RenderingContext,
shader_type: u32, shader_type: u32,
@@ -1013,6 +1136,46 @@ fn draw_webgl_texture_frame(
Ok(()) Ok(())
} }
fn draw_webgl_backdrop_quads(
state: &WebGlRendererState,
dom_state: CanvasDomState,
quads: &[SolidQuad],
) -> Result<(), String> {
let gl = &state.gl;
gl.viewport(
0,
0,
dom_state.backing_width as i32,
dom_state.backing_height as i32,
);
gl.clear_color(0.0, 0.0, 0.0, 0.0);
gl.clear(WebGl2RenderingContext::COLOR_BUFFER_BIT);
if quads.is_empty() {
return Ok(());
}
let mut vertices = Vec::with_capacity(quads.len() * WEBGL_VERTEX_FLOATS * 6);
for quad in quads {
push_solid_quad(&mut vertices, *quad, dom_state);
}
gl.use_program(Some(&state.program));
bind_webgl_vertex_layout(gl, state);
upload_webgl_vertices(gl, &vertices);
gl.active_texture(WebGl2RenderingContext::TEXTURE0);
gl.bind_texture(
WebGl2RenderingContext::TEXTURE_2D,
Some(&state.solid_texture),
);
gl.uniform1i(state.sampler_location.as_ref(), 0);
gl.draw_arrays(
WebGl2RenderingContext::TRIANGLES,
0,
(vertices.len() / WEBGL_VERTEX_FLOATS) as i32,
);
Ok(())
}
fn draw_webgl_glyphs( fn draw_webgl_glyphs(
state: &mut WebGlRendererState, state: &mut WebGlRendererState,
frame: CanvasSurfaceFrame<'_>, frame: CanvasSurfaceFrame<'_>,
@@ -1174,39 +1337,6 @@ fn draw_glyph_into_atlas(
Ok(Some(entry)) Ok(Some(entry))
} }
fn render_canvas_backdrop(ctx: &CanvasRenderingContext2d, frame: CanvasSurfaceFrame<'_>) {
let (cell_w, cell_h) = frame.cell_size;
let canvas_width = ctx.canvas().map(|c| c.width() as f64).unwrap_or(0.0);
let canvas_height = ctx.canvas().map(|c| c.height() as f64).unwrap_or(0.0);
ctx.clear_rect(0.0, 0.0, canvas_width, canvas_height);
for (row_idx, row) in frame.visible.iter().enumerate() {
let y = frame.content_top_px + row_idx as f64 * cell_h;
let mut col = 0usize;
for segment in &row.segments {
let x = col as f64 * cell_w;
let width = segment.cols as f64 * cell_w;
if segment.is_cursor {
ctx.set_fill_style_str(&frame.options.theme.cursor_background);
let rect =
cursor_shape_rect(frame.options.cursor.style, x, y, width.max(cell_w), cell_h);
ctx.fill_rect(rect.x, rect.y, rect.width, rect.height);
} else if set_fill_color(ctx, themed_color(&segment.style.background, frame.options)) {
ctx.fill_rect(x, y, width, cell_h);
}
if segment.style.underline {
if segment.is_cursor {
ctx.set_fill_style_str(&frame.options.theme.cursor_foreground);
} else {
set_fill_color(ctx, themed_color(&segment.style.color, frame.options));
}
ctx.fill_rect(x, y + cell_h - 1.0, width, 1.0);
}
col += segment.cols;
}
}
}
fn cursor_cell_text(row: &TerminalRow, cursor_col: usize) -> Option<String> { fn cursor_cell_text(row: &TerminalRow, cursor_col: usize) -> Option<String> {
let mut col = 0usize; let mut col = 0usize;
for segment in &row.segments { for segment in &row.segments {
@@ -1523,6 +1653,103 @@ mod tests {
assert_eq!(&vertices[0..8], &[-1.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0]); assert_eq!(&vertices[0..8], &[-1.0, 0.0, 0.0, 0.5, 1.0, 0.5, 0.0, 1.0]);
} }
#[test]
fn push_solid_quad_uses_full_white_texture_uvs() {
let dom = CanvasDomState {
backing_width: 200,
backing_height: 100,
css_width: 100,
css_height: 50,
top_px: 0.0,
};
let mut vertices = Vec::new();
push_solid_quad(
&mut vertices,
SolidQuad {
x: 0.0,
y: 0.0,
width: 50.0,
height: 25.0,
color: [0.25, 0.5, 0.75, 1.0],
},
dom,
);
assert_eq!(vertices.len(), WEBGL_VERTEX_FLOATS * 6);
assert_eq!(
&vertices[0..8],
&[-1.0, 0.0, 0.0, 1.0, 0.25, 0.5, 0.75, 1.0]
);
}
#[test]
fn collect_backdrop_quads_skips_transparent_and_tracks_cursor_overlay() {
let mut options = default_options();
options.cursor.style = TerminalCursorStyle::Underline;
options.theme.cursor_background = "#ffffff".to_owned();
options.theme.cursor_foreground = "#000000".to_owned();
let rows = vec![TerminalRow {
segments: vec![
TerminalSegment {
text: "ab".to_owned(),
cols: 2,
style: TerminalStyle {
background: "transparent".to_owned(),
underline: true,
color: "#80ff00".to_owned(),
..TerminalStyle::default()
},
is_cursor: false,
},
TerminalSegment {
text: "c".to_owned(),
cols: 1,
style: TerminalStyle::default(),
is_cursor: true,
},
],
}];
let frame = CanvasSurfaceFrame {
dom_state: CanvasDomState {
backing_width: 300,
backing_height: 90,
css_width: 100,
css_height: 30,
top_px: 0.0,
},
dpr: 1.0,
visible: &rows,
cell_size: (10.0, 18.0),
content_top_px: 4.0,
cursor_pos: Some((0, 2)),
options: &options,
};
let quads = collect_backdrop_quads(frame).expect("backdrop quads");
assert_eq!(quads.len(), 2);
assert_eq!(
quads[0],
SolidQuad {
x: 0.0,
y: 21.0,
width: 20.0,
height: 1.0,
color: [128.0 / 255.0, 1.0, 0.0, 1.0],
}
);
assert_eq!(
quads[1],
SolidQuad {
x: 20.0,
y: 19.0,
width: 10.0,
height: 3.0,
color: [1.0, 1.0, 1.0, 1.0],
}
);
}
#[test] #[test]
fn canvas_viewport_top_pins_live_mode_to_bottom() { fn canvas_viewport_top_pins_live_mode_to_bottom() {
let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX; let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX;