three.js를 이용한 VR(Vitrual Reality) 컨텐츠 제작

이 포스트는 three.js를 이용해서 웹 기반의 VR 어플리케이션을 만드는 기본 요소에 대한 개략적인 내용을 언급합니다.

작업 흐름

먼저, 프로젝트에 VRButton.js를 import 해야 합니다.

import { VRButton } from 'three/addons/webxr/VRButton.js';

VRButton.createButton()은 2가지 중요한 것을 수행합니다: VR 호환성을 가지는 버튼을 생성하며 사용자가 그 버튼을 활성화하면 VR 세션을 개시합니다. 이를 위해 단지 다음 코드를 작성하면 됩니다.

document.body.appendChild( VRButton.createButton( renderer ) );

다음으로는, WebGLRenderer 객체의 XR 렌더링을 활성화시킵니다.

renderer.xr.enabled = true;

마지막으로, 애니메이션 루프(Animation Loop)에 대한 코드로 자주 사용되는 requestAnimationFrame 함수 대신에 setAnimationLoop를 대신 사용하도록 합니다. 코드는 다음과 같습니다.

renderer.setAnimationLoop( function () {
    renderer.render( scene, camera );
} );

다음 단계

3D 장면에 대한 구성은 일반적인 three.js API로 개발하면 되고 VR와 관련된 UI적인 기능은 three.js에서 제공하는 VR에 특화된 추가적인 API를 사용해 개발하면 됩니다.

이 글의 원문입니다.

GLSL 코드와 Blender의 쉐이더 노드

쉐이더 프로그래밍 언어 중에 하나인 GLSL의 프레그먼트 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하는 내용에 대한 정리입니다. 먼저 GLSL에 대한 코드는 다음과 같습니다.

varying vec2 vUv;

void main() {
  float strength = step(0.01, abs(distance(vUv, vec2(0.5)) - 0.3));
  gl_FragColor = vec4(vec3(strength), 1.0);
}

결과는 다음과 같습니다. three.js를 사용했습니다.

위의 쉐이더 코드를 블렌더의 쉐이더 노드로 구성하면 다음과 같습니다.

쉐이더의 코드는 블렌더의 쉐이더 노드로 구성할 수 있고, 그 반대로도 가능합니다. 쉐이더 코드는 매우 함축적이고 작은 코드 변화에도 그 결과는 예상하기 어려운 경우가 있습니다. 하지만 블렌더의 쉐이더 노드를 통해 먼저 그 결과를 만들고 이를 다시 쉐이더 코드로 변환한다면 좀 더 나은 개발 접근이 될 수 있습니다.

dFdx와 dFdy를 이용한 법선 벡터 계산

버텍스 쉐이더에서 정점을 흔들었을 경우 법선 벡터 역시 다시 계산을 해줘야 하는데, 이때 정점의 x와 y에 대한 편미분 값을 얻을 수 있는 dFdx와 dFdy를 이용하면 법선 벡터를 얻을 수 있습니다.

이런 경우 버텍스 쉐이더에 전달된 normal을 vary를 통해 프레그먼트 쉐이더로 전달할 필요가 없고 프레그먼트 쉐이더에서 법선 벡터를 계산해 주면 되는데, 프레그먼트 쉐이더에서 이에 대한 코드는 다음과 같습니다.

vec3 normal = normalize(
    cross(
        dFdx(vPosition.xyz),
        dFdy(vPosition.xyz)
    )
);

위의 vPosition은 버텍스 쉐이이더에서 재계산된 정점인데, 버텍스 쉐이더의 코드에서 보면 다음과 같습니다.

varying vec3 vPosition;

void main() {	
    vec3 posClone = position;
    posClone = /* 정점 흔들기(변경) */
    
    ...

    vPosition = modelMatrix * vec4(posClone, 1.0)).xyz;

    ...
}

적용 결과로 비교하면 먼저 dFdx와 dFdy를 통한 법선 벡터를 사용하지 않고 지오메트리를 통해 제공되는 법선 벡터를 그대로 사용한 경우는 아래와 같습니다.

버텍스 쉐이더에서 정점을 흔들어서 원래 제공된 법선 벡터가 맞지 않아 음영 효과가 제대로 표현되지 않는데, 이를 개선하기 위해 dFdx와 dFdy를 통한 법선 벡터를 사용한 결과는 다음과 같습니다.

