본문 바로가기
> 레벨업의 테크노트

[프론트엔드]3D 웹앱 깊이 빠져보기 - 2: webGL 퍼포먼스 향상시키기

by rabelais 2022. 6. 7.

안녕하세요, 빅픽처인터랙티브 서비스 개발 크루 프론트엔드(FE) 박성렬입니다.

아직도 🌍3D 웹앱 깊이 빠져보기 1부를 읽지 않으셨다면 보고 오시기를 추천드립니다! 1부에서는 3D 웹앱 기획과 디자인, 개발시 고려할 사항에 대해서 간단하게 살펴 보면서, 2부에서 어떤 내용을 다룰지 간단히 암시드렸습니다. 이번 글에서 다룰 내용은 조금 더 심도있고 기술적인 이해가 필요합니다. 따라서 기술적인 자신이 없는 분들은 건너뛰실 수 있도록 다른 챕터로 나누게 되었습니다.

 

이번에는 실제 코드 중심으로 어떻게 3D 웹앱을 최적화하고 구현했는지 알아보겠습니다. 제가 기존에 작성해오던 내용과 달리, 통일되지 않은 영어표현이 제법 많습니다. 한글로 번역했을 때 해당 의미가 다소 명확하게 전달되지 않는 부분이 있어, 읽기 쉽게 번역하지 못하고 영어로 그대로 전달하는 부분이 있으니 양해 부탁드립니다.

i.g.)
개념으로써의 도형(geometry) != Three.js의 Geometry == React-Three-Fiber의 <Geometry />
개념으로써의 속성(attribute) != Three.js의 Geometry.attribute(구현체) == WebGL의 attribute(값 바인딩이므로 사실상 같지만, 구현체 레이어를 구별하여 설명이 필요한 구간도 있음)

웹에서의 3D 개발

웹에 3D를 구현하는 일 자체는 너무나 간단합니다. 상상 외로 많은 라이브러리들 덕분입니다.

웹 3D 툴셋 선택하기

webGL을 이용하는 웹 3D 라이브러리는 아래의 두 가지가 가장 대표적입니다.

이 외에도 webGL라이브러리들을 정리해둔 깃헙에 따르면 2022년 4월 현재 19개의 webGL 라이브러리가 있습니다.

 

⏰절차적 프로그래밍 vs ⛳️선언형 프로그래밍

프론트엔드면 React 혹은 Vue와의 호환성이 높은 아래 라이브러리를 사용할 수도 있습니다.

이 라이브러리들은 웹 컴포넌트를 표시하기 위한 가상 DOM 노드(ShadowDOM, Virtual DOM)를 Three.js의 객체로 변환해줍니다. 따라서 일반적으로 3D 구현을 위해 필요한 작업을 순서적으로 나열한 절차적(procedural) 코드를 선언형(declarative) 코드로 훨씬 간결하게 작성할 수 있습니다.

 

절차적 코드는 오로지 절차와 순서만을 나열합니다. 우리가 아는 일반적인 코드에 제일 가깝다고 할 수 있습니다.

 

// 절차적 코드로 나타낸 3D 화면

// 일반적으로는 THREE로 import 하지만,
// 조금 더 간결하게 볼 수 있도록 T로 표기합니다.
import * as T from 'three';
const canvas = document.getElementById('my-canvas');
const renderer = T.WebGLRenderer();
const scene = new T.Scene();

// mesh 초기화
const geometry = new T.BoxGeometry(1,1,1);
const material = new T.MeshBasicMaterial();
const mesh = new T.Mesh(geometry, material);
scene.add(mesh);

// camera 초기화
const camera = new T.PerspectiveCamera(75, width / height);
scene.add(camera);

renderer.setSize(width, height);

const tick = () => {
	renderer.render(scene, camera)
	requestAnimationFrame(tick)
}
tick();

 

반면 선언형 코드는 각 이벤트가 주어질시의 화면(혹은 결과)를 묘사하여 프로그램을 완성합니다. 순서와 로직은 추상화를 통해 감춥니다.

// 위의 코드를 선언형 코드로 나타냈을 때
import { Canvas } from 'react-three-fiber';

const App = () => {
  return <Canvas>
    <boxGeometry args={[1,1,1]}/>
    <meshBasicMaterial />
  </Canvas>
}

웹 개발자에게는 어쩐지 매우 친숙한 변화입니다. 웹의 트렌드가 바닐라 JS 및 JQuery에서 JSX를 사용한 프론트엔드 프레임웍으로 이동한 것과 유사하다고 할 수 있습니다. 프론트엔드의 역사에서도 역시 절차적 프로그래밍의 순서형 구조가 웹DOM의 복잡한 화면 결과물을 묘사하는데 지나치게 거추장스러웠기 때문입니다.

절차적 프로그래밍: 코드가 행 별로 실행되는 순서가 중점⏰
명시적 프로그래밍: 원인과 결과가 중점⛳️

