[R3F] React Three Fiber 찔러보기

KoEunseo·2023년 8월 8일
1

R3F

목록 보기
1/1

r3f 보일러플레이트 doc

들어가며

프론트엔드 개발자라면 3d로 화려하게 만들어진 홈페이지를 보면서 한번쯤은 손이 드릉드릉했을거라고 생각한다.
그리고 언젠가는 나도 저런 앱(웹)을 만들어보리라 목표로 삼았을 것이다.
쓰리디 춘식이가 맵을 돌아다니면서 컨텐츠를 보여주는 웹을 보면서 너무너무 만들어보고싶었던 기억이 난다.
뭐, 나는 그렇다.

계속 생각만 하다가 개발에 대한 재미가 떨어져서(계속 새로운 것을 넣어줘야하는 새럼 나야나 🙌) r3f를 건들여보기로 했다!! 예이 생각만 해도 신난다

r3f는 한글로 된 자료가 없다. 유데미에도 강의가 많지 않음 😭
three.js를 배워보는 방법도 있지만 리액트 프로젝트에 바로 써먹어보고싶엇..!
영어강의를 보면서 진행해보려다가 위 링크의 웹페이지를 따라가는 내용이라는 사실을 깨달아서, 블로그에 한글로 정리하면서 따라가보려 한다.

빌드

유튜브 강의 링크

$ mkdir react-three-fiber-boilerplate
$ cd react-three-fiber-boilerplate

./package.json

{
  "name": "react-three-fiber-boilerplate",
  "version": "1.0.0",
  "description": "",
  "keywords": [],
  "main": "src/index.jsx",
  "dependencies": {
    "@react-three/fiber": "8.13.5",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "react-scripts": "5.0.1",
    "three": "0.154.0"
  },
  "devDependencies": {
    "@babel/plugin-proposal-private-property-in-object": "^7.21.11"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "browserslist": {
    "production": [">0.2%", "not dead", "not op_mini all"],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

./src/index.jsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Canvas } from '@react-three/fiber'
import './styles.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Canvas camera={{ position: [0, 0, 2] }}>
      <mesh>
        <boxGeometry />
        <meshBasicMaterial color={0x00ff00} wireframe />
      </mesh>
    </Canvas>
  </StrictMode>
)

./src/styles.css

html,
body,
#root {
  height: 100%;
  margin: 0;
  background: #000000;
}

./public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, shrink-to-fit=no"
    />
    <title>React Three Fiber Tutorials by Sean Bradley</title>
  </head>

  <body>
    <noscript> You need to enable JavaScript to run this app. </noscript>
    <div id="root"></div>
  </body>
</html>

npm install

npm start

./.prettierrc

{
  "printWidth": 80,
  "tabWidth": 2,
  "useTabs": false,
  "semi": false,
  "singleQuote": true,
  "trailingComma": "none",
  "bracketSpacing": true,
  "jsxBracketSameLine": true
}

./.eslintrc

{
  "extends": "react-app",
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    "no-unused-vars": [
      "warn",
      {
        "argsIgnorePattern": "^_",
        "varsIgnorePattern": "^_"
      }
    ]
  }
}

Componentize : 컴포넌트화

canvasmesh를 컴포넌트화한다.

./src/Box.jsx : mesh 컴포넌트화

export default function Box() {
  return (
    <mesh>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

./src/App.jsx : canvas 컴포넌트화

import { Canvas } from '@react-three/fiber'
import Box from './Box'

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 2] }}>
      <Box />
    </Canvas>
  )
}

./src/index.jsx 수정하기

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles.css'
import App from './App'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
)

Props

./src/Box.jsx

Box가 2개 이상일 경우, 위치(position)을 부모 컴포넌트로부터 받을 수 있도록 props를 뚫는다.

export default function Box(props) {
  return (
    <mesh {...props}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

./src/App.jsx

import { Canvas } from '@react-three/fiber'
import Box from './Box'

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 2] }}>
      <Box position={[-0.75, 0, 0]} name="A" />
      <Box position={[0.75, 0, 0]} name="B" />
    </Canvas>
  )
}

useRef Hook

런타임 중 컴포넌트 인스턴스의 속성을 읽거나 수정할 때 ref를 이용할 수 있다.
ref를 사용해 컴포넌트 인스턴스의 내부 프로퍼티 및 메서드와 필수적으로 상호 작용하는 데 사용할 수 있다.
useRef를 사용할 때, 초기값을 설정하지 않으면 jsx가 렌더링 될 때 초기화될 때까지 undefined가 되어버린다.

initValue를 설정해 ref 값이 초기화될 때까지 기본값을 갖고 작동할 수 있도록 한다.

./src/Box.jsx

import { useRef } from 'react'

