Skip to content

Commit 0f03f27

Browse files
fix: handle context loss and restore, update opacity, reset shared resources, reuse temp positions map
1 parent cdc4f47 commit 0f03f27

3 files changed

Lines changed: 148 additions & 42 deletions

File tree

src/features/Overview/ShredsProgression/WebGl/Chart.tsx

Lines changed: 79 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,51 @@
1-
import { useLayoutEffect, useRef } from "react";
1+
import { useCallback, useLayoutEffect, useRef, useState } from "react";
22

33
import { useMeasure, useRafLoop } from "react-use";
44
import { Box, Flex } from "@radix-ui/themes";
55
import type { FlexProps } from "@radix-ui/themes";
66
import { useShredsChartScale } from "../useShredsChartScale";
77
import { useSetAtom } from "jotai";
8-
import { minDirtySlotByChartAtom } from "../atoms";
8+
import { isWebgl2SupportedAtom, minDirtySlotByChartAtom } from "../atoms";
99
import type { RendererObj, TsRange } from "./chartUtils";
1010
import { setUpRenderer, draw } from "./chartUtils";
1111
import ShredsSlotLabels from "../ShredsSlotLabels";
1212
import { MChartAxes, xAxisHeight } from "./ChartAxes";
1313
import { createLabelsState, type LabelsState } from "../utils";
14+
import { disposeWebglResources } from "../webglUtils";
1415

1516
const REDRAW_INTERVAL_MS = 15;
17+
/**
18+
* How long to wait for the GPU to restore a lost WebGL context before falling back to the canvas chart.
19+
* Context loss is usually transient (tab backgrounded, GPU reset, driver hiccup) and the browser restores it within
20+
* 1 ~ 2 frames.
21+
*/
22+
const CONTEXT_RESTORE_TIMEOUT_MS = 10_000;
1623

