How Did I Build The Rubik Cube


cover

Concept & Idea

The Rubik’s Cube is a 3-D combination puzzle that has been around since 1974. One of it’s interesting property is that there are so many combinations. Somebody has done the math and apparently it is indeed alot

8!×37×12!2×2114.3×10198! \times 3^7 \times \frac{12!}{2} \times 2^{11} \approx 4.3 \times 10^{19}

The Rubik’s Cube is not only interesting, it is also beautiful aesthetically. Let’s take a look at some art people have done with cubes

cube

Today I will use ThreeJS to build a Rubik’s Cube. Our Cube would be able to rotate like a real cube. We will not cover manual rotation here because a good controller would be very tricky to get right. We will also not cover Cube solving because I am not too smart, I just want to look at the Cube not solving them.

Breakdown

Build single cube

cube

We need to consider 2 things

  1. The cube geometry
  2. The cube’s color data

Thank to ThreeJS we can make a cube geometry easily with

import { BoxGeometry } from "three";

const piece = new BoxGeometry();

Then we need to give color to each face

import { Color, Float32BufferAttribute } from "three";

const vertices = piece.getAttribute("position").count;
const faces = vertices / 6;
const buffer = [];
const color = new Color();
for (let f = 0; f < faces; i++) {
  color.setHex(getColor(x, y, z, f));
  for (let j = 0; j < 6; j++) {
    buffer.push(color.r, color.g, color.b);
  }
}
piece.setAttribute("color", new Float32BufferAttribute(buffer, 3));

If you have experience with OpenGL or something similar before, this code will looks obvious to your eyes. But if you don’t, here is a brief explaination about what the code done:

  • Get the number of facesfaces by taking vertices÷6vertices \div 6. Because 3 verticesvertices make up a triangletriangle, 2 triangletriangle make up a faceface
  • For each face, calculate color based on that face, push the result to the buffer. Notice that we have to push 6 times for 6 vertices
  • Send the color buffer back to vertices data

Here is our completed cube

Add more cubes (build 3x3 cubes)

When you have done making a single cube, it’s trivial to build more cubes. Just throw a loop and check out your new creation! Some small details to look out for:

  • Remember to a little gap between cubes
  • Avoid wasting resource by reusing material
import { Mesh } from "three";

for (let x = 0; x < cubeNum; x++) {
  for (let y = 0; y < cubeNum; y++) {
    for (let z = 0; z < cubeNum; z++) {
      const geometry = makeSingleCube(x, y, z);
      const cube = new Mesh(geometry, material);
      cube.position.x = x * (1 + CUBE_MARGIN);
      cube.position.y = y * (1 + CUBE_MARGIN);
      cube.position.z = z * (1 + CUBE_MARGIN);
      cubes[x][y][z] = cube;
      scene.add(cube);
    }
  }
}

Here is the finished 3x3 Cube

Rotate the cube

Here is the tricky part, obviously the cubes do not rotate one by one. There has to be a parent-child relation here. At first I was confused but later I figured it out.

My approach is based on some observation:

  • Rotations has to be done in group
  • We need to group relevant pieces together each time we rotate
  • After we done rotating, ungroup them
  • Rotation pivot stay the same at the center of the Cube

cube

Group & rotate function

function startMove(face, depth, magnitude) {
  for (let x = 0; x < cubeNum; x++) {
    for (let y = 0; y < cubeNum; y++) {
      for (let z = 0; z < cubeNum; z++) {
        if (!isInFace(x, y, z, face, depth)) {
          continue;
        }
        pivot.attach(cubes[x][y][z]);
      }
    }
  }
  let targetX = pivot.rotation.x;
  let targetY = pivot.rotation.y;
  let targetZ = pivot.rotation.z;
  if (face == FACE_LEFT || face == FACE_RIGHT) {
    targetX += (Math.PI / 2) * magnitude;
  } else if (face == FACE_TOP || face == FACE_BOTTOM) {
    targetY += (Math.PI / 2) * magnitude;
  } else if (face == FACE_FRONT || face == FACE_BACK) {
    targetZ += (Math.PI / 2) * magnitude;
  }
  anime({
    targets: pivot.rotation,
    x: targetX,
    y: targetY,
    z: targetZ,
    easing: "linear",
    duration: 600 * Math.abs(magnitude),
    round: 100,
    delay: 200,
    complete: cleanUpAfterMove,
  });
}

