💥 동기

회사에서 독서비를 지원해주는 덕에 코로나 한참 전부터 거리두기를 하던 책을 조금씩 읽기 시작했다. 최근에 책을 알아보러 알라딘에 들어가보니 책에 마우스를 올리면 책을 돌려볼 수 있도록 되어있었다.

요즘 three.js에 대해 호기심이 있었는데 이 기회에 three.js 경험해볼 겸 혹시 다른 사이드 프로젝트에서 써먹어볼 수 있지 않을까 하는 마음으로 알라딘의 책을 three.js로 클론해보게 되었다.(알라딘에서 책의 앞, 뒤, 옆면에 사용할 이미지 소스도 구할 수 있어서 너무 좋았다.😊)

./3d-book-1.png

클론을 하면서 이런저런 소스를 통해서 three.js를 조금은 이해해볼 수 있게 되었는데 혹시 three.js를 뭔가를 만들어보면서 공부해보고 싶으신 분들을 워해 과제 형식으로 내용을 정리 해보았다.

🎮 알라딘 책 클론해보기

🤔 과제

알라딘의 책 미리보기를 three.js를 통해 최대한 유사하게 구현해본다.

제한시간: 5시간(긴장감을 위해)

🧺 준비물

책의 앞, 뒤, 옆면에서 사용할 이미지는 여기에서 받을 수 있다. 다른 책의 표지를 얻어오고 싶다면 알라딘에서 원하는 책을 검색해서 아래처럼 개발자 도구를 통해 가져올 수 있다.

./3d-book-2.png

✅ 요구사항

  • 처음에는 책의 정면이 보여지게 하기
  • 마우스를 책에 올리면 책의 옆면이 살짝 보이도록 돌리기
  • 알라딘 미리보기와 동일한 그림자 구현하기
  • (선택) 클릭 시 책이 180도 회전하게 하기

🍄 레퍼런스

아래부터는 내가 만든 방법에 대한 설명이다.(🔥스포주의!)



🧠 내가 만든 방법

내가 구현항 방법은 다음 링크에서 볼 수 있다.

내 블로그나 다른 프로젝트에서 사용할지도 모르기에 React로 개발해서 배포해놓았다.

🖲 기본 구조 만들기

먼저 three.js로 3D 세상에 세트장을 만들어보자.

🌉 Scene

Scene은 말그대로 장면을 의미한다. 무언가를 보여주기 위해서는 scene을 먼저 만들어야 한다.

const scene = new THREE.Scene();
scene.background = new THREE.Color(style.background);

그리고 나서 만든 scene에서 보여줄 여러 요소들을 추가한다.

📷 Camera

Camera는 말그대로 카메라로 Scene의 어디서, 어디를, 어떻게 보고 있게 할 것인가를 정의할 수 있다. 나는 이번 프로젝트에서 사람의 눈을 가장한 방식의 카메라인 PerspectiveCamera를 사용하였다.

const camera = new THREE.PerspectiveCamera(70, aspect, 1, 1000);
camera.position.set(0, 0, 6);

🎥 WebGLRenderer

SceneCamera가 준비되었다면 이제 보여주기를 하기 위한 기본 환경은 준비가 완료되었다. 이를 실제로 보여주기 위해서는 WebGLRenderer를 사용해야 한다.

WebGLRenderer는 말 그대로 내가 만든 Scene을 WebGL을 활용해서 보여주는 역할을 한다. 나는 renderer를 만들면서 그림자가 보여질 수 있도록 shadowMap을 설정해주었다.

const renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setSize(style.width, style.height);

그리고 나서 이렇게 아래와 같이 animate 함수를 통해 render를 진행하면 screen에 변화되는 내용을 지속적으로 그릴 수 있게 된다. 여기서 setInterval이 아닌 requestAnimationFrame를 사용하는 이유는 많지만 특히 브라우저의 다른 탭으로 이동했을 때는 반복이 잠시 멈춰서 쓸데없는 배터리 소모가 없다고 한다.

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

📦 물체 추가하기

그럼 이제 본격적으로 물체들을 추가해보자. Mesh는 Object의 기본틀이다. Mesh에 여러 Material을 추가하면서 물체를 만들 수 있다. 이 과제를 해결하기 위해서 추가해야하는 Mesh는 뭐가 있을까? 언뜻 생각하면 책만 있으면 될 것 같지만 책에 있는 그림자가 보여지기 위해 그림자가 비추어질 물체도 필요하다.

