← Tất cả bài viết
Tối Ưu Hiệu Năng Three.js: Hướng Dẫn Thực Chiến
Tối ưu hiệu năng Three.js đúng chỗ để FPS tăng thật: giảm draw call, gộp geometry, dọn bộ nhớ và render theo nhu cầu trong React Three Fiber.

Lần đầu một khách hàng mở hero WebGL của tôi trên chiếc laptop ba năm tuổi, nó chạy 18 FPS và quạt máy kêu như máy sấy tóc. Cảnh đó mượt mà trên con Mac M-series của tôi — đó chính là cái bẫy. Tối ưu hiệu năng Three.js là ranh giới giữa một demo đoạt giải và một website bị đóng trước cả khi load xong. Sau cả chục website 3D, các "đòn" hiệu quả gần như luôn là vài thứ giống nhau, theo cùng một thứ tự. Đây là checklist tôi dùng thật, kèm con số và những cái bẫy.
Ý quan trọng nhất: GPU không quan tâm bạn có bao nhiêu tam giác bằng việc bạn bắt nó vẽ bao nhiêu lần. Đa số cảnh Three.js chậm không phải vì nhiều tam giác — mà vì quá nhiều draw call, rò rỉ bộ nhớ, hoặc render mỗi frame trong khi chẳng có gì chuyển động.
Đo trước đã: renderer.info là nguồn sự thật
Đừng bao giờ tối ưu mù. Trước khi đụng vào thứ gì, hãy đọc những con số renderer đã cho sẵn:
function logStats(renderer: THREE.WebGLRenderer) {
const { render, memory, programs } = renderer.info
console.table({
drawCalls: render.calls,
triangles: render.triangles,
geometries: memory.geometries,
textures: memory.textures,
programs: programs?.length ?? 0,
})
}
// gọi mỗi giây một lần, không phải mỗi frame
Ngân sách thô của tôi cho 60 FPS mượt trên máy tầm trung: dưới ~100–150 draw call, tam giác dưới vài triệu, và số geometry/texture phẳng theo thời gian (nếu nó tăng khi đang đứng yên, bạn bị rò rỉ). Kết hợp với GPU profiler của trình duyệt và stats.js để xem FPS trực tiếp. Tối ưu con số lớn nhất trước — đoán mò là cách bạn tốn cả ngày để cạo 2% từ thứ chẳng quan trọng.
1. Diệt draw call — instancing và batching
Mỗi mesh riêng lẻ tốn ít nhất một draw call. Một khu rừng 2.000 cây dựng thành 2.000 mesh là bản án tử. Hai công cụ giải quyết:
InstancedMesh — một geometry, một material, hàng nghìn transform, một draw call:
const count = 2000
const mesh = new THREE.InstancedMesh(geometry, material, count)
const dummy = new THREE.Object3D()
for (let i = 0; i < count; i++) {
dummy.position.set(
(Math.random() - 0.5) * 100, 0, (Math.random() - 0.5) * 100,
)
dummy.rotation.y = Math.random() * Math.PI
dummy.updateMatrix()
mesh.setMatrixAt(i, dummy.matrix)
}
mesh.instanceMatrix.needsUpdate = true
BatchedMesh (Three.js r159+, cải thiện mạnh ở r170) là chiêu mới khi các vật thể dùng chung material nhưng khác geometry — nó gộp chúng thành một multi-draw call. Dùng InstancedMesh cho geometry giống hệt nhau, BatchedMesh cho geometry khác nhau nhưng chung material. Kết hợp lại, hàng trăm draw call có thể rút về một chữ số.
Nếu geometry tĩnh và bạn không cần điều khiển từng vật thể, hãy gộp sẵn ngoại tuyến:
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js"
const merged = mergeGeometries([geoA, geoB, geoC])
Trong React Three Fiber, <Instances>/<Instance> và <Merged> của drei cho bạn lợi ích tương tự theo kiểu khai báo — xem hướng dẫn React Three Fiber cho người mới để biết các mẫu component.
2. Render theo nhu cầu — đừng vẽ frame tĩnh
Rất nhiều GPU bị lãng phí để vẽ lại một cảnh chẳng hề thay đổi. Nếu phần 3D của bạn chủ yếu tĩnh (trình xem sản phẩm, hero chỉ chạy khi cuộn), hãy tắt vòng lặp liên tục.
Trong React Three Fiber, đặt frameloop="demand" và gọi invalidate() khi thật sự có thay đổi:
import { Canvas, useThree } from "@react-three/fiber"
<Canvas frameloop="demand">
<Scene />
</Canvas>
// chỉ render lại khi bạn thay đổi cảnh
const invalidate = useThree((s) => s.invalidate)
useEffect(() => { invalidate() }, [someState, invalidate])
OrbitControls và các spring của drei tự gọi invalidate() giúp bạn. Trên một trình cấu hình sản phẩm tôi từng làm, frameloop="demand" kéo mức dùng GPU lúc rảnh về gần như bằng không và ngừng làm laptop rú quạt — đây là cú thắng pin/nhiệt lớn nhất, gần như không tốn code.
Với Three.js thuần, đơn giản là đừng gọi renderer.render() trong vòng lặp RAF trừ khi có cờ báo cảnh bị "bẩn".
3. Giới hạn pixel ratio — dòng lệnh thắng 2× chỉ một dòng
Màn Retina báo devicePixelRatio 2 hoặc 3, tức bạn đang render gấp 4–9 lần số pixel. Gần như không ai phân biệt được khi vượt mức 2:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
Trong R3F: <Canvas dpr={[1, 2]} />. Đây thường là dòng lệnh hiệu quả nhất toàn dự án — các cảnh nặng fragment shader (có post-processing hay material cầu kỳ) bị giới hạn bởi fill-rate, và bạn vừa cắt một nửa khối lượng tính toán.
4. Nén texture và geometry — payload thật sự
Texture thường là phần nặng nhất khi tải và khi nằm trong VRAM. Một PNG 4096×4096 có thể nặng 8–10 MB trên đĩa và ~64 MB sau giải nén trong VRAM. Hai thứ bắt buộc:
- Texture nén GPU (KTX2 / Basis) giữ nguyên trạng thái nén trong VRAM, không chỉ trên đường truyền. Dùng
KTX2Loader. Một texture từng 64 MB có thể chỉ còn ~4 MB trong bộ nhớ. - Draco hoặc Meshopt cho geometry. Draco có thể giảm 80–90% kích thước mesh glTF trên đường truyền (tốn chút thời gian giải mã — gần như luôn đáng).
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"
const draco = new DRACOLoader().setDecoderPath("/draco/")
const loader = new GLTFLoader().setDRACOLoader(draco)
Chạy file .glb qua gltf-transform hoặc gltfpack một lần lúc build — nó Draco hóa mesh, tạo texture KTX2, và dọn dữ liệu thừa trong một lệnh. Đây là việc làm offline nhưng sinh lời ở mọi lượt truy cập.
5. Dispose mọi thứ — lỗi rò rỉ giết SPA
Three.js không tự thu gom tài nguyên GPU cho bạn. Trong một single-page app khi bạn mount/unmount cảnh (đổi route React, mở modal), geometry/material/texture chưa dispose sẽ rò rỉ VRAM cho đến khi context sập. Theo dõi renderer.info.memory — nếu nó chỉ tăng, bạn đang dính lỗi này.
function disposeObject(obj: THREE.Object3D) {
obj.traverse((node) => {
const mesh = node as THREE.Mesh
mesh.geometry?.dispose()
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => {
if (!m) return
for (const key in m) {
const val = (m as any)[key]
if (val?.isTexture) val.dispose()
}
m.dispose()
})
})
}
R3F xử lý phần lớn việc này tự động khi unmount — nhưng không với tài nguyên bạn tạo thủ công hoặc cache ngoài cây React. Nếu tự new THREE.Texture(), bạn tự dispose.
6. Material, ánh sáng và bóng đổ — chi phí âm thầm
- Ánh sáng và bóng đổ rất đắt. Shadow map thời gian thực render lại cảnh từ mỗi nguồn sáng. Bake ánh sáng vào texture khi có thể, giữ đèn đổ bóng ở mức một cái, và hạ
shadow.mapSizexuống 1024 hoặc 512. - Tái sử dụng material và geometry. Hai mesh dùng chung một material có thể batch; hai mesh trông giống nhau nhưng material tách riêng thì không.
- Chọn material rẻ nhất mà vẫn đẹp. Chi phí:
MeshBasicMaterial<MeshLambertMaterial<MeshStandardMaterial<MeshPhysicalMaterial. Hiếm khi bạn cần physical.
Nếu tự viết GLSL, hãy đẩy phép tính nặng ra khỏi fragment shader — nó chạy trên từng pixel, hàng triệu lần. Cùng hiệu ứng tính trong vertex shader (trên từng đỉnh) có thể rẻ hơn nhiều bậc. Xem kỹ thuật trong hướng dẫn hiệu ứng displacement ảnh WebGL.
7. Thích nghi theo thiết bị — đừng giao một mức chất lượng duy nhất
Cảnh nhanh nhất là cảnh biết co giãn theo phần cứng. <PerformanceMonitor> của drei theo dõi FPS trung bình và gọi onDecline/onIncline để bạn hạ DPR, tắt post-processing, hoặc giảm số instance trên GPU yếu:
import { PerformanceMonitor } from "@react-three/drei"
<PerformanceMonitor
onDecline={() => setDpr(1)}
onIncline={() => setDpr(2)}
/>
Luôn chuẩn bị một phương án tĩnh (ảnh poster hoặc CSS) cho thiết bị không chạy nổi WebGL, và tôn trọng prefers-reduced-motion. Một website xuống cấp nhẹ nhàng vẫn hơn một website giật lag cho một phần ba người xem.
Tra cứu nhanh: dùng gì khi gặp gì
| Triệu chứng | Cách sửa đầu tiên | API |
|---|---|---|
| Draw call cao | Instance / batch mesh lặp lại | InstancedMesh, BatchedMesh |
| Giật trên Retina, mượt trên desktop | Giới hạn pixel ratio | setPixelRatio(min(dpr, 2)) |
| Chậm cả khi đứng yên | Render theo nhu cầu | frameloop="demand", invalidate() |
| Tải nặng / VRAM lớn | Nén tài nguyên | KTX2, Draco, gltf-transform |
| Bộ nhớ tăng dần | Dispose khi unmount | .dispose() cho geo/mat/tex |
| Giới hạn fill-rate (post-fx) | Hạ DPR, đơn giản shader | tính ở vertex thay vì fragment |
Liên hệ với Core Web Vitals
Một file .glb nặng chặn main thread và kéo tụt LCP, INP. Hãy lazy-load canvas khi nằm dưới màn hình đầu, giải mã model ngoài critical path, và đừng để WebGL chặn lần vẽ đầu tiên. Tôi đào sâu phần chỉ số trong Core Web Vitals cho web nhiều chuyển động — hiệu năng 3D và Vitals là cùng một bài toán nhìn từ hai góc. Nếu muốn làm chuẩn trên một sản phẩm thật, xem cách tôi dựng web 3D hoặc trao đổi về dự án.
FAQ
Bao nhiêu draw call là quá nhiều trong Three.js?
Không có ngưỡng cứng, nhưng trên mobile tầm trung, cảnh bắt đầu đuối khi vượt vài trăm draw call. Hãy nhắm dưới ~100–150 để có 60 FPS thoải mái. Dùng renderer.info.render.calls để đo, rồi instance hoặc batch những thủ phạm lớn nhất.
Giảm số tam giác có sửa được cảnh Three.js chậm không?
Thường là không phải việc đầu tiên. Đa số cảnh chậm bị giới hạn bởi draw call, fill-rate (pixel ratio), hoặc frame lãng phí — không phải số tam giác thô. Hãy profile bằng renderer.info trước khi giảm geometry; bạn thường phát hiện một texture hoặc pixel ratio không giới hạn mới là thủ phạm thật.
React Three Fiber có chậm hơn Three.js thuần không?
Không — R3F chỉ là lớp reconciler mỏng trên Three.js với overhead không đáng kể, và nó cho bạn frameloop="demand", tự dispose, cùng các helper drei giúp việc tối ưu ở trên dễ hơn. Trần hiệu năng là như nhau; bạn đạt tới đó với ít code hơn.
Khác nhau giữa InstancedMesh và BatchedMesh là gì?
InstancedMesh render nhiều bản sao của một geometry với một material trong một draw call. BatchedMesh (r159+) render nhiều geometry khác nhau dùng chung material trong một multi-draw call. Dùng instancing cho vật thể giống hệt, batching cho vật thể khác nhau nhưng chung material.