diff --git a/src/data/handTrackingConfig.ts b/src/data/handTrackingConfig.ts index 2b28dda..5133463 100644 --- a/src/data/handTrackingConfig.ts +++ b/src/data/handTrackingConfig.ts @@ -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 // `nearby` toggles at trigger borders. Invisible to the user (~5 frames). 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; diff --git a/src/providers/gameplay/HandTrackingProvider.tsx b/src/providers/gameplay/HandTrackingProvider.tsx index b84a375..55b311c 100644 --- a/src/providers/gameplay/HandTrackingProvider.tsx +++ b/src/providers/gameplay/HandTrackingProvider.tsx @@ -1,4 +1,6 @@ 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 { useDebugStore } from "@/hooks/debug/useDebugStore"; import { useInteraction } from "@/hooks/interaction/useInteraction"; @@ -38,37 +40,74 @@ export function HandTrackingProvider({ } }); const { nearby, holding, handHolding } = useInteraction(); - const enabled = + const requested = repairNeedsHands || (sceneMode === "physics" && (nearby || holding || handHolding)); - if (!enabled) { - return ( - - {children} - - ); - } + // Keep the runtime active a little after `requested` turns off so + // MediaPipe has time to initialize the webcam + model + first frame + // before being torn down. Without this, a quick walk-through of a + // trigger zone never produces a detected hand and the user sees + // nothing. + const enabled = useLingeredFlag(requested, HAND_TRACKING_LINGER_MS); - return {children}; + // Always render the same JSX root (HandTrackingRuntime). Returning + // different element types from this provider would force React to + // remount its entire subtree — including the below — every + // time `enabled` toggles, which destroys the WebGL context. + return ( + {children} + ); } -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, }: { + enabled: boolean; children: ReactNode; }): React.JSX.Element { const handTrackingSource = useDebugStore((debug) => debug.getHandTrackingSource(), ); const backendSnapshot = useRemoteHandTracking({ - enabled: handTrackingSource === "backend", + enabled: enabled && handTrackingSource === "backend", }); const browserSnapshot = useBrowserHandTracking({ - enabled: handTrackingSource === "browser", + enabled: enabled && handTrackingSource === "browser", }); - const snapshot = - handTrackingSource === "browser" ? browserSnapshot : backendSnapshot; + const snapshot = !enabled + ? HAND_TRACKING_IDLE_SNAPSHOT + : handTrackingSource === "browser" + ? browserSnapshot + : backendSnapshot; return {children}; }