말 그대로 디버깅 하기위해 존재한다.
Geometry에 맞게 물리 도형이나 colliders를 만들려고 할 때, dimensions, positions, rotations 을 실제로 시각화할 수 없기 때문에 만들기가 까다롭다.
다행히 Rapier 에는 그래픽 렌더러가 없지만 색상을 사용하여 선을 그리는 데 사용할 수 있는 shape data 를 제공해준다.
const { vertices, colors } = world.debugRender()
vertices
: 디버그 선을 나타내는 정점 데이터 (Float32Array)
colors
: 각 정점의 색상 데이터 (Float32Array).
Rapier.js와 Three.js를 사용하여 물리 시뮬레이션의 디버그 시각화를 처리하는 커스텀 클래스를 만들어보자.
class RapierDebugRenderer {
mesh
world
enabled = true
constructor(scene: THREE.Scene, world: RAPIER.World) {
this.world = world
this.mesh = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: 0xffffff, vertexColors: true }))
this.mesh.frustumCulled = false
scene.add(this.mesh)
}
update() {
if (this.enabled) {
const { vertices, colors } = this.world.debugRender()
this.mesh.geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3))
this.mesh.geometry.setAttribute('color', new THREE.BufferAttribute(colors, 4))
this.mesh.visible = true
} else {
this.mesh.visible = false
}
}
}
mesh
: Rapier.js의 디버그 데이터를 시각화하기 위해 넣음. 추후 Line 을 상속받은 LineSegments 가 들어갈 예정.
world
: Rapier.js의 RAPIER.World 객체. 물리 시뮬레이션 데이터를 가져오기 위해 참조
enabled
: 디버그 렌더러의 활성화 여부를 나타내는 플래그. 기본값은 true.
mesh
: Rapier.js 에서 가져온 디버그 데이터를 렌더링하기 위한 LineSegments 객체. BufferGeometry: 디버그 데이터를 담는 3D 기하학적 데이터 구조. (default 값임)
LineBasicMaterial: 기본 선 재질로, 흰색(0xffffff)이며 꼭짓점 색상(vertexColors)을 활성화.
mesh.frustumCulled
: 이 메쉬는 카메라의 시야(frustum) 밖에 있어도 항상 렌더링 됨.
scene.add(this.mesh)
: Constructor 에서 TREE.Scene 에 디버그 메쉬를 추가.
world.debugRender()
: Rapier.js에서 디버그 데이터를 가져오는 이 포스팅의 핵심 메서드.
mesh.geometry.setAttribute()
: 가져온 데이터를 THREE.BufferGeometry
에 설정.
position: 선 정점 위치 (3차원 좌표), color: 각 정점의 색상 (RGBA 값)
mesh.visible
: 디버그 메쉬의 표출 유무.
즉, 커스텀 DebugRender 는 Rapier.js의 디버그 데이터를 Three.js로 렌더링하고,
Rapier.js에서 제공하는 충돌체 등의 디버그 데이터를 시각적으로 확인할 수 있도록 변환할 수 있도록 코드를 작성하였다.
// 레이피어 초기화
await RAPIER.init()
const gravity = new RAPIER.Vector3(0.0, -9.81, 0.0)
const world = new RAPIER.World(gravity)
// three.Object3D 와 rapier.RigidBody 동기화를 위한 배열
const dynamicBodies: [THREE.Object3D, RAPIER.RigidBody][] = []
const scene = new THREE.Scene()
// Scene 과 world 생성 후 RapierDebugRenderer 생성
const rapierDebugRenderer = new RapierDebugRenderer(scene, world)
...
function animate() {
requestAnimationFrame(animate)
delta = clock.getDelta()
world.timestep = Math.min(delta, 0.1)
world.step()
rapierDebugRenderer.update()
controls.update()
renderer.render(scene, camera)
stats.update()
}
animate()
RapierDebugRenderer 에서 작성한 .update 함수를 각 animate 마다 동작하여 RigidBody 데이털를 Line 에 업데이트 해줘야함.
위 사진을 보면 convexHull vs trimesh 의 차이를 알 수 있다. 점, 선, 면을 봐도 오른쪽(triemsh) 가 훨씩 복잡하다는 것을 알 수 있다. 이는 용량을 보다 많이 먹는 것을 뜻한다.
new OBJLoader().loadAsync('models/suzanne.obj').then((object) => {
scene.add(object)
const suzanneMesh = object.getObjectByName('Suzanne') as THREE.Mesh
suzanneMesh.material = new THREE.MeshNormalMaterial()
suzanneMesh.castShadow = true
const suzanneBody = world.createRigidBody(RAPIER.RigidBodyDesc.dynamic().setTranslation(-1, 10, 0).setCanSleep(false))
const points = new Float32Array(suzanneMesh.geometry.attributes.position.array)
const suzanneShape = (RAPIER.ColliderDesc.convexHull(points) as RAPIER.ColliderDesc).setMass(1).setRestitution(0.5)
world.createCollider(suzanneShape, suzanneBody)
dynamicBodies.push([suzanneMesh, suzanneBody])
})
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(5, 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(0.5)
world.createCollider(torusKnotShape, torusKnotBody)
dynamicBodies.push([torusKnotMesh, torusKnotBody])
trimesh
는 정점(Vertex) 배열과 인덱스(Index) 배열을 제공해야 한다. 이를 통해 정밀한 충돌 감지가 가능해지며, 특히 복잡한 모양의 객체에 적합하다.
다만 역시 복잡하면 성능이 더 낮을 수 있다.
그럼 원숭이를 trimesh 해보고 싶은데?
모델 중geometry.index
가 없는 모델들이 있다 앞서 설명했듯 trimesh 는 정점과 인덱스가 필요한데geometry.attributes.position
는 있지만 인덱스가 없는 경우가 있다.
이때 강제로 삼각형으로 분할(Triangulate)하는 과정을 거칠 수도 있고 혹은 그냥 인덱스도 함께 제공하는 모델을 사용하면 된다.
new OBJLoader().loadAsync('models/suzanne.obj').then((object) => {
scene.add(object);
const suzanneMesh = object.getObjectByName('Suzanne') as THREE.Mesh;
suzanneMesh.material = new THREE.MeshNormalMaterial();
suzanneMesh.castShadow = true;
// geometry를 가져오기
let geometry = suzanneMesh.geometry;
// geometry.index가 없는 경우 처리
if (!geometry.index) {
geometry = BufferGeometryUtils.mergeVertices(geometry); // 중복 정점 병합
geometry = geometry.toNonIndexed(); // 비인덱스 -> 인덱스 변환
}
// 정점(Vertex) 및 인덱스(Index) 배열 추출
const vertices = new Float32Array(geometry.attributes.position.array);
const indexArray = geometry.index?.array as Uint32Array;
if (!indexArray) {
console.error("Failed to generate indexArray from geometry.");
return;
}
// 물리 바디 생성
const suzanneBody = world.createRigidBody(
RAPIER.RigidBodyDesc.dynamic()
.setTranslation(-1, 10, 0)
.setCanSleep(false)
);
// Trimesh 생성
const suzanneShape = RAPIER.ColliderDesc.trimesh(vertices, indexArray)
.setMass(1)
.setRestitution(0.5);
// 콜라이더 생성
world.createCollider(suzanneShape, suzanneBody);
// 동기화용 배열에 추가
dynamicBodies.push([suzanneMesh, suzanneBody]);
});