React - Threejs 3D 웹사이트를 만들어보자

kyu725·2022년 9월 5일
50
post-thumbnail

Three.js 란?

웹에서 3d를 구현할 수 있는 자바스크립트 라이브러리입니다.

Three.js 를 사용한 페이지
https://github.com/home 깃허브
https://eyes.nasa.gov/apps/mars2020/#/home 나사
https://www.midwam.com/en midwam

https://threejs.org/ 에서 더많은 three.js를 사용한 페이지들을 볼 수 있습니다.

Settings

  1. 리액트 프로젝트 생성 create-react-app
    npx create-react-app 3d-app

  2. 라이브러리 설치
    npm install three Three.js
    npm install @react-three/fiber React에서 Three.js를 편리하게 사용하기 위한 Lib
    npm install use-cannon 물리엔진 Lib

Scene 만들기

1. 기본 Three.js로 scene 만들기

import * as THREE from "three";

3요소 생성

three.js에서 3d 오브젝트를 표시하려면 3가지 요소가 필요합니다.

  • scene
  • camera
  • renderer
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
const renderer = new THREE.WebGLRenderer();

camera의 생성자에 들어가는 4가지 속성은 다음과 같습니다.

  • FOV(시야각) : 해당 시점의 화면이 보여지는 정도. 각도로 값을 설정합니다.
  • aspect ratio(종횡비) : 대부분 요소의 높이와 너비에 맞추어 표시하고 그렇지 않으면 와이드 스크린에 옛날 영화를 트는 것처럼 이미지가 틀어집니다.
  • near : 이 값보다 가까이 있는 오브젝트는 렌더링 되지 않음
  • far : 이 값보다 멀리 있는 오브젝트는 렌더링 되지 않음

renderer 추가

renderer.setSize(window.innerWidth, window.innerHeight);
document.body.innerHTML = "";
document.body.appendChild(renderer.domElement);

다음으로 렌더링 할 곳의 크기를 설정해주고 HTML 문서에 추가해줘야 합니다. 일단 높이와 너비를 각각 윈도우의 크기로 설정하였습니다.document.body.innerHTML = ""renderer만 화면에 표시하기 위해서입니다. renderer<canvas> 엘리먼트로 HTML 문서에 추가됩니다.

Cube 생성

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshBasicMaterial({
  color: "blue"
});

camera.position.z = 5;
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

Three.js 에서 3d 오브젝트는 mesh라 부릅니다. mesh는 2가지 요소로 구성됩니다.

  • geometry : 기하학적 형태, 뼈대를 담당하는 부분
  • material : 질감, 색, 반사율 등 물체의 표면

2가지 속성으로 cube라는 mesh를 만들고 scene에 추가했습니다. 그리고 기본 설정상 scene.add()로 물체를 추가하면 (0,0,0)의 위치를 갖습니다. 이렇게 되면 cameracube가 동일한 위치에 겹쳐서 제대로 보이지 않을 것입니다. 이를 방지하기 위해서 camera의 위치를 약간 변경하였습니다.

scene rendering

아직 화면에는 아무것도 나오지 않을 것입니다. 아무것도 렌더링하지 않았기 때문입니다. 화면에 cube를 렌더링하고 회전시키기 위한 코드를 추가합니다.

function animate() {
    requestAnimationFrame(animate);
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;
    renderer.render(scene, camera);
  }
  animate();

1초에 60번 렌더링(60fps) 할 것이고 그럴 때마다 x 방향으로 0.01, y 방향으로 0.01만큼 회전할 것입니다.

창 크기 변경 이슈

이미 멋지게 cube가 렌더링되고 회전하고 있겠지만 창 크기를 변경했을 때 비율이 뭉게지는게 보입니다. 왜냐하면 renderercamera의 설정이 처음 창을 켰을 때의 윈도우 사이즈로 고정되어 있기 때문입니다. addEventListener를 통해 창이 resize될 때마다 renderercamera의 설정을 바꿔줍시다.

window.addEventListener("resize", () => {
  renderer.setSize(window.innerWidth, window.innerHeight);
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
});

전체코드

import * as THREE from "three";
import "./App.css";

function App() {
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(
    75,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  const renderer = new THREE.WebGLRenderer();
  renderer.setSize(window.innerWidth, window.innerHeight);
  document.body.innerHTML = "";
  document.body.appendChild(renderer.domElement);

  const geometry = new THREE.BoxGeometry();
  const material = new THREE.MeshBasicMaterial({
    color: "blue"
  });

  camera.position.z = 5;
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);

  function animate() {
    requestAnimationFrame(animate);
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;
    renderer.render(scene, camera);
  }

  animate();

  window.addEventListener("resize", () => {
    renderer.setSize(window.innerWidth, window.innerHeight);
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
  });

  return null;
}