export default function Box(props) {
  const ref = useRef()
  console.log(ref) // { current : undefined } { current : undefined }

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

ref.current 값이 있는지 여부를 확인하기 위해 useEffect를 사용한다.

useEffect Hook

useEffect는 컴포넌트가 생성된 후에 실행된다.

./src/Box.jsx

import { useRef, useEffect } from 'react'

export default function Box(props) {
  const ref = useRef()

  useEffect(() => {
    console.log(ref.current)
  })

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

ref.current

Mesh {isObject3D: true, uuid: '2bf1c713-2ce7-4bd5-a019-a0564938b3a6', name: 'B', type: 'Mesh', parent: Scene, …}

UseLayoutEffect

useEffect는 비동기적이다. callback이 캔버스에 WebGL 픽셀이 그려진 첫 타임에 호출될 수 있다.
R3F는 웹gl 데이터를 트리거해 캔버스에 그린다. 그래서 mesh의 위치가 변경될 때와 같은 상황에서 딜레이가 발생하지 않는다.

frameloop="demand"

<canvas frameloop="demand"></canvas>

위와 같은 상황에서는 딜레이(깜빡임)가 발생한다. 이때는 useEffect대신 useLayoutEffect를 사용하면 된다. 브라우저가 페인팅을 하기 전에 useLayoutEffect가 동기적으로 호출된다.

import { Canvas } from '@react-three/fiber'
import Box from './Box'

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 2] }} frameloop="demand">
      <Box position={[-0.75, 0, 0]} name="A" />
      <Box position={[0.75, 0, 0]} name="B" />
    </Canvas>
  )
}

./src/Box.jsx

새로고침하면 위치(y)가 0에서 1로 수정되면서 오른쪽 상자가 깜박인다.

useEffect

import { useRef, useEffect } from 'react'

export default function Box(props) {
  const ref = useRef()

  useEffect(() => {
    if (ref.current.name === 'B') {
      ref.current.position.y = 1
    }
  })

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

useLayoutEffect 사용시 깜빡이지 않고 상자의 위치가 바뀐다.
대신 성능저하가 발생할 수도 있으니 될수있으면 useEffect를 쓰는 게 좋다.

useLayoutEffect

import { useRef, useLayoutEffect } from 'react'

export default function Box(props) {
  const ref = useRef()

  useLayoutEffect(() => {
    if (ref.current.name === 'B') {
      ref.current.position.y = 1
    }
  })

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

useFrame

  • useFrame은 R3F에서 제공하는 훅이다.
  • 렌더링되는 모든 프레임 전에 코드를 실행할 때 사용한다.

    state와 delta를 제공한다.

  1. three.js 객체의 state
  2. delta(마지막으로 설정된 후 몇 ms가 지났는지: 렌더링 사이의 ms)
    클라이언트 프레임 속도와 관계없이 일정한 속도로 오브젝트를 변경하는 데 사용한다.

예시

10ms마다 10씩 x좌표를 움직임

ref.current.position.x += 10 * delta
  • jsx가 three.js 객체로 변환된 후 현재 프레임이 캔버스에 렌더링되기 직전 호출된다.
  • 지속적으로 호출되며 초당 60프레임의 속도를 유지하려 한다.

./src/Box.jsx

import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'

export default function Box(props) {
  const ref = useRef()

  useFrame((_, delta) => {
    ref.current.rotation.x += 1 * delta
    ref.current.rotation.y += 0.5 * delta
  })

  return (
    <mesh {...props} ref={ref}>
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

./src/App.jsx

위에서 작성했던 'frameloop="demand"'를 삭제해야 box가 돌아간다.

import { Canvas } from '@react-three/fiber'
import Box from './Box'

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 2] }}>
      <Box position={[-0.75, 0, 0]} />
      <Box position={[0.75, 0, 0]} />
    </Canvas>
  )
}

Event

이벤트를 캡처할 때 이벤트에 대한 정보들이 콜백으로 전달된다. (useRef 훅을 따로 만들 필요가 없다.)

이벤트에 대한 정보
객체를 클릭하면 해당 객체에 대한 정보,
클릭이 발생한 3D 벡터,
클릭이 발생한 카메라로부터의 거리,
객체의 어느 면을 클릭했는지,
클릭의 UV 좌표 등

포인터 이벤트는 React Three Fiber 캔버스가 인스턴스화될 때 자동으로 생성되는 레이캐스터(raycaster)에 의존한다.

./src/Box.jsx

import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'

export default function Box(props) {
  const ref = useRef()

  useFrame((_, delta) => {
    ref.current.rotation.x += 1 * delta
    ref.current.rotation.y += 0.5 * delta
  })

  return (
    <mesh
      {...props}
      ref={ref}
      onPointerDown={(e) => console.log('pointer down ' + e.object.name)}
      onPointerUp={(e) => console.log('pointer up ' + e.object.name)}
      onPointerOver={(e) => console.log('pointer over ' + e.object.name)}
      onPointerOut={(e) => console.log('pointer out ' + e.object.name)}
      onUpdate={(self) => console.log(self)}
    >
      <boxGeometry />
      <meshBasicMaterial color={0x00ff00} wireframe />
    </mesh>
  )
}

