이번 포스팅에서는 토스 페이스 3D 체험하기 페이지를 클론코딩하는 프로젝트에 관해서 이야기할 예정이다.
(좌) 토스 페이스 3D 체험하기 (우) 클론코딩한 결과
약 한 달 간 R3F를 학습하고 간단한 프로젝트 하나를 해볼 만하다고 판단되어 도전해보았다.
개발 환경 세팅부터 Trouble Shooting, 개선 예정 사항까지 기록하였다.
npm create vite@latest
npm install three @types/three @react-three/fiber @react-three/drei @react-spring/three
직접 3D 모델을 blender로 그려서 추출하는 방법도 있지만,
시간 관계상 누군가 잘 만든 것을 그대로 가져와 보겠다.
Spline Design는 별도의 애플리케이션 설치 없이 웹에서 3D asset의 예제를 사용할 수 있고,
직접 편집할 수 있는 툴이다.
Figma처럼 공동 작업도 가능하다고 하는데, 현재 프로젝트에서는 그렇게 쓰진 않았다.
새롭게 계정을 생성하면 바로 빈 파일이 열릴 것이고, 그게 아니라면 New File로 하나 만들어주면 된다.
별도의 설정 없이 Library
에 들어가 원하는 asset을 가져오면 된다.
Spline Design을 쓰는 이유는 여기서 'Emoji' asset 카테고리가 있어서
토스페이스의 그것을 모방하기에 아주 적합하다고 생각했기 때문이다.
그런 다음 원하는 asset을 메인 화면에 배치한 뒤 export를 하면 된다.
여기서 다음 단계에서는 3D Formats>GLTF
로 모델을 추출하면 된다.
앞에서 초기화한 프로젝트의 public 폴더에 asset들을 전부 넣어주자.
한 가지 아쉬운 점은 Material을 grey로밖에 추출하지 못한다는 것이다.
그 이유는... 실제 색으로 추출하려면 구독해야하기 때문이다. 😢
돈이 많았더라면... 시원하게 구독하고 좀 더 토스페이스와 가깝게 구현할 수 있었을 텐데...
🗃️ GLTF?
(Graphics Library Transmission Format or GL Transmission Format and formerly known as WebGL Transmissions Format or WebGL TF)
3D scene과 모델에 대한 표준 파일 형식입니다.
출처: GLTF - wikipedia
GLTF 파일로 추출하려는 이유는 다음과 같다.
처음에는 모델을 추출하지 않고 react-three-fiber 코드로 바로 추출했었다.
코드로 추출하면 모델 mesh의 세부 설정까지 건드릴 수 있다는 장점이 있지만
당시 목표는 최대한 다양한 asset을 렌더하는 것이었다.
그래서 모델별로 별개의 컴포넌트를 만드는 것은 비효율적이라 판단되어,
GLTF 파일을 loader로 부르는 것으로 결정한 것이다.
또한 이 방식은 URL, 즉 import 하는 spline design의 파일에 의존하는데,
개별 asset을 뽑아내야 하는 상황에서 코드로 관리하려면 파일도 개별적으로 생성해야 하는 번거로움이 있었다.
마지막으로 GLTF 파일로 받는 것이 가장 실무와 가까운 시나리오라고 생각됐기 때문이다.
클론코딩할 토스페이스 페이지를 보고 어떤 기능을 구현해 볼 것인가 생각했다.
그래서 두 가지 측면으로 나누어 구현 사항을 리스트업해보았다.
<비인터렉티브 측면>
<인터렉티브 측면>
초기 폴더 구조는 다음과 같이 구성한다.
📦src
┣ 📂components
┃ ┗ 📜Emoji.tsx
┣ 📂group
┃ ┗ 📜Group.tsx
┣ 📂types
┃ ┗ 📜Emoji.ts
┣ 📜App.css
┣ 📜App.tsx
┣ 📜index.css
┣ 📜main.tsx
┗ 📜vite-env.d.ts
Emoji.tsx
: Emoji별 모델의 loader 및 애니메이션 적용Group.tsx
: Emoji들의 배치, 상태 관리, 카메라 제어 포함App.tsx
: Canvas 태그를 가진 최상위 컴포넌트혹시나 그룹을 여러 개로 설정한다거나 Emoji에 필요한 다른 컴포넌트가 있을 것으로 생각해서 폴더링을 하였으나,
결과적으로 보면 굳이 폴더로 depth를 늘릴 필요는 없다고 판단된다.
하지만 여기서는 일단 역할 분리를 위해 확실하게 폴더링을 했으니
필요에 따라 각자 폴더링 여부를 결정하면 될 것 같다.
heartEyes.gltf
가 잘 렌더되는 것을 확인할 수 있다.
위의 useLoader
를 이용한 방식은 동일한 모델을 여러 위치에서 렌더하려고 할 때 문제가 발생한다.
위의 캡쳐를 참고하자면,
원래 [0, 2, 0] 자리에 로봇 Emoji가 렌더되어야 하는데
가장 나중에 그려진 것만 나타나는 것이다.
그 이유는 useLoader
훅의 특성 때문이다.
GLTF 모델이 메모리에 캐시되어 중복된 로딩이 이루어지지 않는 것이다.
결국 캐시되지 않도록 로드하는 것마다 복사하는 것으로 해결하면 된다.
useLoader to load one object, use it multiple times - react-three-fiber github issues
위 방법은 R3F의 훅을 사용했다면,
이 방법은 useLoader
훅을 사용하지 않고 ThreeJS의 loader만을 이용하는 방법이다.
❓ 사실 R3F이 ThreeJS 기반이기 때문에 R3F 전용 메서드를 쓰지 않고 ThreeJS의 그것을 쓸 수 있는 상황이 종종 발생한다. 이럴 경우 어떤 기준으로 방법을 선택해야 하는지 고민하고 있다.
모델을 배치할 준비가 됐다면, 화면 드래그, 줌 및 자동 회전을 구현할 차례이다.
Group.tsx
컴포넌트에서 drei에서 지원하는 CameraControls
를 설정하면 된다.
여기서 시점이 y축 기준으로 양수 방향으로 이동하는 기능을 구현해 보겠다.
drei에는 auto rotate가 없지만, drei가 참고하는 CameraControls
의 ThreeJS 기반 레포지토리에는 관련 예시가 있어서 해당 코드를 참고했다.
참고: auto-rotate - yomotsu/camera-controls github repository
// Group.tsx
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { CameraControls } from "@react-three/drei";
import GoodEmojiUseLoader from "../components/GoodEmojiUseLoader";
import { useFrame } from "@react-three/fiber";
const Group = () => {
const groupRef = useRef<THREE.Group>(null);
const cameraRef = useRef<CameraControls>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [disableAutoRotate, setDisableAutoRotate] = useState<boolean>(false);
useEffect(() => {
cameraRef.current?.setTarget(0, 0, 0, true);
});
useFrame((_, delta) => {
if (cameraRef.current && !disableAutoRotate) {
cameraRef.current.azimuthAngle += THREE.MathUtils.degToRad(4 * delta);
}
});
return (
<group ref={groupRef}>
<CameraControls
ref={cameraRef}
enable={true}
maxDistance={10}
onStart={() => setDisableAutoRotate(true)}
onEnd={() => setDisableAutoRotate(false)}
/>
<GoodEmojiUseLoader position={[0, 0, 0]} src={"heartEyes"} />
<GoodEmojiUseLoader position={[0, 1, 0]} src={"skull"} />
<GoodEmojiUseLoader position={[0, 2, 0]} src={"happy"} />
</group>
);
};
export default Group;
줌, 드래그의 인터렉션이 있는 경우 auto rotate를 중지시켜야 하므로,
이 부분은 auto rotate를 disable시키는 상태를 하나 만들어서
CameraControls
의 속성 중 제어가 시작되고 끝날 때까지 트리거할 수 있는
onStart
, onEnd
를 통해 disableAutoRotate
값을 바꿔주었다.
시점 이동 속도를 빠르게 하려면 useFrame
에서 azimuthAngle
을 늘리는 부분에서
delta와 곱하는 수를 늘리면 된다.
토스페이스의 경우 1프레임 당 4도 정도가 적당하다고 생각해서 4로 고정했다.
azimuthAngle을 늘리면 카메라 돌아가는 속도가 빨라지는 것을 볼 수 있다.
토스페이스에서 렌더되는 이모지가 대략 몇백 개는 되어 보인다.
물론 이러한 모델들의 position을 일일이 정해줄 수도 있지만,
십자수 놓듯 정성스레 심는 것은 개발자답지 않다.
그래서 이 부분을 랜덤하게 배치하는 기능을 구현해 보겠다.
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { useFrame } from "@react-three/fiber";
import { CameraControls } from "@react-three/drei";
import Emoji from "../components/Emoji";
import { getRandomNumberInRange } from "../utils/random";
import { EmojiModelProps } from "../types/Emoji";
const EMOJI_NAME: EmojiModelProps["src"][] = [
"heartEyes",
"wink",
"happy",
"grateful",
"smile",
"angry",
"boring",
"heart",
"snowFlake",
"skull",
"robot",
"poop",
"ghost",
];
const COUNT = 300;
const SampleGroup = () => {
const groupRef = useRef<THREE.Group>(null);
const cameraRef = useRef<CameraControls>(null);
const [disableAutoRotate, setDisableAutoRotate] = useState<boolean>(false);
const EMOJI_ARRAY = useMemo(() => {
const emojis: EmojiModelProps["src"][] = [];
for (let i = 0; i < COUNT; i++) {
emojis.push(EMOJI_NAME[i % EMOJI_NAME.length]);
}
return emojis;
}, []);
useEffect(() => {
cameraRef.current?.setTarget(0, 0, 0, true);
}, []);
useFrame((_, delta) => {
if (cameraRef.current && !disableAutoRotate) {
cameraRef.current.azimuthAngle -= THREE.MathUtils.degToRad(4 * delta);
}
});
return (
<group ref={groupRef}>
<CameraControls
maxDistance={10}
ref={cameraRef}
onStart={() => setDisableAutoRotate(true)}
onEnd={() => setDisableAutoRotate(false)}
/>
{EMOJI_ARRAY.map((ele, idx) => {
return (
<Emoji
key={ele + idx}
position={[
getRandomNumberInRange(-5, 5),
getRandomNumberInRange(-5, 5),
getRandomNumberInRange(-5, 5),
]}
src={ele}
/>
);
})}
</group>
);
};
export default SampleGroup;
약 300개 정도가 나타나도록,
그리고 x, y, z 위치별로 -5~5 범위 내의 수를 랜덤하게 적용하도록 설정했다.
어라? Camera 이동을 시킬 때마다 Emoji들의 위치가 새롭게 지정되는 버그가 발생했다.
CameraControls
의 문제인가 싶어
여러 속성별 이벤트 트리거 속성에 콘솔 함수를 심어 어떤 경우에 위치가 바뀌는지 디버깅해 보았다.
onStart
, onEnd
에 의해 disableAutoRotate의 상태가 변경될 때마다,
즉 Group 컴포넌트가 리렌더될 때마다 Emoji들도 새롭게 적용되는 것을 알 수 있었다.
EMOJI_ARRAY
를 메모이제이션해서 문제가 없을 것으로 생각했다.
하지만 문제는 Emoji
컴포넌트 속성으로 들어가는 position이 메모이제이션되어 있지 않기 때문에
position 값 또한 메모이제이션 해야 하는 것이었다.
아래 코드로 변경했더니 해결할 수 있었다.
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import Emoji from "../components/Emoji";
import { useFrame } from "@react-three/fiber";
import { CameraControls } from "@react-three/drei";
import { EmojiModelProps } from "../types/Emoji";
const EMOJI_NAME: EmojiModelProps["src"][] = [
"heartEyes",
"wink",
"happy",
"grateful",
"smile",
"angry",
"boring",
"heart",
"snowFlake",
"skull",
"robot",
"poop",
"ghost",
];
const COUNT = 300;
const Group = () => {
const groupRef = useRef<THREE.Group>(null);
const cameraRef = useRef<CameraControls>(null);
const [disableAutoRotate, setDisableAutoRotate] = useState<boolean>(false);
const EMOJI_ARRAY = useMemo(() => {
const emojis: EmojiModelProps["src"][] = [];
for (let i = 0; i < COUNT; i++) {
emojis.push(EMOJI_NAME[i % EMOJI_NAME.length]);
}
return emojis;
}, []);
//** 👇👇👇👇👇👇👇👇👇👇 *//
// position도 메모이제이션 해주는 코드
const initialPositions = useMemo(() => {
const randomPositions = new Array(COUNT * 3);
for (let i = 0; i < COUNT * 3; i++) {
randomPositions[i] = (Math.random() - 0.5) * 10;
}
return randomPositions;
}, []);
const [positions, setPositions] = useState<number[]>(initialPositions);
//** 👆👆👆👆👆👆👆👆👆👆 *//
useEffect(() => {
if (!disableAutoRotate) {
setPositions(initialPositions);
}
}, [disableAutoRotate, initialPositions]);
useEffect(() => {
cameraRef.current?.setTarget(0, 0, 0, true);
}, []);
useFrame((_, delta) => {
if (cameraRef.current && !disableAutoRotate) {
cameraRef.current.azimuthAngle -= THREE.MathUtils.degToRad(5 * delta);
}
});
return (
<group ref={groupRef}>
<CameraControls
maxDistance={10}
ref={cameraRef}
onStart={() => setDisableAutoRotate(true)}
onEnd={() => setDisableAutoRotate(false)}
/>
{EMOJI_ARRAY.map((ele, idx) => {
return (
<Emoji
key={ele + idx}
// random으로 생성한 300개의 position을 3개씩 잘라서 지정해줍니다
position={positions.slice(idx * 3, idx * 3 + 3)}
src={ele}
/>
);
})}
</group>
);
};
export default Group;
애니메이션을 적용하기 전,
토스페이스를 보면 Emoji들이 중앙을 시작으로 바깥으로 뻗어나가는 방향을 바라보는 것을 알 수 있다.
해당 부분은 처음에 position이 정해지면,
그 위치를 바탕으로 삼각함수를 이용해서 벡터를 별도로 계산하려고 시도했다.
그런데 생각보다 더 잘 되지 않아서,
ThreeJS 객체인 Object3D의 메서드들을 낱낱이 파헤쳐보았다.
이름부터 벌써 내가 찾던 그것!
그래서 다음과 같이 적용했다.
//Emoji.tsx
//...
const [x, y, z] = position;
useEffect(() => {
if (groupRef.current) {
groupRef.current.lookAt(x * 2, y * 2, z * 2);
}
});
//...
❓ 왜 x, y, z가 아니라
x * 2
,y * 2
,z * 2
인가요.
lookAt
은 말 그대로 글로벌 좌표계의 (x, y, z)를 향해 바라보도록 해당 객체를 회전시키는 메서드이다.
여기서 Emoji가 있는 위치인 (x, y, z)를 바라보면 자기 자신을 바라보기 때문에 벡터 (0, 0, 0)-(x, y, z)와 같은 직선상이지만, 중심보다 더 멀리 있는 임의의 지점을 정해 바라보도록 했기 때문이다.
그래서 동일한 원리로 꼭 2가 아니라 1 초과인 어떤 값이라도 괜찮다.
조금 변형해서 모두 중앙을 바라보도록 하고 싶다면lookAt(0, 0, 0)
을 하면 된다.
다음으로 react-spring
을 이용해 Emoji에 애니메이션을 적용해 보겠다.
react spring을 이전 프로젝트에서 React 내에서 사용해 본 적 있었는데,
React와 달리 R3F는 자유도가 높지 않아서 세세한 애니메이션을 적용하기에는 무리가 있었다.
다음과 같이 애니메이션 트리거 플래그를 받고 tranformation 값을 반환하는 커스텀 훅을 만들었다.
scale과 rotation을 각각 두 가지 경우로 useSpring
을 만든 다음,
이를 반환 전 렌덤하게 지정하는 로직을 구성했다.
해당 로직은 각각의 값이 두 가지인 경우만 커버할 수 있기에,
더욱 다양한 애니메이션 구성 세트를 만들려면 다른 방법을 구상해야 한다.
Emoji 컴포넌트에서는 react-spring의 animated.group
을 새로 감싼 다음,
animated.group
의 scale, rotation-z, rotation-y에 커스텀 훅으로부터 반환된 useSpring
값을 적용하고,
상위 group
태그에는 이벤트 트리거 속성에 트리거 플래그 상태 값 setState
를 적용한다.
useRef.current
issue새로고침을 할 때마다 모든 Emoji가 정면을 바라보는 버그가 발생했다.
animationGroupRef.current
가 첫 렌더 때 잡히지 않아서 lookAt이 적용되지 않는 것이었다.
그래서 lookAt을 첫 렌더 때도 적용하기 위한 방법을 찾아보았다.
requestAnimationFrame
requestAnimationFrame
은 주로 애니메이션 및 렌더 작업에서 사용되는데,
브라우저에 함수를 제공하여 다음 리페인트 이전에 해당 함수를 호출하도록 예약할 수 있다.
그래서 이 API에 lookAt
함수를 제공하여 브라우저가 프레임 렌더를 최적화했다.
// AS-IS
useEffect(() => {
if (animatedGroupRef.current) {
animatedGroupRef.current.lookAt(x * 2, y * 2, z * 2);
}
}, []);
// TO-BE
useEffect(() => {
let frameId: number;
const updateLookAt = () => {
if (animatedGroupRef.current) {
animatedGroupRef.current.lookAt(x * 2, y * 2, z * 2);
} else {
frameId = requestAnimationFrame(updateLookAt);
}
};
updateLookAt();
return () => {
cancelAnimationFrame(frameId);
};
}, [x, y, z, src]);
참고: RequestAnimationFrame in JavaScript - builtin
Emoji가 전부 같은 색이라서 밋밋한 느낌을 주었다.
다채롭게 보이기 위해 랜덤하게 Emoji에 matcap을 적용하는 기능을 구현하겠다.
// AS-IS
const matcap = useTexture(`./images/matcap4.jpeg`);
// TO-BE
const textureNum = useMemo(() => getRandomIntegerInRange(1, 5), []);
const matcap = useTexture(`./images/matcap${textureNum}.jpeg`);
짜잔~!
약 백 개의 에셋에 대한 position을 현재는 random으로 생성했고, 아마 토스도 마찬가지로 보인다.
그래서 발생하는 모델끼리 겹치는 문제.
일정 unit을 생성하고 unit 안에 배정하도록 하는 방법은 어떨까?
이러면 다소 격자로 나열된 것처럼 보일 수도 있을 것 같다.
또는 물리엔진을 적용하여 서로 겹치는 것으로 확인되면 렌더하지 않도록 적용하는 방법은 어떨까?
더불어서 단순 circuit이 아니라 키프레임 애니메이션을 적용하고 싶었는데,
R3F+React Spring에서 하는 방식을 아직 알 수 없어서 백로그로 남겨두었다.
다른 라이브러리를 적용해 보거나, React Spring을 좀 더 파봐야 할 것 같다.
로딩 전 모델이 전부 렌더될 때까지 기다리는 suspense 화면을 별도로 적용하고 싶다.
배운 것을 바탕으로 토스에서 구현한 것을 (그럴 싸할 만큼) 그대로 만들었더니 뿌듯했다.
그래서 글또에서 하는 스터디, 커피챗마다 요즘 이런 거 하고 있다며 주절주절 자랑하고 다녔다.
메이커로서 웹 개발에 매력을 느끼고 그런 순간들이 찾아올 때마다 개발하길 잘했다는 생각이 든다.
R3F는 미지의 땅이다.
어떤 기능을 구현하고 싶어도 어떻게 물어볼 지도 모르겠거니와
검색해도 R3F가 아닌, ThreeJS 또는 다른 3D 툴에 대한 레퍼런스가 많다.
어디로 가야할 지 모르겠기에 막막한 순간이 종종 찾아왔다.
한편으로는 내가 먼저 이 척박한 땅을 밟아보고 자료를 만들어서
다른 사람들, 이후에 찾아오는 사람들의 레퍼런스가 되고자 한다.
헐 채정님 너무 멋져요