ui-pagescene: snapshot+restore camera quat across pointer-lock unlock to stop rotation snap

When the user left/middle-click drags to rotate the camera and releases,
Chromium synthesizes one final `mousemove` while exiting pointer lock so
it can reposition the cursor back to where the user grabbed it. At that
exact moment, Three's PointerLockControls still has `isLocked === true`
(the flag flips after the unlock event dispatches), so its mousemove
handler reads the synthesized `movementX/Y` as user input and rotates
the camera by it — producing a visible lurch back by roughly the inverse
of the user's last drag delta.

Capture the camera quaternion in `MouseControls.onMouseUp` immediately
before `lockControls.unlock()`. In a real (non-debug) `unlock` listener
attached at editor init, copy the snapshot back into the camera and call
`updateMatrixWorld()`. By the time `unlock` fires, the synthetic move
has already been applied, so overwriting is the surgical fix at the
boundaries we actually own — no Three.js subclassing, no per-frame
state machine.

Trace that confirmed the cause:
  [mc] up    button=1 locked=true quat=(-0.2684, 0.1394, 0.0393, 0.9524)
  [cam-frame]                     quat=(-0.2684, 0.1394, 0.0393, 0.9524)
  [plc] unlock                    quat=(-0.2180, 0.5112, 0.1358, 0.8202)
This commit is contained in:
Tanish Manku committed 2026-05-08 21:02:05 -07:00
1 parent 1ea94e4b42
commit edd900d29b
2 files changed
+34

No files matched your search

@@ -425,6 +425,25 @@ class Editor {
this.cameraController.camera,
this.renderer.domElement,
);
// Restore the quaternion captured at mouseup. Chromium synthesizes
// one final `mousemove` while exiting pointer lock so it can
// reposition the cursor; PLC reads that synthesized delta and
// rotates the camera by it because `isLocked` is still true at
// that moment. By the time the `unlock` event fires here, that
// synthetic move has already been applied — overwriting it with
// the pre-unlock snapshot keeps the camera at the orientation the
// user actually released on.
this.cameraController.lockControls.addEventListener("unlock", () => {
const snap = this.mouse_controls?.quatAtUnlockRequest;
const cam = this.cameraController.camera;
if (snap && cam) {
cam.quaternion.copy(snap);
cam.updateMatrixWorld();
}
if (this.mouse_controls) {
this.mouse_controls.quatAtUnlockRequest = null;
}
});
}
// FreeCam math + listeners now live in hooks/useFreeCam.ts; the
// editor reads `cameraController.freeCamState` (set by that hook) on
@@ -130,6 +130,14 @@ export class MouseControls {
private isBoneDragged: boolean = false;
private ignoreNextClick: boolean = false;
// Snapshot of the camera quaternion taken in onMouseUp right before
// lockControls.unlock(). The unlock listener in editor.ts restores
// this value to neutralize the synthetic mousemove Chromium fires
// while pointer-lock is releasing — that synthetic move would
// otherwise be processed by PointerLockControls and lurch the camera
// back by the cursor's "trip home" delta, producing a visible snap.
quatAtUnlockRequest: THREE.Quaternion | null = null;
// Deps held for read callbacks + bus emissions throughout the class.
// Constructor still copies primitive fields onto `this.x` for the
// existing internals; new code reads through `this.deps` directly.
@@ -273,6 +281,13 @@ export class MouseControls {
onMouseUp(event: any) {
if (event.button === 0 || event.button === 1) {
// Snapshot the quaternion the user actually ended on. The unlock
// listener in editor.ts will restore this once Chromium's
// synthetic-mousemove window has closed, so the camera doesn't
// lurch by the cursor's trip-home delta.
if (this.camera) {
this.quatAtUnlockRequest = this.camera.quaternion.clone();
}
this.lockControls?.unlock();
this.isMouseClicked = false;
}