[R3F] 캐릭터 랜덤생성, react-three/rapier로 캐릭터 충돌 처리하기(+socket.io)

loopydoopy·2025년 1월 20일
1
post-thumbnail

작년 it 동아리에서 활동할 당시 실시간 양방향 통신 기능을 구현해보고 싶었는데, 기획을 따라가다 보니 구현할 기회가 없었던 게 아쉬워서 혼자 간단하게라도 적용하면서 정리했던 내용이다.

3D 형태에 적용하면 재미있을 것 같아서 간단하게 추가해봤고 중간에 추가한 부분도 있어서 순서대로 정리해봤다.



@react-three/fiber: React에서 three.js 사용
@react-three/drei: OrbitControls, CameraControls 등 react-three/fiber에서 사용할 수 있는 유용한 기능 제공
@types/three: TypeScript 환경에서 사용
three-stdlib: three.js 확장 유틸리티 사용(SkeletonUtils 등)
socket.io-client, socket.io(server): 양방향 통신 구현
gltfjsx: gltf/glb 3D 모델 -> tsx 변환
jotai: 클라이언트 상태 관리



Canvas 설정 및 지면 추가

간단한 지면을 추가하기 위해 poly.pizza에서 low poly 모델을 다운받고 tsx로 변환해준다.(gltfjsx)


Playground.tsx

App.tsx Canvas 하위에 있는 객체들을 모아놓은 파일이다.

export default function Playground() {
  return (
    <>
      <axesHelper
        scale={200}
        position={[0, 0, 0]}
      />
      <Environment preset='apartment' />
      <ambientLight intensity={1} />
      <OrbitControls
        maxPolarAngle={Math.PI / 2.1}
        minPolarAngle={Math.PI / 8}
      />

      {/* 메인 오브젝트 */}
      {/* ground */}
      <FloatingIsland
        scale={100}
        position={[0, -75, 0]}
      />
    </>
  )
}

react-three/drei의OrbitControls를 적용해서 최대/최소 polarAngle을 설정해서 카메라가 타겟을 바라볼 때 불필요한 부분에 대한 범위를 제한했다.
여기에서 polarAngle은 y축 기준을 시작으로 내려온 각도를 뜻한다.

아래에서 주황색으로 표시한 부분이 카메라가 궤도를 따라 이동할 수 있는 영역의 범위이다.


결과 화면


캐릭터 생성 StartModal 생성 + socket.io 연결

📝 구현 계획은 아래와 같았다
1. 첫 접속 시 StartModal에서 닉네임, 캐릭터 종류(male/female) 선택
2. 입력 후 입장할 때 new 이벤트 전달로 서버에서 위치와 색상 랜덤으로 설정해서 캐릭터 생성 후 characters 업데이트
3. 클라이언트 측에서는 characters(현재 접속한 모든 캐릭터)를 전역으로 관리하면서 지면 위에 실시간으로 배치되도록 하기


StartModal.tsx

import { useState } from 'react'
import styles from './StartModal.module.css'

export default function StartModal() {
  const [isOpen, setIsOpen] = useState<boolean>(true)
  const [info, setInfo] = useState({ nickname: '', gender: 'female' })
  const GENDER_TYPE = ['female', 'male']

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInfo({ ...info, nickname: e.target.value })
  }

  const handleSave = () => {
    if (!info.nickname) {
      alert('Nickname is a required field')
      return
    }
    socket.emit('new', info)
    setIsOpen(false)
  }

  if (!isOpen) return
  return (
    <>
      <div className={styles['modal-container']}>
        <div className={styles['modal-box']}>
          <label htmlFor='nickname'>Nickname</label>
          <input
            id='nickname'
            className={styles.input}
            type='text'
            value={info.nickname}
            onChange={(e) => handleChange(e)}
          />
          <label>Character</label>
          <div className={styles['character-group']}>
            {GENDER_TYPE.map((type: string) => (
              <img
                className={`${styles['character']} ${
                  info.gender === type && styles['selected']
                }`}
                src={`src/assets/${type}.png`}
                width={120}
                height={270}
                onClick={() => setInfo({ ...info, gender: type })}
              />
            ))}
          </div>
          <button
            className='button'
            onClick={handleSave}
          >
            ENTER
          </button>
        </div>
      </div>
    </>
  )
}