mesh가 겹칠 경우 마우스 이벤트를 감지할 때 뒤에 있는 mesh까지 캡쳐된다.
이 현상을 방지하기 위해서는 stopPropagation() 메서드를 이벤트 핸들러에서 사용한다.

useState

./src/Box.jsx

import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'

export default function Box(props) {
  const ref = useRef()
  const [hovered, setHover] = useState(false)
  const [rotate, setRotate] = useState(false)

  useFrame((_, delta) => {
    if (rotate) {
      ref.current.rotation.x += 1 * delta
      ref.current.rotation.y += 0.5 * delta
    }
  })

  return (
    <mesh
      {...props}
      ref={ref}
      scale={hovered ? [1.1, 1.1, 1.1] : [1, 1, 1]}
      onPointerDown={() => setRotate(!rotate)} //click
      onPointerOver={() => setHover(true)} //hover
      onPointerOut={() => setHover(false)}
    >
      <boxGeometry />
      <meshBasicMaterial color={hovered ? 0xff0000 : 0x00ff00} wireframe />
    </mesh>
  )
}

useMemo

./src/Box.jsx

import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'

export default function Box(props) {
  const ref = useRef()
  const [rotate, setRotate] = useState(false)

  useFrame((_, delta) => {
    ref.current.rotation.x += delta * rotate
    ref.current.rotation.y += 0.5 * delta * rotate
  })

  return (
    <mesh {...props} ref={ref} onPointerDown={() => setRotate(!rotate)}>
      <boxGeometry />
      <meshBasicMaterial color={'lime'} wireframe />
    </mesh>
  )
}

geometry의 uuid를 확인해보면 동일한 geometry 객체를 사용하고 있다는 것을 알 수 있다.

import { useRef, useState, useEffect } from 'react'
import { useFrame } from '@react-three/fiber'

export default function Box(props) {
  const ref = useRef()
  const [rotate, setRotate] = useState(false)

  useEffect(() => {
    console.log(ref.current.geometry.uuid)
  })

  useFrame((_, delta) => {
    ref.current.rotation.x += delta * rotate
    ref.current.rotation.y += 0.5 * delta * rotate
  })

  return (
    <mesh {...props} ref={ref} onPointerDown={() => setRotate(!rotate)}>
      <boxGeometry />
      <meshBasicMaterial color={'lime'} wireframe />
    </mesh>
  )
}

geometry={geometry}

boxGeometry를 동적으로 바꿀 수 있다.

  • <boxGeometry />를 사용하지 않는다.
  • Three.BoxGeometry를 인스턴스화해서 mesh의 props로 추가한다.
  • 이때, 상태가 변경될 때마다 Three.BoxGeometry가 다시 생성되기때문에 uuid가 바뀐다.
import { useRef, useState, useEffect } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'

export default function Box(props) {
  const ref = useRef()
  const [rotate, setRotate] = useState(false)
  const geometry = new THREE.BoxGeometry()

  useEffect(() => {
    console.log(ref.current.geometry.uuid)
  })

  useFrame((_, delta) => {
    ref.current.rotation.x += delta * rotate
    ref.current.rotation.y += 0.5 * delta * rotate
  })

  return (
    <mesh
      {...props}
      ref={ref}
      onPointerDown={() => setRotate(!rotate)}
      geometry={geometry}
    >
      <meshBasicMaterial color={'lime'} wireframe />
    </mesh>
  )
}

useMemo 최적화

  • 최적화를 위해 useMemo를 사용한다. useMemo는 캐시역할을 한다.
  • boxGeometry는 변경되지만 uuid는 변하지 않는다.
import { useRef, useState, useEffect, useMemo } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'

export default function Box(props) {
  const ref = useRef()
  const [count, setCount] = useState(0)
  const geometry = useMemo(
    () => [new THREE.BoxGeometry(), new THREE.SphereGeometry(0.785398)],
    []
  )

  useEffect(() => {
    console.log(ref.current.geometry.uuid)
  })

  useFrame((_, delta) => {
    ref.current.rotation.x += delta
    ref.current.rotation.y += 0.5 * delta
  })

  return (
    <mesh
      {...props}
      ref={ref}
      onPointerDown={() => setCount((count + 1) % 2)}
      geometry={geometry[count]}
    >
      <meshBasicMaterial color={'lime'} wireframe />
    </mesh>
  )
}
profile
주니어 플러터 개발자의 고군분투기

1개의 댓글

comment-user-thumbnail
2023년 8월 8일

좋은 글 감사합니다.

답글 달기