three.js의 내장 Inspector 코드 정리

내장 인스펙터 UI의 설치는 다음과 같습니다.

import { Inspector } from 'three/addons/inspector/Inspector.js';

...

renderer.inspector = new Inspector();
this.domApp.appendChild(renderer.domElement);

제어하고자 하는 객체는 다음과 같습니다.

const obj = {
  bVisible: false,

  value: 0.5,

  color: new THREE.Color(0xff0000),
  // color: 0xffff00,
  // color: '#ff00ff',

  item: 'glass'
}

객체에 대한 각 필드를 제어하는 UI의 추가는 다음과 같습니다.

const gui = this.renderer.inspector.createParameters('파라메터 설정1');

gui.add(obj, 'bVisible').name('obj의 bVisible')/*.onChange(v => { console.log(v) });*/
gui.add(obj, 'value', 0, 1, 0.01).name('obj의 value')/*.onChange(v => { console.log(v) });*/
gui.addColor(obj, 'color').name('obj의 color')/*.onChange(v => { console.log(v) });*/
gui.add(obj, 'item', [ 'duck', 'glass' ]).name('obj의 item')/*.onChange(v => { console.log(v) });*/

결과 UI는 다음과 같습니다.

아래와 같은 three.js 최신버전에서의 인스펙터 이슈가 있습니다.

three.js r184의 Inspector UI 적용시

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에 넘겨주면 되므로 오버헤드가 줄어듭니다.

Deep JavaScript

WeakRef와 FinalizationRegistry (메모리 관리)

GC에 의해 수거되는 리소스에 대해 개발자가 처리해야할 때 알아두면 좋은 코드이다. 바로 WeakRef와 FinalizationRegistry인데 관련된 코드는 다음과 같다.

// 무거운 데이터 객체
let bigData = { payload: new Array(1000000) };

// 약한 참조 생성
// WeakRef: 객체를 참조하되, 가비지 컬렉션의 대상이 되는 것을 막지 않는 '약한 참조'를 만듭니다. 
// 객체가 메모리에서 해제되면 참조도 자동으로 끊깁니다.
const ref = new WeakRef(bigData);

// 사용처에서 꺼내 쓰기
const derefData = ref.deref();
if (derefData) {
  console.log("아직 메모리에 남아있음:", derefData.payload);
}

// 특정 객체가 메모리에서 해제될 때 정리 작업을 해주는 레지스트리
// FinalizationRegistry: 객체가 가비지 컬렉터에 의해 메모리에서 완전히 지워지는 시점에 특정 콜백 함수를 실행하도록 등록합니다.
const registry = new FinalizationRegistry((heldValue) => {
  console.log(`${heldValue} 객체가 가비지 컬렉션되었습니다.`);
});
registry.register(bigData, "무거운 데이터");

// 참조를 끊으면 가비지 컬렉터가 수거해감
bigData = null;

코드 실행을 일정 시간 블로킹시키는 함수

Node 환경과 웹브라우저 환경에 따라 다르긴 한데 Node에서는 다음 방법을 추천한다.

