Giới thiệu
Trước tiên, hãy đọc giới thiệu về Three.js vì đây là nền tảng cho tài liệu này.
Tôi muốn thảo luận về chương trình đổ bóng. WebGL rất tuyệt vời và như tôi đã nói trước đây, Three.js (và các thư viện khác) làm rất tốt việc loại bỏ những khó khăn cho bạn. Nhưng sẽ có những lúc bạn muốn đạt được một hiệu ứng cụ thể hoặc bạn sẽ muốn tìm hiểu sâu hơn một chút về cách những nội dung tuyệt vời đó xuất hiện trên màn hình của bạn và các chương trình đổ bóng gần như chắc chắn sẽ là một phần của phương trình đó. Bạn có thể muốn chuyển từ những thứ cơ bản trong hướng dẫn trước sang những thứ phức tạp hơn một chút.
Tôi sẽ dựa trên giả định rằng bạn đang sử dụng Three.js, vì thư viện này giúp chúng ta rất nhiều trong việc thiết lập chương trình đổ bóng. Trước tiên, bạn sẽ tìm hiểu bối cảnh cho các chương trình đổ bóng, sau đó là các bước nâng cao ở phần sau trong tài liệu này.
1. Hai chương trình đổ bóng của chúng tôi
WebGL không cho phép sử dụng Fixed Pipeline (Đường ống cố định), đây là cách nói ngắn gọn rằng WebGL không cung cấp cho bạn bất kỳ phương tiện nào để kết xuất nội dung của bạn ngay lập tức. Tuy nhiên, nó có cung cấp Programmable Pipeline (Quy trình có thể lập trình). Đây là một quy trình mạnh mẽ hơn nhưng cũng khó hiểu và khó sử dụng hơn. Nói tóm lại, Programmable Pipeline (Quy trình có thể lập trình) có nghĩa là với tư cách là lập trình viên, bạn chịu trách nhiệm hiển thị các đỉnh, v.v. lên màn hình. Chương trình đổ bóng là một phần của quy trình này và có 2 loại chương trình đổ bóng:
- Chương trình đổ bóng đỉnh
- Chương trình đổ bóng mảnh
Tôi chắc chắn bạn sẽ đồng ý rằng cả hai điều này đều không có ý nghĩa gì cả. Điều bạn cần biết về hai loại này là cả hai đều chạy hoàn toàn trên GPU của card đồ hoạ. Điều này có nghĩa là chúng ta muốn giảm tải tất cả những gì có thể cho các lõi này, để CPU có thể làm những việc khác. GPU hiện đại được tối ưu hoá rất nhiều cho các chức năng mà chương trình đổ bóng yêu cầu, vì vậy, bạn nên sử dụng GPU.
2. Chương trình đổ bóng đỉnh
Lấy một hình dạng nguyên thuỷ tiêu chuẩn, chẳng hạn như hình cầu. Nó được tạo thành từ các đỉnh, phải không? Trình đổ bóng đỉnh được cung cấp lần lượt từng đỉnh trong số này và có thể tuỳ ý chỉnh sửa các đỉnh đó. Trình đổ bóng đỉnh có quyền quyết định việc thực hiện với từng đỉnh, nhưng trình đổ bóng đỉnh có một trách nhiệm: tại một thời điểm nào đó, trình đổ bóng đỉnh phải đặt một giá trị gọi là gl_Position, một vectơ số thực 4D, đây là vị trí cuối cùng của đỉnh trên màn hình. Bản thân quy trình này khá thú vị, vì chúng ta đang nói về việc lấy một vị trí 3D (một đỉnh có x, y, z) trên hoặc được chiếu lên màn hình 2D. Rất may là nếu đang dùng một thư viện như Three.js, chúng ta có thể thiết lập gl_Position một cách ngắn gọn mà không cần phải làm gì quá phức tạp.
3. Chương trình đổ bóng mảnh
Vậy là chúng ta đã có đối tượng với các đỉnh của nó và chúng ta đã chiếu các đỉnh đó lên màn hình 2D, nhưng còn màu sắc mà chúng ta sử dụng thì sao? Còn về việc tạo hoạ tiết và ánh sáng thì sao? Đó chính xác là mục đích của chương trình đổ bóng mảnh. Tương tự như chương trình đổ bóng đỉnh, chương trình đổ bóng mảnh cũng chỉ có một việc bắt buộc phải làm: chương trình này phải đặt hoặc loại bỏ biến gl_FragColor, một vectơ dấu phẩy động 4D khác, là màu cuối cùng của mảnh.
Nhưng đoạn mã là gì? Hãy nghĩ đến 3 đỉnh tạo thành một tam giác. Bạn cần vẽ từng pixel trong hình tam giác đó. Một mảnh là dữ liệu do 3 đỉnh đó cung cấp cho mục đích vẽ từng pixel trong tam giác đó. Do đó, các mảnh nhận được các giá trị nội suy từ các đỉnh cấu thành của chúng. Nếu một đỉnh có màu đỏ và đỉnh lân cận có màu xanh dương, chúng ta sẽ thấy các giá trị màu nội suy từ màu đỏ, qua màu tím, đến màu xanh dương.
4. Biến đổ bóng
Bạn có thể khai báo 3 biến: Uniforms, Attributes và Varyings. Những ngôn ngữ này có thể gây nhầm lẫn vì không khớp với bất kỳ ngôn ngữ phổ biến nào khác. Sau đây là cách bạn có thể xem xét các chỉ số này:
Uniform được gửi đến cả chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh, đồng thời chứa các giá trị giữ nguyên trong toàn bộ khung hình đang được kết xuất. Một ví dụ điển hình về điều này có thể là vị trí của đèn.
Thuộc tính là những giá trị được áp dụng cho từng đỉnh. Thuộc tính chỉ có sẵn cho chương trình đổ bóng đỉnh. Ví dụ: mỗi đỉnh có một màu riêng biệt. Các thuộc tính có mối quan hệ một-một với các đỉnh.
Varying là các biến được khai báo trong chương trình đổ bóng đỉnh mà chúng ta muốn chia sẻ với chương trình đổ bóng mảnh. Để làm điều này, chúng ta phải đảm bảo khai báo một biến thay đổi có cùng loại và tên trong cả chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh. Một cách sử dụng cổ điển của việc này là pháp tuyến của đỉnh vì bạn có thể dùng pháp tuyến này trong các phép tính chiếu sáng.
Sau đó, chúng ta sẽ sử dụng cả 3 loại để bạn có thể cảm nhận được cách chúng thực sự được áp dụng.
Không, hãy xem một trong những chương trình đổ bóng đơn giản nhất mà bạn có thể tạo.
5. Bonjourno World
Sau đây là chương trình Hello World của chương trình đổ bóng đỉnh:
/**
* Multiply each vertex by the model-view matrix
* and the projection matrix (both provided by
* Three.js) to get a final vertex position
*/
void main() {
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
và đây là mã tương tự cho chương trình đổ bóng mảnh:
/**
* Set the color to a lovely pink.
* Note that the color is a 4D Float
* Vector, R,G,B and A and each part
* runs from 0.0 to 1.0
*/
void main() {
gl_FragColor = vec4(1.0, 0.0, 1.0, 1.0);
}
Tuy nhiên, không quá phức tạp phải không?
Trong chương trình đổ bóng đỉnh, Three.js sẽ gửi cho chúng ta một vài biến đồng nhất. Hai chương trình đổ bóng này là ma trận 4D, được gọi là Ma trận mô hình-khung hiển thị và Ma trận phép chiếu. Bạn không cần biết chính xác cách hoạt động của những thành phần này, mặc dù tốt nhất là bạn nên hiểu cách mọi thứ hoạt động nếu có thể. Phiên bản ngắn gọn là chúng cho biết vị trí 3D của đỉnh thực sự được chiếu đến vị trí 2D cuối cùng trên màn hình.
Tôi đã loại bỏ các hàm này khỏi đoạn mã ví dụ vì Three.js sẽ tự động thêm các hàm này vào đầu mã chương trình đổ bóng của bạn, nên bạn không cần lo lắng về việc thực hiện. Thành thật mà nói, nó thực sự thêm nhiều thứ hơn thế, chẳng hạn như dữ liệu ánh sáng, màu đỉnh và pháp tuyến đỉnh. Nếu đang thực hiện việc này mà không có Three.js, bạn sẽ phải tự tạo và thiết lập tất cả các thuộc tính và đồng nhất đó. Câu chuyện có thật.
6. Sử dụng MeshShaderMaterial
Được rồi, chúng ta đã thiết lập một chương trình đổ bóng, nhưng làm cách nào để sử dụng chương trình đó với Three.js?
/**
* Assume we have jQuery to hand and pull out
* from the DOM the two snippets of text for
* each of our shaders
*/
var shaderMaterial = new THREE.MeshShaderMaterial({
vertexShader: $('vertexshader').text(),
fragmentShader: $('fragmentshader').text()
});
Từ đó, Three.js sẽ biên dịch và chạy các chương trình đổ bóng được đính kèm vào lưới mà bạn cung cấp cho chất liệu đó. Thực sự không có cách nào dễ dàng hơn thế. Có lẽ là có, nhưng chúng ta đang nói về đồ hoạ 3D chạy trong trình duyệt của bạn, nên tôi cho rằng bạn sẽ mong đợi một mức độ phức tạp nhất định.
Chúng ta có thể thêm 2 thuộc tính khác vào MeshShaderMaterial: uniform và attribute. Cả hai đều có thể lấy vectơ, số nguyên hoặc số thực nhưng như tôi đã đề cập trước đó, các giá trị đồng nhất đều giống nhau cho toàn bộ khung hình hoặc cho tất cả các đỉnh, vì vậy, chúng thường là các giá trị đơn lẻ. Tuy nhiên, các thuộc tính là biến trên mỗi đỉnh, vì vậy, chúng dự kiến sẽ là một mảng. Phải có mối quan hệ một-một giữa số lượng giá trị trong mảng thuộc tính và số lượng đỉnh trong lưới.
7. Các bước tiếp theo
Bây giờ, chúng ta sẽ dành chút thời gian để thêm một vòng lặp ảnh động, các thuộc tính đỉnh và một giá trị đồng nhất. Chúng ta cũng sẽ thêm một biến thay đổi để chương trình đổ bóng đỉnh có thể gửi một số dữ liệu đến chương trình đổ bóng mảnh. Kết quả cuối cùng là quả cầu màu hồng của chúng ta sẽ xuất hiện như thể được chiếu sáng từ trên xuống và từ bên cạnh, đồng thời sẽ rung động. Có thể bạn sẽ cảm thấy hơi khó hiểu, nhưng hy vọng rằng điều này sẽ giúp bạn hiểu rõ 3 loại biến cũng như mối quan hệ giữa chúng và hình học cơ bản.
8. Ánh sáng giả
Bây giờ, hãy cập nhật màu sắc để đối tượng không còn là một đối tượng có màu đơn sắc. Chúng ta có thể xem cách Three.js xử lý ánh sáng, nhưng tôi chắc chắn bạn có thể thấy rằng cách này phức tạp hơn mức cần thiết, vì vậy chúng ta sẽ mô phỏng nó. Bạn nên xem kỹ các chương trình đổ bóng tuyệt vời trong Three.js, cũng như các chương trình đổ bóng trong dự án WebGL tuyệt vời của Chris Milk và Google, Rome. Quay lại với chương trình đổ bóng của chúng ta. Chúng ta sẽ cập nhật Vertex Shader để cung cấp từng đỉnh bình thường cho Fragment Shader. Chúng tôi thực hiện việc này với:
// create a shared variable for the
// VS and FS containing the normal
varying vec3 vNormal;
void main() {
// set the vNormal value with
// the attribute value passed
// in by Three.js
vNormal = normal;
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(position,1.0);
}
và trong Fragment Shader, chúng ta sẽ thiết lập cùng một tên biến rồi sử dụng tích vô hướng của pháp tuyến đỉnh với một vectơ biểu thị ánh sáng chiếu từ phía trên và bên phải của hình cầu. Kết quả cuối cùng của việc này mang lại cho chúng ta hiệu ứng tương tự như ánh sáng định hướng trong gói 3D.
// same name and type as VS
varying vec3 vNormal;
void main() {
// calc the dot product and clamp
// 0 -> 1 rather than -1 -> 1
vec3 light = vec3(0.5,0.2,1.0);
// ensure it's normalized
light = normalize(light);
// calculate the dot product of
// the light to the vertex normal
float dProd = max(0.0, dot(vNormal, light));
// feed into our frag color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
Lý do tích vô hướng hoạt động là vì khi cho hai vectơ, tích vô hướng sẽ cho ra một số cho biết mức độ "tương tự" của hai vectơ đó. Với các vectơ được chuẩn hoá, nếu chúng chỉ theo cùng một hướng, bạn sẽ nhận được giá trị là 1. Nếu chúng chỉ theo hướng ngược nhau, bạn sẽ nhận được -1. Chúng tôi sẽ lấy số đó và áp dụng cho ánh sáng. Vì vậy, một đỉnh ở trên cùng bên phải sẽ có giá trị gần bằng 1 (được chiếu sáng hoàn toàn), trong khi một đỉnh ở bên cạnh sẽ có giá trị gần bằng 0 và ở phía sau sẽ là -1. Chúng ta sẽ giới hạn giá trị ở 0 cho mọi giá trị âm, nhưng khi bạn cắm các số vào, bạn sẽ nhận được ánh sáng cơ bản mà chúng ta đang thấy.
Tiếp theo là gì? Có lẽ sẽ rất thú vị nếu thử thay đổi vị trí của một số đỉnh.
9. Thuộc tính
Điều tôi muốn chúng ta làm bây giờ là đính kèm một số ngẫu nhiên vào mỗi đỉnh bằng cách sử dụng một thuộc tính. Chúng ta sẽ dùng số này để đẩy đỉnh ra ngoài theo hướng pháp tuyến của đỉnh. Kết quả cuối cùng sẽ là một loại quả bóng có gai kỳ lạ và sẽ thay đổi mỗi khi bạn làm mới trang. Hiện tại, biểu tượng này chưa được tạo hiệu ứng động (việc này sẽ diễn ra tiếp theo), nhưng sau vài lần làm mới trang, bạn sẽ thấy biểu tượng này được tạo ngẫu nhiên.
Bắt đầu bằng cách thêm thuộc tính vào chương trình đổ bóng đỉnh:
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// push the displacement into the three
// slots of a 3D vector so it can be
// used in operations with other 3D
// vectors like positions and normals
vec3 newPosition = position +
normal *
vec3(displacement);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
Giao diện của tính năng này như thế nào?
Không khác nhau nhiều! Điều này là do thuộc tính chưa được thiết lập trong MeshShaderMaterial, vì vậy, về cơ bản, chương trình đổ bóng sẽ sử dụng giá trị bằng 0. Hiện tại, nó giống như một phần giữ chỗ. Trong giây lát, chúng ta sẽ thêm thuộc tính vào MeshShaderMaterial trong JavaScript và Three.js sẽ tự động liên kết hai thuộc tính này với nhau.
Ngoài ra, tôi phải chỉ định vị trí đã cập nhật cho một biến vec3 mới vì thuộc tính ban đầu (giống như tất cả các thuộc tính) chỉ có thể đọc.
10. Đang cập nhật MeshShaderMaterial
Chuyển thẳng sang cập nhật MeshShaderMaterial bằng thuộc tính cần thiết để hỗ trợ tính năng dịch chuyển. Xin lưu ý: các thuộc tính là giá trị trên mỗi đỉnh nên chúng ta cần một giá trị trên mỗi đỉnh trong hình cầu. Chẳng hạn như:
var attributes = {
displacement: {
type: 'f', // a float
value: [] // an empty array
}
};
// create the material and now
// include the attributes property
var shaderMaterial = new THREE.MeshShaderMaterial({
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
// now populate the array of attributes
var vertices = sphere.geometry.vertices;
var values = attributes.displacement.value
for(var v = 0; v < vertices.length; v++) {
values.push(Math.random() * 30);
}
Giờ đây, chúng ta thấy một quả cầu bị biến dạng, nhưng điều thú vị là tất cả sự dịch chuyển đều diễn ra trên GPU.
11. Chuyển thành ảnh động
Chúng ta nên tạo ảnh động cho việc này. Làm cách nào để thực hiện? Có hai việc chúng ta cần làm:
- Một giá trị đồng nhất để tạo ảnh động cho mức độ dịch chuyển cần áp dụng trong mỗi khung hình. Chúng ta có thể dùng hàm sin hoặc cosin cho việc đó vì các hàm này chạy từ -1 đến 1
- Một vòng lặp ảnh động trong JS
Chúng ta sẽ thêm uniform vào cả MeshShaderMaterial và Vertex Shader. Đầu tiên là Vertex Shader:
uniform float amplitude;
attribute float displacement;
varying vec3 vNormal;
void main() {
vNormal = normal;
// multiply our displacement by the
// amplitude. The amp will get animated
// so we'll have animated displacement
vec3 newPosition = position +
normal *
vec3(displacement *
amplitude);
gl_Position = projectionMatrix *
modelViewMatrix *
vec4(newPosition,1.0);
}
Tiếp theo, chúng ta sẽ cập nhật MeshShaderMaterial:
// add a uniform for the amplitude
var uniforms = {
amplitude: {
type: 'f', // a float
value: 0
}
};
// create the final material
var shaderMaterial = new THREE.MeshShaderMaterial({
uniforms: uniforms,
attributes: attributes,
vertexShader: $('#vertexshader').text(),
fragmentShader: $('#fragmentshader').text()
});
Hiện tại, chúng ta đã hoàn tất việc tạo các chương trình đổ bóng. Nhưng có vẻ như chúng ta đã đi lùi một bước. Điều này chủ yếu là do giá trị biên độ của chúng ta ở mức 0 và vì chúng ta nhân giá trị đó với độ dịch chuyển nên không thấy gì thay đổi. Chúng ta cũng chưa thiết lập vòng lặp hoạt ảnh nên không bao giờ thấy số 0 thay đổi thành bất kỳ số nào khác.
Trong JavaScript, giờ đây, chúng ta cần gói lệnh gọi kết xuất vào một hàm rồi dùng requestAnimationFrame để gọi hàm đó. Trong đó, chúng ta cũng cần cập nhật giá trị của uniform.
var frame = 0;
function update() {
// update the amplitude based on
// the frame value
uniforms.amplitude.value = Math.sin(frame);
frame += 0.1;
renderer.render(scene, camera);
// set up the next call
requestAnimFrame(update);
}
requestAnimFrame(update);
Kết luận
Chỉ vậy thôi! Bây giờ, bạn có thể thấy ảnh động này đang nhấp nháy theo một cách kỳ lạ (và hơi ảo).
Chúng ta có thể tìm hiểu thêm nhiều điều về chủ đề chương trình đổ bóng và tôi hy vọng bạn thấy phần giới thiệu này hữu ích. Giờ đây, bạn có thể hiểu được các chương trình đổ bóng khi nhìn thấy chúng và tự tin tạo ra một số chương trình đổ bóng tuyệt vời của riêng mình.