[Three.js journey 강의노트] 20

9rganizedChaos·2021년 7월 9일
6
post-thumbnail

🙌🏻 해당 글은 Three.js Journey의 강의 노트입니다.

20 Physics

Raycaster와 같은 몇 가지 수식과 솔루션을 사용해 자신만의 Physics를 구현할 수도 있지만, 장력, 마찰, 바운싱, 제약 조건, 피벗 등의 사실적인 Physics를 구현하기 위해서는 라이브러리를 사용하는 편이 좋다!

일단, Physics를 구현하기 위한 대략적 아이디어는 다음과 같다!

1) Physics world를 만든다.
2) Three.js엣 무언가를 만들면, Physics world에도 그것이 동일하게 생성된다.
3) 각 프레임에서 무언가를 렌더링하기 전에 Physics world에게 스스로 업데이트 하도록 지시한다.
4) physics object의 좌표를 가져와서 Three.js Mesh에 그것들을 적용한다.
5) 그 이후로는 개발자 각자의 재량이다.

Libraries

우선 3D 라이브러리가 필요한지, 2D 라이브러리가 필요한지 판단해야 한다. Three.js를 쓰고 있기 때문에 당연히 3D 라이브러리를 써야 한다고 생각할 수도 있지만, 그렇지 않다. 2D 라이브러리들이 보통 더 성능이 좋다! 2D 라이브러리를 이용해 Physics를 더 잘 구현할 수 있는 방법도 있다. Three.js로 핀볼 게임을 만든다고할 때, 사실 2D 평면에 모든 걸 투영한다고 상상할 수도 있다. Merci Michel의 Ouigo Let's play는 좋은 예이다.

3D Physics의 주요한 라이브러리

  • Ammo.js
  • Cannon.js
  • Oimo.js

인기있는 2D Physics 라이브러리

  • Matter.js
  • P2.js
  • Planck.js
  • Box2D.js

사실 이 20번째 강의에서는 2D 라이브러리를 사용하지는 않을 것이다. 3D 라이브러리와 거의 유사하지만, 다른 점이 있다면 업데이트 해야하는 축이 다르다는 것 정도이다. 또, Three.js를 Physijs와 같은 라이브러리와 결합하는 솔루션이 있으나, 학습을 위해 이것도 사용하지 않을 것이다. Ammo.js가 가장 많이 사용되는 라이브러리이지만, 우리는 일단 현재 우리의 프로젝트에서 구현하기 쉬운 Cannon.js를 사용할 것이다.

Import Cannon.js

  • npm install --save cannon 실행
  • 캐논 import
import CANNON from 'cannon'

BASE

World (Gravity)

gravity 속성을 통해서 중력을 결정해줄 수 있다. 이를 테면 태양계의 각 행성이 가진 서로 다른 중력을 구현해줄 수 있는 것이다. 이 gravity 속성은 Cannon.js의 Vec3인데, 이는 마치 Three.js의 Vector3와 유사하다. set() 메서드도 사용해줄 수 있다.

const world = new CANNON.World();
world.gravity.set(0, -9.82, 0);

Object

Scene에 이미 구가 있으므로, Cannon.js World에도 구를 생성한다. 그렇게 하기 위해서 우리는 Body라는 것을 만들어야 한다. Body는 다른 Body와 충돌하거나 추락한다. 물론 Body의 모양도 결정해주어야 한다. 우리는 일단 현재 구현된 구와 같은 Shpere를 선택한다.

const sphereShape = new CANNON.Sphere(0.5);

const sphereBody = new CANNON.Body({
  mass: 1,
  position: new CANNON.Vec3(0, 3, 0),
  shape: sphereShape,
});
world.addBody(sphereBody);

이걸 만들어주어도 아직은 아무 일이 일어나지 않는다. 아직 Cannon.js도 Three.js sphere도 업데이트해주지 않았다.

Update the Cannon.js world and the Three.js scene

업데이트를 위해서 우리는 step() 메서드를 사용해주어야 한다. 해당 메서드의 코드에 대해 자세하게 설명하지는 않을 것이지만 이 기사에서 심층적 이해를 도모할 수 있다.

