https://fastcampus.co.kr/dev_online_3dinteractive

 

더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트

5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터랙티브 웹 강의. 3D 인터랙티브 웹의 근본 Three.js부터 최근 주목받는 React기반의 R3F까지! 더 쉽게, 하지만 더 다양한 기능을 포함한 3D 인

fastcampus.co.kr

https://designct.tistory.com/6

 

패스트 캠퍼스 강의 촬영 회고록 - 개발편 2(R3F + Scroll Animation + gsap)

https://fastcampus.co.kr/dev_online_3dinteractive 더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터

designct.tistory.com

강의 자료 중 3D SNS 프로젝트를 진행했을 때에 대한 회고를 작성하고자 한다.

3D SNS 페이지  

3D SNS는 앞서 배운 Threejs, R3F와 더불어, 다양한 hooks 및 유틸함수를 활용하여 제작되었다.

 

이 프로젝트에서 짚고 넘어가야 할 핵심 구현 요소는 크게 4가지이다.

1. useFrame 훅의 활용
2. SkeletonUtils와 useGraph 훅을 활용한 효율적 복제
3. Canvas 3D Scene 상에서의 Pointer(Mouse) 이벤트 처리
4. @react-three/cannon 라이브러리 및 물리엔진 처리

 

1. useFrame 훅의 활용

useFrame 훅은 Javascript의 requestionAnimationFrame 함수의 기능을 사용할 수 있게 해주는 hook이다.

useFrame((state, delta) => {
  // 매 프레임마다 실행될 코드
},1 // 우선순위 
)

파라미터로 오는 state에는 useThree훅과 같이, 현재 scene, clock, camera 등의 상태를 활용할 수 있도록 값이 포함되어 있다.

delta값은 이전 프레임과 현재 프레임과의 시간 차이 값으로써, 프레임이 다른 화면에서 동일한 효과 혹은 기능을 구현해야 할 때 이용 가능하다.

마지막에 optional 하게 넣어줄 수 있는 우선순위 값은 값이 작을수록 우선순위가 높다.

 

useFrame 내부에서는, React.useRef를 사용한 props를 참조해야 과도한 리렌더를 막을 수 있다.

-> useFrame 내부에서 setState 함수를 함부로 사용할 경우, 매 프레임마다 리렌더 되는 짜릿함을 맛볼 수 있다.

 

이 프로젝트에서는,

여러 유저가 캔버스 상에 접속한 게임화면 같은 UI가 필요했으므로,

내 화면에서 Socket으로 전송된 다른 유저들의 위치를 동기화하거나,

내 캐릭터를 이동했을 때 해당 위치를 애니메이션을 실행하며 실제로 이동하는 과정을 보여주기 위해 주로 사용했다.

