[Three.js] To infinity and beyond! 3D 웹 도전기 #8
⚠️ 1분 코딩님의 강의를 바탕으로 작성한 글입니다.
Three.js에 물리엔진 기능 추가하기(ft. cannon.js)
cannon.js는 10-11년 동안 업데이트가 한 번도 안 된 레거시한 라이브러리였다. 이에 그나마 1년에 한 번씩이지만 업데이트가 이루어지고 있는... cannon-es를 활용하여 물리엔진을 구현하는 방법에 대해 배울 수 있었다. ChatGPT에 물리엔진에 관해 질문해도 해당 라이브러리를 활용하여 설명해 준다.
당연하게도 외부 라이브러리이기 때문에 설치가 필요하다.
npm i cannon-es
자세한 사항은 해당 라이브러리의 깃헙을 참조하자.
1. 물리 엔진이 동작할 공간/무대 만들기 (a.k.a Scene of Three.js)
const cannonWorld = new CANNON.World();
2. 중력 작용 기능 추가하기
Y축 방향으로 중력 작용이 일어나도록 기능을 추가한다. 지구의 중력 가속도는 -9.82이다.
cannonWorld.gravity.set(0, -10, 0);
3. 오브젝트 만들기
중력을 받을 물체와 그 물체가 떨어져서 닿을 바닥면을 만들어주어야 한다.
이때 cannon.js와 three.js를 각각 만들어주어야 하는데 이는 물리 엔진 역할을 해주는 cannon.js의 body는 눈에 보이지 않고, 단순히 계산된 물리적 결과를 three.js의 mesh에 전달하여 물리적 변화를 현실감 있게 표현하는 원리이기 때문이다.
우선, three.js의 오브젝트부터 만들어보자.
바닥면은 plane으로 만들 수 있으며, rotation.x를 해주는 이유는 plane을 눕히기 위함이다.
const floorMesh = new THREE.Mesh(
new THREE.PlaneGeometry(10, 10),
new THREE.MeshStandardMaterial({
color: 'slategray'
})
);
floorMesh.rotation.x = -Math.PI / 2;
scene.add(floorMesh);
떨어질 임의의 물체도 만들어준다. 이때 position.y에 0.5를 해주는 이유는 0 위치에 있는 plane에 물리기 때문이다. 때문에 boxMesh가 온전히 제자리에 위치하려면 자신의 절반만큼 올라온 위치에 놓아야 물체가 바닥 위에 있게 된다.
const boxGeometry = new THREE.BoxGeometry(0.5, 5, 0.5);
const boxMaterial = new THREE.MeshStandardMaterial({
color: 'seagreen'
});
const boxMesh = new THREE.Mesh(boxGeometry, boxMaterial);
boxMesh.position.y = 0.5;
scene.add(boxMesh);
다음 cannon.js에 필요한 오브젝트를 만들어주겠다.
코드 작성에 들어가기 전에 용어를 살짝 정리하고 넘어가 보고자 한다. three.js에서 사용되는 'geometry'는 cannon.js에서 'shape'이라는 명칭으로 비슷한 역할을 수행하고 있으며, ' quaternion'이라는 단어는 rotation과 같은 기능을 한다.
현재 모든 오브젝트가 중력 작용을 받고 있는데, 이는 바닥면 또한 물체와 똑같이 떨어지게 된다는 것이다. 당연하게도 바닥은 고정되어 있어야 한다(현재 Y축으로 -10으로 낙하되도록 설정되어 있다). 때문에 중력 작용을 안 받도록 mess 값에 0을 할당한다.
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body({
mass: 0,
position: new CANNON.Vec3(0, 0, 0),
shape: floorShape
});
floorBody.quaternion.setFromAxisAngle(
new CANNON.Vec3(-1, 0, 0),
Math.PI / 2
);
cannonWorld.addBody(floorBody);
물리엔진이 적용된 물체의 움직임도 정의해 보자. 우선 떨어지는 동작을 원하기 때문에 낙하지점을 설정해주어야 한다. mass는 질량을 나타내는 데, 할당한 값이 클수록 물체의 무게가 무겁다는 것을 의미한다. 예로 들어 1을 주게 되면 질량이 작기 때문에 같은 힘을 받아도 더 큰 가속도를 받거나, 더 큰 반동을 일으킨다. 때문에 mass에 더 큰 값을 줄수록 변화가 적고 충격 흡수를 잘한다. shape에는 도형의 형태를 전달해 주면 된다.
setFromAxisAngle: 축(첫 번째 인자)과 각도(두 번째 인자)를 전달 받는다.
const boxShape = new CANNON.Box(new CANNON.Vec3(0.25, 2.5, 0.25));
const boxBody = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 10, 0),
shape: boxShape
});
cannonWorld.addBody(boxBody);
three.js에서 box를 크기를 정의할 때는 가로, 세로, 깊이를 숫자로 전달하였는데, cannon.js에서는 중심을 기준으로 동작하기 때문에 box geometry의 절반 값을 할당해야 한다.
4. draw 함수에 연결시키기 (a.k.a 화면에 그리기)
let cannonStepTime = 1/60; // 초당 60 프레임
if (delta < 0.01) cannonStepTime = 1/120;
cannonWorld.step(cannonStepTime, delta, 3);
시간 단계를 세팅한다. 첫 번째 인자는 '몇 분의 1초 간격으로 갱신을 할 것인가'를 나타내며, 두 번째 인자는 성능 보장을 위한 시간 차를, 마지막 인자는 잠재적인 동작 지연에 대비하여 몇 번의 시도를 할 것인지에 대해 숫자 값을 전달한다.
주사율이 두 배인 화면에서 성능 활용이 불가능하기 때문에 유동적으로 대응하기 위해 조건문을 추가하였다.
5. cannon.js의 body 위치를 mesh가 따라가도록 만들기
boxMesh.position.copy(boxBody.position); // 위치
boxMesh.quaternion.copy(boxBody.quaternion); // 회전
물리엔진을 적용하고자 하는 mesh에 body를 전달하면, 이를 복사하여 정의한 물리엔진을 따라갈 것이다.
Cannon.js으로 재질에 따른 마찰력과 반발력 주기
1. Contact Material 등록하기
어떤 재질로, 얼마큼의 마찰과 반발을 줄 것인지 등록한다.
const defaultMaterial = new CANNON.Material('default');
const rubberMaterial = new CANNON.Material('rubber'); // 고무
const ironMaterial = new CANNON.Material('iron'); // 철
const defaultContactMaterial = new CANNON.ContactMaterial(
defaultMaterial,
defaultMaterial,
{
friction: 0.5,
restitution: 0.3
}
);
cannonWorld.defaultContactMaterial = defaultContactMaterial;
const rubberDefaultContactMaterial = new CANNON.ContactMaterial(
rubberMaterial,
defaultMaterial,
{
friction: 0.5,
restitution: 0.7
}
);
cannonWorld.addContactMaterial(rubberDefaultContactMaterial);
const ironDefaultContactMaterial = new CANNON.ContactMaterial(
ironMaterial,
defaultMaterial,
{
friction: 0.5,
restitution: 0
}
);
cannonWorld.addContactMaterial(ironDefaultContactMaterial);
첫 번째, 두 번째 인자로 부딪힐 Material을 넣어준다. 세 번째 인자에는 속성을 정의해 줄 수 있다. friction은 마찰의 정도, restitution은 반발의 정도를 나타낸다.
2. Contact Material 동작시키기
위에서 서술했지만, 다시 언급하자면, cannon은 우리 눈에 보이진 않지만 body라는 공간에 자신이 원하는 물리 엔진을 Three.js mesh에 적용하여 생동감 있는 액션을 표현하는 것이다. 그 말은 즉, contact material도 body에 연결해 주어야 화면상으로 볼 수 있게 되는 것이다.
앞서 오브젝트 만들기에서 정의했던 코드에서 material만 추가해 주면 된다. 각 mesh에 원하는 material를 전달해 주면 된다. (첫 번째 챕터에서는 긴 막대 오브젝트였지만, 현재는 공 모양의 오브젝트로 변경됨.)
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body({
mass: 0,
position: new CANNON.Vec3(0, 0, 0),
shape: floorShape,
material: defaultMaterial
});
...
cannonWorld.addBody(floorBody);
const sphereShape = new CANNON.Sphere(0.5);
const sphereBody = new CANNON.Body({
mass: 1,
position: new CANNON.Vec3(0, 10, 0),
shape: sphereShape,
// material: rubberMaterial
material: ironMaterial
});
cannonWorld.addBody(sphereBody);
적용 결과를 예측한다면, 첫 번째 인자는 바닥 역할을 해주는 오브젝트에 적용되어 기본 재질을 가지게 되고, 두 번째 인자는 공 모형의 오브젝트에 적용되어 고무 또는 철 재질을 가지게 될 것이다. 그리고 두 사물이 부딪힐 때 설정 값에 따라 효과가 나타날 것이다.
사물에 힘 전달 시키기
1. 이벤트 적용하기
힘을 전달시키면 동작할 사물에 이벤트를 먼저 연결해 준다.
canvas.addEventListener('click', () => {
if (preventDragClick.mouseMoved) return;
sphereBody.applyForce(new CANNON.Vec3(-500, 0, 0), sphereBody.position);
});
applyForce 메서드에 힘을 3D 형태로 전달해주어야 한다. 첫 번째 인자에 힘의 방향과 크기를 Vector3로 값을 준다. 숫자 값이 클수록 힘의 크기가 커지는 것이다. 두 번째 인자는 힘이 적용될 곳을 정의한다.
이벤트를 실행시켜 보면 카메라 방향을 바꾸는 동작에도 applyForce가 실행되기 때문에 이전 게시물에서 정의했던 드래그 클릭 방지 기능을 적용시켜 드래그 시에는 함수가 종료되도록 해주었다.
단순히 applyforce만 정의하여 실행시켜 보면 힘이 누적되어 클릭할 때마다 강도가 점점 심하게 적용되는 현상이 있다. 만약 이 같은 현상을 원하지 않는다면 속도를 0으로 만들어주어 해결할 수 있다.
canvas.addEventListener('click', () => {
if (preventDragClick.mouseMoved) return;
sphereBody.velocity.x = 0;
sphereBody.velocity.y = 0;
sphereBody.velocity.z = 0;
sphereBody.angularVelocity.x = 0;
sphereBody.angularVelocity.y = 0;
sphereBody.angularVelocity.z = 0;
sphereBody.applyForce(new CANNON.Vec3(-500, 0, 0), sphereBody.position);
});
힘이 전달된 후, 사물이 점점 속도가 느려지면서 결국에는 멈추는 기능도 추가해 보도록 하겠다.
화면에 그려지는 모든 현상들은 draw 함수에서 실행되고 있기 때문에 해당 함수에서 velocity(속도)를 제어해 주는 기능을 구현해주어야 한다.
function draw() {
// 생략
sphereBody.velocity.x *= 0.98;
sphereBody.velocity.y *= 0.98;
sphereBody.velocity.z *= 0.98;
sphereBody.angularVelocity.x *= 0.98;
sphereBody.angularVelocity.y *= 0.98;
sphereBody.angularVelocity.z *= 0.98;
...
}
0을 전달하면 멈추기 때문에 1보다 작은 수를 곱해 누적이 될수록 0에 수렴하게 되어 멈추게 되도록 해주는 방식이다.
성능 최적화하기
cannon에서 관리하는 객체가 늘어난다는 의미는 그만큼 연산이 늘어난다는 것을 의미하며 이는 컴퓨터에 부담을 주는 일이다. 때문에 성능 관리를 고려해야 한다.
cannonWorld.allowSleep = true;
cannonWorld.broadphase = new CANNON.SAPBroadphase(cannonWorld);
- allowSleep: CANNON.World 객체에서 물리 객체들이 일정 시간 동안 움직임이 없거나 매우 미세한 움직일 경우에는 '휴먼 상태(sleeping)'로 들어가는 것에 대한 여부를 제어하는 메서드이다. false로 설정하면 물리 객체가 계속 계산된다.
- broadphase: 모든 물체 간의 충돌 검사 전, 'Broad Phase" 알고리즘에 따라 물체들을 그룹화하여 충돌 검사 범위를 줄이는 역할을 한다. 특히 Sweep and Prune 방식은 물체의 위치를 기반으로 정렬하여 충돌 검사 범위를 줄이고 성능을 향상해 준다. 즉, 물체의 위치가 변경될 때만 정렬을 업데이트하여 효율적인 충돌 검사를 수행한다.