우선 step() 메서드를 사용해주기 위해서 고정된 time step이 필요하다. 또한 마지막 step으로부터 얼마나 많은 시간이 플렀는지, 잠재적 딜레이를 따라집기 위해 world는 얼만큼의 반복을 적용할 수 있는지의 정보도 필요하다. time step에 대해서도 심층적이게 다루지는 않지만 우리는 60fps을 원하기 떄문에 1/60을 사용한다. 프레임 속도가 더 높거나 낮아도 동일하게 출력될 것이다. 반복횟수는 그다지 중요하지 않다. 3을 사용할 것이다. Delta time은 조금 어렵다. 우리는 마지막 프레임으로부터 얼마나 시간이 흘렀는지를 계산해야 한다. Clock class의 getDelta() 메서드는 사용치 않는 것이 좋다. 제대로 Delta time을 구하려면 직전 프레임부터 현재의 elapedTime까지에서 elapedTime을 빼준다.

const clock = new THREE.Clock()
let oldElapsedTime = 0

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - oldElapsedTime
    oldElapsedTime = elapsedTime

    // Update physics
    world.step(1 / 60, deltaTime, 3)
    // step은 업데이트 해주는 메소드
    console.log(sphereBody.position.y)
  
    // cannon.js world에 있는 값으로 Three.js의 sphere 값을 업데이트
    // sphere.position.x = sphereBody.position.x
    // sphere.position.y = sphereBody.position.y
    // sphere.position.z = sphereBody.position.z
  
    sphere.position.copy(sphereBody.position)
}

이제 생기는 문제는 구가 하늘에서 중력을 받아 떨어지기는 하지만, 바닥을 뚫고 들어가버린다는 것이다. 왜 이런 문제가 생기냐 하면! Three.js 세계에는 바닥이 있지만, Cannon.js 세계에는 없기 때문이다! Plane shape를 이용해서 간단하게 새로운 body를 만들어주어도 된다. 하지만 바닥이 중력과 낙하의 영향을 맏기를 원치 않는다. 바닥이 정적이길 원한다는 것이다. mass를 0으로 만들어주면 된다!

// Floor
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.addShape(floorShape);
world.addBody(floorBody);

갑자기 구체가 이상한 곳으로 점프하는 모습을 확인할 수 있다. 우리가 의도했던 건 이게 아니다. 우리가 만든 plane이 기본적으로 카메라를 행하고 있기 떄문이다. Three.js에서 바닥을 회전한 것처럼 회전해주어야 한다. 기본적으로 plane을 만들면 서있는 색종이 모양으로 만들어진다는 것을 기억하자!

Cannon.js에서 회전을 해줄 때는 Quaternion을 사용해야 한다. 그래서 Three.js보다 더 어렵다. 여기서는 setFromAxisAngle()을 사용할 것이다. 첫 번째 매개변수는 축, 두번째 매개변수는 각도이다.

floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5) 

이제 구가 제대로 바닥을 향해 떨어지는 모습을 확인할 수가 있다.

Contact material

만약 공을 튀게 하고 싶다면 어떻게 해야 할까. Material을 건드려줘야 한다. Material은 사실 그저 reference일 뿐이다. 변수에 할당하고 Body와 연결해주면 된다. scene에 있는 모든 타입의 material에 Material을 만들어준다는 것이다.

Material을 만들어줄 때는 Cannon.Material을 인스턴스화한다. 이 때 첫 두 매개변수는 재료, 세 번째 매개변수는 두 가지 중요한 속성을 포함하는 객체이다. 두 가지 중요한 속성은 각각 마찰계수-얼마나 마찰이 발생하는지-와 반발-얼마나 튀어오르는지-계수이다. 둘다 기본 값은 0.3! 생성하고나면 addContactMaterial 메서드를 통해 world에 추가해준다!

Body에 Material을 적용할 때는, 인스턴싱할 때 바로 전달해줄 수도 있고 후에 적용해줄 수도 있다.

바닥을 뚫고 들어가지도, 떨어지자마자 멈추지도 않고, 공처럼 튀는 모습을 확인할 수 있다.

const concreteMaterial = new CANNON.Material('concrete')
const plasticMaterial = new CANNON.Material('plastic')

// 두 개의 Material이 부딪혔을 때 충돌과 마찰을 설정!
const concretePlasticContactMaterial = new CANNON.ContactMaterial(
    concreteMaterial,
    plasticMaterial,
    {
        friction: 0.1,
        restitution: 0.7
    }
)
world.addContactMaterial(concretePlasticContactMaterial)

// 각 Body에 Material 적용!
const sphereBody = new CANNON.Body({
    // ...
    material: plasticMaterial
})

// ...

const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial

튀기는 것은 잘 확인할 수 있지만, 공이 보통은 공중에 떠 있으므로 마찰에 대해서는 작용을 확인하기 힘들다. 우선 쉽게 실습하기 위해 모든 Material을 defaultMaterial로 교체해준다!

