Skip to content

Navigation Menu

Sign in
Sign up

Hand models remount on every tracking blip, re-cloning the skeleton and leaking a bone texture #502

Open

Description

Summary

Every time hand tracking is lost and re-acquired, XRHands fully unmounts and remounts both hand components. XRHandModel clones the GLTF on each mount (SkeletonUtils.clone), producing a new Skeleton each time. three lazily allocates a bone texture for a skeleton and nothing ever calls Skeleton.dispose(), so the previous bone texture is orphaned on the GPU — never deleted.

On a Quest 2 this is an unbounded leak of 2 textures per tracking blip, plus the CPU cost of re-cloning a 25-bone rig and rebuilding the React subtree each time.

The textures are small (×ばつ12 RGBA32F ≈ 2.3 KB), so this is a slow leak rather than a frame-rate problem — but deleteTexture is called exactly zero times for them, so nothing ever comes back.

Environment

@pmndrs/xr 6.6.30
@react-three/xr 6.6.30
three 0.171.0
Device Meta Quest 2, Oculus Browser 149
Mode immersive-ar, default hand implementation

Root cause

1. The remount. @react-three/xr/dist/elements.js:41-54 keys each hand on objectToKey(state):

function XRHands() {
 const handStates = useXR((xr) => xr.inputSourceStates.filter((s) => s.type === 'hand'), shallow);
 ...
 }, objectToKey(state))); // <-- identity-based key
}

objectToKey (utils.js:3-9) is a WeakMap handing out an incrementing integer per object identity, so any fresh input-source state object produces a new React key and therefore a full unmount/remount.

Worth noting: the controller path immediately above (elements.js:38) keys on state.id instead. The inconsistency may be unintentional.

2. The re-clone. @react-three/xr/dist/hand.js:17-19:

const gltf = useLoader(GLTFLoader, state.assetPath); // suspense-cached, no refetch
const model = useMemo(() => cloneXRHandGltf(gltf), [gltf]); // new clone on every mount
...
return <primitive object={model} />;

cloneXRHandGltf (@pmndrs/xr/dist/hand/model.js:15) is SkeletonUtils.clone, which builds new Bones and a new Skeleton per call. There is no caching anywhere in this path.

3. The leak. three allocates the bone texture lazily in setProgram (three.module.js:16707):

if ( skeleton.boneTexture === null ) skeleton.computeBoneTexture();
p_uniforms.setValue( _gl, 'boneTexture', skeleton.boneTexture, textures );

and computeBoneTexture (three/src/objects/Skeleton.js:161) creates a DataTexture(..., RGBAFormat, FloatType) sized:

let size = Math.sqrt( this.bones.length * 4 );
size = Math.ceil( size / 4 ) * 4;
size = Math.max( size, 4 );

For the 25-bone hand rig that is ×ばつ12 RGBA32F.

three provides Skeleton.dispose() (Skeleton.js:205), which disposes exactly this texture:

dispose( ) {
 if ( this.boneTexture !== null ) {
 this.boneTexture.dispose();
 this.boneTexture = null;
 }
}

but nothing calls it for the discarded clone. <primitive> is also the one R3F node that deliberately does not dispose its object, so unmount frees nothing.

Evidence

Instrumented texStorage2D and wrapped WebGLRenderer.renderBufferDirect to capture the object and material being drawn at the moment of each allocation. Every leaked texture was:

12x12 fmt=RGBA32F
 objType : SkinnedMesh
 objName : l_handMeshNode / r_handMeshNode
 path : Scene/Group/Group/Group/Scene/Armature/l_handMeshNode
 bones : 25

Tracking identity across tracking cycles shows the remount plainly — note every uuid changes, and the texture count steps up:

[t=1604s tex=76] r_handMeshNode obj:ecce4fee skel:24b09be4 boneTex:e86bc2e1
[t=1628s tex=80] l_handMeshNode obj:ee4ac371 skel:31b86802 boneTex:b515e9db
 r_handMeshNode obj:861dac7a skel:b9e7a38d boneTex:2e5e9740
