Three.js cannon 3D 물리 엔진 알아보기

기운찬곰·2023년 8월 17일
1
post-thumbnail

Overview

이전 글 "[사이드 프로젝트] 3D 주사위 게임 만들기" 블로그 포스팅을 마지막으로 이제 그만하고 다른 걸 해볼 예정이었는데... 이대로 끝내기는 역시 아쉬워서 다른 사람이 작성한 소스 코드를 분석해봤습니다. 와 근데 하면서 공부가 많이 되네요. 아.. 프로젝트를 다시 만들까...

이번 시간에는 위 소스 코드를 분석해보면서 cannon 물리 엔진에 대해서도 글을 작성해보려고 합니다.


1단계. 기본 구성

Next.js, React 환경이 아닌 오로지 바닐라JS 환경에서 코드를 작성했습니다. 차라리 이편이 공부하기는 훨신 편하더군요. 번거롭게 R3F를 사용할 필요도 없으니까요.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />

    <link rel="stylesheet" href="./style.css" />
    <title>Document</title>
  </head>
  <body>
    <div class="container">
      <canvas id="canvas"></canvas>
      <div class="ui-controls">
        <div class="score">Score: <span id="score-result"></span></div>
        <button id="roll-btn">throw the dice</button>
      </div>
    </div>

    <script
      async
      src="https://unpkg.com/es-module-shims@1.6.3/dist/es-module-shims.js"
    ></script>

    <script type="importmap">
      {
        "imports": {
          "three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/0.145.0/three.module.min.js",
          "three/addons/": "https://threejs.org/examples/jsm/"
        }
      }
    </script>

    <script src="./main.js" type="module"></script>
  </body>
</html>

기본적인 세팅을 위해 renderer, scene, camera를 설정해줍니다.

import * as CANNON from "https://cdn.skypack.dev/cannon-es";
import * as THREE from "three";
import * as BufferGeometryUtils from "three/addons/utils/BufferGeometryUtils.js";

const canvasEl = document.querySelector("#canvas");
const scoreResult = document.querySelector("#score-result");
const rollBtn = document.querySelector("#roll-btn");

let renderer, scene, camera, diceMesh, physicsWorld;

initScene();

function initScene() {
  renderer = new THREE.WebGLRenderer({
    alpha: true,
    antialias: true,
    canvas: canvasEl,
  });

  scene = new THREE.Scene();

  camera = new THREE.PerspectiveCamera(
    45, // 커질수록 양옆의 사이즈(시야각)은 증가하되 물체는 그만큼 줄어들게됨
    window.innerWidth / window.innerHeight,
    0.1,
    100
  );

  camera.position.set(0, 0.4, 3.5).multiplyScalar(6); // 벡터 곱?
  camera.lookAt(0, 0, 0); // (0, 0, 0)이 카메라의 중심이 되도록 설정?

  updateSceneSize();

  const axesHelper = new THREE.AxesHelper(5);
  scene.add(axesHelper);

  const gridHelper = new THREE.GridHelper(10, 10);
  scene.add(gridHelper);

  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
  diceMesh = new THREE.Mesh(geometry, material);
  scene.add(diceMesh);

  render();
}

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

function updateSceneSize() {
  renderer.setSize(window.innerWidth, window.innerHeight); // canvas 크기를 viewport 크기에 맞게
}

💻 카메라 설정 참고 : https://youtu.be/qe3mahuoYlw?t=97

카메라에서 수치를 어떻게 하면 좋을지 고민이라면 위 유튜브 영상을 참고하면 좋을거 같습니다. 카메라에 대해 아주 자세하게 알 수 있습니다.

반응형 지원을 위해 resize 이벤트에 대해 camera와 renderer를 조절해주면 됩니다. 이에 대해서도 관련 유튜브 영상을 참고해보면 좋습니다. 참고 : https://youtu.be/O0HAN1nXvMU?t=908