🌘 plane (그림자를 위한 배경)

혹시 추가 구현인 회전을 위해서 그림자를 보여줄 물체는 특정 색깔을 가지고 있지 않고 그냥 그림자만 보여줄 수 있다면 최고일 것이다. 이를 위해서 그림자만을 받고 다른 부분들을 모두 투명한 상태인 ShadowMaterial을 활용했다.

const planeGeometry = new THREE.PlaneGeometry(500, 500, 32, 32);
const planeMaterial = new THREE.ShadowMaterial();
planeMaterial.opacity = 0.5;

const plane = new THREE.Mesh(planeGeometry, planeMaterial);
plane.receiveShadow = true;
scene.add(plane);

📓 cube (책)

책을 만들기 위해서는 육면체인 BoxGeometry에 사이즈를 정해 책의 형태를 만들고 책의 6개의 면에 들어갈 Material를 getBookMaterials 함수를 통해 구현하였다.

const geometry = new THREE.BoxGeometry(3.5, 5, 0.5);
const cube = new THREE.Mesh(geometry, getBookMaterials(bookCovers));
cube.castShadow = true;
cube.position.set(0, 0, 0);
scene.add(cube);

책의 육면에 들어갈 Material을 정의하는 함수는 다음과 같다. 6개 면을 돌면서 각 면에 해당하는 image Url이 넘어오면 Image를 TextureLoader를 통해 이미지를 넣어서 면에 해당하는 Material을 반환하고 없다면 흰색 Material을 반환하도록 구현하였다.

const getBookMaterials = (urlMap) => {
  const materialNames = ['edge', 'spine', 'top', 'bottom', 'front', 'back'];
  return materialNames.map((name) => {
    if (!urlMap[name]) return new THREE.MeshBasicMaterial(0xffffff);

    const texture = new THREE.TextureLoader().load(urlMap[name]);

    // to create high quality texture
    texture.generateMipmaps = false;
    texture.minFilter = THREE.LinearFilter;
    texture.needsUpdate = true;

    return new THREE.MeshBasicMaterial({ map: texture });
  });
};

💡 Light

그럼 이제 조명을 설치해보자. 조명의 위치와 각도를 잘 조정해줘야 책 뒤에 자연스러운 그림자가 생겨날 것이다. 이를 위해서 특정 지점에서 빛을 쏘는 DirectionalLight를 활용하였다.

const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(-10, 10, 10);
light.castShadow = true;
scene.add(light);

🤸‍♂️ 회전시키기

이제 책에 마우스를 호버했을 때 책이 움직이도록 해보자. 근데 알라딘의 책이 움직이는 걸 보면 책이 움직임에 따라 우측에 보이던 그림자가 갑자기 사라지는 것을 볼 수 있다. 조명이 좌상단에 있다면 책을 돌린다고 그림자가 사라지면 안되는데 뭔가 이상했다. 같은 구도로 실제로 책과 조명을 위치시키고 책을 돌리다가 이게 불가능하다는 것을 알게 되었다.

그러다 책을 돌리는 것이 아니라 내 눈의 위치를 바꿔야한다는 것을 알게 되었다. 이런 부분은 2D 세계에서 고민해보지 못했던 것이라 신기했다ㅎㅎ

먼저 마우스가 올라와있는지 여부를 확인하기 위해 eventListener를 추가했다.

let isMouseOver = false;
refCurrent.addEventListener('mouseover', () => {
  isMouseOver = true;
});
refCurrent.addEventListener('mouseleave', () => {
  isMouseOver = false;
});

그럼 마우스가 위에 있으면 최대 135도까지 카메라를 회전시키고 마우스가 떠나면 다시 원래 위치로 서서히 돌아오도록 만들었다. 여기서 카메라의 위치가 바뀌어도 계속 scene을 보고 있도록 lookAt 함수를 활용했다.

const animate = () => {
  requestAnimationFrame(animate);
  isMouseOver ? rotate() : rotateBack();
  camera.lookAt(scene.position);
  renderer.render(scene, camera);
};

카메라와 물체의 거리를 유지한 상태로 각도만 바꾸기 위해서 오랜만에 삼각함수를 활용하여 x와 z 값을 구했다. 책과의 거리는 distanceTo 함수를 사용하면 간단하게 구할 수 있었다.