절차적 프로그래밍을 선언형 프로그래밍으로 접근하면 훨씬 난이도를 낮추고 코드에 대한 가독성을 끌어올릴 수 있습니다. 이미 말씀드렸던 웹은 물론이고, 데이터 시각화를 위한 대표적인 라이브러리인 D3.js에서도 선언형 접근 방식을 통해 데이터 이벤트를 좀더 읽기 쉽게 풀어내고 있습니다.

// d3: 데이터에 대한 ⛳️선언형 접근
const data = [1,2,3,4 /* ... */];
function renderDots() {
d3.selectAll('.dot')
  .data(data)
  /* 데이터 업데이트시 이벤트 */
  .enter(drawItem/* 데이터 진입시 이벤트 */)
  .exit(removeItem/* 데이터 퇴장시 이벤트 */)
}

// 데이터 업데이트
data = /* ... */;
renderDots(data); // 데이터 이벤트를 중심으로 작성된 선언형 코드가 알아서 해야될 동작을 구분한다.

 

데이터에 대한 선언형 접근이 없을 때, 위의 내용을 절차적으로 나타나면 이렇습니다.

// vanilla js: ⏰절차적 코딩으로 같은 결과를 묘사하고자 할 경우
// 같은 형태를 vanilla js에서 접근하는 경우를 상상해 본 의사코드(pseudocode)
const data = [1,2,3,4 /* ... */];

function enter(prevData, data) {
  const previousDots = document.querySelectorAll('.dot');
  data.forEach(compareAndDrawItem);
  /* ... 데이터 진입시 이벤트 */
}
function update(prevData, data) {
  /* ... 데이터 업데이트시 이벤트 */
}
function exit(prevData, data) {
  /* ... 데이터 퇴장시 이벤트 */
}

// 절차적 프로그래밍은 이벤트가 아닌 절차를 통해서 기능을 구현한다.
// 따라서 함수(function)에 결과 혹은 원인을 나타내는 작명을 하는 것 정도만을 기대할 수 있다.

// 데이터를 새로 삽입하면 수동으로 enter 이벤트를 실행시킨다.
previousData = []
data = /* ... */
enter();
previousData = data;
// 데이터를 삭제하고 수동으로 exit 이벤트를 실행시킨다.
data = []
exit();

 

세상 일에 만능이란 없습니다. 선언형 역시 모든 경우에 추천할 수 있는 것은 아닙니다. 작업의 효율성과 성능이 대체로 반비례하기 때문입니다. 오히려 추상화로 인한 오버헤드가 발생할 수 있습니다.

 

더 많은 추상화는 사용자 입장에서는 더욱 많은 로딩 화면을 뜻합니다. 더 많은 코드를 다운로드해야하고, 코드를 메모리에 모두 실을 때까지 더 많은 시간이 걸립니다.

 

그러나 이벤트를 사전에 정의하고 사용하는 것이므로 첫 프레임 렌더가 완료되고 나면, 구동 중일때의 퍼포먼스 차이는 크지 않습니다. 따라서 필요에 따라 적절한 라이브러리를 사용하는 것이 더 중요합니다.

 

따라서 아래의 경우에 선언형 웹 3D 라이브러리 사용을 고려해볼 수 있습니다.

  • 만들고자하는 앱의 구조가 복잡하다
  • React, Vue등의 프론트엔드 라이브러리 기능을 같이 활용해야 한다
  • 제한된 시간내에 프로토타이핑해야한다
  • 사용자가 첫 로딩을 충분히 감내할 수 있다

반대로 정밀한 최적화가 필요할 경우(i.e. 빠른 속도로 나타나야 하는 3d 대시보드) 조금 불편하더라도 명령형 라이브러리를 사용하여 로딩 시간과 메모리 사용을 더욱 줄일 수 있습니다.

 

GCO의 경우 토이 프로젝트의 성격을 고려하여 React-Three-Fiber를 사용했습니다. 선언형 접근 방식을 선택한 덕분에 매우 빠른 프로토타이핑이 가능했습니다.

 

