Giới thiệu về chương trình đổ bóng

Giới thiệu

Trước tiên, hãy đọc phần 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 đôi khi, bạn sẽ 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 nội dung tuyệt vời đó xuất hiện trên màn hình, và 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 nội dung cơ bản trong hướng dẫn trước sang 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ì nó thực hiện rất nhiều công việc cho chúng ta về mặt khởi động chương trình đổ bóng. Trước tiên, bạn sẽ tìm hiểu bối cảnh của chương trình đổ bóng, sau đó là các bước nâng cao trong tài liệu này.

1. Hai chương trình đổ bóng của chúng ta

WebGL không cung cấp việc sử dụng Đường ống cố định, đây là một cách viết tắt để nói 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. Thay vào đó, OpenGL ES 2.0 cung cấp Programmable Pipeline (Đường ống 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. Nói ngắn gọn, 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 thành phần khác lên màn hình. Trình đổ bóng là một phần của quy trình này và có hai loại trình đổ bóng:

  1. Trình đổ bóng đỉnh
  2. Chương trình đổ bóng mảnh

Tôi chắc rằng bạn sẽ đồng ý rằng cả hai điều này đều không có ý nghĩa gì nếu đứng riêng lẻ. Điều bạn cần biết về hai loại chương trình 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 mọi thứ có thể cho GPU, để CPU làm việc khác. GPU hiện đại được tối ưu hoá mạnh mẽ 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. Trình đổ bóng đỉnh

Lấy một hình dạng cơ bản 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ể xử lý chúng. Trình đổ bóng đỉnh có thể làm bất cứ điều gì với mỗi đỉnh, nhưng 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, là vị trí cuối cùng của đỉnh trên màn hình. Bản thân điều này là một quy trình khá thú vị, vì chúng ta đang nói về việc đưa 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 thư viện như Three.js, chúng ta có thể đặt gl_Position theo cách viết tắt mà không cần thực hiện quá nhiều thao tác.

3. Chương trình đổ bóng mảnh

Vậy là chúng ta đã có đối tượng với các đỉnh và đã chiếu chúng lên màn hình 2D, nhưng còn màu sắc thì sao? Còn việc tạo hoạ tiết và ánh sáng thì sao? Đó chính xác là lý do có chương trình đổ bóng mảnh. Rất giống với chương trình đổ bóng đỉnh, chương trình đổ bóng mảnh cũng chỉ có một việc phải làm: nó phải đặt hoặc loại bỏ biến gl_FragColor, một vectơ số thực 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. Mỗi pixel trong tam giác đó cần được vẽ ra. 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. 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ừ đỏ, qua tím, đến 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. Bạn có thể hình dung về các loại từ khoá này như sau:

  1. 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ị không đổi trong toàn bộ khung hình đang được kết xuất. Một ví dụ điển hình cho trường hợp này có thể là vị trí của đèn.

  2. Thuộc tính là các 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. Đây có thể là một thuộc tính như 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.

  3. 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 cần đảm bảo khai báo một biến thay đổi có cùng kiểu và tên trong cả chương trình đổ bóng đỉnh và chương trình đổ bóng mảnh. Một trường hợp sử dụng điển hình của thuộc tính này là pháp tuyến của đỉnh, vì thuộc tính này có thể được 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ể hiểu rõ cách áp dụng chúng trong thực tế.

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à sau đâ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);
}

Nhưng cũng không quá phức tạp, đúng không?

Trong chương trình đổ bóng đỉnh, chúng ta được Three.js gửi một vài đồng phục. Hai biến đồng nhất này là ma trận 4D, được gọi là Ma trận khung hiển thị mô hình và Ma trận chiếu. Bạn không cần biết chính xác cách hoạt động của các thành phần này, mặc dù bạn nên tìm hiểu cách hoạt động của chúng nếu có thể. Nói ngắn gọn thì đây là cách vị trí 3D của đỉnh được chiếu thực tế đến vị trí 2D cuối cùng trên màn hình.

Tôi thực sự đã loại bỏ chúng khỏi đoạn mã ví dụ vì Three.js tự thêm chúng vào đầu mã chương trình đổ bóng của bạn, nên bạn không cần phải lo lắng về việc này. Thực ra, nó còn bổ sung 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 không sử dụng Three.js, bạn sẽ phải tự tạo và thiết lập tất cả các biến đồng nhất và thuộc tính đó. Câu chuyện có thật.

6. Sử dụng MeshShaderMaterial

Được rồi, 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 này 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 chương trình đổ bóng được đính kèm vào lưới mà bạn cung cấp chất liệu đó. Thực sự thì không có cách nào dễ hơn thế. Có lẽ là vậy, nhưng chúng ta đang nói về 3D chạy trong trình duyệt của bạn, vì vậy tôi cho rằng bạn mong đợi một mức độ phức tạp nhất định.

