4.3 GLB 도구와 실전 예제

GLB 생성·최적화 도구(obj2gltf, gltf-transform, Blender, pygltflib), 플랫폼별 로드 예제(Three.js, Babylon.js, CesiumJS), 그리고 CesiumJS 3D Tiles에서 자주 발생하는 sampler/Mipmap aliasing 보정. 업데이트: 2026-05-14


핵심 요약

구분내용
📖 정의GLB를 만들고 최적화하는 도구 사용법, 플랫폼별 로드 코드, 그리고 obj2gltf 출력에 대한 sampler 보정 워크플로우.
💡 핵심KTX2 압축 + Mipmap + sampler 명시적 설정 — 3가지가 모두 갖춰져야 3D Tiles에서 텍스처가 부드럽게 렌더된다.
🎯 대상OBJ/FBX를 GLB로 변환하거나, 3D Tiles 파이프라인에서 텍스처 품질 문제를 해결하려는 개발자
⚠️ 주의obj2gltf로 생성된 GLB는 sampler의 minFilter/magFilter가 **null**로 비어 있다. 후처리(pygltflib)로 보정해야 CesiumJS aliasing이 사라진다.

목차

  1. 도구 한눈에 보기
  2. obj2gltf — OBJ → GLB 변환
  3. gltf-transform — GLB 최적화·KTX2 압축
  4. Blender — GUI 기반 GLB export
  5. pygltflib — Python으로 GLB 수정
  6. Three.js — GLB 로드 및 애니메이션
  7. Babylon.js — GLB 로드
  8. CesiumJS — GLB 모델 배치
  9. CesiumJS Sampler Mipmap Aliasing 사례
  10. 전체 최적화 파이프라인

도구 한눈에 보기

도구환경역할
obj2gltfNode.js CLIOBJ → GLB 변환 (Cesium)
gltf-transformNode.js CLI/SDKGLB 최적화·압축·KTX2 변환
BlenderGUI3D 편집 + GLB Import/Export
pygltflibPythonGLB 읽기·수정·저장
KTX-Software (toktx)CLIKTX2 텍스처 생성 (gltf-transform 의존성)

obj2gltf — OBJ → GLB 변환

obj2gltf v3.2.x는 OBJ 모델을 glTF/GLB로 변환하는 Cesium의 Node.js 기반 CLI 도구다.

작업명령어비고
설치npm install -g obj2gltf전역 설치
기본 변환 (GLB)obj2gltf -i model.obj -o model.glb-o 확장자에 따라 자동 결정
바이너리 출력obj2gltf -i model.obj -b.glb로 출력
PBR 재질 변환obj2gltf -i model.obj --metallicRoughnessMetallic-Roughness 워크플로
텍스처 분리obj2gltf -i model.obj -t텍스처 파일을 외부로 분리

⚠️ obj2gltf의 sampler 함정: 생성된 GLB의 sampler에 minFilter/magFilternull로 비어 있다. CesiumJS 3D Tiles에서 텍스처 aliasing의 원인이 된다 → 아래 보정 사례 참조.


gltf-transform — GLB 최적화·KTX2 압축

gltf-transform v4.3.x는 glTF/GLB의 최적화, 특히 KTX2 텍스처 압축에 강력한 기능을 제공한다. 실행 환경에 KTX-Software가 설치되어 있어야 한다.

작업명령어비고
CLI 설치npm install --global @gltf-transform/cli전역 설치
통합 최적화gltf-transform optimize input.glb output.glb --texture-compress ktx2Draco + KTX2 + 미사용 제거
ETC1S 압축gltf-transform etc1s input.glb output.glb --quality 255 --mipmaps높은 압축률, 일반 텍스처용
UASTC 압축gltf-transform uastc input.glb output.glb --level 4 --zstd 18 --mipmaps고품질, 노멀 맵·디테일 텍스처용
특정 텍스처만 UASTC... --slots "{normalTexture,occlusionTexture}"텍스처 슬롯 선택
Draco 압축gltf-transform draco input.glb output.glb지오메트리 압축

