[wsLib] 사이드 프로젝트 사용 기술 정리

홍정민·2024년 7월 6일
4
post-thumbnail

사이드 프로젝트에서 사용했던 three.js의 기술들을 정리해 본다. 내가 나중에 three.js를 다시 사용할 때 도움되기 위해 기록을 하는 것 이지만, 나와 비슷한 서비스를 만드는 개발자를 위해 도움이 되었으면 하는 마음에 작성한다.

코드 중심보다 해당 프로젝트에서 어떤 기능인지? 어떤 문제를 해결하는지? 로 분류하여 기능을 정리할 것 이다.


소개


개요

우송 도서관의 열람실 내부를 3D 웹으로 구현한 프로젝트이다. 기존 우송 도서관 예약 서비스의 데이터를 가져와 실제 좌석 현황을 살펴볼 수 있다.

단점으로는, 기존의 우송 도서관 웹 서비스가 웹에서 좌석 예약이 불가능하여 해당 사이드 프로젝트에서 좌석을 예약하는 기능은 당연히도 할 수 없었다.

3D 객체들을 최대한 재사용 하며 배치하였고, 미니맵과 객체 위치를 동기화 하는 등을 구현한 3D 좌석 확인 서비스이다.


배포 링크

무료로 상시 배포 상태를 유지하기 위해 vercel을 사용했으며, 이에 따라 웹소켓을 사용한 채팅 기능은 빠져있다. (vercel은 서버리스이기 때문)
https://wslib.vercel.app

react-three-fiber


mesh 생성

리액트에서 three.js의 mesh를 생성하는 기본적인 코드이다. 바닐라 자바스크립트에서 사용하는 코드와 비교해봤을 때, JSX문법을 통해 가독성이 좋으며 mesh 생성이 쉽다.

three.js 코드

 // 박스 지오메트리(BoxGeometry) 생성
const geometry = new THREE.BoxGeometry(1, 1, 66);

// 메쉬 피지컬 머티리얼(MeshPhysicalMaterial) 생성
const material = new THREE.MeshPhysicalMaterial({
  color: 0x6e6e6e,
  roughness: 0.5,
  metalness: 0.1,
});

// 메쉬(Mesh) 생성
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

r3f 코드

<mesh>
  <boxGeometry args={[1, 1, 66]} />
  <meshPhysicalMaterial
	color="#6e6e6e"
    roughness={0.5}
    metalness={0.1}
   />
</mesh>

material 유리 효과

material의 transparent속성을 true로 하면 유리 효과를 구현할 수 있다. 해당 기능을 테스트 해본 결과 유리 효과는 성능 비용이 크기 때문에(더 많은 계산과 버퍼 처리 등) 나의 프로젝트에는 적용하지 않았다.

유리효과 대신 재질을 푸른색으로 하고 roughnessmetalness를 조절하여 뿌옇게 된 유리 효과로 만들었다.

유리 효과

 <meshPhysicalMaterial transparent/>

성능 대체 유리

 <meshPhysicalMaterial color="#bee5fe" roughness={0.3} metalness={1}/>

mesh 앞, 뒤, 양면 렌더링

three.js에는 성능 향상을 위해 앞면, 뒷면, 양면으로 보이게 선택할 수 있다. 앞면과 뒷면 렌더링이 비교적 성능적으로 좋다.

material의 side 속성으로 THREE.FrontSide(앞면), THREE.BackSide(뒷면), THREE.DoubleSide(양면) 을 지정할 수 있다.

<meshStandardMaterial side={THREE.BackSide} />

r3f 애니메이션

requestAnimationFrame을 사용하여 애니메이션 루프를 구현 r3f Hooks이다.

콜백함수의 인자 state는 scene, camera 등의 정보를 가져올 수 있다. delta는 각 프레임 간의 시간 간격을 초 단위로 측정한 값으로 부드러운 애니메이션을 구현할 수 있다.

다음은 메시를 회전시키는 애니메이션이다.

function AnimatedBox() {
  const meshRef = useRef();

  useFrame((state, delta) => {
    // 카메라 위치를 설정
    state.camera.position.set(0, 0, 0);
    // delta: 이전 프레임과 현재 프레임 사이의 시간 차이 (초 단위)
    if (meshRef.current) {
      // 초당 1 라디안씩 회전한다고 가정
      meshRef.current.rotation.x += delta;
      meshRef.current.rotation.y += delta;
    }
  });

  return (
    <mesh ref={meshRef}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color="orange" />
    </mesh>
  );
}});