const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
        friction: 0.1,
        restitution: 0.7
    }
)
world.addContactMaterial(defaultContactMaterial)

// ...

const sphereBody = new CANNON.Body({
    // ...
    material: defaultMaterial
})

// ...

floorBody.material = defaultMaterial


world.defaultContactMaterial = defaultContactMaterial

Apply forces

force는 반드시 Body 표면에 있는 것이 아님.

  • applyForce는 공간의 특정 지점에서부터 Body에 힘을 가한다. 마치 바람 처럼. 혹은 도미노나 앵그리버드처럼.
  • applyImpulse는 applyForce와 비슷하지만, 속도를 변경하는 힘을 더하는 대신, 직접적으로 바로 속도를 더한다.
  • applyLocalForce는 applyForce와 같다. 하지만 좌표가 Body의 로컬이다. (0, 0, 0)이 Body의 중심이 되는 것.
  • applyLocalImpulse는 applyImpulse와 같지만, 로컬인 것!

force메서드를 사용하면 속도 변화가 일어나기 때문에, impulse 메서드는 따로 사용해주지 않는다.
아래와 같이 코드를 작성해주면, 구체가 위에서 아래로 떨어지는 것이 아니라 약간 옆으로 힘을 받아서 떨어지는 것을 확인할 수 있다. 오른쪽으로 튕겨서 굴러가게 된다.

sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))

이번에는 applyForce() 메서드를 사용해 바람을 적용한다. 바람은 영구적이기 때문에 World를 업데이트하기 전에 힘을 각 프레임에 적용해야 한다. 그리고 힘이 올바르게 작용하려면 점이 sphereBody.position이어야 한다.

이제 오른쪽으로 계속 튕겨서 나가는 것이 아니다. 왼쪽에서 바람이 계속 불고 있기 때문에, 오른쪽으로 튕기는 폭도 좁고 결국 위에서 떨어진 힘이 소진되고 나면 왼쪽으로 굴러가게 된다.

Handle multiple objects

object를 한두 개 컨트롤하는 것은 쉽지만, 엄청 많아질 경우 이것들을 자동화할 필요가 있다.

Automate with functions

Three.js와 Cannon.js를 동시에 포함하는 함수로 구를 만들어줄 수 있다.

const createSphere = (radius, position) => {
  // Three.js mesh
  const mesh = new THREE.Mesh(
    new THREE.SphereGeometry(radius, 20, 20),
    new THREE.MeshStandardMaterial({
      metalness: 0.3,
      roughness: 0.4,
      envMap: environmentMapTexture,
    })
  );
  mesh.castShadow = true;
  mesh.position.copy(position);
  scene.add(mesh);

  // Cannon.js body
  const shape = new CANNON.Sphere(radius);

  const body = new CANNON.Body({
    mass: 1,
    position: new CANNON.Vec3(0, 3, 0),
    shape: shape,
    material: defaultMaterial,
  });
  body.position.copy(position);
  world.addBody(body);
};

createSphere(0.5, { x: 0, y: 3, z: 0 });

위와 같이 특정 공간에 떠 있는 구체를 확인할 수 있다. 위치값은 Vector3 혹은 Vec3일 필요가 없고, x, y, z 속성이 있는 객체를 사용하면 된다.

Use an array of objects

이 부분을 처리하기 위해 우리는 업데이트 해야하는 모든 Object를 담은 배열을 만들준다. 그리고 객체 내부에 새로 생성된 Mesh와 Body를 해당 배열에 추가한다.

const objectsToUpdate = []

const createSphere = (radius, position) =>
{
    // ...

    // Save in objects to update
    objectsToUpdate.push({ mesh, body })
}

// ...


const tick = () =>
{
    // ...

    world.step(1 / 60, deltaTime, 3)

    for(const object of objectsToUpdate)
    {
        object.mesh.position.copy(object.body.position)
    }
}

다시 통통 튀며 위에서 아래로 떨어지는 것을 확인할 수 있다.

Add to Dat.GUI

Dat.GUI에 createSphere 버튼을 추가하고, createSphere버튼을 클릭해준다.

const gui = new dat.GUI();
const debugObject = {};

debugObject.createSphere = () => {
  createSphere(0.5, { x: 0, y: 3, z: 0 });
};

gui.add(debugObject, "createSphere");

엉뚱하게도 새로 생긴 구체들이 계속해서 같은 지점에서 통통 튀며 쌓아는 모습을 확인할 수 있다.
자연스럽게 연출되도록 randomness를 추가해준다.

