토스페이스를 클론코딩 해보자

Chaejung·2024년 1월 21일
71

3D_JavaScript

목록 보기
6/6

개요

이번 포스팅에서는 토스 페이스 3D 체험하기 페이지를 클론코딩하는 프로젝트에 관해서 이야기할 예정이다.

(좌) 토스 페이스 3D 체험하기 (우) 클론코딩한 결과

약 한 달 간 R3F를 학습하고 간단한 프로젝트 하나를 해볼 만하다고 판단되어 도전해보았다.

개발 환경 세팅부터 Trouble Shooting, 개선 예정 사항까지 기록하였다.

🤔 구경하러 가기
🗃️ R3F 공부 일지

사용한 기술 스택

Vite

TypeScript

React Three Fiber

React Drei

React Spring

Vercel

개발 환경 세팅

1. 프로젝트 초기화

npm create vite@latest
npm install three @types/three @react-three/fiber @react-three/drei @react-spring/three

2. asset 추출

직접 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 파일로 받는 것이 가장 실무와 가까운 시나리오라고 생각됐기 때문이다.

주요 기능 구현

클론코딩할 토스페이스 페이지를 보고 어떤 기능을 구현해 볼 것인가 생각했다.
그래서 두 가지 측면으로 나누어 구현 사항을 리스트업해보았다.

<비인터렉티브 측면>

  • ☑️ 시점(Camera)이 y축 기준 양수 방향으로 이동한다
  • ☑️ 다양한 Emoji들을 랜덤하게 배치한다
    - ☑️ 중앙에서 바깥으로 향한 각도로 배치한다

<인터렉티브 측면>

  • ☑️ 화면을 드래그, 줌할 수 있다
  • ☑️ Emoji들을 hover하면 랜덤하게 애니메이션이 시작된다

1. 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가 잘 렌더되는 것을 확인할 수 있다.

TroubleShooting - model caching

위의 useLoader를 이용한 방식은 동일한 모델을 여러 위치에서 렌더하려고 할 때 문제가 발생한다.

위의 캡쳐를 참고하자면,
원래 [0, 2, 0] 자리에 로봇 Emoji가 렌더되어야 하는데
가장 나중에 그려진 것만 나타나는 것이다.

그 이유는 useLoader 훅의 특성 때문이다.
GLTF 모델이 메모리에 캐시되어 중복된 로딩이 이루어지지 않는 것이다.

출처: useLoader - Pmndrs.docs

결국 캐시되지 않도록 로드하는 것마다 복사하는 것으로 해결하면 된다.

해결 방법 1) scene.clone

useLoader to load one object, use it multiple times - react-three-fiber github issues

해결 방법 2) loader 인스턴스 생성

위 방법은 R3F의 훅을 사용했다면,
이 방법은 useLoader 훅을 사용하지 않고 ThreeJS의 loader만을 이용하는 방법이다.

❓ 사실 R3F이 ThreeJS 기반이기 때문에 R3F 전용 메서드를 쓰지 않고 ThreeJS의 그것을 쓸 수 있는 상황이 종종 발생한다. 이럴 경우 어떤 기준으로 방법을 선택해야 하는지 고민하고 있다.

2. Camera 설정

모델을 배치할 준비가 됐다면, 화면 드래그, 줌 및 자동 회전을 구현할 차례이다.
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을 늘리면 카메라 돌아가는 속도가 빨라지는 것을 볼 수 있다.

3. 모델 랜덤하게 배치하기

토스페이스에서 렌더되는 이모지가 대략 몇백 개는 되어 보인다.
물론 이러한 모델들의 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 범위 내의 수를 랜덤하게 적용하도록 설정했다.

TroubleShooting - unexpected rerender

어라? Camera 이동을 시킬 때마다 Emoji들의 위치가 새롭게 지정되는 버그가 발생했다.

CameraControls의 문제인가 싶어
여러 속성별 이벤트 트리거 속성에 콘솔 함수를 심어 어떤 경우에 위치가 바뀌는지 디버깅해 보았다.

해결 방법) position 메모이제이션

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;

4. Emoji 애니메이션