react-three-fiber drei


3D 객체 렌더링

useGLTF 함수를 통해 외부의 3D 객체를 가져올 수 있다. 3D 객체 파일은 .glb, .gltf, .fbx 파일 등 다양한 확장자로 존재하지만 three.js에서는 .glb.gltf 파일을 권장한다.

useGLTF 예제

import { useGLTF } from "@react-three/drei";
import chairGlb from "shared/asset/3d/chair.glb";

const { nodes, materials } = useGLTF(`${chairGlb}`);

캐릭터 외형 및 움직임 모션

캐릭터 외형과 움직임은 ReadyPlayerMe 라이브러리와 Mixamo 서비스를 통해 구현했다.

캐릭터 외형

ReadyPlayerMe 라이브러리는 커스텀 캐릭터 외형을 .glb파일로 가져올 수 있다.

캐릭터 움직임 모션

Mixamo 에서 다양한 애니메이션을 .fbx파일 등으로 가져올 수 있다. Mixamo는 ReadyPlayerMe에서 만든 객체를 자동으로 리깅(rigging)해주는 서비스를 제공하여 편리하게 애니메이션을 만들 수 있다.

캐릭터 외형의 .glb 파일에 애니메이션이 부착되는 것이 아닌 캐릭터 외형 .glb파일과 .fbx파일을 r3f 훅스로 결합하여 재생하는 방식으로 개발한다.

코드 예제

const { scene: character } = useGLTF(`${avatar}`);
const { animations: standing } = useFBX(StandingFBX);
const { animations: walking } = useFBX(WalkingFBX);

const { actions: standingAction } = useAnimations(standing, scene);
const { actions: walkingAction } = useAnimations(walking, scene);

//방향 키보드를 누르고 있는지 확인하는 커스텀 훅스
const { isMoving } = useMoving(player);

useEffect(() => {
    const standingAnimation = standingAction["mixamo.com"];
    const walkingAnimation = walkingAction["mixamo.com"];
    if (isMoving) {
      walkingAnimation?.play();
    } else {
      walkingAnimation?.stop();
      standingAnimation?.play();
    }
  }, [standingAction, walkingAction, isMoving]);

PlaneGeometry 재질 바꾸기

도서관의 바닥은 대리석 비슷한 재질의 타일이 반복된다. 그러므로 실제 바닥 이미지를 repeat 하여 반복시켜주면 된다.

import * as THREE from "three";
import { useTexture } from "@react-three/drei";
import ZoneFloorTexture from "shared/asset/image/zoneFloor.png";

export const ZoneFloor = () => {
  const tile = useTexture(`${ZoneFloorTexture}`);
  tile.repeat.set(30, 50);
  //텍스처 모양의 가로 세로 비율을 자연스럽게 맞추는 코드
  tile.wrapS = tile.wrapT = THREE.RepeatWrapping;

  return (
    <mesh rotation={[-Math.PI / 2, 0, 0]}>
      <planeGeometry args={[15, 25]} />
      <meshStandardMaterial map={tile} />
    </mesh>
  );
};

3D 객체 복사

도서관에는 무수히 많은 의자 객체들과 테이블 등이 존재한다. 그러나 이 방식은 성능 향상에 도움을 주지 않기 때문에 해당 방식은 사용하지 않았다.

const { nodes } = useGLTF(url);

return (
  <div>
    {Array.from({length: 5}).map((_, i) => (
    	<Clone object={nodes.table} />
    ))}
  </div>
)

3D 객체 복사 (성능)

3D 객체를 가져오는 것 자체로 많은 성능 비용을 요구한다. 이런 객체를 400개 생성한다고 하면 웹 사이트가 버틸 수 없다.

그래서 three.js는 한 번의 드로우 콜로 수십만 개의 객체를 정의할 수 있는 InstacedMesh를 제공한다. Instance는 성능적으로 중요한 역할을 하기 때문에 잘 알아두는 것이 좋다.

Instance 사용 예제

Instance는 오직 하나의 Geometry 와 Material 만을 가질 수 있다. position, rotation 등은 geometry가 아닌 <Instance /> 속성에 정의하면 된다.