Chúng ta thực sự có thể thêm hai thuộc tính nữa vào MeshShaderMaterial: đồng phục và các thuộc tính. Cả hai đều có thể nhận vectơ, số nguyên hoặc số thực, nhưng như tôi đã đề cập trước đây, các biến đồng nhất là giống nhau cho toàn bộ khung hình hoặc cho tất cả các đỉnh, vì vậy chúng có xu hướng là các giá trị đơn lẻ. Tuy nhiên, các thuộc tính là biến theo đỉnh, vì vậy, chúng phải 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 một chút thời gian để thêm vào 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à hình cầu màu hồng của chúng ta sẽ xuất hiện dưới dạng được chiếu sáng từ phía trên và từ bên cạnh, đồng thời sẽ nhấp nháy. Nó có vẻ hơi phức tạp, nhưng hy vọng nó sẽ giúp bạn hiểu rõ về ba loại biến cũng như cách chúng liên quan đến nhau 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 chỉ có một màu phẳng. Chúng ta có thể xem xét cách Three.js xử lý ánh sáng, nhưng tôi chắc chắn rằng bạn có thể hiểu nó phức tạp hơn mức chúng ta cần, vì vậy chúng ta sẽ làm giả nó. Bạn nên xem qua 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 chương trình đổ bóng. Chúng ta sẽ cập nhật Vertex Shader để cung cấp từng pháp tuyến đỉnh cho Fragment Shader. Chúng ta thực hiện việc này bằng một biến:

// 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 Trình đổ bóng mảnh, 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ừ 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 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 color
gl_FragColor = vec4(dProd, dProd, dProd, 1.0);

}

Vì vậy, lý do tích vô hướng hoạt động là vì khi có hai vectơ, nó sẽ cho ra một con số cho biết mức độ "tương đồng" giữa hai vectơ đó. Với các vectơ chuẩn hoá, nếu chúng chỉ về cùng một hướng, bạn sẽ nhận được giá trị là 1. Nếu hai ngón tay chỉ về hai hướng ngược nhau, bạn sẽ bị trừ 1 điểm. Chúng ta sẽ lấy con 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 hoặc 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à đỉnh ở phía sau sẽ là -1. Chúng ta giới hạn giá trị ở mức 0 cho mọi giá trị âm, nhưng khi bạn nhập 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ì? Sẽ rất tuyệt nếu có thể thử thay đổi một số vị trí đỉnh.

9. Thuộc tính

Bây giờ, tôi muốn chúng ta gán một số ngẫu nhiên cho mỗi đỉnh bằng cách sử dụng một thuộc tính. Chúng ta sẽ sử dụng số này để đẩy đỉnh ra dọc theo pháp tuyến của nó. Kết quả cuối cùng sẽ là một loại bóng gai kỳ lạ sẽ thay đổi mỗi khi bạn làm mới trang. Nó sẽ chưa được tạo ảnh động (điều đó sẽ 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 nó được chọn 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);
}

Bạn thấy thế nào?

Không khác biệt nhiều! Điều này là do thuộc tính chưa được thiết lập trong MeshShaderMaterial, nên hiệu ứng đổ 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 cả hai với nhau.

Cũng cần lưu ý rằng tôi phải gán 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, đều ở chế độ chỉ đọc.

10. Cập nhật MeshShaderMaterial

Hãy bắt tay ngay vào việc cập nhật MeshShaderMaterial bằng thuộc tính cần thiết để tạo hiệu ứng dịch chuyển. Xin lưu ý: thuộc tính là giá trị cho mỗi đỉnh, vì vậy, chúng ta cần một giá trị cho 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ị 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 hoàn toàn nên tạo ảnh động cho việc này. Chúng tôi làm việc này như thế nào? Có hai điều chúng ta cần thực hiện:

  1. Một biến đồ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ể sử dụng hàm sin hoặc cosin cho việc đó vì chúng chạy từ -1 đến 1
  2. Vòng lặp ảnh động trong JS

Chúng ta sẽ thêm biến đồng nhất 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()
});

Trình đổ bóng của chúng ta hiện đã hoàn tất. Nhưng có vẻ như chúng ta đã lù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 là 0 và vì chúng ta nhân giá trị đó với độ dịch chuyển nên chúng ta không thấy có gì thay đổi. Chúng ta cũng chưa thiết lập vòng lặp ảnh động nên sẽ không bao giờ thấy giá trị 0 đó thay đổi thành giá trị 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 biến đồng nhất.

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! Giờ đây, bạn có thể thấy ảnh động này đang chuyển động theo kiểu nhịp đập lạ (và hơi ảo giác).

Chúng ta còn có thể tìm hiểu nhiều điều khác về chương trình đổ bóng. 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 chương trình đổ bóng khi nhìn thấy chúng và tự tin tạo một số chương trình đổ bóng tuyệt vời của riêng mình.