export default App;

2. react-three/fiber로 scene 만들기

사실 @react-three/fiber를 활용하면 훨씬 쉽게 간단한 scene을 만들 수 있습니다.

Import

import { Canvas, useFrame } from "@react-three/fiber";

Canvas 생성

return (
<div style={{ width: "100vw", height: "100vh" }}>
      <Canvas style={{ background: "black" }}>
        <Box />
      </Canvas>
</div>
);

Canvas를 만들면 scene, camera, renderer를 따로 만들필요가 없습니다. Canvas의 스타일은 다른 HTML 태그들 처럼 style속성으로 지정할 수 있습니다.
Canvas안에 렌더링될 Box는 따로 컴포넌트로 빼서 만들었습니다.

Box 생성

const Box = () => {
  const ref = useRef();
  useFrame((state) => {
    ref.current.rotation.x += 0.01;
    ref.current.rotation.y += 0.01;
  });
  return (
    <mesh ref={ref}>
      <boxBufferGeometry />
      <meshBasicMaterial color="blue" />
    </mesh>
  );
};

Canvas안에는 mesh를 직접 사용할 수 있습니다. mesh는 안에 geometry, material 속성을 가져야 합니다. boxBufferGeometry, meshBasicMaterial는 각각 presetting된 기본 상자 geometry, 기본 material 입니다.
meshuseRef를 연결합니다. 매 프레임마다 동작하는 useFrame 훅에서 ref를 조작해 mesh를 회전시킵니다.

전체 코드

import { Canvas, useFrame } from "@react-three/fiber";
import { useRef } from "react";

const Box = () => {
  const ref = useRef();
  useFrame((state) => {
    ref.current.rotation.x += 0.01;
    ref.current.rotation.y += 0.01;
  });
  return (
    <mesh ref={ref}>
      <boxBufferGeometry />
      <meshBasicMaterial color="blue" />
    </mesh>
  );
};

function App() {
  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      <Canvas style={{ background: "black" }}>
        <Box />
      </Canvas>
    </div>
  );
}

export default App;

기본 three.js만 사용해서 scene을 만들 때보다 코드가 훨씬 짧고 간단해진 것을 볼 수 있습니다. 이후 내용들은 전부 @react-three/fiber를 활용한 코드입니다.

Axes Helper, Orbit Controller

Axes Helper


Axex Helper는 three.js에서 x, y, z축을 나타내 주는 도구입니다. canvas안에 axesHelper를 추가해주면 됩니다.

<Canvas style={{ background: "black" }} camera={{ position: [3, 3, 3] }}>
        <Box />
        <axesHelper args={[5]} />
</Canvas>

camera={{ position: [3, 3, 3] }} 기존 카메라 위치가 [0,0,5]여서 x, y축 밖에 안보이기 때문에 카메라의 위치를 바꿔주었습니다
axesHelperargs는 사이즈를 나타냅니다.

Orbit Controls

Orbit Controls는 카메라(화면)의 각도를 돌리거나 확대하거나 이동할 수 있는 컨트롤러 입니다.

  • 회전 : 좌클릭 후 드래그
  • 확대/축소 : 스크롤
  • 이동 : 우크릭 후 드래그

import, extend

import { Canvas, useFrame, extend, useThree } from "@react-three/fiber";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
extend({ OrbitControls });

Orbit Controls를 jsx 형태로 사용하려면 three.js에서 import 한 뒤 react-three/fiber로 extend해서 사용해야 합니다.

Orbit jsx 생성

const Orbit = () => {
  const { camera, gl } = useThree();
  return <orbitControls args={[camera, gl.domElement]} />;
};

orbitControls 생성자는 camera객체와 이벤트 리스너에 사용되는 HTML 엘리먼트가 필요합니다. 우리는 react-three/fiber에서 제공하는 useThree()를 객체 구조 분해해서 사용하면 됩니다.

Canvas에 넣기

<Canvas style={{ background: "black" }} camera={{ position: [3, 3, 3] }}>
  <Box />
  <axesHelper args={[5]} />
  <Orbit />
</Canvas>

Light, Shadow

