Bài này dạy gì: SDF (signed distance function, hàm khoảng cách có dấu) là cách shader vẽ hình bằng công thức thay vì bằng ảnh: hình tròn, hộp, hộp bo góc, đoạn thẳng, rồi ghép, khoét, làm viền, làm bóng, làm quầng sáng. Cuối bài là thanh máu và vòng hồi chiêu vẽ hoàn toàn bằng shader, không cần một file ảnh nào.
Cần biết trước: Bài 10: Shader: làm quen với GLSL : GLSL cơ bản, uniform, và sân chơi (learn_shader_playground.c). Mọi ví dụ ở đây chạy trong sân chơi đó, chế độ 1.
Ý tưởng: hỏi "điểm này cách hình bao xa?"
Để vẽ một hình tròn trong shader, câu hỏi đơn giản nhất là "điểm này nằm trong hay ngoài hình?", trả lời đúng hoặc sai. Câu trả lời đúng/sai cho ra mép răng cưa, và không nói gì về những điểm gần mép. SDF hỏi khác đi:
Từ điểm này đến mép hình gần nhất là bao xa, và điểm ở trong hay ngoài?
Câu trả lời là một con số có dấu:
| Giá trị d | Nghĩa |
| d < 0 | điểm nằm bên trong hình, -d là khoảng cách đến mép |
| d = 0 | điểm nằm đúng trên mép |
| d > 0 | điểm nằm bên ngoài hình, d là khoảng cách đến mép |
Bài Bài 10: Shader: làm quen với GLSL đã dùng ý này mà chưa gọi tên: length(p) là khoảng cách từ điểm p đến gốc toạ độ, và trừ đi bán kính r thì được SDF của hình tròn:
float sd_circle(vec2 p, float r) {
return length(p) - r;
}
Một con số như vậy cho ta nhiều hơn "trong hay ngoài": biết d là biết làm mờ mép (theo d), vẽ viền (|d| nhỏ), phát sáng (d dương nhỏ), đổ bóng (dời hình đi rồi dùng lại d), và ghép hình (kết hợp các d), tất cả từ cùng một con số.
Nhìn thấy trường khoảng cách
Cách hiểu SDF nhanh nhất là vẽ chính d thành màu. Ví dụ dưới đây vẽ SDF của một hình tròn quanh chuột (chế độ 1; tâm hình tròn đi theo chuột):
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
uniform vec2 mouse; // 0..1, tâm hình tròn đi theo chuột
out vec4 finalColor;
// Khoảng cách CÓ DẤU từ điểm p đến hình tròn bán kính r quanh gốc:
// âm bên trong, 0 đúng trên đường biên, dương bên ngoài.
float sd_circle(vec2 p, float r) {
return length(p) - r;
}
void main() {
vec2 aspect = vec2(resolution.x / resolution.y, 1.0);
vec2 p = (fragTexCoord - 0.5) * aspect;
vec2 center = (mouse - 0.5) * aspect;
float d = sd_circle(p - center, 0.2);
// Hiện d thành màu: cam bên trong, xanh bên ngoài, càng xa biên càng sáng.
vec3 col = d > 0.0 ? vec3(0.35, 0.55, 0.90) : vec3(0.95, 0.60, 0.20);
col *= 1.0 - exp(-6.0 * abs(d)); // tối sát biên, sáng dần khi ra xa
col *= 0.8 + 0.2 * cos(150.0 * d); // các đường đồng mức: cùng khoảng cách thì cùng vòng
col = mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d))); // vẽ trắng đúng chỗ d = 0
finalColor = vec4(col, 1.0);
}
Cam là bên trong (d < 0), xanh là bên ngoài (d > 0). Vòng trắng là d = 0, chính là mép hình tròn. Các vòng đồng tâm là những chỗ có cùng khoảng cách.
Đọc từng dòng:
- float d = sd_circle(p - center, 0.2); : p - center dời hình đến center. Dời hình = trừ vị trí khỏi điểm đang xét, nghe ngược nhưng đúng: thay vì dời hình sang phải, ta coi điểm đang xét dịch sang trái.
- col *= 1.0 - exp(-6.0 * abs(d)); : sát mép (|d| nhỏ) thì tối, xa dần thì sáng lên. Hàm exp(-k * x) là một cách đơn giản để có đường cong giảm nhanh; bạn sẽ dùng lại nó cho quầng sáng.
- col *= 0.8 + 0.2 * cos(150.0 * d); : các đường đồng mức. cos của d lặp lại theo khoảng cách, nên mỗi vòng là một khoảng cách cố định đến mép. Đây là cách quen thuộc để gỡ lỗi một SDF: nếu các vòng không đều hay đứt quãng thì công thức của bạn sai.
- mix(col, vec3(1.0), 1.0 - smoothstep(0.0, 0.01, abs(d))) : tô trắng đúng chỗ d = 0. Đây chính là hình tròn.
- Ghi chú
- Khi một SDF trông sai, đừng đoán: hiện d thành màu như trên. Bài Bài 12: Các mẫu shader hay gặp có mục "Hiện một
số thành màu để gỡ lỗi", cùng một ý.
Từ khoảng cách thành hình: fill
Muốn tô hình thì đổi d thành độ phủ từ 0 đến 1. Bài trước dùng smoothstep(0.28, 0.30, d) với hai số cố định, và mép chỉ đẹp ở đúng một kích thước: phóng to thì mép mờ đi, thu nhỏ thì mép răng cưa. Cách chuẩn dùng fwidth:
float fill(float d) {
float aa = fwidth(d); // d đổi bao nhiêu khi đi sang pixel bên cạnh
return 1.0 - smoothstep(-aa, aa, d); // 1 trong hình, 0 ngoài, mờ đúng khoảng một pixel ở mép
}
fwidth(d) cho biết d thay đổi bao nhiêu từ pixel này sang pixel kề, tức "một pixel dài bao nhiêu trong hệ toạ
độ của `d`". Mép mượt đúng một pixel dù hình lớn hay nhỏ, và không phải chỉnh số thủ công.
- Ghi chú
- fwidth (và các hàm đạo hàm khác như dFdx, dFdy) so các pixel kề nhau, nên theo đặc tả GLSL kết quả không xác định khi gọi trong nhánh if mà các pixel kề nhau đi các hướng khác nhau. Tính aa ở đầu hàm, ngoài mọi if, như trên. Mình chưa thử trường hợp lỗi này, nên đây là lời khuyên theo đặc tả chứ không phải kết quả đo.
Từ nay, mọi hình đều vẽ bằng cùng một khuôn: col = mix(col, màu, fill(d)), và mỗi hình mới đè lên hình trước.
Các hình cơ bản
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
out vec4 finalColor;
// --- Bốn hình cơ bản: mỗi hàm trả về khoảng cách có dấu đến hình, quanh gốc toạ độ ---
float sd_circle(vec2 p, float r) {
return length(p) - r;
}
// Hộp nửa kích thước b. Phía ngoài đo đến góc hoặc cạnh gần nhất, phía trong là số âm.
float sd_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}
// Hộp bo góc: hộp nhỏ hơn r ở mỗi phía, rồi "phồng" ra r (trừ r khỏi khoảng cách).
float sd_round_box(vec2 p, vec2 b, float r) {
return sd_box(p, b - r) - r;
}
// Đoạn thẳng a-b, dày w: đo đến điểm gần nhất TRÊN đoạn, rồi trừ nửa bề dày.
float sd_segment(vec2 p, vec2 a, vec2 b, float w) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h) - w;
}
// Đổi khoảng cách thành độ phủ 0..1, mép mượt đúng MỘT pixel bất kể phóng to hay nhỏ.
// fwidth(d) là d đổi bao nhiêu khi đi sang pixel bên cạnh.
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
vec3 col = vec3(0.10, 0.10, 0.15);
col = mix(col, vec3(1.00, 0.80, 0.20), fill(sd_circle(p - vec2(-0.55, 0.0), 0.16)));
col = mix(col, vec3(0.35, 0.70, 1.00), fill(sd_box(p - vec2(-0.18, 0.0), vec2(0.14, 0.10))));
col = mix(col, vec3(0.50, 0.90, 0.50), fill(sd_round_box(p - vec2(0.22, 0.0), vec2(0.15, 0.11), 0.07)));
col = mix(col, vec3(1.00, 0.45, 0.55), fill(sd_segment(p, vec2(0.50, -0.12), vec2(0.72, 0.12), 0.04)));
finalColor = vec4(col, 1.0);
}
Hình tròn, hộp, hộp bo góc, đoạn thẳng có đầu tròn. Mép mọi hình đều mượt.
Mỗi hình là một hàm nhận điểm p (đã dời về gốc) và trả về d:
- Hình tròn: length(p) - r.
- Hộp sd_box(p, b), với b là nửa kích thước. abs(p) - b gấp bốn góc phần tư thành một, nên chỉ cần xét một góc. q âm ở cả hai trục nghĩa là ở trong hộp (khoảng cách âm là max(q.x, q.y)); q dương nghĩa là ở ngoài, và khoảng cách đến hộp là độ dài phần dương của q (length(max(q, 0.0))), chính xác cả ở góc.
- Hộp bo góc: một mẹo dùng cho mọi hình. Cắt hộp nhỏ đi r ở mỗi phía rồi trừ r khỏi khoảng cách: sd_box(p, b - r) - r. Trừ một số khỏi d là phồng hình ra đều mọi phía một đoạn r, và góc nhọn thành cung tròn. Vì vậy hộp bo góc chính là hộp nhỏ được phồng ra.
- Đoạn thẳng sd_segment: tìm điểm gần nhất trên đoạn (h kẹp trong 0 đến 1, đó là vị trí chiếu của p lên đường thẳng), đo khoảng cách đến nó, rồi trừ nửa bề dày. Đầu tròn có được miễn phí.
Vài phép biến đổi dùng cho mọi SDF:
| Muốn | Làm | Ví dụ |
| Dời hình | d = sd_x(p - vị_trí) | sd_circle(p - vec2(0.3, 0.0), 0.1) |
| Phồng hình ra r | d - r | hộp bo góc |
| Chỉ lấy viền dày w | abs(d) - w | vòng tròn rỗng ở bài tập và ở vòng hồi chiêu |
| Phóng hình to s lần | chia p cho s, rồi nhân d với s | nhân lại d để nó vẫn là khoảng cách thật (theo định nghĩa, chưa đo) |
Ghép hình bằng cách ghép khoảng cách
Đây là chỗ SDF mạnh nhất: ghép hai hình chỉ là ghép hai con số.
| Phép | Công thức | Nghĩa |
| Hợp | min(a, b) | trong a hoặc trong b |
| Giao | max(a, b) | trong a và trong b |
| Trừ | max(a, -b) | trong a, trừ phần trong b |
| Hợp mềm | smin(a, b, k) | như hợp, nhưng hai hình dính vào nhau như giọt nước |
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
uniform float time;
out vec4 finalColor;
float sd_circle(vec2 p, float r) {
return length(p) - r;
}
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
// Kết hợp hai hình bằng cách kết hợp hai KHOẢNG CÁCH:
float op_union(float a, float b) { return min(a, b); } // trong a HOẶC trong b
float op_intersect(float a, float b) { return max(a, b); } // trong a VÀ trong b
float op_subtract(float a, float b) { return max(a, -b); } // trong a, trừ phần trong b
// Hợp mềm: hai hình dính lại như giọt nước khi lại gần nhau. k là độ "dính".
float op_smooth_union(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
// Hai vòng tròn chạy lại gần rồi ra xa nhau; mỗi ô trong bốn ô dùng đúng cặp này quanh tâm của ô.
// Khoảng cách hai tâm là 2 * offset, luôn nhỏ hơn tổng hai bán kính nên chúng luôn chồng lên nhau.
vec2 offset = vec2(0.06 + 0.04 * sin(time * 2.0), 0.0);
vec3 col = vec3(0.10, 0.10, 0.15);
float d;
d = op_union(sd_circle(p - vec2(-0.60, 0.0) - offset, 0.12), sd_circle(p - vec2(-0.60, 0.0) + offset, 0.12));
col = mix(col, vec3(1.00, 0.80, 0.20), fill(d));
d = op_intersect(sd_circle(p - vec2(-0.20, 0.0) - offset, 0.15), sd_circle(p - vec2(-0.20, 0.0) + offset, 0.15));
col = mix(col, vec3(0.35, 0.70, 1.00), fill(d));
d = op_subtract(sd_circle(p - vec2(0.20, 0.0), 0.15), sd_circle(p - vec2(0.20, 0.0) - offset, 0.12));
col = mix(col, vec3(0.50, 0.90, 0.50), fill(d));
d = op_smooth_union(sd_circle(p - vec2(0.60, 0.0) - offset, 0.10), sd_circle(p - vec2(0.60, 0.0) + offset, 0.10), 0.15);
col = mix(col, vec3(1.00, 0.45, 0.55), fill(d));
finalColor = vec4(col, 1.0);
}
Ba thời điểm khác nhau (mỗi hàng một lần): hợp (vàng), giao (xanh dương), trừ (xanh lục), hợp mềm (hồng). Hai vòng tròn chạy lại gần rồi xa nhau.
Hợp mềm là điều khó nhất nếu vẽ bằng ảnh: lại gần thì hai vòng hồng dính và thắt eo ở giữa (hàng thứ hai trong ảnh), đến gần hơn nữa thì thành một khối. k là độ "dính": k lớn thì dính từ xa.
- Ghi chú
- Về toán, min của hai SDF vẫn là SDF chính xác ở bên ngoài, còn max (giao, trừ) và hợp mềm cho ra một số đúng dấu (trong hay ngoài luôn đúng) nhưng không còn đúng từng đơn vị khoảng cách. Vẽ hình thì đủ; nếu dùng d để làm quầng sáng hay bóng rất mềm thì hình quầng sáng có thể hơi méo ở chỗ hai hình gặp nhau.
Dùng trong game: giao diện không cần ảnh
Thanh máu, viền nút, vòng hồi chiêu, tất cả là hình đơn giản, và SDF vẽ chúng ở mọi độ phân giải, mọi kích cỡ, không cần ảnh. Ví dụ này vẽ thanh máu và vòng hồi chiêu, đầy 70% (mouse.x là lượng đầy; thử di chuột):
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
uniform vec2 mouse; // mouse.x đóng vai "lượng máu" và "tiến độ hồi chiêu", 0..1
out vec4 finalColor;
float sd_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}
float sd_round_box(vec2 p, vec2 b, float r) {
return sd_box(p, b - r) - r;
}
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
float amount = clamp(mouse.x, 0.0, 1.0);
vec3 col = vec3(0.10, 0.10, 0.15);
// --- Thanh máu: khung bo góc, nền tối, phần đầy cắt bằng một đường thẳng ---
vec2 bar_center = vec2(0.0, -0.30);
vec2 bar_half = vec2(0.40, 0.045);
vec2 q = p - bar_center;
float frame = sd_round_box(q, bar_half, 0.03);
float inner = sd_round_box(q, bar_half - 0.012, 0.02);
float cut = q.x - (-bar_half.x + 2.0 * bar_half.x * amount); // âm bên trái đường cắt
float filled = max(inner, cut); // giao: trong khung VÀ bên trái đường cắt
col = mix(col, vec3(0.30, 0.30, 0.38), fill(frame)); // viền khung
col = mix(col, vec3(0.05, 0.05, 0.08), fill(inner)); // lòng thanh
col = mix(col, vec3(0.90, 0.25, 0.30), fill(filled)); // phần máu
// Quầng sáng: giảm theo khoảng cách ra khỏi khung. exp(-k * d) là cách rẻ nhất để làm "phát sáng".
// Chỉ cộng ở BÊN NGOÀI khung (1 - fill(frame)); bên trong d âm, exp(...) sẽ lớn hơn 1 và làm cả thanh sáng bệch.
col += vec3(0.9, 0.2, 0.25) * 0.25 * exp(-40.0 * max(frame, 0.0)) * (1.0 - fill(frame));
// --- Vòng hồi chiêu: một vòng tròn dày, chỉ tô phần đã hồi, đi theo chiều kim đồng hồ từ đỉnh ---
vec2 c = p - vec2(0.0, 0.10);
float ring = abs(length(c) - 0.17) - 0.028; // vòng tròn dày 0.056
float turn = fract(atan(c.x, -c.y) / 6.2831853); // 0 ở đỉnh, tăng theo chiều kim đồng hồ
float ready = 1.0 - step(amount, turn); // 1 nếu góc này đã hồi xong
col = mix(col, vec3(0.20, 0.22, 0.30), fill(ring)); // vòng nền
col = mix(col, vec3(0.35, 0.80, 1.00), fill(ring) * ready); // phần đã hồi
finalColor = vec4(col, 1.0);
}
Thanh máu (đầy 70%, có quầng sáng đỏ nhẹ) và vòng hồi chiêu đi theo chiều kim đồng hồ từ đỉnh.
Thanh máu. frame là hộp bo góc ngoài, inner là hộp nhỏ hơn bên trong. Phần máu đầy là giao của inner với "bên trái đường cắt": max(inner, cut), trong đó cut = q.x - vị_trí_đường_cắt âm ở bên trái đường cắt. Đổi lượng máu chỉ là dời đường cắt, hình vẫn bo góc đúng.
Vòng hồi chiêu. abs(length(c) - 0.17) - 0.028 là một vòng tròn dày (viền của hình tròn bán kính 0,17). Muốn tô một phần vòng theo góc thì cần góc của điểm: atan(c.x, -c.y) cho 0 ở đỉnh và tăng theo chiều kim đồng hồ, chia cho 2π và lấy phần lẻ (fract) thì được 0 đến 1. Điểm nào có góc nhỏ hơn amount thì đã hồi xong.
Một lỗi tôi gặp khi viết ví dụ này
Bản đầu của thanh máu cộng quầng sáng bằng col += màu * 0.25 * exp(-40.0 * max(frame, 0.0)). Đo pixel thật thì phần máu đỏ ra (255, 76, 92) chứ không phải (230, 64, 77) như tính, và phần lòng thanh (lẽ ra tối) cũng sáng lên (70, 25, 36). Nguyên nhân: bên trong hình frame âm, max(frame, 0.0) bằng 0, và exp(0) bằng 1, nên quầng sáng được cộng đủ mạnh vào cả bên trong. Quầng sáng chỉ nên có ở bên ngoài, nên bản đúng nhân thêm (1.0 - fill(frame)). Bài học chung: hàm của d cho quầng sáng và bóng phải được kiểm tra cả ở phía d âm, không chỉ phía dương mình đang nhìn.
Trong njin
njin dùng đúng loại shader này, và ví dụ ở trên chạy nguyên xi:
#include <njin.h>
namespace {
void startup(njin::context &ctx) {
}
void draw(njin::context &ctx) {
.scale = {size.
x / image.x, size.
y / image.y}});
}
void setup(njin::context &ctx) {
}
}
int main() {
njin::context *ctx =
njin::create({.title =
"SDF trong njin", .width = 960, .height = 540, .target_fps = 60,
.clear_bg_color = {0.2f, 0.25f, 0.3f, 1.0f}});
}
void run(context &ctx)
Chạy vòng lặp chính cho đến khi cửa sổ đóng.
context * create(const config &cfg)
Mở cửa sổ và trả về context của engine.
void destroy(context *ctx)
Giải phóng mọi tài nguyên của engine và đóng cửa sổ.
void ecs_register(context &ctx, sys_phase phase, sys_fnc fnc, const char *name=nullptr)
Thêm một system vào lịch chạy của một phase.
void mod_register(context &ctx, const mod_desc &desc)
Chạy setup của module và đưa các system của nó vào lịch chạy.
@ phase_startup
Một lần, trước frame đầu tiên.
Definition _mod.h:32
@ phase_post_render
Mỗi frame, vẽ trong không gian màn hình (UI).
Definition _mod.h:39
void shader_begin(const context &ctx, shader_handle handle)
Bật shader cho mọi thứ vẽ sau đó, cho đến shader_end().
void shader_end(const context &ctx)
Tắt shader đã bật bằng shader_begin().
void shader_set_vec2(context &ctx, shader_handle handle, const char *name, vec2 value)
Đặt uniform kiểu vec2.
shader_handle shader_load(context &ctx, const char *vspath, const char *fspath)
Nạp và biên dịch một shader.
vec2 texture_size(const context &ctx, texture_handle handle)
Kích thước texture (pixel).
void texture_draw_ex(const context &ctx, texture_handle handle, const texture_draw_desc &desc)
Vẽ texture với vùng nguồn, tỉ lệ, điểm neo, góc xoay và lật.
texture_handle texture_load(context &ctx, const char *path)
Nạp một texture từ file ảnh.
Định danh của một shader, tạo bởi shader_load().
Definition _types.h:101
Tham số đầy đủ cho texture_draw_ex().
Definition njin_draw.h:225
Định danh của một texture, tạo bởi texture_load().
Definition _types.h:146
Vector 2 chiều. Dùng cho vị trí, kích thước, độ dời.
Definition _types.h:39
f32 y
Thành phần dọc (trong không gian màn hình, y tăng khi đi xuống).
Definition _types.h:41
f32 x
Thành phần ngang.
Definition _types.h:40
Cùng shader thanh máu, chạy trong njin: một ảnh kéo giãn thành khung 400 x 225, shader tự tạo hình lên đó.
Các điểm cần nhớ (đã chạy thử):
- Shader không dùng màu của ảnh, chỉ dùng toạ độ fragTexCoord chạy 0 đến 1 trên ảnh đang vẽ. Nên bất kỳ ảnh nào kéo giãn ra đúng kích cỡ khung cần là đủ; đổi kích cỡ khung là đổi scale của njin::texture_draw_desc.
- resolution là kích thước của khung vẽ, không phải của cửa sổ, vì nó dùng để giữ hình tròn không bị dẹt. Đặt bằng njin::shader_set_vec2() trước njin::shader_begin().
- Vẽ trong phase_post_render cho giao diện (không đi qua camera), hoặc phase_render cho vật trong thế giới.
- Thấy shader hậu kỳ có sẵn của njin dùng đúng ý tưởng này: vignette của njin::post_fx đo khoảng cách từ tâm màn hình (length(...) trong src/engine/runtime/modules/post_fx.cpp) rồi smoothstep, tức là SDF của một điểm.
- njin chưa có hàm vẽ SDF dựng sẵn, cũng chưa hỗ trợ font SDF (đã tìm trong mã nguồn): với thanh máu, viền nút, chữ, ảnh vẫn là cách thường dùng; SDF hợp nhất cho hình đơn giản cần nhìn sắc ở mọi kích cỡ và cần hiệu ứng theo khoảng cách (viền, sáng, bóng).
Khi nào nên dùng, khi nào thôi
Nên: hình hình học đơn giản (tròn, hộp, vòng, đoạn thẳng), giao diện kiểu thanh và vòng, sóng xung kích, quầng sáng, vùng tầm đánh, bất cứ thứ gì cần mép mượt ở mọi kích cỡ hoặc tham số hoá bằng số (lượng máu, tiến độ).
Không nên: hình phức tạp như nhân vật, cây, chữ tay vẽ. Chúng có sẵn trong ảnh, và vẽ bằng công thức chỉ tốn công. Giá mỗi pixel cũng tỉ lệ với số hình trong shader (mỗi hình là một lần tính d cho mọi pixel của khung), nên đừng nhét hàng chục hình vào một shader phủ cả màn hình. Nếu chỉ cần vài hình, giữ khung vẽ nhỏ (như 400 x 225 ở trên) thì chi phí là số pixel của khung, không phải của cả màn hình.
Tự kiểm tra
- d = -0.05 ở một điểm nghĩa là gì? Còn d = 0.3?
- Vì sao hộp bo góc là sd_box(p, b - r) - r chứ không phải sd_box(p, b) - r?
- Vì sao dùng fwidth(d) tốt hơn smoothstep(0.28, 0.30, d) với hai số cố định?
- Muốn vẽ viền của một hình (chỉ đường bao, ruột rỗng) thì dùng công thức nào?
- Hợp mềm khác hợp thường ở điểm nào khi hai hình ở gần nhau?
Bài tập
- Sóng xung kích: một vòng tròn mỏng nở ra từ giữa rồi mờ dần, lặp lại (dùng time).
- Dấu cộng: vẽ một dấu cộng ở giữa bằng phép ghép hình.
- Bóng đổ: một hộp bo góc sáng trên nền sáng, có bóng mềm dời xuống dưới bên phải.
Đáp án
Tự kiểm tra
- -0.05: điểm ở trong hình, cách mép 0,05. 0.3: điểm ở ngoài hình, cách mép 0,3.
- sd_box(p, b) đã là hộp đầy đủ kích thước; trừ r phồng nó ra thành hộp to hơn r mỗi phía. Muốn giữ đúng kích thước b, phải thu hộp lại r trước (b - r), rồi phồng ra r: hình cuối vẫn vừa khung b, chỉ góc tròn.
- Hai số cố định là khoảng cách trong đơn vị của hình, nên mép chỉ mượt đúng ở một kích cỡ: phóng to thì mép bị mờ rộng ra, thu nhỏ thì mép răng cưa. fwidth(d) đo "một pixel dài bao nhiêu" nên tự thích ứng.
- abs(d) - w: gần bằng 0 chỉ ở sát mép, và w là nửa bề dày viền.
- Hợp thường min(a, b) giữ hai hình nguyên vẹn và chỉ chạm nhau ở một góc nhọn. Hợp mềm làm khoảng giữa hai hình đầy lên thành cổ nối tròn, nên chúng dính như giọt nước và tách ra rồi cũng thắt eo dần.
Bài tập 1: sóng xung kích
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
uniform float time;
out vec4 finalColor;
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
float life = fract(time * 0.4); // 0 lúc vừa nổ, tiến dần đến 1, rồi lặp lại
float radius = life * 0.45;
float d = abs(length(p) - radius) - 0.015; // vòng: cách đường tròn bán kính `radius` không quá 0.015
float alpha = fill(d) * (1.0 - life); // mờ dần khi vòng nở ra
vec3 col = mix(vec3(0.10, 0.10, 0.15), vec3(1.0, 0.85, 0.4), alpha);
finalColor = vec4(col, 1.0);
}
Hai thời điểm: vòng nở ra và mờ đi.
Bán kính tăng theo fract(time * 0.4), abs(length(p) - radius) - 0.015 là vòng dày 0,03, và độ đậm nhân với 1 - life. Mình đo trên ảnh chụp: vòng sáng đạt cực đại ở bán kính 0,177 rồi 0,335 (hai thời điểm), tròn đều ở cả bốn hướng.
Bài tập 2: dấu cộng
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
out vec4 finalColor;
float sd_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
// Dấu cộng = hợp của một hộp nằm ngang và một hộp đứng.
float sd_cross(vec2 p) {
return min(sd_box(p, vec2(0.20, 0.06)), sd_box(p, vec2(0.06, 0.20)));
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
vec3 col = mix(vec3(0.10, 0.10, 0.15), vec3(0.95, 0.35, 0.40), fill(sd_cross(p)));
finalColor = vec4(col, 1.0);
}
Hợp của hộp nằm ngang và hộp đứng.
Kiểm tra trên ảnh: tâm và bốn cánh có màu, bốn góc trống.
Bài tập 3: bóng đổ
#version 330
in vec2 fragTexCoord;
uniform vec2 resolution;
out vec4 finalColor;
float sd_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}
float sd_round_box(vec2 p, vec2 b, float r) {
return sd_box(p, b - r) - r;
}
float fill(float d) {
float aa = fwidth(d);
return 1.0 - smoothstep(-aa, aa, d);
}
void main() {
vec2 p = (fragTexCoord - 0.5) * vec2(resolution.x / resolution.y, 1.0);
vec2 half_size = vec2(0.22, 0.10);
float d = sd_round_box(p, half_size, 0.05);
// Bóng đổ = CÙNG hình, dời xuống dưới bên phải, nhưng làm mờ bằng một dải smoothstep rộng thay vì một pixel.
float shadow_d = sd_round_box(p - vec2(0.025, 0.035), half_size, 0.05);
float shadow = 1.0 - smoothstep(-0.03, 0.06, shadow_d);
vec3 col = vec3(0.55, 0.60, 0.70); // nền sáng để thấy bóng
col *= 1.0 - 0.55 * shadow; // tối đi theo độ đậm của bóng
col = mix(col, vec3(0.95, 0.95, 1.0), fill(d)); // hình vẽ đè lên bóng
finalColor = vec4(col, 1.0);
}
Bóng nằm dưới bên phải, mờ dần ra ngoài.
Bóng là cùng hình đã dời, nhưng thay vì mép một pixel thì dùng smoothstep(-0.03, 0.06, d): dải mờ rộng nhiều lần. Đây chính là lợi ích của biết d: mép mềm bao nhiêu tuỳ ý. Đo trên ảnh: cạnh dưới phải của hộp tối hơn nền xa (122, 133, 156) so với (140, 153, 178), và phía đối diện (trên trái) sáng bằng nền.
Mọi ví dụ và đáp án trong bài này đã chạy trong sân chơi, ảnh chụp được đối chiếu với giá trị tính tay tại những điểm cố định (màu tâm mỗi hình, góc bị cắt của hộp bo góc, mép mượt, phần đầy của thanh máu và vòng). Ví dụ chạy trong njin là learn_shader_sdf_njin.cpp, dùng chính shader learn_shader_sdf_ui.fs. Chỉ thử trên một card đồ họa (Intel Iris Xe).
Bước tiếp theo
Bài 12: Các mẫu shader hay gặp : các mẫu shader hay gặp (hậu kỳ, nháy trắng, viền, tan biến, pixel art) và cách chúng ứng với njin, trong đó có sprite_dissolve đã dựng sẵn.