Drei 컴포넌트 : Grid

Drei 컴포넌트는 R3F에서 사용할 수 있는 매우 유용한 컴포넌트의 집합입니다. 그 중 Grid 컴포넌트에 대한 사용인데 Grid 이외의 Drei 컴포넌트도 사용되고 있습니다. 예를 들어 StatsGl, Center, Grid, GizmoHelper, GizmoViewcube, GizmoViewport 입니다.

먼저 결과는 다음과 같습니다.

위의 결과에 대한 코드를 하나씩 살펴보면, 먼저 App.jsx 입니다.

import './App.css'
import { Canvas } from '@react-three/fiber'
import MyElement3D from './MyElement3D_Grid'

function App() {
  return (
    <>
      <Canvas shadows camera={{ position: [10, 12, 12], fov: 25 }}>
        <color args={["#303035"]} attach="background" />
        <MyElement3D />
      </Canvas>
    </>
  )
}

export default App

다음은 App 컴포넌트에서 사용하고 있는 MyElement3D_Grid 파일에 대한 코드입니다. 이 파일의 코드를 기능 별로 언급합니다.

먼저 모델 컴포넌트입니다.

import { AccumulativeShadows, Center, Environment, GizmoHelper, GizmoViewcube, GizmoViewport, Grid, OrbitControls, RandomizedLight, Stats, StatsGl, useGLTF } from "@react-three/drei"
import { useControls } from "leva"
import { memo } from "react"

function Robot(props) {
  const { nodes } = useGLTF('./models/model.glb')

  nodes.Scene.traverse(obj => {
    obj.castShadow = true
    obj.receiveShadow = true
  })

  console.log(nodes)
  return (
    <primitive object={nodes.Scene} {...props} />
  )
}

다음은 그림자 컴포넌트입니다. memo 고차 컴포넌트를 사용했는데 그림자일 경우 이렇게 처리하면 장점이 있다고 하네요.

const Shadows = memo(() => (
  <AccumulativeShadows temporal frames={100} color="#000000" colorBlend={0.5} alphaTest={0.9} scale={20}>
    <RandomizedLight amount={8} radius={4} position={[5, 5, -10]} />
  </AccumulativeShadows>
))

이 글의 주인공인 Grid 컴포넌트입니다. 이 컴포넌트의 속성을 UI로 설정하기 위해서 useControls를 사용했습니다.

function MyGrid() {
  const { gridSize, ...gridConfig } = useControls({
    gridSize: [10.5, 10.5],
    cellSize: { value: 0.6, min: 0, max: 10, step: 0.1 },
    cellThickness: { value: 1, min: 0, max: 5, step: 0.1 },
    cellColor: '#6f6f6f',
    sectionSize: { value: 3.3, min: 0, max: 10, step: 0.1 },
    sectionThickness: { value: 1.5, min: 0, max: 5, step: 0.1 },
    sectionColor: '#9d4b4b',
    fadeDistance: { value: 25, min: 0, max: 100, step: 1 },
    fadeStrength: { value: 1, min: 0, max: 1, step: 0.1 },
    followCamera: false,
    infiniteGrid: true
  })

  return (
    <Grid args={gridSize} position={[0, -0.01, 0]} {...gridConfig} />
  )
}

위에서 정의된 컴포넌트들을 사용한 실제 MyElement3D입니다. 리엑트의 좋은 코딩 습관은 말단 컴포넌트는 최대한 JSX 코드만을 반환하는 것으로 작성하는 것입니다. 그러면 코드 가독성이 높아지고 컴포넌트 분리가 효과적으로 됩니다.

function MyElement3D() {
  return (
    <>
      {/* <Stats /> */}
      <StatsGl />

      <OrbitControls makeDefault />
      <Environment preset="city" />
      
      {/* <Center top>
        <Suzi rotation={[-0.63, 0, 0]} scale={2} />
      </Center> */}
      
      <Center>
        <Robot scale={2} />
      </Center>

      <MyGrid />

      <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
        <GizmoViewcube />
        {/* <GizmoViewport axisColors={['#9d4b4b', '#2f7f4f', '#3b5b9d']} labelColor="white" /> */}
      </GizmoHelper>
      
      <Shadows />
    </>
  )
}

export default MyElement3D

이 코드는 추후 제 유튜브 채널을 통해 영상으로 소개될 예정입니다.