3D Rotations: Rotating around the screen axes

I have been there…

I may have captured and posted the wrong animated GIF; I have a dozen or so animations in that sketch to select from various rotations, signs, etc.
I really got into this as a personal exercise!

Is this it?
The corners of the box move vertically along the y-axis along with mouse movement.
I just tweaked your original code.
0000001

  float y = map(mouseX, 0, width,   PI/4, -PI/4);
  float x = map(mouseY, 0, height, -PI/4,  PI/4);

  translate(width/2, height/2);

  rotateX(x);
  rotateY(-y);

Here is some fun math:
https://www.euclideanspace.com/maths/geometry/rotations/conversions/index.htm

That site helped me with this project:

:slight_smile:

1 Like

The gif is missing a crucial part i.e. moving the mouse to the right with an x rotation applied.

I will leave this in your hands.
Have fun!

:slight_smile:

@raetrakt@glv’s solution is absolutely correct for a fixed-axis camera.

However, it seems that you may want your secondary rotation to be the plane of intersection created by your primary rotation. One approach to do this is a quaternion-based camera with relative motion input. For that please take a look at the p5.easyCam library, based on peasyCam.

For example, check out the Quick Start Ortho demo:

https://diwi.github.io/p5.EasyCam/examples/QuickStart_Ortho/

Note that, regardless, some of the basic feedback above still applies. If you want things to “always move right” etc. that cannot be true if a point rotates beyond the silhouette of the sphere – so your ranges (-PI, PI) are incorrect. Your inputs should range from -PI/4 to PI/4.

The other way is to actually translate to your plane of intersection, rotate, and draw. Here is an example of how that works:

https://editor.p5js.org/jeremydouglass/sketches/C8iRfwjx-

/* tumbleSphere - p5.js 2019-09 Jeremy Douglass
 * -  red is a normal sphere rotating on two axes
 * -  blue translates to the x point of rotation,
 *    then rotates in y round that cross-section.
 * Use the blue/red overlap to see the difference.
 * 
 * This sketch uses an orthographic camera to prevent
 * perspective warping on the non-centered examples.
 *
 * See https://discourse.processing.org/t/3d-rotations-rotating-around-the-screen-axes/14239
 */

function setup() {
  createCanvas(windowWidth, windowHeight, WEBGL);
  smooth();
  noFill();
  ortho(-width / 2, width / 2, -height / 2, height / 2, 0);
}

function windowResized() {
  resizeCanvas(windowWidth, windowHeight, WEBGL);
}

function draw() {
  background(255);
  let x = map(mouseX, 0, width, -PI / 2, PI / 2);
  let y = map(mouseY, 0, height, -PI / 2, PI / 2);
  let r = width/6;

  translate(-width/4, -height/4);
  stroke(0,0,255);
  tumbleSphere(x, y, r);

  translate(width/4, height/4);
  stroke(255, 0, 0);
  axisSphere(x, y, r);
  stroke(0,0,255);
  tumbleSphere(x, y, r);

  translate(width/4, -height/4);
  stroke(255, 0, 0);
  axisSphere(x, y, r);
}

function axisSphere(rx, ry, radius) {
  ghostSphere(radius);
  ellipse(0, 0, 2*radius, 2*radius);
  push();
  rotateY(rx);
  rotateX(-ry);
  push();
  translate(0, 0, radius);
  sphere(5);
  pop();
  ellipse(0, 0, radius * 2, radius * 2, 50);
  rotateX(HALF_PI);
  ellipse(0, 0, radius * 2, radius * 2, 50);
  rotateY(HALF_PI);
  ellipse(0, 0, radius * 2, radius * 2, 50);
  pop();
}

function tumbleSphere(rx, ry, radius) {
  ghostSphere(radius);
  ellipse(0, 0, 2*radius, 2*radius);
  push();
  line(-radius, 0, 0, radius, 0, 0);
  translate(sin(rx) * radius, 0, 0);
  rotateY(HALF_PI);
  ellipse(0, 0, radius*2 * cos(rx), radius*2 * cos(rx), 50);
  rotateZ(-ry);
  translate(-radius * cos(rx), 0, 0);
  sphere(5);
  pop();
}