애니메이션을 적용하기 전,
토스페이스를 보면 Emoji들이 중앙을 시작으로 바깥으로 뻗어나가는 방향을 바라보는 것을 알 수 있다.

해당 부분은 처음에 position이 정해지면,
그 위치를 바탕으로 삼각함수를 이용해서 벡터를 별도로 계산하려고 시도했다.
그런데 생각보다 더 잘 되지 않아서,
ThreeJS 객체인 Object3D의 메서드들을 낱낱이 파헤쳐보았다.

Object3D - ThreeJS docs

이름부터 벌써 내가 찾던 그것!

그래서 다음과 같이 적용했다.

//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를 적용한다.

TroubleShooting - 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

5. Emoji 랜덤 matcap 적용

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`);

짜잔~!

Further Issue

1) 랜덤 position

약 백 개의 에셋에 대한 position을 현재는 random으로 생성했고, 아마 토스도 마찬가지로 보인다.
그래서 발생하는 모델끼리 겹치는 문제.

일정 unit을 생성하고 unit 안에 배정하도록 하는 방법은 어떨까?
이러면 다소 격자로 나열된 것처럼 보일 수도 있을 것 같다.

또는 물리엔진을 적용하여 서로 겹치는 것으로 확인되면 렌더하지 않도록 적용하는 방법은 어떨까?

2) 키프레임 애니메이션

더불어서 단순 circuit이 아니라 키프레임 애니메이션을 적용하고 싶었는데,
R3F+React Spring에서 하는 방식을 아직 알 수 없어서 백로그로 남겨두었다.

다른 라이브러리를 적용해 보거나, React Spring을 좀 더 파봐야 할 것 같다.

3) Suspense

로딩 전 모델이 전부 렌더될 때까지 기다리는 suspense 화면을 별도로 적용하고 싶다.

느낀 점

배운 것을 바탕으로 토스에서 구현한 것을 (그럴 싸할 만큼) 그대로 만들었더니 뿌듯했다.
그래서 글또에서 하는 스터디, 커피챗마다 요즘 이런 거 하고 있다며 주절주절 자랑하고 다녔다.
메이커로서 웹 개발에 매력을 느끼고 그런 순간들이 찾아올 때마다 개발하길 잘했다는 생각이 든다.

R3F는 미지의 땅이다.
어떤 기능을 구현하고 싶어도 어떻게 물어볼 지도 모르겠거니와
검색해도 R3F가 아닌, ThreeJS 또는 다른 3D 툴에 대한 레퍼런스가 많다.
어디로 가야할 지 모르겠기에 막막한 순간이 종종 찾아왔다.
한편으로는 내가 먼저 이 척박한 땅을 밟아보고 자료를 만들어서
다른 사람들, 이후에 찾아오는 사람들의 레퍼런스가 되고자 한다.

전체 코드입니다

profile
프론트엔드 기술 학습 및 공유를 활발하게 하기 위해 노력합니다.

12개의 댓글

comment-user-thumbnail
2024년 1월 24일

헐 채정님 너무 멋져요

1개의 답글
comment-user-thumbnail
2024년 1월 30일

헐 구독메일에서 보고서 넘멋지다 생각했는데 채정님 이였어요..!! 아는분이 나오다니 소름..

답글 달기
comment-user-thumbnail
2024년 1월 31일

비개발자가 봐도 멋진거 같아요!
채정님과 커피챗을 해보고 싶은데, 아래 메일로 가능여부 회신주실 수 있을까요?

jayden@vanpl.co.kr

답글 달기
comment-user-thumbnail
2024년 2월 1일

흥미롭게 읽고 갑니다!

1개의 답글
comment-user-thumbnail
2024년 2월 1일

채정님 👍👍

1개의 답글
comment-user-thumbnail
2024년 2월 3일

토스 페이스 얼굴 다 앞으로 보게 되어 있는거 너무 변태 같아요.ㅠㅠ 진짜 저런거보면 너무 구현해보고 싶었는데 이 기분을 해소해주셔서 감사합니다.

1개의 답글
comment-user-thumbnail
2024년 2월 20일

👍👍👍👍👍👍👍

답글 달기