// React-Three-Fiber 내의 JSX를 사용한 ⛳️선언형 구조
<>
  <StickyHtml /> {/* 2D 오버레이 화면 */}
  <Canvas> {/* 3D 캔버스 화면 */}
    <Suspense fallback={<LoaderHtml />}> {/* 내부에 로딩이 필요할 경우 로딩화면 처리 */
      <RootScene />
    </Suspense>
  </Canvas>
</>
// RootScene
<>
  <CamControl /> {/* 카메라 컨트롤 */}
  <Environment /> {/* 반사맵 및 자연광원 */}
  <CustomBackdrop /> {/* 배경 */}
  <Heading /> {/* 타이틀 화면: 여기서부터 화면 == 100vh */}
  <Section1 /> {/* 화면 1 */}
  <Section2 /> {/* 화면 2 */}
  {/* ... */}
</>

 

절차적 코드에 비하여 어느 기능이 어느 화면까지 영향을 미칠지를 코드를 보고 쉽게 유추할 수 있습니다. 만약 위와 같은 코드를 절차적으로 나타내면 아래와 같이 복잡하게 나타내야 합니다. 뜻을 유추할 수 없는 것은 아니지만, 각 객체간의 계층 구조나 원인과 결과에 대해서 이해하기는 훨씬 어렵습니다.

// 위의 코드를 Three.js만을 이용하여 ⏰절차적으로 나타냈을 때
const StickyHtml = document.createElement('div');
document.appendChild(StickyHtml);
const Canvas = document.createElement('canvas');
document.appendChild(Canvas)
const renderer = T.WebGLRenderer();
const RootScene = new T.Scene();
const LoaderHtml = document.createElement('div');
document.appendChild(LoaderHtml);

// CustomBackdrop에 해당
RootScene.environment = // ...
RootScene.background = // ...

// 로딩이 끝나면 직접 Html을 안보이게 지정
const loadJob = async() => /* ... */
loadJob.then(() => document.removeChild(LoaderHtml));

// CamControl에 해당
const camera = new T.PerspectiveCamera(/* ... */)
RootScene.add(camera);

const Heading = new T.Scene();
RootScene.add(Heading);

const Section1 = new T.Scene();
RootScene.add(Section1);

renderer.render(RootScene, camera)

 

WebGL 퍼포먼스 향상시키기

툴셋을 선택했으니 이제는 프로그램을 작성할 차례입니다.

 

단순한 엔진 사용법에 관련된 설명은 건너 뛰도록 하겠습니다. 그건 너무 지루한 설명이거든요. 3D 물체의 배치는 라이브러리의 도움을 통해 생각보다 매우 간단하게 구현할 수 있습니다. 라이브러리 문서를 읽어보면 쉽게 파악할 수 있는 내용보다, 더욱 복잡한 화면을 구성하고자 할 때 도움이 될 수 있는 부분을 알아보겠습니다.


WebGL 생명주기 및 병목구간

웹상의 3D에서 🎮🕹콘솔 게임기나 닌텐도 스위치같은 성능을 기대하기는 어렵습니다.

 

게임 전용 하드웨어에서는 최대한의 기기의 성능을 끌어낼 수 있도록 불필요한 기능과 연산이 제거되어 있습니다. 웹의 경우는 브라우저 내부의 환경에서 GPU의 연산을 함께 처리해야하기 때문에 그리 간단하지 않습니다.

 

병목현상이 일어나는 구간을 이해하기 위해 간단하게 WebGL의 생명주기(Lifecycle)를 그려보았습니다.

 

 

이처럼 생명주기가 복잡해보이는 이유는, 웹의 생명주기 안에서 webGL의 생명주기가 작동하는 2중의 구조로 되어있기 때문입니다. 이 생명주기 도식을 이해하기 위해 추가로 설명이 필요합니다.

 

 

program, shader webGL은 내부에 여러가지의 프로그램(program)을 가지고 있습니다. 프로그램은 셰이더(shader)라고도 합니다. 셰이더들은 일반적인 공식(function)처럼 입력값에 따라 다른 출력값을 내보냅니다.

input -> shader(input) -> output

 

 

context 모든 webGL 프로그램(셰이더)상에서 공유되는 전역적인 변수들은 컨텍스트(context)에 등록합니다. 프로그램상에서 관리되는 공용 상태를 뜻하는 용어이므로, 문맥으로 번역하지 않고 컨텍스트라고 번역했습니다. 일반적으로 webGL의 전역적인 변수에 접근하고자 하면 컨텍스트에 접근하면 됩니다.

 

 

frame loop/render loop 프레임 렌더를 위해 계속 반복해서 window.requestAnimationFrame()을 호출하는 부분을 frame loop으로 표현하겠습니다. requestAnimationFrame은 다음 프레임이 그려지기 전까지 블록킹이 있지만, requestAnimationFrame바깥의 다른 stack call(이하 스택 호출)은 블록킹 하지 않습니다. 또한 재귀로 자기 자신을 호출할 경우 클로저를 공유하지 않습니다. 렌더 함수 render()가 호출되므로, render loop으로 부르기도 합니다.

 

 

GLSL glsl(GL Shading Language)이란 webGL에서 사용되는 OpenGL기반의 셰이더 언어입니다. glsl로 작성된 내용은 등록 즉시 GPU용 기계어로 번역됩니다. 컴파일에 frame loop보다 상대적으로 긴 시간이 필요하므로 동적으로 변동될 수 없고, 프로그램 동작 초기에(보통은 리소스 로딩 도중에) 컴파일합니다. 컴파일이 가능하기 때문에 특정 값을 GLSL에 상수로 등록하면 불필요한 할당 메모리(allocated memory) 낭비를 줄일 수 있습니다.

 

 

buffer, attribute

webGL의 기본 이론을 다루고 있는 개념서인 webGL 기초(webGL fundamentals)에 따르면 아래와 같습니다.

  • buffer: GPU에 업로드 된 이진수의 데이터
  • attribute: buffer를 프로그램(셰이더)에서 사용할 수 있도록 재가공한 형태의 변수
자바스크립트 변수var/const/let -> attribute -> buffer(GPU 메모리)
-> attribute -> shader 공식 -> output

아래에서 다시 언급하겠지만 자바스크립트 변수는 따라서 그대로 유지되지 않고 아래의 두 공간을 모두 통과해야 합니다.

  • CPU를 통하여 절차적이고 복잡한 연산이 가능한 브라우저(자바스크립트) 환경
  • GPU를 통하여 빠른 병렬연산이 가능한 webGL(glsl)의 환경

따라서 어떤 자료를 GPU에 보낼 것인지, 어떤 자료를 CPU에 보낼 것인지를 명확하게 결정하여 필요한 최소한의 자료만을 각 공간으로 보내어 병목현상을 최소화해야 합니다.

 

이제 실질적인 예를 통해 각 구간에서 어떻게 병목현상을 최소화할 수 있을지 알아보겠습니다.


병목 현상 구간 A: buffer, attribute

 

three.js를 사용했을 때, 일반적인 webGL렌더는 아래와 같이 진행됩니다.

// three.js 코드
import * as T from 'three';
const canvas = document.getElementById('my-canvas');
const renderer = T.WebGLRenderer();

const geometry = T.BoxGeometry(/* ... */);
const material = T.ShaderMaterial(/* ... */);
const mesh = T.Mesh(geometry, material);

/* ... 그 외에 필요한 scene 등록 */

window.requestAnimationFrame(tick);

// render loop
function tick() {
  // webGL렌더에 필요한 값을 주입하는 부분
  material.uniforms.uMyVar.value = /* ... */
  // 또는
  geometry.position.x = /* ... */

  renderer.render();
  window.requestAnimationFrame(tick);
}

 

render loop 내부에서 매 프레임마다 정보를 webGL에 attribute 라는 형태로 전달합니다. 전달된 자료는 브라우저 내부의 메모리에 저장되지 않고 비디오 메모리인 buffer에 저장되어 재활용됩니다. 위의 코드에서 실제로 정보 전달이 이루어지는 부분만 떼어보면 아래와 같습니다.

 

material.uniforms.uMyVar.value = /* ... */
geometry.position.x = /* ... */

 

uMyVar 또는 geometry.position에 값을 집어넣으면 브라우저 내부의 heap 메모리에 있던 정보가 GPU의 버퍼로 이동합니다.

 

 

stack 메모리의 연산이 끝나야 비로소 heap 메모리의 변수가 확정되고 GPU의 버퍼에 정보를 전달할 수 있기 때문에 브라우저의 연산속도만큼 딜레이가 발생합니다. SIGGRAPH에서 만든 webGL 기술서인 webGL insight에서도 자바스크립트 변수의 비트 표기와, (webGL) API 내부의 C++ 비트 표기가 동일하지 않기 때문에 변환 작업이 필요하다고 언급하고 있습니다.

 

the bit representation of these parameters is not the same on the JavaScript side and on the browser internal C++ side, so some conversion work needs to be done - webGL insights p.38

 

변환 작업이 이루어지기 위해서는 브라우저의 연산 작업이 우선 완료된 상태여야 합니다. 만약 추가적인 연산이 이루어지게 된다면 GPU는 그만큼 느리게 작업을 시작하게 됩니다. 아무리 GPU의 성능이 좋더라도 여기서 발생하는 딜레이로 인해 병목 현상이 발생하게 됩니다. 도식으로 나타냈을 때, 빨간색으로 표시된 화살표 부분의 연산을 최대한 줄여야 합니다.

 

브라우저의 퍼포먼스 측정 도구를 이용해보면 더욱 뚜렷하게 알 수 있습니다. 브라우저의 Performance 탭에서 frame loop을 측정해보면 아래와 같이 브라우저의 쓰레드 작업이 완료된 후에야 GPU가 작동하는 것을 볼 수 있습니다.

스토리북에서 uniform을 통해 자바스크립트 변수를 uniform으로 집어넣는 frame loop을 만든 뒤 크롬의 Performance 탭에서 캡쳐했습니다

이 구간에서는 매 프레임마다 발생하는 브라우저의 쓰레드 작업을 경량화 시키는 것이 핵심입니다. 따라서 대기가 발생할 수 있는 브라우저 내부의 변수를 최대한 적게 전달해야합니다.

 

따라서 어느 정보를 버퍼에 넣어서 미리 전달하고, 어느 정보를 매 프레임마다 갱신할 것인지를 구분해야합니다.

 

const complexVectorData = new Float32Array(/* ... */);
// 복잡하거나 굳이 업데이트가 필요 없는 데이터는 사전에 buffer attribute로 전달한다.
// 당연히 도형의 기본 형태나 텍스쳐, 지형리소스 등이 이에 포함된다.
geometry.addAttribute('myData', complexVectorData);

/* ... */

// frame loop
function tick() {
  // frame loop 안에서는 필요한 핵심 정보만 전달한다.
  geometry.uniforms.uMyVar = /* ... */
  /* ... */
}

 

 

아래 화면에 나오는 도형의 경우 도형의 각 면(vertex, 이하 면으로 통일합니다) 좌표는 Three.js의 Geometry를 통해 버퍼에 미리 제공하고, 각 면이 이동할 때에는 해당 위치로부터 uniform값을 받아 가감하여 도형의 면이 멀어지는 파괴효과(disperse)가 나타나도록 처리했습니다.

면 파괴효과가 일어나기 전 기본 도형

 

 

vertex disperse가 일어난 뒤의 도형

import { useScroll, useTexture } from '@react-three/drei';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { GroupProps, useFrame, useLoader } from '@react-three/fiber';
// GLTF로더를 통해 외부에서 가져온 로고 도형을 사용합니다.
// 로고는 블렌더를 이용해 SVG를 extrude하여 만들었습니다.
const { nodes } = useLoader(GLTFLoader, '/models/logo.glb');
const m = useMemo(() => nodes.Mesh as THREE.Mesh, [nodes]);
const map = useTexture('/textures/matcaps/white.jpg');
const refMesh =
    useRef<THREE.Mesh<THREE.BufferGeometry, THREE.MeshStandardMaterial>>();
// Drei.useFrame을 이용하여 render loop에 조금 더 쉽게 접근할 수 있습니다.
useFrame(({ clock }) => {
  if (refMesh.current) {
    // 시간에 따라 도형을 회전시킵니다
    refMesh.current.rotation.z = clock.getElapsedTime();
  }
  if (refShader.current) {
    // 스크롤값에 따라 도형 왜곡의 정도를 조절합니다.
    // 즉 실제로 uniform을 통해 매 프레임마다 전달될 수 있는 값은 오로지 0~1사이의 숫자 하나입니다. <-- 👀
    refShader.current.uniforms.uIntensity.value =
      1 - scroll.curve(1 / scroll.pages, 0.5 / scroll.pages, 0.1);
  }
});

<group name="logo">
  {/* 모델의 기본(default) 회전값도 사전에 미리 제공합니다 */}
  <mesh geometry={m.geometry} rotation={[0.5 * Math.PI, 0, 0]}>
    {/* 스크롤에 맞추어 도형이 반응하므로, 스크롤값=세기값(intensity)으로 하여  uniform으로 제공합니다 */}
    <meshStandardMaterial 
      attach="material"
      map={map}
      color={0xed0005}
      metalness={0.3}
      roughness={0.5}
      onBeforeCompile={(shader) => {
	      // meshphysical.glsl.js
	      shader.uniforms.uIntensity = { value: 0 };
	      shader.vertexShader = shader.vertexShader.replace(
	        `varying vec3 vViewPosition;`,
	        `varying vec3 vViewPosition;uniform float uIntensity;`
	      );
	      shader.vertexShader = shader.vertexShader.replace(
	        `#include <begin_vertex>`,
	        `
          // 도형의 position과 matrix는 메모리에 이미 있기 때문에 이를 활용합니다.
          // 이미 값이 buffer에 들어가있기 때문에 병목현상이 적습니다.
	        vec3 new_position = position + uIntensity * (0.5 + 0.5 * cos(position.x + 4.0 * pow(2.0, uIntensity))) * normal;
	        vec3 transformed = vec3(new_position);
	      `
	      );
	      refShader.current = shader;
	    }}
		/>
  </mesh>
</group>

 

이 부분의 코드가 복잡하여 이해가 어렵다는 피드백도 있었는데요, 실제로 주목할 것은 파괴효과를 구현하기 위한 수식 그 자체가 아닙니다.

frame loop 안에서는 어떤 일이 일어나고, webGL안에서는 어떤 일을 하는 것으로 구분했는지를 집중적으로 보아주시면 될 것 같습니다.

  • 최대한 적은 uniform값만을 전달하여 불필요한 메모리 형태 변환을 막고 있다는 것
  • 실제 계산은 GPU안에서 병렬로 진행하는 것

면 위치(vertex position) 변조를 처음 시도해보는 많은 프로그래머들이 uniform에 너무 많은 값을 넣거나, 반대로 수치 계산을 CPU안에서 진행하여 퍼포먼스 조절에 실패합니다.

 

// ❌😵 uniform에 너무 많은 값을 넣는 경우

function frameloop() {
  myShader.uniforms.uVertices.value = new Float32Array(/* 도형 vertex의 모든 좌표 */) }
}

