Touch slot leak on Meta Quest Browser: hovering "touch" pointers permanently break all pointer input (previous pen-only fixes don't cover this)

Hi! On Meta Quest Browser I hit Max number of touches exceeded. Ignoring touches in excess of 2, after which the camera stops responding to drags permanently.

I found two earlier reports of the same warning, so let me be upfront that this is not a duplicate. Both were fixed for pen input only, and I confirmed those fixes are present in the version I tested while this still reproduces.

(I’m a new user so I can’t post links yet — URLs are in code spans so you can copy them.)

  • forum.babylonjs.com/t/.../50593 — “Hitting Max number of touches exceeded when using a pen on a tablet (Samsung + S Pen)”. Fixed by making _pointerDownEvent reuse a slot already held by the same pointerId.
  • forum.babylonjs.com/t/.../56405 — “onPointerObservable ignoring tablet pen input”. Fixed by PR #16156, cancelling the touch on pointerleave.

Why neither covers this case:

  • The pointerleave cleanup is gated on evt.pointerType === "pen". Quest controller rays report pointerType: "touch", so they never reach _pointerCancelTouch.
  • The pointerdown slot reuse assumes the same pointerId comes back. Quest assigns a fresh pointerId for every interaction, so there is never a slot to reuse — it simply takes another one.

Tested with @babylonjs/core 9.18.0, and reproduced again with the CDN build (9.18.1 at the time of writing).

What happens

_pointerMoveEvent retroactively allocates an _activeTouchIds slot for an untracked touch. On Quest, a controller ray emits pointermove with buttons: 0 merely by pointing at the page — no press involved — so a slot is taken by a pointer that will never be pressed, and therefore never gets a matching pointerup.

Once the pool is full, _pointerDownEvent drops the event outright:

else {
    Tools.Warn(`Max number of touches exceeded.  Ignoring touches in excess of ${this._maxTouchPoints}`);
    return;   // never reaches scene.onPointerObservable
}

For a hovering touch pointer, none of the release paths apply:

Release path Why it doesn’t fire
_pointerUpEvent needs a pointerup that never comes (never pressed)
_pointerLeaveEvent_pointerCancelTouch gated on pointerType === "pen"
_pointerBlurEvent only frees slots with LeftClick === 1 (currently pressed)

There’s also a sizing mismatch: navigator.maxTouchPoints describes how many contacts can be touched simultaneously, but this pool also holds hovering pointers, which are unbounded over time when the environment issues a fresh pointerId per interaction.

Repro

No headset needed — synthetic pointer events reproduce it deterministically. Save as an HTML file and open it over HTTP, then click Run repro.

<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>
<canvas id="c" style="width:600px;height:300px;touch-action:none"></canvas>
<button id="run">Run repro</button><pre id="out"></pre>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script>
const c = document.getElementById("c"), out = document.getElementById("out");
const engine = new BABYLON.Engine(c, true), scene = new BABYLON.Scene(engine);
const camera = new BABYLON.ArcRotateCamera("cam", -Math.PI/2, Math.PI/3, 6, BABYLON.Vector3.Zero(), scene);
camera.attachControl(c, false);
camera.inertia = 0;                       // so leftover motion can't pollute the measurement
new BABYLON.HemisphericLight("l", new BABYLON.Vector3(0,1,0), scene);
BABYLON.MeshBuilder.CreateBox("b", {size:2}, scene);
engine.runRenderLoop(() => scene.render());

const log = m => out.textContent += m + "\n";
const slots = () => engine._deviceSourceManager._deviceInputSystem._activeTouchIds;
const frame = () => new Promise(requestAnimationFrame);
const send = (type, pointerId, x, buttons, button) => c.dispatchEvent(
  new PointerEvent(type, {bubbles:true, cancelable:true, pointerId,
                          pointerType:"touch", button, buttons, clientX:x, clientY:150}));

async function dragAndMeasureRotation(id) {
  await frame(); await frame();
  const before = camera.alpha;
  send("pointerdown", id, 200, 1, 0);
  for (let i = 1; i <= 10; i++) { send("pointermove", id, 200 + i*10, 1, -1); await frame(); }
  send("pointerup", id, 300, 0, 0);
  await frame();
  return camera.alpha - before;
}

document.getElementById("run").onclick = async () => {
  out.textContent = "";
  log(`navigator.maxTouchPoints = ${navigator.maxTouchPoints}`);
  log(`slots (initial)  = [${slots()}]`);
  log(`\n1) drag BEFORE   -> rotated by ${(await dragAndMeasureRotation(1000)).toFixed(4)}`);

  // Hovering pointers: pointermove only, buttons === 0, no matching pointerup.
  for (let i = 0; i < slots().length; i++) send("pointermove", 2000 + i, 100 + i*10, 0, -1);
  log(`\n2) hover-only pointermove x${slots().length}`);
  log(`   slots         = [${slots()}]   <- occupied, never released`);

  log(`\n3) drag AFTER    -> rotated by ${(await dragAndMeasureRotation(1001)).toFixed(4)}`);
};
</script></body></html>

Output:

navigator.maxTouchPoints = 0
slots (initial)  = [-1,-1]

1) drag BEFORE   -> rotated by -0.1000

2) hover-only pointermove x2
   slots         = [2000,2001]   <- occupied, never released

3) drag AFTER    -> rotated by 0.0000

Step 3 should rotate the camera exactly like step 1, but the pointerdown is dropped and nothing happens. I measure camera.alpha rather than asserting on internals, so it reflects what the user actually sees.

Why it was painful to debug

The events die before any observable fires, so onPointerObservable stays silent and BaseCameraPointersInput._pointA / _pointB look perfectly healthy — everything downstream appears fine. It also looks non-deterministic to users:

  • Pressing any button “fixes” it — that pointerup happens to free a leaked slot.
  • The two controllers appear to fail independently, depending on which pointerId currently holds a slot.

Incidentally, the reporter in the 2024 thread also noticed that clicking a button cleared the state, which I think is a good sign that these share one underlying cause.

Possible fixes

  1. Don’t allocate a slot in _pointerMoveEvent when evt.buttons === 0. A pointer that isn’t pressed doesn’t need to occupy a contact slot. (Most targeted, in my opinion.)
  2. Let _pointerLeaveEvent release touch pointers too, not just pen — i.e. generalise the #16156 fix.
  3. On a full pool in _pointerDownEvent, reclaim a slot held by a non-pressed pointer instead of dropping the event.

We currently do (3) from outside the library, in a capture-phase pointerdown listener that runs before Babylon’s own handler. That fully restores the behaviour on real hardware, but it does mean reaching into _activeTouchIds, so a proper fix upstream would be much appreciated.

Happy to open a GitHub issue or send a PR if that’s useful. Thanks for the great library!

Checking! I want to under stand the issue in full. If you are sure your fix is the right one feel free to submit a PR linking this forum link. I will probably submit a draft PR later today otherwise.

@RaananW Saw on GitHub that the PR was merged! So glad I could help out. Thanks for your quick work on this! :raising_hands: