paint-brush
Di chuyển từ WebGL sang WebGPUtừ tác giả@dmitrii
5,869 lượt đọc
5,869 lượt đọc

Di chuyển từ WebGL sang WebGPU

từ tác giả Dmitrii Ivashchenko13m2023/12/20
Read on Terminal Reader

dài quá đọc không nổi

Hướng dẫn này làm sáng tỏ quá trình chuyển đổi từ WebGL sang WebGPU, bao gồm những điểm khác biệt chính, khái niệm cấp cao và mẹo thực tế. Khi WebGPU nổi lên như tương lai của đồ họa web, bài viết này cung cấp những hiểu biết sâu sắc vô giá cho các kỹ sư phần mềm cũng như người quản lý dự án.
featured image - Di chuyển từ WebGL sang WebGPU
Dmitrii Ivashchenko HackerNoon profile picture

Việc chuyển sang WebGPU sắp tới không chỉ có nghĩa là chuyển đổi các API đồ họa. Đây cũng là một bước tiến tới tương lai của đồ họa web. Nhưng quá trình di chuyển này sẽ diễn ra tốt hơn nếu có sự chuẩn bị và hiểu biết — và bài viết này sẽ giúp bạn sẵn sàng.


Xin chào mọi người, tên tôi là Dmitrii Ivashchenko và tôi là kỹ sư phần mềm tại MY.GAMES. Trong bài viết này, chúng tôi sẽ thảo luận về sự khác biệt giữa WebGL và WebGPU sắp ra mắt, đồng thời chúng tôi sẽ trình bày cách chuẩn bị cho dự án của bạn để di chuyển.


Tổng quan về nội dung

  1. Dòng thời gian của WebGL và WebGPU

  2. Trạng thái hiện tại của WebGPU và những gì sắp xảy ra

  3. Sự khác biệt về khái niệm cấp cao

  4. Khởi tạo

    • WebGL: Mô hình ngữ cảnh

    • WebGPU: Model thiết bị

  5. Chương trình và quy trình

    • WebGL: Chương trình

    • WebGPU: Đường dẫn

  6. Đồng phục

    • Đồng phục trong WebGL 1

    • Đồng phục trong WebGL 2

    • Đồng phục trong WebGPU

  7. Trình đổ bóng

    • Ngôn ngữ đổ bóng: GLSL và WGSL

    • So sánh các kiểu dữ liệu

    • Cấu trúc

    • Khai báo hàm

    • Chức năng tích hợp sẵn

    • Chuyển đổi Shader

  8. Sự khác biệt về quy ước

  9. Kết cấu

    • Không gian khung nhìn

    • Khoảng trống Clip

  10. Mẹo & thủ thuật WebGPU

    • Giảm thiểu số lượng đường ống bạn sử dụng.

    • Tạo trước các đường dẫn

    • Sử dụng RenderBundle

  11. Bản tóm tắt


Dòng thời gian của WebGL và WebGPU

WebGL , giống như nhiều công nghệ web khác, có nguồn gốc từ rất lâu về trước. Để hiểu động lực và động lực đằng sau việc chuyển sang WebGPU, trước tiên hãy xem nhanh lịch sử phát triển WebGL:


  • Máy tính để bàn OpenGL (1993) Phiên bản máy tính để bàn của OpenGL ra mắt.
  • WebGL 1.0 (2011) : Đây là bản phát hành ổn định đầu tiên của WebGL, dựa trên OpenGL ES 2.0, được giới thiệu vào năm 2007. Nó cung cấp cho các nhà phát triển web khả năng sử dụng đồ họa 3D trực tiếp trong trình duyệt mà không cần bổ sung thêm.
  • WebGL 2.0 (2017) : Được giới thiệu sáu năm sau phiên bản đầu tiên, WebGL 2.0 dựa trên OpenGL ES 3.0 (2012). Phiên bản này mang theo một số cải tiến và khả năng mới, giúp đồ họa 3D trên web trở nên mạnh mẽ hơn.