debugObject.createSphere = () =>
{
    createSphere(
        Math.random() * 0.5,
        {
            x: (Math.random() - 0.5) * 3,
            y: 3,
            z: (Math.random() - 0.5) * 3
        }
    )
}

지금 이 코드는 최적화된 코드가 아니고, 컴퓨터를 태워먹고 싶지 않다면 너무 많이 create하지않는 것이 좋다.

Optimize

geometry와 Three.js Mesh의 material이 동일하기 때문에 우리는 createSphere 함수로부터 그것들을 꺼내와도 좋다. 문제는 우리가 우리의geometry를 생성하는 과정에서 radius를 이용하고 있다는 것이다. 쉬운 해결책은 radius를 1로 고정하고 Mesh의 크기를 나중에 따로 조절하는 것이다.

const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture
})
const createSphere = (radius, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)
    mesh.castShadow = true
    mesh.scale.set(radius, radius, radius)
    mesh.position.copy(position)
    scene.add(mesh)

    // ...
}

Add boxes

box를 이용할 때는 매개변수가 조금 다르다는 것에 주의해야 한다. Box Shape는 halfExtents를 인자로 받는다. 상자의 중심에서 시작해 모서리중 하나를 연결하는 세그먼트에 해당하는 Vec3를 나타낸다.

// Create box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture
})
const createBox = (width, height, depth, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(boxGeometry, boxMaterial)
    mesh.scale.set(width, height, depth)
    mesh.castShadow = true
    mesh.position.copy(position)
    scene.add(mesh)

    // Cannon.js body
    const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))

    const body = new CANNON.Body({
        mass: 1,
        position: new CANNON.Vec3(0, 3, 0),
        shape: shape,
        material: defaultMaterial
    })
    body.position.copy(position)
    world.addBody(body)

    // Save in objects
    objectsToUpdate.push({ mesh, body })
}

createBox(1, 1.5, 2, { x: 0, y: 3, z: 0 })

debugObject.createBox = () =>
{
    createBox(
        Math.random(),
        Math.random(),
        Math.random(),
        {
            x: (Math.random() - 0.5) * 3,
            y: 3,
            z: (Math.random() - 0.5) * 3
        }
    )
}
gui.add(debugObject, 'createBox')

상자가 떨어지고나서 이상하게도 옆으로 조금 튀며 이동하는 것을 확인할 수 있다. Mesh가 아직 회전되어 있지 않기 때문에 일어나는 일이다. position에서 했던 것과 동일하게 Body의 quaternion을 Mesh에 복사해오는 것으로 문제를 해결할 수 있다.

    for(const object of objectsToUpdate)
    {
        object.mesh.position.copy(object.body.position)
        object.mesh.quaternion.copy(object.body.quaternion)
    }

이제 상자가 생각한대로 적절하게 떨어지는 것을 확인할 수 있다. 이제 구체와 상자를 동시에 떨어뜨릴 수 있다!

Performance

Broadphase

Object간의 충돌을 테스트할 때, 나이브한 접근방식은 모든 Body에 대한 충돌을 테스트하는 것이다. 이것은 쉽지만, 성능 측면에서 너무 소모적인 선택이다. 그래서 Broadphase가 등장한다. Bodyphase는 Body를 테스트하기 전에 대략적인 분류를 수행한다. 서로 충돌하지 않는 Object들까지 테스트할 필요없다. Cannon.js에는 총 3개의 broadphase 알고리즘이 있다.

  • NaiveBroadphase: 모든 Body를 다른 모든 Body와 견주어 테스트한다.
  • GridBroadphase: 세계를 4분할하고 동일한 상자에 있는 Body들끼리만 테스트한다.
  • SAPBroadphase: 여러 step동안 임의의 축에서 Body를 테스트한다.
world.broadphase = new CANNON.SAPBroadphase(world)

Sleep

개선된 broadphase 알고리즘을 사용하더라도, 더 이상 움직이지 않는 모든 Body를 테스트한다. 우리는 Body에 충분히 힘이 가해지지 않으면 Body fall asleep하게 해 테스트되지 않도록 해줄 수 있다.

world.allowSleep = true

Events

우리는 또한 Body에 이벤트를 먹여줄 수 있다. 물체가 충돌할 때 소리를 재생하거나 발사체가 적에게 닿았는지 알고 싶을 때 유용하다. 'collide', 'sleep' 또는 'wakeup'과 같은 이벤트가 있다. 충돌할 때 사운드를 구현해보자! collide 이벤트를 수신하고 playHitSound 함수를 콜백으로 사용해주자!

