들어가며
좀 더 interactive한 웹을 만들기 위해서 최근 three.js 기반의 R3F 라이브러리를 활용하여, 웹 페이지를 만들고 있습니다.
강아지를 활용하여서 이동하게 하려고 하는데, 강아지의 이동 및 회전에 대해서 학습한 점과 트러블슈팅을 기록 및 공유하고자 작성하게 되었습니다.
간단한 배경 설명
SketchFab 에서 얻은 Shiba 모델을 tsx컴퍼넌트화 하였습니다. 해당 모델은 아쉽게도 animation이 없더군요.. 그렇지만
@react-three/cannon 라이브러리를 통해 만든 물리엔진속에서 강아지가 움직일 수 있도록 하게 하고 싶었습니다.
<group ref={chassisBody} {...props}>
<group position={[0, 0.35, 0.5]} rotation={[-Math.PI / 2, 0, 0]}>
<mesh
geometry={nodes.Group18985_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Box002_default_0.geometry}
material={materials['default']}
/>
<mesh
geometry={nodes.Object001_default_0.geometry}
material={materials['default']}
/>
</group>
</group>
const position: [x: number, y: number, z: number] = [0, 1, 0];
const width = 0.65;
const height = 1.2;
const front = 0.6;
const mass = 50;
const chassisBodyArgs = [width, height, front * 2];
const [chassisBody, chassisApi] = useCompoundBody(
() => ({
position,
mass: mass,
rotation: [0, 0, 0],
collisionFilterGroup: 5,
shapes: [
{
args: chassisBodyArgs,
position: [0, 0, 0],
type: 'Box',
},
],
}),
useRef(null)
);
useCompoundBody 훅은 충돌체로 지정하기 위해서 사용하는데,(복합체의 경우에 사용하는데 위처럼 하나의 대상인 경우, useBox를 사용해도 됩니다.) 위에 설정한 옵션을 기반으로 대상을 정의하였습니다.

이후 키보드 입력을 다룰 useInput 훅을 생성하였습니다.
wasd를 누를 때 해당 값을 boolean형태로 기억하여, forward가 true면 전진 하는 식으로 로직을 구상하려 합니다.
(점프의 경우는 수정할 것 같습니다..)
import { useEffect, useState } from 'react';
type InputState = {
forward: boolean;
backward: boolean;
left: boolean;
right: boolean;
jump: boolean;
};
type KeyMap = {
[key: string]: keyof InputState;
};
export const useInput = (): InputState => {
const [input, setInput] = useState<InputState>({
forward: false,
backward: false,
left: false,
right: false,
jump: false,
});
useEffect(() => {
const keys: KeyMap = {
KeyW: 'forward',
KeyS: 'backward',
KeyA: 'left',
KeyD: 'right',
Space: 'jump',
};
const findKey = (key: string): keyof InputState | undefined => keys[key];
const handleKeyDown = (e: KeyboardEvent): void => {
const key = findKey(e.code);
if (key) {
setInput((prevInput) => ({ ...prevInput, [key]: true }));
}
};
const handleKeyUp = (e: KeyboardEvent): void => {
const key = findKey(e.code);
if (key) {
setInput((prevInput) => ({ ...prevInput, [key]: false }));
}
};
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('keyup', handleKeyUp);
};
}, []);
return input;
};
이제 키보드 이벤트가 성공적으로 입력되니 이를 바탕으로 모델을 움직이기만하면 됩니다.
앞서 Shiba의 경우 useComboundBody 훅으로 얻은 튜플값들 (chassisBody, chassisApi)을 활용하여 제어하면 됩니다.
기본적으로 @react-three/cannon 라이브러리의 경우에 cannon-es를 react에서 쓸 수 있도록 확장하였습니다.
트러브 슈팅
우선 회전대상을 제어하려면 chassisBody, chassisApi을 활용해야 할 것 입니다.
각각 어떤 속성을 가지고 있는지 확인하기 위해서 콘솔을 우선 찍어 보았습니다.


quaternion이 무엇인지 잘 몰랐어서,, rotation을 활용하여 회전된 방향으로 이동을 시키면 되지 않을까? 라는 생각을 했습니다. 결론 부터 말하자면 quaternion은 Unitiy에서 주로 쓰는 회전을 다루기 위해서 쓰는 값입니다..
이걸 미리 알았으면 편했을텐데,,,
1. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴
useFrame((state, delta) => {
chassisApi.position.subscribe((pos) => {
let [x, y, z] = pos;
chassisApi.rotation.subscribe((rot) => {
let [rx, ry, rz] = rot;
if (forward) {
x += Math.sin(ry) * delta;
z += Math.cos(ry) * delta;
}
if (backward) {
x -= Math.sin(ry) * delta;
z -= Math.cos(ry) * delta;
}
if (right) {
ry -= delta;
}
if (left) {
ry += delta;
}
chassisApi.position.set(x, y, z);
chassisApi.rotation.set(rx, ry, rz);
});
});
});

