0

I'm getting into 3D CSS, and I'm having trouble wrapping my head around this inconsistency.

After rotating around the Y-axis, making the "front" face the scene's right face, and the "left" face rotates into place becoming the scenes front face, rotating around either the X-axis or Z-axis rotates the cube around the Z-axis. Why does this happen?

To recreate the issue:

1.) Run the snippet

2.) Click Y-axis (+)

3.) Click Y-axis (+) again

4.) Click any of the X or Z axis buttons

Weird, right? or am I just missing something masterfully simple in the world of 3D transforms?

var rotation_degree = { 'X': 0, 'Y': 0, 'Z': 0 };

$(document).on ("click", "button", function (e)
{
    var degree = parseInt ($(this).attr ("data-degree"));
    var axis   = $(this).attr ("data-axis");
    
    // Animate on an unused property
    $(".cube").css ("text-indent", rotation_degree[axis]);
    
    $('.cube').animate (
    {
        textIndent: rotation_degree[axis] + degree
    },
    {
        step: function (now,fx) 
        {   
            rotation_degree[axis] = now;

            // Center cube in scene
            var transform = "translateZ(-125px)";

            // Add transform rotations in specific order: X, Y, Z
            for (var key in rotation_degree)
            {
                // Skip loop if the property is from prototype
                if (!rotation_degree.hasOwnProperty (key)) 
                    continue;

                transform += (" rotate" + key + "(" + rotation_degree[key] + "deg)");
            }

            // console.log (transform);
            // console.log ("--------------------------------------------------------------");

            $(this).css ('transform', transform);  
        },

        duration: 'slow'

    },
    
    'linear'
    
    );
    
});
html, body {
    width: 100%;
    height: 100%;
}

.scene {
    width: 250px;
    height: 250px;
    perspective: 250px;
    background-color: rgb(0,0,0);
}

.cube {
    width:100%;
    height:100%;
    position:relative;
    transform-style:preserve-3d;
    transform: translateZ(-125px);
    text-indent: 0;
  
}

.face {
    width: 100%;
    height: 100%;
    position: absolute;
    background-color: rgba(3, 121, 255, 0.5);
    color: #FFF;
    line-height: 250px;
    text-indent: 0;
    text-align: center;
}

.front  { transform: rotateY(0deg)    translateZ(125px); }
.right  { transform: rotateY(90deg)   translateZ(125px); }
.left   { transform: rotateY(-90deg)  translateZ(125px); }
.back   { transform: rotateY(180deg)  translateZ(125px); }
.top    { transform: rotateX(90deg)   translateZ(125px); }
.bottom { transform: rotateX(-90deg)  translateZ(125px); }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="scene">

    <div class="cube">
    
        <div class="face front">front</div>
        <div class="face right">right</div>
        <div class="face left">left</div>
        <div class="face back">back</div>
        <div class="face top">top</div>
        <div class="face bottom">bottom</div>
        
    </div>
    
</div>

<button data-degree="45" data-axis="X">
    X-Axis (+)
</button>

<button data-degree="-45" data-axis="X">
    X-Axis (-)
</button>

<button data-degree="45" data-axis="Y">
    Y-Axis (+)
</button>

<button data-degree="-45" data-axis="Y">
    Y-Axis (-)
</button>

<button data-degree="45" data-axis="Z">
    Z-Axis (+)
</button>

<button data-degree="-45" data-axis="Z">
    Z-Axis (-)
</button>
iRector
  • 1,935
  • 4
  • 22
  • 30

1 Answers1

2

This is a normal problem with 3D rotation using 3 axes called Gimbal_lock

Gimbal lock is the loss of one degree of freedom in a three-dimensional, three-gimbal mechanism that occurs when the axes of two of the three gimbals are driven into a parallel configuration, "locking" the system into rotation in a degenerate two-dimensional space.

Solutions are usually either using matrices or quaternions for rotation or staying with 3 axes but decomposing them and recompositing to avoid these issues.