Ungroup function

function cleanUpAfterMove() {
  const clamp = function (n, l, r) {
    return Math.min(r, Math.max(l, n));
  };
  const posToIndex = function (n) {
    return clamp(Math.round(n / (1 + CUBE_MARGIN)), 0, cubeNum - 1);
  };
  let newCubes = cubes;
  for (let i = pivot.children.length - 1; i >= 0; i--) {
    const cube = pivot.children[i];
    const pos = new Vector3();
    scene.attach(cube);
    cube.getWorldPosition(pos);
    const x = posToIndex(pos.x);
    const y = posToIndex(pos.y);
    const z = posToIndex(pos.z);
    cube.position.x = x * (1 + CUBE_MARGIN);
    cube.position.y = y * (1 + CUBE_MARGIN);
    cube.position.z = z * (1 + CUBE_MARGIN);
    newCubes[x][y][z] = cube;
  }
  cubes = newCubes;
  pivot.rotation.x = pivot.rotation.y = pivot.rotation.z = 0;
}

The result will looks somewhere like this

And finally let’s add some variations

At this point, it’s up to your creativity to add more interesting features. I would like to add more dimension, then add some goofy easing curve to spice up the rotation.

Code

Check out this page and the code below

Full implementation
import anime from "animejs";
import {
  BoxGeometry,
  Clock,
  Color,
  Float32BufferAttribute,
  Mesh,
  MeshBasicMaterial,
  Object3D,
  PerspectiveCamera,
  Scene,
  Vector3,
  WebGLRenderer,
} from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

let scene, camera, renderer, controls;
const clock = new Clock();
const material = new MeshBasicMaterial({
  vertexColors: true,
});
let cameraTarget;
let isInIntro = false;
var time = 0;
const CANVAS_ID = "rubik";
const USE_CAMERA_CONTROL = true;
const ASPECT_RATIO = 0.75;
const FACE_RIGHT = 0;
const FACE_LEFT = 1;
const FACE_TOP = 2;
const FACE_BOTTOM = 3;
const FACE_FRONT = 4;
const FACE_BACK = 5;
const CUBE_NUM_DEFAULT = 3;
let cubeNum = CUBE_NUM_DEFAULT;
const CUBE_MARGIN = 0.1;

function isInFace(x, y, z, face, depth) {
  return (
    (face == FACE_TOP && y >= cubeNum - depth) ||
    (face == FACE_BOTTOM && y < depth) ||
    (face == FACE_FRONT && z >= cubeNum - depth) ||
    (face == FACE_BACK && z < depth) ||
    (face == FACE_LEFT && x < depth) ||
    (face == FACE_RIGHT && x >= cubeNum - depth)
  );
}
function getColor(x, y, z, face) {
  const FACE_TO_COLOR = [
    0x40a02b, //right - green
    0x89b4fa, //left - purple
    0xf9e2af, //top - yellow
    0xf8fafc, //bottom - white
    0xef4444, //front - red
    0xfe640b, //back - orange
  ];
  const BLACK = 0x181825;

  if (isInFace(x, y, z, face, 1)) {
    return FACE_TO_COLOR[face];
  }
  return BLACK;
}

function getCurrentSize() {
  return cubeNum;
}

function makeSingleCube(x, y, z) {
  const piece = new BoxGeometry().toNonIndexed();
  const n = piece.getAttribute("position").count / 6;
  const buffer = [];
  const color = new Color();
  for (let i = 0; i < n; i++) {
    color.setHex(getColor(x, y, z, i));
    for (let j = 0; j < 6; j++) {
      buffer.push(color.r, color.g, color.b);
    }
  }
  piece.setAttribute("color", new Float32BufferAttribute(buffer, 3));
  return piece;
}

let cubes = null;
let pivot = null;