Trong những năm gần đây, mối quan tâm ngày càng tăng đối với các API đồ họa mới giúp cung cấp cho nhà phát triển khả năng kiểm soát và tính linh hoạt cao hơn:


  • Vulkan (2016) : Được tạo bởi nhóm Khronos, API đa nền tảng này là "người kế thừa" cho OpenGL. Vulkan cung cấp quyền truy cập cấp thấp hơn vào tài nguyên phần cứng đồ họa, cho phép các ứng dụng hiệu suất cao kiểm soát phần cứng đồ họa tốt hơn.
  • D3D12 (2015) : API này được Microsoft tạo ra và dành riêng cho Windows và Xbox. D3D12 là phiên bản kế thừa của D3D10/11 và cung cấp cho các nhà phát triển khả năng kiểm soát sâu hơn các tài nguyên đồ họa.
  • Metal (2014) : Được tạo bởi Apple, Metal là API độc quyền cho các thiết bị của Apple. Nó được thiết kế với mục tiêu mang lại hiệu suất tối đa trên phần cứng của Apple.


Trạng thái hiện tại của WebGPU và những gì sắp xảy ra

Ngày nay, WebGPU có sẵn trên nhiều nền tảng như Windows, Mac và ChromeOS thông qua trình duyệt Google Chrome và Microsoft Edge, bắt đầu từ phiên bản 113. Dự kiến sẽ có hỗ trợ cho Linux và Android trong tương lai gần.


Dưới đây là một số công cụ đã hỗ trợ (hoặc cung cấp hỗ trợ thử nghiệm) cho WebGPU:


  • Babylon JS : Hỗ trợ đầy đủ cho WebGPU.
  • ThreeJS : Hỗ trợ thử nghiệm vào lúc này.
  • PlayCanvas : Đang phát triển nhưng có triển vọng rất hứa hẹn.
  • Unity : Hỗ trợ WebGPU thử nghiệm và rất sớm đã được công bố trong phiên bản 2023.2 alpha.
  • Cocos Creator 3.6.2 : Chính thức hỗ trợ WebGPU, trở thành một trong những công ty tiên phong trong lĩnh vực này.
  • Cấu trúc : hiện chỉ được hỗ trợ trong phiên bản v113+ dành cho Windows, macOS và ChromeOS.



Xét đến điều này, việc chuyển đổi sang WebGPU hoặc ít nhất là chuẩn bị các dự án cho quá trình chuyển đổi như vậy dường như là một bước đi kịp thời trong tương lai gần.


Sự khác biệt về khái niệm cấp cao

Hãy thu nhỏ và xem xét một số khác biệt về mặt khái niệm cấp cao giữa WebGL và WebGPU, bắt đầu từ việc khởi tạo.

Khởi tạo

Khi bắt đầu làm việc với API đồ họa, một trong những bước đầu tiên là khởi tạo đối tượng chính để tương tác. Quá trình này khác nhau giữa WebGL và WebGPU, với một số điểm đặc biệt đối với cả hai hệ thống.

WebGL: Mô hình bối cảnh

Trong WebGL, đối tượng này được gọi là "ngữ cảnh", về cơ bản đại diện cho một giao diện để vẽ trên phần tử canvas HTML5. Có được bối cảnh này khá đơn giản:

 const gl = canvas.getContext('webgl');


Ngữ cảnh của WebGL thực sự được gắn với một canvas cụ thể. Điều này có nghĩa là nếu bạn cần hiển thị trên nhiều khung vẽ, bạn sẽ cần nhiều ngữ cảnh.

WebGPU: Mẫu thiết bị

WebGPU giới thiệu một khái niệm mới gọi là "thiết bị". Thiết bị này đại diện cho sự trừu tượng hóa GPU mà bạn sẽ tương tác. Quá trình khởi tạo phức tạp hơn một chút so với WebGL, nhưng nó mang lại sự linh hoạt hơn:

 const adapter = await navigator.gpu.requestAdapter(); const device = await adapter.requestDevice(); const context = canvas.getContext('webgpu'); context.configure({ device, format: 'bgra8unorm', });


Một trong những ưu điểm của mô hình này là một thiết bị có thể hiển thị trên nhiều khung vẽ hoặc thậm chí không hiển thị trên nhiều khung vẽ. Điều này mang lại sự linh hoạt bổ sung; ví dụ: một thiết bị có thể kiểm soát việc hiển thị trong nhiều cửa sổ hoặc ngữ cảnh.



Chương trình và quy trình

