3D rotation quirk

I am currently writing a small library for 3D wireframes, because I have an idea for a 3D wireframe game, and I couldn’t find any libraries for 3D wireframes in p5.js. Right now everything, except rotation, works perfectly fine. Adding translation and scaling would be trivial.

So here’s my problem: When I am rotating a cube (though I suppose that the problem is general for any 3D shape), the library appears to:

  1. First project the 3D shape onto the screen as a 2D projection.
  2. Then, rotate that 2D projection as a 3D shape.

However, this is what I want the library to do:

  1. Rotate the 3D shape as a 3D shape.
  2. Project that 3D shape onto a 2D screen.

Just in case you wanted to see how the library’s rotation looks: Upload files for free - https___preview.p5js.org_subtra3t_present_Zyv1jp5tg - Google Chrome 2021-09-29 10-31-06.mp4 - ufile.io


Note: Most code on this thread (especially the rotateThreeDPoint function) is unoptimized and could probably be written to be much faster… However, surprisingly, most code (including rotateThreeDPoint!) is fast, and generally appears to be not laggy. So I please ask you to not suggest me optimizations, since this is a prototype (pun unintended), and I’ll focus on optimizations later, or when the library appears to be genuinely slow.

Another note: This library uses something called Perspective 3D to project 3D shapes onto a 2D screen. This means that farther objects (meaning with a higher z value are rendered smaller (meaning closer to the origin). There are probably better ways to project 3D shapes onto 2D screens, but this is the only way that seemed intuitive to me.

Yet another note: This library interprets coordinates to be relative to the origin, the center of the screen.

You’re probably tired of these notes: The library uses complex multiplication to rotate 2D points, and rotates components of a 3D point to achieve 3D rotation.


My files:

index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/p5.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.4.0/addons/p5.sound.min.js"></script>
    <link rel="stylesheet" type="text/css" href="style.css">
    <meta charset="utf-8" />

  </head>
  <body>
    <script src="cartesian.js"></script>
    <script src="rotation.js"></script>
    <script src="threeD.js"></script>
    <script src="threeDShape.js"></script>
    <script src="threeDShapes.js"></script>
    <script src="sketch.js"></script>
  </body>
</html>

cartesian.js:

function fromCartesian(x, y, width, height) {
  return [
    x + (width  / 2),
    y + (height / 2)
  ];
}

rotation.js:

function multiplyComplexNumbers(c1, c2) {
  return {
    "real"      : (c1.real * c2.real) - (c1.imaginary * c2.imaginary),
    "imaginary" : (c1.real * c2.imaginary) + (c1.imaginary * c2.real)
  };
}

function rotateTwoDPoint(x, y, turn) {
  const _PI = Math.PI || 3.141592653589793;
  
  return [
    multiplyComplexNumbers(
      {
        "real"      : x,
        "imaginary" : y
      },
      {
        "real"      : Math.cos(turn * 2 * _PI),
        "imaginary" : Math.sin(turn * 2 * _PI)
      }
    ).real,
    multiplyComplexNumbers(
      {
        "real"      : x,
        "imaginary" : y
      },
      {
        "real"      : Math.cos(turn * 2 * _PI),
        "imaginary" : Math.sin(turn * 2 * _PI)
      }
    ).imaginary
  ];
}

function rotateThreeDPointX(x, y, z, xt) {
  return [
    x,
    rotateTwoDPoint(y, z, xt)[0],
    rotateTwoDPoint(y, z, xt)[1]
  ];
}

function rotateThreeDPointY(x, y, z, yt) {
  return [
    rotateTwoDPoint(x, z, yt)[0],
    y,
    rotateTwoDPoint(x, z, yt)[1]
  ];
}

function rotateThreeDPointZ(x, y, z, zt) {
  return [
    rotateTwoDPoint(x, z, zt)[0],
    rotateTwoDPoint(x, y, zt)[1],
    z
  ];
}

function rotateThreeDPoint(x, y, z, xt, yt, zt) {
  return rotateThreeDPointZ(rotateThreeDPointY(rotateThreeDPointX(x, y, z, xt)[0], rotateThreeDPointX(x, y, z, xt)[1], rotateThreeDPointX(x, y, z, xt)[2], yt)[0], rotateThreeDPointY(rotateThreeDPointX(x, y, z, xt)[0], rotateThreeDPointX(x, y, z, xt)[1], rotateThreeDPointX(x, y, z, xt)[2], yt)[1], rotateThreeDPointY(rotateThreeDPointX(x, y, z, xt)[0], rotateThreeDPointX(x, y, z, xt)[1], rotateThreeDPointX(x, y, z, xt)[2], yt)[2], zt);
}

function rotateThreeDShape(shape, xt, yt, zt) {
  return shape.map(points => {
    if (typeof points === "object") {
      return points.map(point_ => rotateThreeDPoint(point_[0], point_[1], point_[2], xt, yt, zt));
    }
    
    return rotateThreeDPoint(points[0], points[1], points[2], xt, yt, zt);
  })
}

threeD.js

function threeDPoint(x, y, z, fov) {
  return [
    x / z * fov,
    y / z * fov
  ];
}

function threeDLine(x1, y1, z1, x2, y2, z2, fov, width, height, pointFunction, lineFunction) {
  lineFunction(
    fromCartesian(
      pointFunction(x1, y1, z1, fov)[0],
      pointFunction(x1, y1, z1, fov)[1],
      width,
      height
    )[0],
    fromCartesian(
      pointFunction(x1, y1, z1, fov)[0],
      pointFunction(x1, y1, z1, fov)[1],
      width,
      height
    )[1],
    fromCartesian(
      pointFunction(x2, y2, z2, fov)[0],
      pointFunction(x2, y2, z2, fov)[1],
      width,
      height
    )[0],
    fromCartesian(
      pointFunction(x2, y2, z2, fov)[0],
      pointFunction(x2, y2, z2, fov)[1],
      width,
      height
    )[1]
  );
}

threeDShape.js:

function drawThreeDShape(data, point, line, fov, width, height, pointFunction, lineFunction) {
  for (let i = 0; i < data.length; i += 1) {
    if (typeof(data[i]) === "object") {
      let previousPoint = data[i][0];
      
      for (let j = 1; j < data[i].length; j += 1) {
        line(
          previousPoint[0], previousPoint[1], previousPoint[2],
          data[i][j][0]   , data[i][j][1]   , data[i][j][2],
          fov, width, height, point, lineFunction
        );
        
        previousPoint = data[i][j];
      }
      
      line(
        data[i][data[i].length - 1][0], data[i][data[i].length - 1][1], data[i][data[i].length - 1][2],
        data[i][0][0], data[i][0][1], data[i][0][2],
        fov, width, height, point, lineFunction
      )
    }
    else {
      pointFunction(
        fromCartesian(
          point(
            data[i][0],
            data[i][1],
            data[i][2]
          )
        )[0],
        fromCartesian(
          point(
            data[i][0],
            data[i][1],
            data[i][2]
          )
        )[1]
      );
    }
  }
}

threeDShapes.js:

function threeDCube(l) {
  return [
    [
      [
        0 - l,
        0 - l,
        1
      ],
      [
        0 - l,
        l,
        1
      ],
      [
        l,
        l,
        1
      ],
      [
        l,
        0 - l,
        1
      ]
    ],
    [
      [
        0 - l,
        0 - l,
        2
      ],
      [
        0 - l,
        l,
        2
      ],
      [
        l,
        l,
        2
      ],
      [
        l,
        0 - l,
        2
      ]
    ],
    [
      [
        0 - l,
        0 - l,
        1
      ],
      [
        0 - l,
        0 - l,
        2
      ]
    ],
    [
      [
        l,
        0 - l,
        1
      ],
      [
        l,
        0 - l,
        2
      ]
    ],
    [
      [
        0 - l,
        l,
        1
      ],
      [
        0 - l,
        l,
        2
      ]
    ],
    [
      [
        l,
        l,
        1
      ],
      [
        l,
        l,
        2
      ]
    ]
  ];
}

sketch.js

function setup() {
  createCanvas(512, 512);
  
  strokeWeight(2);
  stroke(255, 255, 255);
  
  background(0);
  
  frameRate(64);
}

function draw() {
  background(0);
  
  drawThreeDShape(
    rotateThreeDShape(threeDCube(width / 4), 1, 1, (frameCount % 1024) / 1024),
    threeDPoint, threeDLine, 1, width, height, point, line
  );
}

Excuse me but shouldn’t you use webgl

https://p5js.org/reference/#/p5/createCanvas

Why would I use WebGL? My code doesn’t need any advanced 3D functions. It requires just the line and point functions to work. Nothing else.

In my own project I arrange 64 smaller cubes into 1 big cube.
My approach was to use webgl, calsulate the position in 3D my smaller cubes need to be in (from my 3d array indizes to the actual pixel space), rotate around x-, y- and z-axis as determined by 3 sliders, translate to the correct pixel position and draw a box.

Because you could just use it to create a working version of whatever you want to achieve.
If you need your own library for some purpose I do not know you can use it as a reference to see if your code produces the same / similar output.
If you just need to get things on the screen that are defined in a 3D space you could use webgl directly.
I certainly don´t utilize 5% of what webgl could do - but I get my graphics to the screen quite easy.
Sure, you could calculate the correct matrizes / quaternions to make the math yourself. That is a nice project in itself.
webgl is something that does that. It projects 3D-objects to a 2D canvas.
Why do you want to write your own library?

This may be the source of your problem.
I would suggest that you try to use 3D points using matrix multiplication. Rotation matrix - Wikipedia would be a good place to start.
You could implement the relevant matrix functions directly or convert them to linear functions as you prefer. Reading your code I´d say that you are halfway there anyways.
But it seems to me that you may have inadvertedly projected your 3D points to a 2D plane.

I had a hard time reading your code so I´m not quite certain if at the moment you are rotating your squished cube or if you are stretching it.

By using the usual approach of rotation matrizes you can

  1. Rotate all the points of your 3D objects in 3D space
  2. project those 3D points to the 2D pixelspace plane of the canvas
  3. draw 2D lines between those points

But I still don´t see the advantage this approach of your own library has over using webgl

1 Like

The reason I’m rolling my library is because I want to know how it all works, behind the scenes. Using some existing library or WebGL wouldn’t teach me about what’s really going on.

It doesn’t need to have any “advantage” over WebGL. I created my own library so I could learn about 3D.

1 Like

A perfectly understandable and acceptable advantage in my opinion.

So I`m glad I posted the things above the one sentence you quoted. That advice stands.
Do all transformations (rotations and movements) in logical 3D space before you do the projections to 2D space.
Use rotation matrizes for rotation as it is the correct math and there is a reason why it´s usually done that way.

For the projection I´d do the following:
Define a point to represent the position of a camera / an observer
Define a plane (aka a 2D space) some distance away from the observer point.
Calculate vectors (or lines) from the observer point to every point in your 3D objects
Calculate intersection points between those vectors/lines and the plane
Those intersection points are the projections of the object points.
Draw lines between the projection points.

Using a 3D rotation matrix may be more straightforward, but I’m not really sure how to go about the entries of the matrix.

OK. That´s a fair question…
let me refer you to Rotation matrix - Wikipedia and specifically the section about “General rotations in three dimensions”
I tried posting the matrix R here but I´m not proficient enough in this editor.
I´ll post the individual entries instead. I´ll number them according to programming convention rather than mathematical convention. So the uppermost leftmost entry is r0,0 (please imagine the zeroes as indizes), the uppermost rightmost entry is r2,0

Entries of matrix R
r0,0 = cos(alpha) * cos(beta)
r1,0 = cos(alpha)*sin(beta)*sin(gamma) - sin(alpha)*cos(gamma)
r2,0 = cos(alpha)*sin(beta)*cos(gamma) + sin(alpha)*sin(gamma)
r0,1 = sin(alpha)*cos(beta)
r1,1 = sin(alpha)*sin(beta)*sin(gamma) + cos(alpha)*cos(gamma)
r2,1 = sin(alpha)*sin(beta)*cos(gamma) - cos(alpha)*sin(gamma)
r0,2 = -sin(beta)
r1,2 = cos(beta)*sin(gamma)
r2,2 = cos(beta)*cos(gamma)

based on those entries you can write a function to multiply the matrix R with a point-vector p (mathematically it should be a vector. But you can program it with the individual elements px, py, pz).
Let me call the resulting point rp (rotated point) with elements rpx, rpy, rpz

matrix-vector-product by components
rpx = r0,0 * px + r1,0 * px + r2,0 * px
rpy = r0,1 * py + r1,1 * py + r2,1 * py
rpz = r0,2 * pz + r1,2 * pz + r2,2 * pz

By the way: should you prefer to use Euler angles instead of yaw,pitch,roll then the entries differ slightly. But Wikipedia only showed the 3 individual matrizes for the 3 axis and I thought I´d let you have the fun of multiplying them (or googling an Euler angle based 3D rotation matrix).

And for what should you grind throug this garbled mathematical mess?
Well, alpha, beta and gamma are the rotational angles - in this form of the matrix represented in yaw, pitch and roll of the point-vectors from the origin (0,0,0) to point p (px,py,pz).
I´d advise you to translate(width/2, height/2) to have the origin in the center of the canvas. That would make it easier and is consistent with the way you defined your threeDCube.
You could then write a function applyRotationMatrix( px, py, pz, alpha, beta, gamma) that rotates any given point.
one possible way to write it could be

function applyRotationMatrix (px, py, pz, a, b, g){
let rpx = r00(a, b, g) * px + r10(a, b, g) * px + r20(a, b, g) * px
let rpy = r01(a, b, g) * py + r11(a, b, g) * py + r21(a, b, g) * py
let rpz = r02(a, b, g) * pz + r12(a, b, g) * pz + r22(a, b, g) * pz
return createVector(rpx, pry, rpz)
}

with helper functions r<nn>() for the individual matrix elements.

For integration into your code you would first rotate and translate all points of your 3D-shapes to a new position and orientation in 3D-space, second project those points to the 2D canvas and third draw the appropriate lines between the 2D-points.

I wouldn´t bother with 3D lines for the simple fact that any line is fully defined by the two endpoints and the second fact (not so simple) that any given projection that is reasonable for general purposes will project a straight 3D-line to a straight 2D-line. In combination: a projected 3D-line will be the same line as the 2D-line between the projected endpoints of the 3D-line

PS: the above written implies, that rotate, translate and (if wanted) shear and scale are different functions that are independent of each other. If needed they would be called one after the other, but all of them before you project to 2D.
If you would like to integrate them you should definitely have a look at Quaternions - but those are way into the deep end for me.

Just try this one here and play around with it.
And remember that these operations are not commutative - order of operation matters

And while implementing beware of reserved words like rotate, translate and scale. “shear” should be fine though