fix(handtracking): stabilize provider root and linger enabled
(1) HandTrackingProvider always renders the same JSX root (HandTrackingRuntime) so toggling `enabled` no longer remounts the <Canvas> below — that remount was destroying the WebGL context every time the player entered an interaction zone. (2) Add HAND_TRACKING_LINGER_MS (2s) cooldown on `enabled` so brief walk-throughs of a trigger zone don't tear down MediaPipe before it has time to initialize the webcam + model + first frame (cold start ~800ms). Resolves the WebGL context lost + respawn loop and restores visible hand tracking in the backend runtime. Browser JS runtime detection quality is a separate follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,9 @@ export const HAND_TRACKING_BROWSER_DELEGATE: "CPU" | "GPU" = "CPU";
|
|||||||
// Absorbs React StrictMode's mount/unmount/mount cycle in dev and rapid
|
// Absorbs React StrictMode's mount/unmount/mount cycle in dev and rapid
|
||||||
// `nearby` toggles at trigger borders. Invisible to the user (~5 frames).
|
// `nearby` toggles at trigger borders. Invisible to the user (~5 frames).
|
||||||
export const HAND_TRACKING_RUNTIME_START_DELAY_MS = 80;
|
export const HAND_TRACKING_RUNTIME_START_DELAY_MS = 80;
|
||||||
|
|
||||||
|
// How long the hand tracking stays active after the trigger condition
|
||||||
|
// (nearby / holding / repair step) turns off. Gives MediaPipe enough time
|
||||||
|
// to initialize webcam + model + first frame inference before we cleanup,
|
||||||
|
// so the user actually sees their hands when entering a zone briefly.
|
||||||
|
export const HAND_TRACKING_LINGER_MS = 2000;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { HAND_TRACKING_LINGER_MS } from "@/data/handTrackingConfig";
|
||||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||||
import { useInteraction } from "@/hooks/interaction/useInteraction";
|
import { useInteraction } from "@/hooks/interaction/useInteraction";
|
||||||
@@ -38,37 +40,74 @@ export function HandTrackingProvider({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const { nearby, holding, handHolding } = useInteraction();
|
const { nearby, holding, handHolding } = useInteraction();
|
||||||
const enabled =
|
const requested =
|
||||||
repairNeedsHands ||
|
repairNeedsHands ||
|
||||||
(sceneMode === "physics" && (nearby || holding || handHolding));
|
(sceneMode === "physics" && (nearby || holding || handHolding));
|
||||||
|
|
||||||
if (!enabled) {
|
// Keep the runtime active a little after `requested` turns off so
|
||||||
return (
|
// MediaPipe has time to initialize the webcam + model + first frame
|
||||||
<HandTrackingContext value={HAND_TRACKING_IDLE_SNAPSHOT}>
|
// before being torn down. Without this, a quick walk-through of a
|
||||||
{children}
|
// trigger zone never produces a detected hand and the user sees
|
||||||
</HandTrackingContext>
|
// nothing.
|
||||||
);
|
const enabled = useLingeredFlag(requested, HAND_TRACKING_LINGER_MS);
|
||||||
}
|
|
||||||
|
|
||||||
return <ActiveHandTrackingProvider>{children}</ActiveHandTrackingProvider>;
|
// Always render the same JSX root (HandTrackingRuntime). Returning
|
||||||
|
// different element types from this provider would force React to
|
||||||
|
// remount its entire subtree — including the <Canvas> below — every
|
||||||
|
// time `enabled` toggles, which destroys the WebGL context.
|
||||||
|
return (
|
||||||
|
<HandTrackingRuntime enabled={enabled}>{children}</HandTrackingRuntime>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActiveHandTrackingProvider({
|
function useLingeredFlag(value: boolean, lingerMs: number): boolean {
|
||||||
|
const [latched, setLatched] = useState(value);
|
||||||
|
|
||||||
|
// Asymmetric sync: snap up immediately when `value` becomes true,
|
||||||
|
// debounce the down transition by `lingerMs`. The setLatched(true)
|
||||||
|
// call below is intentionally a direct setState inside an effect
|
||||||
|
// because that is exactly the pattern we want (mirror upward edge,
|
||||||
|
// delay downward edge), and there is no equivalent without it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional upward edge sync, see hook comment
|
||||||
|
setLatched(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setLatched(false);
|
||||||
|
}, lingerMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [value, lingerMs]);
|
||||||
|
|
||||||
|
return latched;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandTrackingRuntime({
|
||||||
|
enabled,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const handTrackingSource = useDebugStore((debug) =>
|
const handTrackingSource = useDebugStore((debug) =>
|
||||||
debug.getHandTrackingSource(),
|
debug.getHandTrackingSource(),
|
||||||
);
|
);
|
||||||
const backendSnapshot = useRemoteHandTracking({
|
const backendSnapshot = useRemoteHandTracking({
|
||||||
enabled: handTrackingSource === "backend",
|
enabled: enabled && handTrackingSource === "backend",
|
||||||
});
|
});
|
||||||
const browserSnapshot = useBrowserHandTracking({
|
const browserSnapshot = useBrowserHandTracking({
|
||||||
enabled: handTrackingSource === "browser",
|
enabled: enabled && handTrackingSource === "browser",
|
||||||
});
|
});
|
||||||
const snapshot =
|
const snapshot = !enabled
|
||||||
handTrackingSource === "browser" ? browserSnapshot : backendSnapshot;
|
? HAND_TRACKING_IDLE_SNAPSHOT
|
||||||
|
: handTrackingSource === "browser"
|
||||||
|
? browserSnapshot
|
||||||
|
: backendSnapshot;
|
||||||
|
|
||||||
return <HandTrackingContext value={snapshot}>{children}</HandTrackingContext>;
|
return <HandTrackingContext value={snapshot}>{children}</HandTrackingContext>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user