지금은 빛이 없는 상태지만 큐뷰가 잘 보입니다. 그 이유는 meshBasicMaterial은 빛의 영향을 받지 않는 material이기 때문에 그 자체의 색을 내고 있던 것입니다. 빛의 영향을 받는 material을 적용시켜 보겠습니다.

<mesh ref={ref} {...props}>
  <boxBufferGeometry />
  <meshPhysicalMaterial color="blue" />
</mesh>

meshBasicMaterial대신 meshPhysicalMaterial을 사용하니 더이상 큐브가 보이지 않습니다. 이제 빛을 추가해봅시다.

Light

ambientLight

ambientLight는 모든 mesh가 모든 각도에서 동일한 양을 받게하는 빛입니다. 당연히 그림자도 생기지 않습니다.

<ambientLight intensity={0.2} />

canvas안에 추가해 줍시다. intensity로 빛의 세기를 정할 수 있습니다.

pointLight

pointLight는 한 점에서 나오는 빛입니다. 거리에 따라 빛의 양이 줄어들고 각도에 따라 빛을 받지 못할 수 있는 흔히 우리가 아는 빛입니다. 다만 빛 자체는 관측되지 않고 빛을 받는 mesh들에게 영향을 줄 뿐입니다.

<pointLight />

canvas안에 추가해 줍시다.

default 위치 [0,0,0]을 바라보는 큐브의 면만 밝아진 것이 보입니다.

빛을 내는 구체

지금까지는 빛 자체는 관측할 수 없었습니다. 하지만 실제 세상에서는 태양과 같이 빛을 분출하는 오브젝트가 있습니다. 우리는 meshPhongMaterial안에 pointLight를 넣음으로써 태양 비슷한 mesh를 만들 수 있습니다.
meshPhongMaterial은 빛을 반사시켜 표면이 빛나는 material입니다.

const Sun = (props) => {
  return (
    <mesh {...props}>
      <pointLight castShadow />
      <sphereBufferGeometry args={[0.3]} />
      <meshPhongMaterial emissive="yellow" />
    </mesh>
  );
};

그동안은 계속 boxBufferGeometry를 사용했는데 구를 만들기 위해서 sphereBufferGeometry를 사용했습니다. args는 반지름의 길이 입니다.
meshPhongMaterial에서 emissive로 빛이 반사되는 색을 정할 수 있습니다.

<Sun position={[0, 3, 0]} />

Shadow

분명 방향와 거리가 유효한 빛을 만들었는데 그림자가 생기지 않습니다. 그림자를 만들려면 추가적인 설정이 필요합니다.

Canvas 속성 추가

Canvas에 shadows속성을 추가하면 그림자를 그릴 수 있는 Canvas가 됩니다.

<Canvas
	shadows
    style={{ background: "black" }}
    camera={{ position: [3, 3, 3] }}
>

그림자를 그리는 자, 그려지는 자


그림자를 만드는 오브젝트, 받아서 그리는 오브젝트를 설정해줘야 합니다. 예를 들어 아까 만든 Sun은 그림자를 그리는 오브젝트이고 바닥인 Floor는 그림자가 그려지는 오브젝트 입니다.
각각 castShadowreceiveShadow를 추가해줍니다.

