[그래픽스] three.js에서 dynamic instancing으로 렌더링 최적화 하는 법

Date:     Updated:

카테고리:

태그:

🚀Introduction

오늘은 three.js에서의 최적화에 대해 다루어보고자 한다. 회사에서 3D 웹 에디터를 만들면서 알게된 지식을 공유하고자 한다. 그래픽파이프라인을 (나처럼)찍먹정도 해봤다는 가정하에 웹 관점에서 작성해보도록 하겠다.


⭐️drawCall

병목현상을 해결하기 위해서는 결국 drawCall을 줄여야 한다. drawCall이란 무엇일까? graphics1_bottleneck

모바일경우 100정도를 넘어가면 힘들어 한다고 한다. (최신 모바일 기종은 200개 까지, PC는 1000정도…)

drawCall을 구하는 아주 간단한 공식(mesh * material)이 있지만, 지금은 편의상 1오브젝트렌더링 = 1drawCall발생으로 생각하도록 하겠다.


⭐️instancing

drawCall을 줄이는 가장 손쉬운 방법이다. 이는 동일 mesh에 대해 한번의 drawCall로 각각의 위치로 renderState를 바꿔주는 방법이다. 다음글에서 이야기할 batching에서는 고려할게 조금 있지만, instancing의 경우 하면 그냥 좋다. 적용할 수 있는 상황에서는 무조건 좋다. 똑같이 생긴 풀, 나무, 파티클들을 하나씩 렌더링하면 엄청난 드로우콜이 발생하므로, 인스턴싱을 적용하는것이 좋다.

10000개의 큐브를 만들 때, instancing을 주면 그저 1번의 drawCall만 일어난다. 물론 화면에 rendering하는 부분은 10000개만큼 부하를 받겠지만 큰 문제없다. 영상에서 보듯, 60FPS를 잘 방어한다. (M1-air여서 60FPS가 풀프레임이다)

하지만 instancing이 없는 상태로 만들 때에는 바로 frameDrop이 이루어지는것을 관찰할 수 있다. drawCall이 3000개를 넘어가기 시작하자 약 40fps까지 뚝 떨어지는것을 볼 수 있다.

그렇다면 어디까지 인스턴싱이 가능한 것일까?

Three.js라이브러리 내부의 InstancedMesh 함수를 열어보면 다음과 같은 구조로 짜여져 있다.

threeLibrary_InstancedMesh

BufferAttribute를 상속받은 객체, InstancedBufferAttribute를 통해 InstancedMesh를 구성한다. 결국 만들어진 버퍼를 GL에 던져준다. 버퍼와 함께 “그림그려!”라고 던져주는 이 명령이 바로 drawCall인 것이다. 그렇다면 이 Buffer는 어떻게 구성되어있을까?

| R R R T |
| R R R T |
| R R R T |
| 0 0 0 1 |

R은 rotation, T는 Transform, 숫자들은 동차좌표계이다. InstancedMesh를 동적으로 추가하는 코드들을 보면 float32로 이루어진 Vector4를 이용해서 컨트롤하기도 하는데, 여기서의 4X4벡터가 바로 이 버퍼인 것이다. 결국 우리가 복붙하고자 하는 어떤 물체, 즉, InstancedMesh화 하고자 하는 물체에 대해 회전, 위치변경, scale조절만 변형 가능한 것이다. 이를테면 조금 큰 나무와, 살짝 기울어진 나무는 하나의 나무로부터 Instancing시킬 수 있다는 것이다.

저는 색깔만 다른 큐브를 인스턴싱 시키고 싶은데요?

라고 생각하시는 분들이 있을 수도 있다. 이럴 땐, 별도의 컬러버퍼를 추가적으로 구성해서 GPU에게 전달해야한다. 매우 귀찮아진다고 볼 수 있다.


⭐️동적으로 추가하기

react-three-fiber를 사용했다. 일단 간단한 로직을 설명해 보겠다.

  1. 복사를 한다.
  2. 복사된 메쉬를 (three.js기본제공)traverse하며 모든 material을 기록한다.
  3. 모든 geometry와 material을 포함하는 컴포넌트 속성을 instancedMesh 속성에 attach시켜준다.
  4. 붙여넣기 할 때 마다 count를 올려준다.

고민이 많이 되었던 부분들을 설명 해 보겠다.

InstancedMesh를 생성할 때, three에서는 아래와 같다. 여러 example에서도 count를 특정해두었었다.

mesh = new THREE.InstancedMesh( geometry, material, count );

그리고는 이미 만들어진 instancedMesh들에게 그 위치를 부여하는 방식으로 구성되었다.

const cube = { position: new THREE.Vector3(x, y, z), rotation };

하지만, 복사붙여넣기를 할 때 마다 동적으로 count가 올라가기를 원했고, 다행히도 react-three-fiber에서 희망을 볼 수 있었다. 아래 react-three-fiber의 기본사용법에서는 아래와 같이 주어졌었다. 이를 wrapping해서 바깥에서 count를 주입해줌으로써 적절히 사용할 수 있었다. 전체코드는 여기에서 볼 수 있다.

    <instancedMesh args={[null, null, count]}>
      <boxBufferGeometry />
      <meshNormalMaterial />
    </instancedMesh>

그럼, 최종적으로 아래처럼 10000개의 큐브를 1개의 drawCall로 부르는 모습을 확인할 수 있다.

instancedCube10000


⭐️프로젝트 실 적용

이제 단순한 primitive 도형들을 instancedMesh로 그 숫자를 늘려주는 방법은 알아보았다. 하지만 프로젝트에 적용을 함에 있어 어려웠던점이 있었다.