useFrame(({ camera }) => {
    if (!player) return;
    if (!playerRef.current) return;
    if (playerRef.current.position.distanceTo(position) > 0.1) {
      const direction = playerRef.current.position
        .clone()
        .sub(position)
        .normalize()
        .multiplyScalar(0.04);
      playerRef.current.position.sub(direction);
      playerRef.current.lookAt(position);

      if (point) {
        point.style.transform = `translate(
          ${calculateMinimapPosition(playerRef.current.position).x}px,
          ${calculateMinimapPosition(playerRef.current.position).y}px
          )`;
      }

      setAnimation("CharacterArmature|CharacterArmature|CharacterArmature|Run");
    } else {
      setAnimation(
        "CharacterArmature|CharacterArmature|CharacterArmature|Idle"
      );
    }
    
    ... 중략 ...
    
}

 

다소 복잡해 보일 수 있지만, 핵심은

위치 값이 현재 위치보다 0.1 이상 변동이 생긴 playerRef가 새로운 목적지 위치를 바라보고,

일정한 벡터씩 그 목적지를 향해 다가가는 로직이 매 프레임마다 실행되도록 구현했다는 점이다. 

 

2. SkeletonUtils.clone 함수와 useGraph 훅을 활용한 효율적 복제

우선 이 프로젝트는 아래 동영상에서 확인 가능한 것처럼, 여러 유저가 동시에 접속하고,

각 유저마다 화면에 그 유저가 이동한 위치가 동기화되어야 하는 것이 중요한 기능이다.

 

현재 구현된 프로젝트에는 캐릭터의 종류가 3종류이다.

유저가 999명이 접속했고, 3종류의 캐릭터를 골고루 333명씩 나눠서 골랐다고 해보자.

이때 scene상에 저 캐릭터 모델링을 정직하게 999번 렌더링 해준다면, 상당히 비효율적일 것이다.

어차피 똑같이 생긴 캐릭터인데 캐릭터 모델링 당 1번씩 총 3번만 그려주면 좋지 않을까?

간단하게, 이 굳이 또 안 만들어도 되는 모델링의 뼈와 살을 복제해서, 이미 scene상에 존재하는 캐릭터를 신규유저가 골랐다면,

복제된 뼈(geometry)와 살(material)을 재활용해서, 위에 닉네임만 새 걸로 갈아치워 주는 방법을 택하면 아주 효율적일 것이다.

// 실제 강의 자료 코드
// usePalyer.ts
 const { scene, materials, animations } = useGLTF('modelings.glb')
 const clone = useMemo(() => SkeletonUtils.clone(scene), []);
 const objectMap = useGraph(clone);
 const nodes = objectMap.nodes as any;
 
 ...중략...
 
 return { nodes }
 
 
 // Player.tsx

const {
    me,
    nicknameRef,
    playerRef,
    memoizedPosition,
    playerId,
    nodes, // 복제된 뼈와 살
    materials,
    setCurrentMyRoomPlayer,
  } = usePlayer({
    player,
    position,
    modelIndex,
  });

  <group
    ref={playerRef}
    position={memoizedPosition}
    name={playerId ?? ""}
    onClick={(e: ThreeEvent<MouseEvent>) => {
      e.stopPropagation();
      if (me?.id !== playerId) {
        setCurrentMyRoomPlayer(player);
      }
    }}
    dispose={null}
  >
    <group name="Root_Scene">
      <group name="RootNode">
        <group
          name="CharacterArmature"
          rotation={[-Math.PI / 2, 0, 0]}
          scale={100}
        >
          <primitive object={nodes.Root} />
        </group>
        <skinnedMesh
          castShadow
          receiveShadow
          name="Character"
          geometry={nodes.Character.geometry}
          material={
            modelIndex === 1 ? materials["Atlas.001"] : materials.Atlas
          }
          skeleton={nodes.Character.skeleton}
          rotation={[-Math.PI / 2, 0, 0]}
          scale={100}
        />
  </group>
 </group>
</group>

위 코드를 보면, useGLTF 훅으로 얻어온 모델링 오브젝트를 다시 

SkeletonUtils.clone 함수로 복제한 후, (뼈와 살을 복제)

그것을 useMemo 훅을 통해 메모이제이션 한 후, (첫 렌더시에만 한 번 실행되도록)

useGraph 훅을 통해, Threejs의 내부 그래프를 React 생태계에 맞는 구조로 변환해 주었고, (threejs scene graph를 react의 virtual DOM과 호환이 잘되는 선언적 형태로 변환)

최종적으로React화 된 복제된 모델링의 geometry와 material을 r3f의 새로운 group 할당해준 것이다. (껍데기 group에 실제 뼈와 살을 불어넣어줌)

3. Canvas 3D Scene 상에서의 Pointer(Mouse) 이벤트 처리

이 프로젝트에는 위 영상처럼, 내 방에 기술스택 혹은 가구 등을 배치하는 기능도 구현했다.

이러한 event 함수 처리에 있어서는 명령형 프로그래밍 기법을 불가피하게 사용했다.

// 실제 강의 자료 코드

// 내 방에 가구를 배치하는 이벤트 핸들러

useEffect(() => {
    if (!ref.current) return;

    gsap.to(ref.current.scale, {
      duration: 0.5,
      repeat: -1,
      yoyo: true,
      x: 1.2,
      y: 1.2,
      z: 1.2,
    });
    const handlePointerMove = (e: PointerEvent) => {
      const { clientX, clientY } = e;
      const { x, y } = calculateThreePosition({ clientX, clientY });
      const rayCaster = new THREE.Raycaster();
      rayCaster.setFromCamera(new THREE.Vector2(x, y), camera);
      const [intersect] = rayCaster
        .intersectObjects(scene.children)
        .filter((item) => item.object.name !== "placing");
      intersect.normal?.clone();
      let roomTouched = false;
      let xOffset = 0;
      let yOffset = 0;
      let zOffset = 0;
      if (!intersect.normal) return;

      // 현재 rayCaster에 잡힌 첫번째 오브젝트의 법선벡터와 3축의 벡터가 평행하다면 각 축에 맞는 offset을 더해준다.
      if (1 - Math.abs(intersect.normal.clone().dot(floorVector)) < 0.1) {
        roomTouched = true;
        yOffset = myRoomSkillBoxSize / 2 + 0.01;
        if (intersect.point.x < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          xOffset += Math.abs(
            intersect.point.x + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.x > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          xOffset -= Math.abs(
            intersect.point.x - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.z < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          zOffset += Math.abs(
            intersect.point.z + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.z > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          zOffset -= Math.abs(
            intersect.point.z - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
      }
      if (1 - Math.abs(intersect.normal.clone().dot(leftWallVector)) < 0.1) {
        roomTouched = true;
        xOffset = myRoomSkillBoxSize / 2 + 0.01;
        if (intersect.point.y < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          yOffset += Math.abs(
            intersect.point.y + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.y > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          yOffset -= Math.abs(
            intersect.point.y - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.z < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          zOffset += Math.abs(
            intersect.point.z + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.z > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          zOffset -= Math.abs(
            intersect.point.z - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
      }
      if (1 - Math.abs(intersect.normal.clone().dot(rightWallVector)) < 0.1) {
        roomTouched = true;
        zOffset = myRoomSkillBoxSize / 2 + 0.01;
        if (intersect.point.x < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          xOffset += Math.abs(
            intersect.point.x + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.x > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          xOffset -= Math.abs(
            intersect.point.x - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.y < -(myRoomSize / 2 - myRoomSkillBoxSize / 2)) {
          yOffset += Math.abs(
            intersect.point.y + (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
        if (intersect.point.y > myRoomSize / 2 + myRoomSkillBoxSize / 2) {
          yOffset -= Math.abs(
            intersect.point.y - (myRoomSize / 2 + myRoomSkillBoxSize / 2)
          );
        }
      }
      if (intersect && roomTouched) {
        ref.current?.position.set(
          intersect.point.x + xOffset,
          intersect.point.y + yOffset,
          intersect.point.z + zOffset
        );
      }
    };

    const handlePointerUp = () => {
      const myRoomObjects = getMyRoomObjects(
        scene,
        `my-room-${currentPlacingMyRoomSkill}`
      );
      socket.emit(
        "myRoomChange",
        {
          objects: [
            ...myRoomObjects,
            {
              // eslint-disable-next-line @typescript-eslint/no-explicit-any
              name: `my-room-${currentPlacingMyRoomSkill}`,
              position: [
                ref.current!.position.x,
                ref.current!.position.y,
                ref.current!.position.z,
              ],
              rotation: [
                ref.current!.rotation.x,
                ref.current!.rotation.y,
                ref.current!.rotation.z,
              ],
            },
          ],
        },
        currentMyRoomPlayer?.id
      );
      setCurrentPlacingMyRoomSkill(undefined);

      // socket.emit 하기 배치했음을 알려야함
    };

    gl.domElement.addEventListener("pointermove", handlePointerMove);
    gl.domElement.addEventListener("pointerup", handlePointerUp);
    return () => {
      gl.domElement.removeEventListener("pointermove", handlePointerMove);
      gl.domElement.removeEventListener("pointerup", handlePointerUp);
    };
  }, [
    camera,
    currentMyRoomPlayer?.id,
    currentPlacingMyRoomSkill,
    gl.domElement,
    scene,
    scene.children,
    setCurrentPlacingMyRoomSkill,
    texture,
  ]);

 

시간이 지난 현재, 이를 비교적 선언적인 코드로 바꿀 수도 있겠다는 생각을 했고,

// 선언적으로 바꾼 코드의 루트 코드
import React, { useCallback } from 'react';
import { useThree } from '@react-three/fiber';
import { useTexture } from '@react-three/drei';
import { useRecoilState, useRecoilValue } from 'recoil';
import * as THREE from 'three';
import { CurrentMyRoomPlayerAtom, CurrentPlacingMyRoomSkillAtom } from '../../../store/PlayersAtom';
import { myRoomSkillBoxSize } from '../../../data/constants';
import { useSkillPlacement } from '../hooks/useSkillPlacement';
import { PlaceableBox } from './PlaceableBox';

const MyRoomSkillPlaceMode: React.FC<{ currentPlacingMyRoomSkill: string }> = ({ currentPlacingMyRoomSkill }) => {
  const { scene } = useThree();
  const currentMyRoomPlayer = useRecoilValue(CurrentMyRoomPlayerAtom);
  const [, setCurrentPlacingMyRoomSkill] = useRecoilState(CurrentPlacingMyRoomSkillAtom);
  const texture = useTexture(`/images/skills/${currentPlacingMyRoomSkill}.webp`);
  
  const handlePlacement = useCallback((position: THREE.Vector3) => {
    // Handle the placement logic here
    console.log('Skill placed at', position);
    setCurrentPlacingMyRoomSkill(undefined);
    // Emit socket event here
  }, [setCurrentPlacingMyRoomSkill]);

  const { position, isPlaceable } = useSkillPlacement(scene, myRoomSkillBoxSize);

  return (
    <PlaceableBox
      size={myRoomSkillBoxSize}
      position={position}
      isPlaceable={isPlaceable}
      onPlace={handlePlacement}
      texture={texture}
    />
  );
};

export default MyRoomSkillPlaceMode;
// 이벤트 핸들러 처리 훅

import { useState, useEffect } from 'react';
import * as THREE from 'three';
import { useThree } from '@react-three/fiber';

export const useSkillPlacement = (scene: THREE.Scene, boxSize: number) => {
  const { camera, raycaster, pointer, gl } = useThree();
  const [position, setPosition] = useState(new THREE.Vector3());
  const [isPlaceable, setIsPlaceable] = useState(false);

  useEffect(() => {
    const handlePointerMove = () => {
      raycaster.setFromCamera(pointer, camera);
      const intersects = raycaster.intersectObjects(scene.children, true);
      
      if (intersects.length > 0) {
        const intersect = intersects[0];
        const adjustedPosition = calculateAdjustedPosition(intersect.point, intersect.normal, boxSize);
        setPosition(adjustedPosition);
        setIsPlaceable(checkIsPlaceable(adjustedPosition, scene, boxSize));
      }
    };

    gl.domElement.addEventListener('pointermove', handlePointerMove);
    return () => gl.domElement.removeEventListener('pointermove', handlePointerMove);
  }, [camera, raycaster, pointer, scene, boxSize]);

  return { position, isPlaceable };
};

const calculateAdjustedPosition = (point: THREE.Vector3, normal: THREE.Vector3, boxSize: number) => {
 ...중략...
  return point.clone().add(normal.multiplyScalar(boxSize / 2));
};

const checkIsPlaceable = (position: THREE.Vector3, scene: THREE.Scene, boxSize: number) => {
 ...중략...
  return true; // Placeholder
};
// 회고를 준비하며 선언적으로 가구 배치 코드

export const PlaceableBox = ({ size, position, isPlaceable, onPlace, texture }:PlaceableBoxProps) => {
  const meshRef = useRef<THREE.Mesh>(null);

  useFrame(() => {
    if (meshRef.current) {
      meshRef.current.position.copy(position);
      meshRef.current.material.color.setHex(isPlaceable ? 0x00ff00 : 0xff0000);
    }
  });

  const handlePointerUp = () => {
    if (isPlaceable) {
      onPlace(position);
    }
  };

  return (
    <mesh ref={meshRef} onPointerUp={handlePointerUp}>
      <boxGeometry args={[size, size, size]} />
      <meshStandardMaterial map={texture} transparent opacity={0.7} />
    </mesh>
  );
};

위 코드는 기존 코드에서, 계산 로직 등은 상단 컴포넌트의 훅에서 처리하고,

말 그대로 계산이 뚝딱뚝딱 끝난 결과만을 useFrame훅에서 매 프레임마다 업데이트해주는 식으로 바꿔본 코드이다.

 

회고를 통해, 강의 자료 제작 당시에는 미처 생각하지 못했던 방안을 떠올리기도 했다는 점이 재밌었다.

 

 

4. @react-three/cannon 라이브러리 및 물리엔진 맛보기 및 Instancing

사실 물리엔진을 사용했다고 하기엔 찍먹(?) 수준의 맛보기만 해볼 수 있도록 강의 자료를 구성했다.

실무에서 물리엔진을 사용해 본 적은 없었으므로, 이 기능의 구현을 위해 급하게 공부를 한 후 자료를 만들었다.

// 총알 컴포넌트

import { PublicApi, useSphere } from "@react-three/cannon";
import { useEffect } from "react";
import { Mesh } from "three";

type BulletProps = {
  position: number[];
  shoot: (api: PublicApi) => void;
};

export const Bullet = ({ position, shoot }: BulletProps) => {
  const [ref, api] = useSphere<Mesh>(() => ({
    mass: 10,
    position: [position[0], position[1], position[2]],
    collisionFilterGroup: 1,
    collisionFilterMask: 2,
    allowSleep: false,
  }));

  useEffect(() => {
    if (ref.current) shoot(api);
  }, [api, ref, shoot]);

  return (
    <mesh
      name="bullet"
      ref={ref}
      position={[position[0], position[1], position[2]]}
    >
      <sphereGeometry attach="geometry" args={[0.5, 32, 32]} />
      <meshStandardMaterial attach="material" color="red" />
    </mesh>
  );
};

위는 총을 쐈을 때 날아가는 총알을 구현한 컴포넌트이다.

위 useSphere 함수 내에 있는 속성들은

mass는 객체의 질량
position는 객체의 초기 위치
collisionFilterGroup는 객체가 속한 충돌그룹

collisionFilterMask는 객체가 충돌할 수 있는 충돌그룹
allowSleep는 물리 시뮬레이션에서 총알이 "잠들지" 않도록 설정하는 값이다. 위 총알을 false로 설정한 이유는 sleep상태에서 객체와 충돌할 경우 예기치 못한 동작이 일어날 수 있기에 그것을 방지하고자 설정했다.

와 같다.

 

 

// 그룹 정의
const PLAYER = 1;
const ENEMY = 2;
const BULLET = 4;
const WALL = 8;

// 플레이어 설정
const [playerRef] = useBox(() => ({
  collisionFilterGroup: PLAYER,
  collisionFilterMask: ENEMY | WALL,
}));

// 적 설정
const [enemyRef] = useBox(() => ({
  collisionFilterGroup: ENEMY,
  collisionFilterMask: PLAYER | BULLET | WALL,
}));

// 총알 설정
const [bulletRef] = useSphere(() => ({
  collisionFilterGroup: BULLET,
  collisionFilterMask: ENEMY | WALL,
}));

// 벽 설정
const [wallRef] = useBox(() => ({
  collisionFilterGroup: WALL,
  collisionFilterMask: PLAYER | ENEMY | BULLET,
}));

충돌그룹의 경우 이렇게 작성이 되어있다면,

플레이어는 적과 벽에만 충돌 가능.
적은 플레이어, 총알, 벽에 충돌 가능.
총알은 적과 벽에만 충돌 가능.
벽은 모든 객체와 충돌 가능.

하다고 이해하면 된다.

 

또한 수가 그리 많지 않아, 꼭 필요는 없지만

위의 SkeletonUtils.clone 함수와 비슷하게, 총알에 맞을 타깃 구(sphere)에 대한 인스턴싱 처리를 했다.

// 사격게임 코드

... 중략 ...
<instancedMesh>
        {!isMiniGameCleared &&
          randomPositions.map((position, i) => {
            return (
              <TargetMesh
                position={position}
                color={randomColors[i]}
                shapes={randomShapes[i]}
                setHitCount={setHitCount}
              />
            );
          })}
  </instancedMesh>

 

이렇게 되면 TargetMesh는 내부 scene 그래프를 공유하게 된다.

 

물리엔진을 딥하게 이용하는 기능을 토이프로젝트에서 추후 진행해 보며, 다양한 기능을 사용해 보고, 포스팅해 볼 예정이다.

 

다음 포스트에서는 강의 촬영에 관련한 회고를 작성해 볼 예정이다.

https://fastcampus.co.kr/dev_online_3dinteractive

 

더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트

5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터랙티브 웹 강의. 3D 인터랙티브 웹의 근본 Three.js부터 최근 주목받는 React기반의 R3F까지! 더 쉽게, 하지만 더 다양한 기능을 포함한 3D 인

fastcampus.co.kr

이 포스트는 패스트캠퍼스 강의 촬영 회고록 개발편 - 1(Threejs vs. R3F)편에 이어서

 

패스트 캠퍼스 강의 촬영 회고록 - 개발편 1(Threejs vs. R3F)

https://fastcampus.co.kr/dev_online_3dinteractive 더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터

designct.tistory.com

 

강의 자료 중 스크롤에 따라 모델링 에셋이 역동적으로 움직이는 프로젝트를 진행했을 때에 대한 회고를 작성하고자 한다.

스크롤댄서 페이지

스크롤댄서는 앞서 배운 Threejs, R3F 지식을 바탕으로 React, R3F, drei, gsap 등의 라이브러리를 이용하여 

하나의 온전히 돌아가는 프로젝트를 만들어보는 프로젝트이다.

 

이 프로젝트에서는 크게 3개의 핵심 구현 요소가 들어갔다.

관련 예제 코드중 일부는 실제 강의에 쓰인 코드가 아닌, 이 포스트 작성을 위해 임의로 만들었음을 참고하자.

ScrollControls

ScrollControls는 스크롤에 따라, 카메라의 위치를 이동시킬 수 있는 기능을 제공한다.

@react-three/drei 라이브러리에 구현된 컴포넌트이다.

 

@react-three/drei

useful add-ons for react-three-fiber. Latest version: 9.112.0, last published: 12 days ago. Start using @react-three/drei in your project by running `npm i @react-three/drei`. There are 338 other projects in the npm registry using @react-three/drei.

www.npmjs.com

 

주요 특징은 다음과 같다.

3D scene 상에서 ScrollControls 하위에 배치된 요소에 대한 스크롤 기반 카메라 이동을 구현할 수 있다.

<ScrollControls pages={3} damping={0.1}>
  <Scroll>
    {/* 3D 콘텐츠 */}
    <mesh position={[0, 0, 0]}>
      <boxGeometry />
      <meshStandardMaterial />
    </mesh>
  </Scroll>
  <Scroll html>
    {/* HTML 콘텐츠 */}
    <h1 style={{ position: 'absolute', top: '100vh', left: '0.5em' }}>Hello</h1>
  </Scroll>
</ScrollControls>

 

하위의 <Scroll> 컴포넌트에선 html props를 통해,

html이 true면, 이 하위 컴포넌트를 HTML 콘텐츠로 처리하고, css style 적용도 가능해진다.

html이 false면, 이 하위 컴포넌트를 3D 콘텐츠로 간주하여, Threejs Scene 상에서 렌더한다.

이 프로젝트 상에선, 스크롤에 따른 DOM 요소의 스타일을 css로 처리해주는 부분이 필요했으므로

해당 부분에선 html을 true로 처리하였다.

 

또한 useScroll 훅을 이용해, <ScrollControls> 의 children 컴포넌트에서 현재 스크롤 상태에 접근하여 사용했다.

function AnimatedCube() {
  const scroll = useScroll()
  const meshRef = useRef()

  useFrame(() => {
    const { offset } = scroll
    meshRef.current.position.y = offset * 10 - 5 // 스크롤에 따라 Y축 위치 변경
    meshRef.current.rotation.y = offset * Math.PI * 2 // 스크롤에 따라 회전
  })

  return (
    <mesh ref={meshRef}>
      <boxGeometry />
      <meshStandardMaterial />
    </mesh>
  )
}

// 사용
<ScrollControls pages={3}>
  <AnimatedCube />
</ScrollControls>

 

gsap

gsap

 

gsap

GSAP is a framework-agnostic JavaScript animation library that turns developers into animation superheroes. Build high-performance animations that work in **every** major browser. Animate CSS, SVG, canvas, React, Vue, WebGL, colors, strings, motion paths,.

www.npmjs.com

gsap(GreenSock Animation Platform)은 JavaScript 애니메이션 라이브러리이다. 성능적으로도 이점이 있다고 한다.

이 프로젝트에서는, ScrollControls 하위에서 useScroll 훅을 통해 얻은 scroll 정보와 gsap의 타임라인을 이용하여,

scroll에 따라 카메라를 이동시키거나, glb에 내장된 애니메이션을 유동적으로 변경시키는데에 활용했다.

// 실제 강의 예제 코드 중 일부

// gsap 초기 카메라 회전 및 배경 별 반짝임 애니메이션
  useEffect(() => {
    if (!isEntered) return;
    if (!dancerRef.current) return;
    gsap.fromTo(
      three.camera.position,
      {
        x: -5,
        y: 5,
        z: 5,
      },
      {
        duration: 2.5,
        x: 0,
        y: 6,
        z: 12,
      },
    );
    gsap.fromTo(
      three.camera.rotation,
      {
        z: Math.PI,
      },
      {
 ...중략...

}
// gsap 스크롤에 따른 댄서 모델 회전 및 이동 애니메이션
useEffect(() => {
    if (!dancerRef.current) return;
    const pivot = new THREE.Group();
    pivot.position.copy(dancerRef.current.position);
    pivot.add(three.camera);
    three.scene.add(pivot);
    timeline = gsap.timeline();
    timeline
      .from(
        dancerRef.current.rotation,
        {
          duration: 4,
          y: Math.PI,
        },
        0.5,
      )
      ...중략...
      

}

이처럼, gsap 라이브러리의 특성 상, 선언적 프로그래밍은 힘들고, 타임라인에 따른 세부 조작이 불가피했다.

그래서 적용되는 애니메이션의 대상 별로 별개의 useEffect 훅을 만들어 명령형으로 처리하였다.

 

이 부분에서 개별 timeline 애니메이션 조작 로직을 별도 커스텀 훅으로 분리하여 해당 부분을 은닉화하여, 실제 메인 컴포넌트에선 비교적 덜 복잡하게 하면 코드적으로 더 좋지 않았을까 하는 아쉬움이 남는다.(작성일 기준, 현재 @gsap/react 라는 라이브러리가 나왔다.)

 

React.Suspense 및 useProgress 를 활용한 Loader 컴포넌트

// 실제 강의 예제 Canvas 컴포넌트 코드
 <Canvas
      id="canvas"
      gl={{ antialias: true }}
      shadows="soft"
      camera={{
        fov: 30,
        aspect: aspectRatio,
        near: 0.01,
        far: 1000,
        position: [0, 6, 12],
      }}
      scene={{ background: new Color(0x000000) }}
    >
      <Suspense fallback={<Loader />}>
        <GLTFLoader />
        {isEntered ? (
          <ScrollControls pages={8} damping={0.25}>
            <MovingDOM />
            <Dancer />
          </ScrollControls>
        ) : (
          <Loader />
        )}
      </Suspense>
</Canvas>

const GLTFLoader = () => {
  useGLTF('/models/dancer.glb');
  return null;
};

위 코드는 다소 오래걸리는 모델링 glb파일이 비동기적으로 로드되는동안, Suspense처리한 코드이다.

 

export const Loader = () => {
  const setIsEntered = useSetRecoilState(IsEnteredAtom);
  const progress = useProgress();

  return (
    <Html center>
      <BlurredBackground />
      <Container>
        <ProgressBar>{Math.round(progress.progress)}%</ProgressBar>
        {progress.progress === 100 && (
          <EnterBtn
            onClick={() => {
              setIsEntered(true);
            }}
          >
            Enter
          </EnterBtn>
        )}
      </Container>
    </Html>
  );
};

위 코드는 Loader 컴포넌트의 코드이다.

위 코드에 주석에도 작성했듯, 겉보기엔 같은 Loader 컴포넌트이지만,

useProgress 훅을 통해, 현재 glb 파일을 다운로드 중일 때, 즉 %수치가 바뀌는 동안에는 Suspense의 fallback ui로 렌더가 되는 Loader 컴포넌트이고, 100%로 로드가 완료되면, Canvas 컴포넌트에서 isEntered로 분기된 부분에서 렌더되는 Loader컴포넌트이다.

 

만약 로딩이 되는 ux가 별로라면 preload가 대안이 될 수도 있다.

 

이 부분은 강의 자료에는 조금 중복된 Loading 처리로 부자연스러운 로딩이 발생했으나, 추후 수정했음을 알린다.

 

다음 포스트에서는 패스트 캠퍼스 강의 촬영 회고록 - 개발편의 마지막 챕터로, 3D SNS 서비스에서 다루고 스스로 배웠던 점을 회고해보도록 하겠다.

 

https://fastcampus.co.kr/dev_online_3dinteractive

 

더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트

5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터랙티브 웹 강의. 3D 인터랙티브 웹의 근본 Three.js부터 최근 주목받는 React기반의 R3F까지! 더 쉽게, 하지만 더 다양한 기능을 포함한 3D 인

fastcampus.co.kr

패스트 캠퍼스 강의 촬영 개발 회고록 개요

이 포스트는 회고록 개요에 이어서

첫 번째 회고 주제인 강의 자료를 제작함에 있어

개발적으로 혹은 개념적으로 더 확실하게 정리했던 것들을

이 포스트를 작성하며 기록으로 남기고자 한다.

 

내가 맡은 강의 파트는

Threejs의 기초와 R3F(@react-three/fiber), @react-three/drei라이브러리의

기본적인 개념 및 사용법을 다룬 파트와

스크롤 기반에 따라 3D 힙합 댄서가 춤을 추는 웹사이트 만들기 및

 

여기서 배운 것을 바탕으로

3D 아바타들이 채팅 및 내 방 꾸미기, 미니게임 등을 할 수 있는

3D SNS 서비스를 만드는 파트이다.

 

이 포스트에서는, Threejs와 R3F의 기본을 다루었던 파트의 강의자료 제작에 있어서의 개발적 회고를 남기고자 한다.

 

우선 이 강의는 React, Javascript 등의 기본적인 Frontend 개발 지식은 갖고 있지만,

3D 개발에 필요한 threejs 등의 라이브러리는 완전히 처음 배운다는 가정하에 제작되었다.

 

기존에 실무에서 R3F를 활용하여 프로젝트를 진행한 경험이 있었기에 강의 기획을 보고,

다소 만만(?)하게 생각하고 강의 자료를 만들기 시작했으나,

생각보다 나 스스로 기본적인 개념이 탄탄하지 않다는 걸 강하게 느꼈다.

또한, 빠르게 예제를 파악한 후 특정 기능을 개발하는 것이 목표였던 업무와는 달리,

강의는 이것을 듣는 누군가가 빠르게 이해하는 것도 중요하지만,

이 예제뿐 아니라, 기저에 있는 개념을 사용하는 다른 요구사항이 주어지더라도,

유연하게 구현해 내도록 누군가를 제대로 이해시키는 것이 더 중요하다는 것을 깨닫자,

눈에 보이지 않게 가라앉아 있던, 미묘하게 헷갈리는 개념들이 수면 위로 떠오르기 시작했다.


지금부터 작성되는 글을 이해하려면 Threejs 관련 사전지식이 필요하므로, 

필요하다면 아래 게시글을 간단하게 참고하고 와도 좋을 것 같다.

Threejs 기본 개념 정리 - 개념편

Threejs 기본 개념 정리 - 코드 편

Threejs 공식문서

 

미묘하게 헷갈리는 개념은 Threejs와 R3F의 정확한 차이가 무엇인가에 대해 정확하게 대답을 하지 못했던 것에서부터 시작한다.

내가 처음 Threejs를 접한 경로는 전 직장에서 가상 인테리어를 할 수 있는 3D웹 에디터를 만드는 프로젝트에 투입이 되었을 때였다.

당시, 최대한 빠르게 업무 얼라인을 해야 하므로,

개인 시간에 강의, 공식문서 등을 참고해서 예제를 익히는 것을 목표로 공부했었고,

급하게 공부하다 보니 당연히 핵심 개념 등에 대한 이해에 있어서 빈틈이 다수 생겼던 걸로 기억한다.

프로젝트는 Typscript + React + R3F(Threejs)를 기반으로 진행 중이었기에, Threejs가 아닌 R3F에 대한 빠른 적응이 필요했다.

이때, 왜라는 질문 없이 그냥 용법 위주로 적응을 해나갔다. 그냥 예제코드에서 이렇게 했고, 동료 개발자 분이 이렇게 했으니까 그렇게 했다.

하지만 왜 그렇게 했냐는 질문에 답을 해야 하는 시점이 왔다.

그 시점이 바로 강의 자료를 만들 때였다.


서론이 길었다.

그래서 새롭게 배운 것들과 기존에 대충은 알고 있었지만 좀 더 선명하게 이해하게 된 개념을 간단하게 정리해 보겠다.

 

이 포스트의 핵심을 한줄로 표현하자면 아래와 같다.

R3F는 선언적이다.

명령형, 선언형 프로그래밍의 개념을 막연하게만 알고 있었고, 강의 자료를 만들면서 좀 더 선명한 개념을 갖게 되었다.

비슷한 주제에서 더 나아가, Threejs와 R3F가 내부적인 상태관리, 메모리 관리 등에 있어서 어떠한 차이가 있는지도 개념적으로 더욱 선명해졌다.

먼저 기본적으로 R3F는 기존에 다소 절차적이고 명령형 프로그래밍 패턴으로 작성해야 하는 Threejs와 달리, React에서 개발할 때와 익숙하게 선언적인 코딩이 가능하다. 

 

명령형 프로그래밍이란, 아래 코드처럼

다소 저레벨에서 개발자가 직접 해당 기능이 어떻게 동작해야 하는지에 좀 더 집중해서 개발해 나가는 방식

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

 

선언형 프로그래밍이란, 아래 코드처럼

다소 고레벨에서 개발자가 해당 기능을 미리 추상화된 어떠한 도구를 이용해서 무엇을 개발해 나갈지에 좀 더 집중한 방식

    <mesh ref={meshRef}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color="green" />
    </mesh>

 

1. Threejs(명령형 프로그래밍)은 구구절절, R3F(선언형 프로그래밍)은 단도직입.

Threejs(명령형): Threejs에서는 위 예제 코드처럼, 어떤 cube하나를 scene에 추가한다고 가정할 때, 일일이 geometry를 만들어주고.. material을 만들어주고.. 그걸 이용해서 cube라는 이름의 mesh도 직접 만들어서 할당해 준 뒤, 그걸 scene에 add 하는 것까지 개발자가 직접 하게 된다. 또한 추후에 어떤 상황에서 이 cube를 scene에서 제거해야 하는 경우, 이때에도 개발자가 직접 scene에서 remove 해주어야 한다.

->

R3F(선언형): R3F에서는 <Scene> 컴포넌트 내부에 컴포넌트를 배치하면 알아서 scene에 배치가 되었다고, 내부적으로 간주해주고, 위 예제 코드처럼 <mesh> 컴포넌트 내부에 geometry와 meshStandardMaterial 컴포넌트를 배치해 주면 알아서 그 mesh가 하위 두 요소로 이루어져 있구나 하고, 불필요한 add 와 remove 등을 관리해주어야 하는 수고를 패러다임 적으로 개발자에게서 덜어가 주는 고마운(?) 방식이라고 할 수 있다.


2. Threejs(명령형 프로그래밍)은 렌더링, 메모리 관리도 직접, R3F(선언형 프로그래밍)은 스스로 알아서.

Threejs(명령형): Threejs에서는 mesh가 클릭되면, material이 빨간색으로 변해야 하는 상황일 때, 위 예제코드에 있는 명령형 메서드를 활용해 바꿔준 후, 이를 실제 브라우저 상에서 반영하려면, Javascript의 내장 api인 requestAnimationFrame을 활용하여, 직접 매 프레임마다 scene을 다시 그려줘야 한다. 즉 현재 scene 상의 상태와, 렌더링 간 연동을 직접 제어해야 한다는 어려움이 있다.

또한 메모리 관리 또한 개발자가 직접 dispose 등의 메소드를 이용해서 직접 처리해야 한다.

->

R3F(선언형): R3F에서는 React의 hooks를 이용한 상태관리 및 생명주기 관리 매커니즘과 라이브러리의 해당 관리 메커니즘이 연동되어 있다. 즉, 리액티브 패턴을 지원하므로 이에 따른 보일러플레이트 성격의 코드량이 획기적으로 줄어든다. 따라서, 직접적으로 추가 및 삭제 등의 선언을 제어하는 경우가 드물다.


3. Threejs(명령형 프로그래밍)은 최적화도 직접, R3F(선언형 프로그래밍)은 스스로 알아서.

Threejs(명령형): Threejs에서는 성능 최적화를 처음부터 끝까지 직접 해결해야 한다. 예를 들어, LOD(Level of Detail)(카메라와 가까운 객체는 고화질, 멀어질수록 저화질의 모델링을 렌더 하는 기법)을 구현해야 하는 경우, Frustum Culling(카메라의 시야에 있지 않은 객체를 렌더링하지 않도록 걸러내는 알고리즘) 혹은 Occlusion Culling(다른 객체에 의해 가려진 객체를 렌더링 하지 않는 알고리즘)과 같은 기법을 직접 적용해야 한다.

->

R3F(선언형): R3F에서는 렌더링 Frustum최적화 기법 등, 기본적인 성능 최적화가 자동으로 지원된다.

 


결론

선언적으로 개발한다는건 내부 동작은 라이브러리를 믿고 가야 한다는 단점도 존재하나, 대부분의 상황(라이브러리 내부를 뜯어서 커스터마이징등을 하지 않는 한)에서 장점이 더 많다고 느꼈다.

그러나 선언형이 최고고, 명령형이 무조건 별로인 방식은 아님을 분명히 하고 싶다. R3F를 통해 선언적으로 개발하다가도, 저수준 제어가 필요한 상황에서는 Threejs의 명령형 api를 직접 활용하여 구현하는 유연함도 필요하다.

 

 

 

 

 

 

 

 

https://fastcampus.co.kr/dev_online_3dinteractive

 

더 쉽고 편하게 만드는 3D 인터랙티브 웹 개발 : 구현부터 최적화까지 (feat. R3F & Three.js) | 패스트

5개 3D 인터랙티브 웹 프로젝트, 약 36시간 + @ 분량의 3D 인터랙티브 웹 강의. 3D 인터랙티브 웹의 근본 Three.js부터 최근 주목받는 React기반의 R3F까지! 더 쉽게, 하지만 더 다양한 기능을 포함한 3D 인

fastcampus.co.kr

이 포스트는 위 링크로 접속 가능한

패스트캠퍼스 3D 인터랙티브 웹 강의 촬영 회고록이다.

작년 이맘 때 부터, 올해 초까지 강의 제작에 참여하며,

경험 속에서 배우고 도전했었던 점을 위주로 작성코자 한다.

 

회고할 주제는 크게는 세 가지 이다.

 

첫 번째 파트인 개발편 에서는 강의에 필요한 강의자료를 만들며 새롭게 터득한 지식이나,

기존에 잘 알고 있다고 생각했던 지식을 조금더 견고히 했었던 경험을 기술할 예정이다.

기존에 실무에 활용은 하고 있었지만,

명확하게 이해를 하고 사용하진 않고 있었던 개념이나 기술스택에 대해 텍스트로서 정리해두고 저 또한 이 글을 쓰며 한 번 더 내재화 할 예정이다.

 

두 번째 파트인 강의편 에서는 강의를 촬영 및 녹화하는 것에 있어서, 배우고 성장했던 점에 대해 가볍게 적어볼 예정이다.

 

마지막 세 번째는 아쉬웠던 점과, 잘했던 점에 대해 스스로 평가해보고 그 결과를 공유해볼 예정이다.

 

이어지는 포스트에서 첫 번째 회고 주제에 대해 다뤄보려 한다.

+ Recent posts