Three.js from Zero to Senior · Part 13 — Three.js in the Real World: an Interior Decorator
Turn the series into something useful: a furniture room planner. Build furniture from primitives, click-to-place, drag-to-move, recolor materials, repaint the room, and swing day to night — the pattern behind product configurators.
Mỗi phần đến giờ dạy một kỹ thuật. Phần này trả lời câu hỏi mà người ra quyết định thật sự hỏi: “cái này dùng được vào việc gì?” Câu trả lời mà ai cũng từng chạm — tính năng “đặt thử vào phòng” của IKEA, room planner của trang bất động sản, trang giày xoay được sản phẩm — đều cùng một bộ xương: một scene 3D nhỏ, vật thể đặt và cấu hình được, và một camera di chuyển được.
Demo này là một trình trang trí nội thất chạy thật. Click đồ nội thất trong catalog để thả vào, kéo trên sàn, rồi tinh chỉnh từng món — màu bất kỳ, kích thước, góc xoay và bề mặt (mờ tới kim loại) — sơn lại phòng, bật trần, và trượt từ trưa tới nửa đêm. Bạn có thể upload ảnh sản phẩm thật để dựng trong phòng, và lưu bố cục để quay lại. Mọi vật liệu — sàn gỗ lát, vải dệt, vân gỗ trên bàn — đều sinh trong trình duyệt, nên không nạp gì từ bên ngoài.
Trên điện thoại: một ngón xoay, hai ngón zoom, và kéo một món để di chuyển.
Đồ nội thất chỉ là các khối nguyên thuỷ, gom lại
Bạn không cần phần mềm dựng hình hay một file .glb nào để tạo đồ nội thất nhận ra được. Một ghế sofa là vài cái box; một cây đèn là hai cylinder và một point light; một chậu cây là cái chậu cộng mấy quả cầu bị bẹp. Mẹo khiến nó cảm giác như một vật thể là Group — một node không có hình học riêng nhưng di chuyển, xoay, scale tất cả con cùng lúc.
function sofa(color) {
const g = new THREE.Group();
const base = box(2.6, 0.45, 1.1, color); base.position.y = 0.45;
const back = box(2.6, 0.7, 0.28, color); back.position.set(0, 0.85, -0.41);
g.add(base, back /* …arms, cushions, legs… */);
return g; // move g → the whole sofa moves
}
Đặt vị trí mỗi con một lần, tương đối với gốc của group, rồi quên chúng đi. Từ đó về sau bạn chỉ chạm vào group. Đây là bài học scene-graph ở Part 3 đang làm việc thật.
Click để đặt, raycast để chọn
Hai tương tác gánh toàn bộ trải nghiệm: thêm một món và chọn một món. Thêm thì dễ — dựng group, thả gần tâm, add vào scene và một mảng items. Chọn dùng lại Raycaster ở Part 9, nhưng có một điểm xoắn: click trúng một mesh con (cái nệm), mà bạn muốn cả ghế sofa.
Cách sửa gọn là gắn cho mọi con cháu một tham chiếu ngược về group gốc ngay khi tạo:
g.traverse((o) => { o.userData.root = g; }); // tag once on add
// …later, on click:
const hit = raycaster.intersectObjects(items, true)[0]; // true = recurse
const group = hit ? hit.object.userData.root : null; // resolve to root
intersectObjects(items, true) đệ quy vào con để tia có thể trúng cái nệm; userData.root đưa bạn ngược lên thứ người dùng thật sự muốn. Một BoxHelper quanh group được chọn cho bạn viền chọn quen thuộc miễn phí.
Kéo-để-di-chuyển: chiếu con trỏ xuống sàn
Đây là phần làm người ta bất ngờ: làm sao biến vị trí chuột 2D thành vị trí 3D trên sàn? Bạn không di chuyển vật trong không gian màn hình — bạn bắn tia con trỏ vào scene và cắt nó với một mặt phẳng toán học, không cần hình học.
const floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); // y = 0
const hitPoint = new THREE.Vector3();
// on pointerdown over an item: remember the grab offset so it doesn't snap
raycaster.ray.intersectPlane(floorPlane, hitPoint);
dragOffset.subVectors(group.position, hitPoint);
// on pointermove while dragging:
raycaster.setFromCamera(pointer, camera);
raycaster.ray.intersectPlane(floorPlane, hitPoint);
group.position.copy(hitPoint.clone().add(dragOffset)); // x/z only
Ba chi tiết senior ẩn ở đây. Thứ nhất, độ lệch điểm cầm — không có nó vật sẽ nhảy để gốc của nó dính dưới con trỏ; lưu position − hitPoint giữ điểm cầm dưới ngón tay. Thứ hai, tắt OrbitControls khi kéo (controls.enabled = false) để camera không tranh với thao tác kéo. Thứ ba, setPointerCapture giữ sự kiện chảy liên tục dù con trỏ rời canvas giữa chừng.
Rồi kẹp kết quả vào trong phòng để đồ không bao giờ trượt xuyên tường:
v.x = THREE.MathUtils.clamp(v.x, -HALF + margin, HALF - margin);
v.z = THREE.MathUtils.clamp(v.z, -HALF + margin, HALF - margin);
Vật liệu cấu hình được — tính năng hái ra tiền
Mục đích của một configurator là lựa chọn. Vì đồ nội thất là group, “đổi màu ghế” nghĩa là “đổi màu các mesh đại diện cho vải, không phải chân kim loại.” Nên mỗi factory đánh dấu con nào đổi màu được và việc recolor chỉ duyệt danh sách đó:
group.userData.tint = [base, back, armL, armR]; // fabric, not legs
function applyTint(group, hex) {
for (const m of group.userData.tint) m.material.color.setHex(hex);
}
Đổi material.color là tức thì và miễn phí — không dựng lại hình học, không upload lại. Cũng ý tưởng đó mở rộng tới việc đổi cả vật liệu (da vs vải vs gỗ) bằng cách giữ một thư viện nhỏ các MeshStandardMaterial và gán lại mesh.material. Tường và sàn chỉ là hai MeshStandardMaterial nữa mà bạn set .color từ một hàng ô màu.
Trông thật mà không cần file asset nào
Màu nhựa phẳng là thứ khiến scene 3D bị nhìn như đồ chơi. Bề mặt thật có vân, sợi dệt và độ nhám li ti — nhưng bạn không cần tải texture về để có chúng. Vẽ chúng lên một <canvas> rồi bọc trong CanvasTexture: ván gỗ thành vài hình chữ nhật ngả màu với nét vân lượn sóng, vải là ô caro dệt cộng nhiễu, vữa chỉ là nhiễu.
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace; // color maps must be sRGB
tex.wrapS = tex.wrapT = THREE.RepeatWrapping; // tile across the surface
tex.repeat.set(5, 5);
floorMat.map = tex; // colored wood grain
Mẹo senior: đưa bản grayscale vào bumpMap và roughnessMap thay vì map, để bề mặt vẫn đổi màu qua material.color (danh sách tint vẫn chạy) mà có thêm độ gồ ghề thật. Thêm một cửa sổ dạng RectAreaLight cho ánh sáng phụ mềm và có hướng, giữ một DirectionalLight đổ bóng, và để environment map RoomEnvironment lo phản chiếu tinh tế — bộ ba đó “bán” được cảm giác “chụp trong phòng thật.”
Upload ảnh thật: 2D gặp 3D
Đây là nước đi khiến khách hàng chú ý: cho họ thả ảnh sản phẩm thật vào phòng 3D. Bạn không dựng gì cả — bạn dựng tấm ảnh đứng lên trên một plane có alpha-test, để ảnh PNG cắt nền của cái ghế trông như đang ở trong scene.
const url = URL.createObjectURL(file); // from an <input type="file">
new THREE.TextureLoader().load(url, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace;
const aspect = tex.image.width / tex.image.height;
const mat = new THREE.MeshStandardMaterial({
map: tex, transparent: true, alphaTest: 0.5, // cut-outs: hide near-transparent pixels
side: THREE.DoubleSide,
});
const photo = new THREE.Mesh(new THREE.PlaneGeometry(2 * aspect, 2), mat);
URL.revokeObjectURL(url); // free the blob once uploaded to the GPU
});
Ba chi tiết khiến nó như được đặt chứ không phải dán. alphaTest: 0.5 loại pixel trong suốt để ảnh PNG cắt nền có viền sạch (và tránh đau đầu sắp xếp của transparency thật). Cho plane theo tỉ lệ khung của ảnh để không bị kéo méo. Và một vệt bóng tiếp đất tròn mờ dưới sàn neo tấm ảnh phẳng để nó không trông như đang lơ lửng. Tấm ảnh sau đó tham gia cùng hệ thống chọn, kéo, xoay, xoá như mọi thứ khác — với phần còn lại của app nó chỉ là một item nữa. Luôn URL.revokeObjectURL sau khi texture đã nạp, không thì mỗi lần upload rò một blob.
Ngày sang đêm: ánh sáng là tâm trạng
Người mua muốn thấy phòng lúc 3 giờ chiều và lúc 9 giờ tối. Một slider điều khiển một giá trị 0 → 1 rồi lerp giữa preset đêm và ngày — màu trời, màu và cường độ directional light, mức ambient — và đẩy PointLight của đèn sáng hơn sau khi trời tối.
scene.background = nightSky.clone().lerp(daySky, k);
sun.intensity = THREE.MathUtils.lerp(0.5, 2.4, k);
ambient.intensity = THREE.MathUtils.lerp(0.18, 0.6, k);
Độ sáng là một cần gạt riêng — renderer.toneMappingExposure — vì exposure tone-mapping điều khiển camera, không phải thế giới, nên bạn làm sáng bản render mà không làm phẳng tương phản ngày/đêm. Bóng mềm từ một DirectionalLight (Part 4) neo mỗi món xuống sàn và “bán” được sự chân thực.
Tranh treo tường và đèn rọi
Thứ biến căn phòng từ “có đồ” thành “được thiết kế” là bức tường — tranh khung, được chiếu sáng có chủ đích. Hai ý tưởng mới xuất hiện ở đây. Thứ nhất, bản thân tranh được sinh trên canvas (gradient trừu tượng, bố cục Bauhaus, tranh nét, phong cảnh hoàng hôn) nên phòng tranh là vô tận và không nặng — không file ảnh. Thứ hai, mỗi khung gắn lên tường, không phải sàn, nên việc kéo chiếu con trỏ lên một mặt phẳng đứng thay vì sàn:
const backWall = new THREE.Plane(new THREE.Vector3(0, 0, 1), HALF - 0.18);
// dragging a wall item moves it in x and y, with z pinned to the wall
const p = ray.intersectPlane(backWall, hit);
art.position.x = clamp(hit.x + offset.x, -HALF + 1, HALF - 1);
art.position.y = clamp(hit.y + offset.y, 1, WALL_H - 0.7);
Vẻ “sang” đến từ đèn rọi tranh: một SpotLight gắn vào khung, nhắm vào một node target nhỏ ở giữa tranh, với penumbra cao cho một vũng sáng mềm. Vì spotlight là con của group khung, nó tự đi theo tranh — di chuyển khung, đèn theo cùng. Buộc cường độ của nó vào giá trị ngày/đêm (sáng hơn khi tối) và căn phòng đọc như một phòng tranh nhỏ về đêm.
Đừng rò rỉ căn phòng
Một planner nơi người dùng thêm và xoá hàng chục món là cái bẫy rò rỉ bộ nhớ nếu bạn chỉ gọi scene.remove(). Gỡ khỏi scene graph không giải phóng bộ nhớ GPU — bạn phải tự dispose geometry và material (bài học Part 8, áp dụng):
function deleteItem(group) {
group.traverse((o) => {
if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); }
});
scene.remove(group);
items.splice(items.indexOf(group), 1);
}
“Clear room” chạy cùng việc dispose cho mọi món — nên người dùng trang trí lại cả buổi chiều mà tab không bò dần tới chỗ sập.
Mẫu này ship ở đâu trong thực tế
- Configurator thương mại điện tử — xe, giày, sofa, mắt kính: đổi vật liệu và màu trên một model, đúng như danh sách tint ở đây.
- Room & kitchen planner — IKEA, dàn dựng bất động sản: đặt món trên mặt sàn, kéo, xoay, kẹp vào tường.
- Kiến trúc & digital twin — cùng scene graph, asset glTF thật và số đo thật thay cho khối nguyên thuỷ.
- Chụp ảnh sản phẩm quy mô lớn — render scene đã cấu hình phía server để tạo hàng nghìn ảnh catalog.
Bước nhảy lên production phần lớn là thay khối nguyên thuỷ bằng model glTF thật (Part 6) và thêm số đo — mọi hệ thống khác ở đây giữ nguyên.
Lời cảnh báo của sư phụ
- Click chọn nhầm? Bạn quên
userData.root, hoặc truyềnfalsechointersectObjectsnên tia không bao giờ tới mesh con. - Kéo tranh với camera? Bạn chưa set
controls.enabled = falsekhi pointer-down trên một món. - Vật nhảy tới con trỏ khi cầm? Thiếu độ lệch điểm cầm — lưu
position − hitPointrồi cộng lại. - Đồ trượt xuyên tường? Kẹp
x/zvào nửa kích thước phòng trừ một biên. - Bộ nhớ leo khi người dùng chỉnh sửa?
scene.remove()không phải dispose — gọi cảgeometry.dispose()vàmaterial.dispose(). - Ảnh upload bị bệch màu? Bạn quên
texture.colorSpace = THREE.SRGBColorSpace— color map phải là sRGB, không thì hiện nhợt nhạt. - PNG cắt nền hiện ô xám? Đặt
transparent: truevàalphaTest, và dùng PNG thật có kênh alpha — JPG thì không có.
Luyện tập, không thì coi như chưa học
- Món mới: thêm
bookcasehoặctvvào factory chỉ bằng box và cylinder, rồi đăng ký vào catalog. - Bắt lưới: làm tròn vị trí kéo về bội số
0.5gần nhất để các món thẳng hàng như planner thật. - Lưu & khôi phục: tuần tự hoá
key,position,rotation.yvà màu của mỗi món ra JSON, rồi dựng lại phòng từ đó.
Phần tiếp theo
Giờ bạn đã lấy cả series — scene graph, đèn và bóng, vật liệu, raycasting, dispose — và lắp thành thứ một product team thật sự ship. Thay khối nguyên thuỷ bằng model glTF, nối với API catalog thật, và lưu phòng vào backend, và món đồ chơi này thành một configurator thực thụ. Đó là nước đi senior mà cả series hướng tới: không phải “tôi biết Three.js,” mà “tôi ship được 3D giải quyết một vấn đề.”