function makeRubik() {
  cubes = new Array(cubeNum);
  for (let x = 0; x < cubeNum; x++) {
    cubes[x] = new Array(cubeNum);
    for (let y = 0; y < cubeNum; y++) {
      cubes[x][y] = new Array(cubeNum);
    }
  }
  for (let x = 0; x < cubeNum; x++) {
    for (let y = 0; y < cubeNum; y++) {
      for (let z = 0; z < cubeNum; z++) {
        const geometry = makeSingleCube(x, y, z);
        const cube = new Mesh(geometry, material);
        cube.position.x = x * (1 + CUBE_MARGIN);
        cube.position.y = y * (1 + CUBE_MARGIN);
        cube.position.z = z * (1 + CUBE_MARGIN);
        cubes[x][y][z] = cube;
        scene.add(cube);
        //addDebugArrow(cube);
      }
    }
  }
  pivot = new Object3D();
  const k = ((cubeNum - 1) / 2) * (1 + CUBE_MARGIN);
  pivot.position.x = k;
  pivot.position.y = k;
  pivot.position.z = k;
  scene.add(pivot);
  camera.lookAt(pivot.position);
  controls.target.set(k, k, k);
  controls.enableRotate = false;
  controls.autoRotate = false;
  cameraTarget.set(0, 2 + cubeNum * 2, 5 + cubeNum * 2);
  isInIntro = true;
  //addDebugArrow(pivot);
}

function remakeRubik(n) {
  if (cubeNum == n) {
    return;
  }
  scene.clear();
  cubeNum = n;
  makeRubik();
}

function setupCamera(w, h) {
  camera = new PerspectiveCamera(45, w / h, 1, 2000);
  scene = new Scene();
  camera.position.set(0, 0, 0);
  cameraTarget = new Vector3(0, 0, 0);
  rebuildOrbitControl();
}

function rebuildOrbitControl() {
  if (!USE_CAMERA_CONTROL) {
    return;
  }
  controls = new OrbitControls(camera, renderer.domElement);
  const k = ((cubeNum - 1) / 2) * (1 + CUBE_MARGIN);
  controls.target.set(k, k, k);
  //controls.enablePan = false;
  controls.minDistance = 4; // the minimum distance the camera must have from center
  controls.maxDistance = 30; // the maximum distance the camera must have from center
  //controls.update();
  controls.enableRotate = true;
  controls.autoRotate = true;
}

function init() {
  const canvas = document.getElementById(CANVAS_ID);
  const w = canvas.clientWidth;
  const h = canvas.clientHeight; //w * ASPECT_RATIO;
  renderer = new WebGLRenderer({
    canvas: canvas,
    antialias: true,
    alpha: true,
  });
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(w, h);
  if (scene != null) {
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    rebuildOrbitControl();
    return;
  }
  setupCamera(w, h);
  makeRubik();
  startMove(
    Math.floor(Math.random() * 5),
    Math.floor(Math.random() * cubeNum),
    Math.floor(Math.random() * 5) - 2
  );
  window.addEventListener("resize", onWindowResize);
}

function destroy() {
  renderer.dispose();
}

function onWindowResize() {
  const canvas = document.getElementById(CANVAS_ID);
  if (!canvas) {
    return;
  }
  canvas.style = "";
  const w = canvas.clientWidth;
  const h = canvas.clientHeight; //w * ASPECT_RATIO;
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
  renderer.setSize(w, h);
}
// function addDebugArrow(object) {
//   const dirZ = new Vector3(0, 0, 1);
//   const dirY = new Vector3(0, 1, 0);
//   const dirX = new Vector3(1, 0, 0);
//   const origin = Vector3.Zero; //object.position;
//   const length = 2;
//   const hex = 0x0077ff;
//   const zArrow = new ArrowHelper(dirZ, origin, length, hex);
//   object.add(zArrow);
//   const yArrow = new ArrowHelper(dirY, origin, length, hex);
//   object.add(yArrow);
//   const xArrow = new ArrowHelper(dirX, origin, length, hex);
//   object.add(xArrow);
// }