window.addEventListener("resize", updateSceneSize);

function updateSceneSize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}

2단계. 조명 + 그림자 효과 적용

참고 : https://youtu.be/jbzTSGsgOoo

일단 renderer에 그림자 효과를 true로 해줘야 합니다.

renderer.shadowMap.enabled = true;

여기서는 기본 조명(ambientLight)과 PointLight 조명을 사용했습니다. 참고로 PointLight가 자연광을 의미하고, DirectionalLight가 방향성이 있는 조명을 의미합니다. 그리고 AmbientLight는 그림자가 생기지 않는 모든 방향 조명을 의미하니 참고 바랍니다.

const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);

const topLight = new THREE.PointLight(0xffffff, 0.5);
topLight.position.set(3, 3, 3);
topLight.castShadow = true;
scene.add(topLight);

const topLightHelper = new THREE.PointLightHelper(topLight, 0.5, 0x0000ff);
scene.add(topLightHelper);

주의할 점은 조명과 물체 모두 castShadow를 true로 해줘야 합니다.

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
diceMesh = new THREE.Mesh(geometry, material);
diceMesh.castShadow = true;
scene.add(diceMesh);

그리고 바닥을 만들어서 receiveShadow를 true로 설정해주면 그림자가 바닥에 생기게 됩니다.

function createFloor() {
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(1000, 1000),
    new THREE.ShadowMaterial({
      opacity: 0.15,
    })
  );
  floor.receiveShadow = true;
  floor.position.y = -7;
  floor.quaternion.setFromAxisAngle(new THREE.Vector3(-1, 0, 0), Math.PI * 0.5);

  scene.add(floor);
}

그림자 해상도를 높이는 법 : https://youtu.be/jbzTSGsgOoo?t=238

그림자에도 해상도가 있더군요. 값을 크게하면 더욱 뚜렷하게 보이는대신 리소스를 잡아먹게 되므로 적당한 조절이 필요합니다. 보통 1024나 2048을 많이 사용하는 듯 합니다.

topLight.shadow.mapSize.width = 2048;
topLight.shadow.mapSize.height = 2048;

3단계. 주사위 만들기

해당 codepen 소스코드는 주사위를 직접 만들더군요. 진짜 엄청난 재주네요. 여기서 핵심은 createInnerGeometry과 createBoxGeometry 입니다. 자세한 내용은 튜토리얼에도 나와있으므로 생략하겠습니다. 개인적으로 저는 그냥 Blender로 직접 만드는 편이...더 쉬울 거 같습니다...

function createDiceMesh() {
  const boxMaterialOuter = new THREE.MeshStandardMaterial({
    color: 0xeeeeee,
  });
  const boxMaterialInner = new THREE.MeshStandardMaterial({
    color: 0x000000,
    roughness: 0,
    metalness: 1,
    side: THREE.DoubleSide,
  });

  const diceMesh = new THREE.Group();
  const innerMesh = new THREE.Mesh(createInnerGeometry(), boxMaterialInner); // 주사위 눈?
  const outerMesh = new THREE.Mesh(createBoxGeometry(), boxMaterialOuter); // 주사위 박스(테두리)
  outerMesh.castShadow = true;
  diceMesh.add(innerMesh, outerMesh);

  return diceMesh;
}

참고 BoxGeometry segments 의미 : https://youtu.be/ITA9no8Bsio

제가 대충 로직을 예상해보면 BoxGeometry에 4,5,6번째 매개변수는 segments를 의미해서 40x40x40이라는 세브 박스로 분할해줍니다. 이를 이용해 주사위 눈 위치를 움푹 파이게 한다던가 주사위 테두리를 매끄럽게 하는 작업을 해준거 같습니다.

// segments가 분할이라는 의미구나. 그니까 박스를 40*40*40 의 세부 박스로 분할하겠다는 의미
let boxGeometry = new THREE.BoxGeometry(1,1,1,params.segments,params.segments,params.segments);