17-
type FlexPropsSubset = Pick<FlexProps, "height" | "minHeight" | "flexGrow">;
18-
19-
interface ShredsChartProps {
24+
interface ShredsChartProps
25+
extends Pick<FlexProps, "height" | "minHeight" | "flexGrow"> {
2026
chartId: string;
2127
}
22-
export default function ShredsChart({
28+
export default function ShredsChart(props: ShredsChartProps) {
29+
const [key, setKey] = useState(0);
30+
const triggerRemount = useCallback(() => {
31+
setKey((prev) => prev + 1);
32+
}, [setKey]);
33+
return (
34+
<ShredsChartInner key={key} {...props} triggerRemount={triggerRemount} />
35+
);
36+
}
37+
38+
interface ShredsChartInnerProps extends ShredsChartProps {
39+
triggerRemount: () => void;
40+
}
41+
42+
export function ShredsChartInner({
2343
chartId,
44+
triggerRemount,
2445
...flexProps
25-
}: ShredsChartProps & FlexPropsSubset) {
46+
}: ShredsChartInnerProps) {
2647
const setMinDirtySlotByChart = useSetAtom(minDirtySlotByChartAtom);
48+
const setWebgl2Supported = useSetAtom(isWebgl2SupportedAtom);
2749

2850
const prevTimeDiffsRef = useRef<number[]>([]);
2951
const lastRedrawRef = useRef(0);
@@ -41,9 +63,31 @@ export default function ShredsChart({
4163
prevLabels: createLabelsState(),
4264
tempNewLabels: createLabelsState(),
4365
});
66+
/**
67+
* timeout to fall back to canvas chart if context is lost and not restored
68+
*/
69+
const contextRestoreTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
4470

4571
const scale = useShredsChartScale();
4672

73+
const handleContextLost = useCallback(
74+
(event: Event) => {
75+
// preventDefault so that the browser fires webglcontextrestored
76+
event.preventDefault();
77+
78+
clearTimeout(contextRestoreTimeoutRef.current);
79+
contextRestoreTimeoutRef.current = setTimeout(() => {
80+
setWebgl2Supported(false);
81+
}, CONTEXT_RESTORE_TIMEOUT_MS);
82+
},
83+
[setWebgl2Supported],
84+
);
85+
86+
const handleContextRestored = useCallback(() => {
87+
clearTimeout(contextRestoreTimeoutRef.current);
88+
triggerRemount();
89+
}, [triggerRemount]);
90+
4791
useLayoutEffect(() => {
4892
// setup dirty slot tracking
4993
setMinDirtySlotByChart((prev) => {
@@ -58,8 +102,9 @@ export default function ShredsChart({
58102
return prev;
59103
});
60104

105+
clearTimeout(contextRestoreTimeoutRef.current);
106+
61107
// dispose of WebGL resources
62-
// NOTE: keep sharedMaterial and unitQuad to be reused across mounts
63108
const obj = rendererRef.current;
64109
if (!obj) return;
65110

@@ -69,13 +114,30 @@ export default function ShredsChart({
69114
for (const slotMesh of obj.availableMeshes) {
70115
slotMesh.mesh.geometry.dispose();
71116
}
117+
// dispose this chart's own unitQuad / sharedMaterial
118+
disposeWebglResources(obj.resources);
119+
120+
obj.renderer.domElement.removeEventListener(
121+
"webglcontextlost",
122+
handleContextLost,
123+
);
124+
obj.renderer.domElement.removeEventListener(
125+
"webglcontextrestored",
126+
handleContextRestored,
127+
);
128+
72129
obj.renderer.dispose();
73130
// force release of GL context on repeated mount / unmounts
74131
obj.renderer.forceContextLoss();
75132
obj.renderer.domElement.remove();
76133
rendererRef.current = undefined;
77134
};
78-
}, [chartId, setMinDirtySlotByChart]);
135+
}, [
136+
chartId,
137+
handleContextLost,
138+
handleContextRestored,
139+
setMinDirtySlotByChart,
140+
]);
79141

80142
// handle chart resize
81143
useLayoutEffect(() => {
@@ -95,6 +157,10 @@ export default function ShredsChart({
95157
}, [scale, width, height, chartId]);
96158

97159
useRafLoop(function drawShredsLoop(time: number) {
160+
// Don't draw while waiting for context restore.
161+
// but keep canvas mounted to listen for restore event.
162+
if (contextRestoreTimeoutRef.current != null) return;
163+
98164
if (
99165
lastRedrawRef.current == null ||
100166
time - lastRedrawRef.current >= REDRAW_INTERVAL_MS
@@ -105,7 +171,10 @@ export default function ShredsChart({
105171
if (!rendererObj) return;
106172

107173
rendererRef.current = rendererObj;
108-
containerRef.current?.replaceChildren(rendererObj.renderer.domElement);
174+
const canvas = rendererObj.renderer.domElement;
175+
canvas.addEventListener("webglcontextlost", handleContextLost);
176+
canvas.addEventListener("webglcontextrestored", handleContextRestored);
177+
containerRef.current?.replaceChildren(canvas);
109178
} else {
110179
draw(
111180
chartId,

src/features/Overview/ShredsProgression/WebGl/chartUtils.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ import {
1616
} from "../atoms";
1717
import { shredEventDescPriorities } from "../const";
1818
import { updateLabels } from "../shredsProgressionPlugin";
19-
import type { SlotMesh } from "../webglUtils";
19+
import type { SlotMesh, WebglResources } from "../webglUtils";
2020
import {
2121
createSlotMesh,
2222
updateSlotMeshCounts,
2323
ensureCapacity,
2424
addRectangleToMesh,
2525
convertToWebGlColor,
26+
createWebglResources,
2627
} from "../webglUtils";
2728
import {
2829
shredPublishedColor,
@@ -47,13 +48,20 @@ const store = getDefaultStore();
4748
const SKIPPED_SLOT_DOT_DURATION_MS = 10;
4849
const msPerDay = 24 * 60 * 60 * 1000;
4950

51+
const tempEventPositions = new Map<
52+
Exclude<ShredEvent, ShredEvent.slot_complete>,
53+
{ x: number; w: number }
54+
>();
55+
5056
export type RendererObj = {
5157
renderer: THREE.WebGLRenderer;
5258
camera: THREE.OrthographicCamera;
5359
scene: THREE.Scene;
5460
meshes: Map<number, SlotMesh>;
5561
availableMeshes: SlotMesh[];
5662
worldTsRange: TsRange;
63+
// resources shared by this renderer's slot meshes
64+
resources: WebglResources;
5765
};
5866

5967
const MAX_PIXEL_RATIO = 2;
@@ -96,6 +104,7 @@ export function setUpRenderer(canvasWidth: number, canvasHeight: number) {
96104

97105
const meshes = new Map<number, SlotMesh>();
98106
const availableMeshes: SlotMesh[] = [];
107+
const resources = createWebglResources();
99108
renderer.render(scene, camera);
100109

101110
return {
@@ -105,6 +114,7 @@ export function setUpRenderer(canvasWidth: number, canvasHeight: number) {
105114
meshes,
106115
availableMeshes,
107116
worldTsRange,
117+
resources,
108118
};
109119
} catch {
110120
// context creation can still fail despite the probe (e.g. too many live
@@ -186,10 +196,6 @@ export function draw(
186196

187197
let anythingDrawn = false;
188198
const minDirtySlot = store.get(minDirtySlotByChartAtom).get(chartId);
189-
const tempEventPositions = new Map<
190-
Exclude<ShredEvent, ShredEvent.slot_complete>,
191-
{ x: number; w: number }
192-
>();
193199

194200
for (const slotNumber of orderedSlotNumbers) {
195201
const slot = liveShreds.slots.get(slotNumber);
@@ -199,7 +205,7 @@ export function draw(
199205
const isNewMesh = !slotMesh;
200206
if (!slotMesh) {
201207
const lastMesh = rendererObj.availableMeshes.pop();
202-
slotMesh = lastMesh ?? createSlotMesh();
208+
slotMesh = lastMesh ?? createSlotMesh(rendererObj.resources);
203209
rendererObj.meshes.set(slotNumber, slotMesh);
204210
rendererObj.scene.add(slotMesh.mesh);
205211
}
@@ -216,6 +222,7 @@ export function draw(
216222
const shred = slot.shreds[shredIdx];
217223
if (!shred) continue;
218224

225+
tempEventPositions.clear();
219226
const rectanglesAdded = addEventsForRow({
220227
tempEventPositions,
221228
slotMesh,
@@ -325,8 +332,6 @@ function addEventsForRow({
325332
y,
326333
visibleTsRange,
327334
}: AddEventsForRowArgs) {
328-
tempEventPositions.clear();
329-
330335
let endTs: number =
331336
slotCompletionTsDelta == null
332337
? // event goes to max x

src/features/Overview/ShredsProgression/webglUtils.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import * as THREE from "three";
22
import { SHRED_EVENT_TYPES_COUNT } from "../../../api/entities";
33

4+
const SHREDS_OPACITY = 0.8;
5+
46
export type SlotMesh = {
57
mesh: THREE.Mesh;
68
rectArray: Float32Array;
@@ -39,26 +41,53 @@ void main() {
3941
}
4042
`;
4143

42-
const unitQuad = new THREE.BufferGeometry();
43-
unitQuad.setAttribute(
44-
"position",
45-
new THREE.BufferAttribute(
46-
new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]),
47-
2,
48-
),
49-
);
50-
unitQuad.setIndex([0, 1, 2, 1, 3, 2]);
51-
unitQuad.boundingSphere = new THREE.Sphere();
52-
53-
const sharedMaterial = new THREE.RawShaderMaterial({
54-
vertexShader,
55-
fragmentShader,
56-
side: THREE.FrontSide,
57-
transparent: true,
58-
uniforms: {
59-
uOpacity: { value: 1 },
60-
},
61-
});
44+
/**
45+
* Resources shared by all slot meshes of a single chart / renderer.
46+
* Compiled shaders / uploaded buffers are bound to a specific GL
47+
* context, so a fresh renderer (e.g. after a context loss) needs its own copy.
48+
*/
49+
export type WebglResources = {
50+
unitQuad: THREE.BufferGeometry;
51+
sharedMaterial: THREE.RawShaderMaterial;
52+
};
53+
54+
function createUnitQuad() {
55+
const geometry = new THREE.BufferGeometry();
56+
geometry.setAttribute(
57+
"position",
58+
new THREE.BufferAttribute(
59+
new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]),
60+
2,
61+
),
62+
);
63+
geometry.setIndex([0, 1, 2, 1, 3, 2]);
64+
geometry.boundingSphere = new THREE.Sphere();
65+
return geometry;
66+
}
67+
68+
function createSharedMaterial() {
69+
return new THREE.RawShaderMaterial({
70+
vertexShader,
71+
fragmentShader,
72+
side: THREE.FrontSide,
73+
transparent: true,
74+
uniforms: {
75+
uOpacity: { value: SHREDS_OPACITY },
76+
},
77+
});
78+
}
79+
80+
export function createWebglResources(): WebglResources {
81+
return {
82+
unitQuad: createUnitQuad(),
83+
sharedMaterial: createSharedMaterial(),
84+
};
85+
}
86+
87+
export function disposeWebglResources(resources: WebglResources) {
88+
resources.unitQuad.dispose();
89+
resources.sharedMaterial.dispose();
90+
}
6291

6392
// 700 shreds, all events except completion could have a rectangle
6493
const INITIAL_CAPACITY = 700 * (SHRED_EVENT_TYPES_COUNT - 1);
@@ -67,7 +96,7 @@ const INITIAL_CAPACITY = 700 * (SHRED_EVENT_TYPES_COUNT - 1);
6796
* Create one mesh per slot. That way, we can easily add / delete slots without updating
6897
* unchanged slots.
6998
*/
70-
export function createSlotMesh(): SlotMesh {
99+
export function createSlotMesh(resources: WebglResources): SlotMesh {
71100
const rectArray = new Float32Array(INITIAL_CAPACITY * 4);
72101
const colorArray = new Float32Array(INITIAL_CAPACITY * 3);
73102

@@ -78,14 +107,17 @@ export function createSlotMesh(): SlotMesh {
78107
colorAttr.setUsage(THREE.DynamicDrawUsage);
79108

80109
const geometry = new THREE.InstancedBufferGeometry();
81-
geometry.index = unitQuad.index;
82-
geometry.setAttribute("position", unitQuad.getAttribute("position"));
110+
geometry.index = resources.unitQuad.index;
111+
geometry.setAttribute(
112+
"position",
113+
resources.unitQuad.getAttribute("position"),
114+
);
83115
geometry.setAttribute("instanceRect", rectAttr);
84116
geometry.setAttribute("instanceColor", colorAttr);
85117
geometry.instanceCount = 0;
86118
geometry.boundingSphere = new THREE.Sphere();
87119

88-
const mesh = new THREE.Mesh(geometry, sharedMaterial);
120+
const mesh = new THREE.Mesh(geometry, resources.sharedMaterial);
89121
mesh.frustumCulled = false;
90122

91123
return {

0 commit comments

Comments
 (0)