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이다.

드디어 three.js에서도 GI 기능이 지원됩니다.

드디어 three.js의 WebGPU 지원이 매우 안정화되면서 GI(Global Illumination, 전역 조명) 기능을 사용할 수 있습니다. 꽤 오래전 babylon.js에서 GI 기능을 제공하지만 three.js는 그러지 못해 늘 안타까웠는데요. 먼저 GI란 3D 장면 내에서 빛이 광원에서 나와 물체에 직접 닿는 것(직접광)뿐만 아니라, 표면에 반사되고 산란되어 주변 환경을 밝히는 간접광(Indirect Lighting)까지 모두 계산하여 사실적인 조명 효과를 만들어내는 기술이라고 정의되어 있습니다. 네, 더 나은 장면을 만들어 주는 기술이라는 것입니다. 먼저 GI가 적용되지 않은 장면입니다. 광원은 PointLight 하나만 사용되었습니다.

오직 PointLight에 대한 광원만으로 개별 메시에 대한 쉐이딩만이 고려되어 렌더링된 일반적인 three.js 렌러링 결과죠. 부족한 이런 렌더링 품질을 보완하기 위해 블렌더와 같은 3D 툴을 이용해 광원맵을 baking해서 사용하곤 했습니다. 하지만 이제 그럴 필요없이 GI를 적용하면 위의 결과가 아래처럼 바뀝니다.

품질이 훨씬 좋아졌습니다. FPS가 양호합니다. 예전에 babylon.js에서 GI를 적용했을때 FPS가 매우 나쁘게 나왔는데 three.js는 양호합니다. 물론 현재 babylon.js의 GI 기능이 개선되어 좋아졌을 수도 있지만 말입니다.

끝으로 주요 GI 기술의 종류는 3가지입니다.

  • 레이 트레이싱(Ray Tracing): 빛의 경로를 역추적하여 반사, 굴절 등을 물리적으로 정확하게 계산.
  • 라이트 맵(Light Map): 빛의 효과를 미리 계산해 텍스처로 물체에 입히는 방식.
  • 앰비언트 오클루전(AO): 주로 구석이나 틈새 부분에 은은한 음영을 추가해 3D 모델의 입체감을 향상시키는 기술 (GI의 보조 역할).

이제 넓은 범위의 지형 지물에 대한 장면에 GI를 적용해 멋진 장면을 렌더링해 봅시다.

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)