jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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.

11 MIN READ Updated JUL 12, 2026

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.

Mở demo toàn màn hình

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 .bin và ả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 MapNormal.
  • 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ành object.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 UpBẬT. Đổi Z-up của Blender sang Y-up. Tắt = model nằm ngửa.
  • Data → Mesh → Apply ModifiersBẬ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 → ImagesAutomatic 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 → AnimationBẬ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ộcDRACOLoader, 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 conceptThree.js resultNote
scenegltf.scene (Group)the root you add()
nodeObject3D / Group / Bonetransform hierarchy preserved
mesh + primitiveone Mesh per primitivea 2-material object → 2 child Meshes
materialMeshStandardMaterial (or MeshPhysicalMaterial if KHR_*)maps wired automatically
texture / imageTHREE.Texturecolor space set per slot
skinSkinnedMesh + Skeletonbones live under nodes
animationAnimationClip[]gltf.animationsplay with AnimationMixer
cameraPerspectiveCamera / OrthographicCameragltf.camerasnot auto-activated
node extrasobject.userDatayour 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 channelThree.js propertyColor space
Base ColorbaseColorTexturemap + colorsRGB
Metallicmetallic-roughness BmetalnessMap + metalnesslinear
Roughnessmetallic-roughness GroughnessMap + roughnesslinear
Ambient OcclusionocclusionTexture RaoMaplinear
NormalnormalTexturenormalMaplinear
EmissionemissiveTextureemissiveMap + emissivesRGB
Alpha / Blend modealphaMode + base color αtransparent / opacity / alphaTest
ClearcoatKHR_materials_clearcoatMeshPhysicalMaterial.clearcoat
TransmissionKHR_materials_transmissionMeshPhysicalMaterial.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, outputColorSpacescene.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 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.

SymptomLikely causeFix
Nothing appearsmodel off-screen or mis-scaledlog Box3 size; run fit-and-ground
Microscopic / giganticauthored in mm/cm/kmnormalize via bounding box
Lying on its back / rotated”+Y Up” off, transforms not appliedre-export with +Y Up; Ctrl+A in Blender
Pure black surfacesno light/environmentadd 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 brighttone-map / color-space mismatchACESFilmic + outputColorSpace = SRGB
Colors look dull / greycolor map loaded as linearlet GLTFLoader set color space; don’t override
Faceted / flat shadingnormals missing or not exportedexport Normals; Shade Smooth in Blender
Animation won’t movemissing mixer.update(dt)update the mixer in the loop
No DRACOLoader instance errorDraco-compressed filesetDRACOLoader(...) (Part 16)
CORS error in consolecross-origin host, no CORS headersserve 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. .glb 5 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

  1. Dựng một cái ghế đẩu đơn giản trong Blender, apply transform, gán material Principled, xuất .glb với +Y Up. Nạp vào và xác nhận nó đứng trên y = 0.
  2. 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.
  3. Đọc một custom property: đặt trong Blender, xuất với Custom Properties bật, rồi log mesh.userData sau khi nạp.
  4. 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.