From f4c919de400c1bed12661456a114754ebb795c66 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Wed, 24 Jun 2026 13:47:31 +0800 Subject: [PATCH] feat(terminal): recover webgl renderer context --- TERMINAL_HANDOFF.md | 3 +- TERMINAL_ROADMAP.md | 6 +- app/src/terminal/component.rs | 75 ++++++++++++++++-- app/src/terminal/renderer.rs | 139 +++++++++++++++++++++++++++++++++- 4 files changed, 214 insertions(+), 9 deletions(-) diff --git a/TERMINAL_HANDOFF.md b/TERMINAL_HANDOFF.md index 324d6b2..aed4b3c 100644 --- a/TERMINAL_HANDOFF.md +++ b/TERMINAL_HANDOFF.md @@ -1,5 +1,7 @@ # Terminal Handoff +> 说明:本文档保留早期 scrollback 排查背景。当前终端实现已经进入 WebGL 默认渲染器、addon parity 与 P9 renderer 工程化阶段;最新状态请优先看 `TERMINAL_ROADMAP.md`。 + 本文档用于把 `base-path-demo` 当前浏览器终端实现的状态、已验证结论、未解决问题和下一步建议整理清楚,方便后续继续开发。 ## 1. 当前目标 @@ -278,4 +280,3 @@ pub struct TerminalBufferModel { - 滚回底部是否恢复 prompt - history 模式输入后是否回到 live - resize 后历史视图是否稳定 - diff --git a/TERMINAL_ROADMAP.md b/TERMINAL_ROADMAP.md index 00ab782..d922ccd 100644 --- a/TERMINAL_ROADMAP.md +++ b/TERMINAL_ROADMAP.md @@ -231,7 +231,11 @@ ### P9 — WebGL Renderer 工程化深化 **计划实现**: -1. WebGL context loss/recovery 与 atlas 重建。 +1. **WebGL context loss/recovery 与 atlas 重建** ✅ + - 监听 `webglcontextlost` / `webglcontextrestored`,lost 时阻止默认行为并清空 `WebGlRendererState`。 + - context lost 或 WebGL 初始化失败时显示独立 Canvas 2D fallback surface,避免同一 canvas 上 WebGL lost 后无法获取 2D context 导致白屏。 + - restore 后清空旧 state 并触发重绘,下一帧重建 shader/program/buffer/texture/glyph atlas。 + - context lifecycle listener 由 `WebGlContextLifecycle` 持有,组件卸载或 renderer 切换时自动移除。 2. 渲染性能 benchmark 和自动截图 / pixel smoke test。 3. Canvas 2D / WebGL runtime switch。 4. 与 Canvas 2D 做 benchmark 对比,按 viewport size / output rate 自动选择 renderer。 diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 41e0c13..2fcbd54 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -27,8 +27,10 @@ use super::mouse::{ }; use super::protocol::{ClientTerminalMessage, ServerTerminalMessage}; use super::renderer::{ - CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, RenderCanvasSurfaceRequest, - WebGlRendererState, canvas_render_plan, render_canvas_surface, render_webgl, + CanvasDomState, CanvasPlanInput, CanvasSurfaceFrame, CanvasSurfaceMode, + RenderCanvasSurfaceRequest, WebGlContextLifecycle, WebGlRendererState, + attach_webgl_context_lifecycle, canvas_render_plan, render_canvas_surface, render_webgl, + set_canvas_surface_mode, webgl_surface_mode, }; use super::scrollback::ViewMode; use super::selection::{Selection, SelectionRect, selection_rects}; @@ -472,6 +474,7 @@ pub fn TerminalPanel( let terminal_ref = NodeRef::::new(); let ime_ref = NodeRef::::new(); let canvas_ref = NodeRef::::new(); + let canvas_fallback_ref = NodeRef::::new(); let measure_ref = NodeRef::::new(); let connection_status = RwSignal::new("Connecting".to_owned()); let ws_signal = RwSignal::new(None::); @@ -525,8 +528,11 @@ pub fn TerminalPanel( Rc::new(std::cell::Cell::new(None)); let canvas_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); + let canvas_fallback_state: Rc>> = + Rc::new(std::cell::RefCell::new(None)); let webgl_state: Rc>> = Rc::new(std::cell::RefCell::new(None)); + let webgl_context_lost: Rc> = Rc::new(std::cell::Cell::new(false)); handle.serialize_text_fn.set_value(Some(Arc::new(move || { core_signal @@ -623,6 +629,34 @@ pub fn TerminalPanel( } }); + let lifecycle_webgl_state = webgl_state.clone(); + let lifecycle_context_lost = webgl_context_lost.clone(); + Effect::new(move |prev: Option>| { + drop(prev); + + let renderer = options_signal.get().renderer; + if renderer != Renderer::WebGl { + lifecycle_context_lost.set(false); + lifecycle_webgl_state.borrow_mut().take(); + return None; + } + let Some(canvas) = canvas_ref.get() else { + return None; + }; + let Some(canvas_el) = canvas.dyn_ref::() else { + return None; + }; + + attach_webgl_context_lifecycle( + canvas_el, + lifecycle_webgl_state.clone(), + lifecycle_context_lost.clone(), + move || { + let _ = render_tick.try_update(|t| *t += 1); + }, + ) + }); + Effect::new(move |_| { let Some(on_selection_change) = on_selection_change else { return; @@ -815,6 +849,9 @@ pub fn TerminalPanel( let Some(canvas) = canvas_ref.get() else { return; }; + let Some(fallback_canvas) = canvas_fallback_ref.get() else { + return; + }; let Some(element) = terminal_ref.get() else { return; }; @@ -827,6 +864,9 @@ pub fn TerminalPanel( let Some(canvas_el) = canvas.dyn_ref::() else { return; }; + let Some(fallback_canvas_el) = fallback_canvas.dyn_ref::() else { + return; + }; let is_live = view_mode.get().is_live(); let st = scroll_top.get(); @@ -881,15 +921,35 @@ pub fn TerminalPanel( }; match renderer { Renderer::WebGl => { - if render_webgl(canvas_el, frame, &canvas_state, &webgl_state).is_err() { - render_canvas_surface(RenderCanvasSurfaceRequest { + if render_webgl( + canvas_el, + frame, + &canvas_state, + &webgl_state, + &webgl_context_lost, + ) + .is_ok() + { + set_canvas_surface_mode( canvas_el, + fallback_canvas_el, + webgl_surface_mode(true), + ); + } else { + set_canvas_surface_mode( + canvas_el, + fallback_canvas_el, + webgl_surface_mode(false), + ); + render_canvas_surface(RenderCanvasSurfaceRequest { + canvas_el: fallback_canvas_el, frame, - canvas_state: &canvas_state, + canvas_state: &canvas_fallback_state, }); } } Renderer::Canvas => { + set_canvas_surface_mode(canvas_el, fallback_canvas_el, CanvasSurfaceMode::WebGl); render_canvas_surface(RenderCanvasSurfaceRequest { canvas_el, frame, @@ -1885,6 +1945,11 @@ pub fn TerminalPanel( node_ref=canvas_ref class="terminal-canvas" > + {move || { // Canvas-backed renderers: only the scroll sizer // and selection overlay are DOM; text/backgrounds diff --git a/app/src/terminal/renderer.rs b/app/src/terminal/renderer.rs index d862853..f4e1031 100644 --- a/app/src/terminal/renderer.rs +++ b/app/src/terminal/renderer.rs @@ -4,10 +4,11 @@ use std::rc::Rc; 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, HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer, WebGlProgram, - WebGlShader, WebGlTexture, WebGlUniformLocation, + CanvasRenderingContext2d, Event, HtmlCanvasElement, WebGl2RenderingContext, WebGlBuffer, + WebGlProgram, WebGlShader, WebGlTexture, WebGlUniformLocation, }; use super::component::{ResolvedTerminalOptions, TerminalCursorStyle}; @@ -254,6 +255,115 @@ pub(super) fn render_canvas_surface(request: RenderCanvasSurfaceRequest<'_>) { render_canvas(&ctx, request.frame); } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CanvasSurfaceMode { + WebGl, + CanvasFallback, +} + +impl CanvasSurfaceMode { + fn webgl_visible(self) -> bool { + matches!(self, Self::WebGl) + } + + fn fallback_visible(self) -> bool { + matches!(self, Self::CanvasFallback) + } +} + +pub(super) fn webgl_surface_mode(rendered_with_webgl: bool) -> CanvasSurfaceMode { + if rendered_with_webgl { + CanvasSurfaceMode::WebGl + } else { + CanvasSurfaceMode::CanvasFallback + } +} + +fn should_attempt_webgl_render(context_lost: bool) -> bool { + !context_lost +} + +pub(super) fn set_canvas_surface_mode( + webgl_canvas: &HtmlCanvasElement, + fallback_canvas: &HtmlCanvasElement, + mode: CanvasSurfaceMode, +) { + set_canvas_hidden(webgl_canvas, !mode.webgl_visible()); + set_canvas_hidden(fallback_canvas, !mode.fallback_visible()); +} + +fn set_canvas_hidden(canvas: &HtmlCanvasElement, hidden: bool) { + if hidden { + let _ = canvas.set_attribute("hidden", ""); + } else { + let _ = canvas.remove_attribute("hidden"); + } +} + +pub(super) struct WebGlContextLifecycle { + canvas: HtmlCanvasElement, + on_lost: Closure, + on_restored: Closure, +} + +impl Drop for WebGlContextLifecycle { + fn drop(&mut self) { + let _ = self.canvas.remove_event_listener_with_callback( + "webglcontextlost", + self.on_lost.as_ref().unchecked_ref(), + ); + let _ = self.canvas.remove_event_listener_with_callback( + "webglcontextrestored", + self.on_restored.as_ref().unchecked_ref(), + ); + } +} + +pub(super) fn attach_webgl_context_lifecycle( + canvas: &HtmlCanvasElement, + webgl_state: Rc>>, + context_lost: Rc>, + render_tick: impl Fn() + 'static, +) -> Option { + let lost_state = webgl_state.clone(); + let lost_flag = context_lost.clone(); + let on_lost = Closure::::new(move |event: Event| { + event.prevent_default(); + lost_flag.set(true); + lost_state.borrow_mut().take(); + }); + canvas + .add_event_listener_with_callback("webglcontextlost", on_lost.as_ref().unchecked_ref()) + .ok()?; + + let restored_state = webgl_state; + let restored_flag = context_lost; + let on_restored = Closure::::new(move |_| { + restored_flag.set(false); + restored_state.borrow_mut().take(); + render_tick(); + }); + if canvas + .add_event_listener_with_callback( + "webglcontextrestored", + on_restored.as_ref().unchecked_ref(), + ) + .is_err() + { + let _ = canvas.remove_event_listener_with_callback( + "webglcontextlost", + on_lost.as_ref().unchecked_ref(), + ); + return None; + } + + Some(WebGlContextLifecycle { + canvas: canvas.clone(), + on_lost, + on_restored, + }) +} + const WEBGL_VERTEX_SHADER: &str = r#" attribute vec2 a_position; attribute vec2 a_tex_coord; @@ -549,7 +659,11 @@ pub(super) fn render_webgl( frame: CanvasSurfaceFrame<'_>, canvas_state: &Rc>>, webgl_state: &Rc>>, + context_lost: &Rc>, ) -> Result<(), String> { + if !should_attempt_webgl_render(context_lost.get()) { + return Err("WebGL context is lost".to_owned()); + } sync_canvas_dom(canvas, frame.dom_state, canvas_state); let mut state_ref = webgl_state.borrow_mut(); if state_ref.is_none() { @@ -558,6 +672,11 @@ pub(super) fn render_webgl( let state = state_ref .as_mut() .ok_or_else(|| "missing WebGL renderer state".to_owned())?; + if state.gl.is_context_lost() { + context_lost.set(true); + state_ref.take(); + return Err("WebGL context is lost".to_owned()); + } sync_canvas_bitmap(&state.source_canvas, frame.dom_state); let _ = state @@ -1218,6 +1337,22 @@ mod tests { ); } + #[test] + fn webgl_context_loss_gates_render_attempts() { + assert!(should_attempt_webgl_render(false)); + assert!(!should_attempt_webgl_render(true)); + } + + #[test] + fn webgl_surface_mode_selects_fallback_after_failed_render() { + assert_eq!(webgl_surface_mode(true), CanvasSurfaceMode::WebGl); + assert_eq!(webgl_surface_mode(false), CanvasSurfaceMode::CanvasFallback); + assert!(CanvasSurfaceMode::WebGl.webgl_visible()); + assert!(!CanvasSurfaceMode::WebGl.fallback_visible()); + assert!(!CanvasSurfaceMode::CanvasFallback.webgl_visible()); + assert!(CanvasSurfaceMode::CanvasFallback.fallback_visible()); + } + #[test] fn split_segment_glyphs_tracks_width_and_color() { let segment = TerminalSegment {