feat(terminal): recover webgl renderer context

This commit is contained in:
zhangheng
2026-06-24 13:47:31 +08:00
parent e66dcb6f80
commit f4c919de40
4 changed files with 214 additions and 9 deletions

View File

@@ -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 后历史视图是否稳定

View File

@@ -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。

View File

@@ -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::<html::Div>::new();
let ime_ref = NodeRef::<html::Textarea>::new();
let canvas_ref = NodeRef::<html::Canvas>::new();
let canvas_fallback_ref = NodeRef::<html::Canvas>::new();
let measure_ref = NodeRef::<html::Span>::new();
let connection_status = RwSignal::new("Connecting".to_owned());
let ws_signal = RwSignal::new(None::<WebSocket>);
@@ -525,8 +528,11 @@ pub fn TerminalPanel(
Rc::new(std::cell::Cell::new(None));
let canvas_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
Rc::new(std::cell::RefCell::new(None));
let canvas_fallback_state: Rc<std::cell::RefCell<Option<CanvasDomState>>> =
Rc::new(std::cell::RefCell::new(None));
let webgl_state: Rc<std::cell::RefCell<Option<WebGlRendererState>>> =
Rc::new(std::cell::RefCell::new(None));
let webgl_context_lost: Rc<std::cell::Cell<bool>> = 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<Option<WebGlContextLifecycle>>| {
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::<HtmlCanvasElement>() 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::<HtmlCanvasElement>() else {
return;
};
let Some(fallback_canvas_el) = fallback_canvas.dyn_ref::<HtmlCanvasElement>() 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"
></canvas>
<canvas
node_ref=canvas_fallback_ref
class="terminal-canvas"
hidden
></canvas>
{move || {
// Canvas-backed renderers: only the scroll sizer
// and selection overlay are DOM; text/backgrounds

View File

@@ -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<dyn Fn(Event)>,
on_restored: Closure<dyn Fn(Event)>,
}
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<std::cell::RefCell<Option<WebGlRendererState>>>,
context_lost: Rc<std::cell::Cell<bool>>,
render_tick: impl Fn() + 'static,
) -> Option<WebGlContextLifecycle> {
let lost_state = webgl_state.clone();
let lost_flag = context_lost.clone();
let on_lost = Closure::<dyn Fn(Event)>::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::<dyn Fn(Event)>::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<std::cell::RefCell<Option<CanvasDomState>>>,
webgl_state: &Rc<std::cell::RefCell<Option<WebGlRendererState>>>,
context_lost: &Rc<std::cell::Cell<bool>>,
) -> 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 {