//컴포넌트로 geometry, material을 전달하는 방법
return (
    <Instances>
      <boxGeometry args={[1, 1, 1]} />
      <meshPhysicalMaterial color="white"/>
      {Array.from({ length: 5 }).map((_, i) => (
        <Instance position={[0, i, 0]} />
      ))}
    </Instances>
  );


//Instances의 속성으로 geometry, material을 전달하는 방법
//gltf 모델을 Instance할 경우 속성으로 geometry, material을 전달하면 된다.
const { nodes, materials } = useGLTF(`${chairGlb}`);

return (
    <Instances
      geometry={nodes.office_chair_Material_0.geometry}
      material={materials.Material}
    >
      {Array.from({ length: 5 }).map((_, i) => (
        <Instance position={[0, i, 0]} />
      ))}
    </Instances>
  );

Instance의 치명적인 단점

Instance와 mesh(geometry, material)은 1대1로 매핑된다. 즉 하나의 인스턴스에 하나의 geometry와 material을 사용할 수 있는 것이다.

테이블 같은 경우 상판 1개, 다리 4개로 boxGeometry가 총 5개 필요하다. 이러한 경우 <Instances />를 5번 반복해야 할까? 이러한 경우 Merged를 사용하면 된다.

Merged

한 번의 드로우 콜로 Merged 안의 mesh들을 정의할 수 있다.

import * as THREE from "three";
import { Merged } from "@react-three/drei";

export const Table = () => {
  const table = new THREE.Mesh(
    new THREE.BoxGeometry(8, 0.1, 3),
    new THREE.MeshPhysicalMaterial()
  );
  const leg = new THREE.Mesh(
    new THREE.BoxGeometry(0.1, 1, 0.1),
    new THREE.MeshPhysicalMaterial()
  );

  return (
    <Merged meshes={[table, leg]}>
      {(Table: any, Leg: any) => (
        <>
          <Table position={[0, 0.5, 0]} />
          <Leg position={[4, 0, 1.5]} />
          <Leg position={[4, 0, -1.5]} />
          <Leg position={[-4, 0, 1.5]} />
          <Leg position={[-4, 0, -1.5]} />
        </>
      )}
    </Merged>
  );
};

Merged vs Instance

1500개의 boxGeometry를 그리는데 MergedInstance의 성능 비교 테스트를 하였다.

Instance 사용

{
  Array.from({ length: 1500 }).map((_, i) => (
    <Instances>
      <boxGeometry args={[0.1, 1, 0.1]} />
      <meshPhysicalMaterial />
      <Instance position={[i / 100, 0, 0]} />
    </Instances>
  ));
}

Merged 사용

export const Table = (props: Props) => {
  const leg = new THREE.Mesh(
    new THREE.BoxGeometry(0.1, 1, 0.1),
    new THREE.MeshPhysicalMaterial()
  );

  return (
    <group {...props}>
      <Merged meshes={[leg]}>
        {(Leg: any) => (
          <>
            <Leg position={[0, 0, 0]} />
          </>
        )}
      </Merged>
    </group>
  );
};
{
  Array.from({ length: 1500 }).map((_, i) => (
    <Table position={[i / 100, 0, 0]} />
  ));
}

MergedInstance는 저사양 PC 기준 약 3~4FPS 차이로 Instance가 약간 빨랐다. 위의 테스트 말고도 100개의 테이블 Geometry로 테스트(5개의 Instance vs Merged) 했을 때도 동일하게 성능이 크게 차이나지 않았다.

결론

하나의 mesh만 반복해서 생성 한다면 Instance를, 여러 개의 mesh를 합쳐서 반복해서 생성 한다면 Merged를 사용하는 등의 상황에 맞게 사용하면 될 것 같다.

키보드 이벤트

캐릭터를 움직이기 위해 r3f에서 키보드 컨트롤을 제공한다.

키 컨트롤 바인딩

KeyboardControls안에 Canvas를 감싸준다.

<KeyboardControls
  map={[
    { name: "up", keys: ["ArrowUp", "KeyW"] },
    { name: "down", keys: ["ArrowDown", "KeyS"] },
    { name: "left", keys: ["ArrowLeft", "KeyA"] },
    { name: "right", keys: ["ArrowRight", "KeyD"] },
    { name: "jump", keys: ["Space"] },
  ]}
