Just for fun, I had been attempting to build a model cube in vanilla JS that can be moved and/or rotated. What I was going for was the simplest possible representation (so, no adjusting for perspective or anything: it just uses the literal x, y and z positions for each vertex).
The result I got is in fact 3-dimensional: rotating about the axes in the plane of the screen reveals the further-back vertices which were originally hidden. But the way it rotates isn't at all what I intended, since instead of spinning in place, it stretches, distorts, and even scales!
I asked a friend what was going on with this, and he said that what was actually happening was that the camera perspective was changing. (As opposed to the cube truly changing shape.) I confirmed that this is correct by superimposing my cube model on a representation of the 3 axes (x, y, z) and using the same rotation algorithm on the axes themselves - sure enough, their movement paralleled that of the cube.
This is the bizarre part, though: I did not try (and don't even know how) to program any kind of 'camera object'! What this cube simulation is supposed to do is 'physically' (or as close as you can get for a virtual object, anyway) move/spin the cube within a fixed coordinate space. Looking back over the code, I don't see any way that anything other than this could be happening, but it is.
I had also received feedback that it isn't possible to accurately represent a 3D object in 3D space, and that to work correctly, it would have to be modeled in 4 dimensions. While I know that this is true in general,* that really doesn't seem like it should apply here.** Especially, since it isn't an actual 3D cube, but instead the projection of a 3D cube onto a 2D plane (the screen), since only the x and y coordinates are used in actually displaying it (z is only there to calculate changes in x and y).
So, what exactly is going on to create the 'moving camera' illusion, and is there a simple fix? I want the cube to rotate in place, and the coordinate system to remain fixed. I would be very interested to learn where, specifically, my version diverges from the expected outcome, and whether or not the version I intended is actually possible to program using only 3 dimensions. I have included the complete code as a Stack Snippet, so you can see the strange results for yourself! (Translation works as intended, only rotation is problematic.)
Comments are included next to the relevant parts of the code to describe the method being used in modeling the rotations (basically, conversion back and forth from Cartesian to polar coordinates).
*The general rule is that you need n + 1 dimensions to observe all possible rotations of an n-dimensional object - e.g. for a line (1D), you have to have a 2D plane to rotate it in; for a square (2D), you have to have a 3D space, etc.
**In every case, there is always an exception to the n + 1 rule. It is possible to rotate an n-dimensional object in only n dimensions within the dimension containing the object. You can rotate a line segment within the line it is a part of; you can rotate a square within the theoretical infinite plane which contains it; it's only when the object leaves the dimension that originally contained it (as in the square being rotated perpendicularly to the original plane) that you need the extra dimension. Since we can't actually leave the 3rd dimension, it seems like any rotation being performed on a real 3D object should fit into this 'special case' that does not require an extra axis to represent.
// References: https://www.w3schools.com/graphics/canvas_drawing.asp and https://www.w3schools.com/jsref/api_canvas.asp
// CANVAS SETUP
var canvas = document.getElementById("_canvas");
var c = canvas.getContext("2d");
c.fillStyle = "rgb(0, 20, 0)";
c.fillRect(0, 0, 256, 256);
// GLOBAL VARIABLES
var CENTER = {x: 128, y: 128, z: 128};
var SIDE_LENGTH = 32;
var translationIncrement = 1;
var rotationIncrement = 1;
var mouseIsDown = false; // Potential Bug - You can cause infinite recursion inside the cube's translate and rotate methods by pressing down a button, then sliding the mouse off of the button before releasing the mouse. Why would anyone do this, though? :D (Can be stopped by clicking any of the translation buttons again)
var buttons = {
translation: [
{button: document.getElementById("translateX+"), action: function() { mouseIsDown = true; return theCube.translate('x', translationIncrement); }},
{button: document.getElementById("translateY+"), action: function() { mouseIsDown = true; return theCube.translate('y', translationIncrement); }},
{button: document.getElementById("translateX-"), action: function() { mouseIsDown = true; return theCube.translate('x', -translationIncrement); }},
{button: document.getElementById("translateY-"), action: function() { mouseIsDown = true; return theCube.translate('y', -translationIncrement); }}
],
rotation: [
{button: document.getElementById("rotateX+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', rotationIncrement); }},
{button: document.getElementById("rotateY+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', rotationIncrement); }},
{button: document.getElementById("rotateZ+"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', rotationIncrement); }},
{button: document.getElementById("rotateX-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('x', -rotationIncrement); }},
{button: document.getElementById("rotateY-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('y', -rotationIncrement); }},
{button: document.getElementById("rotateZ-"), action: function() { mouseIsDown = true; return theCube.rotateAboutOwnCenter('z', -rotationIncrement); }}
]
};
for (buttonSet in buttons) {
for (var btn = 0; btn < buttons[buttonSet].length; btn++) {
buttons[buttonSet][btn].button.addEventListener('mousedown', buttons[buttonSet][btn].action);
buttons[buttonSet][btn].button.addEventListener('mouseup', function() { mouseIsDown = false; });
}
}
// UTILITY FUNCTIONS
var toRadians = function(numberInDegrees) {
return numberInDegrees * Math.PI / 180;
};
var toDegrees = function(numberInRadians) {
return numberInRadians * 180 / Math.PI;
};
// Returns the angle (in degrees) representing a point's rotational position relative to the specified axis
// aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
var getAngleAboutAxisForPointRelToCenter = function(aboutAxis, center, coords) {
// Vertical and horizontal displacements of coords relative to center
var vrt, hrz;
if (aboutAxis.toLowerCase() === 'x') {
vrt = coords.y - center.y;
hrz = coords.z - center.z;
}
else if (aboutAxis.toLowerCase() === 'y') {
vrt = coords.z - center.z;
hrz = coords.x - center.x;
}
else if (aboutAxis.toLowerCase() === 'z') {
vrt = coords.y - center.y;
hrz = coords.x - center.x;
}
// Avoid division by 0 inside expression to calculate the angle if the point is the same as the center of rotation
if (!vrt && !hrz) {
return undefined;
}
// The intermediate value A will be a value between 0 and 180 degrees.
// Math.asin returns an angle which is 90 degrees offset from what you'd expect it to be, and also the negative of the expected value, so A is adjusted for this. (The final absolute value call is to avoid the 0 degree angle being output as -0.)
var A = Math.abs(-(toDegrees(Math.asin(hrz/(vrt**2 + hrz**2)**0.5)) - 90));
// The vertical axis is inverted on the HTML canvas element!
if (vrt > 0) { // QIII and QIV - angle is decreasing when it should be increasing. Fix this by subtracting value from 360.
return 360 - A;
} else {
return A; // QI and QII: No adjustments needed
}
};
// Returns the differences in a point's vertical and horizontal coordinates after it has been rotated about the specified axis by angle degrees, centered on the specified center coordinates.
// aboutAxis should be a string representing the name of the axis (capital or lowercase); center should be an object with x, y and z properties; coords should also be an object with x, y and z properties
// initialAngle parameter is optional; it will be calculated inside this function only if it hasn't already been provided as input, to reduce unnecessary computations.
var getCoordChangesForPointRotatedAboutAxisRelToCenter = function(aboutAxis, center, coords, angle, initialAngle) {
if (initialAngle === undefined) {
initialAngle = getAngleAboutAxisForPointRelToCenter(aboutAxis, center, coords);
}
// Vertical and horizontal displacements of coords relative to center
var vrt, hrz, vName, hName;
if (aboutAxis.toLowerCase() === 'x') {
vrt = coords.y - center.y;
hrz = coords.z - center.z;
vName = 'y', hName = 'z';
}
else if (aboutAxis.toLowerCase() === 'y') {
vrt = coords.z - center.z;
hrz = coords.x - center.x;
vName = 'z', hName = 'x';
}
else if (aboutAxis.toLowerCase() === 'z') {
vrt = coords.y - center.y;
hrz = coords.x - center.x;
vName = 'y', hName = 'x';
}
// Length of the line segment connecting the point to the center of rotation
var distToCenter = (vrt**2 + hrz**2)**0.5;
// The differences in horizontal and vertical positions
diffs = {};
// When rotating about x or z axis, the vertical coordinate will be y, which is inverted on the HTML canvas.
var invertMultiplier = ((-1)**(aboutAxis.toLowerCase() === 'z' || aboutAxis.toLowerCase() === 'x'));
diffs[hName] = distToCenter*(Math.cos(toRadians(angle + initialAngle)) - Math.cos(toRadians(initialAngle)));
diffs[vName] = distToCenter*(Math.sin(toRadians(angle + initialAngle)) - Math.sin(toRadians(initialAngle)))*invertMultiplier;
return diffs;
};
// CUBE MODEL PROTOTYPE
/* A hollow cube consists of |8 vertices| and |the set of all line segments, connecting those vertices, which are not diagonals| */
/** The argument for the 'center' parameter should be an object of the form {x: n1, y: n2, z: n3} where n1, n2 and n3 are Numbers; sideLength should be a Number */
var HollowCube = function(center, sideLength) {
// Store references to the cube's dimensions and center position
this.center = {x: center.x, y: center.y, z: center.z}; // Make a deep copy, not a shallow copy here
this.sideLength = sideLength;
// Format of each vertex is {x, y, z}
this.vertices = [{x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
{x: center.x - 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
{x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z + 0.5*sideLength},
{x: center.x + 0.5*sideLength, y: center.y - 0.5*sideLength, z: center.z - 0.5*sideLength},
{x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength},
{x: center.x - 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
{x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z + 0.5*sideLength},
{x: center.x + 0.5*sideLength, y: center.y + 0.5*sideLength, z: center.z - 0.5*sideLength}];
this.initialVertexAngles = [];
// Initial angular positions of each vertex (represented as angles of rotation about the 3 axes)
for (var i = 0; i < this.vertices.length; i++) {
this.initialVertexAngles[i] = {
x: getAngleAboutAxisForPointRelToCenter('x', this.center, this.vertices[i]),
y: getAngleAboutAxisForPointRelToCenter('y', this.center, this.vertices[i]),
z: getAngleAboutAxisForPointRelToCenter('z', this.center, this.vertices[i])
};
}
// Cumulative angles of rotation about each axis, representing the cube's current position
this.currentRotationsAboutAxes = {x: 0, y: 0, z: 0};
// Amounts (Cartesian coordinates) by which each vertex is currently displaced from its initial position
this.vertexDisplacementsFromInitial = [{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0},
{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}];
// Format of each edge is [vertex1, vertex2] where vertex1 and vertex2 are the indices of vertices in this.vertices - Note that each line is only specified once (i.e. all vertex pairs which would be duplicates have been omitted)
this.edgesConnectingVertices = [[0, 1], [0, 3], [0, 4], [1, 2], [1, 5], [2, 3], [2, 6], [3, 7], [4, 5], [4, 7], [5, 6], [6, 7]];
this.edgeColors = ["rgb(255, 0, 0)", "rgb(0, 0, 255)", "rgb(0, 255, 255)", "rgb(255, 255, 0)", "rgb(255, 0, 255)", "rgb(0, 255, 0)",
"rgb(0, 255, 255)", "rgb(255, 0, 255)", "rgb(0, 255, 0)", "rgb(255, 255, 0)", "rgb(0, 0, 255)", "rgb(255, 255, 0)"];
// Used in setting a delay between recursive calls of the translation and rotation functions, when one of the buttons is pressed down
this.onCooldown = false;
};
/** The argument for the 'alongAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase. The argument for the 'amountInPixels' parameter should be of type Number */
HollowCube.prototype.translate = function(alongAxis, amountInPixels, canvasContext = c) {
this.center[alongAxis.toLowerCase()] += amountInPixels;
for (var v = 0; v < this.vertices.length; v++) {
this.vertices[v][alongAxis.toLowerCase()] += amountInPixels;
}
this.display(canvasContext);
// Keep on moving the cube until the mouse is released; don't make the user have to click for every single increment
if (mouseIsDown && !this.onCooldown) {
this.onCooldown = true;
// Inside setTimeout, 'this' no longer refers to the HollowCube object, so it is necessary to set up a new reference to the original object to use inside setTimeout. It is important that the statement that resets the cooldown timer go INSIDE the inner wrapped function (not outside it!) in order for the timing to function as intended.
var temp = function(obj) { return function() { obj.onCooldown = false; obj.translate(alongAxis, amountInPixels, canvasContext); }};
var temp2 = this;
setTimeout(temp(temp2), 10); // BARF - this is an ugly kludge :(
}
};
/** The argument for the 'aboutAxis' parameter should be a string representing the letter name of an axis, either capital or lowercase. The argument for the 'amountInDegrees' parameter should be of type Number */
HollowCube.prototype.rotateAboutOwnCenter = function(aboutAxis, amountInDegrees, canvasContext = c) {
this.currentRotationsAboutAxes[aboutAxis.toLowerCase()] += amountInDegrees;
// Reset this before calculating the current displacements, as they aren't cumulative (displacements are recalculated every time the cube is rotated, for the new resultant angle
this.vertexDisplacementsFromInitial = [{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0},
{x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}, {x: 0, y: 0, z: 0}];
for (var i = 0; i < this.vertices.length; i++) {
for (var axs in this.currentRotationsAboutAxes) {
var rotn = getCoordChangesForPointRotatedAboutAxisRelToCenter(axs, this.center, this.vertices[i], this.currentRotationsAboutAxes[axs], this.initialVertexAngles[i][axs]);
for (var cmpnt in rotn) {
this.vertexDisplacementsFromInitial[i][cmpnt] += rotn[cmpnt];
}
}
}
this.display(canvasContext);
// Keep on rotating the cube until the mouse is released; don't make the user have to click for every single increment
if (mouseIsDown && !this.onCooldown) {
this.onCooldown = true;
var temp = function(obj) { return function() { obj.onCooldown = false; obj.rotateAboutOwnCenter(aboutAxis, amountInDegrees, canvasContext); }};
var temp2 = this;
setTimeout(temp(temp2), 10);
}
};
/** Since the screen is 2-dimensional, the depth information (z-axis) cannot be used directly and is only perceived via its effects in calculating the new x and y coordinates when the cube is rotated. */
HollowCube.prototype.display = function(canvasContext) {
// Redraw background to avoid the cube leaving trails across the canvas
c.fillStyle = "rgb(0, 20, 0)";
c.fillRect(0, 0, 256, 256);
c.lineWidth = 2;
for (var segment = 0; segment < this.edgesConnectingVertices.length; segment++) {
canvasContext.beginPath();
canvasContext.moveTo(this.vertices[this.edgesConnectingVertices[segment][0]].x + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][0]].x, this.vertices[this.edgesConnectingVertices[segment][0]].y + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][0]].y);
canvasContext.lineTo(this.vertices[this.edgesConnectingVertices[segment][1]].x + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][1]].x, this.vertices[this.edgesConnectingVertices[segment][1]].y + this.vertexDisplacementsFromInitial[this.edgesConnectingVertices[segment][1]].y);
canvasContext.strokeStyle = this.edgeColors[segment];
canvasContext.stroke();
}
};
// INSTANTIATION
var theCube = new HollowCube(CENTER, SIDE_LENGTH);
theCube.display(c);
body {
background-color: lightgreen;
}
div {
background-color: green;
width: 80%;
margin-left: 10%;
padding-top: 2%;
padding-bottom: 2%;
}
button {
background-color: lightgreen;
color: darkgreen;
font-weight: bold;
height: 25pt;
}
<body>
<div>
<p align="center">
<canvas id="_canvas" width="256" height="256"></canvas>
</p>
<p align="center">
<button type="button" id="translateX+">Move Along Positive X</button>
<button type="button" id="translateY+">Move Along Positive Y</button><br><br>
<button type="button" id="translateX-">Move Along Negative X</button>
<button type="button" id="translateY-">Move Along Negative Y</button><br><br><br>
<button type="button" id="rotateX+">Rotate About X (+)</button>
<button type="button" id="rotateY+">Rotate About Y (+)</button>
<button type="button" id="rotateZ+">Rotate About Z (+)</button><br><br>
<button type="button" id="rotateX-">Rotate About X (-)</button>
<button type="button" id="rotateY-">Rotate About Y (-)</button>
<button type="button" id="rotateZ-">Rotate About Z (-)</button>
</p>
<div>
</body>
P.S. This question is not a duplicate of the one here: 2D camera perspective projection from 3D coordinates -- HOW? because in that question, OP wanted to design a perspective projection, while I'm going for what I believe is called an 'orthographic projection' - one that does not use perspective. It is also not a duplicate of this one: Rotate object around fixed axis, because they're using 3D rendering software (not vanilla JS), and also quaternions (I'm specifically asking whether it's possible without modeling the cube in 4 dimensions).
EDIT (a reply to the accepted answer):
@Theraot, your version is everything I was going for in half the number of lines!
About your observation "I understand that you were trying to not move the vertex" - initially, I had tried a version that DID actually change the vertex coordinates for rotation as well as for translation. It also used the length of the internal diagonal from the cube's center to any one of the vertices in place of the
var distToCenter = (vrt**2 + hrz**2)**0.5;
equation. The problem was that somehow, a vertical asymptote was appearing at the 90 degree point: as the cube was rotated to this point, it started stretching infinitely instead of rotating past that point. Your version avoids that problem, so is actually more like the effect I originally wanted than was my 'patched' version with thevertexDisplacementsFromInitial
.Inside the (much streamlined)
rotateAboutOwnCenter
method, you're now assigning the vertices' positions to the rotation's components, instead of incrementing them. I see that you've added the extra term+ center[(h/v)Name]
inside the function that performs the rotations, to be equivalent to the end result of adding the original vertex positions to the displacements in my version. What I'm still confused about is how yours works (and it does work perfectly) without having any kind of 'accumulator' to store the partial vs. total changes in each coordinate (x, y and z). In the original, I was calculating delta(x) = xy + xz, etc.: each coordinate's total change was the sum of two partial components from rotations about two different axes. With assignment instead of incrementation inside the for loop, shouldn't the second partial component just overwrite the first? Again, I know yours is correct, but just don't see how.Now you have me really curious about how you would have fixed the button kludge! I'm also interested in seeing how the rotation matrix would replace my implementation which used individual, separate calculations for X, Y and Z axis rotations. I'm familiar with the concept of matrices from the one matrix algebra class I took, but it definitely isn't the way that naturally occurs to me to go about solving problems. A side-by-side comparison would be really neat, to see how the matrix version performs the calculations 'in parallel' instead of 'in series' (at least, I'd assume that it would). If I post a new question on the code review site, asking about how the cube model could be improved in those two aspects specifically, would you be interested in answering?