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

Giới thiệu

Trước đây, tôi có giới thiệu về Three.js cho bạn. Nếu chưa đọc thì có thể bạn muốn dùng vì đó là nền tảng mà tôi sẽ xây dựng trong suốt bài viết này.

Việc tôi muốn làm là thảo luận về chương trình đổ bóng. WebGL rất tuyệt vời, như tôi đã nói trước đây Three.js (và các thư viện khác) làm rất tốt trong việc loại bỏ những khó khăn cho bạn. Nhưng sẽ có 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 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 đó. 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 thao tác 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 giúp ích rất nhiều cho việc triển khai chương trình đổ bóng. Tôi cũng sẽ nói trước rằng ngay từ đầu tôi sẽ giải thích ngữ cảnh của 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 vào lĩnh vực nâng cao hơn một chút. Lý do là chương trình đổ bóng rất khác thường ngay từ đầu và cần phải giải thích một chút.

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

WebGL không cung cấp khả năng sử dụng Đường ống cố định. Đây là cách viết ngắn gọn để 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 ngay từ đầu. Tuy nhiên, tính năng này cung cấp là Quy trình có thể lập trình. Quy trình này 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 nghĩa là lập trình viên, bạn chịu trách nhiệm đưa các đỉnh, v.v. kết xuất 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ó hai loại:

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

Tôi chắc chắn rằng cả hai điều đó đều có nghĩa là hoàn toàn không có tác dụng đối với cả hai. Tuy nhiên, bạn nên biết 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 CPU, để CPU làm công việc khác. GPU hiện đại đã được tối ưu hoá mạnh mẽ cho các chức năng mà chương trình đổ bóng yêu cầu, vì vậy bạn có thể sử dụng nó.

2. Chương trình đổ bóng Vertex

Tạo hình dạng nguyên thuỷ chuẩn, như hình cầu. Nó được tạo thành từ các đỉnh, đúng không? Chương trình đổ bóng đỉnh được cung cấp lần lượt cho từng đỉnh trong số các đỉnh này và có thể kết hợp với các đỉnh đó. Công việc thực sự của chương trình đổ bóng đỉnh phụ thuộc vào công việc của nó, nhưng nó 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 thiết lập một thứ gì đó có tên là gl_Position, một vectơ nổi 4D, là vị trí cuối cùng của đỉnh trên màn hình. Thực tế, đó là một quá trình khá thú vị, vì chúng ta thực sự đang nói về việc đưa một vị trí 3D (đỉnh có x, y, z) lên hoặc chiếu lên màn hình 2D. Rất may là nếu chúng tôi đang sử dụng Three.js, chúng tôi sẽ có cách viết tắt để đặt gl_Position mà không quá phức tạp.

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

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 màu chúng ta sử dụng thì sao? Vậy còn việc thêm 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. Rất giống với chương trình đổ bóng đỉnh (vertex), chương trình đổ bóng mảnh cũng chỉ có một công việc việc cần làm: phải đặt hoặc huỷ biến gl_FragColor, một vectơ nổi 4D khác, là màu cuối cùng của mảnh. Nhưng mảnh là gì? Hãy nghĩ về 3 đỉ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 3 đỉnh đó cung cấp nhằm mục đích vẽ từng pixel trong tam giác đó. Do đó, các mảnh sẽ 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 cạnh của nó là màu xanh lam, chúng ta sẽ thấy các giá trị màu sắc xen kẽ từ màu đỏ, qua màu tím, đến màu xanh lam.

4. Biến trong chương trình đổ bóng

