- Bắt đầu từ một animation CSS
border-radius đơn giản trên trang chủ, rồi tự triển khai animation blob mượt bằng vài dòng fragment shader
- Shader là các chương trình nhỏ trên GPU biến tọa độ pixel thành màu sắc; nhờ thực thi song song nên rất nhanh, nhưng bị hạn chế về chia sẻ trạng thái và trừu tượng hóa cấp cao
- Ví dụ GLSL cơ bản ánh xạ tọa độ
vUv thành giá trị RGBA của gl_FragColor, đồng thời làm quen với cú pháp như varying, uniform, kiểu vector và swizzling
- Hình tròn và blob được tạo bằng
distance(), step(), smoothstep() và Signed Distance Function (SDF), rồi kết hợp nhiều SDF bằng min() hoặc smooth minimum
- Dùng
u_time để tạo chuyển động cho các quả cầu, và khi thêm u_mouse vào mảng tâm thì có thể mở rộng thành shader tương tác cho phép người dùng điều khiển bằng chuột
Shader làm gì và các ràng buộc của nó
- Shader là những chương trình nhỏ chạy trên GPU, tối thiểu nhận tọa độ pixel làm đầu vào và xuất ra màu sắc
- Trong game, chúng được dùng cho ánh sáng, hiệu ứng đặc biệt, render phong cách hoạt hình và nhiều hiệu ứng thị giác khác, là nền tảng quan trọng của đồ họa game hiện đại
- Điểm cốt lõi tạo nên tốc độ là song song hóa, tức chạy đồng thời trên nhiều pixel
- Bài giới thiệu này tập trung vào OpenGL Shading Language thân thiện với trình duyệt, tức GLSL
- Đổi lại hiệu năng, shader phải được viết theo kiểu nhỏ gọn và cấp thấp
- Khó dựa vào trừu tượng hóa cấp cao hoặc import thư viện
- Do đặc tính thực thi song song, không thể lưu hay chia sẻ dữ liệu giữa các pixel, nên đây là cách làm không trạng thái, không ghi nhớ
Ví dụ GLSL đầu tiên: biến tọa độ thành màu sắc
- Shader biến các tọa độ đã chuẩn hóa thành màu RGBA
- Tọa độ thường được chuẩn hóa trong khoảng từ 0 đến 1
(0, 0) là góc dưới bên trái, (1, 1) là góc trên bên phải
- Theo quy ước, các tọa độ này thường được gọi là
st hoặc uv
- Ví dụ đơn giản nhất là một gradient trong đó tọa độ x càng lớn thì màu đỏ càng tăng, tọa độ y càng lớn thì màu xanh lá càng tăng
varying vec2 vUv;
void main() {
vec2 st = vUv;
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
- Gradient màu xanh dương có thể tạo bằng cách đưa tọa độ x vào kênh xanh dương như
gl_FragColor = vec4(0.0, 0.0, st.x, 1.0);
- Các yếu tố cú pháp quan trọng gồm:
varying: đầu vào thay đổi theo từng pixel
uniform: đầu vào giống nhau cho mọi pixel
vec2, vec3, vec4, mat2, mat3: các kiểu vector và ma trận phải khai báo kiểu như trong C
- swizzling: cú pháp lấy ra một phần vector, như
vec4(1, 2, 3, 4).xy
gl_FragColor: đầu ra quyết định màu của từng pixel ở cuối main()
Tạo hình tròn với distance(), step(), smoothstep()
- Ngay cả khi vẽ các hình sắc nét như hình tròn, ta cũng không dùng hàm kiểu
drawCircle() mà dùng khoảng cách toán học
- Khoảng cách giữa pixel hiện tại và tâm hình tròn có thể tính bằng
distance(vec2 p1, vec2 p2)
- Nếu ánh xạ trực tiếp khoảng cách thành màu thì sẽ được một gradient hình tròn; còn hình tròn đặc có thể tạo bằng
step(float threshold, float value)
- Nếu khoảng cách vượt ngưỡng thì trả về 1
- Nếu không thì trả về 0
step() chuyển đổi quá gắt nên có thể gây aliasing ở mép hình tròn
- Mép mượt hơn có thể tạo bằng
smoothstep(float t_start, float t_end, float x)
Biểu diễn hình dạng bằng Signed Distance Function
- Signed Distance Function (SDF) biểu diễn khoảng cách có dấu từ một điểm trong không gian tới một hình
- Bên trong hình là số âm
- Bên ngoài hình là số dương
- Biên là 0
- SDF của hình tròn có thể tạo bằng cách lấy khoảng cách tới tâm trừ đi bán kính
float circleSDF(vec2 p, float r) {
return length(p) - r;
}
- Nếu dịch chuyển điểm theo vị trí pixel
uv, ta có thể tính khoảng cách tới một hình tròn nằm ở vị trí bất kỳ trong không gian UV
- Nếu
d < 0.0 thì pixel nằm trong hình tròn, nên có thể tô màu khác
- Có thể xem thêm nhiều hình SDF 2D khác trong comprehensive list của Inigo Quilez
Ghép nhiều SDF để tạo blob
- SDF rất dễ dùng để tạo hình mới bằng các phép toán Boolean
- Hợp của hai SDF có thể lấy bằng
min() trên hai khoảng cách
- Nếu chỉ cần nằm trong một trong hai hình thì khoảng cách nhỏ nhất sẽ là số âm
- Nếu đều ở ngoài cả hai thì khoảng cách nhỏ nhất sẽ là số dương
- Lý do dùng
1. - smoothstep() là vì step() và smoothstep() trả về 1 cho các khoảng cách lớn hơn ngưỡng, tức ở bên ngoài hình
min() đơn giản sẽ tạo ra chỗ gãy sắc tại điểm hai hình tròn giao nhau
- Để trộn mượt như blob, ta dùng smooth minimum
- Tham số bổ sung
k điều khiển mức độ mượt
- Ví dụ dùng hàm polynomial smooth min
float smin(float a, float b, float k)
{
float h = max( k-abs(a-b), 0.0 )/k;
return min( a, b ) - h*h*k*(1.0/4.0);
}
Tạo animation metaball bằng uniform thời gian
- Có thể truyền các giá trị tùy ý vào shader dưới dạng uniform, giống như slider
- Animation được tạo bằng cách đưa
u_time từ JavaScript vào làm đầu vào của shader, rồi dùng nó để tính tọa độ tâm các hình tròn
- Shader mặc định được cập nhật 60 lần mỗi giây với giá trị
u_time mới, tạo chuyển động mượt
- Tọa độ tâm có thể dao động bằng các hàm tuần hoàn như
sin, cos
- Khi ghép nhiều hình tròn thành metaball, ta lưu tọa độ tâm trong một mảng và cộng dồn SDF bằng vòng lặp
vec2 centers[4] = vec2[4](c1,c2,c3,c4);
float d = 99.;
for (int i = 0; i < 4; i++) {
vec2 c = centers[i];
float sdf = circleSDF(uv, c, .1*u_slider);
d = smin(d, sdf, K);
}
- Blob tạo ra đã hoạt động đúng về mặt chức năng nhưng vẫn là một màu đơn, nên sau đó sẽ thêm màu sắc và tương tác
Tương tác cuối cùng bằng chuột
- Bước cuối là nhận tọa độ chuột qua uniform
u_mouse để người dùng trực tiếp điều khiển một quả cầu bên trong blob
uniform vec2 u_mouse;
- Chỉ cần thêm tọa độ chuột vào mảng tâm hình tròn là đã có tương tác chỉ với một dòng
vec2 centers[5] = vec2[5](c1,c2,c3,c4,u_mouse);
- Shader hoàn chỉnh sẽ đảo trục y của chuột để khớp với tọa độ canvas, rồi kết hợp 4 tâm chuyển động với tâm chuột
- Màu sắc được pha bằng cách dùng
mix(colorA, colorB, percent) nhiều lần
- Nếu
percent được dùng như boolean thì nó hoạt động tương tự if/else
- Giá trị
metaball, khoảng cách tới tâm, shine và membrane đều được phản ánh vào màu cuối cùng
- Khi hiểu nguyên lý này, bạn cũng sẽ dễ hiểu hơn cách hoạt động bên trong của các trình biên tập shader dạng node như Blender’s shader nodes hay Unity’s Shader Graph
Tài liệu đáng xem thêm
1 bình luận
Ý kiến trên Hacker News
Cuối cùng tôi cũng lấy hết can đảm để viết trên Internet và để lộ bản thân. Từ lâu tôi đã muốn học shader, và nghĩ rằng sẽ tốt nếu ghi lại quá trình học rồi chia sẻ với người khác
Blog chân thành đang chết dần trong vô hình dưới cái bóng của thuật toán toàn năng, và Internet có cảm giác như đã đi đến giữa Star Wars Episode IV. Tôi chỉ có một phản hồi: cảm ơn, và mong bạn viết nhiều hơn nữa
Nếu muốn xem các cao thủ có thể làm gì với shader, tôi muốn giới thiệu Inigo Quilez và nghệ thuật shader của ông ấy: https://www.youtube.com/watch?v=BFld4EBO2RE
Bổ sung: tôi không để ý bạn là tác giả. Bài viết rất tốt, và tôi đã tìm một tutorial xử lý nghệ thuật coding shader theo cách dễ tiếp cận và tương tác hơn
smoothstep(0.0f, 0.01f, dist);, chỉ cần dùngsmoothstep(fwidth(dist), -fwidth(dist), dist);Bài viết khá hay, nhưng lướt qua vấn đề cốt lõi của shader quá nhẹ.
Shader là thứ phiền toái mà phần lớn chương trình và ứng dụng không muốn. 3D thích tam giác, GPU cũng khớp tốt với phép trừu tượng đó, và shader hữu ích để nội suy trên các tam giác ấy.
Nhưng ở gần như mọi nơi không phải 3D, tam giác lại không hay lắm. Render 2D muốn path, render font muốn path hoặc pixmap, GUI hợp với path và pixmap hơn nhiều, compositor muốn pixmap, còn video decoder muốn pixmap và render song song.
Thứ phía không phải 3D muốn là pixmap hình chữ nhật và cách truy cập tính toán trực tiếp lên pixmap đó, nhưng GPU không thích điều này cho lắm và shader cũng không khớp tốt với nó
Đúng là ở mức cao hơn đó không phải hình thức mong muốn, nhưng làm nền để triển khai các trừu tượng cấp cao hơn thì đủ tốt. Phần cứng cũng có thể hỗ trợ trực tiếp, nhưng lợi thế không thật rõ ràng. Không ai phàn nàn rằng CPU không hỗ trợ vòng lặp
forở cấp kiến trúc.Đọc lại thì tôi không rõ bạn đang nói GPU có vấn đề gì. Có thể bỏ qua hoàn toàn pipeline xử lý đỉnh và chỉ vẽ một hình chữ nhật toàn màn hình, hoặc dùng compute shader; GPU xử lý việc này rất tốt. Bài được liên kết cũng đang nói về kiểu này
Ít nhất vào những năm 2000, cách nghĩ là như thế. Khi phần mềm phức tạp hơn, GPU có thể trở thành tối ưu hóa mong muốn, nhưng pipeline GPU luôn nghiêm ngặt và đóng; mô hình khi chuyển từ lập trình đơn lõi sang đa lõi thường đòi hỏi các thuật toán khác.
Lập trình GPU đa dụng giúp thoát khỏi sự ràng buộc với tam giác, nhưng để hưởng lợi từ tính song song đó thì vẫn cần một cách tiếp cận hoàn toàn khác
Tuyệt! Gần đây tôi có đào khá sâu vào cái hố thỏ SDF. Rất vui khi thấy có liên kết tới trang của iq; tài liệu bên đó thật sự rất tốt.
Không thể không liên kết shader “happy bouncing” của ông ấy. Cá nhân tôi thấy nó kỳ diệu: https://www.shadertoy.com/view/3lsSzf
Cũng có một video YouTube dài 6 tiếng ghi lại quá trình tạo ra nó. Khoảng 500 dòng code rất cô đọng
Tôi đã thử nhiều lần để hứng thú với chủ đề này nhưng chưa tìm được lối vào dễ tiếp cận; bài giới thiệu này đã cho tôi điều đó. Cách viết thật sự vui và tinh nghịch, tôi rất mong được đọc bài tiếp theo
Bắt lỗi nhỏ thôi, nhưng khi nhắc đến cel shading bạn đã viết là
cell. Thuật ngữ này xuất phát từ cel dùng trong hoạt hình vẽ tay và các tông màu lượng tử hóa của kiểu shading đóRất hay. Từ góc độ một người từng là nghệ sĩ rồi trở thành lập trình viên, thỉnh thoảng tôi lại có thôi thúc muốn đào sâu vào lập trình đồ họa.
Tôi đã viết vài shader rất cơ bản, nhưng khi toán học bắt đầu xuất hiện — thật ra là từ khá sớm — tôi đụng tường. Tôi học mỹ thuật chứ không phải khoa học máy tính, nên năng lực toán học về cơ bản là không có.
Dù sao thì bài viết rất tốt và tôi thích nó
Một bài nhập môn tuyệt vời, và mong nó sẽ được tiếp tục. Những bài kiểu này quá thường xuyên bắt đầu bằng một phần mở đầu rất hay rồi lại dừng mất
Tôi chưa từng làm việc với shader nên có thể đây là câu hỏi rất cơ bản. Trong một frame của game, về bản chất có phải shader vẽ mọi thứ không?
Hay là có các hình cơ bản như tam giác, hình vuông, hình tròn, rồi shader phủ thêm những thứ như bóng hoặc làm mượt cạnh lên trên đó?
Nhìn các ví dụ thì có vẻ như có thể tạo shader để vẽ bất kỳ đối tượng nào trong cảnh, rồi sau đó kết hợp các shader khác để có được bóng đổ và ánh sáng. Với kinh nghiệm vẽ rất hạn chế của tôi, tôi từng vẽ các hình chứ không vẽ bằng shader. Tôi vẫn nghĩ shader không tự vẽ đối tượng
GPU biến các hình vector trừu tượng như tam giác, được định nghĩa bởi ba đỉnh và dữ liệu gắn với từng đỉnh như pháp tuyến, thành một luồng fragment: một fragment cho mỗi pixel mà hình đó phủ lên trong buffer đầu ra, hoặc nhiều hơn nếu dùng multisampling. Phần này đều do phần cứng xử lý
Fragment gồm tọa độ pixel và dữ liệu do người dùng cung cấp. Dữ liệu đó có thể là uniform hằng, hoặc varying được nội suy trên toàn bộ mặt tam giác từ dữ liệu đỉnh nói trên. Việc nội suy này cũng do phần cứng xử lý và không thể lập trình
Fragment shader nhận fragment làm đầu vào, tính toán màu dựa trên dữ liệu đó, rồi sau vài bước nữa xuất ra màu của pixel tương ứng trên màn hình hoặc buffer ngoài màn hình. Nó có thể là một màu đơn sắc, cũng có thể là phép tính ánh sáng phức tạp
Trong rendering bằng GPU, tất cả việc này diễn ra song song quy mô lớn, nên rất nhiều fragment được xử lý cùng lúc. Shader là các hàm thuần và không trạng thái, nên chỉ có thể truy cập đầu vào, và hiệu ứng có thể tạo ra cũng chỉ là trả về một vài thứ như màu và giá trị độ sâu
Tóm lại, phần cứng GPU tính toán những pixel nào cần được tô để vẽ từng tam giác, còn fragment shader quyết định giá trị màu của từng pixel đó
Trước khi fragment shader vẽ “lên trên” cảnh, các giai đoạn khác như vertex, tessellation sẽ vẽ các hình cơ bản
Riêng về fragment shader cũng còn nhiều nội dung hơn rất nhiều so với phần đã mô tả. Ví dụ như deferred rendering[2], và bản thân nó cũng là một chủ đề lớn nếu đi sâu
1: https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipe...
2: https://learnopengl.com/Advanced-Lighting/Deferred-Shading
Có một graphics pipeline trộn lẫn các giai đoạn phần cứng chức năng cố định và các giai đoạn có thể lập trình. Ở mức cao, 1) GPU nhận một tập các tam giác 3D từ CPU, 2) vertex shader biến đổi các đỉnh tam giác 3D thành các đỉnh tam giác 2D có tọa độ pixel, làm chúng phẳng ra, 3) GPU rasterize tam giác 2D để quyết định chính xác pixel nào bị tam giác phủ, 4) với mỗi pixel bị phủ, pixel shader được chạy để quyết định màu pixel, và 5) màu pixel kết quả được lưu vào framebuffer. Lúc này nó cũng có thể được blend với màu hiện có
Pipeline này được lặp lại nhiều lần với các mesh tam giác và shader khác nhau cho đến khi toàn bộ frame được vẽ xong
Các tác vụ như màu sắc, bóng đổ, shading, hiệu ứng ảnh, xử lý ảnh nói chung đều được thực hiện bằng các phép tính song song kết hợp nhiều mảng dữ liệu. Chúng bao gồm đỉnh và thuộc tính của chúng, texture nguồn, các hàm đã tính trước, texture đích, buffer, v.v.
Ví dụ, để có ánh sáng và bóng đổ, shader cần có khả năng truy cập những thứ như vị trí và hướng của spotlight, có lẽ thông qua biến toàn cục. Ánh sáng tổng hợp thường được tạo bằng cách kết hợp nhiều shader pass. Chẳng hạn một pass cơ bản cho global illumination và một pass cho từng nguồn sáng, trong đó mỗi pass đúng nghĩa là cộng thêm ánh sáng
Giờ, để không cộng ánh sáng vào các pixel mà nguồn sáng bị che khuất, tức là vùng bóng, kỹ thuật phổ biến nhất là dùng thứ gọi là Z-buffer. Thực chất nó là một texture số thực dấu phẩy động. Vì ta muốn biết từng nguồn sáng trong cảnh chiếu tới đâu, trước khi áp dụng toàn bộ ánh sáng, ta thiết lập một shader pass duy nhất kết hợp mọi hình học rắn của cảnh, dùng vị trí và hướng của nguồn sáng như một phép biến đổi camera, và dùng một shader đặc biệt chỉ ghi khoảng cách tới đối tượng vào Z-buffer
Sau đó, mỗi khi muốn biết ánh sáng có tới được một điểm trong không gian hay không, sau một chút tính toán hình học, ta sample Z-buffer này rồi so sánh giá trị được lưu theo hướng đó với khoảng cách tới điểm tương ứng. Việc này có thể nhiều lỗi, và lỗi độ chính xác cũng thường gặp. Engine tốt sẽ làm sẵn việc này, nhưng cho phép bạn chỉnh sửa quy trình đó
Phần còn lại đều là các biến thể của chủ đề này. Deferred rendering là cách render dữ liệu chứ không phải màu vào các texture trung gian, rồi xử lý sau để thu được màu. Hiệu ứng blur được xử lý bằng tích chập 2D trên render texture, ví dụ bằng Gaussian kernel. Tessellation shader liên quan đến việc tạo hình học mới từ vertex shader. Ngay cả việc vẽ văn bản cũng được thực hiện thông qua font atlas và các hình chữ nhật nhỏ
Vì vậy các artist tài năng đẩy giới hạn bên trong fragment shader và vật lộn với các đánh đổi về hiệu năng
Fragment shader thường được dùng hơn cho các hiệu ứng bộ lọc toàn màn hình, ví dụ như hiệu chỉnh màu
Shader cũng được dùng để tạo texture và material cho các đối tượng cơ bản. Các material artist thường tạo texture bằng toán học shader
Nhiều hiệu ứng thị giác được tạo bằng cách dùng shader một cách sáng tạo
Shader chạy trên GPU theo kiểu song song dạng wave. Rất nhiều thread chạy trên cùng dữ liệu trong một wave
Trong một số trường hợp, shader nhanh hơn nhiều so với code rẽ nhánh trên CPU. Shader cũng có thể truy cập một số dữ liệu rendering dễ dàng hơn
Vì vậy, đây là một không gian rất phù hợp để tạo ra các hiệu ứng đặc biệt mang tính sáng tạo. Trong game, các đối tượng có độ chi tiết bề mặt cao thường là mục tiêu để chuyển phần chi tiết đó sang shader. Chẳng hạn như bề mặt biển, mesh tessellation, v.v.; và vì GPU rất mạnh mẽ và linh hoạt nên còn có nhiều ứng dụng khác nữa.
Nếu có ai thấy hình ảnh trông như nhiễu nhấp nháy, tôi đã khắc phục bằng cách sao chép ảnh trong trình duyệt rồi dán sang nơi khác. Làm vậy thì có thể xem ảnh bình thường
Đây là liên kết Imgur. Ảnh đầu tiên là ảnh chụp màn hình hiển thị trong trình duyệt, các ảnh còn lại là ảnh thực tế sau khi dán lên Imgur
https://imgur.com/a/F4203rz
Cách tôi hiểu về shader là như thế này.
Khi vẽ một đường bằng CPU, đó là một hàm lặp qua từng pixel giữa điểm A và điểm B rồi vẽ tuần tự từng pixel một. Nó có đúng số bước bằng số pixel nằm trên đường và chạy một lần
Khi vẽ một đường bằng GPU, đó là một hàm kiểm tra xem pixel đó có nằm trên đường hay không, nếu đúng thì vẽ. Nó chạy đồng thời trên mọi pixel của màn hình, kể cả các pixel ở rất xa đường
Như vậy có đúng không?
Thứ hai, GPU cũng không cần chạy pixel shader cho toàn bộ màn hình. Có thể dùng tam giác để chỉ chạy shader cho bất kỳ hình dạng mong muốn nào. Vì vậy, cách vẽ đường hiệu quả là gửi cho GPU hai tam giác khớp với hình học của đường cần vẽ, rồi chỉ chạy pixel shader trên các pixel mà các tam giác đó chồng lên. Như vậy hiệu quả hơn nhiều