Quaternions vs Euler

Quaternions or Euler Angles for Rotation

Euler angles describe an orientation as three turns about x, y and z in a fixed order. They are easy to type, but when the middle turn reaches 90 degrees the other two axes line up and a degree of freedom disappears (gimbal lock), and interpolating the angles does not give the shortest turn. A quaternion stores an orientation as a unit axis scaled by sin(angle / 2), plus cos(angle / 2): it has no gimbal lock and interpolates along the shortest arc with slerp. gl-matrix 5,695 's quat converts both ways (fromEuler, in degrees with an intrinsic axis order, default 'zyx'; setAxisAngle; mat4.fromQuat; mat4.getRotation):

Gimbal lock with Euler angles, and interpolating orientationsJavaScript
const { quat, vec3 } = glMatrix;
const r = (v) => [...v].map((c) => +c.toFixed(3) + 0).join(', ');
const euler = (x, y, z) => quat.fromEuler(quat.create(), x, y, z, 'xyz');   // in degrees
console.log('y = 90:', [[30, 90, 0], [0, 90, 30], [15, 90, 15]].map((a) => r(euler(...a)))
  .join(' | '));
const start = euler(0, 0, 0), end = euler(180, 0, 180);  // equals 180 degrees about y
const cover = [0, 0, 1];                                   // a cover's outward normal
const half = quat.slerp(quat.create(), start, end, 0.5);
const byEuler = vec3.transformQuat(vec3.create(), cover, euler(90, 0, 90));
const bySlerp = vec3.transformQuat(vec3.create(), cover, half);
console.log('end', r(end), '| halfway by Euler', r(byEuler), '| by slerp', r(bySlerp));
Output
y = 90: 0.183, 0.683, 0.183, 0.683 | 0.183, 0.683, 0.183, 0.683 | 0.183, 0.683, 0.183, 0.683
end 0, -1, 0, 0 | halfway by Euler 0, -1, 0 | by slerp -1, 0, 0

With y at 90 degrees, three x and z pairs give one quaternion: only their sum matters. The end orientation (180, 0, 180) is a half turn about y, yet interpolating the angles halfway tips the cover's normal straight down, while slerp turns it a quarter turn about y. Store and animate quaternions; show Euler angles only in the UI, as Three.js 30,815 does (Three.js).

The same start and end orientation reached by interpolating Euler angles (left) and by quat.slerp (right)HTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font-family: monospace; }
  .names div { flex: 1; padding: 4px 2px; }
</style>
<script src="https://cdn.jsdelivr.net/npm/gl-matrix@3.4.4/gl-matrix-min.js"></script>
<canvas id="view" width="600" height="250"></canvas>
<div class="names"><div id="left">Euler (180t, 0, 180t)</div><div id="right">slerp(start, end, t)</div></div>
<script>
const { mat4, quat, vec3 } = glMatrix;
const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
               [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
const verts = [];
for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
  verts.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
verts.push(0, 0, 0.5, 0, 0, 0,  0, 0, 5, 0, 0, 0);       // a line along the cover's normal (+z; z is scaled by 0.3)

const gl = document.getElementById('view').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out vec3 vNormal; out vec3 vLocal;
void main() { vNormal = mat3(uModel) * aNormal; vLocal = aNormal; gl_Position = uViewProjection * uModel * vec4(aPosition, 1.0); }`],
  [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec3 vNormal; in vec3 vLocal; uniform vec3 uColor; out vec4 c;
void main() {
  if (vNormal == vec3(0.0)) { c = vec4(0.1, 0.1, 0.1, 1.0); return; }   // the normal line
  vec3 n = normalize(vNormal);
  vec3 color = vLocal.z > 0.9 ? vec3(0.88, 0.6, 0.06) : uColor;       // gold front cover (object space)
  c = vec4(color * (0.45 + 0.55 * max(dot(n, normalize(vec3(0.3, 0.8, 0.6))), 0.0)), 1.0);
}`]]) {
  const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
  gl.attachShader(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(verts), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST);
const u = (n) => gl.getUniformLocation(program, n);
const vp = mat4.perspective(mat4.create(), 0.8, 300 / 250, 0.1, 20);
mat4.multiply(vp, vp, mat4.lookAt(mat4.create(), [2.2, 1.6, 3.4], [0, 0, 0], [0, 1, 0]));
gl.uniformMatrix4fv(u('uViewProjection'), false, vp);

const euler = (x, y, z) => quat.fromEuler(quat.create(), x, y, z, 'xyz');   // degrees
const start = euler(0, 0, 0), end = euler(180, 0, 180);  // equal to a half turn about y
const q = quat.create(), model = mat4.create(), n = vec3.create();
const fmt = (v) => [...v].map(c => (+c.toFixed(2) + 0).toFixed(2)).join(', ');

function frame(ms) {
  const t = 0.5 - 0.5 * Math.cos(ms * 0.0009);            // 0 -> 1 -> 0
  gl.clearColor(0.97, 0.96, 0.93, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  [[euler(180 * t, 0, 180 * t), 'left', 'Euler (180t, 0, 180t)'],
   [quat.slerp(q, start, end, t), 'right', 'slerp(start, end, t)']].forEach(([rotation, id, label], i) => {
    gl.viewport(i * 300, 0, 300, 250);
    mat4.fromQuat(model, rotation);
    mat4.scale(model, model, [1.1, 1.6, 0.3]);
    gl.uniformMatrix4fv(u('uModel'), false, model);
    gl.uniform3fv(u('uColor'), [0.12, 0.37, 0.55]);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
    gl.drawArrays(gl.LINES, 36, 2);
    vec3.transformQuat(n, [0, 0, 1], rotation);            // where the cover faces now
    document.getElementById(id).textContent = `${label}: normal (${fmt(n)})`;
  });
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>