🚀1. 성능측정

DrawCall이 몇번 일어나는지 확실히 측정해야, Instancing이 이루어지고있는지 확인할 수 있을 것이다. 웹 에디터 특성상 매 프레임마다 useFrame으로 렌더링 시켜주어야 하며 다음과 같은 코드를 통해 확인할 수 있었다.

  console.log(gl.info.render.calls)

하지만 제대로 된 값이 나오질 않았다. 무엇이 잘못되었는지 한참 찾다가 찾기를 포기했다. 찾기를 포기하고도 해결할 수 있는 방법이 있었다. 모든 컴포넌트들이 들어있는 최상단 <Canvas>에서 gl정보를 reset해주고 드로우콜을 측정함으로써 문제를 해결할 수 있었다.

  function DrawCallCounter() {
    gl.info.autoReset = false;
    useFrame(() => {
      console.log(gl.info.render.calls);
      gl.info.reset();
    });
    return null;
  }

  function MasterCanvas(){
    return (
      <>
        <Canvas id="canvas"> //최상단 캔버스
          <DrawCallCounter />
        </Canvas>
        </>
    )
  }

(물론 console.log로 찍지는 않고 화면에 pop-up을 따로 만들어서 “gl.info.render.calls”의 정보를 계속 업데이트 하며 보여주는 방법을 사용했다.)


🚀2. 메쉬가 복사되지 않는 문제

단순한 primitive 도형들을 추가하는 방법은 에 전체 코드를 공개해 두었다. 그렇다면 한개 이상의 mesh를 들고있을 때는 어떻게 해결해야 할까? 아래와 같은 방법으로 오브젝트의 모든 메쉬에 대한 정보를 따로 저장함으로써 문제를 해결할 수 있었다.

  메쉬가많은오브젝트.traverse((child) => {
    if (child.isMesh) {
      따로만들어둔배열.push(child)
    }
  });

위와같은 방법을 사용하면 따로만들어둔배열에 모든 메쉬정보를 담아낼 수 있다. 이를 이용하여 copy한 오브젝트에 대한 정보를 채울 수 있었다. 위에서 제시한 primitive 도형들을 instancing하는 코드를 살짝 응용하여 다음과 같이 작성함으로써 코드를 완성했다.

      <>
        {meshes.map((mesh, index) => {
          return (
            <instancedMesh
              key={index}
              ref={meshRef}
              args={[mesh.geometry, mesh.material, count]}
            >
              <primitive attach="geometry" object={mesh.geometry} />
              <primitive attach="material" object={mesh.material} />
            </instancedMesh>
          );
        })}
      </>


🚀3. 물체를 복사해서 원하는 위치에 생성시키는 로직

Instancing모드일 때와, 아닐때의 차이를 두었다.

Instancing모드가 아닐 때는, 클립보드에 복사한 오브젝트의 속성을 기반으로 새로운 오브젝트를 동일하게 init시키고 raycaster로 클릭한 곳에 오브젝트를 생성 해 주었다.

Instancing모드 일 때는, 오브젝트의 직접적인 생성은 InstancedMesh태그에게 위임하고, count를 올려주고 raycaster로 구한 위치를 dummy로 전달시켜줌으로써 해당 위치를.. InstancedMesh로 전달 될 배열에 하나 더 추가해준다.


⭐️마무리

Three.js로 만든 웹은 기본적으로 느리다. GPU를 fully사용할 수 없기 때문이다. 우리가 blender, Maya등으로 만들 3D에 비해 당연하게도 성능이 떨어질 수 밖에 없다. 이를 극복해내기 위한 많은 최적화기법들이 있으며, 대부분의 최적화 기법은 three.js뿐만이 아니라 통상 그래픽쪽에서 많이 사용하는 방법들이다. 실제로 최적화를 덕지덕지 적용하고 나면, ‘오? 빠르네?’ 라는 생각이 드는 수준까지 끌어올릴 수있다. 다양한 방법들에 대해서는 다음 글에서 다룰 예정이며 오늘은 instancing에 대해 알아보았다.

cpu-gpu 병목현상의 주 원인인 drawCall을 instancing을 통해 획기적으로 잡을 수 있었으며, 이는 동일한 mesh를 가지는 object들에 한해서 동작한다. scale, position, rotation등을 바꿀 수 있으니, 조금씩 모양이 다른 풀밭이나 나무, 총알, 파티클 등을 표현하는데 자주 쓰이는 기법이다. 그리고 <InstancedMesh>태그를 이용하여 three에서 이를 구현하는 방법과 자질구레한 이슈들과 해결방안들을 알아보았다.

프로젝트에 Instancing을 적용해보고자 하였지만, 정보가 거의 없어서 힘들었던 기억이 난다. 항상 공부를 하게되면 약간 minor한쪽으로 파고들게 되는것 같다. 예를들면, AI쪽에서 일을 할 때에는 ComputerVision이나 NLP보다 정보가 훨씬 적은 MIR쪽을 공부하게 되었고, 프론트엔드쪽에서 일을 할 때에도 정보가 별로 없는 그래픽쪽의 최적화를 공부하게 되는 식이다.

minor한 지식이라 커리어에 도움이 안된다고 생각하지 않고, 정보가 없는 상황에서도 문제를 잘 해결해 나갈 수 있는 개발자가 될 수 있도록 깊이 파고들어가며 그 과정속에서 얻은 지식이 뒤에서 올 누군가에게 도움이 되길 바란다.


맨 위로 이동하기

graphics 카테고리 내 다른 글 보러가기

댓글 남기기