SocketManager.tsx (client)

import { atom, useAtom } from 'jotai'
import { useEffect } from 'react'
import { io } from 'socket.io-client'
import { Character } from 'src/types/Character'

export const socket = io('http://localhost:3001')
export const characterAtom = atom<Character[]>([])

export const SocketManager = () => {
  const [, setCharacters] = useAtom(characterAtom)
  
  useEffect(() => {
    function onConnect() {
      console.log('connect')
    }
    function onDisconnect() {
      console.log('disconnect')
    }

    function onCharacters(characters: Character[]) {
      setCharacters(characters)
    }

    socket.on('connect', onConnect)
    socket.on('disconnect', onDisconnect)
    socket.on('characters', onCharacters)

    return () => {
      socket.off('connect', onConnect)
      socket.off('disconnect', onDisconnect)
      socket.off('characters', onCharacters)
    }
  }, [])

  return null
}

socket.io 연결과 관련된 이벤트를 등록해준다.


index.js(server)


import { Server } from 'socket.io'
import { getRandomPosition } from './utils/getRandomPosition.js'
import { getRandomHexColor } from './utils/getRandomHexColor.js'

const io = new Server({
  cors: {
    origin: 'http://localhost:5173',
  },
})

io.listen(3001)

const characters = []

io.on('connection', (socket) => {
  // create new character
  socket.on('new', (info) => {
    characters.push({
      id: socket.id,
      position: getRandomPosition(), // 캐릭터 랜덤 위치 생성
      color: getRandomHexColor(), // 캐릭터 구분을 위해 랜덤 색상 생성
      nickname: info.nickname,
      gender: info.gender,
    })
    io.emit('characters', characters)
    console.log('characters', characters)
  })

  socket.on('disconnect', () => {
    console.log('disconnected')
    characters.splice(
      characters.findIndex((char) => char.id === socket.id),
      1
    )
    io.emit('characters', characters)
  })
})

서버 측에서는 전달받은 info 값에 대해 랜덤 위치/색상값을 추가해서 접속한 캐릭터 목록에 추가하고 접속한 모든 클라이언트에게 브로드 캐스트한다.
또한, 당연히 연결이 해제되면 characters 목록에서 제거한다.


결과 화면

  • StartModal

  • server/index.js 터미널 콘솔
    랜덤 위치/색상 생성과 사용자가 모달에서 생성한 값이 전달된 것을 확인할 수 있다.
    (테스트용으로 추가한 chatRoomId은 무시하기~)

캐릭터 생성 및 characters(접속한 유저) 배치

지금은 female/male로 캐릭터 모델 종류가 2개뿐이라 개별 Character 컴포넌트로 생성하지는 않았다.

Characters.tsx

export default function Characters() {
  const [characters] = useAtom(characterAtom)

  return (
    <>
      {characters.map((char) => {
        return React.createElement(
          char.gender === 'male' ? AnimatedMan : AnimatedWoman,
          {
            key: char.id,
            charId: char.id,
            position: new THREE.Vector3(
              char.position[0],
              char.position[1],
              char.position[2]
            ),
            bodyColor: char.color,
            nickname: char.nickname,
          }
        )
      })}
    </>
  )
}

결과 화면

개별 캐릭터 오브젝트 파일에서 props로 정보를 받아와서 props.position 위치에 배치하면 된다.
예시로 생성한 캐릭터가 아래 터미널 사진과 같고,

아래 캡처 이미지에서 축을 기준으로 확인하면 [-21, 0, 29] 위치에 잘 배치된 것을 볼 수 있다.
+) nickName 출력 react-three/drei 의 HTML 컴포넌트 사용
(캐릭터 구분 목적으로 단일 색으로 설정했더니 좀 무섭게 생겼다..🥲)


캐릭터 이동 및 collider 적용으로 충돌 처리 구현

캐릭터 이동에 대한 부분은 예전에 작성한 포스팅에서 마지막에 사이드뷰 시점 구현한 부분을 거의 그대로 적용했다.

⭐️ 캐릭터 이동 관련 포스팅


❗️탭을 두 개 열어서 테스트했는데 두 번째 캐릭터의 HTML 컴포넌트 위치가 이상하게 나오는 문제가 발생했다.

