Giới thiệu
Trước đây, tôi đã giới thiệu cho bạn kiến thức cơ bản về Three.js. Nếu bạn chưa đọc có thể bạn muốn vì đó là nền tảng mà tôi sẽ xây dựng trong bài viết 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) thực hiện rất tốt việc trừu tượng hoá các khó khăn cho bạn. Tuy nhiên, đôi khi bạn muốn đạt được một hiệu ứng cụ thể hoặc muốn tìm hiểu sâu hơn một chút về cách những hiệu ứng tuyệt vời đó xuất hiện trên màn hình và gần như chắc chắn chương trình đổ bóng sẽ là một phần của phương trình đó. Ngoài ra, nếu giống như tôi, bạn có thể muốn chuyển từ những nội dung cơ bản trong hướng dẫn trước sang một nội dung phức tạp hơn một chút. Tôi sẽ làm việc trên cơ sở bạn đang sử dụng Three.js, vì công cụ này làm rất nhiều việc khó khăn cho chúng ta trong việc bắt đầu chương trình đổ bóng. Tôi cũng xin nói trước rằng ở phần đầu, tôi sẽ giải thích ngữ cảnh cho chương trình đổ bóng và phần sau của hướng dẫn này là nơi chúng ta sẽ đi sâu hơn một chút. Lý do là vì shader trông khá lạ mắt và cần giải thích một chút.
1. Hai chương trình đổ bóng
WebGL không cung cấp tính năng sử dụng Quy trình cố định. Nói một cách ngắn gọn, 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 ngay từ đầu. Tuy nhiên, tính năng có cung cấp là Quy trình có thể lập trình, mạnh mẽ hơn nhưng cũng khó hiểu và khó sử dụng hơn. Tóm lại, 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à các đối tượng khác trê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ó hai 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 đều không có ý nghĩa gì cả. Điều bạn cần biết về các công cụ này là cả hai đều chạy hoàn toàn trên GPU của thẻ đồ 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 họ, để CPU thực hiện công việc khác. GPU hiện đại được tối ưu hoá rất nhiều cho các hàm 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? Một chương trình đổ bóng đỉnh được cung cấp cho từng đỉnh trong số này và có thể làm rối các đỉnh đó. Việc thực sự làm gì với mỗi đỉnh là tuỳ thuộc vào chương trình đổ bóng đỉnh, nhưng chương trình này có một trách nhiệm: tại một thời điểm nào đó, chương trình này phải đặt một giá trị có tên là gl_Position, một vectơ float 4D, là vị trí cuối cùng của đỉnh trên màn hình. Đây là một quy trình khá thú vị, vì chúng ta thực sự đang nói về việc lấy một vị trí 3D (một đỉnh có x, y, z) lên hoặc chiếu lên màn hình 2D. Rất may là nếu sử dụng một công cụ như Three.js, chúng ta sẽ có một cách viết tắt để thiết lập gl_Position 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ì vậy, chúng ta có đối tượng với các đỉnh và đã chiếu các đỉnh đó lên màn hình 2D, nhưng còn màu sắc chúng ta sử dụng thì sao? Còn về kết cấu và ánh sáng thì sao? Đó chính là lý do có 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 nhiệm vụ bắt buộc: phải đặt hoặc loại bỏ biến gl_FragColor, một vectơ float 4D khác, là màu cuối cùng của mảnh. Nhưng mảnh là gì? Hãy nghĩ đến ba đỉnh tạo thành một tam giác. Bạn cần vẽ từng pixel trong tam giác đó. Mảnh là dữ liệu do ba đỉnh đó cung cấp nhằm mục đích vẽ từng pixel trong tam giác đó. Do đó, các mảnh nhận đượ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 bên cạnh 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
Khi nói về biến, bạn có thể thực hiện 3 cách khai báo: Đồng phục, Thuộc tính và Biến. Khi lần đầu tiên nghe nói về 3 trò chơi đó, tôi đã rất bối rối vì chúng không phù hợp với bất cứ nền tảng nào khác mà tôi từng hợp tác. Tuy nhiên, sau đây là cách bạn có thể hình dung:
Bộ đồng nhất đượ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ị không thay đổi trên toàn bộ khung hình đang được kết xuất. Ví dụ điển hình về điều này có thể là vị trí của ánh sáng.
Thuộc tính là các giá trị được áp dụng cho từng đỉnh. Thuộc tính chỉ dành 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 với 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 việc 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. Cách sử dụng cổ điển của phương thức này là bình thường của đỉnh vì phương thức này có thể dùng trong các phép tính ánh sáng.
Sau này, chúng ta sẽ sử dụng cả ba loại để bạn có thể cảm nhận được cách áp dụng thực tế.
Bây giờ, chúng ta đã nói về chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh cũng như các loại biến mà chúng xử lý, giờ đây, chúng ta nên xem xét các chương trình đổ bóng đơn giản nhất mà chúng ta 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à tương tự với chương trình đổ bóng mảnh:
/**
* Set the colour 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, chúng ta được gửi một vài thông số đồng nhất bởi Three.js. Hai đồng nhất này là ma trận 4D, được gọi là Ma trận chế độ xem mô hình và Ma trận chiếu. Bạn không cần phải biết chính xác cách hoạt động của các thành phần này, mặc dù tốt nhất bạn vẫn nên hiểu cách hoạt động của các thành phần nếu có thể. Tóm lại, đó là cách 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 đã đưa chúng ra khỏi đoạn mã ở trên vì Three.js thêm chúng vào đầu mã chương trình đổ bóng, vì vậy, bạn không cần lo lắng về việc này. Thực sự thì nó còn thêm nhiều thông tin 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 làm 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 phục đó. Câu chuyện có thật.
6. Sử dụng MeshShaderMaterial
OK, chúng ta đã thiết lập chương trình đổ bóng, nhưng làm cách nào để sử dụng chương trình đổ bóng đó với Three.js? Hóa ra việc này rất dễ dàng. Phần này tương tự như sau:
/**
* 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 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 đó. Việc này thực sự không dễ dàng hơn nhiều. Vâng, đúng vậy, nhưng chúng ta đang nói về chạy 3D trong trình duyệt, vì vậy, tôi cho rằng bạn sẽ gặp phải độ phức tạp nhất định.
Trên thực tế, chúng ta có thể thêm hai thuộc tính nữa vào MeshShaderMaterial: đồng nhất và thuộc tính. 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 thông số đồng nhất giống nhau cho toàn bộ khung, tức là cho tất cả các đỉnh, vì vậy, chúng thường là các giá trị đơn. Tuy nhiên, các thuộc tính là biến trên mỗi đỉnh, vì vậy, các thuộc tính này dự kiến sẽ là một mảng. Phải có mối quan hệ một với 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 đồng phục. 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ư được chiếu sáng từ trên cao và từ bên cạnh, đồng thời sẽ nhấp nháy. Điều này có vẻ hơi khó hiểu, nhưng hy vọng rằng bạn sẽ hiểu rõ về ba loại biến cũng như mối quan hệ giữa các biến đó với nhau và hình học cơ bản.
8. Ánh sáng giả
Hãy cập nhật màu sắc để vật thể không phải là một vật thể có màu dẹt. Chúng ta có thể xem xét cách Three.js xử lý ánh sáng, nhưng tôi chắc rằng bạn có thể hiểu rõ rằng tính năng này phức tạp hơn mức cần thiết hiện tại, nên chúng ta sẽ giả mạo nó. Bạn nên xem xét kỹ các chương trình đổ bóng tuyệt vời thuộc Three.js, cũng như các chương trình đổ bóng trong dự án WebGL tuyệt vời gần đây của Chris Milk và Google, Rome. Quay lại chương trình đổ bóng. Chúng ta sẽ cập nhật chương trình đổ bóng đỉnh để cung cấp cho mỗi đỉnh một pháp tuyến cho chương trình đổ bóng mảnh. Chúng ta thực hiện việc này bằng cách thay đổ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, sau đó 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ừ trên xuống và bên phải quả cầu. Kết quả cuối cùng của việc này mang lại cho chúng ta một 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 colour
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);
}
Vì vậy, lý do tích vô hướng hoạt động là do khi cho hai vectơ, tích vô hướng sẽ cho ra một số cho bạn biết hai vectơ đó "tương tự" như thế nào. Với các vectơ đã chuẩn hoá, nếu các vectơ này chỉ theo cùng một hướng, bạn sẽ nhận được giá trị là 1. Nếu các mũi tên chỉ theo hướng ngược nhau, bạn sẽ nhận được -1. Việc chúng ta cần làm là lấy số đó và áp dụng cho ánh sáng. Vì vậy, một đỉnh ở góc trên cùng bên phải sẽ có giá trị gần bằng hoặc bằng 1, tức là đượ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ẽ cố định giá trị thành 0 cho mọi giá trị âm, nhưng khi bạn cắm các con số vào, bạn sẽ thấy ánh sáng cơ bản mà chúng ta đang thấy.
Tiếp theo là gì? Có thể sẽ tốt hơn nếu thử kết hợp với một vài vị trí đỉnh.
9. Thuộc tính
Việc chúng ta cần làm bây giờ là đính kèm một số ngẫu nhiên vào mỗi đỉnh thông qua một thuộc tính. Chúng ta sẽ sử dụng số này để đẩy đỉnh ra theo pháp tuyến. Kết quả cuối cùng sẽ là một quả bóng gai kỳ lạ sẽ thay đổi mỗi khi bạn làm mới trang. Ảnh động này vẫn chưa được tạo ảnh động (điều này xảy ra tiếp theo) nhưng một vài lần làm mới trang sẽ cho bạn thấy ảnh được sắp xếp ngẫu nhiên.
Hãy 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 trông như thế nào?
Thực sự không có gì khác biệt! Điều này là do thuộc tính chưa được thiết lập trong MeshShaderMaterial, do đó, chương trình đổ bóng sẽ sử dụng giá trị bằng 0. Nó giống như một phần giữ chỗ hiện tại. Trong giây lát, chúng ta sẽ thêm thuộc tính này 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, cần lưu ý rằng tôi phải chỉ định vị trí đã cập nhật cho 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. Cập nhật MeshShaderMaterial
Hãy bắt đầu cập nhật MeshShaderMaterial bằng thuộc tính cần thiết để hỗ trợ dịch chuyển. Lưu ý: thuộc tính là giá trị trên mỗi đỉnh, vì vậy, 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);
}
Bây giờ, chúng ta thấy một hình cầu bị đứt đoạn, nhưng điều thú vị là toàn bộ quá trình dịch chuyển đều diễn ra trên GPU.
11. Tạo ảnh động cho con mồi
Chúng ta nên tạo ảnh động cho toàn bộ quá trình này. Làm cách nào để thực hiện việc này? Chúng ta cần làm hai việc:
- Đồng nhất để tạo ảnh động mức độ dịch chuyển sẽ được áp dụng trong mỗi khung hình. Chúng ta có thể sử dụng hàm sin hoặc cosin vì các hàm này chạy từ -1 đến 1
- Vòng lặp ảnh động trong JS
Chúng ta sẽ thêm màu đồng nhất vào cả MeshShaderMaterial và Vertex Shader. Trước tiên, hãy xem Vertex Shader (Bộ đổ bóng đỉnh):
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 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 trình đổ bóng của chúng ta đã hoàn tất. Nhưng có vẻ như chúng ta đã lùi một bước. Điều này phần lớn là do giá trị biên độ của chúng ta bằng 0 và vì chúng ta nhân giá trị đó với độ dịch chuyển, chúng ta không thấy gì thay đổi. Chúng ta cũng chưa thiết lập vòng lặp ảnh động nên không bao giờ thấy giá trị 0 thay đổi thành bất kỳ giá trị 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, sau đó sử dụng requestAnimationFrame để gọi hàm đó. Trong đó, chúng ta cũng cần cập nhật giá trị của đồng phục.
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);
12. Kết luận
Chỉ vậy thôi! Giờ đây, bạn có thể thấy nó đang chuyển động theo cách lạ thường (và hơi giật mình).
Còn rất nhiều nội dung khác mà chúng ta có thể đề cập về chương trình đổ bóng trong một chủ đề, nhưng tôi hy vọng bạn thấy phần giới thiệu này hữu ích. Bây giờ, bạn đã có thể hiểu rõ các chương trình đổ bóng đó cũng như có thể tự tin tạo một số chương trình đổ bóng tuyệt vời của riêng bạn!