Three.js from Zero to Senior · Part 14 — Blender → glTF → Three.js: the Import Workflow
Stop building everything from primitives. Model in Blender, export glTF/GLB, and load it with GLTFLoader. Export settings, scene-graph mapping, scale, units, PBR materials, and the pitfalls that bite — with a live model loader.
Part 13 dựng đồ nội thất từ box và cylinder. Đó là công cụ đúng cho vật đơn giản, cấu hình được — nhưng khi bạn cần một cái mũ sci-fi sờn rách, một nhân vật có xương, hay một quả bơ scan, bạn không dựng nó bằng code. Bạn dựng nó trong một công cụ DCC thật — Blender, chuẩn ngành miễn phí — xuất ra glTF, rồi nạp vào. Đây là cách 95% scene Three.js production có nội dung.
Demo bên dưới là một trình nạp model. Đổi giữa bốn asset thật và xem chỉ số: importer tạo ra bao nhiêu mesh, material, triangle, và file có mang animation không. Bật wireframe để thấy lưới, và environment để thấy material PBR cần ánh sáng mới đẹp.
Bên trong một glTF thực ra có gì
glTF là “JPEG của 3D” — định dạng mở của Khronos, thiết kế để truyền tải và render, không phải để chỉnh sửa. Hiểu cấu trúc của nó giúp đọc được mọi thông báo của loader. Một file glTF là một JSON nhỏ cộng các buffer nhị phân, sắp theo một phân cấp chặt chẽ:
scene → nodes (transforms) → meshes → primitives (geometry + 1 material)
↳ accessors → bufferViews → buffer
materials · textures · images · samplers · skins · animations · cameras
Quy tắc quan trọng nhất nằm ở đó: một primitive có đúng một material. Một object hai material sẽ thành hai primitive — nên số mesh sau khi import thường nhiều hơn số object.
Nó đóng gói theo ba kiểu:
.glb— một file nhị phân: JSON + hình học + texture gói chung. Ship cái này. Một request, không lạc file..gltf(tách rời) — JSON tham chiếu file.binvà ảnh bên ngoài. Tốt để soi và quản version; nhưng nhiều file phải phục vụ..gltf(nhúng) — một JSON với buffer base64. Tránh dùng production: base64 phình ~33%.
Các tính năng ngoài lõi đến dưới dạng extension KHR_* / EXT_*. GLTFLoader xử lý các cái phổ biến, nhưng ba extension nén cần decoder tương ứng — thiếu là load văng lỗi.
Tiền kiểm Blender: các bước chính xác
Bản xuất chỉ sạch bằng đúng file gốc. Chạy checklist này (Blender 3.6–4.x) trước mỗi lần xuất — nó diệt tận gốc các lỗi “xoay / khổng lồ / đen / mất texture”.
1. Đặt đơn vị. Dựng sao cho 1 đơn vị Blender = 1 mét.
2. Làm sạch hình học (Object Mode, đã chọn object):
- Apply transform: sau đó N-panel hiện Location 0, Rotation 0, Scale 1.
- Đặt origin: origin trở thành tâm xoay của object trong Three.js.
- Tính lại normal: bật overlay Face Orientation — mặt đỏ là bị lật, sẽ trông đen hoặc lộn trong ra ngoài.
- Gộp đỉnh trùng: loại đỉnh trùng làm hỏng shading.
3. Unwrap UV cho mọi thứ có texture. Không UV = không texture map.
4. Làm material bằng Principled BSDF (shader duy nhất glTF ánh xạ gọn). Trong workspace Shading:
- Ảnh Base Color → Base Color (giữ Color Space sRGB).
- Ảnh Roughness / Metallic → đặt Color Space Non-Color, rồi nối vào Roughness / Metallic.
- Normal map → Image Texture (Non-Color) → node Normal Map → Normal.
- Exporter tự gói Roughness/Metallic riêng thành một texture metallic-roughness của glTF.
5. Đặt tên object trong Outliner. Tên sống sót thành node.name / mesh.name — để getObjectByName('Door') tìm được cánh cửa sau này.
Hộp thoại export, từng tùy chọn
File → Export → glTF 2.0. Thanh bên phải là nơi quyết định xuất đúng hay sai — đây là ý nghĩa thật của từng mục quan trọng.
- Format — chọn glTF Binary (.glb) cho production.
- Include → Selected Objects — chỉ xuất phần đã chọn.
- Include → Custom Properties — ghi custom props vào
extras, thànhobject.userData. - Include → Cameras / Punctual Lights — thường tắt với web; bạn dựng đèn và khung hình trong Three.js.
- Transform → +Y Up — BẬT. Đổi Z-up của Blender sang Y-up. Tắt = model nằm ngửa.
- Data → Mesh → Apply Modifiers — BẬT. Nướng Subsurf/Mirror/Array vào hình học.
- Data → Mesh → UVs / Normals / Tangents — bật UVs + Normals; bật Tangents nếu dùng normal map.
- Data → Material → Images — Automatic giữ PNG/JPEG; ép JPEG + giảm Image Quality để nhẹ file.
- Data → Skinning / Shape Keys — bật cho mesh có xương hoặc morph target.
- Animation → Animation — BẬT. Bật Always Sample Animations để nướng ra keyframe; dùng Group by NLA Track để xuất nhiều clip.
- Compression (Draco) — TẮT lúc này. Bật thì loader bắt buộc có
DRACOLoader, không thì import fail (để Part 16).
Một loader hoàn chỉnh, chạy được
Đoạn 10 dòng thì chạy, nhưng loader production làm đúng bốn thứ cùng lúc: quản lý màu, ánh sáng IBL, mọi decoder tùy chọn, và an toàn bất đồng bộ. Đây là toàn bộ — cứ copy.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
// 1) Renderer — correct color + tone mapping is half of "looking right".
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace; // default since r152
renderer.toneMapping = THREE.ACESFilmicToneMapping; // filmic highlights
renderer.toneMappingExposure = 1.0;
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
// 2) Scene, camera, controls.
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(3, 2, 4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 3) Image-based lighting — PBR metal/roughness needs something to reflect.
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
// 4) A key light for crisp shadows (env light alone is too soft).
const sun = new THREE.DirectionalLight(0xffffff, 2);
sun.position.set(5, 8, 4); sun.castShadow = true;
scene.add(sun, new THREE.HemisphereLight(0xbfd4ff, 0x202428, 0.4));
// 5) Wire the compressed formats this product contract accepts.
const draco = new DRACOLoader()
.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.7/');
const ktx2 = new KTX2Loader()
.setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/libs/basis/')
.detectSupport(renderer);
const loader = new GLTFLoader()
.setDRACOLoader(draco)
.setKTX2Loader(ktx2)
.setMeshoptDecoder(MeshoptDecoder);
// 6) Load → fit → ground → shadows → play animation.
let mixer;
const timer = new THREE.Timer();
timer.connect(document);
loader.load('/models/helmet.glb', (gltf) => {
const model = gltf.scene;
model.traverse((o) => { if (o.isMesh) o.castShadow = o.receiveShadow = true; });
fitAndGround(model, controls);
scene.add(model);
if (gltf.animations.length) {
mixer = new THREE.AnimationMixer(model);
mixer.clipAction(gltf.animations[0]).play();
}
}, undefined, (err) => console.error('glTF failed:', err)); // ALWAYS handle errors
renderer.setAnimationLoop((timestamp) => {
timer.update(timestamp);
if (mixer) mixer.update(timer.getDelta());
controls.update();
renderer.render(scene, camera);
});
Hai điều cần khắc cốt. Một, load() bất đồng bộ — mọi thứ chạm vào model nằm trong callback thành công, đừng để dòng kế. Hai, decoder là một phần của asset contract: chỉ nối format bạn kiểm thử, pin cùng revision Three.js, và bundle/self-host trong production thay vì phụ thuộc ba CDN rời rạc như snippet học tập.
glTF ánh xạ sang object Three.js thế nào
gltf.scene là một THREE.Group bình thường, nên mọi thứ bạn biết về scene graph đều áp dụng. Đây là bản dịch chính xác mà importer thực hiện:
| glTF concept | Three.js result | Note |
|---|---|---|
scene | gltf.scene (Group) | the root you add() |
node | Object3D / Group / Bone | transform hierarchy preserved |
mesh + primitive | one Mesh per primitive | a 2-material object → 2 child Meshes |
material | MeshStandardMaterial (or MeshPhysicalMaterial if KHR_*) | maps wired automatically |
texture / image | THREE.Texture | color space set per slot |
skin | SkinnedMesh + Skeleton | bones live under nodes |
animation | AnimationClip[] → gltf.animations | play with AnimationMixer |
camera | PerspectiveCamera / OrthographicCamera → gltf.cameras | not auto-activated |
node extras | object.userData | your Blender custom props land here |
Vậy cách gọn để soi mọi bản import là một lần traverse — và bạn phải dùng nó để bật bóng đổ, vì importer không bao giờ tự bật:
let meshes = 0, tris = 0; const materials = new Set();
gltf.scene.traverse((o) => {
if (!o.isMesh) return;
meshes++; materials.add(o.material.uuid);
o.castShadow = o.receiveShadow = true; // <-- the importer won't
const g = o.geometry;
tris += (g.index ? g.index.count : g.attributes.position.count) / 3;
});
Vật liệu & color space: ánh xạ hay cắn
GLTFLoader dựng lại từng material và — quan trọng — đặt đúng color space cho mọi texture. Bảng này giải thích vì sao tự nạp cùng file PNG bằng TextureLoader lại hay sai màu:
| Blender (Principled BSDF) | glTF channel | Three.js property | Color space |
|---|---|---|---|
| Base Color | baseColorTexture | map + color | sRGB |
| Metallic | metallic-roughness B | metalnessMap + metalness | linear |
| Roughness | metallic-roughness G | roughnessMap + roughness | linear |
| Ambient Occlusion | occlusionTexture R | aoMap | linear |
| Normal | normalTexture | normalMap | linear |
| Emission | emissiveTexture | emissiveMap + emissive | sRGB |
| Alpha / Blend mode | alphaMode + base color α | transparent / opacity / alphaTest | — |
| Clearcoat | KHR_materials_clearcoat | MeshPhysicalMaterial.clearcoat | — |
| Transmission | KHR_materials_transmission | MeshPhysicalMaterial.transmission | — |
Quy ước đóng gói cần nhớ là ORM: Occlusion ở đỏ, Roughness ở lục, Metalness ở lam, trong một ảnh. Map màu (base, emissive) là sRGB; map dữ liệu (normal, ORM) là linear — loader lo hết, nên đừng ghi đè texture.colorSpace sau khi import.
Vì sao trông khác Blender
Nỗi hoảng thường gặp: “nó tối/phẳng hơn viewport Blender”. Cùng dữ liệu, khác camera: Blender dùng view transform Filmic/AgX và world riêng, còn Three.js dùng toneMapping, outputColorSpace và scene.environment của bạn. Để gần giống: tone mapping ACESFilmic, một environment HDRI giống world của Blender, và exposure khớp. Hãy nhắm tới cùng phản ứng vật liệu, không phải khớp từng pixel.
Tỉ lệ, đơn vị, fit và đặt sàn
Quả bơ trong demo dựng theo centimet — nạp thô thì bé tí; asset khác lại chiếm hết màn hình. Tỉ lệ thật là nội dung đúng, nhưng với trình xem thì bạn chuẩn hoá lại. Đừng chỉnh số bằng tay — đo bounding box rồi fit:
function fitAndGround(model, controls) {
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
model.scale.setScalar(2.6 / maxDim); // fit to ~2.6 units
box.setFromObject(model); // re-measure after scaling
const c = box.getCenter(new THREE.Vector3());
model.position.x -= c.x; // center on X…
model.position.z -= c.z; // …and Z
model.position.y -= box.min.y; // drop onto y = 0
controls?.target.set(0, (size.y * 2.6 / maxDim) * 0.5, 0);
}
Thứ tự quan trọng: scale trước, đo lại, rồi căn giữa. Căn giữa theo box cũ sau khi scale sẽ khiến model lệch tâm và lửng lơ.
Animation, sâu thêm chút
Nếu file có animation, nó đến dưới dạng gltf.animations (mảng AnimationClip). Điều khiển bằng AnimationMixer, mỗi model một cái:
const mixer = new THREE.AnimationMixer(model);
const idle = THREE.AnimationClip.findByName(gltf.animations, 'Idle');
const action = mixer.clipAction(idle);
action.setLoop(THREE.LoopRepeat); // or LoopOnce + action.clampWhenFinished = true
action.play();
// inside setAnimationLoop(timestamp), after timer.update(timestamp):
mixer.update(timer.getDelta());
Quy tắc khiến ai cũng vấp: mixer không làm gì nếu thiếu mixer.update(dt) mỗi frame, với delta từ THREE.Timer. Trộn clip bằng crossFadeTo là trọng tâm của Part 17.
Đổi model không rò rỉ
Loader cấp phát geometry và texture trên GPU; phải giải phóng cả hai khi thay model. Texture là phần người ta hay quên:
scene.remove(old);
old.traverse((o) => {
if (!o.isMesh) return;
o.geometry.dispose();
for (const m of [].concat(o.material)) {
for (const k in m) if (m[k]?.isTexture) m[k].dispose(); // every map
m.dispose();
}
});
Khắc phục: triệu chứng → nguyên nhân → cách sửa
Chín mươi phần trăm sự cố glTF nằm trong bảng này.
| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing appears | model off-screen or mis-scaled | log Box3 size; run fit-and-ground |
| Microscopic / gigantic | authored in mm/cm/km | normalize via bounding box |
| Lying on its back / rotated | ”+Y Up” off, transforms not applied | re-export with +Y Up; Ctrl+A in Blender |
| Pure black surfaces | no light/environment | add scene.environment (PMREM) + a light |
| Pink / white “missing texture” | external images 404 (separate glTF) | ship .glb, or fix relative paths/CORS |
| Washed-out or too bright | tone-map / color-space mismatch | ACESFilmic + outputColorSpace = SRGB |
| Colors look dull / grey | color map loaded as linear | let GLTFLoader set color space; don’t override |
| Faceted / flat shading | normals missing or not exported | export Normals; Shade Smooth in Blender |
| Animation won’t move | missing mixer.update(dt) | update the mixer in the loop |
No DRACOLoader instance error | Draco-compressed file | setDRACOLoader(...) (Part 16) |
| CORS error in console | cross-origin host, no CORS headers | serve same-origin or enable CORS |
Lời cảnh báo của bậc thầy
- Đừng tin mạng. Luôn nối callback lỗi — canvas đen im lặng là UX tệ nhất.
- Một mega-mesh không nhanh. Mesh 200k-tri đơn lẻ không cull/instance theo bộ phận được.
- Để ý ngân sách texture.
.glb5 MB thường 90% là texture. - Đừng theo phản xạ mà xuất đèn/camera của Blender. Bạn gần như luôn dựng lại đèn và khung hình trong Three.js.
- Đường dẫn decoder phải tới được. Sai đường dẫn chỉ fail với file nén, rất khó lần.
Thực hành
- Dựng một cái ghế đẩu đơn giản trong Blender, apply transform, gán material Principled, xuất
.glbvới +Y Up. Nạp vào và xác nhận nó đứng trêny = 0. - Thêm model thứ năm vào demo bằng URL và xác nhận logic fit/center xử lý được mà không sửa code.
- Đọc một custom property: đặt trong Blender, xuất với Custom Properties bật, rồi log
mesh.userDatasau khi nạp. - Bật/tắt một bộ phận theo tên bằng
scene.getObjectByName(...)và một nút.
Tiếp theo
Giờ bạn nạp được mọi thứ — nhưng model đến từ đâu, và mở app nào để tạo? Part 15 là cẩm nang về các công cụ tạo 3D và cách từng cái đến được Three.js. Rồi Part 16 làm các asset đó nhẹ cho production bằng nén Draco/Meshopt và texture KTX2 qua gltf-transform — và decoder bạn đã nối ở trên sẽ nạp được chúng.