개요
물리 효과를 구현할 때, RayCast는 특정 지점에서 광선을 쏘아 충돌을 감지하는 기초적인 기술입니다. 예를 들어 캐릭터가 땅을 밟고 있는지, 벽에 얼마나 가까워졌는지를 확인하는 데 사용될 수 있습니다. 하지만 이는 단순한 '감지'에 가깝습니다.
실제와 같은 물리 현상 즉 마찰력(friction), 장력(tension), 탄성/반발력(bouncing), 질량(mass) 등을 종합적으로 시뮬레이션하려면 전문 물리 엔진 라이브러리가 필요합니다. 이 라이브러리들이 복잡한 물리 법칙을 대신 계산해줍니다.
2D vs 3D 물리 라이브러리 선택
프로젝트의 성격에 따라 2D와 3D 라이브러리 중 선택해야 합니다.
- 3D 라이브러리(Ammo.js, Cannon.js, Oimo.js): Z축을 포함한 모든 방향으로의 상호작용이 가능하여 복잡하고 현실적인 시뮬레이션에 적합합니다.
- 2D 라이브러리(Matter.js, P2.js, Box2D.js): 당구나 핀볼 게임처럼 평면적임 움직임에 최적화되어 있어, 더 적은 계산량으로 높은 성능을 낼 수 있습니다.
핀볼 게임인 3D 같은 입체감을 주고, 2D 물리 엔진 기반으로 만들어진 좋은 예시입니다.
예제에서는 Cannon.js를 사용합니다. 가장 널리 쓰이는 Ammo.js는 C++ 코드를 자바스크립트로 변환(포팅)한 것이라 다소 무겁고 복잡할 수 있지만, Cannon.js는 순수 자바스크립트로 작성되어 더 가볍고 문법이 직관적이라 배우기 쉽기 때문입니다.
두개의 분리된 세계
가장 중요한 핵심 이론은 '보이는 세계'와 '계산되는 세계'가 분리되어 있다는 것입니다.
- Three.js 3D 월드: 우리가 눈으로 보는 모든 시각적 요소를 담당합니다. Mesh, Geometry, Material 등으로 구성된 보이는 세계입니다.
- Cannon.js 물리 월드: 눈에 보이지 않지만, 중력, 충돌 등 모든 물리 법칙을 계산합니다. Body들로 구성된 계산되는 세계입니다.
이 두 세계는 독립적으로 존재하며, 이 둘을 연결하고 동기화하는 '접착제' 역할을 하는 코드를 작성해야 합니다. 그 과정은 다음과 같습니다.
- 물리 월드를 만든다(new CANNON.World()).
- Three.js 3D 월드를 만든다.(new THREE.Scene()).
- Three.js 월드에 Mesh를 추가할 때, 그에 상응하는 물리적 속성을 가진 Body를 만들어 물리 월드에도 추가한다.
- 매 프레임마다(애니메이션 루프 안에서) 물리 월드의 시간을 흐르게 하여 Body들의 위치를 계산하고, 그 계산된 위치와 회전값을 3D 월드에 Mesh에 복사하여 화면을 업데이트 한다.
물리 월드 구축 및 객체(Body) 추가
물리 월드 생성과 중력 설정
/**
* Physics
*/
const world = new CANNON.World();
world.gravity.set(0, -9.82, 0); // Y축(아래) 방향으로 중력 적용
먼저 물리 법칙이 적용될 공간인 CANNON.World를 생성합니다. 그리고 world.gravity를 설정하여 중력을 부여합니다. -9.82는 지구의 중력 가속도(m/s^2)값으로, 현실적인 낙하 효과를 줍니다. X와 Z축은 0으로 설정하여 수직으로만 중력이 작용하게 합니다.
물리 객체(Body 생성)
Three.js에서 객체를 Mesh라고 부르듯, Cannon.js에서는 Body라고 부릅니다.
// 1. 모양(Shape) 정의: Three.js의 Geometry와 유사
const sphereShape = new CANNON.Sphere(0.5); // 반지름 0.5
// 2. 바디(Body) 생성 및 속성 부여
const sphereBody = new CANNON.Body({
mass: 1, // 질량. 0이 아니면 중력의 영향을 받음
position: new CANNON.Vec3(0, 3, 0), // 시작 위치
shape: sphereShape // 위에서 정의한 모양 적용
});
// 3. 월드에 바디 추가
world.addBody(sphereBody);
이 sphereBody는 눈에 보이지 않지만, 이제부터 물리 월드 안에서 중력의 영향을 받아 아래로 떨어지기 시작합니다.
두 세계의 동기화
물리 시간 진행시키기: world.step()
물리 월드의 시간을 흐르게 하려면 tick 함수 안에서 world.step()을 호출해야 합니다.
const clock = new THREE.Clock();
let oldElapsedTime = 0;
const tick = () => {
const elapsedTime = clock.getElapsedTime();
const deltaTime = elapsedTime - oldElapsedTime;
oldElapsedTime = elapsedTime;
// ...
// 물리 월드 업데이트
world.step(1 / 60, deltaTime, 3);
// ...
}
world.step()의 세 가지 인자는 매우 중요합니다.
- 1 / 60 (고정된 타임스탬프): 물리 계산을 초당 60번 수행하도록 고정합니다. 이렇게 하면 컴퓨터 성능에 따라 프레임 속도가 변하더라도 물리 효과는 항상 일정하게 유지됩니다.
- deltaTime(이전 프레임과의 시간 간격): 마지막 프레임 이후 얼마나 많은 시간이 흘렀는지 알려줍니다. 만약 프레임이 잠시 끊겼다면(deltatime이 크다면) , 그 시간만큼 물리 변환를 한 번에 계산하여 따라잡습니다.
- 3(최대 반복 횟수): deltaTime이 너무 클 경우, 지연을 따라잡기 위해 내부적으로 스텝을 최대 3번까지 반복할 수 있음을 의미합니다.
결과를 시각 세계에 반영하기
물리 계산이 끝난 후 sphereBody의 위치 값을 Three.js의 sphere(Mesh)에 복사합니다.
// tick 함수 내, world.step() 다음 줄에 추가
sphere.position.copy(sphereBody.position);
이제 화면을 보면 구체가 중력에 의해 아래로 떨어지는 것을 볼 수 있습니다! 하지만 아직 바닥에 물리가 적용되지 않아 화면 밖으로 사라져 버립니다.
충돌 처리와 재질(Material)
정적인 바닥(Floor) Body 만들기
떨어지는 구체를 막아줄 바닥을 물리 월드에도 추가해야 합니다.
// Floor
const floorShape = new CANNON.Plane(); // 무한한 평면 모양
const floorBody = new CANNON.Body();
floorBody.mass = 0; // 질량을 0으로 설정하면 중력의 영향을 받지 않고 움직이지 않음
floorBody.addShape(floorShape);
// Three.js의 바닥 Mesh가 회전되어 있으므로, 물리 Body도 똑같이 회전
floorBody.quaternion.setFromAxisAngle(
new CANNON.Vec3(-1, 0, 0), // x축을 기준으로
Math.PI * 0.5 // 90도 회전
);
world.addBody(floorBody);
- mass: 0: 질량이 0인 객체는 정적(Static) 객체가 되어, 다른 객체와 충돌은 하지만 스스로는 절대 움직이지 않습니다. 땅과 벽이 대표적인 예시입니다.
- quaternion: 3D 회전을 표현하는 방식입니다. Three.js에서 바닥을 rotation.x로 90도 눕혔으니, 물리 Body도 쿼터니언을 사용해 동일하게 눕혀주어야 모양이 일치합니다. setFromAxisAngle은 '(-1, 0, 0) 방향의 x축을 막대기 삼아 90도(PI * 0.5) 돌려라'라는 의미입니다.
이제 구체가 바닥에 닿으면 더 이상 떨어지지 않고 멈춥니다.
탄성과 마찰력 구현하기: Material & ContactMaterial
현재 구체가 바닥에 부딪혀도 통통 튀지 않고 멈추기만 합니다. 이 상호작용을 정의하는 것이 Material입니다. 물리적 상호작용을 정의하는 두 가지 주요 접근법이 있습니다.
- 개별 재질(Material)을 지정하여, 특정 재질끼리 만났을 때의 효과를 구체적으로 정의하는 방법.
- 기본 재질(Default Material)을 지정하여, Scene에 있는 대부분의 객체에 전반적인 물리 효과를 한 번에 적용하는 방법.
두 방법에 대해서 자세히 알아봅시다.
개별 재질로 구체적인 상호작용 정의하기 (Concrete, Plastic)
이 방법은 "플라스틱 공이 콘크리트 바닥에 닿을 때" 와 같이, 각기 다른 재질 간의 상호작용을 세밀하게 제어하고 싶을 때 사용합니다. 마치 게임에서 '기사'는 '궁수'에게 강하고, '궁수'는 '마법사'에게 강한 것처럼, 각 재질 간의 상성 관계를 개별적으로 설정하는 것 같습니다.
// 1. '콘크리트'와 '플라스틱'이라는 두 개의 개별 재질을 만듭니다.
const concreteMaterial = new CANNON.Material('concrete');
const plasticMaterial = new CANNON.Material('plastic');
// 2. '콘크리트'와 '플라스틱'이 만났을 때의 상호작용을 정의하는 ContactMaterial을 만듭니다.
const concretePlasticContactMaterial = new CANNON.ContactMaterial(
concreteMaterial, // 첫 번째 재질
plasticMaterial, // 두 번째 재질
{
friction: 0.1, // 두 재질 사이의 마찰력
restitution: 0.7 // 플라스틱 공이 콘크리트 바닥에서 튀는 정도 (반발력)
}
);
// 3. 이 상호작용 규칙을 물리 월드에 추가합니다.
world.addContactMaterial(concretePlasticContactMaterial);
// 4. Body를 생성할 때, 각각에 맞는 재질을 직접 할당합니다.
// 바닥 Body
const floorBody = new CANNON.Body({
mass: 0,
shape: floorShape,
material: concreteMaterial // 이 바닥은 '콘크리트' 재질이다.
});
world.addBody(floorBody);
// 구체 Body
const sphereBody = new CANNON.Body({
mass: 1,
shape: sphereShape,
position: new CANNON.Vec3(0, 3, 0),
material: plasticMaterial // 이 공은 '플라스틱' 재질이다.
});
world.addBody(sphereBody);
- 장점: 매우 세밀하고 현실적인 시뮬레이션이 가능합니다. '고무와 나무', '얼음과 쇠' 등 다양한 조합의 상호작용을 모두 다르게 설정할 수 있습니다.
- 단점: 재질의 종류가 많아질수록 설정해야 할 ContactMaterial의 조합이 기하급수적으로 늘어나 코드가 복잡해질 수 있습니다.
기본(Default) 재질로 전반적인 상호작용 정의하기
이 방법은 대부분의 객체가 비슷한 방식으로 상호작용하는, 보다 단순한 장면에서 유용합니다. "특별히 지정되지 않는 모든 객체는 서로 부딪히면 이 규칙을 따르라" 는 전역 규칙을 설정하는 것과 같습니다.
// 1. 'default'라는 이름의 재질을 하나만 만듭니다.
const defaultMaterial = new CANNON.Material('default');
// 2. 'default' 재질이 자기 자신과 만났을 때의 상호작용을 정의합니다.
const defaultContactMaterial = new CANNON.ContactMaterial(
defaultMaterial,
defaultMaterial,
{
friction: 0.1,
restitution: 0.7
}
);
// 3. 이 상호작용 규칙을 물리 월드에 추가합니다.
world.addContactMaterial(defaultContactMaterial);
// 4. 월드의 "기본 접촉 재질"로 이 설정을 지정합니다.
// 이 코드가 핵심입니다!
world.defaultContactMaterial = defaultContactMaterial;
// 5. Body를 생성할 때, 모두에게 defaultMaterial을 할당합니다.
// (또는 material을 아예 지정하지 않아도 defaultContactMaterial의 영향을 받게 됩니다)
const floorBody = new CANNON.Body({
mass: 0,
shape: floorShape,
material: defaultMaterial // 기본 재질 할당
});
world.addBody(floorBody);
const sphereBody = new CANNON.Body({
mass: 1,
shape: sphereShape,
position: new CANNON.Vec3(0, 3, 0),
material: defaultMaterial // 기본 재질 할당
});
world.addBody(sphereBody);
- world.defaultContactMaterial: 이 속성은 Cannon.js에게 "만약 두 객체가 부딪혔는데, 그 둘 사이에 특별히 정의된 CantactMaterial이 없다면, 이 기본 설정을 사용해라" 라고 알려주는 역할을 합니다.
- 장점: 코드가 매우 간결하고 관리하기 편합니다. 프로토타이핑이나 간단한 물리 효과 구현에 적합합니다.
- 단점: 모든 객체가 똑같은 마찰력과 반발력을 갖게 되므로, 재질별 특성을 살린 디테일한 표현은 어렵습니다.
두 방법 모두 유용하며, 프로젝트의 복잡성에 따라 선택하면 됩니다.
- 다양한 객체가 등장하고 현실적인 상호작용이 중요하다면 개별 재질 사용
- 빠른 구현과 단순한 물리 효과가 필요하다면 기본 재질을 사용
객체에 힘 가하기
객체를 움직이는 방법에는 여러 가지가 있습니다.
- applyForce(force, worldPoint): 지속적인 힘을 가합니다. (바람, 로켓 추진)
- applyImpulse(impulse, worldPoint): 순간적인 충격을 가합니다. (공을 치거나, 폭발)
- applyLocal...: 객체의 로컬 좌표계를 기준으로 힘을 가합니다. (회전하는 비행기의 정면 방향으로 추진력)
바람 효과
tick 함수 안에서 매 프레임마다 applyForce를 호출하면 지속적인 바람 효과를 낼 수 있습니다.
// tick 함수 내
sphereBody.applyForce(new CANNON.Vec3(-0.5, 0, 0), sphereBody.position);
구체의 중심정(sphereBody.position)에 X축의 음수 방향으로 계속해서 -0.5만큼의 힘을 가합니다.
코드 리펙토링 및 다중 객체 관리
sphere와 box를 쉽게 생성하기 위해 createSphere, createBox 함수를 만듭니다. 이 함수는 Three.js Mesh와 Cannon.js Body를 한 번에 생성하고, 이 둘을 동기화할 목록(objectsToUpdate)에 추가하는 역할을 합니다.
const objectsToUpdate = [];
const createSphere = (radius, position) => {
// Three.js mesh 생성...
// Cannon.js body 생성...
// 동기화 목록에 추가
objectsToUpdate.push({
mesh: mesh,
body: body
});
};
// tick 함수 수정
const tick = () => {
// ...
world.step(1 / 60, deltaTime, 3);
for(const object of objectsToUpdate) {
object.mesh.position.copy(object.body.position);
object.mesh.quaternion.copy(object.body.quaternion); // 회전값도 복사!
}
// ...
};
성능 최적화: Geometry와 Matrial 재사용
많은 객체를 생성할 때, new THREE.SphereGeometry()를 매번 호출하면 비효율적입니다. Geometry와 Material 객체를 한 번만 만들어두고, 새로운 Mesh를 만들 때 재사용하는 것이 성능에 훨씬 좋습니다. scale을 이용해 크기를 조절하면 됩니다.
// 한 번만 생성
const sphereGeometry = new THREE.SphereGeometry(1, 20, 20);
const sphereMaterial = new THREE.MeshStandardMaterial({ /* ... */ });
const createSphere = (radius, position) => {
const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial);
mesh.scale.set(radius, radius, radius); // 스케일로 크기 조절
// ...
};
Box 생성 시 주의점: Half Extents
Box를 만들 때 매우 중요한 점이 있습니다. Three.js의 BoxGeometry는 전체 너비, 높이, 깊이를 인자로 받지만, Cannon.js의 CANNON.Box는 중심으로부터 각 면까지의 거리, 즉 크기의 절반 값(half extents)을 인자로 받습니다.
const createBox = (width, height, depth, position) => {
// ... Three.js Mesh 생성 ...
mesh.scale.set(width, height, depth);
// Cannon.js Shape 생성 시, 크기의 절반 값을 넣어주어야 함!
const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5));
// ...
};
이 부분을 놓치면 Mesh와 계산되는 Body의 크기가 달라 물리 현상이 어색해집니다.
GUI를 이용한 동적 객체 생성 (Sphere, Box)
debugObject에 createSphere 함수를 추가하고, createBox와 마찬가지로 랜덤한 값들을 인자로 넘겨주도록 수정합니다.
const gui = new GUI();
const debugObject = {};
// 1. 랜덤 구체(Sphere) 생성 함수를 debugObject에 추가
debugObject.createSphere = () => {
createSphere(
Math.random() * 0.5, // radius를 0 ~ 0.5 사이의 랜덤 값으로 설정
{
x: (Math.random() - 0.5) * 3, // 랜덤 x 위치
y: 3, // 시작 높이는 3으로 고정
z: (Math.random() - 0.5) * 3 // 랜덤 z 위치
}
);
};
// 2. 랜덤 박스(Box) 생성 함수를 debugObject에 추가
debugObject.createBox = () => {
createBox(
Math.random() * 0.5, // 랜덤 width
Math.random() * 0.5, // 랜덤 height
Math.random() * 0.5, // 랜덤 depth
{
x: (Math.random() - 0.5) * 3, // 랜덤 x 위치
y: 3, // 시작 높이는 3으로 고정
z: (Math.random() - 0.5) * 3 // 랜덤 z 위치
}
);
};
// 3. 두 함수를 모두 GUI에 버튼으로 추가
gui.add(debugObject, 'createSphere');
gui.add(debugObject, 'createBox');
이렇게 하면 lil-gui 패널에 'createSphere'와 'createBox' 버튼이 모두 생성됩니다. 이제 각 버튼을 클릭할 때마다 랜덤한 크기와 위치를 가진 구체와 박스가 동적으로 Scene에 추가되어, 두 종류의 객체에 대한 물리 효과를 편리하게 테스트하고 디버깅 할 수 있게 됩니다.
성능 최적화를 위한 기법
객체가 수십, 수백 개로 늘어나면 CPU에 큰 부하가 걸려 프레임 속도가 떨어집니다. 이를 해결하기 위한 방법입니다.
Broadphase (충돌 감지 최적화)
물리 엔진은 어떤 객체들이 충돌할 가능성이 있는지 미리 검사하는 광역 단계(Broadphase)를 거칩니다.
- NaiveBroadphase(기본값): 가장 비효율적인 방식으로, 모든 객체를 다른 모든 객체와 일일이 비교합니다. (객체 수가 N개일 때 N^2에 비례)
- SAPBroadphase (Sweep and Prune): 권장되는 방식입니다. 특정 축을 따라 모든 객체를 정렬한 뒤, 겹치는 범위에 있는 객체들만 충돌 후보로 간주합니다. 대부분의 상황에서 훨씬 빠르고 효율적입니다.
world.broadphase = new CANNON.SAPBroadphase(world);
SAPBroadphase 는 매우 빠른 객체 등 극단적인 상황에 버그가 발생하며, 대부분의 프로젝트에서는 성능상의 이점이 훨씬 크므로 사용하는 것이 좋습니다.
Sleep (휴면 상태)
움직임이 거의 멈춘 객체는 물리 계산에서 잠시 제외하여 CPU 자원을 아낄 수 있습니다.
world.allowSleep = true;
world.allowSleep을 true로 설정하면, Body의 속도가 특정 값(sleepSpeedLimit) 이하로 일정 시간(sleepTimeLimit) 동안 유지될 때 자동으로 휴면(Sleep) 상태에 들어갑니다. 휴면 상태의 Body는 다른 활성 Body와 충돌하면 즉시 '깨어나(wakeup)' 다시 계산에 포함됩니다. 이는 성능에 매우 큰 도움이 됩니다.
이벤트 기반 상호작용 (소리 추가)
물리 엔진은 특정 상황에 이벤트(Event)를 발생시킵니다. 이를 활용해 상호작용을 만들 수 있습니다.
- collide: 두 Body가 충돌했을 때 발생
- sleep: Body가 휴면 상태에 들어갈 때 발생
- wakeup: Body가 휴면 상태에서 깨어날 때 발생
const hitSound = new Audio('/sounds/hit.mp3');
const playHitSound = (collision) => {
// 충돌의 강도를 가져옴
const impactStrength = collision.contact.getImpactVelocityAlongNormal();
if (impactStrength > 1.5) { // 충격이 일정 세기 이상일 때만
hitSound.volume = Math.min(impactStrength / 10, 1); // 충격 세기에 비례하여 볼륨 조절
hitSound.currentTime = 0; // 사운드를 처음부터 다시 재생
hitSound.play();
}
};
// Body를 생성할 때 'collide' 이벤트 리스너를 추가
body.addEventListener('collide', playHitSound);
단순히 play()만 호출하면, 객체들이 살짞 스치거나 미끄러질 때도 소리가 계속 재생되어 시끄럽습니다. collision 이벤트 객체에 담겨있는 getImpactVelocityAlongNormal() 함수로 충돌 강도를 확인하고, 일정 강도 이상일 때만 소리를 재생하도록 필터링하는 것이 훨씬 좋은 사용자 경험을 제공합니다.
정리 및 추가 발전 방향
Reset 기능 구현
생성된 모든 객체를 제거하는 리셋 버튼을 구현할 때는 제거 순서를 최대한 맞춰주세요.
debugObject.reset = () => {
for(const object of objectsToUpdate) {
// 1. 이벤트 리스너 먼저 제거 (메모리 누수 방지)
object.body.removeEventListener('collide', playHitSound);
// 2. 물리 월드에서 Body 제거
world.removeBody(object.body);
// 3. 3D 월드에서 Mesh 제거
scene.remove(object.mesh);
}
// 4. 추적용 배열 비우기
objectsToUpdate.splice(0, objectsToUpdate.length);
};
이 순서를 지키지 않으면 이미 사라진 객체를 참조하려다 에러가 발생할 수 있습니다.
결론
Cannon.js를 통해 Three.js 물리 시뮬레이션을 단계별로 학습했습니다. Cannon.js를 넘어서 다양한 것들을 시도해보세요.
- 제약 조건 (Constraints): HingeConstraint(경첩), DistanceConstraint(거리 유지) 등을 사용하면 두 Body를 쇠사슬이나 관절처럼 연결하는 더 복잡한 물리 효과를 만들 수 있습니다.
- 웹 워커 (Web Workers): 물리 연산은 CPU를 많이 사용하므로, 이를 별도의 스레드에서 처리하는 웹 워커를 사용하면 메인 스레드의 부하를 줄여 훨씬 부드러운 애니메이션을 구현할 수 있습니다.
- cannon-es 사용하기: 오리지널 Cannon.js는 수년간 업데이트가 중단되었습니다. 커뮤니티에서 이를 개선한 cannon-es를 사용하는 것이 버그 수정, 성능 향상, 최신 자바스크립트 문법 지원 등 모든 면에서 훨씬 좋습니다.
# 기존 cannon 제거
npm uninstall --save cannon
# cannon-es 설치
npm install cannon-es
// import 방식 변경
import * as CANNON from 'cannon-es';
- Git 저장소: https://github.com/pmndrs/cannon-es
- NPM 페이지: https://www.npmjs.com/package/cannon-es
GitHub - pmndrs/cannon-es: 💣 A lightweight 3D physics engine written in JavaScript.
💣 A lightweight 3D physics engine written in JavaScript. - pmndrs/cannon-es
github.com
cannon-es
A lightweight 3D physics engine written in JavaScript.. Latest version: 0.20.0, last published: 3 years ago. Start using cannon-es in your project by running `npm i cannon-es`. There are 62 other projects in the npm registry using cannon-es.
www.npmjs.com
'Three.js > Advanced techniques' 카테고리의 다른 글
[threejs-journey 3-3] Raycaster and Mouse Events (0) | 2025.08.23 |
---|---|
[threejs-journey 3-2] Imported models (0) | 2025.08.20 |