[t=1652s tex=80] (no skinned meshes — tracking lost, models unmounted)
[t=1660s tex=82] l_handMeshNode obj:550bf55f skel:4d248c10 boneTex:0832cee0
 r_handMeshNode obj:0a60e561 skel:6f63dbbe boneTex:7f51c2f5

Over one session renderer.info.memory.textures went 10 → 20 → 29 → 44 with deleteTexture never called for these, while buffers (160 deletes) and vertex arrays (40 deletes) released normally in the same windows.

Workaround

Because createXRStore's hand option accepts a ComponentType, this can be fixed from app code without a fork. We supply our own model component that caches the clone per handedness, so the Skeleton — and its bone texture — is created once per hand for the life of the page. A remount then re-parents an existing object instead of allocating a new one.

DefaultXRHand is reused with model={false} so grab, touch and ray pointers are unaffected; only the model is swapped. (The model sub-option only accepts options or false and cannot take a component, hence replacing the whole hand component.)

import { Suspense, useMemo } from "react";
import { useFrame, useLoader } from "@react-three/fiber";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DefaultXRHand, useXRInputSourceStateContext, useXRSpace } from "@react-three/xr";
import { cloneXRHandGltf, configureXRHandModel, createUpdateXRHandVisuals } from "@pmndrs/xr/internals";
import type { Object3D } from "three";
const cache = new Map<XRHandedness, Object3D>();
function CachedHandModel() {
 const state = useXRInputSourceStateContext("hand");
 const gltf = useLoader(GLTFLoader, state.assetPath);
 const handedness = state.inputSource.handedness;
 const model = useMemo(() => {
 let cached = cache.get(handedness);
 if (!cached) cache.set(handedness, (cached = cloneXRHandGltf(gltf)));
 return cached;
 }, [gltf, handedness]);
 configureXRHandModel(model, {});
 state.object = model;
 const referenceSpace = useXRSpace();
 const update = useMemo(
 () => createUpdateXRHandVisuals(state.inputSource.hand, model, referenceSpace),
 [state.inputSource, model, referenceSpace],
 );
 useFrame((_state, _delta, frame) => update(frame));
 return <primitive object={model} />;
}
export function CachedXRHand() {
 return (
 <>
 <Suspense><CachedHandModel /></Suspense>
 <DefaultXRHand model={false} />
 </>
 );
}

Wired with createXRStore({ hand: CachedXRHand }).

Reuse is safe here because createUpdateXRHandVisuals is already re-bound per mount on [state.inputSource, model, referenceSpace], so a cached model still gets a correct updater for the new input source. Only one hand of each handedness exists at a time, so a cached model is never mounted in two places.

Verified on-device by identity rather than absence: across two acquire/lose cycles, both hands kept the same object, skeleton and bone-texture uuids through unmount/remount, the texture count held flat, and zero ×ばつ12 RGBA32F allocations occurred — where previously each cycle produced all-new uuids and +2 textures.

A global monkey-patch is not viable for this one: the leaked texture belongs to the orphaned skeleton, so wrapping computeBoneTexture never sees it.

Suggested fix

Any one of these would close it; the first two also remove the per-blip CPU cost:

  1. Cache the cloned model per handedness inside XRHandModel, so the skeleton is built once instead of per mount.
  2. Key hands on a stable id (state.id) in XRHands, as the controller path already does, so a re-acquired hand reuses its component instead of remounting.
  3. Dispose on unmount — at minimum, call Skeleton.dispose() for the cloned rig when XRHandModel unmounts, which frees the bone texture three allocated.

The remount is partly legitimate (WebXR really does remove the input source), so (1) or (3) may be the more robust target; (2) reduces how often it happens.

Happy to open a PR for whichever direction you prefer.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      AltStyle によって変換されたページ (->オリジナル) /