const Sun = (props) => {
  return (
    <mesh {...props}>
      <pointLight castShadow />
		...
const Floor = (props) => {
  return (
    <mesh {...props} receiveShadow>
      ...

Box는 어떨까요? Box는 그림자를 Floor에 그리기도 하지만 Sun이 만드는 그림자를 자신에게 그리기도 합니다. castShadowreceiveShadow 둘다 추가해줍니다.

const Box = (props) => {
  ...
  return (
    <mesh ref={ref} {...props} castShadow receiveShadow>
      ...

Material

meshMaterial 기본 속성

meshMaterial에서 몇가지 자주 사용하는 속성들을 훑어보려고 합니다.

opacity, transparent

opacity는 불투명도입니다. 1이면 완전한 불투명이고 0이면 완전한 투명입니다. 하지만 opacity만으로는 mesh가 실제로 투명해지지 않습니다. transparent 속성이 true일 때만 opacity속성이 작동합니다. material 속성을 잘 확인하기 위해서 큐브의 위치를 변경하였습니다.

<meshPhysicalMaterial
    color="blue"
    opacity={0.3}
    transparent
/>

wireframe

mesh의 프레임만 나오게 하는 속성입니다. 프레임의 색은 color속성으로 적용됩니다.

<meshPhysicalMaterial
    color="blue"
    opacity={0.3}
    transparent
    wireframe
/>

metalness, roughness

metalness는 금속 재질을 만들어 주고 1이면 완전한 금속입니다. roughness는 표면의 거칠기를 뜻하고 0이면 빛을 그대로 반사합니다. metalness의 default 는 0, roughness는 1입니다.

<meshPhysicalMaterial
    color="blue"
    metalness={1}
	roughness={0}
/>


roughness가 0이기 때문에 표면에서 한점으로 빛을 그대로 반사하는 것이 보입니다.

더 많은 속성들은 https://threejs.org/docs/index.html?q=mater#api/en/materials/Material 에서 추가로 확인하실 수 있습니다.

Texture

텍스쳐를 적용시키는 법도 간단합니다. 먼저 텍스쳐 사진을 준비해서 작업폴더에 넣어줍니다.

const Box = (props) => {
  ...
  const texture = useLoader(THREE.TextureLoader, "/assets/wood.jpg");
  ...
  return (
    <mesh ref={ref} {...props} castShadow>
      <boxBufferGeometry />
      <meshPhysicalMaterial map={texture} />
    </mesh>
  );
};

@react-three/fiber의 훅 useLoader로 이미지를 활용해 텍스쳐를 만듭니다. meshMaterial의 map 속성으로 텍스쳐를 적용시킵니다.

하지만 이 상태면 texture가 로딩되기 전에 화면이 렌더링되기 때문에 오류가 발생합니다. <Suspense>안에 Box를 넣어 texture 로딩이 완료되면 보여주기로 합시다. Suspense는 리액트에서 제공하는 기능입니다.

<Canvas
  shadows
  style={{ background: "black" }}
  camera={{ position: [3, 3, 3] }}>
  <Suspense fallback={null}>
    <Box position={[0, 1, 0]} />
  </Suspense>
  ...

Texture - background

이번에는 배경에 텍스쳐를 넣어보겠습니다. 다만 3d 배경을 사용하려면 360도 이미지(파나로마)가 필요합니다.

https://cdn.pixabay.com/photo/2018/07/11/16/34/landscape-3531355_960_720.jpg 사용한 이미지

const BackGround = (props) => {
  const texture = useLoader(THREE.TextureLoader, "/assets/back.jpg");

  const { gl } = useThree();

  const formatted = new THREE.WebGLCubeRenderTarget(
    texture.image.height
  ).fromEquirectangularTexture(gl, texture);

  return <primitive attach="background" object={formatted.texture} />;
};

텍스쳐를 만드는건 기존과 같습니다. 우리는 이미지를 화면에 표시하기 전, 후에 처리를 하는 WebGlRenderTarget을 사용할 것입니다. WebGLCubeRenderTarget는 큐브 카메라로 촬영한 360도 이미지 용 WebGlRenderTarget입니다. 그 중에서 fromEquirectangularTexture 메소드는 파나로마 이미지를 360도 큐브맵(3d 배경)으로 컨버팅해줍니다.

<Suspense fallback={null}>
    <BackGround />
</Suspense>

BackGround도 텍스쳐 로딩이 필요하기 때문에 Suspense 사이에 껴서 Canvas에 넣어줍니다.

Event

이번엔 이벤트를 다루려고 합니다. 3d 오브젝트와 상호작용 할 수 있으면 훨씬 더 재밌을 것 같습니다.

event 오브젝트 살펴보기

먼저 Box를 클릭했을 때 생기는 이벤트를 확인해보려 합니다. mesh에는 on으로 시작하는 eventHandler 속성이 있습니다. 그 중 onPointerDown 사용할 것입니다.

const handlePointerDown = (e) => {
    console.log(e);
};
return (
    <mesh
      ref={ref}
      {...props}
      castShadow
      onPointerDown={handlePointerDown}>
      <boxBufferGeometry />
      <meshPhysicalMaterial map={texture} />
    </mesh>

onPointerDown에 이벤트 오브젝트를 출력하는 함수를 연결해줍니다. onClick과 같이 클릭했을 때 실행되는 속성입니다. 그럼 이제 Box 클릭해서 로그에 무엇이 찍히는지 확인해 보겠습니다. 이벤트 안에 여러 정보들이 많지만 사실 이 중에 우리는 object만 확인하면 됩니다. object안에는 position scale visible 등 여러 속성들이 들어 있고 이 값을 변경하면 Box오브젝트와 직접적으로 상호작용을 할 수 있어 보입니다.

Scale 변경

일단 scale부터 바꿔봅시다. 마우스가 Box에 들어가면 커지고 나오면 원래크기로 돌아오게 해보겠습니다.

const handlePointerEnter = (e) => {
    e.object.scale.x = 1.5;
    e.object.scale.y = 1.5;
    e.object.scale.z = 1.5;
};
const handlePointerLeave = (e) => {
    e.object.scale.x = 1;
    e.object.scale.y = 1;
    e.object.scale.z = 1;
};
return (
    <mesh
      ...
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
      ...


들어갈 때 커지고 나오면 돌아오는건 좋은데 들어가서 클릭하면, 나와도 크기를 유지하게 하고 싶습니다. 이 Box를 선택했다는 느낌을 주고 싶습니다. 그렇게 하려면 클릭했을 때 Box 오브젝트에 상태를 설정해야 합니다. active라는 속성을 만들어 클릭시 true 값으로 바꿔줍시다. 아까 사용했떤 이벤트 핸들러 함수를 활용하겠습니다.

const handlePointerDown = (e) => {
    e.object.active = true;
    console.log(e);
};

클릭했을 때 로그에 e.object.active 속성이 생겼을 것이고 값은 true일 것입니다.

const handlePointerLeave = (e) => {
    if (!e.object.active) {
      e.object.scale.x = 1;
      e.object.scale.y = 1;
      e.object.scale.z = 1;
    }
  };

이제 클릭하고 나오면 Box가 원래크기로 돌아가지 않습니다. 선택의 느낌을 주려면 Box가 한개 더 있으면 좋을 것 같습니다. canvas에 Box를 한개 더 추가하겠습니다. 위치는 적당히 떨어지게 놓습니다. 클릭한 Box가 커진 상태로 유지되고 반대 Box는 다시 원래대로 돌아가게 만들겠습니다.

const handlePointerDown = (e) => {
    console.log(e);
    e.object.active = true;
    if (window.activeMesh) {
      scaleDown(window.activeMesh);
      window.activeMesh = false;
    }
    window.activeMesh = e.object;
  };
const scaleDown = (object) => {
    object.scale.y = 1;
    object.scale.x = 1;
    object.scale.z = 1;
    object.active = false;
  };

window.activeMesh에 현재 클릭된 mesh를 저장합니다. 다른 Box를 클릭했을 때 기존의 클릭된 meshscaleDown함수를 실행시키면 원래 크기로 돌아갑니다.

Color 변경

선택된 Box의 색상을 변경해 봅시다. 무슨 색으로 변경할지 선택할 구간을 먼저 만들겠습니다.

<div style={{ position: "absolute", zIndex: 1 }}>
        <div
          onClick={clickHandler}
          style={{ background: "blue", height: 50, width: 50 }}
        />
        <div
          onClick={clickHandler}
          style={{ background: "red", height: 50, width: 50 }}
        />
        <div
          onClick={clickHandler}
          style={{ background: "white", height: 50, width: 50 }}
        />
</div>

canvas밖에 만들어 줍시다. 고정된 팔레트를 확인하실 수 있습니다. clickHandler는 다음과 같습니다.

const clickHandler = (e) => {
    if (window.activeMesh) {
      window.activeMesh.material.color = new THREE.Color(
        e.target.style.background
      );
    }
};

선택된 Box가 있으면 해당 오브젝트의material.color를 변경해줍니다. 해당 속성은 THREE.Color()로 값을 만들어 넣어줘야 합니다.

profile
김찬규

7개의 댓글

comment-user-thumbnail
2022년 9월 5일

너무 좋은 글이에요. 잘보고 갑니다. 😍

답글 달기
comment-user-thumbnail
2022년 9월 6일

좋은 정보 감사합니다^^

답글 달기
comment-user-thumbnail
2022년 9월 6일

정말 좋은 글이네요ㅎㅎ

답글 달기
comment-user-thumbnail
2022년 9월 10일

Your writing is really informative, especially because it's so meaningful and updated. Thanks for sharing this wonderful post!

Your writing is really great. I’m so glad I read it. It kept me hooked the whole way through.

Thanks for this information. I really appreciate the information that you have provided.

https://www.myccpay.onl/
https://www.mcd-voicex.com/
https://www.tellpopeyes.org/

답글 달기
comment-user-thumbnail
2022년 9월 12일

좋은 글 잘보고 갑니다:)

답글 달기
comment-user-thumbnail
2022년 9월 13일

I value the insights and guidance you provide, I will try to figure it out for more.

답글 달기
comment-user-thumbnail
2022년 9월 14일

오늘 월급루팡은....이거다..! 감사합니다

답글 달기