// ❌😵 frameloop안에서 너무 많은 계산을 하는 경우

function frameloop() {
  vertices = vertices.map(/* ...계산 공식... */)
  myShader.uniforms.uVertices.value = vertices;
}

// ✅👌 frameloop안에서 최대한 적은 계산과 uniform 값을 전달하고, 실제 계산은 webGL에서 진행

myShader.shader = glsl`/* ... glsl로 된 webGL 계산 공식 ... */`

function frameloop() {
  const delta = /* ... delta는 0~1 사이의 자연수 ... */
  myShader.uniforms.uIntensity.value = delta;
}

 

 

 


병목 현상 구간 B: 인스턴스 재사용하기

frame loop안에서 인스턴스를 생성하게 되면 수십 밀리초보다 짧은 시간동안 인스턴스가 생성되었다가 삭제되어야 합니다. 메모리를 새롭게 할당하고 해제하는 데 계속 CPU 자원이 낭비되면 속도가 느려질 수밖에 없습니다. 따라서 필요하다면 frame loop 밖에서 미리 필요한 인스턴스를 생성해두고 재활용하면 됩니다.

 

// ❌ 인스턴스를 매 프레임마다 생성
function tick() {
  geometry.position = new THREE.Vector3(/* ... */);
}

// 👍🏻 인스턴스를 미리 생성해두고 재활용
const myVector = new THREE.Vector3(/* ... */)