function startMove(face, depth, magnitude) {
  for (let x = 0; x < cubeNum; x++) {
    for (let y = 0; y < cubeNum; y++) {
      for (let z = 0; z < cubeNum; z++) {
        if (!isInFace(x, y, z, face, depth)) {
          continue;
        }
        pivot.attach(cubes[x][y][z]);
      }
    }
  }
  let targetX = pivot.rotation.x;
  let targetY = pivot.rotation.y;
  let targetZ = pivot.rotation.z;
  if (face == FACE_LEFT || face == FACE_RIGHT) {
    targetX += (Math.PI / 2) * magnitude;
  } else if (face == FACE_TOP || face == FACE_BOTTOM) {
    targetY += (Math.PI / 2) * magnitude;
  } else if (face == FACE_FRONT || face == FACE_BACK) {
    targetZ += (Math.PI / 2) * magnitude;
  }
  const easingFunctions = [
    "easeInElastic",
    "easeOutElastic",
    "easeInOutElastic",
    "easeOutInElastic",
    "easeInQuad",
    "easeInCubic",
    "easeInQuart",
    "easeInQuint",
    "easeInSine",
    "easeInExpo",
    "easeInCirc",
    "easeInBack",
    "easeOutQuad",
    "easeOutCubic",
    "easeOutQuart",
    "easeOutQuint",
    "easeOutSine",
    "easeOutExpo",
    "easeOutCirc",
    "easeOutBack",
    "easeInBounce",
    "easeInOutQuad",
    "easeInOutCubic",
    "easeInOutQuart",
    "easeInOutQuint",
    "easeInOutSine",
    "easeInOutExpo",
    "easeInOutCirc",
    "easeInOutBack",
    "easeInOutBounce",
    "easeOutBounce",
    "easeOutInQuad",
    "easeOutInCubic",
    "easeOutInQuart",
    "easeOutInQuint",
    "easeOutInSine",
    "easeOutInExpo",
    "easeOutInCirc",
    "easeOutInBack",
    "easeOutInBounce",
  ];
  const easing =
    easingFunctions[Math.floor(Math.random() * easingFunctions.length)];
  anime({
    targets: pivot.rotation,
    x: targetX,
    y: targetY,
    z: targetZ,
    duration: 600 * Math.abs(magnitude),
    round: 100,
    delay: 200,
    easing: easing,
    complete: cleanUpAfterMove,
  });
}

function cleanUpAfterMove() {
  const clamp = function (n, l, r) {
    return Math.min(r, Math.max(l, n));
  };
  const posToIndex = function (n) {
    return clamp(Math.round(n / (1 + CUBE_MARGIN)), 0, cubeNum - 1);
  };
  const newCubes = cubes;
  for (let i = pivot.children.length - 1; i >= 0; i--) {
    const cube = pivot.children[i];
    const pos = new Vector3();
    scene.attach(cube);
    cube.getWorldPosition(pos);
    const x = posToIndex(pos.x);
    const y = posToIndex(pos.y);
    const z = posToIndex(pos.z);
    cube.position.x = x * (1 + CUBE_MARGIN);
    cube.position.y = y * (1 + CUBE_MARGIN);
    cube.position.z = z * (1 + CUBE_MARGIN);
    newCubes[x][y][z] = cube;
  }
  cubes = newCubes;
  pivot.rotation.x = pivot.rotation.y = pivot.rotation.z = 0;
  startMove(
    Math.floor(Math.random() * 5),
    Math.floor(Math.random() * cubeNum),
    Math.floor(Math.random() * 5) - 2
  );
}

function render() {
  time += clock.getDelta();
  if (renderer && scene && camera) {
    renderer.render(scene, camera);
  }
  if (controls) {
    controls.update();
  }
  if (isInIntro && camera) {
    camera.position.lerp(cameraTarget, 0.1);
    if (camera.position.distanceTo(cameraTarget) < 0.01) {
      isInIntro = false;
      controls.enableRotate = true;
      controls.autoRotate = true;
    }
  }
}

export { CANVAS_ID, init, destroy, render, getCurrentSize, remakeRubik };