KTX2 비교

구분ETC1SUASTC
특징높은 압축률, 작은 용량높은 품질, 큰 용량
용도대용량 타일셋품질 중요 단일 모델
주의Mipmap 포함 필수Mipmap 포함 필수

⚠️ KTX2 압축 텍스처는 WebGL 명세상 런타임에 generateMipmap() 호출이 불가능하다. Mipmap이 필요하면 반드시 KTX2 파일 내부에 포함해야 한다 (--mipmaps 플래그).

Node.js 스크립트 예제

import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { toktx } from '@gltf-transform/functions';
 
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const document = await io.read('input.glb');
 
// ETC1S 압축 적용
await document.transform(
  toktx({ mode: 'etc1s', quality: 255 })
);
 
await io.write('output-etc1s.glb', document);

Blender — GUI 기반 GLB export

Blender는 glTF/GLB 포맷을 네이티브로 지원한다. 최신은 4.x (LTS).

  1. 경로: File > Export > glTF 2.0 (.glb/.gltf)
  2. 포맷: glTF Binary (.glb) 선택 (단일 파일로 패키징)
  3. 주요 옵션:
    • Include: Selected Objects / Custom Properties
    • Transform: +Y Up (대부분의 웹 뷰어 표준 — 3D Tiles 변환 시에도 반드시 Y-up 유지)
    • Geometry: Apply Modifiers, UVs, Normals, Vertex Colors
    • Animation: Use scene frame range

⚠️ 흔한 실수: 3D Tiles는 Z-up이라고 Blender에서 직접 Z-up으로 export하면, CesiumJS가 자동 Y-up→Z-up 변환을 적용해 모델이 90도 누워서 렌더된다. Blender는 반드시 Y-up으로 export한다.


pygltflib — Python으로 GLB 수정

pygltflib v1.15.x는 Python에서 glTF/GLB 파일을 프로그래밍 방식으로 생성·수정할 수 있는 라이브러리다.

pip install pygltflib

GLB 구조 탐색

import pygltflib
 
gltf = pygltflib.GLTF2().load("model.glb")
 
print(f"nodes    : {len(gltf.nodes)}")
print(f"meshes   : {len(gltf.meshes)}")
print(f"materials: {len(gltf.materials)}")
print(f"textures : {len(gltf.textures)}")
print(f"images   : {len(gltf.images)}")
print(f"samplers : {len(gltf.samplers)}")
 
for i, sampler in enumerate(gltf.samplers):
    print(f"\nsampler[{i}]")
    print(f"  minFilter: {sampler.minFilter}")
    print(f"  magFilter: {sampler.magFilter}")
    print(f"  wrapS    : {sampler.wrapS}")
    print(f"  wrapT    : {sampler.wrapT}")

노드 복제 + 이름 변경

from pygltflib import GLTF2, Node
 
gltf = GLTF2.load("existing_model.glb")
 
if gltf.nodes:
    original = gltf.nodes[0]
    new_node = Node(
        name=f"{original.name}_copy",
        mesh=original.mesh,
        skin=original.skin,
        matrix=original.matrix,
        translation=original.translation,
        rotation=original.rotation,
        scale=original.scale
    )
    gltf.nodes.append(new_node)
    if gltf.scenes:
        gltf.scenes[gltf.scene].nodes.append(len(gltf.nodes) - 1)
 
gltf.convert_buffers_to_binary_blob()
gltf.save_binary("modified_model.glb")

노드 이름 변경

gltf = pygltflib.GLTF2().load("model.glb")
for node in gltf.nodes:
    if node.name == "OldName":
        node.name = "NewName"
gltf.save_binary("model_renamed.glb")

Three.js — GLB 로드 및 애니메이션

Three.js r162 기준. GLTFLoaderthree/addons에 포함된다.

import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
 
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
 
camera.position.set(0, 2, 5);
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
scene.add(new THREE.DirectionalLight(0xffffff, 1));
 
const loader = new GLTFLoader();
let mixer;
 