function tick() {
  myVector.set(/* ... */);
  geometry.position.copy(myVector);
  /* ... */
}

 

webGL insights에서도 frame loop 안에서 객체를 생성하지 말 것을 강조하고 있습니다. 변수의 메모리 할당과 해제를 담당하는 가비지 콜렉터(Garbage Collector, 이하 GC)는 자바스크립트 코드와 webGL보다 우선순위가 높기 때문에 너무 많은 할당과 해제가 일어나면 프로그램 자체가 멈춰버릴 수 있습니다.

 

For performance, avoid object allocation in the render loop. Reuse objects and arrays where possible, and avoid built-in array methods such as map and filter. Each new object creates more work for the Garbage Collector, and in some cases, GC pauses can freeze an application for multiple frames every few seconds. - webGL insights

 

Three.js에 비해 이를 React-Three-Fiber에서 사용하기는 조금 어려운데, JSX 추상화 과정에서 같은 Mesh 라도 여러번 등록하면 같은 인스턴스로 처리하지 않기 때문에 InstancedMesh를 사용해야 합니다. InstancedMesh는 몇 가지 제약사항이 있지만, 인스턴스를 재활용하는 것은 같습니다. 같은 모델을 좌표 matrix 값만 바꾸어 병렬연산하기 때문에 더욱 빠른 속도로 작업이 가능합니다.

 