콜뱀함수를 통해 실행하다 보니 많은 부하가 걸림. 프레임마다 시켜서 그런 것 같습니다.. 따라서
current에 있는 값을 쓰면 되지 않을까 확인해보니, 해당 값은 초기 설정값만 나오고 중력에 의해서 위치가 변경되어도 똑같은 값이 계속 나왔습니다..
따라서 '로직을 변경하면 나아지지 않을까?' 생각하게 되었고, 콜백함수 형식에서 state에 값을 할당하는 방식으로 변경하였습니다.
2. 내장 함수를 통해 위치와 회전 정도를 구한 후 회전시킴
const [chassisPosition, setChassisPosition] =
useState<[number, number, number]>(position);
const [chassisRotation, setChassisRotation] = useState<
[number, number, number]
>([0, 0, 0]);
useEffect(() => {
const unsubscribePosition = chassisApi.position.subscribe((pos) => {
setChassisPosition(pos as [number, number, number]);
});
const unsubscribeRotation = chassisApi.rotation.subscribe((rot) => {
setChassisRotation(rot as [number, number, number]);
});
return () => {
unsubscribePosition();
unsubscribeRotation();
};
}, [chassisApi]);
useFrame((state, delta) => {
let [x, y, z] = chassisPosition;
let [rx, ry, rz] = chassisRotation;
if (forward) {
x += Math.sin(ry) * delta;
z += Math.cos(ry) * delta;
}
if (backward) {
x -= Math.sin(ry) * delta;
z -= Math.cos(ry) * delta;
}
if (right) {
ry -= delta;
}
if (left) {
ry += delta;
}
chassisApi.position.set(x, y, z);
chassisApi.rotation.set(rx, ry, rz);
});
하지만 여전히 문제점이 있었습니다. 회전값의 경우에 ry가 왼쪽 90에서 최솟값( - π)오른쪽 90도에서 최댓값( π)
앞 뒤에서는 ry값이 모두 0이 나옵니다.

즉 값을 단순히 더하거나 빼서는 안됩니다. 이때 앞면 뒷면에 따라서 rx의 부호가 바뀌기 때문에 이를 분기 삼아서 처리할 수 있습니다.. 하지만 회전 및 이동하다 보면 버벅거리는 버그가 여전히 생겼습니다..
따라서,, 좀 더 공부할 필요성을 느끼고, cannon-es 및 react-three/cannon 의 소스 코드를 참고하였습니다.
해결 방법
앞서 얘기했듯이 회전을 수월하게 해주 Quaternion을 활용했습니다.
1. 우선 시바의 위치, 회전, 회전축을 기록할 각각의 변수를 생성합니다.
const worldPosition = useMemo(() => new Vector3(), []);
const worldDirection = useMemo(() => new Vector3(), []);
const worldQuaternion = useMemo(() => new Quaternion(), []);
2. 시바를 따라다닐 카메라를 설치하였는데, 이 과정에서 chassicBody의 함수인 getWorldPosition을 통해 시바의 위치를 변수에 할당해줍니다.
const makeFollowCam = () => {
chassisBody?.current!.getWorldPosition(worldPosition);
chassisBody?.current!.getWorldDirection(worldDirection);
pivot.position.lerp(worldPosition, 0.9);
};
3. 해당 값을 활용하여 이동 및 회전을 시킵니다.
const controlMovement = (delta: number) => {
if (forward || backward) {
const speed = delta * 2;
let { x, y, z } = worldPosition;
let { x: rx, y: ry, z: rz } = worldDirection;
let [newX, newY, newZ] = [x, y, z];
if (forward) {
newX += rx * speed;
newZ += rz * speed;
}
if (backward) {
newX -= rx * speed;
newZ -= rz * speed;
}
chassisApi.position.set(newX, newY, newZ);
}
if (right || left) {
const turnAngle = delta;
const turnQuaternion = new Quaternion();
if (right) {
turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), -turnAngle);
}
if (left) {
turnQuaternion.setFromAxisAngle(new Vector3(0, 1, 0), turnAngle);
}
worldQuaternion.multiplyQuaternions(turnQuaternion, worldQuaternion);
chassisApi.quaternion.set(
worldQuaternion.x,
worldQuaternion.y,
worldQuaternion.z,
worldQuaternion.w
);
}
};
이동 로직은 비슷하나 회전 로직을 수정하였습니다. 회전의 경우 left,right의 경우에만 이동의 경우 forward, backward의 경우에만 하므로 조건문을 통해 분기처리 하였습니다.
그리고 해당 코드를 useFrame훅에서 실행해줍니다.
useFrame((_, delta) => {
makeFollowCam();
controlMovement(delta);
});

followCam의 경우에 fastcampus 강의에서 배웠떤 것을 사용하였는데, 자동차 이동과 달리 카메라가 부자연스럽네요.. 그래도 시바의 회전이 잘 되고 있습니다.
느낀점
R3F도 그렇고 react-three/cannon 도 그렇고 기반이 되는 라이브러리에 대한 학습이 우선시 되어야 한다고 느꼈습니다.
또한 아직 관심사의 분리면에서 코드가 불안정해 보여서 코드를 develop할 필요성을 느끼고 있습니다.
보통 중력이나 힘을 이용해서 물체의 이동을 많이 구현하는데, 다른 방법으로 구현하는 과정이 재밌었네요..
'프로젝트' 카테고리의 다른 글
Warning: Extra attributes from the server: style 에러 해결하기( next.js) (0) | 2024.06.20 |
---|---|
R3F에서 물체가 지나갈 수 있는 언덕 만들기(react-three/cannon) (0) | 2024.06.04 |
자바스크립트로 테트리스 클론 코딩 및 디벨롭(Phaser , Vite)-2 성능개선(Chrome Performane활용) (0) | 2024.03.24 |
자바스크립트로 테트리스 클론 코딩 및 디벨롭(Phaser , Vite)-1 리뷰 (0) | 2024.03.21 |
windowWidth를 통해서 구한 값을 통해, ui라이브러리에 반응형 크기주기 (리팩토링을 하며) (0) | 2023.10.08 |