Khi nói về biến, bạn có thể khai báo 3 mục: Uniforms (Đồng phục), Attributes (Thuộc tính) và Varyings (Varyings). Khi lần đầu biết đến ba công cụ đó, tôi đã rất bối rối vì chúng không khớp với bất cứ sản phẩm nào khác mà tôi từng làm việc. Nhưng dưới đây là cách bạn có thể hình dung về chúng:

  1. Biểu tượng thố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. Một ví dụ điển hình cho trường hợp này là vị trí của đèn.

  2. Thuộc tính là các giá trị được áp dụng cho từng đỉnh riêng lẻ. Các thuộc tính chỉ có sẵn cho chương trình đổ bóng đỉnh. Đây có thể là một đại lượng như mỗi đỉnh có một màu riêng biệt. Thuộc tính có mối quan hệ một với một với các đỉnh.

  3. Varying (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 tôi đảm bảo khai báo một biến khác nhau 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. Việc sử dụng thuộc tính này thường là một đỉnh thông thường vì thuộc tính này có thể dùng trong các tính toán ánh sáng.

Sau này, chúng ta sẽ sử dụng cả 3 loại này để bạn có thể cảm nhận được cách áp dụng thực tế.

Chúng ta đã nói về chương trình đổ bóng đỉnh (vertex đổ bóng) và chương trình đổ bóng mảnh cũng như các loại biến thể xử lý. Bây giờ, bạn nên xem các chương trình đổ bóng đơn giản nhất mà chúng ta có thể tạo.

5. Thế giới Bonjourno

Vậy thì, xin chào thế giới của các chương trình đổ bóng đỉnh (vertex):

/**
* 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à quy trình tương tự cũng áp dụng cho 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 đồng nhất bởi Three.js. Hai ma trận 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 nhất thiết phải biết chính xác các thao tác này hoạt động như thế nào, mặc dù tốt nhất bạn nên hiểu rõ cách hoạt động của các chức năng này khi có thể. Phiên bản ngắn gọn là cách mà vị trí 3D của đỉnh thực sự được chiếu vào vị trí 2D cuối cùng trên màn hình.

Tôi đã bỏ chúng ra khỏi đoạn mã ở trên vì Three.js thêm chúng vào phần đầu của chính mã chương trình đổ bóng của bạn, vì vậy bạn không cần phải lo lắng về việc thực hiện việc này. Sự thật là nó còn bổ sung nhiều hơn nữa, 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à đặt tất cả các giá trị đồ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 đó với Three.js? Hoá ra việc này cực kỳ dễ dàng. Sẽ có dạ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 các chương trình đổ bóng được gắn vào lưới mà bạn cung cấp cho Material đó. Thực sự không phải là điều dễ dàng hơn nhiều. Có thể 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 sẽ cần một độ phức tạp nhất định.

Chúng ta thực sự có thể thêm 2 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 có độ chính xác đơn, nhưng như tôi đã đề cập trước khi đồng nhất là giống nhau cho toàn bộ khung, tức là đối với tất cả các đỉnh, vì vậy chúng có xu hướng là các giá trị đơn. Tuy nhiên, thuộc tính là các biến mỗi đỉ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ố đỉ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 ảnh động vòng lặp, các thuộc tính dạng đỉnh (vertex) và một hiệu ứng đồng nhất. Chúng ta cũng sẽ thêm một biến khác nhau để 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 trông sẽ như phát sáng từ phía trên và bên cạnh, đồng thời sẽ nhấp nháy. Đây là một câu hỏi khá khó khăn, nhưng hy vọng rằng bạn sẽ hiểu rõ về 3 loại biến cũng như mối liên hệ giữa chúng và hình học cơ bản.

8. Đèn giả

Hãy cập nhật màu để đây 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ể hiểu rằng giải pháp này phức tạp hơn so với nhu cầu hiện tại của chúng ta, vì vậy chúng ta sẽ giả mạo nó. Bạn nên hoàn toàn xem qua các chương trình đổ bóng tuyệt vời là một phần của Three.js và cũng như các chương trình từ dự án WebGL tuyệt vời gần đây của Chris Sữa và Google, Rome. Quay lại với chương trình đổ bóng. Chúng ta sẽ cập nhật Vertex Shader để cung cấp từng đỉnh thông thường cho Fragment Shader. Chúng tôi thực hiện điều 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, sau đó sử dụng tích của dấu chấm của đỉnh thông thường với vectơ biểu thị ánh sáng chiếu từ phía trên và bên phải hình cầu. Kết quả rò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 điểm hoạt động là vì với hai vectơ, nó sẽ xuất hiện với một số cho bạn biết hai vectơ đó "tương tự" như thế nào. Với vectơ được chuẩn hoá, nếu chúng trỏ chính xác 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 lại, bạn sẽ có -1. Việc chúng ta làm là lấy con số đó và áp dụng vào á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, tức là được chiếu sáng đầy đủ, trong khi một đỉnh ở bên phải có giá trị gần 0 và làm tròn phía sau sẽ là -1. Chúng tôi sẽ đặt giá trị này thành 0 đối với mọi giá trị âm, nhưng khi đưa các con số vào, bạn sẽ thấy ánh sáng cơ bản mà chúng ta thấy.

Tiếp theo là gì? Tốt hơn là có thể thử thay đổi vị trí các đỉ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 từng đỉnh thông qua một thuộc tính. Chúng tôi sẽ sử dụng số này để đẩy đỉnh theo phương pháp thông thường. Kết quả cuối cùng sẽ là một quả bóng tăng đột biến kỳ lạ và sẽ thay đổi mỗi khi bạn làm mới trang. Trang này sẽ chưa được tạo ảnh động (điều này 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 trang này đượ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 này trông như thế nào?

Thực sự không khác gì nhiều! Nguyên nhân là do thuộc tính này chưa được thiết lập trong MeshShaderMaterial, nên chương trình đổ bóng sử dụng giá trị 0 một cách hiệu quả. Công cụ này giống như một trình giữ chỗ ngay bây giờ. 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 phần tử này với nhau.

Ngoài ra, xin 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 gốc, giống như tất cả các thuộc tính, ở chế độ 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ợ chuyển đổi. Lưu ý: thuộc tính là các giá trị trên 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 đang thấy một hình cầu bị xé toạc, nhưng điều thú vị là tất cả sự dịch chuyển đang diễn ra trên GPU.

11. Tạo ảnh động cho cái mút

Chúng ta hoàn toàn tạo hiệu ứng động. Chúng tôi làm cách nào để thực hiện điều này? Có 2 việc chúng ta cần phải thực hiện:

  1. Một hình ảnh thống nhất để tạo ảnh động cho mức độ dịch chuyển sẽ được áp dụng trong mỗi khung. Chúng ta có thể sử dụng sin hoặc cosin cho điều đó vì chúng chạy từ -1 đến 1
  2. Vòng lặp ảnh động trong JS

Chúng ta sẽ thêm tính năng đồng nhất cho cả MeshShaderMaterial và Vertex Shader. Đầu tiên là Chương trình đổ bóng Vertex:

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()
});

Chương trình đổ bóng của chúng ta hiện đã hoàn tất. Nhưng đúng rồi, có vẻ như chúng tôi đã lùi một bước. Điều này phần lớn là do giá trị biên độ của chúng tôi bằng 0 và vì chúng tôi nhân giá trị đó với độ dịch chuyển nên không có gì thay đổi. Chúng tôi cũng chưa thiết lập vòng lặp ảnh động, vì vậy, chúng tôi sẽ không bao giờ nhận thấy giá trị 0 thay đổi thành bất cứ điều gì khác.

Trong JavaScript, chúng ta hiện cần kết xuất lệnh gọi kết xuất vào một hàm, sau đó sử dụng requestAnimationFrame để gọi. Tại đó, chúng ta cũng cần cập nhật giá trị đồ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);

12. Kết luận

Chỉ vậy thôi! Giờ đây, bạn có thể thấy ứng dụng tạo ảnh động theo cách nhấp nháy lạ (và hơi giật gân).

Còn rất nhiều chủ đề nữa 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 chương trình đổ bóng khi nhìn thấy, 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 bạn!