WebGL và WebGPU thể hiện các cách tiếp cận khác nhau để quản lý và tổ chức quy trình đồ họa.

WebGL: Chương trình

Trong WebGL, trọng tâm chính là chương trình đổ bóng. Chương trình kết hợp các trình đổ bóng đỉnh và đoạn, xác định cách chuyển đổi các đỉnh và cách tô màu từng pixel.

 const program = gl.createProgram(); gl.attachShader(program, vertShader); gl.attachShader(program, fragShader); gl.bindAttribLocation(program, 'position', 0); gl.linkProgram(program);


Các bước tạo chương trình trong WebGL:


  1. Tạo Shader : Mã nguồn của shader được viết và biên dịch.
  2. Tạo chương trình : Các shader đã biên dịch được đính kèm vào chương trình và sau đó được liên kết.
  3. Sử dụng Chương trình : Chương trình được kích hoạt trước khi kết xuất.
  4. Truyền dữ liệu : Dữ liệu được truyền đến chương trình được kích hoạt.


Quá trình này cho phép điều khiển đồ họa linh hoạt, nhưng cũng có thể phức tạp và dễ xảy ra lỗi, đặc biệt đối với các dự án lớn và phức tạp.

WebGPU: Đường ống

WebGPU giới thiệu khái niệm "đường ống" thay vì một chương trình riêng biệt. Đường dẫn này không chỉ kết hợp các trình đổ bóng mà còn các thông tin khác, trong WebGL, được thiết lập dưới dạng trạng thái. Vì vậy, việc tạo một đường dẫn trong WebGPU có vẻ phức tạp hơn:

 const pipeline = device.createRenderPipeline({ layout: 'auto', vertex: { module: shaderModule, entryPoint: 'vertexMain', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }], }, fragment: { module: shaderModule, entryPoint: 'fragmentMain', targets: [{ format, }], }, });


Các bước để tạo quy trình trong WebGPU:


  1. Định nghĩa trình đổ bóng : Mã nguồn trình đổ bóng được viết và biên dịch, tương tự như cách thực hiện trong WebGL.
  2. Tạo đường ống : Trình đổ bóng và các tham số kết xuất khác được kết hợp thành một đường ống.
  3. Sử dụng đường ống : Đường ống được kích hoạt trước khi kết xuất.


Trong khi WebGL tách biệt từng khía cạnh của kết xuất, WebGPU cố gắng gói gọn nhiều khía cạnh hơn vào một đối tượng duy nhất, làm cho hệ thống trở nên mô-đun và linh hoạt hơn. Thay vì quản lý riêng các trình đổ bóng và trạng thái kết xuất, như được thực hiện trong WebGL, WebGPU kết hợp mọi thứ vào một đối tượng đường dẫn. Điều này làm cho quá trình dễ dự đoán hơn và ít xảy ra lỗi hơn:



Đồng phục

Các biến thống nhất cung cấp dữ liệu không đổi có sẵn cho tất cả các phiên bản đổ bóng.

Đồng phục trong WebGL 1

Trong WebGL cơ bản, chúng tôi có khả năng đặt các biến uniform trực tiếp thông qua lệnh gọi API.


GLSL :

 uniform vec3 u_LightPos; uniform vec3 u_LightDir; uniform vec3 u_LightColor;


JavaScript :

 const location = gl.getUniformLocation(p, "u_LightPos"); gl.uniform3fv(location, [100, 300, 500]);


Phương pháp này đơn giản nhưng yêu cầu nhiều lệnh gọi API cho mỗi biến uniform .

Đồng phục trong WebGL 2

Với sự xuất hiện của WebGL 2, giờ đây chúng ta có khả năng nhóm các biến uniform vào bộ đệm. Mặc dù bạn vẫn có thể sử dụng các bộ đổ bóng đồng nhất riêng biệt, một lựa chọn tốt hơn là nhóm các bộ đồng phục khác nhau thành một cấu trúc lớn hơn bằng cách sử dụng bộ đệm đồng nhất. Sau đó, bạn gửi tất cả dữ liệu thống nhất này đến GPU cùng một lúc, tương tự như cách bạn có thể tải bộ đệm đỉnh trong WebGL 1. Điều này có một số lợi thế về hiệu suất, chẳng hạn như giảm lệnh gọi API và gần hơn với cách hoạt động của GPU hiện đại.