const distance = camera.position.distanceTo(cube.position);

const rotate = () => {
  if (degrees < 135) {
    degrees += 2;
    const radian = degrees * (Math.PI / 180);
    camera.position.x = Math.cos(radian) * distance;
    camera.position.z = Math.sin(radian) * distance;
  }
};

const rotateBack = () => {
  if (degrees > 90) {
    degrees -= 2;
    const radian = degrees * (Math.PI / 180);
    camera.position.x = Math.cos(radian) * distance;
    camera.position.z = Math.sin(radian) * distance;
  }
};

⚡️최종 코드

이렇게 구현한 3d-book의 최종 코드는 다음과 같다!

import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';

const Book = (props) => {
  const mountRef = useRef(null);

  useEffect(() => {
    const { bookCovers, style } = props;
    const aspect = style.width / style.height;

    const getBookMaterials = (urlMap) => {
      const materialNames = ['edge', 'spine', 'top', 'bottom', 'front', 'back'];
      return materialNames.map((name) => {
        if (!urlMap[name]) return new THREE.MeshBasicMaterial(0xffffff);

        const texture = new THREE.TextureLoader().load(urlMap[name]);

        // to create high quality texture
        texture.generateMipmaps = false;
        texture.minFilter = THREE.LinearFilter;
        texture.needsUpdate = true;

        return new THREE.MeshBasicMaterial({ map: texture });
      });
    };

    const refCurrent = mountRef.current;
    const scene = new THREE.Scene();
    scene.background = new THREE.Color(style.background);

    const planeGeometry = new THREE.PlaneGeometry(500, 500, 32, 32);
    const planeMaterial = new THREE.ShadowMaterial();
    planeMaterial.opacity = 0.5;

    const plane = new THREE.Mesh(planeGeometry, planeMaterial);
    plane.receiveShadow = true;
    scene.add(plane);

    const light = new THREE.DirectionalLight(0xffffff, 1);
    light.position.set(-10, 10, 10);
    light.castShadow = true;
    scene.add(light);

    const geometry = new THREE.BoxGeometry(3.5, 5, 0.5);
    const cube = new THREE.Mesh(geometry, getBookMaterials(bookCovers));
    cube.castShadow = true;
    cube.position.set(0, 0, 0);
    scene.add(cube);

    let isMouseOver = false;
    refCurrent.addEventListener('mouseover', () => {
      isMouseOver = true;
    });
    refCurrent.addEventListener('mouseleave', () => {
      isMouseOver = false;
    });

    let degrees = 90;

    const camera = new THREE.PerspectiveCamera(70, aspect, 1, 1000);
    camera.position.set(0, 0, 6);

    const renderer = new THREE.WebGLRenderer();
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    renderer.setSize(style.width, style.height);

    const distance = camera.position.distanceTo(cube.position);

    const rotate = () => {
      if (degrees < 135) {
        degrees += 2;
        const radian = degrees * (Math.PI / 180);
        camera.position.x = Math.cos(radian) * distance;
        camera.position.z = Math.sin(radian) * distance;
      }
    };

    const rotateBack = () => {
      if (degrees > 90) {
        degrees -= 2;
        const radian = degrees * (Math.PI / 180);
        camera.position.x = Math.cos(radian) * distance;
        camera.position.z = Math.sin(radian) * distance;
      }
    };

    const animate = () => {
      requestAnimationFrame(animate);
      isMouseOver ? rotate() : rotateBack();
      camera.lookAt(scene.position);
      renderer.render(scene, camera);
    };

    refCurrent.appendChild(renderer.domElement);
    animate();

    return () => {
      refCurrent.removeChild(renderer.domElement);
    };
  }, [props]);

  return <div ref={mountRef} style={props.style} />;
};
export default Book;

🤩 느낀점

뭔가 새로웠다. 여태까지의 개발은 모니터라는 단면에서 보여지는 세상을 그리는 느낌이었다면 이번 개발은 뭔가 세트장을 꾸며놓고 보여질 장면을 촬영하는 느낌이었다. 안 쓰던 삼각함수도 좀 써보고 실제로 책도 돌려보면서 만들어내다보니 개발하다보니 재미도 있고 보람도 있었다.

이게 어떻게 쓰일지 모르겠지만 3d로 만들어 볼만 한 것들을 좀 고민해봐야겠다ㅎㅎ