Three.js from Zero to Senior · Bonus 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.
Every part so far taught a technique. {Mỗi phần đến giờ dạy một kỹ thuật.} This one answers the question a stakeholder actually asks: “what is this good for?” {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ì?”} The answer everyone has touched — IKEA’s “place it in your room”, a real-estate site’s room planner, a sneaker site that spins the shoe — is the same skeleton: a small 3D scene, objects you can place and configure, and a camera you can move. {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.}
This demo is a working interior decorator. {Demo này là một trình trang trí nội thất chạy thật.} Click furniture from the catalog to drop it in, drag it across the floor, then fine-tune each piece — any color, size, rotation and finish (matte to metal) — repaint the room, toggle a ceiling, and slide from noon to midnight. {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.} You can upload a real product photo to stand it in the room, and save your layout to come back to it. {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.} Every material — the wood-plank floor, the woven fabric, the grain on the table — is generated in the browser, so nothing external loads. {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.}
On a phone: one finger orbits, two fingers zoom, and dragging a piece moves it. {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.} If the panels feel cramped, open the demo full-screen below. {Nếu panel thấy chật, mở demo toàn màn hình bên dưới.}
Open the full demo {Mở demo đầy đủ}: /tools/threejs-interior-decor-demo/.
Furniture is just primitives, grouped {Đồ nội thất chỉ là các khối nguyên thuỷ, gom lại}
You don’t need a modeling tool or a single .glb file to make recognizable furniture. {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.} A sofa is a few boxes; a lamp is two cylinders and a point light; a plant is a pot plus squashed spheres. {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.} The trick that makes it feel like one object is the Group — a node with no geometry of its own that moves, rotates, and scales all its children together. {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
}
Position every child once, relative to the group’s origin, then forget about them. {Đặ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.} From then on you only ever touch the group. {Từ đó về sau bạn chỉ chạm vào group.} This is the scene-graph lesson from Part 3 doing real work. {Đây là bài học scene-graph ở Part 3 đang làm việc thật.}
Click to place, raycast to select {Click để đặt, raycast để chọn}
Two interactions carry the whole experience: adding an item and selecting one. {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.} Adding is trivial — build the group, drop it near the centre, add it to the scene and an items array. {Thêm thì dễ — dựng group, thả gần tâm, add vào scene và một mảng items.} Selecting reuses the Raycaster from Part 9, but with one wrinkle: a click lands on a child mesh (a cushion), and you want the whole sofa. {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.}
The clean fix is to tag every descendant with a back-reference to its root group when you create it: {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) recurses into children so the ray can hit the cushion; userData.root walks you back up to the thing the user means. {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.} A BoxHelper around the selected group gives the familiar selection outline for free. {Một BoxHelper quanh group được chọn cho bạn viền chọn quen thuộc miễn phí.}
Drag-to-move: project the pointer onto the floor {Kéo-để-di-chuyển: chiếu con trỏ xuống sàn}
This is the part that surprises people: how do you turn a 2D mouse position into a 3D position on the floor? {Đâ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?} You don’t move the object in screen space — you cast the pointer ray into the scene and intersect it with a mathematical plane, no geometry required. {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
Three senior details hide here. {Ba chi tiết senior ẩn ở đây.} First, the grab offset — without it the object teleports so its origin snaps under the cursor; storing position − hitPoint keeps the grab point under your finger. {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.} Second, you disable OrbitControls while dragging (controls.enabled = false) so the camera doesn’t fight the drag. {Thứ hai, tắt OrbitControls khi kéo (controls.enabled = false) để camera không tranh với thao tác kéo.} Third, setPointerCapture keeps events flowing even if the pointer briefly leaves the canvas mid-drag. {Thứ ba, setPointerCapture giữ sự kiện chảy liên tục dù con trỏ rời canvas giữa chừng.}
Then clamp the result to the room so furniture never slides through a wall: {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);
Configurable materials — the money feature {Vật liệu cấu hình được — tính năng hái ra tiền}
A configurator’s whole point is choice. {Mục đích của một configurator là lựa chọn.} Because furniture is a group, “recolor the sofa” means “recolor the meshes that represent fabric, not the metal legs.” {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.”} So each factory marks which children are tintable and the recolor just walks that list: {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);
}
Changing material.color is instant and free — no geometry rebuild, no re-upload. {Đổ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.} The same idea scales to swapping whole materials (leather vs fabric vs wood) by keeping a small library of MeshStandardMaterials and reassigning mesh.material. {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.} Walls and floor are just two more MeshStandardMaterials whose .color you set from a swatch row. {Tường và sàn chỉ là hai MeshStandardMaterial nữa mà bạn set .color từ một hàng ô màu.}
Looking real without a single asset file {Trông thật mà không cần file asset nào}
Flat plastic colors are what make a 3D scene read as a toy. {Màu nhựa phẳng là thứ khiến scene 3D bị nhìn như đồ chơi.} Real surfaces have grain, weave and micro-roughness — but you don’t need to download textures to get them. {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.} Draw them on a <canvas> and wrap it in a CanvasTexture: wood planks become a few tinted rectangles with wavy grain strokes, fabric is a woven checker plus noise, plaster is just noise. {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
The senior trick: feed the grayscale versions into bumpMap and roughnessMap instead of map, so the surface still recolors via material.color (your tint list keeps working) while gaining real micro-relief. {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.} Add a window as a RectAreaLight for soft, directional fill, keep one shadow-casting DirectionalLight, and let the RoomEnvironment map handle subtle reflections — that trio is what sells “photographed in a real room.” {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 a real photo: 2D meets 3D {Upload ảnh thật: 2D gặp 3D}
Here’s the move that makes clients lean in: let them drop a real product photo into the 3D room. {Đâ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.} You don’t model anything — you stand the photo up on an alpha-tested plane, so a cut-out PNG of a chair looks like it’s in the scene. {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
});
Three details make it feel placed rather than pasted. {Ba chi tiết khiến nó như được đặt chứ không phải dán.} alphaTest: 0.5 discards transparent pixels so a PNG cut-out has clean edges (and avoids the sorting headaches of true transparency). {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).} Sizing the plane to the image’s aspect ratio keeps it from stretching. {Cho plane theo tỉ lệ khung của ảnh để không bị kéo méo.} And a soft radial contact-shadow decal on the floor under it grounds the flat photo so it doesn’t look like it’s floating. {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.} The photo then joins the same selection, drag, rotate and delete system as everything else — to the rest of the app it’s just another item. {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.} Always URL.revokeObjectURL once the texture is uploaded, or every upload leaks a blob. {Luôn URL.revokeObjectURL sau khi texture đã nạp, không thì mỗi lần upload rò một blob.}
Day to night: light is the mood {Ngày sang đêm: ánh sáng là tâm trạng}
Buyers want to see the room at 3pm and at 9pm. {Người mua muốn thấy phòng lúc 3 giờ chiều và lúc 9 giờ tối.} One slider drives a single 0 → 1 value that lerps between a night and a day preset — sky color, directional-light color and intensity, ambient level — and nudges lamp PointLights brighter after dark. {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);
Brightness is a separate lever — renderer.toneMappingExposure — because tone-mapping exposure controls the camera, not the world, so you can brighten the render without flattening the day/night contrast. {Độ 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.} Soft shadows from a single DirectionalLight (Part 4) anchor every piece to the floor and sell the realism. {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.}
Gallery wall art and picture lights {Tranh treo tường và đèn rọi}
What turns a room from “furnished” to “designed” is the wall — framed art, lit deliberately. {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.} Two new ideas appear here. {Hai ý tưởng mới xuất hiện ở đây.} First, the artwork itself is generated on a canvas (an abstract gradient, a Bauhaus composition, a line drawing, a dusk landscape) so the gallery is endless and weightless — no image files. {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.} Second, each frame is mounted on a wall, not the floor, so dragging projects the pointer onto a vertical plane instead of the floor: {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);
The “expensive” look comes from the picture light: a SpotLight parented to the frame, aimed at a small target node at the canvas center, with a high penumbra for a soft pool of light. {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.} Because the spotlight is a child of the frame group, it travels with the art automatically — move the frame, the light follows. {Vì spotlight là con của group khung, nó tự đi theo tranh — di chuyển khung, đèn theo cùng.} Tie its intensity to the day/night value (brighter after dark) and the room reads like a small gallery at night. {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.}
Don’t leak the room {Đừng rò rỉ căn phòng}
A planner where users add and delete dozens of items is a memory-leak trap if you only call scene.remove(). {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().} Removing from the scene graph does not free GPU memory — you must dispose geometries and materials yourself (the Part 8 lesson, applied): {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” runs the same disposal across every item — so a user can redecorate all afternoon without the tab creeping toward a crash. {“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.}
Where this pattern ships in the real world {Mẫu này ship ở đâu trong thực tế}
- E-commerce configurators — cars, sneakers, sofas, eyewear: swap materials and colors on a single model, exactly like the tint list here. {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 planners — IKEA, real-estate staging: place items on a floor plane, drag, rotate, clamp to walls. {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.}
- Architecture & digital twins — same scene graph, real glTF assets and measurements instead of primitives. {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ỷ.}
- Product photography at scale — render the configured scene server-side to generate thousands of catalog images. {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.}
The leap to production is mostly swapping primitives for real glTF models (Part 6) and adding measurements — every other system here stays the same. {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.}
The master’s warnings {Lời cảnh báo của sư phụ}
- Click selects the wrong thing? You forgot
userData.root, or passedfalsetointersectObjectsso the ray never reached the child meshes. {Click chọn nhầm? Bạn quênuserData.root, hoặc truyềnfalsechointersectObjectsnên tia không bao giờ tới mesh con.} - Dragging fights the camera? You didn’t set
controls.enabled = falseon pointer-down over an item. {Kéo tranh với camera? Bạn chưa setcontrols.enabled = falsekhi pointer-down trên một món.} - Object jumps to the cursor on grab? Missing grab offset — store
position − hitPointand add it back. {Vật nhảy tới con trỏ khi cầm? Thiếu độ lệch điểm cầm — lưuposition − hitPointrồi cộng lại.} - Furniture slides through walls? Clamp
x/zto the room half-size minus a margin. {Đồ trượt xuyên tường? Kẹpx/zvào nửa kích thước phòng trừ một biên.} - Memory climbs as users edit?
scene.remove()isn’t disposal — callgeometry.dispose()andmaterial.dispose()too. {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().} - Uploaded photo looks washed out? You forgot
texture.colorSpace = THREE.SRGBColorSpace— color maps must be sRGB or they render pale. {Ảnh upload bị bệch màu? Bạn quêntexture.colorSpace = THREE.SRGBColorSpace— color map phải là sRGB, không thì hiện nhợt nhạt.} - Cut-out PNG shows a grey rectangle? Set
transparent: trueandalphaTest, and use a real PNG with an alpha channel — a JPG has none. {PNG cắt nền hiện ô xám? Đặttransparent: truevàalphaTest, và dùng PNG thật có kênh alpha — JPG thì không có.}
Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}
- New piece {Món mới}: add a
bookcaseortvto the factory using only boxes and cylinders, then register it in the catalog. {thêmbookcasehoặctvvào factory chỉ bằng box và cylinder, rồi đăng ký vào catalog.} - Grid snap {Bắt lưới}: round the dragged position to the nearest
0.5so pieces align like a real planner. {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.} - Save & restore {Lưu & khôi phục}: serialize each item’s
key,position,rotation.yand color to JSON, then rebuild the room from it. {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ừ đó.}
What’s next {Phần tiếp theo}
You’ve now taken the entire series — scene graph, lights and shadows, materials, raycasting, disposal — and assembled it into something a product team would actually ship. {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.} Swap the primitives for glTF models, wire it to a real catalog API, and persist rooms to a backend, and this toy becomes a genuine configurator. {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ụ.} That’s the senior move the whole series was building toward: not “I know Three.js,” but “I can ship 3D that solves a problem.” {Đó 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 đề.”}