GLSL :

 layout(std140) uniform ub_Params { vec4 u_LightPos; vec4 u_LightDir; vec4 u_LightColor; };


JavaScript :

 gl.bindBufferBase(gl.UNIFORM_BUFFER, 1, gl.createBuffer());


Để liên kết các tập hợp con của bộ đệm đồng nhất lớn trong WebGL 2, bạn có thể sử dụng lệnh gọi API đặc biệt được gọi là bindBufferRange . Trong WebGPU, có một thứ tương tự được gọi là độ lệch bộ đệm đồng nhất động, trong đó bạn có thể chuyển danh sách độ lệch khi gọi API setBindGroup .



Đồng phục trong WebGPU

WebGPU cung cấp cho chúng tôi một phương pháp thậm chí còn tốt hơn. Trong bối cảnh này, các biến uniform riêng lẻ không còn được hỗ trợ và công việc được thực hiện độc quyền thông qua bộ đệm uniform .


WGSL :

 [[block]] struct Params { u_LightPos : vec4<f32>; u_LightColor : vec4<f32>; u_LightDirection : vec4<f32>; }; [[group(0), binding(0)]] var<uniform> ub_Params : Params;


JavaScript :

 const buffer = device.createBuffer({ usage: GPUBufferUsage.UNIFORM, size: 8 });


GPU hiện đại thích dữ liệu được tải trong một khối lớn hơn là nhiều khối nhỏ. Thay vì tạo lại và đóng lại các bộ đệm nhỏ mỗi lần, hãy cân nhắc việc tạo một bộ đệm lớn và sử dụng các phần khác nhau của bộ đệm đó cho các lệnh rút thăm khác nhau. Cách tiếp cận này có thể làm tăng đáng kể hiệu suất.


WebGL bắt buộc hơn, đặt lại trạng thái chung với mỗi lệnh gọi và cố gắng đơn giản nhất có thể. Mặt khác, WebGPU hướng tới mục tiêu hướng đối tượng hơn và tập trung vào việc tái sử dụng tài nguyên, điều này dẫn đến hiệu quả.


Việc chuyển đổi từ WebGL sang WebGPU có vẻ khó khăn do sự khác biệt về phương pháp. Tuy nhiên, bắt đầu bằng việc chuyển đổi sang WebGL 2 như một bước trung gian có thể đơn giản hóa cuộc sống của bạn.



Trình đổ bóng

Việc di chuyển từ WebGL sang WebGPU yêu cầu những thay đổi không chỉ về API mà còn cả về trình đổ bóng. Thông số kỹ thuật WGSL được thiết kế để giúp quá trình chuyển đổi này diễn ra suôn sẻ và trực quan, đồng thời duy trì hiệu suất và hiệu suất cho các GPU hiện đại.

Ngôn ngữ đổ bóng: GLSL và WGSL

WGSL được thiết kế để trở thành cầu nối giữa WebGPU và API đồ họa gốc. So với GLSL, WGSL có vẻ dài dòng hơn một chút nhưng cấu trúc vẫn quen thuộc.


Đây là một ví dụ về đổ bóng cho kết cấu:


GLSL :

 sampler2D myTexture; varying vec2 vTexCoord; void main() { return texture(myTexture, vTexCoord); }


WGSL :

 [[group(0), binding(0)]] var mySampler: sampler; [[group(0), binding(1)]] var myTexture: texture_2d<f32>; [[stage(fragment)]] fn main([[location(0)]] vTexCoord: vec2<f32>) -> [[location(0)]] vec4<f32> { return textureSample(myTexture, mySampler, vTexCoord); } 



So sánh các kiểu dữ liệu

Bảng dưới đây so sánh các kiểu dữ liệu cơ bản và ma trận trong GLSL và WGSL:



Việc chuyển đổi từ GLSL sang WGSL thể hiện mong muốn gõ chặt chẽ hơn và xác định rõ ràng về kích thước dữ liệu, điều này có thể cải thiện khả năng đọc mã và giảm khả năng xảy ra lỗi.



cấu trúc

Cú pháp khai báo cấu trúc cũng đã thay đổi:


GLSL:

 struct Light { vec3 position; vec4 color; float attenuation; vec3 direction; float innerAngle; float angle; float range; };


