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는 앞서 배운 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 그래프를 공유하게 된다.
물리엔진을 딥하게 이용하는 기능을 토이프로젝트에서 추후 진행해 보며, 다양한 기능을 사용해 보고, 포스팅해 볼 예정이다.
다음 포스트에서는 강의 촬영에 관련한 회고를 작성해 볼 예정이다.
'Development > Frontend' 카테고리의 다른 글
| 패스트 캠퍼스 강의 촬영 회고록 - 개발편 2(R3F + Scroll Animation + gsap) (0) | 2024.09.15 |
|---|---|
| 패스트 캠퍼스 강의 촬영 회고록 - 개발편 1(Threejs vs. R3F) (0) | 2024.09.15 |
| 패스트 캠퍼스 강의 촬영 회고록 - 개요 (0) | 2024.09.15 |
