Search code examples
javascriptthree.jsrotationmulti-touchquaternions

Quaternion rotation in three.js going haywire when rotating past about 90°


I'm using two-finger touch events to pinch-rotate-zoom a THREE.Mesh object, using quaternions. It's my first time using this rotation method, and due to I'm sure some property of quaternions that I can't seem to understand, the rotation gradually starts to jitter all over the place when the total touch drag rotates the object over about 90°. Then, it gradually returns to the original smooth, albeit noticeably nonlinear, rotation when dragged back under 90° again. (90° is just a guess).

I'm completely stumped. Here is the code I'm using to rotate the object obj:

  // global state
var 
      // keeps track of original object scale
    pinchScale = new THREE.Vector3(),

      // current first touch
    touch1 = new THREE.Vector2(),

      // current second touch
    touch2 = new THREE.Vector2(),

      // first touch at touch start
    touch1OnHold = new THREE.Vector2(),

      // second touch at touch start
    touch2OnHold = new THREE.Vector2(),

      // keeps track of total held rotation at last fired touchmove event
    angularHoldPrev = new THREE.Quaternion();

  ⋮

  // key-value pairs inside an addEventListener utility
touchstart: function (event) {
  event.preventDefault();
  if ( event.touches.length === 1 ) {
    …
  } else if ( event.touches.length === 2 ) {
    touch1OnHold.set(event.touches[0].pageX, event.touches[0].pageY);
    touch2OnHold.set(event.touches[1].pageX, event.touches[1].pageY);
    angularHoldPrev.set(0, 0, 0, 1)
  }
},
touchmove: function (event) {
  event.preventDefault();
  if ( event.touches.length === 1 ) {
    …
  } else if ( event.touches.length === 2 ) {
    touch1.set(event.touches[0].pageX, event.touches[0].pageY);
    touch2.set(event.touches[1].pageX, event.touches[1].pageY);
    var 
          // get touch spread at present event firing, and at the start of current hold
        touchDiff = touch2.clone().sub(touch1),
        touchDiffOnHold = touch2OnHold.clone().sub(touch1OnHold),

          // camera is on z-axis; get this axis regardless of obj orientation
        axis1 = new THREE.Vector3(0, 0, 1).applyQuaternion(obj.quaternion.clone().inverse()),

          // get a touch rotation around this axis
        rot1 = new THREE.Quaternion().setFromAxisAngle(axis1, (Math.atan2(touchDiffOnHold.y, touchDiffOnHold.x) - Math.atan2(touchDiff.y, touchDiff.x))).normalize(),

          // get touch barycentre at present event firing, and at the start of current hold
        touchCentre = touch1.clone().add(touch2).multiplyScalar(.5),
        touchCentreOnHold = touch1OnHold.clone().add(touch2OnHold).multiplyScalar(.5),

          // get axis of touch barycentre movement on the xy plane, regardless of obj orientation
        axis2 = new THREE.Vector3(touchCentre.y - touchCentreOnHold.y, touchCentre.x - touchCentreOnHold.x, 0).applyQuaternion(obj.quaternion.clone().inverse()),

          // get a rotation proportional to magnitude of touch movement
        rot2 = new THREE.Quaternion().setFromAxisAngle(axis2, axis2.length() * rotationSensitivity).normalize(),

          // combine the two rotations
        rot = rot1.multiply(rot2);

      // undo last rotation if not the empty quaternion
    if (!angularHoldPrev.equals(new THREE.Quaternion())) obj.quaternion.multiply(angularHoldPrev.inverse());

      // perform the currently calculated rotation
    obj.quaternion.multiply(rot);

      // save this rotation for next event firing
    angularHoldPrev.copy(rot);

      // resize object according to change in touch spread
    obj.scale.copy(pinchScale.clone().multiplyScalar(touchDiff.length() / touchDiffOnHold.length()))
  }
},
touchend: function (event) {
  event.preventDefault();

    // reset original object scale
  pinchScale.copy(obj.scale)
}

Any clue how I can maintain proportional rotation for all two-touch input would be greatly appreciated. Cheers.


Solution

  • I got it working by making all the touchmove rotations incremental rather than relative to touchstart, and it actually cleans things up a little. rotV is an angular velocity variable I have for the animation loop that gives the object a nice inertial kick when you move it around. (Thanks @WestLangley for the optimisation advice)

    var touch1 = new THREE.Vector2(),
        touch2 = new THREE.Vector2(),
        touch1Prev = new THREE.Vector2(),
        touch2Prev = new THREE.Vector2(),
        rotV = new THREE.Quaternion(),
    
        touchDiff = new THREE.Vector2(),
        touchDiffPrev = new THREE.Vector2(),
        touchCentre = new THREE.Vector2(),
        touchCentrePrev = new THREE.Vector2(),
        axis1 = new THREE.Vector3(),
        axis2 = new THREE.Vector3(),
        rot1 = new THREE.Quaternion(),
        rot2 = new THREE.Quaternion(),
        adjq = new THREE.Quaternion();
    
    ⋮
    
    touchstart: function (event) {
      event.preventDefault();
      if ( event.touches.length === 1 ) {
        …
      } else if ( event.touches.length === 2 ) {
        touch1Prev.set(event.touches[0].pageX, event.touches[0].pageY);
        touch2Prev.set(event.touches[1].pageX, event.touches[1].pageY)
      }
    },
    touchmove: function (event) {
      event.preventDefault();
      adjq.copy(obj.quaternion).inverse();
      if ( event.touches.length === 1 ) {
        …
      } else if ( event.touches.length === 2 ) {
        touch1.set(event.touches[0].pageX, event.touches[0].pageY);
        touch2.set(event.touches[1].pageX, event.touches[1].pageY);
    
        touchDiff.copy(touch2).sub(touch1);
        touchDiffPrev.copy(touch2Prev).sub(touch1Prev);
        axis1.set(0, 0, 1).applyQuaternion(adjq);
        rot1.setFromAxisAngle(axis1, (Math.atan2(touchDiffPrev.y, touchDiffPrev.x) - Math.atan2(touchDiff.y, touchDiff.x))).normalize();
    
        touchCentre.copy(touch1).add(touch2).multiplyScalar(.5);
        touchCentrePrev.copy(touch1Prev).add(touch2Prev).multiplyScalar(.5);
        axis2.set(touchCentre.y - touchCentrePrev.y, touchCentre.x - touchCentrePrev.x, 0).applyQuaternion(adjq);
        rot2.setFromAxisAngle(axis2, axis2.length() * rotationSensitivity * 10).normalize();
    
        obj.quaternion.multiply(rot1.multiply(rot2));
        rotV.multiply(rot1.slerp(adjq.set(0, 0, 0, 1), .9));
        obj.scale.multiplyScalar(touchDiff.length() / touchDiffPrev.length());
    
        touch1Prev.copy(touch1);
        touch2Prev.copy(touch2)
      }
    },
    touchend: function (event) {
      event.preventDefault()
    }
    

    I still wish I knew what caused the original problem though :P