fix(handtracking): reduce GPU pressure on WebGL context loss

Several mitigations against the WebGL context lost that fires when
hand tracking starts on a loaded scene:

- Canvas: fixed DPR [1,1], antialias off, scoped id="game-canvas",
  context-lost handler releases MediaPipe and logs GPU memory counters
- optimizeGLTFScene: cap anisotropy at 2 and stop forcing mipmaps /
  needsUpdate on every pass — avoids massive texture re-uploads
- MediaPipe: force CPU delegate (HAND_TRACKING_BROWSER_DELEGATE),
  cache the landmarker instance, and expose releaseBrowserHandLandmarker
- useBrowserHandTracking / useRemoteHandTracking: idempotent cleanup
  guarded by a cleanedUp flag, try/catch around the detect loop, and
  release of the landmarker on stop
- World: mount HandTrackingGlove only when the matching hand is
  actually present in the snapshot (status connected + hands.length > 0)
- HandTrackingGlove: drop the eager useGLTF.preload that was running
  at startup whether or not hand tracking was used

Does not yet absorb the React StrictMode double-mount — that is the
follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boullay
2026-06-02 16:48:39 +02:00
parent 864e075b42
commit d217c3376b
8 changed files with 156 additions and 35 deletions
@@ -1,7 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Component, useEffect, useMemo, useRef } from "react"; import { Component, useEffect, useMemo, useRef } from "react";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { SkeletonUtils } from "three-stdlib"; import { SkeletonUtils } from "three-stdlib";
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
@@ -362,6 +361,3 @@ export function HandTrackingGlove({
</HandTrackingGloveErrorBoundary> </HandTrackingGloveErrorBoundary>
); );
} }
useGLTF.preload(GLOVE_CONFIGS.left.modelPath);
useGLTF.preload(GLOVE_CONFIGS.right.modelPath);
+1
View File
@@ -8,3 +8,4 @@ export const HAND_TRACKING_BROWSER_WASM_URL =
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm"; "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.35/wasm";
export const HAND_TRACKING_BROWSER_MODEL_URL = export const HAND_TRACKING_BROWSER_MODEL_URL =
"https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task"; "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task";
export const HAND_TRACKING_BROWSER_DELEGATE: "CPU" | "GPU" = "CPU";
@@ -7,12 +7,14 @@ import {
import { import {
convertBrowserHandResult, convertBrowserHandResult,
getBrowserHandLandmarker, getBrowserHandLandmarker,
releaseBrowserHandLandmarker,
} from "@/lib/handTracking/browserHandTracking"; } from "@/lib/handTracking/browserHandTracking";
import { import {
INITIAL_HAND_TRACKING_SNAPSHOT, INITIAL_HAND_TRACKING_SNAPSHOT,
getCameraStreamWithTimeout, getCameraStreamWithTimeout,
} from "@/lib/handTracking/handTrackingSession"; } from "@/lib/handTracking/handTrackingSession";
import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking"; import type { HandTrackingSnapshot } from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
interface UseBrowserHandTrackingOptions { interface UseBrowserHandTrackingOptions {
enabled: boolean; enabled: boolean;
@@ -34,8 +36,12 @@ export function useBrowserHandTracking({
} }
let cancelled = false; let cancelled = false;
let cleanedUp = false;
const cleanup = (): void => { const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
if (intervalRef.current !== null) { if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current); window.clearInterval(intervalRef.current);
intervalRef.current = null; intervalRef.current = null;
@@ -44,6 +50,7 @@ export function useBrowserHandTracking({
streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null; streamRef.current = null;
videoRef.current = null; videoRef.current = null;
releaseBrowserHandLandmarker();
}; };
const start = async (): Promise<void> => { const start = async (): Promise<void> => {
@@ -111,6 +118,7 @@ export function useBrowserHandTracking({
intervalRef.current = window.setInterval(() => { intervalRef.current = window.setInterval(() => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return; if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;
try {
const result = handLandmarker.detectForVideo( const result = handLandmarker.detectForVideo(
video, video,
performance.now(), performance.now(),
@@ -125,10 +133,29 @@ export function useBrowserHandTracking({
: "available", : "available",
error: null, error: null,
})); }));
} catch (error) {
logger.error("HandTracking", "Browser JS runtime error", {
error: error instanceof Error ? error.message : String(error),
});
cleanup();
setSnapshot({
hands: [],
status: "error",
usageStatus: "inactive",
serverStatus: "Browser JS",
error:
error instanceof Error
? error.message
: "Browser hand tracking failed",
});
}
}, 1_000 / HAND_TRACKING_TARGET_FPS); }, 1_000 / HAND_TRACKING_TARGET_FPS);
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
logger.error("HandTracking", "Browser JS runtime failed", {
error: error instanceof Error ? error.message : String(error),
});
setSnapshot({ setSnapshot({
hands: [], hands: [],
status: "error", status: "error",
@@ -17,6 +17,7 @@ import type {
HandTrackingServerMessage, HandTrackingServerMessage,
HandTrackingSnapshot, HandTrackingSnapshot,
} from "@/types/handTracking/handTracking"; } from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
interface UseRemoteHandTrackingOptions { interface UseRemoteHandTrackingOptions {
enabled: boolean; enabled: boolean;
@@ -100,6 +101,7 @@ export function useRemoteHandTracking({
} }
let cancelled = false; let cancelled = false;
let cleanedUp = false;
const clearResponseTimeout = (): void => { const clearResponseTimeout = (): void => {
if (responseTimeoutRef.current === null) return; if (responseTimeoutRef.current === null) return;
@@ -108,6 +110,9 @@ export function useRemoteHandTracking({
}; };
const cleanup = (): void => { const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
if (sendIntervalRef.current !== null) { if (sendIntervalRef.current !== null) {
window.clearInterval(sendIntervalRef.current); window.clearInterval(sendIntervalRef.current);
sendIntervalRef.current = null; sendIntervalRef.current = null;
@@ -283,6 +288,9 @@ export function useRemoteHandTracking({
}; };
ws.onerror = () => { ws.onerror = () => {
markResponseReceived(); markResponseReceived();
logger.error("HandTracking", "Backend WebSocket error", {
websocketUrl,
});
setSnapshot((current) => ({ setSnapshot((current) => ({
...current, ...current,
status: "error", status: "error",
@@ -307,6 +315,10 @@ export function useRemoteHandTracking({
); );
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
logger.error("HandTracking", "Backend runtime failed", {
error: error instanceof Error ? error.message : String(error),
websocketUrl,
});
setSnapshot({ setSnapshot({
hands: [], hands: [],
status: "error", status: "error",
+31 -2
View File
@@ -1,4 +1,5 @@
import { import {
HAND_TRACKING_BROWSER_DELEGATE,
HAND_TRACKING_BROWSER_MODEL_URL, HAND_TRACKING_BROWSER_MODEL_URL,
HAND_TRACKING_BROWSER_WASM_URL, HAND_TRACKING_BROWSER_WASM_URL,
} from "@/data/handTrackingConfig"; } from "@/data/handTrackingConfig";
@@ -6,6 +7,7 @@ import type {
HandTrackingHand, HandTrackingHand,
HandTrackingLandmark, HandTrackingLandmark,
} from "@/types/handTracking/handTracking"; } from "@/types/handTracking/handTracking";
import { logger } from "@/utils/core/Logger";
type HandLandmarkerModule = typeof import("@mediapipe/tasks-vision"); type HandLandmarkerModule = typeof import("@mediapipe/tasks-vision");
type HandLandmarker = Awaited< type HandLandmarker = Awaited<
@@ -14,6 +16,7 @@ type HandLandmarker = Awaited<
type HandLandmarkerResult = ReturnType<HandLandmarker["detectForVideo"]>; type HandLandmarkerResult = ReturnType<HandLandmarker["detectForVideo"]>;
let handLandmarkerPromise: Promise<HandLandmarker> | null = null; let handLandmarkerPromise: Promise<HandLandmarker> | null = null;
let handLandmarkerInstance: HandLandmarker | null = null;
function averageLandmarks( function averageLandmarks(
landmarks: HandTrackingLandmark[], landmarks: HandTrackingLandmark[],
@@ -78,20 +81,46 @@ export async function getBrowserHandLandmarker(): Promise<HandLandmarker> {
HAND_TRACKING_BROWSER_WASM_URL, HAND_TRACKING_BROWSER_WASM_URL,
); );
return HandLandmarker.createFromOptions(vision, { const handLandmarker = await HandLandmarker.createFromOptions(vision, {
baseOptions: { baseOptions: {
modelAssetPath: HAND_TRACKING_BROWSER_MODEL_URL, modelAssetPath: HAND_TRACKING_BROWSER_MODEL_URL,
delegate: "GPU", delegate: HAND_TRACKING_BROWSER_DELEGATE,
}, },
numHands: 2, numHands: 2,
runningMode: "VIDEO", runningMode: "VIDEO",
}); });
handLandmarkerInstance = handLandmarker;
return handLandmarker;
}, },
); );
return handLandmarkerPromise; return handLandmarkerPromise;
} }
export function releaseBrowserHandLandmarker(): void {
const activeLandmarker = handLandmarkerInstance;
const pendingLandmarker = handLandmarkerPromise;
handLandmarkerInstance = null;
handLandmarkerPromise = null;
if (activeLandmarker) {
activeLandmarker.close();
return;
}
void pendingLandmarker
?.then((landmarker) => {
landmarker.close();
})
.catch((error: unknown) => {
logger.warn("HandTracking", "Browser JS landmarker release failed", {
error: error instanceof Error ? error.message : String(error),
});
});
}
export function convertBrowserHandResult( export function convertBrowserHandResult(
result: HandLandmarkerResult, result: HandLandmarkerResult,
): HandTrackingHand[] { ): HandTrackingHand[] {
+43 -4
View File
@@ -15,7 +15,9 @@ import {
} from "@/components/ui/intro"; } from "@/components/ui/intro";
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig"; import { INITIAL_SCENE_LOADING_STATE } from "@/data/world/sceneLoadingConfig";
import { useDebugStore } from "@/hooks/debug/useDebugStore";
import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator"; import { useTransientLoadingIndicator } from "@/hooks/ui/useTransientLoadingIndicator";
import { releaseBrowserHandLandmarker } from "@/lib/handTracking/browserHandTracking";
import { AudioManager } from "@/managers/AudioManager"; import { AudioManager } from "@/managers/AudioManager";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore"; import { useWorldSettingsStore } from "@/managers/stores/useWorldSettingsStore";
@@ -26,6 +28,9 @@ import { logger } from "@/utils/core/Logger";
import { World } from "@/world/World"; import { World } from "@/world/World";
const LOADING_TO_VIDEO_FADE_MS = 500; const LOADING_TO_VIDEO_FADE_MS = 500;
const WEBGL_CONTEXT_RESTORE_DELAY_MS = 500;
const CANVAS_DPR: [number, number] = [1, 1];
const registeredWebglContextCanvases = new WeakSet<HTMLCanvasElement>();
export function HomePage(): React.JSX.Element | null { export function HomePage(): React.JSX.Element | null {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -38,6 +43,11 @@ export function HomePage(): React.JSX.Element | null {
const graphicsPreset = useWorldSettingsStore( const graphicsPreset = useWorldSettingsStore(
(state) => state.graphics.preset, (state) => state.graphics.preset,
); );
const cameraMode = useDebugStore((debug) => debug.getCameraMode());
const handTrackingSource = useDebugStore((debug) =>
debug.getHandTrackingSource(),
);
const sceneMode = useDebugStore((debug) => debug.getSceneMode());
const dialogMessage = useGameStore( const dialogMessage = useGameStore(
(state) => state.missionFlow.dialogMessage, (state) => state.missionFlow.dialogMessage,
); );
@@ -48,9 +58,18 @@ export function HomePage(): React.JSX.Element | null {
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
); );
const sceneReadyRef = useRef(false); const sceneReadyRef = useRef(false);
const cameraModeRef = useRef(cameraMode);
const handTrackingSourceRef = useRef(handTrackingSource);
const sceneModeRef = useRef(sceneMode);
const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`; const runtimeLoadingSignal = `${graphicsPreset}:${mainState}:${ebikeStep}:${pylonStep}:${farmStep}`;
const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal); const previousRuntimeLoadingSignalRef = useRef(runtimeLoadingSignal);
useEffect(() => {
cameraModeRef.current = cameraMode;
handTrackingSourceRef.current = handTrackingSource;
sceneModeRef.current = sceneMode;
}, [cameraMode, handTrackingSource, sceneMode]);
useEffect(() => { useEffect(() => {
sceneReadyRef.current = sceneLoadingState.status === "ready"; sceneReadyRef.current = sceneLoadingState.status === "ready";
}, [sceneLoadingState.status]); }, [sceneLoadingState.status]);
@@ -138,11 +157,25 @@ export function HomePage(): React.JSX.Element | null {
// page stays frozen on a black canvas until the user reloads. // page stays frozen on a black canvas until the user reloads.
const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context"); const loseContextExt = gl.getContext().getExtension("WEBGL_lose_context");
if (registeredWebglContextCanvases.has(canvas)) return;
registeredWebglContextCanvases.add(canvas);
const handleContextLost = (event: Event) => { const handleContextLost = (event: Event) => {
event.preventDefault(); event.preventDefault();
logger.error("WebGL", "Context lost - attempting auto-restore"); releaseBrowserHandLandmarker();
logger.error("WebGL", "Context lost - attempting auto-restore", {
cameraMode: cameraModeRef.current,
geometries: gl.info.memory.geometries,
handTrackingSource: handTrackingSourceRef.current,
sceneMode: sceneModeRef.current,
textures: gl.info.memory.textures,
});
// Give the GPU a moment to free resources before asking it back. // Give the GPU a moment to free resources before asking it back.
window.setTimeout(() => loseContextExt?.restoreContext(), 500); window.setTimeout(
() => loseContextExt?.restoreContext(),
WEBGL_CONTEXT_RESTORE_DELAY_MS,
);
}; };
const handleContextRestored = () => { const handleContextRestored = () => {
@@ -150,7 +183,11 @@ export function HomePage(): React.JSX.Element | null {
gl.shadowMap.type = THREE.PCFShadowMap; gl.shadowMap.type = THREE.PCFShadowMap;
gl.shadowMap.autoUpdate = true; gl.shadowMap.autoUpdate = true;
gl.shadowMap.needsUpdate = true; gl.shadowMap.needsUpdate = true;
logger.info("WebGL", "Context restored"); logger.info("WebGL", "Context restored", {
cameraMode: cameraModeRef.current,
handTrackingSource: handTrackingSourceRef.current,
sceneMode: sceneModeRef.current,
});
}; };
canvas.addEventListener("webglcontextlost", handleContextLost); canvas.addEventListener("webglcontextlost", handleContextLost);
@@ -191,10 +228,12 @@ export function HomePage(): React.JSX.Element | null {
<HandTrackingProvider> <HandTrackingProvider>
<Canvas <Canvas
camera={{ position: [85, 60, 85], fov: 42 }} camera={{ position: [85, 60, 85], fov: 42 }}
dpr={CANVAS_DPR}
id="game-canvas"
shadows={{ type: THREE.PCFShadowMap }} shadows={{ type: THREE.PCFShadowMap }}
gl={{ gl={{
powerPreference: "high-performance", powerPreference: "high-performance",
antialias: true, antialias: false,
stencil: false, stencil: false,
}} }}
onCreated={handleCanvasCreated} onCreated={handleCanvasCreated}
+8 -7
View File
@@ -19,20 +19,21 @@ type TexturedMaterial = THREE.Material &
Partial<Record<TextureKey, THREE.Texture>>; Partial<Record<TextureKey, THREE.Texture>>;
const optimizedTextures = new WeakSet<THREE.Texture>(); const optimizedTextures = new WeakSet<THREE.Texture>();
const MAX_GLTF_TEXTURE_ANISOTROPY = 2;
function optimizeTexture(texture: THREE.Texture, maxAnisotropy: number): void { function optimizeTexture(texture: THREE.Texture, maxAnisotropy: number): void {
if (optimizedTextures.has(texture)) return; if (optimizedTextures.has(texture)) return;
optimizedTextures.add(texture); optimizedTextures.add(texture);
texture.anisotropy = Math.min(4, Math.max(1, maxAnisotropy)); const nextAnisotropy = Math.min(
MAX_GLTF_TEXTURE_ANISOTROPY,
if (!(texture instanceof THREE.CompressedTexture)) { Math.max(1, maxAnisotropy),
texture.generateMipmaps = true; );
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.magFilter = THREE.LinearFilter;
}
if (texture.anisotropy > nextAnisotropy) {
texture.anisotropy = nextAnisotropy;
texture.needsUpdate = true; texture.needsUpdate = true;
}
} }
function optimizeMaterialTextures( function optimizeMaterialTextures(
+19 -3
View File
@@ -31,11 +31,20 @@ import { CharacterSystem } from "@/world/characters/CharacterSystem";
import { Player } from "@/world/player/Player"; import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap"; import { TestMap } from "@/world/debug/TestMap";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import type { HandTrackingGloveHandedness } from "@/hooks/handTracking/useHandTrackingGloveStatus";
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
interface WorldProps { interface WorldProps {
onLoadingStateChange?: SceneLoadingChangeHandler | undefined; onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
} }
function hasTrackedHand(
hands: HandTrackingHand[],
handedness: HandTrackingGloveHandedness,
): boolean {
return hands.some((hand) => hand.handedness.toLowerCase() === handedness);
}
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element { export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
useEnvironmentDebug(); useEnvironmentDebug();
useMapPerformanceDebug(); useMapPerformanceDebug();
@@ -49,7 +58,7 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
(state) => state.showPlayerModel, (state) => state.showPlayerModel,
); );
const showDebugOctree = useDebugVisualsStore((state) => state.showOctree); const showDebugOctree = useDebugVisualsStore((state) => state.showOctree);
const { status, usageStatus } = useHandTrackingSnapshot(); const { hands, status, usageStatus } = useHandTrackingSnapshot();
const { const {
octree, octree,
gameplayReady, gameplayReady,
@@ -63,8 +72,11 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
? PLAYER_SPAWN_POSITION_GAME ? PLAYER_SPAWN_POSITION_GAME
: PLAYER_SPAWN_POSITION_PHYSICS; : PLAYER_SPAWN_POSITION_PHYSICS;
const showHandTrackingGloves = const showHandTrackingGloves =
sceneMode === "physics" || status === "connected" && usageStatus !== "inactive" && hands.length > 0;
(status !== "idle" && usageStatus !== "inactive"); const showLeftHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "left");
const showRightHandTrackingGlove =
showHandTrackingGloves && hasTrackedHand(hands, "right");
const spawnPlayer = const spawnPlayer =
cameraMode !== "debug" && cameraMode !== "debug" &&
(sceneMode === "game" ? gameplayReady : octree !== null); (sceneMode === "game" ? gameplayReady : octree !== null);
@@ -82,8 +94,12 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
) : null} ) : null}
{showHandTrackingGloves ? ( {showHandTrackingGloves ? (
<Suspense fallback={null}> <Suspense fallback={null}>
{showLeftHandTrackingGlove ? (
<HandTrackingGlove handedness="left" /> <HandTrackingGlove handedness="left" />
) : null}
{showRightHandTrackingGlove ? (
<HandTrackingGlove handedness="right" /> <HandTrackingGlove handedness="right" />
) : null}
</Suspense> </Suspense>
) : null} ) : null}
{cameraMode === "debug" ? <DebugCameraControls /> : null} {cameraMode === "debug" ? <DebugCameraControls /> : null}