Hiệu suất WebGL và Three.js

Table of Contents
Khi phát triển các ứng dụng 3D phức tạp cho web, hiệu suất thường là rào cản quan trọng nhất để đạt được thành công. WebGL cung cấp API nền tảng để kết xuất đồ họa 3D tăng tốc phần cứng trong trình duyệt, trong khi Three.js đóng vai trò là lớp trừu tượng cấp cao hơn giúp đơn giản hóa việc tạo cảnh 3D. Mặc dù Three.js mang lại nhiều tiện lợi, việc đạt được hiệu suất tối ưu đòi hỏi sự hiểu biết sâu sắc về quy trình kết xuất cơ bản, kiến trúc GPU và cách cấu trúc các lệnh gọi WebGL một cách hiệu quả.
Trong bài viết chuyên sâu này, chúng ta sẽ khám phá các kỹ thuật nâng cao để tối ưu hóa hiệu suất WebGL và Three.js, bao gồm các chủ đề như giảm lệnh gọi vẽ (draw call), tối ưu hóa hình học, độ phức tạp của shader, quản lý bộ nhớ và các chiến lược kết xuất.
Chi phí của Draw Call
Một trong những nút thắt cổ chai đáng kể nhất trong hiệu suất WebGL là số lượng draw call. Một draw call xảy ra khi CPU gửi lệnh đến GPU để kết xuất một lô hình học cụ thể với một trạng thái cụ thể (shader, texture, uniform). Giao tiếp CPU-GPU này có chi phí cao.
Kết xuất Instance (Instanced Rendering)
Khi kết xuất nhiều đối tượng giống hệt nhau với các phép biến đổi hoặc vật liệu khác nhau (ví dụ: một rừng cây, một đám đông nhân vật hoặc một hệ thống hạt), kết xuất instance là cách hiệu quả nhất để giảm draw call. Three.js cung cấp InstancedMesh cho mục đích này.
Thay vì tạo hàng trăm hoặc hàng nghìn đối tượng Mesh riêng lẻ, một InstancedMesh cho phép bạn kết xuất nhiều instance của một hình học duy nhất với một vật liệu duy nhất, chỉ sử dụng một draw call. Ma trận biến đổi cho mỗi instance được lưu trữ trong một bộ đệm thuộc tính instance, mà vertex shader sử dụng để định vị từng instance một cách chính xác.
const geometry = new THREE.BoxGeometry( 1, 1, 1 );
const material = new THREE.MeshStandardMaterial( { color: 0xff0000 } );
const count = 10000;
const instancedMesh = new THREE.InstancedMesh( geometry, material, count );
const dummy = new THREE.Object3D();
for ( let i = 0; i < count; i ++ ) {
dummy.position.set( Math.random() * 100, Math.random() * 100, Math.random() * 100 );
dummy.updateMatrix();
instancedMesh.setMatrixAt( i, dummy.matrix );
}
scene.add( instancedMesh );
Hợp nhất Hình học (Geometry Merging)
Nếu bạn có nhiều đối tượng tĩnh chia sẻ cùng một vật liệu nhưng không giống hệt nhau (hoặc nếu instancing không phù hợp), việc hợp nhất hình học của chúng thành một hình học lớn duy nhất cũng có thể giảm draw call. Tiện ích BufferGeometryUtils.mergeBufferGeometries trong Three.js có thể kết hợp nhiều hình học thành một.
Tuy nhiên, việc hợp nhất đi kèm với một sự đánh đổi: bạn mất khả năng loại bỏ hoặc biến đổi các đối tượng con riêng lẻ một cách độc lập và hiệu quả. Frustum culling sẽ áp dụng cho toàn bộ hộp giới hạn đã hợp nhất, có khả năng khiến GPU xử lý các đỉnh không hiển thị.
Tối ưu hóa Hình học và Bộ đệm
Lượng dữ liệu đỉnh được gửi đến GPU và cách nó được cấu trúc ảnh hưởng trực tiếp đến cả băng thông bộ nhớ và thời gian xử lý của vertex shader.
Thuộc tính Đỉnh và Xen kẽ (Interleaving)
Trong WebGL, dữ liệu đỉnh thường được lưu trữ trong ArrayBuffers (VBOs). Theo mặc định, Three.js sử dụng các thuộc tính không xen kẽ, nghĩa là vị trí, pháp tuyến và UV được lưu trữ trong các bộ đệm riêng biệt. Xen kẽ các thuộc tính này vào một bộ đệm duy nhất có thể cải thiện tính cục bộ của bộ đệm bộ nhớ trên GPU, dẫn đến việc tìm nạp đỉnh nhanh hơn.
Độ chính xác và Kiểu dữ liệu
Không phải tất cả các thuộc tính đỉnh đều yêu cầu độ chính xác dấu phẩy động 32-bit. Ví dụ, màu sắc, pháp tuyến và tọa độ UV thường có thể được đóng gói vào các định dạng số nguyên 16-bit hoặc 8-bit bằng cách sử dụng các thuộc tính được chuẩn hóa (gl.vertexAttribPointer với normalized = true). Điều này làm giảm dung lượng bộ nhớ và yêu cầu băng thông. Three.js hỗ trợ các định dạng này thông qua các mảng kiểu như Uint16Array hoặc Int8Array khi tạo BufferAttribute.
Độ phức tạp của Shader và Tốc độ điền (Fill Rate)
Fragment shader được thực thi cho mỗi pixel (hoặc fragment) mà một primitive được raster hóa bao phủ. Độ phức tạp của fragment shader cao hoặc kết xuất quá nhiều đối tượng trong suốt chồng chéo (overdraw) có thể ảnh hưởng nghiêm trọng đến tốc độ điền và tốc độ khung hình.
Tối ưu hóa Shader
- Tránh phân nhánh: Các câu lệnh điều kiện (
if/else) trong shader có thể gây ra sự phân kỳ warp trên kiến trúc GPU, làm giảm tính song song. Sử dụng các hàm toán học nhưstep,smoothstep, hoặcmixđể nội suy giữa các giá trị thay vì phân nhánh. - Bộ định tính độ chính xác: Sử dụng bộ định tính độ chính xác
mediumphoặclowptrong GLSL cho các biến không yêu cầu độ chính xác cao (ví dụ: màu sắc hoặc vector chuẩn hóa) để cải thiện hiệu suất, đặc biệt trên GPU di động. - Tính toán trước giá trị: Di chuyển các phép tính từ fragment shader sang vertex shader nếu kết quả có thể được nội suy trên primitive mà không có hiện vật đáng chú ý. Di chuyển các phép tính từ vertex shader sang CPU nếu chúng không đổi cho toàn bộ draw call.
Overdraw và Kiểm tra độ sâu (Depth Testing)
Overdraw xảy ra khi cùng một pixel được ghi nhiều lần trong một khung hình. Các đối tượng mờ đục lý tưởng nên được vẽ theo thứ tự từ trước ra sau để tận dụng kiểm tra độ sâu sớm (Early-Z culling), loại bỏ các fragment nằm phía sau hình học đã được kết xuất trước khi thực thi fragment shader. Three.js cố gắng sắp xếp các đối tượng mờ đục từ trước ra sau theo mặc định.
Đối với các đối tượng trong suốt, việc sắp xếp từ sau ra trước là bắt buộc để pha trộn chính xác, điều này vốn dĩ gây ra overdraw. Giảm thiểu diện tích màn hình được bao phủ bởi các đối tượng trong suốt chồng chéo (ví dụ: sử dụng các đa giác giới hạn chặt chẽ hơn cho các sprite hạt thay vì các hình vuông lớn với hầu hết không gian trống).
Bộ nhớ và Băng thông Texture
Texture tiêu thụ một phần đáng kể bộ nhớ và băng thông GPU. Sử dụng texture nén là rất quan trọng đối với các ứng dụng quy mô lớn.
Texture nén
Thay vì tải các định dạng hình ảnh tiêu chuẩn như PNG hoặc JPEG, hãy sử dụng các định dạng texture nén như KTX2 (với nén Basis Universal). Các định dạng này vẫn được nén trong bộ nhớ GPU, giảm đáng kể việc sử dụng VRAM và băng thông bộ nhớ trong quá trình lấy mẫu. Three.js cung cấp KTX2Loader để tải các texture này một cách hiệu quả.
Mipmapping và Lọc
Luôn tạo mipmap cho các texture sẽ được xem ở khoảng cách xa. Mipmapping cải thiện tính nhất quán của bộ đệm texture và giảm các hiện vật răng cưa. Sử dụng minFilter = THREE.LinearMipmapLinearFilter (lọc trilinear) hoặc THREE.LinearMipmapNearestFilter (lọc bilinear). Tránh lọc anisotropic trừ khi cần thiết cho các bề mặt được xem ở các góc dốc, vì nó tốn kém về mặt tính toán.
Các chiến lược kết xuất nâng cao
Mức độ chi tiết (LOD)
Đối tượng THREE.LOD cho phép bạn thay thế các lưới có độ chi tiết cao bằng các lưới đơn giản hơn khi camera di chuyển ra xa hơn. Điều này làm giảm số lượng đỉnh và khối lượng công việc rasterization cho các đối tượng ở xa.
Offscreen Canvas và Web Worker
JavaScript là đơn luồng, và các phép tính CPU nặng (ví dụ: vật lý, tìm đường, hoặc cập nhật biểu đồ cảnh phức tạp) có thể chặn luồng chính và gây ra hiện tượng giật hình. Kết xuất WebGL có thể được chuyển sang một Web Worker bằng cách sử dụng OffscreenCanvas. Điều này cho phép luồng chính xử lý UI và đầu vào trong khi worker xử lý kết xuất 3D và các phép tính.
Chi phí xử lý hậu kỳ (Post-Processing Overhead)
Các hiệu ứng xử lý hậu kỳ (như bloom, độ sâu trường ảnh, hoặc screen-space ambient occlusion) yêu cầu kết xuất cảnh vào một bộ đệm khung hình ngoài màn hình, sau đó là một hoặc nhiều lần truyền toàn màn hình. Điều này cực kỳ tốn kém về tốc độ điền. Khi sử dụng EffectComposer trong Three.js, hãy cố gắng kết hợp nhiều lần truyền vào một lần truyền shader tùy chỉnh duy nhất để giảm số lượng mục tiêu kết xuất và draw call.
Thu gom rác và Rò rỉ bộ nhớ
Trong JavaScript, việc tạm dừng thu gom rác (GC) có thể gây ra hiện tượng giảm khung hình hoặc giật hình (jank) đáng chú ý trong một ứng dụng kết xuất thời gian thực. Để giảm thiểu điều này, hãy tránh tạo đối tượng động trong vòng lặp kết xuất của bạn. Thay vào đó, hãy cấp phát trước các đối tượng, vector, ma trận và mảng, và sử dụng lại chúng qua các khung hình. Thực hành này, được gọi là gộp đối tượng (object pooling), đảm bảo rằng áp lực bộ nhớ vẫn thấp và ngăn bộ thu gom rác can thiệp trong các giai đoạn kết xuất quan trọng.
Hơn nữa, khi loại bỏ các đối tượng khỏi một cảnh Three.js, hãy nhớ rằng các tài nguyên GPU (bộ đệm hình học, texture, shader) không được tự động giải phóng bởi bộ thu gom rác JavaScript. Bạn phải gọi rõ ràng phương thức .dispose() trên các hình học, vật liệu và texture để giải phóng bộ nhớ WebGL liên quan. Việc không làm như vậy sẽ dẫn đến rò rỉ bộ nhớ mà cuối cùng sẽ làm sập ứng dụng.
Kết luận
Tối ưu hóa các ứng dụng WebGL và Three.js là một quá trình lặp đi lặp lại đòi hỏi phải phân tích và đo lường. Sử dụng các công cụ như trình phân tích hiệu suất của trình duyệt, các tiện ích mở rộng WebGL inspector (ví dụ: Spector.js) và WebGLRenderer.info tích hợp sẵn của Three.js để xác định các nút thắt cổ chai. Bằng cách quản lý cẩn thận các draw call, tối ưu hóa hình học và shader, sử dụng texture nén và áp dụng các chiến lược kết xuất nâng cao, bạn có thể đạt được trải nghiệm 60 FPS (hoặc cao hơn) mượt mà ngay cả trong các cảnh 3D phức tạp.
Bạn cũng có thể thích
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

Tích hợp WebGL với Next.js: Hướng dẫn toàn diện
Tích hợp đồ họa WebGL vào Next.js một cách liền mạch: thiết lập canvas Three.js, tối ưu hóa React Three Fiber, an toàn hydration SSR và hiển thị 60 FPS.
Read more
Điện toán lượng tử cho nhà phát triển
Hướng dẫn dành cho nhà phát triển về điện toán lượng tử: viết thuật toán lượng tử với Qiskit, hiểu các cổng lượng tử và mô phỏng mạch trên phần cứng cổ điển.
Read more
Distributed Tracing với OpenTelemetry
Theo dõi vi dịch vụ bằng OpenTelemetry distributed tracing: theo dõi sự lan truyền ngữ cảnh giữa các dịch vụ, các điểm nghẽn độ trễ và xuất sang Jaeger.
Read more