Three.js Rapier

강정우·2025년 1월 13일
0

three.js

목록 보기
18/24
post-thumbnail

Rapier

Rapier 는 물리엔진이다.

강체 힘, 속도, 접촉, 제약 조건 등을 계산하는 데 사용할 수 있는 물리 엔진이다.

시간 경과에 따른 물리 속성과 이벤트만 계산하고 싶다면, 우리는 three.js 가 아닌 다른 물리엔진을 사용할 수 있다.

하지만 우리는 THREE.Object3D 의 위치와 quaternion을 지속적으로 업데이트하기 위한 물리엔진을 찾고있고, 그 대안으로 Rapier 를 사용하여 물리 계산에서 얻은 정보를 사용하여 각 프레임 렌더링 사이에 적용하여 지속적으로 update 해줄 것이다.

  • 참고로 Rapier physics engineThreejs.Scene 을 동기화 ( synchronizing ) 할 때 THREE.Object3DRAPIER.RigidBody 는 서로 다른 독립적인 객체라는 것을 이해하면 좋다.

THREE.Object3DRAPIER.RigidBody 의 차이

THREE.Object3D 는 복잡한 Geometry 와 다채로운 Material 이 있는 Mesh 일 수도 있다. 하지만 RAPIER.RigidBody 는 단순한 shape 혹은 collection Shape 여서 각 프레임 사이에 물리 계산이 빠르게 수행되어 부드러운 프레임 속도와 애니메이션 표출을 보장할 수 있다.

install

우리는 dimforge 팀의 rapier3d 를 받아줄 것이다. 이때 compat( Compatibility package ) ver 을 받아줄 것인데 이는 인라인 웹어셈블리를 base64로 사용하는 호환성 패키지이다.

npm install @dimforge/rapier3d-compat --save-dev

그리고 이 호환성 버전은 반드시 await RAPIER.init() 을 하고 시작해야한다는 것만 기억하면 된다.

compat 을 사용하지 않는다면 ( compat 사용 권장 )

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 설치 에러 시

그냥 npm 으로 설치하게되면 빌드된 패키지 파일에 로컬 경로를 참조하는 방식으로 설정되어서 문제를 일으킨다.
이런 경우는 yarn, pnpm 을 사용하면 link 와 같은 로컬 참조를 패키지 의존성으로 허용하며, 이를 심볼릭 링크 또는 로컬 캐시로 관리하기 때문에 이러한 문제를 쉽게 해결 가능하다.

pnpm install @dimforge/rapier3d-compat@latest --save-dev

world 생성

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 메쉬를 동기화

animate

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

interaction

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 로 사용할 것이다.


💻 Rapier code

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()
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글