3D 모델 데이터에 대한 Progressive Loading

three.js에서 모델 데이터를 로딩할때, 기본적으로 해당 모델 데이터가 완전히 로딩된 이후에 장면에 추가될 수 있고 그럼으로써 화면에 레더링된다. 이러한 처리로 인해 모델 데이터의 용량이 클 경우 사용자는 해당 모델 데이터가 완전히 로딩되기까지 기다려야 한다. 아래는 이러한 상황을 보여주는 동영상이다. 네트워크 속도를 느리게 설정해두었고 3개의 모델 데이터(각각의 용량은 32M, 50M, 54M 임)를 렌더링하는 경우이다.

위에서 보는 것처럼 각 모델 데이터가 완전히 로딩되어야 화면에서 볼 수 있고, 화면에 렌더링되기까지 시간도 제법 많이 소요되는것을 알 수 있다. 이러한 문제점을 개선하기 위해 점진적 로딩(Progressive Loading) 기법이 사용된다. 모델 데이터에 대해 여러개의 LOD 데이터를 미리 구축해두고 순차적으로 로딩하는 것인데, 이 LOD 데이터에는 지오메트리 뿐만 아니라 GPU에 최적화된 텍스쳐 이미지 데이터로의 처리가 되어 매우 빠르게 렌더링된다. 그 결과는 다음과 같다.

동일한 네트워크 환경에서 같은 품질의 모델 데이터를 로딩하는 상황인데, 앞서 봤던 것보다 훨씬 더 빠르게 모델이 표시되는 것을 알 수 있다.

다행히도 모델 데이터에 대한 프로그래시브 로딩을 위해 처음부터 개발할 필요는 없다. @needle-tools/gltf-progressive 패지키를 사용하면 매우 쉽게 만들 수 있다. 한번 알아보자.

먼저 모델 데이터를 점진적 로딩이 될 수 있게 변환해줘야 한다. 변환 프로그램은 다음처럼 임시로 설치해 이용할 수 있다.

npx @needle-tools/gltf-build-pipeline@latest

변환하고자 하는 모델 데이터가 MODEL1.glb라면 이를 점진적 로딩을 위한 모델 데이터로 생성하여 PROGRESSIVE_MODEL1 폴더에 저장해 주는 명령은 다음과 같다.

npx @needle-tools/gltf-build-pipeline@latest transform ./public/MODEL1.glb ./PROGRESSIVE_MODEL1

해당 폴더에는 다음처럼 여러 개의 glb 파일이 생성된다. 총 5개의 LOD 단계로 생성된 지오메트리와 텍스쳐에 대한 데이터이다.

이제 이렇게 만들어진 모델 데이터를 three.js에서 렌더링해서 시각화하는 코드를 살펴보자. 먼저 다음과 같은 패키지의 설치가 필요하다.

npm i @needle-tools/gltf-progressive

그리고 코드는 다음과 같다.

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { useNeedleProgressive } from "@needle-tools/gltf-progressive";

const loader = new GLTFLoader();
useNeedleProgressive(loader, renderer); // 플러그인 등록 (1회)

loader.load('./PROGRESSIVE_MODEL1/MODEL1.glb',
  (gltf) => {
    const model = gltf.scene;
    this._scene.add(model);
  }
);

적용이 매우 쉽다. 하지만 안타깝게도 @needle-tools/gltf-progressive는 WebGL 환경만을 지원한다. WebGPU 환경에 대한 지원도 곧 기대해본다.

Model Space → World Space → View Space → Clip Space에서의 행렬은 어디서 구하나?

먼저 Model Space → World Space로 변환하는 것은 변환하고자 하는 Object3D 객체의 matrixWorld이다. World Space → View Space로 변환하는 행렬은 카메라의 matrixWorldInverse이다. 여기서 matrixWorld는 카메라의 이동,회전에 대한 행렬이고 이의 역행렬을 이용해 World Space → View Space로 변환하는 것이다. View Space → Clip Space는 역시 카메라의 projectionMatrix이다.

Soft Min/Max

SDF에서 두 개의 Distance를 섞을 때….. 합은 최소로, 교차는 최대로 얻을 수 있다. 하지만 두 개의 거리가 만나는 지점에 너무 칼처럼 정확하면 보기가 딱딱해 보이는데.. 예를 들어 다음과 같다.