>
  <Canvas>
    ...
  </Canvas>
</KeyboardControls>;

키 컨트롤 사용

키를 누르면 해당 키의 name이 true상태 이며, 키를 누르지 않은 상태는 false이다.

useFrame(() => {
    const { up, down, left, right, jump } = get();
    // 각각의 키가 true일 경우 캐릭터의 position을 변경하는 등의 작업
  	...
})

마우스로 카메라 제어

PointerLockControls API로 FPS 게임 처럼 마우스 포인터를 제거하고, 마우스가 움직이는 방향으로 카메라를 제어할 수 있는 기능을 제공 해 준다.

PointerLockControls

Canvas 태그 안에 포함 시킨다.

<Canvas>
  <PointerLockControls />
</Canvas>

에러

PointerLockControls에는 마우스 포인터를 잠긴 상태를 해제 후, 즉시 마우스 포인터를 잠그면 에러가 발생한다.

해당 에러는 구글링 결과 내부적인 에러로, 우리가 할 수 있는건 포인터 해제 후 2초 동안 포인터를 잠글 수 없도록 막는 것이다.

카메라 자유 시점

마우스로 카메라 줌, 이동, 회전 등을 제어할 수 있는 기능을 제공한다.

나는 특히 개발할 때, 도서관의 전체적인 외형이나 3D 객체의 position을 잡을 때 유용하게 사용하였다.

<Canvas>
  <OrbitControls
  	enableZoom={false} // 줌 기능을 비활성화
  	rotateSpeed={0.3} // 회전 속도를 설정
  	minPolarAngle={1.3} // 카메라의 최소 수직 회전 각도 (위쪽 제한)
  	maxPolarAngle={1.4} // 카메라의 최대 수직 회전 각도 (아래쪽 제한)
	/>
</Canvas>

FPS 확인

<Stats />은 현재의 FPS를 알려주는 기능을 제공한다.

JSX의 아무 곳에 선언하면 된다.

import { Stats } from "@react-three/drei";

<Stats />

배경 설정

배경을 쉽게 적용하기 위해 SkyEnvironment API가 제공된다.

Sky

Canvas안의 배경을 실제 하늘 처럼 보이게 해준다.

<Canvas>
  <Sky />
</Canvas>

Environment

city, apartment, park 등 여러가지 preset을 통해 간편하게 배경을 설정할 수 있다.

<Canvas>
  <Environment preset="city" background={true}/>
</Canvas>

Mesh Hover시 커서 변경

3D 객체들은 html의 canvas 태그 안에 정의되기 때문에 css를 적용하기 어렵다. 그래서 r3f는 useCursor로 메시에 hover했을 때, cursor: pointer효과를 적용 시킬 수 있다.

export const ThreeJsComponent = () => {
  useCursor(true); //true일 때, cursor: pointer

  return (
    <>
    ...
    </>
  )
}

react-three-fiber rapier


Physical 환경

r3f rapier은 중력, 충돌 등 물리학 구현을 도와주는 라이브러리이다. PhysicsRigidbody라는 중요한 개념이 존재한다.

Physics

물리학이 적용되는 공간이다. Physics태그로 감싸진 Rigidbody들이 충돌이나 중력 등의 물리학적인 상호작용이 가능하게 된다.

Rigidbody

물리학이 적용되는 물체이다. Rigidbody태그로 mesh를 감싸 해당 mesh를 물리적인 물체로 생성할 수 있다.

사용 예제

박스 모양 Rigidbody 물체 코드 예제이다. Rigidbody또한 위치와 회전 속성 등을 적용할 수 있다.

Rigidbodytype="fixed"로 적용하면 다른 Rigidbody 객체가 밀거나 중력의 힘 등을 받지 않고 고정되어 있는 물체로 바뀐다.

Physicsdebug속성을 true로 하면 충돌체들의 경계선이 충돌체들의 관리가 쉬워진다.

<Physics debug>
  <RigidBody type="fixed" position={[0, 0, 0]} >
    <Box args={[1, 1, 1]} />
  </RigidBody>
</Physics>

Invisible 충돌체 만들기

RigidbodyincludeInvisible속성과 meshvisible="false"하여 보이지 않은 충돌체를 생성할 수 있다.