function sleep(ms) {
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

console.log("시작 - 1");
sleep(2000); // 2초 블로킹
console.log("2초 후 - 1");

웹 브라우저에서는 다음 방법을 추천하며 Node에서도 가능함.

function sleepWebBrowser(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

console.log("시작 - 2");
await sleepWebBrowser(2000);
console.log("2초 후 - 2");

Object.groupBy() (데이터 그룹화 공식 지원)

배열의 데이터를 특정 기준에 따라 객체로 묶어주는 기능입니다. 과거에는 reduce를 복잡하게 쓰거나 외부 라이브러리(lodash 등)를 썼어야 했는데, 이제 네이티브 문법으로 지원.

const inventory = [
  { name: "asparagus", type: "vegetables", quantity: 5 },
  { name: "bananas", type: "fruit", quantity: 0 },
  { name: "goat", type: "meat", quantity: 23 },
  { name: "cherries", type: "fruit", quantity: 5 },
];

// 'type' 속성을 기준으로 그룹화
const result = Object.groupBy(inventory, ({ type }) => type);

/*
결과:
{
  vegetables: [ 
    { name: "asparagus", type: "vegetables", ... } 
  ],
  fruit: [ 
    { name: "bananas", ... }, 
    { name: "cherries", ... } 
  ],
  meat: [ 
    { name: "goat", ... } 
  ]
}
*/

Proxy와 Reflect (메타 프로그래밍)

자바스크립트의 기본 동작(객체의 속성 조회, 할당, 열거 등)을 가로채서(Intercept) 사용자 정의 동작을 주입하는 강력한 기능임. Vue 3 같은 모던 프레임워크의 상태 변화 감지(Reactivity) 시스템이 바로 이 Proxy를 기반으로 동작함.

const target = { message: "hello" };

const handler = {
  // 객체의 속성을 읽으려고 할 때(get) 가로챔
  get(target, prop, receiver) {
    console.log(`[로그] ${prop} 속성에 읽기로 접근했습니다.`);
    return Reflect.get(...arguments); // 원래 동작 수행
  },
  // 객체의 속성을 바꿀 때(set) 가로챔
  set(target, prop, value, receiver) {
    console.log(`[로그] ${prop} 속성에 쓰기로 접근했습니다.`);

    if (prop === 'age' && value < 0) {
      throw new Error("나이는 음수가 될 수 없습니다.");
    }
    return Reflect.set(...arguments);
  }
};

const proxy = new Proxy(target, handler);
console.log(proxy.message); // [로그] message 속성에 읽기로 접근했습니다. -> "hello"
proxy.message = "안녕하세요." // [로그] message 속성에 쓰기로 접근했습니다. 
proxy.age = 10; // [로그] age 속성에 쓰기로 접근했습니다.
proxy.age = -10; // [로그] age 속성에 쓰기로 접근했습니다. (Error: 나이는 음수가 될 수 없습니다.)

구조적 복제 (Structured Clone)

객체의 깊은 복사(Deep Copy)를 수행하는 내장 API임. 과거에는 JSON.parse(JSON.stringify(obj))라는 편법을 썼지만, 이 방식은 Date, RegExp, Map, Set 같은 특수 객체나 순환 참조가 있으면 깨지는 치명적인 문제가 있음. structuredClone은 이를 완벽하게 복사해 줌.

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  nested: { inner: "value" }
};

// 완벽한 깊은 복사
const clone = structuredClone(original);

console.log(clone.date instanceof Date); // true
console.log(clone.nested === original.nested); // false (참조가 분리됨, 깊은 복사로 객체에 대한 주소값이 다름)

생성기 (Generators)와 이터레이터

함수의 실행을 중간에 멈췄다가(yield), 원하는 시점에 다시 재개(next)할 수 있는 특수 함수. 비동기 스트림을 제어하거나, 메모리를 아끼면서 대용량의 무한한 데이터를 순차적으로 처리할 때 매우 유용하며 필자는 메인 스레드를 얼리지 않고 대량의 데이터를 순차적으로 처리하는 기능에 적용하기도 함.

function* idGenerator() {
  let id = 1;
  while (true) {
    yield id++; // 여기서 실행을 멈추고 값을 반환
  }
}

const gen = idGenerator();
console.log(gen.next().value); // 1
console.log(gen.next().value); // 2
console.log(gen.next().value); // 3 (필요할 때만 값을 무한히 생성)

명시적 자원 관리 (Explicit Resource Management – using)

최근 ECMAScript 스펙에 추가되고 있는 매우 획기적인 기능. 파일 핸들러, 네트워크 소켓, 데이터베이스 커넥션, 혹은 그래픽 컨텍스트처럼 사용 후 반드시 닫아주어야(close/dispose) 하는 자원을 다룰 때 씀. try…finally로 일일이 닫지 않아도, 블록을 벗어나면 자동으로 자원이 해제됨.

// 자원 정의 (Symbol.dispose 메서드를 구현해야 함)
const disposableResource = {
  [Symbol.dispose]() {
    console.log("자원이 자동으로 해제되었습니다.");
  },
  action() {
    console.log("작업 수행 중...");
  }
};

{
  // using 키워드로 선언
  using res = disposableResource;
  res.action();
} // 이 블록을 나가는 순간 Symbol.dispose()가 자동으로 호출됨!

using은 최근에 도입된 아직은 시험 중으로 실행시 다음 옵션이 필요함. node --js-explicit-resource-management

ArrayBuffer.prototype.resize() & SharedArrayBuffer.prototype.grow()

메모리 버퍼의 크기를 가변적으로 조절할 수 있는 기능. WebGL/WebGPU를 다루거나 큰 바이너리 데이터를 청크(Chunk) 단위로 읽어 들일 때, 이전에는 버퍼 크기가 모자라면 더 큰 버퍼를 새로 만들고 기존 데이터를 하나하나 복사(ArrayBuffer.transfer)해야 했음. 이제는 내부 메모리 주소를 유지한 채로 최대 크기(maxByteLength) 범위 내에서 버퍼를 동적으로 늘리고 줄일 수 있음.

// 초기 1MB, 최대 10MB까지 커질 수 있는 버퍼 선언
const buffer = new ArrayBuffer(1024 * 1024, { maxByteLength: 10 * 1024 * 1024 });

console.log(buffer.byteLength); // 1MB

// 복사 작업 없이 인라인으로 즉시 2MB로 확장
buffer.resize(2 * 1024 * 1024);
console.log(buffer.byteLength); // 2MB

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