const rotation_degree = { 'X': 0, 'Y': 0, 'Z': 0 };
const axisIdToAxis = { 'X': [1, 0, 0], 'Y': [0, 1, 0], 'Z': [0, 0, 1] };
const currentMatrix = new DOMMatrix;

$(document).on ("click", "button", function (e)
{
    const degree = parseInt ($(this).attr ("data-degree"));
    const axis   = $(this).attr ("data-axis");
    
    // Animate on an unused property
    $(".cube").css ("text-indent", rotation_degree[axis]);
    
    $('.cube').animate (
    {
        textIndent: rotation_degree[axis] + degree
    },
    {
        step: function (now,fx) 
        {   
            rotation_degree[axis] = now;

            // Center cube in scene
            const transform = `translateZ(-50px) rotate${axis}(${now}deg) ${currentMatrix}`;
            $(this).css ('transform', transform);  
        },
        complete: function() {
            // apply rotation to currentMatrix
            currentMatrix.preMultiplySelf(
               new DOMMatrix().rotateAxisAngleSelf(
                  ...axisIdToAxis[axis], degree));
            // zero this out since we applied it above
            rotation_degree[axis] = 0;
        },

        duration: 'slow',
        
        queue: true,

    },
    
    'linear',
    
    );
    
});
html, body {
    width: 100%;
    height: 100%;
}

.scene {
    width: 100px;
    height: 100px;
    perspective: 100px;
    background-color: rgb(0,0,0);
}

.cube {
    width:100%;
    height:100%;
    position:relative;
    transform-style:preserve-3d;
    transform: translateZ(-50px);
    text-indent: 0;
  
}

.face {
    width: 100%;
    height: 100%;
    position: absolute;
    background-color: rgba(3, 121, 255, 0.5);
    color: #FFF;
    line-height: 100px;
    text-indent: 0;
    text-align: center;
}

.front  { transform: rotateY(0deg)    translateZ(50px); }
.right  { transform: rotateY(90deg)   translateZ(50px); }
.left   { transform: rotateY(-90deg)  translateZ(50px); }
.back   { transform: rotateY(180deg)  translateZ(50px); }
.top    { transform: rotateX(90deg)   translateZ(50px); }
.bottom { transform: rotateX(-90deg)  translateZ(50px); }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div class="scene">

    <div class="cube">
    
        <div class="face front">front</div>
        <div class="face right">right</div>
        <div class="face left">left</div>
        <div class="face back">back</div>
        <div class="face top">top</div>
        <div class="face bottom">bottom</div>
        
    </div>
    
</div>

<button data-degree="45" data-axis="X">
    X-Axis (+)
</button>

<button data-degree="-45" data-axis="X">
    X-Axis (-)
</button>

<button data-degree="45" data-axis="Y">
    Y-Axis (+)
</button>

<button data-degree="-45" data-axis="Y">
    Y-Axis (-)
</button>

<button data-degree="45" data-axis="Z">
    Z-Axis (+)
</button>

<button data-degree="-45" data-axis="Z">
    Z-Axis (-)
</button>

Note the code above was just the simplest change I could think of. It doesn't handle if you click a button before the rotation has finished but I didn't want to refactor all the code to handle that.

PS: there's a bug in Chrome related to this. See: https://bugs.chromium.org/p/chromium/issues/detail?id=986110

gman
  • 100,619
  • 31
  • 269
  • 393
  • Hey gman, I have a question. Is the matrix reordering the axis hierarchy to prevent gimbal lock, or is the matrix making things quaternion? I'll learn more about all of this soon, I'm just real curious. – iRector Jul 23 '19 at 00:03
  • The code above rotates a matrix and when the rotation is finished it bakes that rotation into the matrix. To think of it another way MATRIX = ROTATION1. Later MATRIX = ROTATION2 * ROTATION1. Then MATRIX = ROTATION3 * ROTATION2 * ROTATION1. In fact if you keep putting the newest rotation in your CSS string at the start of the rotation css parts it would work but the CSS string would get longer and longer and longer and slower and slower and slower. So the code above combines all but the newest rotation while rotating and then combines them. – gman Jul 31 '19 at 15:44