function ghostSphere(radius){
  push();
  stroke(0,16);
  sphere(radius);
  pop();
}
6 Likes

Hi all,

These are great solutions… but am I missing something here? Seems like there’s ambiguity between (1.) making a ‘look at’ function and (2.) rotating the sphere from a given orientation to a new orientation based on the mouse (assuming a fixed camera and and a centered sphere)? From @raetrakt 's initial description, I thought I’d want the ability to spin the sphere all the way around? There also seems to be some muddying of the waters as to whether we have the option of rotating the camera rather than the sphere (in which case, try peasycam)… Anyway, here’s my go at it in Java using quaternions.

First, a quaternion class:

// Should be normalized at all times when used to represent a rotation
// in 3D space.
class Quaternion {
  float x = 0.0;
  float y = 0.0;
  float z = 0.0;
  float w = 1.0;

  Quaternion() {
  }

  Quaternion(Quaternion q) {
    set(q);
  }

  Quaternion(float x, float y, float z, float w) {
    set(x, y, z, w);
  }

  Quaternion div(float scalar) {
    return mult(1.0 / scalar);
  }

  Quaternion mult(float scalar) {
    x *= scalar; 
    y *= scalar; 
    z *= scalar; 
    w *= scalar;
    return this;
  }

  Quaternion normalize() {
    float m = magSq();
    if (m != 0.0 && m != 1.0) {
      div(sqrt(m));
    }
    return this;
  }

  Quaternion mult(Quaternion b) {
    
    // Caveat: quaternion multiplication is not commutative.
    
    float tx = x;
    float ty = y;
    float tz = z;
    x = x * b.w + w * b.x + y * b.z - z * b.y;
    y = y * b.w + w * b.y + z * b.x - tx * b.z;
    z = z * b.w + w * b.z + tx * b.y - ty * b.x;
    w = w * b.w - tx * b.x - ty * b.y - tz * b.z;
    return this;
  }

  Quaternion set(float angle, PVector axis) {
    
    // Assumes that the axis is of unit length, i.e.,
    // has a magnitude of 1.
    
    float halfangle = 0.5 * angle;
    float sinhalf = sin(halfangle);
    x = axis.x * sinhalf; 
    y = axis.y * sinhalf;
    z = axis.z * sinhalf; 
    w = cos(halfangle);
    return this;
  }

  Quaternion set(Quaternion q) {
    return set(q.x, q.y, q.z, q.w);
  }

  Quaternion set(float x, float y, float z, float w) {
    this.x = x; 
    this.y = y; 
    this.z = z; 
    this.w = w;
    return this;
  }

  float magSq() {
    
    // The quaternion's dot product with itself, i.e., dot(q, q).
    
    return x * x + y * y + z * z + w * w;
  }

  PMatrix3D toMatrix(PMatrix3D out) {
    float x2 = x + x; 
    float y2 = y + y; 
    float z2 = z + z;
    float xsq2 = x * x2; 
    float ysq2 = y * y2; 
    float zsq2 = z * z2;
    float xy2 = x * y2; 
    float xz2 = x * z2; 
    float yz2 = y * z2;
    float wx2 = w * x2; 
    float wy2 = w * y2; 
    float wz2 = w * z2;
    out.set(
      1.0 - ysq2 - zsq2, xy2 - wz2, xz2 + wy2, 0.0, 
      xy2 + wz2, 1.0 - xsq2 - zsq2, yz2 - wx2, 0.0, 
      xz2 - wy2, yz2 + wx2, 1.0 - xsq2 - ysq2, 0.0, 
      0.0, 0.0, 0.0, 1.0);
    return out;
  }

  Quaternion ease(Quaternion b, float t) {
    
    // This is a simplistic form of easing. For situations
    // where you need torque minimization, do not use this.
    // Use spherical linear interpolation instead.
    
    if (t <= 0.0) {
      return normalize();
    }

    if (t>=1.0) {
      return set(b).normalize();
    }

    float u = t * t * (3.0 - 2.0 * t);
    float v = 1.0 - u;
    x = v * x + u * b.x; 
    y = v * y + u * b.y; 
    z = v * z + u * b.z; 
    w = v * w + u * b.w;
    return normalize();
  }
}

Then the main class:

float rotSpeed = 1.25;
float easeSpeed = 0.125;