material.colorNode = Fn(([]) => {
  const color = backgroundColor(0.015, BLACK, GRAY);

  const st = uv().sub(.5);

  const A = sdfHexagon(st.sub(vec2(-.1, -.13)), .25);
  const B = sdfBox(st.sub(vec2(.1, .2)), vec2(.2, .2));

  const D = min(A, B, 100);

  color.assign(mix(RED, color, D.smoothstep(0, 0.0025)));
  color.assign(mix(BLACK, color, D.abs().smoothstep(0.005, 0.0052)));

  return vec4(color, 1);
})();

두 개의 거리가 만나는 지점을 좀더 부드럽게 표현하기 위해 Soft Min/Max를 사용하면 되는데, 먼저 Soft Min과 Max에 대한 함수는 다음과 같다.

const softMax = /*@__PURE__*/ Fn(([a, b, k]) => {
  return log(exp(k.mul(a)).add(exp(k.mul(b)))).div(k);
}, { a: 'float', b: 'float', k: 'float', return: 'float' });

const softMin = /*@__PURE__*/ Fn(([a, b, k]) => {
  return softMax(a.negate(), b.negate(), k).negate();
}, { a: 'float', b: 'float', k: 'float', return: 'float' });

앞서봤던 예제 코드에 위의 Soft Min를 적용한 코드는 다음과 같다.

material.colorNode = Fn(([]) => {
  const color = backgroundColor(0.015, BLACK, GRAY);

  const st = uv().sub(.5);

  const A = sdfHexagon(st.sub(vec2(-.1, -.13)), .25);
  const B = sdfBox(st.sub(vec2(.1, .2)), vec2(.2, .2));

  const D = softMin(A, B, 80);

  color.assign(mix(RED, color, D.smoothstep(0, 0.0025)));
  color.assign(mix(BLACK, color, D.abs().smoothstep(0.005, 0.0052)));

  return vec4(color, 1);
})();

끝으로 이와 관련된 또 다른 Soft Min, Max에 대한 글은 아래를 참조하자.

2개의 모양 함수를 smooth하게 섞는 방법(smooth min/max)

TSL에서 법선벡터와 관련된 노드 함수에 대한 고찰

fragmentNode에서 사용할 수 있는 일반적인 법선벡터는 normalWorld 노드이다. normalWorld은 이미 normalize가 되어 있다. transform이 전혀 이뤄지지 않았을지라도 normalLocal은 normalWorld와 다르며 normalLocal을 정규화해야 비로써 normalWorld와 같아진다. 즉, normalWorld.sub(normalLocal)은 0 벡터가 아니며 normalWorld.sub(normalLocal.normalize())가 0 벡터라는 것인데, 전제 조건은 transform이 전혀 이뤄지지 않았을때이다. normalWorld를 직접 계산해 보면 다음과 같다.

const normalView = vertexStage( modelNormalMatrix.mul( normalLocal ) );

vertexStage 노드는 vertex shader에서 varying(보간)으로 fragment로 넘겨준다. varying으로 넘겨줬고 법선벡터는 단위벡터로 사용되어야 하므로 넘겨받은 fragment 측에서 반드시 정규화를 시켜야 한다. 즉, normalWorld와 직접 계산한 normalView가 동일한 값을 가지려면 normalView를 정규화해야 한다.

가끔 시각화를 통해 normal의 동일성 여부를 확인하려고 할때가 있다. 즉, 아래의 코드처럼 말이다.

material.fragmentNode = Fn(([]) => {
  const color = normalWorld.sub(normalLocal);
  return vec4(color, 1);
})();

결과는 마치 normalWorld와 normalLocal이 동일하기라도 한것처럼 까맣게 표시된다. 하지만 아니다. 위의 코드 중 normalWorld.sub(normalLocal)의 결과값에 1000정도 곱해줘 값을 증폭시켜 보면 다음처럼 값에 대한 차이값을 눈으로 볼 수 있다.

쉐이더 프로그래밍의 디버깅은 이처럼 사람의 눈으로 직접 확인하는 방법 이외에 뾰족한 수가 없다는 문제가 있는데, 위와 같은 상황도 있다는 것을 미리 알아두면 좋을 것이다.