1// @effect-diagnostics globalTimers:off - Native display handoff has a bounded acknowledgement window.
2import {
3 AmbientLight,
4 Box3,
5 CanvasTexture,
6 DirectionalLight,
7 Euler,
8 LinearFilter,
9 PerspectiveCamera,
10 PMREMGenerator,
11 Quaternion,
12 Scene,
13 SRGBColorSpace,
14 Vector3,
15 WebGLRenderer,
16} from "three";
17import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
18import {
19 createDuoScene,
20 duoDisplayKey,
21 duoFrameMatches,
22 type DuoPanelId,
23 type DuoHingeLeaf,
24} from "./duoScene.ts";
25import { loadDeviceModel } from "./modelScene.ts";
26import { createDeviceModelSlot, type DeviceModelSource } from "./model.ts";
27import { createDeviceFraming } from "./deviceFraming.ts";
28import { createDeviceMotion } from "./deviceMotion.ts";
29import { duoViewSnaps, nearestDuoView, type DuoRestFace } from "./duoSnap.ts";
30import { createRenderScheduler } from "./renderScheduler.ts";
31import type { DeviceScreenSize } from "./stream.ts";
32import { duoScreenSettled, type DuoPose } from "./duoControl.ts";
33
34export interface DuoViewer {
35 readonly setScreen: (screen: DeviceScreenSize | null) => void;
36 /** A pinch holds the leaf under it and follows the fingers; a fold button eases the hinge and re-presents the device. */
37 readonly setHingePreview: (angle: number | null, pinch?: boolean) => void;
38 readonly setInteractionActive: (active: boolean, mode?: "touch" | "orbit") => void;
39 readonly rejectOrientation: () => void;
40 readonly frameUpdated: (panel: DuoPanelId, primary?: HTMLCanvasElement) => void;
41 readonly resize: (width: number, height: number, pixelRatio: number) => void;
42 readonly screenPoint: (
43 x: number,
44 y: number,
45 captured?: boolean,
46 ) => { x: number; y: number } | null;
47 readonly orbit: (x: number, y: number) => void;
48 readonly beginHinge: (x: number, y: number) => boolean;
49 readonly resetPose: () => void;
50 readonly cancelInput: () => void;
51 readonly dispose: () => void;
52}
53
54/** On-demand renderer for the articulated body. One inner framebuffer spans both leaves; HID belongs to the stream. */
55export function createDuoViewer(options: {
56 canvas: HTMLCanvasElement;
57 sources: Record<DuoPanelId, HTMLCanvasElement>;
58 model: DeviceModelSource;
59 onUnavailable: () => void;
60 onModelError?: (cause: unknown) => void;
61 onPanelRequested?: (panel: DuoPanelId) => void;
62 onOrientationRequested?: (orientation: DeviceScreenSize["orientation"]) => void;
63}): DuoViewer {
64 const surfaces = ([1, 3] as const)
65 .map((id) => {
66 const canvas = document.createElement("canvas");
67 canvas.width = id === 1 ? 784 : 1600;
68 canvas.height = id === 1 ? 1140 : 1125;
69 const context = canvas.getContext("2d", { alpha: false });
70 if (!context) throw new Error("Duo display canvas is unavailable");
71 context.fillStyle = "#080a10";
72 context.fillRect(0, 0, canvas.width, canvas.height);
73 return { id, canvas, context };
74 })
75 .map(({ id, canvas, context }) => {
76 const texture = new CanvasTexture(canvas);
77 texture.colorSpace = SRGBColorSpace;
78 texture.minFilter = texture.magFilter = LinearFilter;
79 texture.generateMipmaps = false;
80 return { id, canvas, context, texture };
81 });
82 const renderer = new WebGLRenderer({
83 canvas: options.canvas,
84 alpha: true,
85 antialias: true,
86 powerPreference: "low-power",
87 });
88 renderer.outputColorSpace = SRGBColorSpace;
89 const scene = new Scene();
90 const environment = (() => {
91 const generator = new PMREMGenerator(renderer);
92 const room = new RoomEnvironment();
93 try {
94 return generator.fromScene(room, 0.04);
95 } catch (cause) {
96 for (const surface of surfaces) surface.texture.dispose();
97 renderer.dispose();
98 renderer.forceContextLoss();
99 throw cause;
100 } finally {
101 room.dispose();
102 generator.dispose();
103 }
104 })();
105 scene.environment = environment.texture;
106 const camera = new PerspectiveCamera(36, 1, 0.1, 150);
107 const key = new DirectionalLight(0xffffff, 4);
108 key.position.set(-6, 10, 14);
109 const fill = new DirectionalLight(0xc7dcff, 2);
110 fill.position.set(8, -3, -5);
111 scene.add(new AmbientLight(0xffffff, 2.4), key, fill);
112 let model: ReturnType<typeof createDuoScene> | null = null;
113 let screen: DeviceScreenSize | null = null;
114 let readyKey = "";
115 let primaryKey = "";
116 let activationAt = 0;
117 let disposed = false;
118 let viewport = { width: 0, height: 0, ratio: 1 };
119 let buffer = { width: 0, height: 0, ratio: 0 };
120 let restFace: DuoRestFace = "inside";
121 let requestedOrientation: DeviceScreenSize["orientation"] | null = null;
122 let viewOrientation: DeviceScreenSize["orientation"] | null = null;
123 const pivot = new Vector3();
124 const targetPivot = new Vector3();
125 let requestedPanel: DuoPanelId | null = null;
126 let handoffTimer: ReturnType<typeof setTimeout> | null = null;
127 const clearHandoff = () => {
128 if (handoffTimer) clearTimeout(handoffTimer);
129 handoffTimer = null;
130 requestedPanel = null;
131 };
132 const activeSnaps = () =>
133 model
134 ? duoViewSnaps(
135 model.restFrames(screen?.screenId === 1 ? 1 : 3),
136 screen?.screenId === 1 ? 1 : 3,
137 )
138 : [];
139 const snaps = () => {
140 if (!model || !screen?.supportsPhysicalOrientation || !options.onPanelRequested)
141 return activeSnaps();
142 const cover = duoViewSnaps(model.restFrames(1), 1);
143 // A shut inner display is occluded and cannot become a useful rest view.
144 return angle > 20 && angle < 180
145 ? [...duoViewSnaps(model.restFrames(3), 3), ...cover]
146 : activeSnaps();
147 };
148 const orbit = createDeviceMotion({
149 choose(rotation) {
150 const snap = nearestDuoView(rotation, snaps());
151 if (!snap) return rotation;
152 restFace = snap.face;
153 const panel = snap.face === "cover" ? 1 : 3;
154 if (screen && panel !== (requestedPanel ?? screen.screenId) && options.onPanelRequested) {
155 clearHandoff();
156 requestedPanel = panel;
157 model?.cancelInput();
158 options.onPanelRequested(panel);
159 handoffTimer = setTimeout(() => {
160 clearHandoff();
161 const confirmed = nearestDuoView(orbit.rotation, activeSnaps());
162 if (confirmed) {
163 restFace = confirmed.face;
164 orbit.setPose(confirmed.rotation, performance.now());
165 }
166 scheduler.invalidate();
167 }, 5000);
168 } else if (
169 screen &&
170 requestedPanel === null &&
171 snap.orientation !== viewOrientation &&
172 options.onOrientationRequested
173 ) {
174 viewOrientation = snap.orientation;
175 requestedOrientation = snap.orientation;
176 options.onOrientationRequested(snap.orientation);
177 }
178 return snap.rotation;
179 },
180 });
181 let angle = 180;
182 let targetAngle = 180;
183 let previewAngle: number | null = null;
184 let hingeLeaf: DuoHingeLeaf | null = null;
185 let appliedAngle = Number.NaN;
186 let interactionActive = false;
187 const framing = createDeviceFraming();
188 const framingBounds = new Box3();
189 let firstPose = true;
190 let foldPending = false;
191 let physicalPose: DuoPose = "open";
192 let presentationAngle = 180;
193 let targetPresentation = new Quaternion();
194 let lastTime = 0;
195 const reduced =
196 typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
197 const applyPose = () => {
198 if (!model) return;
199 const before = hingeLeaf && appliedAngle !== angle ? model.leafRotation(hingeLeaf) : null;
200 model.setAngle(angle);
201 appliedAngle = angle;
202 if (before && hingeLeaf) {
203 // The primary surface stays in camera space; its partner supplies the fold.
204 // Rebase both the displayed rotation and its rest target so releasing a
205 // pinch cannot resume the pre-fold body rotation.
206 const correction = before.multiply(model.leafRotation(hingeLeaf).invert());
207 if (correction.angleTo(new Quaternion()) > 1e-8)
208 orbit.setPose(orbit.rotation.clone().multiply(correction), performance.now(), true);
209 }
210 model.root.quaternion.copy(orbit.rotation);
211 model.root.position.set(0, 0, 0);
212 const frames = model.restFrames(restFace === "cover" ? 1 : 3);
213 const frame = frames.find((frame) => frame.face === restFace) ?? frames[0];
214 targetPivot.copy(frame?.center ?? new Vector3());
215 model.root.position.copy(pivot).applyQuaternion(model.root.quaternion).negate();
216 };
217 const updateCamera = () => {
218 camera.position.set(framing.center.x, framing.center.y, framing.distance());
219 camera.lookAt(framing.center.x, framing.center.y, 0);
220 camera.updateProjectionMatrix();
221 };
222 const fit = (immediate = false) => {
223 if (!model || !viewport.width || !viewport.height) return;
224 camera.aspect = viewport.width / viewport.height;
225 framing.setBounds(
226 framingBounds.setFromObject(model.root),
227 (camera.fov * Math.PI) / 360,
228 camera.aspect,
229 performance.now(),
230 immediate,
231 );
232 updateCamera();
233 };
234 const moving = () =>
235 Math.abs(angle - targetAngle) > 0.01 || pivot.distanceTo(targetPivot) > 0.001;
236 const scheduler = createRenderScheduler(() => {
237 if (disposed || !model || !viewport.width || !viewport.height) return;
238 try {
239 const now = performance.now();
240 const elapsed = Math.max(0, (now - lastTime) / 1000);
241 const amount = reduced
242 ? 1
243 : 1 - Math.exp(-14 * Math.max(0, (now - lastTime) / 1000 || 0.016));
244 lastTime = now;
245 const inMotion = moving();
246 const orbitChanged = orbit.advance(now, reduced);
247 if (inMotion) {
248 angle += (targetAngle - angle) * amount;
249
250 if (Math.abs(angle - targetAngle) <= 0.01) angle = targetAngle;
251 applyPose();
252 pivot.lerp(targetPivot, amount);
253 if (pivot.distanceTo(targetPivot) <= 0.001) pivot.copy(targetPivot);
254 applyPose();
255 fit(reduced || elapsed > 0.5);
256 }
257 if (orbitChanged) {
258 applyPose();
259 fit(reduced || elapsed > 0.5);
260 }
261 if (framing.advance(now, reduced)) updateCamera();
262 if (
263 buffer.width !== viewport.width ||
264 buffer.height !== viewport.height ||
265 buffer.ratio !== viewport.ratio
266 ) {
267 renderer.setDrawingBufferSize(viewport.width, viewport.height, viewport.ratio);
268 buffer = viewport;
269 }
270 renderer.render(scene, camera);
271 if (inMotion || moving() || orbit.needsFrame() || framing.needsFrame())
272 scheduler.invalidate();
273 } catch {
274 options.onUnavailable();
275 }
276 });
277 const slot = createDeviceModelSlot({
278 load: loadDeviceModel,
279 onError(cause) {
280 options.onModelError?.(cause);
281 options.onUnavailable();
282 },
283 install(loaded) {
284 const next = loaded
285 ? createDuoScene(loaded.asset, { 1: surfaces[0]!.texture, 3: surfaces[1]!.texture })
286 : null;
287 if (model) {
288 scene.remove(model.root);
289 model.dispose();
290 }
291 model = next;
292 appliedAngle = Number.NaN;
293 if (!model || disposed) return;
294 scene.add(model.root);
295 applyPose();
296 if (screen?.screenId === 1 && physicalPose !== "tent") {
297 const snap = nearestDuoView(orbit.rotation, activeSnaps());
298 if (snap) {
299 restFace = snap.face;
300 orbit.setPose(snap.rotation, performance.now(), true);
301 applyPose();
302 }
303 }
304 pivot.copy(targetPivot);
305 applyPose();
306 fit();
307 scheduler.invalidate();
308 },
309 });
310 const lost = (event: Event) => {
311 event.preventDefault();
312 options.onUnavailable();
313 };
314 options.canvas.addEventListener("webglcontextlost", lost);
315 slot.set(options.model);
316 return {
317 setScreen(next) {
318 if (disposed) return;
319 const wasMoving = moving() || orbit.needsFrame();
320 if (duoDisplayKey(screen) !== duoDisplayKey(next)) {
321 readyKey = "";
322 activationAt = performance.now();
323 model?.cancelInput();
324 }
325 const previous = screen;
326 const ownedHandoff = requestedPanel !== null;
327 if (!next || next.screenId === requestedPanel) clearHandoff();
328 const requestedRotation = requestedOrientation !== null;
329 const ownedRotation = requestedRotation && next?.screenId === previous?.screenId;
330 const changedDisplay = next?.screenId !== previous?.screenId;
331 requestedOrientation = null;
332 screen = next;
333 const changedPose = next?.hingePose && next.hingePose !== previous?.hingePose;
334 const folding = hingeLeaf !== null && !changedPose;
335 // A hinge change no held leaf owns re-presents the device, centered on the
336 // hinge, once the display that shows the new fold reports. Across the closed
337 // position that takes steps: the outgoing display first reports the incoming
338 // one's orientation.
339 const angleBefore = previous?.hingeAngle ?? (previous?.screenId === 1 ? 0 : 180);
340 const angleAfter = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
341 if (!next || folding || changedPose) foldPending = false;
342 else if (previous && angleBefore !== angleAfter) foldPending = true;
343 const landedFold = foldPending && !!next && duoScreenSettled(next) && !ownedHandoff;
344 if (next && duoScreenSettled(next)) foldPending = false;
345 const rotated =
346 !foldPending &&
347 next?.screenId === previous?.screenId &&
348 next?.hingeAngle === previous?.hingeAngle &&
349 next?.orientation !== previous?.orientation;
350 const leftPhysicalPose =
351 rotated &&
352 !ownedRotation &&
353 !ownedHandoff &&
354 !folding &&
355 !next?.hingePose &&
356 previewAngle === null;
357 if (firstPose || changedPose || leftPhysicalPose || landedFold) {
358 physicalPose = next?.hingePose ?? (next?.screenId === 1 ? "closed" : "open");
359 presentationAngle = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
360 }
361 // A command reply/config confirms hinge state. Display identity, never an angle heuristic, owns input.
362 targetAngle = previewAngle ?? next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180);
363 // Native angle commands clear hingePose. While a leaf is held (a pinch, or a
364 // fold on a stand) they change articulation only and preserve the viewing
365 // pose. A separate rotation clears the physical preset and follows panel orientation.
366 if (
367 firstPose ||
368 changedPose ||
369 landedFold ||
370 (rotated && !ownedRotation && !ownedHandoff && !folding)
371 ) {
372 hingeLeaf = null;
373 viewOrientation = next?.orientation ?? null;
374 const poseAngle = presentationAngle;
375 const fold = ((180 - poseAngle) * Math.PI) / 360;
376 let roll = physicalPose === "closed" ? 0 : Math.PI / 2;
377 if (next && next.width < next.height) {
378 if (next.orientation === "landscape_left") roll -= Math.PI / 2;
379 if (next.orientation === "landscape_right") roll += Math.PI / 2;
380 }
381 if (next?.orientation === "portrait_upside_down") roll -= Math.PI;
382 const physical =
383 physicalPose === "laptop"
384 ? new Euler(-fold + Math.PI / 9, -Math.PI / 9, Math.PI / 2, "YXZ")
385 : physicalPose === "tent"
386 ? new Euler(Math.PI / 2 + Math.PI / 18, -Math.PI / 9, -Math.PI / 2, "YXZ")
387 : new Euler(0, (Math.PI / 2) * Math.pow(1 - poseAngle / 180, 3), 0, "YXZ");
388 targetPresentation = new Quaternion().setFromEuler(physical);
389 if (physicalPose !== "laptop" && physicalPose !== "tent")
390 targetPresentation.premultiply(
391 new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), roll),
392 );
393 if (next?.screenId === 1 && !landedFold && physicalPose !== "tent") {
394 // Native readback can retain the cover even for an open physical preset.
395 // A screen-facing rest must use that display's actual hinged normal.
396 // A landed fold is shut, so its pose already faces the cover; the
397 // hinge is still moving and its current normal would be stale.
398 // Tent stands on both edges with the cover outward; facing it flat would tip it over.
399 const snap = nearestDuoView(targetPresentation, activeSnaps());
400 if (snap) targetPresentation.copy(snap.rotation);
401 }
402 if (firstPose) angle = targetAngle;
403 restFace = next?.screenId === 1 ? "cover" : physicalPose === "laptop" ? "right" : "inside";
404 orbit.setPose(targetPresentation, performance.now(), firstPose);
405 firstPose = false;
406 } else if (
407 next &&
408 changedDisplay &&
409 !ownedHandoff &&
410 !folding &&
411 (restFace === "cover") !== (next.screenId === 1) &&
412 (requestedRotation || duoScreenSettled(next))
413 ) {
414 // A sensor rotation can hand ownership to the other native display.
415 // Face that display without sending another rotation and creating a feedback loop.
416 // Native election can briefly flap to the display a fold does not show and
417 // back; neither is a handoff, and the view may still be easing into place.
418 const snap = nearestDuoView(orbit.rotation, activeSnaps());
419 if (snap) {
420 restFace = snap.face;
421 orbit.setPose(snap.rotation, performance.now());
422 }
423 }
424 if (!wasMoving) lastTime = performance.now();
425 applyPose();
426 fit();
427 scheduler.invalidate();
428 },
429 setHingePreview(next, pinch = false) {
430 if (disposed || (next !== null && (!Number.isFinite(next) || next < 0 || next > 180))) return;
431 if (!moving()) lastTime = performance.now();
432 previewAngle = next;
433 if (next !== null && !pinch) {
434 // A fold button holds no leaf, so the device is presented, centered on
435 // the hinge, once the fold lands.
436 hingeLeaf = null;
437 } else if (next !== null && !hingeLeaf && model) {
438 clearHandoff();
439 requestedOrientation = null;
440 hingeLeaf = screen?.screenId === 1 && angle > 20 ? "left" : "right";
441 orbit.setPose(orbit.rotation.clone(), performance.now(), true);
442 }
443 orbit.hold(interactionActive || (pinch && next !== null), performance.now());
444 targetAngle = next ?? screen?.hingeAngle ?? (screen?.screenId === 1 ? 0 : 180);
445 // A pinch follows the fingers; anything else eases toward the target.
446 if (next !== null && pinch) {
447 angle = next;
448 applyPose();
449 fit();
450 }
451 model?.cancelInput();
452 scheduler.invalidate();
453 },
454 setInteractionActive(active, mode = "touch") {
455 if (disposed) return;
456 if (mode === "orbit") {
457 if (active) hingeLeaf = null;
458 orbit.dragActive(active, performance.now());
459 } else {
460 interactionActive = active;
461 orbit.hold(active || previewAngle !== null, performance.now());
462 framing.hold(active, performance.now());
463 }
464 scheduler.invalidate();
465 },
466 rejectOrientation() {
467 if (disposed || (requestedOrientation === null && requestedPanel === null)) return;
468 clearHandoff();
469 requestedOrientation = null;
470 viewOrientation = screen?.orientation ?? null;
471 const confirmed = activeSnaps().filter((snap) => snap.orientation === screen?.orientation);
472 const snap = nearestDuoView(orbit.rotation, confirmed);
473 if (snap) {
474 restFace = snap.face;
475 orbit.setPose(snap.rotation, performance.now());
476 }
477 scheduler.invalidate();
478 },
479 frameUpdated(id, primary) {
480 if (disposed || screen?.screenId !== id) return;
481 const key = duoDisplayKey(screen);
482 // The elected native feed is authoritative during handoff. Fixed-panel
483 // encoders can retain an inactive blank until that surface changes again.
484 if (!primary && primaryKey === key) return;
485 const source = primary ?? options.sources[id];
486 if (!duoFrameMatches(source, screen)) return;
487 const surface = surfaces[id === 1 ? 0 : 1]!;
488 const { context, canvas } = surface;
489 // Ignore native shutdown blanks only while waiting for an activation. Steady black application content remains valid.
490 if (!primary && !readyKey && performance.now() - activationAt < 1500) {
491 const probe = document.createElement("canvas");
492 probe.width = probe.height = 8;
493 const probeContext = probe.getContext("2d", { willReadFrequently: true });
494 if (probeContext) {
495 probeContext.drawImage(source, 0, 0, 8, 8);
496 if (
497 !probeContext
498 .getImageData(0, 0, 8, 8)
499 .data.some((value, index) => index % 4 !== 3 && value > 3)
500 )
501 return;
502 }
503 }
504 context.save();
505 context.fillStyle = "#080a10";
506 context.fillRect(0, 0, canvas.width, canvas.height);
507 context.translate(canvas.width / 2, canvas.height / 2);
508 // The inner panel is mounted a quarter turn from the native framebuffer.
509 const rotate = id === 3;
510 if (rotate) context.rotate(Math.PI / 2);
511 const scale = Math.min(
512 canvas.width / (rotate ? source.height : source.width),
513 canvas.height / (rotate ? source.width : source.height),
514 );
515 context.drawImage(
516 source,
517 (-source.width * scale) / 2,
518 (-source.height * scale) / 2,
519 source.width * scale,
520 source.height * scale,
521 );
522 context.restore();
523 surface.texture.needsUpdate = true;
524 readyKey = duoDisplayKey(screen);
525 if (primary) primaryKey = readyKey;
526 scheduler.invalidate();
527 },
528 resize(width, height, ratio) {
529 if (disposed || ![width, height, ratio].every(Number.isFinite) || width <= 0 || height <= 0)
530 return;
531 viewport = { width, height, ratio: Math.min(2, Math.max(1, ratio)) };
532 fit(true);
533 scheduler.invalidate();
534 },
535 screenPoint(x, y, captured = false) {
536 if (
537 disposed ||
538 moving() ||
539 previewAngle !== null ||
540 requestedOrientation !== null ||
541 requestedPanel !== null
542 )
543 return null;
544 applyPose();
545 return model?.screenPoint(x, y, camera, screen, readyKey, captured) ?? null;
546 },
547 cancelInput() {
548 hingeLeaf = null;
549 model?.cancelInput();
550 },
551 orbit(x, y) {
552 if (disposed) return;
553 hingeLeaf = null;
554 orbit.orbit(x * viewport.width, y * viewport.height, performance.now());
555 scheduler.invalidate();
556 },
557 beginHinge(x, y) {
558 if (disposed || !model || !screen) return false;
559 applyPose();
560 const leaf = model.hingeLeafAt(x, y, camera);
561 if (!leaf) return false;
562 clearHandoff();
563 requestedOrientation = null;
564 // The right inner leaf and the shut cover share the front-facing plane.
565 // Holding that leaf lets the cover replace the inner image without a
566 // half turn, even when the pinch starts over the moving partner.
567 hingeLeaf = screen.screenId === 1 && angle > 20 ? "left" : "right";
568 orbit.setPose(orbit.rotation.clone(), performance.now(), true);
569 model.cancelInput();
570 return true;
571 },
572 resetPose() {
573 if (disposed) return;
574 hingeLeaf = null;
575 requestedOrientation = null;
576 clearHandoff();
577 const snap = activeSnaps().find((snap) => snap.orientation === screen?.orientation);
578 if (snap && snap.orientation !== viewOrientation && options.onOrientationRequested) {
579 viewOrientation = snap.orientation;
580 requestedOrientation = snap.orientation;
581 options.onOrientationRequested(snap.orientation);
582 }
583 orbit.reset(snap?.rotation ?? targetPresentation, performance.now());
584 applyPose();
585 fit();
586 scheduler.invalidate();
587 },
588 dispose() {
589 clearHandoff();
590 if (disposed) return;
591 disposed = true;
592 scheduler.dispose();
593 slot.dispose();
594 options.canvas.removeEventListener("webglcontextlost", lost);
595 for (const surface of surfaces) surface.texture.dispose();
596 scene.environment = null;
597 environment.dispose();
598 renderer.dispose();
599 renderer.forceContextLoss();
600 },
601 };
602}