3D Rotations: Rotating around the screen axes

Yes, referring to your second example.
In it, after a 90 degree rotation around y, the red dot travels up the side of the sphere when you move the mouse upward.
What I want is, with the same setup, that the red dot stays in its place and the point of the sphere in the center of the sketch, moves upward. This is no longer achievable by rotateX(), because a rotation matrix has already been applied.
I should note that I actually switched to p5js because it allows me to rotate around a specified axis, instead of just x, y and z.

rotate(-y, [cos(x), 0, sin(x)]);

This is not possible in Java Processing afaik.

I can’t help you with that

Take a look here:

:slight_smile:

Example of rotate(float angle, float v0, float v1, float v2):

float angle;

void setup()
{
size(500, 500, P3D);
}

void draw()
{
background(255);
translate(width/2, height/2);

strokeWeight(3);
stroke(128);
line(-100, -100, 0, 100, 100, 0);

rotate(angle, 100, 100, 0);

pushMatrix();
shape();
popMatrix();

angle += TAU/1000;
}

void shape()
{
stroke(128);
strokeWeight(3);
line(0, 0, 0, 100  *cos(TAU/8), -100*  cos(TAU/8), 0);
strokeWeight(10);
point(100  *cos(TAU/8), -100*  cos(TAU/8), 0);
}

Example code I provided rotating around line\axes;

Rotating around line\axes with ellipses:

:slight_smile:

Thank you for your effort, but that only gets me to the point of where I quit in p5.
I tried my best to understand some of the maths in the medium.com article, but I still can’t figure out how to calculate the “screen axis” that is needed to turn the rotated sphere as intended.

I ported your code for Java Processing:

void setup() 
  {
  size(500, 500, P3D);
  smooth();
  noFill();
  // ortho();
  }

void draw() 
  {
  background(255);

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

  translate(width/2, height/2);

  rotateY(x);
  rotateX(-y);
  
  box(2*40, 2*10, 2*90);

  ellipse(0, 0, height/2, height/2);
  rotateX(HALF_PI);
  ellipse(0, 0, height/2, height/2);
  rotateY(HALF_PI);
  ellipse(0, 0, height/2, height/2);
  }

Is that what you are trying to achieve?

Change the ranges (smaller) for the mapping to get a better sense of rotations.
Using ortho() sometimes helps; default is perspective().

:slight_smile:

References:


actually i think there is view problem,
while for a full 3D view the mouse interpretation like

mouseX, 0, width, -PI ,PI 

is optimal, it looks bad.

a much more natural view i feel with

mouseX, 0, width, -PI/2, PI/2

anyhow the whole concept with this mouse requires the thinking
that NULL is when the mouse points at the CENTER of the canvas,
( a other approach would be to use delta values…)

to repair the processing y axis points down,
can use a (-1)*…

as we play here 3D view, what is that without a ZOOM
use mouseWheel.

add i try to separate the

  • view code
  • axis code
  • user circles code
float x=0,y=0,zmag = 1;
float Y = -1;  // to invert processing y axis

void setup() {
  size(500, 500, P3D);
  noSmooth();
  noFill();
  //ortho();
  strokeWeight(2);
  println("use: mouse X Y, wheel for zoom");
}

void draw() {
  background(200, 200, 0);
  my_Camera();
  my_Drawing();
}

void my_Drawing() {
  axis();
  spheres();
}

void axis() {
  stroke(200, 0, 0);
  line(0, 0, 0, height/4, 0, 0);
  sphereAt(height/4, 0, 0, 6);
  stroke(0, 200, 0);
  line(0, 0, 0, 0, Y*height/4, 0);
  sphereAt(0, Y*height/4, 0, 6);
  stroke(0, 0, 200);
  line(0, 0, 0, 0,0,height/4);
  sphereAt(0,0,height/4, 6);
}

void spheres() {
  push();
  stroke(0, 200, 0);
  ellipse(0, 0, height/2, height/2);
  rotateX(HALF_PI);
  stroke(200, 0, 0);
  ellipse(0, 0, height/2, height/2);
  rotateY(HALF_PI);
  stroke(0, 0, 200);
  ellipse(0, 0, height/2, height/2);
  pop();
}

void my_Camera() {                                    // limit from +- PI to +- PI/2 like more natural view??
  x = map(mouseX, 0, width, -PI/2, PI/2);
  y = Y*map(mouseY, 0, height, -PI/2, PI/2);   // invert processing Y
  translate(width/2, height/2);
  scale(zmag);
  rotateY(x);
  rotateX(y);  
  // here the view is axis correct if mouse is middle/center of canvas!!!
  // right hand rule: thumb X RIGHT (RED), point finger Y UP(GREEN), middle finger Z FRONT (BLUE)( points to you )
}

void sphereAt(float x, float y, float z, float diam ) {
  push();
  translate(x, y, z);
  sphere(diam);
  pop();
}

void mouseWheel(MouseEvent event) {
  float e = event.getCount(); //println(e);
  zmag += e*0.01;
}

p5.js version :slight_smile:

does that operation feel better?

I very much appreciate all these attempts to help me, but none of these examples behave the way I tried to describe.

The rotation commands to achieve this cannot be as simple as rotateY(x) and rotateX(y), because the second rotation is executed in an already rotated system, and therefore do not rotate around the axes “of my monitor” that never change.

In your example, compare the behaviour of

  rotateY(x);
  rotateX(y);

to

  rotateY(x);
  rotate(y, cos(x), 0, sin(x));

my problem is achieving the effect rotate(y, cos(x), 0, sin(x)) has, but for both axes.

3 Likes

Keep working at it!

rts_00

I only used rotateY() and rotateX() to achieve this:

  • order matters; try changing the order of rotations and see what happens!
  • change the ranges (smaller) for the mapping to get a better sense of rotations. I already suggested this!
  • I added grids, fills, etc. to better visualize my work and the rotations; there were some interesting optical illusions otherwise.
  • the row of sphere() shapes were added along x-axis
  • point() is easy to plot along x-axis
  • switch between ortho() and perspective(); this can sometimes help in the development

I am not posting my “solution” since this is achievable and can be done by you.

I achieved the same results with:

Or I do not clearly understand what you are trying to achieve…

Please post what you have done so far.

Is this an academic assignment?

:slight_smile:

2 Likes

What is to be seen in your gif is the behaviour of my original post, although with the axes flipped.
Thank you for adding grid and lines, they might help describe my problem:

This is exactly what I meant:
yes

This is what I am trying to get rid of:
no

As you can see, the sphere rotates around the yellow x-axis.
What it should rotate around is the not yet drawn stationary “x-axis” that lies on the grid.
Just as the y-rotation in your sketch always happens around a not drawn y-axis on the grid.

This is not an assignment and I am not trying to bait you into doing it for me :wink:
This is a random sketch idea I had (further problems here lol P5js: 3D Typography Problems/Bugs?) where something I thought would be trivial apparently turned out to be kind of tricky and I’m almost certain there’s some clever math involved in solving this.

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