GCO 프로젝트의 첫 장면. 게임코치의 로고 3D 모델이 여러개 반복하여 나타난다.

GCO의 첫 장면에서 같은 3D 도형을 여러 개 그리고 있는데, 여기서 InstancedMesh를 활용했습니다.

// <Heading /> 내부
// 좌표를 계산하여 미리 prop으로 전달하도록 준비합니다.
const logoProps = useMemo(() => {
	const [repeatX, repeatY] = [8, 5];
	const count = repeatX * repeatY;
	return Array.from({ length: count }).map((_, i) => ({
	  position: [(i + 1) % repeatX, Math.ceil((i + 1) / repeatX), 0],
	  row: i + (1 % repeatX),
	  col: Math.ceil((i + 1) / repeatX),
	}));
}, []);

// 아까와 똑같이 모델을 미리 불러와서 geometry를 접근할 수 있는 형태로 만듭니다.
const { nodes } = useLoader(GLTFLoader, '/models/logo.glb');
const m = useMemo(() => nodes.Mesh as THREE.Mesh, [nodes]);
// matcap 텍스쳐를 이용해서 명암을 좀더 부드럽게 표현했습니다.
const map = useTexture('/textures/matcaps/white.jpg');

<Instances geometry={m.geometry} castShadow limit={40}>
  <meshStandardMaterial
    attach="material"
    map={map}
    color="white"
    metalness={0.3}
    roughness={0.5}
  />
  {logoProps.map(({ position, row, col }, i) => (
    <LogoModel
      row={row}
      col={col}
      key={i}
      position={position as Vector3}
    />
  ))}
</Instances>

interface LogoModelProps extends GroupProps {
  row: number;
  col: number;
}

interface LogoInstance extends THREE.InstancedMesh {
  color: THREE.Color;
}

// <Instance />를 통해 기준 모델의 matrix좌표가 어떻게 변환될지를 전달합니다.
const LogoModel: React.FC<LogoModelProps> = ({
  row,
  col,
  ...props
}) => {
  const refGroup = useRef<THREE.Group>();
  const refInstance = useRef<LogoInstance>();
  const scroll = useScroll();
  const internalState = useRef({ intensity: 0 });
  
  /* ... 이 사이에 모델 회전 등 애니메이션 기능이 들어갑니다 ... */

  return (
    <group scale={2} {...props} ref={refGroup}>
      <Instance rotation={[0.5 * Math.PI, 0, 0]} ref={refInstance} />
    </group>
  );
};

병목 현상 구간 B: damping, 정규화(normalization)로 상태관리 피하기