/**
 * Sounds
 */
const hitSound = new Audio('/sounds/hit.mp3')

const playHitSound = () =>
{
    hitSound.play()
}

const createBox = (width, height, depth, position) =>
{
    // ...

    body.addEventListener('collide', playHitSound)

    // ...
}

이제 소리가 추가되었지만, 여러 상자가 추가되면 소리가 이상하게 들리기 시작한다. 첫 번째 문제는 사운드가 재생되는 동안 hitSound.play()를 호출하면 이미 재생중이기 떄문에 아무 일도 일어나지 않는다. currentTime 속성을 이용해 사운드를 0으로 재설정해 문제를 해결한다.

const playHitSound = () =>
{
    hitSound.currentTime = 0
    hitSound.play()
}

두 번째 문제는 물체끼리 아주 조금만 닿아도 소리가 똑같이 출력된다는 것이다. 충격의 크기를 감지해야 해 소리에 반영해야 한다. 충격 강도를 인지하려면 먼저 충돌에 대한 정보를 얻어야 한다. collide 콜백에 매개변수를 추가해 이를 수행할 수 있다. 거기에 약간의 randomness를 적용해주자!

const playHitSound = (collision) => {
    const impactStrength = collision.contact.getImpactVelocityAlongNormal()

    if(impactStrength > 1.5)
    {
        hitSound.volume = Math.random()
        hitSound.currentTime = 0
        hitSound.play()
    }
};

여기서 더 나아가려면 여러 가지 사운드를 추가해줄 수 있다. 동시에 너무 많은 사운드가 재생되는 것을 방지하려면 사운드가 한 번 재생된 후 다시 재생할 수 없는 매우 짧은 딜레이를 추가해준다. (이것은 직접 시도해보기)

Remove things

리셋버튼을 추가해주자!

// Reset
debugObject.reset = () => {
  for (const object of objectsToUpdate) {
    // Remove body
    object.body.removeEventListener("collide", playHitSound);
    world.removeBody(object.body);

    // Remove mesh
    scene.remove(object.mesh);
  }
};
gui.add(debugObject, "reset");

Go further with Cannon.js

Cannon.js에서 시도해볼 수 있는 더 많은 feature들

Constraints

두 Body사이의 제약조건

  • HingeConstraint: door hinge와 같은 제약
  • DistanceConstraint: 일정한 거리를 유지하도록 함
  • LockConstraint: 한 조각인 것처럼 두 body를 합침
  • PointToPointConstraint: 특정 포인트에 body를 붙임

Workers

Physics 시뮬레이션을 실행하는 데 시간이 걸린다. CPU가 동일 스레드에서 모든 것을 계산하는데, 계산할 양이 너무 많아지면 프레임 속도가 늦어질 수 있다. 이 때 worker를 이용하도록 한다. worker를 사용하면 코드의 일부를 다른 스레드에 넣어 부하를 분산할 수 있다. 이 때 코드를 명확히 분리하도록 해야 한다.

Cannon-es

cannon.js는 지난 몇 년동안 업데이트 되지 않았다. 더 나은 관리 버전의 cannon.js를 써주기 위해서는 cannon-es를 사용하면 된다. 인스톨하고 임포트해서 사용하면 된다.

Ammo.js

cannon.js 대신 ammo.js를 써주어도 된다. cannon.js보다 어렵기는 하지만, 다음과 같은 기능들을 이용해볼 수 있다.

  • a portage of Bullet
  • WebAssembly 지원
  • 퍼포먼스가 더 좋다.

Physijs

Ammo.js를 사용한다.
Three.js Object와 physics object를 동시에 만들어줄 수 있다.

box = new Physijs.BoxMesh(
    new THREE.CubeGeometry(5, 5, 5),
    new THREE.MeshBasicMaterial({ color: 0x888888 })
)
scene.add(box)

매력적이게 보일 수 있지만, 디버깅이 힘들다.

profile
부정확한 정보나 잘못된 정보는 댓글로 알려주시면 빠르게 수정토록 하겠습니다, 감사합니다!

2개의 댓글

comment-user-thumbnail
2021년 8월 30일

안녕하세요, 같은 강의에 관심이 있어서요. 이 프랜치개발자 강의는 어떤가요? 수강할까 고민중이라서요

답글 달기
comment-user-thumbnail
2023년 2월 10일

안녕하세요
three.js 파티클 구현에 관해 외주관련 문의 드릴 수 있을까요?
jaysonbyslash@gmail.com 로 연락처 주시면 연락드리겠습니다.
감사합니다.

답글 달기