loader.load(
    'model.glb',
    (gltf) => {
        scene.add(gltf.scene);
        if (gltf.animations.length > 0) {
            mixer = new THREE.AnimationMixer(gltf.scene);
            gltf.animations.forEach(clip => mixer.clipAction(clip).play());
        }
        console.log('GLB 로드 완료:', gltf);
    },
    (xhr) => console.log(`로드 중: ${(xhr.loaded / xhr.total * 100).toFixed(1)}%`),
    (error) => console.error('GLB 로드 실패:', error)
);
 
const clock = new THREE.Clock();
function animate() {
    requestAnimationFrame(animate);
    if (mixer) mixer.update(clock.getDelta());
    renderer.render(scene, camera);
}
animate();

DRACOLoaderKTX2Loader를 추가 등록하면 Draco 압축 + KTX2 텍스처 GLB도 로드 가능하다.


Babylon.js — GLB 로드

Babylon.js v7 기준. @babylonjs/loaders/glTF를 임포트해야 GLB 로더가 활성화된다.

import { Engine, Scene, ArcRotateCamera, HemisphericLight, Vector3 } from "@babylonjs/core";
import { SceneLoader } from "@babylonjs/core/Loading/sceneLoader";
import "@babylonjs/loaders/glTF";  // glTF/GLB 로더 등록
 
const canvas = document.getElementById("renderCanvas");
const engine = new Engine(canvas, true);
const scene = new Scene(engine);
 
const camera = new ArcRotateCamera("cam", -Math.PI / 2, Math.PI / 4, 10, Vector3.Zero(), scene);
camera.attachControl(canvas, true);
new HemisphericLight("light", new Vector3(0, 1, 0), scene);
 
const result = await SceneLoader.ImportMeshAsync(
    null,
    "path/to/",
    "model.glb",
    scene
);
 
console.log("로드된 메시 수:", result.meshes.length);
result.animationGroups.forEach(ag => ag.play(true));  // 루프 재생
 
engine.runRenderLoop(() => scene.render());
window.addEventListener("resize", () => engine.resize());

CesiumJS — GLB 모델 배치

CesiumJS v1.115+. Entity API(간편)와 Primitive API(고급) 두 방식.

import * as Cesium from 'cesium';
 
const viewer = new Cesium.Viewer('cesiumContainer', {
    terrain: Cesium.Terrain.fromWorldTerrain()
});
 
// ── Entity API (간편) ──────────────────────────────────────────
const entity = viewer.entities.add({
    name: 'GLB 모델',
    position: Cesium.Cartesian3.fromDegrees(127.03, 37.50, 50),
    model: {
        uri: 'model.glb',
        minimumPixelSize: 64,
        maximumScale: 2000,
        silhouetteColor: Cesium.Color.WHITE,
        silhouetteSize: 0
    }
});
viewer.zoomTo(entity);
 
// ── Primitive API (고급) ──────────────────────────────────────
const position = Cesium.Cartesian3.fromDegrees(127.03, 37.50, 50);
const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(135), 0, 0);
const orientation = Cesium.Transforms.headingPitchRollQuaternion(position, hpr);
 
const model = await Cesium.Model.fromGltfAsync({
    url: 'model.glb',
    modelMatrix: Cesium.Matrix4.fromTranslationQuaternionRotationScale(
        position, orientation, new Cesium.Cartesian3(1, 1, 1)
    ),
    scale: 1.0,
    minimumPixelSize: 128
});
viewer.scene.primitives.add(model);

3D Tiles 경로(타일셋)에서는 sampler minFilter가 미설정이면 aliasing이 발생한다. GLB 생성 시 sampler 설정을 반드시 확인한다. → 아래 사례


CesiumJS Sampler Mipmap Aliasing 사례

실제 작업 보고서 (2026-03-17): CesiumJS 1.138에서 3D Tiles 1.1 타일셋의 텍스처가 멀리서 자글자글하게 보이는 문제 분석과 해결.

문제 현상

  • CesiumJS에서 3D Tiles 1.1 타일셋 로드 시 텍스처가 멀리서 자글자글(grainy/shimmer)
  • KTX2 ETC1S / UASTC 모드 무관하게 동일 증상
  • KTX2 미사용(PNG/JPEG)에서도 동일 증상
  • Cesium Ion AEC 타일링도 동일 현상
  • Ion “3D Model” (단일 GLB, 타일링 없음)은 깨끗
  • 가까이 가면 정상, 멀리서만 발생
  • CesiumJS maximumScreenSpaceError = 4 적용해도 개선 없음

1차 추정: KTX2 Mipmap 미포함 — 부분적 원인

KTX2 압축 텍스처에 Mipmap 레벨이 포함되지 않으면 WebGL 스펙상 generateMipmap() 호출이 불가능하다.

KTX2 파일 (Mipmap 없음)
  → WebGL 로드
  → 압축 텍스처이므로 generateMipmap() 호출 불가 (WebGL 명세 제약)
  → Mipmap 레벨 0(원본)만 존재
  → 멀리서 볼 때 원본 텍스처를 직접 축소 → aliasing 발생

하지만 --mipmaps를 적용해도 증상이 동일했다. KTX2 Mipmap은 부분적 원인일 뿐이었다.

2차 추정: GLB sampler minFilter 미설정 — 진짜 원인 ✅

obj2gltf가 생성하는 GLB 파일의 sampler 설정을 pygltflib으로 분석한 결과:

{
  "samplers": [{
    "magFilter": null,
    "minFilter": null,
    "wrapS": 10497,
    "wrapT": 10497
  }]
}

minFiltermagFilter가 모두 null(미설정)이었다.

왜 이것이 문제인가?

GPU가 텍스처를 화면에 그릴 때, “멀리서 작게 보이는 텍스처를 어떻게 축소할 것인가”를 minFilter로 결정한다.

[정상 동작 — minFilter: LINEAR_MIPMAP_LINEAR]

원본 텍스처 (1024x1024, 잔디 텍스처)
  ↓ Mipmap 생성 (GPU 또는 사전 생성)
  Level 0: 1024x1024 (원본)
  Level 1: 512x512   (잔디 패턴이 부드럽게 평균화됨)
  Level 2: 256x256   (더 부드러움)
  Level 3: 128x128   (멀리서 사용)
  ↓ 카메라 거리에 따라 적절한 Mipmap 레벨 선택
  ↓ 인접 레벨 2개를 선형 보간 (trilinear filtering)
  → 부드러운 텍스처 표시
[문제 상황 — minFilter: null (미설정)]

원본 텍스처 (1024x1024, 잔디 텍스처)
  ↓ minFilter가 Mipmap 필터가 아님 → Mipmap을 생성/사용하지 않음
  ↓ 카메라가 멀어져도 항상 원본(1024x1024)에서 직접 샘플링
  ↓ 1024개 텍셀 중 화면에 보이는 10개 픽셀에 대응할 텍셀 선택
  ↓ 어떤 텍셀이 선택되는지 매 프레임 달라짐 (카메라 미세 이동에 따라)
  → 자글자글, 깜빡임(shimmer), 모아레 패턴

glTF 스펙의 함정

  • 스펙에 sampler filter의 기본값이 명시되어 있지 않음
  • “Implementation-specific” — WebGL 구현체마다 다른 기본값 사용
  • CesiumJS/WebGL은 미설정 시 NEAREST 또는 LINEAR(Mipmap 필터가 아님)를 기본으로 사용
  • 따라서 Mipmap이 존재해도 사용되지 않음

이것이 “KTX2에 --mipmaps로 Mipmap을 포함해도 여전히 자글자글”한 이유.

Ion “3D Model”이 깨끗한 이유

Cesium Ion이 “3D Model” 모드로 업로드할 때는 단일 GLB를 그대로 서빙한다. CesiumJS는 단일 모델(non-tiled)을 로드할 때 내부적으로 sampler가 미설정이면 자동으로 적절한 필터를 적용하는 것으로 추정된다. 반면 3D Tiles 경로에서는 이 자동 보정이 동작하지 않아 sampler 설정이 그대로 사용된다.