흔히 애니메이션 작업상의 편의를 위해 framer motion 또는 gsap(green sock)을 사용하게 됩니다. 써드파티 라이브러리를 사용하면 작업 효율이 크게 올라가는데다, requestAnimationFrame을 이용하여 비교적 블록킹이 적게 일어나도록 최적화가 되어 있기는 합니다.

 

 

문제는 라이브러리의 상태관리가 별도로 필요 하다는 점입니다. 애니메이션의 시작과 종료 시점에 메모리를 할당하고 할당 해제하는 작업을 거치면서 일시적인 느려짐(lag, jank)이 발생합니다. 따라서 나타내려고 하는 내용이 크게 복잡하지 않다면 별도의 값 복사 없이 셰이더에 제공되는 변수만을 가지고 애니메이션을 연산해서 퍼포먼스를 개선할 수 있습니다.

 

애니메이션 라이브러리의 애니메이션을 고정된 수식만 가지고 계산하면 메모리의 할당과 해제가 일어나지 않으므로 그만큼 연산 능력을 절약할 수 있게 됩니다.

 

수식 애니메이션은 보통 선형보간법(Linear intERPolation, 이하 LERP)이나 다항식 보간법(quadratic interpolation)에 시간 차이값(delta 혹은 theta)을 제공하는 방식으로 구현합니다. 문제는 이 시간 차이값이 무한대로 증가할 수 있다는 부분입니다.

 

 

// <https://github.com/mrdoob/three.js/blob/master/src/math/MathUtils.js>
function lerp( x, y, t ) {
  // 여기서 목표값 t가 증가할수록 결과값도 무한대로 증가한다.
	return ( 1 - t ) * x + t * y;
}
lerp(1,2,1) == 2 // t가 1을 넘지 않을 때에만 최대값이 나온다.
lerp(1,2,2) == 3 // t가 1을 넘길 경우 최대값을 초과하게 된다.

 

이를 해결하기 위해서 거듭제곱(exponentation)의 지수적 감쇠(exponential decay) 특성을 이용해 최소값과 최대값을 -1에서 1 사이의 값으로 정규화할 수 있습니다. 따라서 별도의 상태 관리 없이도, 원하는 최소값 혹은 최대값에 도달하면 숫자는 더 이상 증가할 수 없게 됩니다. 이를 damping 기법이라고 합니다.

 

// <https://github.com/mrdoob/three.js/blob/master/src/math/MathUtils.js>
function damp( x, y, lambda, dt ) {
  // 
	return lerp( x, y, 1 - Math.exp( - lambda * dt ) );
}
// 여기서 lambda는 기울기(완만함) 정도를 나타낸다.
damp(1,2,l,1) == 2;
// t가 1을 넘겨도 최대값이 유지된다.
// 따라서 애니메이션을 따로 종료할 필요가 없어진다!
damp(1,2,l,2) == 2;

 

위의 인스턴스화된 로고 모델로 돌아가 이를 어떻게 사용하는지 다시 보도록 하겠습니다.


  const LogoModel: React.FC<LogoModelProps> = ({
  row,
  col,
  ...props
}) => {
  const refGroup = useRef<THREE.Group>();
  const refInstance = useRef<LogoInstance>();
  const scroll = useScroll();
  const internalState = useRef({ intensity: 0 });
  
  useFrame(({ clock }, delta) => {
    const visible = scroll.visible(0, 1 / scroll.pages);
    const t = clock.getElapsedTime();
    if (refGroup.current) {
      refGroup.current.position.z =
        Math.sin(t + col) * Math.sin(t + row / 2) * 0.045;
    }
    
    // 여기에서 오로지 damp와 clamp만 이용하여 최대한 조건문 및 반복문 계산을 피하고 있습니다.
    // 코드 가독성을 위한 최소한의 if만 사용하고 있습니다.
    // 여기서 조금 더 퍼포먼스를 올리고자 한다면,
    // 가독성을 희생하고 타입스크립트의 강제 형변환을 이용해 if조건문도 없앨 수 있습니다
    
    if (refInstance.current && visible) {
      internalState.current.intensity =
        1 -
        THREE.MathUtils.clamp(
          Math.pow(Math.sin(t + col) * Math.sin(t + row / 2), 4),
          0,
          1
        );
      refInstance.current.color.g = THREE.MathUtils.damp(
        refInstance.current.color.g,
        internalState.current.intensity,
        4,
        delta
      );
      refInstance.current.color.b = THREE.MathUtils.damp(
        refInstance.current.color.b,
        internalState.current.intensity,
        4,
        delta
      );
    }
  });

  return (
    <group scale={2} {...props} ref={refGroup}>
      <Instance rotation={[0.5 * Math.PI, 0, 0]} ref={refInstance} />
    </group>
  );
};

 

Three.js 수학 유틸리티 사용하기

 

