THREE.InterleavedBuffer 설명 및 예제코드

THREE.InterleavedBuffer는 단일 배열에 용도가 다른 데이터를 섞어 저장해 사용할 수 있도록 해주는 클래스로써 메모리와 퍼포먼스 양쪽 측면 모두에 대해 최적화할 수 있습니다.

예를들어 다음처럼 하나의 배열에 정점의 위치와 색상 값을 저장하고 있습니다.

// 1. 하나의 배열에 위치(3)와 색상(3) 데이터를 교차해서 넣습니다.
// 구조: [ X, Y, Z,  R, G, B, ... ]
const arrayBuffer = new Float32Array([
  // 좌하단 정점
  -1.0, -1.0, 0.0, 1.0, 0.0, 0.0, // Pos(XYZ), Color(RGB)
  // 우하단 정점
  1.0, -1.0, 0.0, 0.0, 1.0, 0.0,
  // 우상단 정점
  1.0, 1.0, 0.0, 0.0, 0.0, 1.0,
  // 좌상단 정점
  -1.0, 1.0, 0.0, 1.0, 1.0, 0.0
]);

위의 배열 하나에 대해 정점과 색상에 대한 데이터를 별도의 Attribute로 쪼개 GPU에 전달하기 위해 THREE.InterleavedBuffer 객체를 생성합니다.

// 2. InterleavedBuffer 생성
// 하나의 정점 데이터 크기(Stride)는 6 (XYZ + RGB)입니다.
const interleavedBuffer = new THREE.InterleavedBuffer(arrayBuffer, 6);

이제 지오메트리 객체를 생성해서 앞서 만든 THREE.InterleavedBuffer 객체를 통해 정점과 색상에 대한 Attribute 데이터를 주입합니다.

// 3. BufferGeometry 생성 및 InterleavedBufferAttribute 연결
const geometry = new THREE.BufferGeometry();

// 위치(position) 속성: 데이터 크기는 3(XYZ), 시작 오프셋은 0
geometry.setAttribute('position', new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 0));

// 색상(color) 속성: 데이터 크기는 3(RGB), 시작 오프셋은 3 (XYZ 다음이므로)
geometry.setAttribute('color', new THREE.InterleavedBufferAttribute(interleavedBuffer, 3, 3));

인덱스 데이터도 주입해야겠죠.

// 4. 인덱스 버퍼 설정 (삼각형 2개로 사각형 구성)
const indices = new Uint16Array([
  0, 1, 2,
  0, 2, 3
]);
geometry.setIndex(new THREE.BufferAttribute(indices, 1));

이제 Mesh를 생성합니다.

// 5. 메쉬 생성 (정점 색상을 사용하기 위해 vertexColors: true 설정)
const material = new THREE.MeshBasicMaterial({ vertexColors: true, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geometry, material);

this.scene.add(mesh);

THREE.InterleavedBufferd의 장점은 다음과 같습니다.

  • GPU 캐시 효율성: GPU가 화면에 점을 그릴 때, 특정 정점의 위치를 읽어오면서 바로 옆에 있는 색상 데이터도 함께 캐시에 로드하므로 메모리 접근 속도가 빨라집니다.
  • 단일 버퍼 관리: 여러 개의 버퍼를 바인딩할 필요 없이 하나의 큰 버퍼만 GPU에 넘겨주면 되므로 오버헤드가 줄어듭니다.

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)