diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index ae2be2a..1c903ee 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -146,7 +146,7 @@ - WebGL2 初始化 shader/program/vertex buffer/atlas texture/fallback texture。 - `GlyphAtlas` 按 grapheme + bold/italic + cell width 缓存字形;ASCII、CJK wide 字符、组合字符按 `unicode-segmentation` / `unicode-width` 拆成 cell glyph。 - 文本通过 per-cell quad 批量绘制,颜色作为 vertex attribute;小字号不再走整屏纹理二次采样。 - - 背景、underline、cursor 背景由 Canvas backdrop 绘制后作为底层 texture 合成;cursor 前景文字仍进 atlas。 + - 背景、underline、cursor 背景在 WebGL 正常路径中直接绘制为纯色 quad;cursor 前景文字仍进 atlas。 - atlas 溢出或遇到不可拆分复杂段时自动回退整屏 Canvas texture,避免真实终端内容白屏。 - WebGL2 不可用时自动落回 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。 - 记录计数: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。 +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。 --- diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs index 05c1d6d..c5d0eec 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -460,6 +460,7 @@ pub(super) struct WebGlRendererState { program: WebGlProgram, atlas_texture: WebGlTexture, fallback_texture: WebGlTexture, + solid_texture: WebGlTexture, buffer: WebGlBuffer, position_location: u32, tex_coord_location: u32, @@ -573,6 +574,15 @@ struct GlyphQuad { 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( segment: &TerminalSegment, row: usize, @@ -631,6 +641,75 @@ fn collect_glyph_cells( Some(cells) } +fn collect_backdrop_quads(frame: CanvasSurfaceFrame<'_>) -> Option> { + 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, + 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]> { let color = color.trim(); if color == "transparent" { @@ -678,6 +757,21 @@ fn push_quad(vertices: &mut Vec, quad: GlyphQuad, dom_state: CanvasDomState vertices.extend_from_slice(&data); } +fn push_solid_quad(vertices: &mut Vec, 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] { let size = f64::from(atlas_size); [ @@ -716,47 +810,53 @@ 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); - stats.backdrop_ms = perf_now() - backdrop_start; - 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; + render_webgl_canvas_fallback(state, frame, &mut stats)?; + stats.total_ms = perf_now() - total_start; + return Ok(stats); + }; + let Some(backdrop_quads) = collect_backdrop_quads(frame) else { + stats.collect_ms = perf_now() - collect_start; + render_webgl_canvas_fallback(state, frame, &mut stats)?; 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; + 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() { 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; + render_webgl_canvas_fallback(state, frame, &mut stats)?; } stats.atlas_entries = state.atlas.entries.len(); stats.total_ms = perf_now() - total_start; 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 { let gl = canvas .get_context("webgl2") @@ -805,6 +905,7 @@ fn create_webgl_renderer(canvas: &HtmlCanvasElement) -> Result Result Result { + 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( gl: &WebGl2RenderingContext, shader_type: u32, @@ -1013,6 +1136,46 @@ fn draw_webgl_texture_frame( 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( state: &mut WebGlRendererState, frame: CanvasSurfaceFrame<'_>, @@ -1174,39 +1337,6 @@ fn draw_glyph_into_atlas( 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 { let mut col = 0usize; 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]); } + #[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] fn canvas_viewport_top_pins_live_mode_to_bottom() { let total_px = 1000.0 * DEFAULT_LINE_HEIGHT_PX;