Rapier 는 물리엔진이다.
강체 힘, 속도, 접촉, 제약 조건 등을 계산하는 데 사용할 수 있는 물리 엔진이다.
시간 경과에 따른 물리 속성과 이벤트만 계산하고 싶다면, 우리는 three.js 가 아닌 다른 물리엔진을 사용할 수 있다.
하지만 우리는 THREE.Object3D
의 위치와 quaternion을 지속적으로 업데이트하기 위한 물리엔진을 찾고있고, 그 대안으로 Rapier
를 사용하여 물리 계산에서 얻은 정보를 사용하여 각 프레임 렌더링
사이에 적용하여 지속적으로 update 해줄 것이다.
Rapier physics engine
과 Threejs.Scene
을 동기화 ( synchronizing ) 할 때 THREE.Object3D
와 RAPIER.RigidBody
는 서로 다른 독립적인 객체라는 것을 이해하면 좋다.THREE.Object3D
와 RAPIER.RigidBody
의 차이THREE.Object3D
는 복잡한 Geometry 와 다채로운 Material 이 있는 Mesh 일 수도 있다. 하지만 RAPIER.RigidBody
는 단순한 shape
혹은 collection Shape
여서 각 프레임 사이에 물리 계산이 빠르게 수행되어 부드러운 프레임 속도와 애니메이션 표출을 보장할 수 있다.
우리는 dimforge 팀의 rapier3d 를 받아줄 것이다. 이때 compat( Compatibility package ) ver 을 받아줄 것인데 이는 인라인 웹어셈블리를 base64로 사용하는 호환성 패키지이다.
npm install @dimforge/rapier3d-compat --save-dev
그리고 이 호환성 버전은 반드시 await RAPIER.init()
을 하고 시작해야한다는 것만 기억하면 된다.
rapier3d 0.12.0 버전은 Vite 의 Prodoction 빌드 레벨에서 동작하지 않기 때문이다.
물론 호환(compat)버전은 WASM 바이너를 base64 인코딩된 문자열을 포함하기 때문에 크기가 더 크다.
하지만 대부분의 웹서버는 번들을 압축하고 네트워크를 통해 이동하기 때문에 바이트 단위로 거의 동일한 사이즈가 된다.
혹시 compat 버전을 쓰지 않은 분들을 위해
Rapier WASM binary
를 별도로 받아주면 된다.npm install vite-plugin-wasm --save-dev npm install vite-plugin-top-level-await --save-dev
그리고 마지막으로
vite.config.js
를 다음과 같이 추가해주면 된다.import { defineConfig } from 'vite' import wasm from 'vite-plugin-wasm' import topLevelAwait from 'vite-plugin-top-level-await' export default defineConfig({ plugins: [wasm(), topLevelAwait()], })
그냥 npm 으로 설치하게되면 빌드된 패키지 파일에 로컬 경로를 참조하는 방식으로 설정되어서 문제를 일으킨다.
이런 경우는 yarn, pnpm 을 사용하면 link 와 같은 로컬 참조를 패키지 의존성으로 허용하며, 이를 심볼릭 링크 또는 로컬 캐시로 관리하기 때문에 이러한 문제를 쉽게 해결 가능하다.
pnpm install @dimforge/rapier3d-compat@latest --save-dev
await RAPIER.init() // 호환성 버전이라면 꼭 필요한 코드.
const gravity = new RAPIER.Vector3(0.0, -9.81, 0.0)
const world = new RAPIER.World(gravity)
const dynamicBodies: [THREE.Object3D, RAPIER.RigidBody][] = []
설치된 RAPIER 폴더를 살펴보면 중력을 부여하여 world 를 설정할 수 있다.
이를 활용하여 기본적인 월드를 작성하였다.
const icosahedronMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 0), new THREE.MeshNormalMaterial())
icosahedronMesh.castShadow = true
scene.add(icosahedronMesh)
늘 먹던 맛으로 Mesh 를 생성해준다. 이때 IcosahedronGeometry
의 파라미터로 반지름, 세분화를 값으로 받아준다.
그리고 Material 은 물리엔진 테스트 용으로 Normal 로 설정해주었다.
그리고 그림자를 설정해주고 Scene 에 추가하여 렌더링할 수 있도록 설정하였다.
const icosahedronBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(2, 5, 0).setCanSleep(false))
.createRigidBody()
: 앞서 생성한 world 변수에 물리 엔전에서 사용할 RigidBody(강체)를 생성한다.
.dynamic()
: 함수로 동적 강체를 생성하여 중력, 충돌 등 물리적 영향을 받도록 설정한다.
.setTranslation(x, y, z)
: RigidBody의 초기 위치를 (x, y, z) 로 설정.
.setCanSleep(false)
: 이 RigidBody가 "수면" 상태(비활성화 상태)로 들어가지 않도록 설정.
"수면"은 성능 최적화를 위해 움직이지 않는 객체를 물리 계산에서 제외하는 메커니즘.
이를 true 로 해두면 수동으로 다시 키기 전 까진 외부 물리의 영향을 받지 않음.
const points = new Float32Array(icosahedronMesh.geometry.attributes.position.array)
const icosahedronShape = (RAPIER.ColliderDesc.convexHull(points) as RAPIER.ColliderDesc).setMass(1).setRestitution(1.1)
world.createCollider(icosahedronShape, icosahedronBody)
정이십면체 (icosahedron 이코사헤드론) 메쉬의 지오메트리 데이터를 가져온 후
.ColliderDesc
: 물리 시뮬레이션에서 이 객체의 충돌 영역을 정의 (뒤에 오는 함수에 따라 영역의 모양이 바뀜)
.convexHull()
: 점들의 집합(points)으로부터 볼록 껍질(Convex Hull)을 생성한다.
즉, three.js 의 Mesh 의 모양과 정확히 일치해야 시각적으로 문제없이 동작한다.
.setMass(number)
: 질량 설정
.setRestitution(number)
: 반발 계수(탄성 계수)를 설정하는데 반발 계수가 1 이상이므로, 충돌 시 에너지가 증가하도록 설정하였다.
.createCollider()
: RigidBody 에 Collider 에 추가 우리가 앞서 작성한 shape 와 body 를 설정하여 생성
dynamicBodies.push([icosahedronMesh, icosahedronBody])
RigidBody와 Three.js 메쉬를 동기화
function animate() {
requestAnimationFrame(animate)
delta = clock.getDelta()
world.timestep = Math.min(delta, 0.1)
world.step()
for (let i = 0, n = dynamicBodies.length; i < n; i++) {
dynamicBodies[i][0].position.copy(dynamicBodies[i][1].translation())
dynamicBodies[i][0].quaternion.copy(dynamicBodies[i][1].rotation())
}
controls.update()
renderer.render(scene, camera)
stats.update()
}
animate()
.timestep
: delta
가 너무 크면 물리 계산이 불안정해질 수 있기 때문에 최대값을 0.1 로 제한하여 안정성을 갖도록 한다. 또한 이를 delta 타임으로 계산함으로써 .step
을 주사율만큼 설정하는 것이 아닌, 시간값으로 설정해줘서 어떤 모니터라도 같은 결과를 받아볼 수 있도록 설정하였다.
.step()
: 물리 시뮬레이션을 한 스탭 진행한다. 이때 Rapier.js
는 내부적으로 모든 동적 RigidBody 와 Collider 의 위치와 회전을 계산한다.
.copy()
, .translation()
, .rotation()
: RigidBody 의 계산 결과를 Three.js Mesh 에 Copy
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
renderer.domElement.addEventListener('click', (e) => {
mouse.set(
(e.clientX / renderer.domElement.clientWidth) * 2 - 1,
-(e.clientY / renderer.domElement.clientHeight) * 2 + 1
)
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(
[cubeMesh, sphereMesh, cylinderMesh, icosahedronMesh, torusKnotMesh],
false
)
if (intersects.length) {
dynamicBodies.forEach((b) => {
b[0] === intersects[0].object && b[1].applyImpulse(new RAPIER.Vector3(0, 10, 0), true)
})
}
})
앞서 포스팅한 raycaster 를 바탕으로 추가 코드만 살펴보자면 dynamicBodies
를 돌며 b[0]: Three.Mesh
, b[1]: Rapier.RigidBody
클릭된 객체가 dynamicBodies 배열에 있는지 확인 후 클릭된 RigidBody 에 Y축 방향으로 힘(Impulse)을 가한다.
여기서 두번째 인수인 true 는 wake up 파라미터로 앞서 sleep 상태인 강체를 깨우는 변수이다. 아마 99%는 true 로 사용할 것이다.
import './style.css'
import * as THREE from 'three'
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'
import Stats from 'three/addons/libs/stats.module.js'
import {GUI} from 'three/addons/libs/lil-gui.module.min.js'
import RAPIER from '@dimforge/rapier3d-compat'
await RAPIER.init() // This line is only needed if using the compat version
const gravity = new RAPIER.Vector3(0.0, -9.81, 0.0)
const world = new RAPIER.World(gravity)
const dynamicBodies: [THREE.Object3D, RAPIER.RigidBody][] = []
const scene = new THREE.Scene()
const light1 = new THREE.SpotLight(undefined, Math.PI * 10)
light1.position.set(2.5, 5, 5)
light1.angle = Math.PI / 3
light1.penumbra = 0.5
light1.castShadow = true
light1.shadow.blurSamples = 10
light1.shadow.radius = 5
scene.add(light1)
const light2 = light1.clone()
light2.position.set(-2.5, 5, 5)
scene.add(light2)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 100)
camera.position.set(0, 2, 5)
const renderer = new THREE.WebGLRenderer({antialias: true})
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.VSMShadowMap
document.body.appendChild(renderer.domElement)
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.target.y = 1
// Cuboid Collider
const cubeMesh = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshNormalMaterial())
cubeMesh.castShadow = true
scene.add(cubeMesh)
const cubeBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0).setCanSleep(false))
const cubeShape = RAPIER.ColliderDesc.cuboid(0.5, 0.5, 0.5).setMass(1).setRestitution(1.1)
world.createCollider(cubeShape, cubeBody)
dynamicBodies.push([cubeMesh, cubeBody])
// Ball Collider
const sphereMesh = new THREE.Mesh(new THREE.SphereGeometry(), new THREE.MeshNormalMaterial())
sphereMesh.castShadow = true
scene.add(sphereMesh)
const sphereBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(-2, 5, 0).setCanSleep(false))
const sphereShape = RAPIER.ColliderDesc.ball(1).setMass(1).setRestitution(1.1)
world.createCollider(sphereShape, sphereBody)
dynamicBodies.push([sphereMesh, sphereBody])
// Cylinder Collider
const cylinderMesh = new THREE.Mesh(new THREE.CylinderGeometry(1, 1, 2, 16), new THREE.MeshNormalMaterial())
cylinderMesh.castShadow = true
scene.add(cylinderMesh)
const cylinderBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(0, 5, 0).setCanSleep(false))
const cylinderShape = RAPIER.ColliderDesc.cylinder(1, 1).setMass(1).setRestitution(1.1)
world.createCollider(cylinderShape, cylinderBody)
dynamicBodies.push([cylinderMesh, cylinderBody])
// ConvexHull Collider
const icosahedronMesh = new THREE.Mesh(new THREE.IcosahedronGeometry(1, 0), new THREE.MeshNormalMaterial())
icosahedronMesh.castShadow = true
scene.add(icosahedronMesh)
const icosahedronBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(2, 5, 0).setCanSleep(false))
const points = new Float32Array(icosahedronMesh.geometry.attributes.position.array)
const icosahedronShape = (RAPIER.ColliderDesc.convexHull(points) as RAPIER.ColliderDesc).setMass(1).setRestitution(1.1)
world.createCollider(icosahedronShape, icosahedronBody)
dynamicBodies.push([icosahedronMesh, icosahedronBody])
// Trimesh Collider
const torusKnotMesh = new THREE.Mesh(new THREE.TorusKnotGeometry(), new THREE.MeshNormalMaterial())
torusKnotMesh.castShadow = true
scene.add(torusKnotMesh)
const torusKnotBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(4, 5, 0))
const vertices = new Float32Array(torusKnotMesh.geometry.attributes.position.array)
let indices = new Uint32Array((torusKnotMesh.geometry.index as THREE.BufferAttribute).array)
const torusKnotShape = (RAPIER.ColliderDesc.trimesh(vertices, indices) as RAPIER.ColliderDesc)
.setMass(1)
.setRestitution(1.1)
world.createCollider(torusKnotShape, torusKnotBody)
dynamicBodies.push([torusKnotMesh, torusKnotBody])
const floorMesh = new THREE.Mesh(new THREE.BoxGeometry(100, 1, 100), new THREE.MeshPhongMaterial())
floorMesh.receiveShadow = true
floorMesh.position.y = -1
scene.add(floorMesh)
const floorBody = world.createRigidBody(RAPIER.RigidBodyDesc.fixed().setTranslation(0, -1, 0))
const floorShape = RAPIER.ColliderDesc.cuboid(50, 0.5, 50)
world.createCollider(floorShape, floorBody)
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
renderer.domElement.addEventListener('click', (e) => {
mouse.set(
(e.clientX / renderer.domElement.clientWidth) * 2 - 1,
-(e.clientY / renderer.domElement.clientHeight) * 2 + 1
)
raycaster.setFromCamera(mouse, camera)
const intersects = raycaster.intersectObjects(
[cubeMesh, sphereMesh, cylinderMesh, icosahedronMesh, torusKnotMesh],
false
)
if (intersects.length) {
dynamicBodies.forEach((b) => {
b[0] === intersects[0].object && b[1].applyImpulse(new RAPIER.Vector3(0, 10, 0), true)
})
}
})
const stats = new Stats()
document.body.appendChild(stats.dom)
const gui = new GUI()
const physicsFolder = gui.addFolder('Physics')
physicsFolder.add(world.gravity, 'x', -10.0, 10.0, 0.1)
physicsFolder.add(world.gravity, 'y', -10.0, 10.0, 0.1)
physicsFolder.add(world.gravity, 'z', -10.0, 10.0, 0.1)
const clock = new THREE.Clock()
let delta
function animate() {
requestAnimationFrame(animate)
delta = clock.getDelta()
world.timestep = Math.min(delta, 0.1)
world.step()
for (let i = 0, n = dynamicBodies.length; i < n; i++) {
dynamicBodies[i][0].position.copy(dynamicBodies[i][1].translation())
dynamicBodies[i][0].quaternion.copy(dynamicBodies[i][1].rotation())
}
controls.update()
renderer.render(scene, camera)
stats.update()
}
animate()