❓처음에는 독립적인 인스턴스로 만들어지지 않아서 잘못된 group을 참조를 하고 있나?? 라는 의문을 가졌지만 캐릭터 모델 내부 코드를 보면 각 캐릭터별로 scene을 복제해서 독립적인 scene을 보장하고 있기 때문에 다른 곳에서 문제가 있다고 판단했다.

무엇보다 카메라의 이동과 관련된 로직을 분리한 useCameraMove 훅 사용한 부분을 주석했더니 문제가 발생하지 않았다.
어떤 연관이 있지..? 생각하던 중 현재 탭에 접속한 유저의 캐릭터를 식별해서 카메라를 설정한 부분이 따로 구현되지 않은 것을 깨달았다..🥲🥲


socket에서 할당한 id로 현재 탭에 접속한 유저의 캐릭터인지 판별하기

서버에서 캐릭터를 생성할 때 charId에 고유한 socket.id를 넘겨주었기 때문에 판별 가능하고 조건에 맞는 경우에만 내 캐릭터가 중심이 되는 3인칭 카메라가 구현된 훅을 사용하면 된다.

  if (props.charId === socket.id) {
    useCameraMove({ group })
  }

결과 화면

탭 두 개 열고 다시 확인해보면 현재 탭 유저의 캐릭터 중심으로 카메라가 따라다니고 캐릭터의 이동이 실시간으로 확인된다👍


이어서 충돌 처리를 위해 Rigidbody를 추가할 캐릭터 오브젝트 상위에 Physics로 감싸준다.
(@react-three/rapier)

Characters.tsx

...
<Physics debug>
  {characters.map((char) => {
    return React.createElement(
      char.gender === 'male' ? AnimatedMan : AnimatedWoman,
      {
        key: char.id,
        charId: char.id,
        position: new THREE.Vector3(
          char.position[0],
          char.position[1],
          char.position[2]
        ),
        bodyColor: char.color,
        nickname: char.nickname,
      }
    )
  })}
</Physics>

Animated_Woman.tsx

...
  return (
    <group
      ref={group as Ref<THREE.Group>}
      {...props}
      position={position}
      dispose={null}
    >
      ...
      
      <RigidBody
        type='dynamic'
        gravityScale={0} // 중력으로 collider 떨어짐 방지
        ref={colliderRef as RefObject<any>}
      >
        <BallCollider
          sensor // 충돌 물리 효과 X (collider 영역 겹침 탐지 목적)
          name={props.charId}
          args={[12]} // radius
          onIntersectionEnter={(e) =>
            console.log(e.colliderObject?.name, props.charId, 'overlapped')
          }
        />
      </RigidBody>
    </group>
  )

움직이는 물체에 RigidBody로 감싼 collider를 추가한다.

RigidBodytypefixed, dynamic 두 개의 타입이 있는데,

fixed: 주로 다른 오브젝트에 물리 영향을 "주는", 즉 자기 자신은 움직이지 않는 경우
dynamic: 충돌이나 중력과 같은 물리 영향을 "받는" 움직이는 물체에 적용한다.

지금은 움직이는 캐릭터 물체에서 서로 물리 영향을 받는 상황이기 때문에 dynamic 타입을 전달했고 위에서 언급했듯이 중력 영향을 받게 되기 때문에 collider가 지면 아래로 떨어지는 것을 방지하기 위해 gravityScale 값을 0으로 설정해준다.


useCharacterMove.tsx

collider의 위치도 캐릭터 이동과 동기화해준다.

  useFrame(() => {
    if (
      group.current &&
      group.current?.position.distanceTo(targetPos) > 1 // 움직임 감지 기준
    ) {
		...
      // collider following
      if (!collider.current) return
      collider.current.setTranslation(group.current.position, true)
    } else {
      setAnimation(animationType.idle)
    }
  })

결과 화면

두 오브젝트 간의 충돌을 감지하고 collider id를 정상적으로 출력하는 것을 확인할 수 있다.



https://poly.pizza/m/uacRjkWA4q
https://www.youtube.com/watch?v=uLv1Zu8GyUw&t=2s
https://github.com/yomotsu/camera-controls?tab=readme-ov-file#orbit-rotations
https://wawasensei.dev/courses/react-three-fiber/lessons/camera-controls

0개의 댓글