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

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 chưa đọc bài viết đó, bạn nên đọc 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 khó 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ì thư viện này thực hiện nhiều công việc cho chúng ta về việc chạy 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à 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 thức kết xuất nào ngay từ đầu. Tuy nhiên, tính năng 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:

  1. Chương trình đổ bóng đỉnh
  2. 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 chuyển tất cả những gì có thể sang các nhân đó, để CPU thực hiện các 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 đang nói về việc lấy 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. Fragment Shader

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 cho 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 chương trình đổ 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ínhBiến. Khi lần đầu nghe thấy ba loại đó, tôi rất bối rối vì chúng không khớp với bất kỳ loại nào khác mà tôi từng làm việc. Tuy nhiên, bạn có thể xem xét các yếu tố này như sau:

  1. 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.

  2. 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.

  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 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 vectơ pháp tuyến này là vectơ pháp tuyến của đỉnh vì vectơ 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ể 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à cũng tương tự đối 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 bộ đồng phục này là các 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.

Thực ra, tôi đã loại bỏ các tham số này khỏi đoạn mã trên vì Three.js sẽ thêm các tham số này vào đầu mã chương trình đổ bóng, vì vậy, bạn không cần phải tự thực hiện 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. Nó giống 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. Có thể là vậy, nhưng chúng ta đang nói về việc chạy 3D trong trình duyệt của bạn, vì vậy, tôi cho rằng bạn sẽ thấy một số phức tạp nhất định.

Chúng ta thực sự có thể thêm hai thuộc tính khác vào MeshShaderMaterial: đồng phục và 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 đó, các giá trị đồ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, 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 xuống 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 để đối tượng không phải là một đối tượng có màu phẳng. 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ể đánh giá rằng việc này phức tạp hơn mức cần thiết hiện tại, vì vậy, chúng ta sẽ giả mạo ánh sáng. 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à đỉnh ở 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ì? Bạn nên thử thay đổi một số 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. Nó sẽ chưa có ảnh động (việc này sẽ diễn 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 tạo 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ị 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 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. 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ị bóp méo, nhưng điều thú vị là tất cả các hoạt động dịch chuyển đều diễn ra trên GPU.

11. Tạo ảnh động cho That Sucker

Chúng ta nên tạo ảnh động cho phần này. Chúng ta sẽ làm như thế nào? Chúng ta cần làm hai việc:

  1. Một đồng phục để 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 vì các hàm này chạy từ -1 đến 1
  2. Vòng lặp ảnh động trong JS

Chúng ta sẽ thêm đồ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 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ời nê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, chúng ta hiện cần gói lệnh gọi kết xuất vào một hàm rồi 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! Bây giờ, bạn có thể thấy ảnh động này đang nhấp nháy theo cách kỳ lạ (và hơi kỳ quái).

Chúng ta có thể nói nhiều hơn về chương trình đổ bóng, như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, cũng như tự tin tạo ra một số chương trình đổ bóng tuyệt vời của riêng mình!