WGSL:

 struct Light { position: vec3<f32>, color: vec4<f32>, attenuation: f32, direction: vec3<f32>, innerAngle: f32, angle: f32, range: f32, };


Việc giới thiệu cú pháp rõ ràng để khai báo các trường trong cấu trúc WGSL nhấn mạnh mong muốn rõ ràng hơn và đơn giản hóa việc hiểu cấu trúc dữ liệu trong trình đổ bóng.



Khai báo hàm

GLSL :

 float saturate(float x) { return clamp(x, 0.0, 1.0); }


WGSL :

 fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }


Việc thay đổi cú pháp của các hàm trong WGSL phản ánh sự thống nhất trong cách tiếp cận khai báo và trả về giá trị, làm cho mã trở nên nhất quán và dễ đoán hơn.



Chức năng tích hợp sẵn

Trong WGSL, nhiều hàm GLSL tích hợp đã được đổi tên hoặc thay thế. Ví dụ:



Việc đổi tên các hàm tích hợp trong WGSL không chỉ đơn giản hóa tên của chúng mà còn làm cho chúng trực quan hơn, điều này có thể tạo điều kiện thuận lợi cho quá trình chuyển đổi của các nhà phát triển quen thuộc với các API đồ họa khác.



Chuyển đổi đổ bóng