코드 예제

  <RigidBody type="fixed" position={[0, 0, 0]} includeInvisible>
    <Box args={[18, 1, 40]} visible={false} />
  </RigidBody>

사이드 프로젝트에서 모든 mesh들을 충돌체로 바꿔 좀 더 사실적인 환경을 구성할 수 있었지만. 충돌체도 상당히 비용이 크다. 따라서 모든 mesh들에 물리학 적용을 하지 않고, 물리학을 적용하는 객체를 최소화 시켰다.

예를 들어, 캐릭터는 그대로 Rigidbody를 적용하고, 바닥, 벽, 유리 등은 보이지 않은 충돌체를 만들어 동일한 위치에 적용 시켰다.

또한 보이지 않은 충돌체의 개수도 최소화 시켰다. 예를 들어, 4개의 유리판이 나란히 세워져 있는 경우 굳이 4개의 충돌체를 생성하는 것이 아닌 width를 4배 키우는 형식으로 구현했다.

충돌체 복사

InstancedRigidBodies를 사용하여 Rigidbody 또한 복사가 가능하다.
InstancedRigidBodiesinstancedMesh를 감싸는 형태이고, 속성으로 충돌체 모양, 정의한 Instance, 개수 등을 설정할 수 있다.

사용 예제

자세한 예제는 공식문서를 확인하도록 하고, 여기서는 간단하게만 알아보자.

<InstancedRigidBodies
      ref={rigidBodies}
      instances={instances}
      colliders="ball"
    >
  <instancedMesh args={[undefined, undefined, COUNT]} count={COUNT} />
</InstancedRigidBodies>

성능

물리학을 적용한 충돌체의 연산이 복잡한 탓인지, InstancedMesh에 비해 성능을 잡을 수 없었다.

도서관 프로젝트의 테이블과 의자의 개수가 총 500개 이상으로 InstancedRigidBodies를 테스트 해 보았을 때, 정상적으로 이용할 수 없을 정도로 느렸기 때문에, 의자와 테이블에 Instance 충돌체 적용을 제외 시켰다.

사이드 프로젝트에서 캐릭터가 도서관 의자와 테이블을 관통하는 이와 같은 이유이다.

other


.glb, .fbx, .module.css 등을 import 하는 법

3D 모델 파일을 import 해야하는 상황이 있을 것 이다. src폴더 내부에 .d.ts 파일에 다음과 같은 코드를 작성한다.

declare module "*.module.css" {
  const content: Record<string, string>;
  export default content;
}

declare module "*.jpg";
declare module "*.png";
declare module "*.jpeg";
declare module "*.gif";

declare module "*.gltf";
declare module "*.glb";

declare module "*.fbx"

이러면 다양한 확장자의 파일들을 import 할 수 있다.

렌더링 후 상태가 바뀌어 리렌더링 시 객체들이 안보이는 현상

3D 객체 개수를 사이드 이펙트로 Fetch하는 경우에 에러가 발생 하였다. 마운트 때 개수가 undefined이고 commit 단계에서 개수가 지정되어 3D 객체가 렌더링 되는 경우 카메라가 3D 객체의 일부만 벗어나도 3D 객체 배열의 요소 전부가 안보여지게 되는 현상이다.

이런 경우 리렌더링 시 렌더링이 변하지 않게 코드를 구성하거나 useLayoutEffect를 사용하면 효과적이였다.

three.js에서 모달 창 관리

three.js는 3D 객체들을 canvas 안에서 렌더링 하기 때문에 sidebar나 modal등은 canvas 밖에서 관리 해야한다. 3D 컴포넌트 내부에서 모달 창을 켜려면 어떻게 해야 할까?

zustand

전역 상태 관리 라이브러리인 zustand를 사용하여 openModal 상태 저장소를 생성하여 3D 컴포넌트 내부에서 setOpenModal을 사용하여 canvas 밖의 modal을 여는 방식으로 관리 하였다.

또한 모달 창은 각각의 개별적인 모달창을 여러 개 생성한 것이 아닌, 오직 하나만 사용하고, 모달 안의 내부 요소 UI를 교체하는 방식으로 하였다.

데이터

wslib의 좌석 데이터는 우송 도서관 앱에서 요청한 api를 찾아 나의 앱에서 요청을 하였고, 받은 데이터를 가공시켜 좌표 값을 설정하였다.

0개의 댓글