이미 알아차리셨을지도 모르겠네요. 위에서 damping 기법을 설명할 때 보여드린 공식은 three.js의 수학 유틸리티 소스에서 가져왔습니다. 3D에는 워낙 많은 수학공식이 필요하다보니 기본수학 정도는 모두 포함하고 있습니다. 데이터사이언스를 위한 pandas 가 통계를 위한 각종 수학공식을 포함하고 있는 것과 같은 이치입니다. 따라서 도형 위의 좌표를 구하는 기능 정도는 내부적으로 모두 구현되어 있습니다.

  • 삼각형
    • 삼각형의 중간점 구하기
    • 삼각형의 부피 구하기
    • UV 좌표 구하기
    • 삼각형의 좌표 안에 포함되었는지 확인하기
    • 삼각형을 정확하게 포함하는 사각형 히트박스 그리기
  • 구체(Sphere)
    • 방위각(azimuth)과 고도(altitude)를 가지고 구체위의 3차원 좌표 구하기
    • 구체를 정확하게 포함하는 사각형 히트박스 그리기
    • 특정 도형이 구체에 겹치는지 확인하기
    • 3차원 좌표가 주어졌을 때 그 좌표를 포함하게 구체 확장하기
    • 구체의 밖으로 나가지 않도록 clamp하기
    • UV 좌표 구하기
    • 등등…

따라서 3D 관련 작업을 하고 있다면, 굳이 그 구현체를 수동으로 구현하는 편 보다는 three.js의 수학공식을 사용하는 편이 빠르고 더 쉽습니다. Signed Distance Field(SDF)를 수동으로 계산하여 극단적으로 최적화가 필요한 경우가 아니라면, 어지간한 공간 내의 도형 연산은 수학 유틸리티로 해결이 가능합니다. 이러한 기능들을 제대로 활용하기 위해선, 공식 문서를 조금 더 꼼꼼히 읽어 볼 필요가 있습니다.

 


병목 현상 구간 C: 셰이더 최적화하기

조건문 혹은 상태관리를 통한 기능 구현보다, damping을 통해 수학적으로 최소값과 최대값을 구현하면 더욱 빨리 최적화가 가능하다고 이미 말씀드렸습니다. 이 부분은 셰이더 내부에서도 마찬가지입니다.

webGL은 if 와 for문을 모두 지원하지만, 이를 최대한 피하는 것이 최적화에 매우 유리합니다.

이유는 몇 가지가 있습니다.

  • if문을 사용해서 특정 값을 비교하고자할 경우 GPU 내에 불필요한 AST(Abstract syntax tree)가 생성됩니다. 메모리에 더 많은 코드를 가지고 있어야 합니다.
  • if문을 거치면 특정 조건에서 어떤 좌표는 다른 좌표보다 더 많은 계산이 필요할 수 있습니다. GPU의 병렬 연산 기능을 활용하려면 모든 연산이 거의 동시에 이루어져야 하는데, 한 구간에서 느려지면 불필요하게 모든 구간에서 느려짐이 발생하게 됩니다.
  • 벡터 공간은 부동소수점에 최적화되어 있어서 정수를 활용한 for 문 처리에 알맞지 않습니다.

따라서 이를 보완하기 위해 openGL에서 지원하는 기본(built-in) 수학 기능을 이용해 if 문과 for문을 대신하게 됩니다.

대표적으로는 min, max, clamp, step등을 생각해볼 수 있습니다.

 

대체로 min, max 기능을 내부적으로 복잡하게 활용하고 있는 기능들인데, 어떠한 수치가 특정 값 이상이면 이를 초과할 수 없도록 하여 true, false를 0과 1로 나타내는 것입니다. if와 기능은 완전히 같지만, 수학적으로 구현하여 메모리 낭비를 피할 수 있습니다.


마치며

웹3D를 깊이 파고든 GCO프로젝트는 난해하고 또 어려운 작업이기도 했습니다. 문서화가 잘되고 예제가 잘 뒷받침된 2D 웹에 비해서 직접 부딪혀가며 배우는 것들이 많았습니다.

 

webGL의 세계는 정말 드넓기 그지 없습니다. 이렇게 긴 내용을 정리하고도 전달해 드릴 것이 ⛰산더미거든요. 3D 좌표계 자체의 복잡함도 있고, 3D업계에서만 사용되는 독특한 용어와 역사를 알아야만 이해가 가능한 부분들도 있습니다. 하지만 그 깊이와 복잡함에 진정한 아름다움이 숨어있습니다. 만일 아마존의 밀림이 걸어서 한 바퀴를 돌면 전부 알 수 있는 근린공원이었다면 그렇게 매력적이지 않았겠죠. 많은 개발팀들이 이 어려움을 감수하고도 3D라는 정글에 뛰어드는 이유 역시 마찬가지가 아닐까 합니다.

 

너무 흔한 트렌드 같지만, 반대로 아무나 흉내낼 수 없는 정교한 예술이기도 하니까요. 한동안 느린 호흡으로 편한 개발을 해왔던 저에게도 아주 신선한 자극이었습니다. 그리고 이제는 자신있게 말할 수 있을 것 같기도 합니다. 다른 건 몰라도, 3D정도는 알고 있다구요. 😜

댓글