Đối với những người đang dự định chuyển đổi dự án của mình từ WebGL sang WebGPU, điều quan trọng cần biết là có các công cụ giúp tự động chuyển đổi GLSL sang WGSL, chẳng hạn như **[Naga](https://github.com/gfx-rs/naga /)**, là thư viện Rust để chuyển đổi GLSL sang WGSL. Nó thậm chí có thể hoạt động ngay trong trình duyệt của bạn với sự trợ giúp của WebAssugging.


Dưới đây là các điểm cuối được Naga hỗ trợ:



Sự khác biệt về quy ước

Kết cấu

Sau khi di chuyển, bạn có thể gặp bất ngờ ở dạng hình ảnh bị lật. Những người đã từng chuyển ứng dụng từ OpenGL sang Direct3D (hoặc ngược lại) đều đã gặp phải vấn đề kinh điển này.


Trong ngữ cảnh OpenGL và WebGL, họa tiết thường được tải theo cách sao cho pixel bắt đầu tương ứng với góc dưới cùng bên trái. Tuy nhiên, trên thực tế, nhiều nhà phát triển tải hình ảnh bắt đầu từ góc trên cùng bên trái, dẫn đến lỗi lật ảnh. Tuy nhiên, lỗi này có thể được bù đắp bằng các yếu tố khác, cuối cùng giải quyết được vấn đề.



Không giống như OpenGL, các hệ thống như Direct3D và Metal theo truyền thống sử dụng góc trên bên trái làm điểm bắt đầu cho họa tiết. Cho rằng cách tiếp cận này có vẻ trực quan nhất đối với nhiều nhà phát triển, những người tạo ra WebGPU đã quyết định làm theo phương pháp này.

Không gian khung nhìn

Nếu mã WebGL của bạn chọn pixel từ bộ đệm khung, hãy chuẩn bị cho thực tế là WebGPU sử dụng hệ tọa độ khác. Bạn có thể cần áp dụng thao tác "y = 1,0 - y" đơn giản để sửa tọa độ.



Clip không gian

Khi nhà phát triển gặp phải vấn đề trong đó các đối tượng bị cắt bớt hoặc biến mất sớm hơn dự kiến, điều này thường liên quan đến sự khác biệt trong miền độ sâu. Có sự khác biệt giữa WebGL và WebGPU trong cách chúng xác định phạm vi độ sâu của không gian clip. Trong khi WebGL sử dụng phạm vi từ -1 đến 1 thì WebGPU sử dụng phạm vi từ 0 đến 1, tương tự như các API đồ họa khác như Direct3D, Metal và Vulkan. Quyết định này được đưa ra do một số lợi ích của việc sử dụng phạm vi từ 0 đến 1 đã được xác định khi làm việc với các API đồ họa khác.



Trách nhiệm chính trong việc chuyển đổi vị trí mô hình của bạn thành không gian clip nằm ở ma trận chiếu. Cách đơn giản nhất để điều chỉnh mã của bạn là đảm bảo rằng kết quả đầu ra ma trận chiếu của bạn nằm trong phạm vi từ 0 đến 1. Đối với những người sử dụng các thư viện như gl-matrix, có một giải pháp đơn giản: thay vì sử dụng hàm phối perspective , bạn có thể sử dụng perspectiveZO ; các chức năng tương tự có sẵn cho các hoạt động ma trận khác.

 if (webGPU) { // Creates a matrix for a symetric perspective-view frustum // using left-handed coordinates mat4.perspectiveZO(out, Math.PI / 4, ...); } else { // Creates a matrix for a symetric perspective-view frustum // based on the default handedness and default near // and far clip planes definition. mat4.perspective(out, Math.PI / 4, …); }


Tuy nhiên, đôi khi bạn có thể có ma trận chiếu hiện có và bạn không thể thay đổi nguồn của nó. Trong trường hợp này, để chuyển đổi nó thành một phạm vi từ 0 đến 1, bạn có thể nhân trước ma trận chiếu của mình với một ma trận khác để điều chỉnh phạm vi độ sâu.



Mẹo & thủ thuật WebGPU

Bây giờ, hãy thảo luận về một số mẹo và thủ thuật để làm việc với WebGPU.

Giảm thiểu số lượng đường ống bạn sử dụng.

Bạn càng sử dụng nhiều đường dẫn, bạn càng có nhiều chuyển đổi trạng thái và hiệu suất càng thấp; điều này có thể không tầm thường, tùy thuộc vào nguồn tài sản của bạn đến từ đâu.

Tạo đường ống trước

Tạo một quy trình và sử dụng nó ngay lập tức có thể hiệu quả, nhưng điều này không được khuyến khích. Thay vào đó, hãy tạo các hàm trả về ngay lập tức và bắt đầu làm việc trên một luồng khác. Khi bạn sử dụng quy trình, hàng thực thi cần đợi quá trình tạo quy trình đang chờ xử lý hoàn tất. Điều này có thể dẫn đến các vấn đề hiệu suất đáng kể. Để tránh điều này, hãy đảm bảo dành chút thời gian từ lúc tạo quy trình đến lần sử dụng đầu tiên.


Hoặc tốt hơn nữa, hãy sử dụng các biến thể create*PipelineAsync ! Lời hứa sẽ được giải quyết khi đường dẫn đã sẵn sàng để sử dụng mà không bị đình trệ.

 device.createComputePipelineAsync({ compute: { module: shaderModule, entryPoint: 'computeMain' } }).then((pipeline) => { const commandEncoder = device.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); passEncoder.setPipeline(pipeline); passEncoder.setBindGroup(0, bindGroup); passEncoder.dispatchWorkgroups(128); passEncoder.end(); device.queue.submit([commandEncoder.finish()]); });

Sử dụng RenderBundle

Gói kết xuất là các lượt kết xuất được ghi trước, một phần, có thể tái sử dụng. Chúng có thể chứa hầu hết các lệnh kết xuất (ngoại trừ những thứ như thiết lập khung nhìn) và có thể được "phát lại" như một phần của quá trình kết xuất thực tế sau này.


 const renderPass = encoder.beginRenderPass(descriptor); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.executeBundles([renderBundle]); renderPass.setPipeline(renderPipeline); renderPass.draw(3); renderPass.end();


Các gói kết xuất có thể được thực thi cùng với các lệnh kết xuất thông thường. Trạng thái kết xuất được đặt lại về mặc định trước và sau mỗi lần thực thi gói. Điều này chủ yếu được thực hiện để giảm chi phí vẽ bằng JavaScript. Hiệu suất GPU vẫn giữ nguyên bất kể cách tiếp cận nào.

Bản tóm tắt

Việc chuyển đổi sang WebGPU không chỉ có nghĩa là chuyển đổi các API đồ họa. Đây cũng là một bước hướng tới tương lai của đồ họa web, kết hợp các tính năng và thực tiễn thành công từ nhiều API đồ họa khác nhau. Việc di chuyển này đòi hỏi sự hiểu biết thấu đáo về những thay đổi về mặt kỹ thuật và triết học, nhưng lợi ích thì rất đáng kể.

Tài nguyên & Liên kết hữu ích: