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이 사라진다. |
목차
- 도구 한눈에 보기
- obj2gltf — OBJ → GLB 변환
- gltf-transform — GLB 최적화·KTX2 압축
- Blender — GUI 기반 GLB export
- pygltflib — Python으로 GLB 수정
- Three.js — GLB 로드 및 애니메이션
- Babylon.js — GLB 로드
- CesiumJS — GLB 모델 배치
- CesiumJS Sampler Mipmap Aliasing 사례
- 전체 최적화 파이프라인
도구 한눈에 보기
| 도구 | 환경 | 역할 |
|---|---|---|
| obj2gltf | Node.js CLI | OBJ → GLB 변환 (Cesium) |
| gltf-transform | Node.js CLI/SDK | GLB 최적화·압축·KTX2 변환 |
| Blender | GUI | 3D 편집 + GLB Import/Export |
| pygltflib | Python | GLB 읽기·수정·저장 |
| KTX-Software (toktx) | CLI | KTX2 텍스처 생성 (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 --metallicRoughness | Metallic-Roughness 워크플로 |
| 텍스처 분리 | obj2gltf -i model.obj -t | 텍스처 파일을 외부로 분리 |
⚠️ obj2gltf의 sampler 함정: 생성된 GLB의 sampler에
minFilter/magFilter가null로 비어 있다. 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 ktx2 | Draco + 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 비교
| 구분 | ETC1S | UASTC |
|---|---|---|
| 특징 | 높은 압축률, 작은 용량 | 높은 품질, 큰 용량 |
| 용도 | 대용량 타일셋 | 품질 중요 단일 모델 |
| 주의 | 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).
- 경로:
File > Export > glTF 2.0 (.glb/.gltf) - 포맷:
glTF Binary (.glb)선택 (단일 파일로 패키징) - 주요 옵션:
- Include:
Selected Objects/Custom Properties - Transform:
+Y Up(대부분의 웹 뷰어 표준 — 3D Tiles 변환 시에도 반드시 Y-up 유지) - Geometry:
Apply Modifiers,UVs,Normals,Vertex Colors - Animation:
Use scene frame range
- Include:
⚠️ 흔한 실수: 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 pygltflibGLB 구조 탐색
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 기준. GLTFLoader는 three/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();
DRACOLoader와KTX2Loader를 추가 등록하면 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
}]
}minFilter와 magFilter가 모두 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를 미설정하는 문제를 후처리로 보정
참고 자료
- Cesium Community: Rendering quality issue with 1.1 tilesets (2025-02-14)
- GitHub Issue #12413: ktx2 texture with mipmap Jagged Edge
- GitHub Issue #9746: ktx2 texture moire pattern
- glTF 2.0 Spec: Samplers
- glTF 2.0 Spec: Texture Filtering
- OpenGL ES 3.0: Texture Minification — WebGL 기반 스펙
- KTX-Software (toktx)
- gltf-transform CLI: etc1s/uastc
전체 최적화 파이프라인
# 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.glbfix_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])문서 탐색
참고 자료
- obj2gltf: github.com/CesiumGS/obj2gltf
- gltf-transform: gltf-transform.dev
- Blender Manual (glTF): docs.blender.org/manual/…/scene_gltf2
- pygltflib: gitlab.com/dodgyville/pygltflib
- KTX-Software: github.com/KhronosGroup/KTX-Software
- Three.js GLTFLoader: threejs.org/docs
- Babylon.js glTF Loader: doc.babylonjs.com
- CesiumJS Model API: cesium.com/learn/cesiumjs
- glTF-Validator: github.khronos.org/glTF-Validator