PVector ref = new PVector(0.0, 0.0, 1.0);
PVector axis = new PVector();
PVector mouse = new PVector();

PMatrix3D sphereMat = new PMatrix3D();

Quaternion rot = new Quaternion();
Quaternion rotMouse = new Quaternion();
Quaternion rotWorld = new Quaternion();
Quaternion identity = new Quaternion(0.0, 0.0, 0.0, 1.0);

PShape sphere;

void setup() {
  size(512, 512, P3D);
  smooth(8);
  
  // Set the camera to the center of the screen.
  ortho();
  camera(
    0.0, 0.0, -height * sqrt(3.0) * 0.5, 
    0.0, 0.0, 0.0, 
    0.0, 1.0, 0.0);

  // Create a sphere PShape of unit size.
  sphereDetail(32, 16);
  sphere = createShape(SPHERE, 0.5);
  //sphere = createShape(BOX, 0.5);
  
  // Just in case the sphere has unnecessary children...
  // Might not be necessary.
  sphere = sphere.getTessellation();

  // Let Processing style tags dictate how the shape looks in draw.
  sphere.disableStyle();
}

void draw() {

  float sphereScale = min(width, height) * 0.75;
  float strokeWeight = 0.0025 / sphereScale;

  if (mousePressed) {

    // If the mouse is pressed, set a mouse vector
    // to coordinates in the range [-1, 1].
    // Invert the y-axis.
    float  wInv = 1.0 / width;
    float hInv = 1.0 / height;
    mouse.set(
      -1.0 + 2.0 * mouseX * wInv, 
      1.0 - 2.0 * mouseY * hInv, 0.0);

    // Capture the mouse's magnitude so that 
    // more dramatic mouse movements will increase
    // the speed of rotation.
    float mag = mouse.mag();

    if (mag > 0.0) {

      // If the mouse is not in the center, then create
      // an angle of rotation.
      float ang = rotSpeed * mag;

      // Cross a reference vector against the mouse vector.
      PVector.cross(ref, mouse, axis);

      // Normalize the axis.
      axis.normalize();
      rotMouse.set(ang, axis);
    }
  } else {

    // When the mouse is not pressed, decelerate the
    // rotation to the identity rotation.
    rotMouse.ease(identity, easeSpeed);
  }

  // The rotation created by the mouse axis is multiplied with
  // the sphere's current rotation.
  rotWorld.set(rotMouse).mult(rot);

  // The rotation is eased toward the target rotation.
  rot.ease(rotWorld, easeSpeed);

  // Since Processing doesn't support quaternions, we need to convert.
  rot.toMatrix(sphereMat);
  
  // The resultant matrix is only a rotation matrix, so we scale
  // up to the size we want.
  sphereMat.scale(sphereScale);
  
  // Apply the matrix to the shape.
  sphere.resetMatrix();
  sphere.applyMatrix(sphereMat);

  // Draw.
  directionalLight(
    255.0, 255.0, 255.0, 
    0.0, 0.6, 0.8);
  background(255.0);
  strokeWeight(strokeWeight);
  stroke(#202020);
  fill(#fff7d5);
  shape(sphere);

  // Update the information for reference.
  surface.setTitle(String.format("%.1f, %.2f, %.2f", 
    frameRate, mouse.x, mouse.y));
}

Cheers,
Jeremy

5 Likes

@raetrakt Nice challenge. I had encountered this problem before but had not put words to it. I’m surprised there’s not an easier way to “apply a rotation to an already rotated 3d object”.

@behreajj 's solution is the only one that solves it, the way I see it (thanks!). It would be nice to have a simplified version (without the easing and things like that) for easier analysis. I might give it a go in plain JavaScript if I have some spare time.

Edit: Another bit I find difficult to parse is the PMatrix3D. Would it be possible to simplify the final rotation values to a 3 axis rotation that can be applied to rotate(XYZ) or in an environment where PMatrix3D does not exist (plain JavaScript)?

Hi @JuanIrache

Glad you found the example helpful! You can get around the differences between 3D in Java-based Processing and p5.js by converting the Quaternion to an axis angle then providing the results to rotate. A p5 web editor example is here.

To axis angle:

class Quaternion {
  // ...
  toAxisAngle() {
    let wAcos = 0.0;
    
    // Values supplied to acos should be in [-1, 1].
    if (this._w <= -1.0) {
      wAcos = TAU;
    } else if (this._w >= 1.0) {
      wAcos = 0.0;
    } else {
      wAcos = 2.0 * acos(this._w);
    }

    const wAsin = TAU - wAcos;
    if (wAsin === 0.0) {
      return {
        angle: wAcos,
        axis: createVector(0.0, 0.0, 1.0)
      };
    }

    const sInv = 1.0 / wAsin;
    const x = this._x * sInv;
    const y = this._y * sInv;
    const z = this._z * sInv;

    // Find magnitude-squared.
    const mSq = x * x + y * y + z * z;
    if (mSq === 0.0) {
      return {
        angle: wAcos,
        axis: createVector(0.0, 0.0, 1.0)
      };
    } else if (mSq === 1.0) {
      return {
        angle: wAcos,
        axis: createVector(x, y, z)
      };
    }

    // Normalize.
    const mInv = 1.0 / sqrt(mSq);
    return {
      angle: wAcos,
      axis: createVector(
        x * mInv,
        y * mInv,
        z * mInv)
    };
  }
}

You could also retrieve the axis and angle from a quaternion separately, but the formulas I’ve tried for independent getAxis and getAngle functions have not provided accurate re-conversion to a quaternion.

From axis angle:

class Quaternion {
  // ...
  setFromAxisAngle(angle = 0.0,
    axis = createVector(0.0, 0.0, 1.0)) {

    const halfAngle = angle * 0.5;
    const sinHalf = sin(halfAngle);
    
    this._x = axis.x * sinHalf;
    this._y = axis.y * sinHalf;
    this._z = axis.z * sinHalf;
    
    this._w = cos(halfAngle);
    
    return this;
  }
}

The ability to ease between quaternions is one of its noteworthy features. The easing function above, smooth step, is a variant of lerp. Since quaternions which serve to represent rotations are usually of unit magnitude, and easing may change a quaternion’s magnitude, the quaternion is normalized after easing. This itself is a simplification: a substitute for spherical linear interpolation, slerp. Any time you want to remove an easing function, you can set the current rotation to the target rotation.

To be honest, 3D rotations are better handled by other JS libraries, such as Three.js. Lastly, to add to the list of resources that cover the maths and concepts better than I can, 3Blue1Brown has a great video on quaternions.

Best,
Jeremy

3 Likes

Thanks @behreajj, that’s brilliant!

I am doing something similar inside After Effects, so plain JavaScript is the only resource I have. I had watched 3Blue1Brown’s videos before, but I could still not fully get my head around quaternions.

@JuanIrache

Whoops, sorry, I had missed what you said before - about plain Javascript. To return to your earlier question

Would it be possible to simplify the final rotation values to a 3 axis rotation that can be applied to rotate(XYZ) or in an environment where PMatrix3D does not exist (plain JavaScript)?

You can convert Euler angles to quaternions and back again, provided that you inform the conversion function of the desired order (XYZ, ZYX, etc.). (Even if you can’t use Three.js, you can reference its source code for how to define these conversions here and here.)

You can also define rotateX, rotateY and rotateZ for a quaternion. By calling such functions in a sequence you will in effect use a quaternion like Euler angles.

But AFAIK any use of Euler angles – or usage of a matrix or quaternion like Euler angles – would result in gimbal lock.

My advice is to search the After Effects scripting documentation to see if it offers you any other conversions. In my experience, the equivalents to a PMatrix3D are typically named some variant of AffineTransform, Transform3D, Mat4, Matrix4x4, etc., etc. They frequently wrap a 2D (4 rows x 4 columns) or 1D array (16 elements long) and/or can be converted to and from such an array.

  const m4_2d = [
    [1.0, 0.0, 0.0, 0.0],
    [0.0, 1.0, 0.0, 0.0],
    [0.0, 0.0, 1.0, 0.0],
    [0.0, 0.0, 0.0, 1.0]
  ];

  const m4_1d = [
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    0.0, 0.0, 0.0, 1.0
  ];

The caveat to mind would be row major vs. col major representation.

Cheers,
Jeremy

3 Likes