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— “HittingMax number of touches exceededwhen using a pen on a tablet (Samsung + S Pen)”. Fixed by making_pointerDownEventreuse a slot already held by the samepointerId.forum.babylonjs.com/t/.../56405— “onPointerObservable ignoring tablet pen input”. Fixed by PR #16156, cancelling the touch onpointerleave.
Why neither covers this case:
- The
pointerleavecleanup is gated onevt.pointerType === "pen". Quest controller rays reportpointerType: "touch", so they never reach_pointerCancelTouch. - The
pointerdownslot reuse assumes the samepointerIdcomes back. Quest assigns a freshpointerIdfor 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
pointeruphappens to free a leaked slot. - The two controllers appear to fail independently, depending on which
pointerIdcurrently 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
- Don’t allocate a slot in
_pointerMoveEventwhenevt.buttons === 0. A pointer that isn’t pressed doesn’t need to occupy a contact slot. (Most targeted, in my opinion.) - Let
_pointerLeaveEventrelease touch pointers too, not just pen — i.e. generalise the #16156 fix. - 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!