4단계. cannon 물리엔진 중력 설정 및 적용

cannon 물리엔진 소개

cannon-es는 가볍고 웹용 3D 물리 엔진을 사용하기 쉽습니다. 이는 three.js의 간단한 API에서 영감을 받았으며, ammo.js와 Bullet 물리 엔진을 기반으로 합니다.

Getting Started

가장 먼저 설정해야 할 것은 우리의 물리학 세계입니다. 지구의 중력으로 세상을 만들 수 있습니다. cannon.js는 SI 단위(미터, 킬로그램, 초 등)를 사용합니다.

function initPhysics() {
  physicsWorld = new CANNON.World({
      gravity: new CANNON.Vec3(0, -60, 0), // m/s²  (지구 중력을 사용하려면 -9.82 설정)
  })
}

그런 다음에 바닥을 만들어줄 겁니다. 바닥을 THREE.Mesh로 생성하고 scene.add로 추가하면 바닥이 생기게 됩니다. 해당 바닥의 물리엔진을 적용하려면 CANNON.Body를 통해 설정을 해주고 copy를 통해 연결을 해줘야 합니다.

function createFloor() {
  const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(1000, 1000),
    new THREE.ShadowMaterial({
      opacity: 0.15,
    })
  );
  floor.receiveShadow = true;
  floor.position.y = -7;
  floor.quaternion.setFromAxisAngle(new THREE.Vector3(-1, 0, 0), Math.PI * 0.5);

  scene.add(floor);

  const floorBody = new CANNON.Body({
    type: CANNON.Body.STATIC, // 힘이나 속도에 영향을 받지 않는 정적 물체를 의미. 따라서 mass도 적을 필요 없음
    shape: new CANNON.Plane(),
  });
  floorBody.position.copy(floor.position);
  floorBody.quaternion.copy(floor.quaternion);
  physicsWorld.addBody(floorBody);
}

공식 문서에서는 이렇게 설명하고 있습니다.

  • cannon은 화면에 어떤 것도 렌더링하는 것을 처리하지 않으며, 시뮬레이션의 산술을 계산할 뿐입니다. 실제로 화면에 표시하려면 three.js와 같은 렌더링 라이브러리를 사용해야 합니다.
  • 그런 다음 three.js와 cannon.js 본체를 연결해야 합니다. 이를 위해서는 각 프레임에 위치 및 회전 데이터를 본체에서 메쉬로 복사합니다.

💡 그니까 2가지 작업을 해야 한다는 뜻이죠. 렌더링 하는 작업과 cannon 본체를 만들고 연결하는 작업을 말합니다.

마찬가지로 box에 대한 cannon을 만들고 연결하는 작업을 진행해줍니다. STATIC이 아닌 경우는 mass를 적어줍니다. “신체가 질량을 가지고 있고 힘에 의해 영향을 받을 때, 그것들은 역동적인 신체라고 불립니다.”

여기서 주의할 점은 position은 CANNON에다가 설정을 해야 제대로 동작합니다. mesh 자체에다가 해주면 제대로 안되더군요.

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
diceMesh = new THREE.Mesh(geometry, material);
diceMesh.castShadow = true;
scene.add(diceMesh);

diceBody = new CANNON.Body({
  mass: 0.3,
  shape: new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)),
  position: new CANNON.Vec3(0, 10, 0),
});

physicsWorld.addBody(diceBody);

마지막으로 시뮬레이션을 진행하기 위해서는 각 프레임에 world.fixedStep()을 호출해야 합니다. 기본값은 1/60으로 60fps를 의미합니다. 그리고 각 프레임에 위치 및 회전 데이터를 본체에서 메쉬로 복사합니다. 그래야 box에 대한 낙하 운동이 제대로 시뮬레이션 됩니다.