해결책 1 — pygltflib로 sampler 강제 설정 (근본)

import pygltflib
 
gltf = pygltflib.GLTF2().load("model.glb")
 
# sampler 필터 설정 (미설정인 경우만 보정)
for i, sampler in enumerate(gltf.samplers):
    if sampler.minFilter is None:
        sampler.minFilter = 9987  # LINEAR_MIPMAP_LINEAR (trilinear)
        print(f"sampler[{i}] minFilter 설정: 9987")
    if sampler.magFilter is None:
        sampler.magFilter = 9729  # LINEAR (bilinear)
        print(f"sampler[{i}] magFilter 설정: 9729")
 
gltf.save_binary("model_fixed.glb")
print("저장 완료: model_fixed.glb")

이미 설정된 sampler는 건드리지 않는다 (is None 체크). obj2gltf가 미설정한 경우만 보정.

해결책 2 — KTX2 Mipmap 포함 (보조)

# UASTC — 고품질 (파일 크기 큼)
gltf-transform uastc input.glb output.glb \
    --level 4 --zstd 18 --mipmaps    # 반드시 포함
 
# ETC1S — 고압축 (파일 크기 작음)
gltf-transform etc1s input.glb output.glb \
    --quality 255 --mipmaps    # 반드시 포함

--mipmaps 없이 sampler만 설정하면: 비압축(PNG/JPEG) 텍스처는 WebGL이 런타임에 Mipmap을 자동 생성하므로 동작하지만, KTX2 압축 텍스처는 런타임 Mipmap 생성이 불가능하므로 반드시 --mipmaps 필요.

두 해결책의 관계

┌─────────────────────────────────────────────┐
│  sampler.minFilter = LINEAR_MIPMAP_LINEAR   │
│  → "Mipmap을 사용하겠다"는 GPU 지시           │
│  → 이것이 없으면 Mipmap이 있어도 무시됨        │
│                                             │
│  KTX2 --mipmaps                             │
│  → Mipmap 데이터를 실제로 생성                 │
│  → 이것이 없으면 압축 텍스처에서 Mipmap 사용 불가│
│                                             │
│  ✅ 둘 다 필요: sampler 설정 + Mipmap 데이터  │
└─────────────────────────────────────────────┘

비압축(PNG/JPEG) 텍스처는 WebGL이 런타임에 자동으로 generateMipmap()을 호출하므로, sampler 설정만 있으면 동작한다.

결과

  • CesiumJS 텍스처 aliasing/shimmer 해결 확인
  • 핵심 패치: GLB sampler에 LINEAR_MIPMAP_LINEAR 강제 설정 + KTX2 Mipmap 포함
  • obj2gltf가 sampler filter를 미설정하는 문제를 후처리로 보정

참고 자료


전체 최적화 파이프라인

# 1단계: OBJ → GLB
obj2gltf -i model.obj -o step0.glb --metallicRoughness
 
# 2단계: sampler 보정 (pygltflib)
python fix_samplers.py step0.glb step1.glb
 
# 3단계: 지오메트리 압축 (Draco)
gltf-transform draco step1.glb step2.glb
 
# 4단계: KTX2 텍스처 압축 + Mipmap
gltf-transform uastc step2.glb step3.glb --mipmaps
 
# 5단계: 기타 최적화 (중복 제거, 미사용 노드 제거)
gltf-transform optimize step3.glb output_final.glb
 
# 6단계: 검증
npx gltf-validator output_final.glb
 
# 파일 크기 확인
ls -lh model.obj output_final.glb

fix_samplers.py 예시:

import sys, pygltflib
 
gltf = pygltflib.GLTF2().load(sys.argv[1])
for sampler in gltf.samplers:
    if sampler.minFilter is None:
        sampler.minFilter = 9987  # LINEAR_MIPMAP_LINEAR
    if sampler.magFilter is None:
        sampler.magFilter = 9729  # LINEAR
gltf.save_binary(sys.argv[2])

문서 탐색


참고 자료