function render() {
  physicsWorld.fixedStep();

  diceMesh.position.copy(diceBody.position);
  diceMesh.quaternion.copy(diceBody.quaternion);

  renderer.render(scene, camera);
  requestAnimationFrame(render);
}

참고로 restitution 는 복원계수라는 뜻인데 기본이 0.3입니다. 이를 늘려보면 박스가 튕기는게 더 커집니다. cannon-es 문서가 잘 되어있으니 검색해보시길 바랍니다.


function initPhysics() {
  physicsWorld = new CANNON.World({
      gravity: new CANNON.Vec3(0, -60, 0), // m/s²  (지구 중력을 사용하려면 -9.82 설정)
  })
  
  physicsWorld.defaultContactMaterial.restitution = 0.3;
}

5단계. 주사위 던지기

주사위를 던질 때에도 cannon 물리엔진을 적용해야 합니다. 뭔가 되게 속성이 많은데 검색을 통해 알아보도록 하겠습니다.

function throwDice() {
  scoreResult.innerHTML = "";
  diceArray.forEach((d, dIdx) => {
    d.body.velocity.setZero();
    d.body.angularVelocity.setZero();
    d.body.position = new CANNON.Vec3(4, dIdx * 1.5, -0.5);
    d.mesh.position.copy(d.body.position);
    d.mesh.rotation.set(
      2 * Math.PI * Math.random(),
      0,
      2 * Math.PI * Math.random()
    );
    d.body.quaternion.copy(d.mesh.quaternion);

    const force = 1 + 2 * Math.random();
    d.body.applyImpulse(
      new CANNON.Vec3(-force, force, 0),
      new CANNON.Vec3(0, 0, 0.2)
    );
    d.body.allowSleep = true;
  });
}
  • velocity, angularVelocity : 솔직히 모르겠습니다. 속도랑 각속도라는 뜻인거 같은데. 이걸 다 0으로 한다..? 뭔차이인지 모르겠습니다. 저게 있고 없음에 차이를 못느낍니다.
  • position을 정해줄때 cannon body랑 mesh copy에다가도 적용해주는거 같습니다. 뭐 근데 굳이 copy는 안해도 잘 됩니다.
  • rotation도 굳이 안해도 별 상관은 없습니다. 아마 초기에 좀 회전시키려는 모양인데 굳이 그럴필요는 없는듯합니다.
  • 여기서 핵심은 아무래도 applyImpulse 입니다. 이게 없으면 그냥 수직 낙하가 되어버립니다. 신체의 한 지점에 충격을 가합니다. 예를 들어 신체 표면의 점일 수 있습니다. 충격(impulse)은 짧은 시간 동안 신체에 가해지는 힘을 말한다(즉, = 힘 * 시간). Body.velocity와 Body.angular Velocity에 자극이 추가될 것이다.
  • allowSleep : 만약 true이라면, 몸은 자동적으로 잠이 들 것이다. 잠든다는게 무슨의미인지… 아 그니까 물체가 멈추면 자동으로 잠들것이다. 뭐 이런건 같습니다. sleep 상태로 들어간다?

결국 위에서 떨어질 때 applyImpulse를 통해 충격을 주는 것이 핵심이군요.


6단계. 주사위 값 판별하기

addDiceEvents 를 추가해줍니다.

diceMesh = createDiceMesh();
  for (let i = 0; i < params.numberOfDice; i++) {
    diceArray.push(createDice());
    addDiceEvents(diceArray[i]); // 추가
  }

sleepTimeLimit을 0.02로 설정해줍니다. 그러면 주사위가 멈춘 다음 sleepTime을 더 빨리 체크하기 때문에 좀 더 빠르게 값을 확인할 수 있습니다.

const body = new CANNON.Body({
    mass: 0.3,
    shape: new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)),
    sleepTimeLimit: 0.02,
  });

addDiceEvents는 다음과 같습니다. sleep 이벤트 리스너랑 연관이 되어있습니다.

참고 : https://pmndrs.github.io/cannon-es/docs/classes/Body.html#sleepEvent

sleepEvent라는 것이 존재하는데 주사위가 던지고 나서 allowSleep를 true로 했기 때문에 주사위가 멈추면 자동으로 sleep 상태로 들어가서 해당 이벤트 리스너가 실행되는 형태라고 볼 수 있습니다. 다시말해, 주사위 값 판별을 위해서는 주사위를 던지고 나서 주사위가 멈출 때까지 기다렸다가 완전히 멈추면 확인해야겠죠?

function addDiceEvents(dice) {
  dice.body.addEventListener("sleep", (e) => {
    dice.body.allowSleep = false;

    const euler = new CANNON.Vec3();
    e.target.quaternion.toEuler(euler);

    const eps = 0.1;
    let isZero = (angle) => Math.abs(angle) < eps;
    let isHalfPi = (angle) => Math.abs(angle - 0.5 * Math.PI) < eps;
    let isMinusHalfPi = (angle) => Math.abs(0.5 * Math.PI + angle) < eps;
    let isPiOrMinusPi = (angle) =>
      Math.abs(Math.PI - angle) < eps || Math.abs(Math.PI + angle) < eps;

    if (isZero(euler.z)) {
      if (isZero(euler.x)) {
        showRollResults(1);
      } else if (isHalfPi(euler.x)) {
        showRollResults(4);
      } else if (isMinusHalfPi(euler.x)) {
        showRollResults(3);
      } else if (isPiOrMinusPi(euler.x)) {
        showRollResults(6);
      } else {
        // landed on edge => wait to fall on side and fire the event again
        dice.body.allowSleep = true;
      }
    } else if (isHalfPi(euler.z)) {
      showRollResults(2);
    } else if (isMinusHalfPi(euler.z)) {
      showRollResults(5);
    } else {
      // landed on edge => wait to fall on side and fire the event again
      dice.body.allowSleep = true;
    }
  });
}

function showRollResults(score) {
  if (scoreResult.innerHTML === "") {
    scoreResult.innerHTML += score;
  } else {
    scoreResult.innerHTML += " + " + score;
  }
}

주사위 값을 판별하는게 제일 문제인데 수학적인 요소도 있다보니 좀 어렵습니다.

참고 : https://pmndrs.github.io/cannon-es/docs/classes/Quaternion.html#toEuler

쿼터니언을 오일러 각도 표현으로 변환합니다. 아마 초기 주사위에서 얼만큼 각도가 변경되었는지, 어디로 어떻게 변경되었는지 x, y, z 축을 각도를 기준으로 확인해서 판단하는 거 같긴 합니다.

( 이후 내용 생략...😂 )


마치면서

이번 시간에는 다른 사람의 소스 코드를 보고 분석도 하고 따라해보면서 좀 더 Three.js에 대한 감을 잡고, cannon 물리엔진 사용방법에 대해 익숙해진 거 같습니다. 처음부터 좀 배우고 나서 프로젝트를 해볼 걸 그랬네요. 느낀점이 많습니다.

아무튼 Three.js 되게 재밌네요. 밋밋한 웹 사이트에 3D 요소를 추가해보면 그만큼 실감도 날 거 같고 여러 서비스에서 적용해볼 수 있으니 배워볼만 한거 같습니다.


참고 자료

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

2개의 댓글

comment-user-thumbnail
2023년 8월 25일

주사위 프로젝트 후속글!! 잘 보고 갑니다!
설명이랑 참고링크를 자세히 달아주셔서 많이 도움됩니다!!!
cannon 물리 엔진 사용해서 하는건 강의 들으면서 떠먹여줘도 잘 이해가 안되던데 혼자 찾아서 적용하시는게 대단하십니다 🙉
물리 엔진까지 더해지니까 생동감이 확 사네요
다음 Three.js 글도 기대하겠습니다 ㅎㅎㅎㅎ

1개의 답글