njin
Engine 2D gọn nhẹ để tự tay làm game theo ý mình. Vibe coding cũng ổn luôn
Loading...
Searching...
No Matches
Shader nâng cao: ảnh phụ, mảng uniform, ánh sáng

Trang này dành cho khi shader một ảnh với vài số (amount, time) không còn đủ: shader cần đọc thêm một ảnh (bảng màu, nhiễu, mặt nạ), nhận một danh sách (các nguồn sáng), hoặc chạy trên cả khung hình.

Muốn ánh sáng (đèn, bóng đổ, vật liệu) thì engine đã có sẵn, xem Ánh sáng 2D (PBR); trang này dạy cách tự viết một shader như vậy và nhiều thứ khác.

Cần biết trước: phần Shader của Vẽ ảnh và shader, và ba bài shader trong Kiến thức nền: học trước khi làm game (bài 10 đến 12). Chạy njin_render_demo rồi bấm phím 7, 8, 9 để xem mọi thứ dưới đây hoạt động.

Bốn thứ engine cho shader

Cần Dùng Ghi chú
Đọc thêm một ảnh ngoài texture0 njin::shader_set_texture() Tối đa 4 ảnh phụ mỗi shader. Nhận texture hoặc render texture
Nhận một danh sách số njin::shader_set_vec4_array() Mảng vec4. vec2 và vec3 đóng vào vec4
Chạy trên cả khung hình njin::camera_set_post_shader() Một lượt vẽ toàn màn hình, không tính UI
Chạy nhiều lượt nối nhau njin::render_texture_begin() rồi njin::shader_set_texture() Lượt trước vẽ vào render texture, lượt sau đọc nó

Ví dụ: đêm, bảng màu và sương mù trong một shader

Một fragment shader chạy trên cả khung hình, với ba hiệu ứng độc lập, mỗi cái có một uniform từ 0 đến 1 để bật dần:

  • Đêm (night): khung hình tối xuống, rồi mỗi nguồn sáng thêm một vệt sáng. Các nguồn sáng là một mảng vec4.
  • Hoàng hôn (dusk): độ sáng của từng pixel được tra trong một ảnh bảng màu 256 x 1 (ramp), nên tối thành xanh tím, sáng thành kem ấm.
  • Sương mù (haze): hai lần lấy mẫu một ảnh nhiễu (noise) đẩy nhẹ chỗ shader đọc khung hình, nên cả cảnh gợn như hơi nóng.
#version 330
// The demo's whole-frame pass (camera_set_post_shader): night with lights (7),
// a colour ramp for dusk (8) and haze (9). Each is off at 0, so with all three
// off the frame comes out as it went in.
in vec2 fragTexCoord;
in vec4 fragColor;
uniform sampler2D texture0; // the frame the game drew
uniform vec4 colDiffuse;
uniform sampler2D ramp; // 256 x 1: what brightness turns into at dusk
uniform sampler2D noise; // tiling grey noise
uniform float time;
uniform vec2 resolution; // logical screen size, in pixels
uniform vec3 ambient; // the light everywhere at night, before any lamp
uniform float night;
uniform float dusk;
uniform float haze;
const int max_lights = 8;
uniform int light_count;
uniform vec4 lights[max_lights]; // xy: position in pixels, z: radius in pixels, w: strength
uniform vec4 light_colors[max_lights]; // rgb: colour
out vec4 finalColor;
void main() {
// The frame is stored upside down: v is 1 at the top of the screen.
vec2 uv = fragTexCoord;
vec2 pixel = vec2(uv.x, 1.0 - uv.y) * resolution;
// Haze: two slow drifting noise samples nudge where the frame is read from.
vec2 spot = uv * vec2(resolution.x / resolution.y, 1.0) * 3.0;
vec2 nudge = vec2(texture(noise, spot + vec2(time * 0.04, time * 0.02)).r,
texture(noise, spot + vec2(0.37 - time * 0.03, time * 0.05)).r) - 0.5;
vec3 c = texture(texture0, uv + nudge * haze * 0.03).rgb;
// Dusk: look the brightness up in the ramp.
float lum = dot(c, vec3(0.299, 0.587, 0.114));
c = mix(c, texture(ramp, vec2(lum, 0.5)).rgb, dusk);
// Night: a dim blue everywhere, plus each light's pool.
vec3 lit = ambient;
for (int i = 0; i < light_count; i++) {
float d = clamp(1.0 - length(pixel - lights[i].xy) / lights[i].z, 0.0, 1.0);
lit += light_colors[i].rgb * lights[i].w * d * d;
}
c *= mix(vec3(1.0), lit, night);
finalColor = vec4(c, 1.0);
}

Phía C++ nạp shader, gắn hai ảnh phụ một lần, rồi mỗi frame đặt uniform và danh sách đèn:

#include <njin.h>
#include <cmath>
namespace {
entt::entity hero = entt::null; // nhân vật, game tự tạo ở chỗ khác
void load(njin::context &ctx) {
// nullptr: giữ vertex shader mặc định, chỉ thay fragment shader.
scene = njin::shader_load(ctx, nullptr, "assets/scene.fs");
// Ảnh phụ: gắn một lần, engine gắn lại mỗi lần shader chạy. Dùng texture_load,
// không dùng ảnh trong atlas.
njin::shader_set_texture(ctx, scene, "ramp", njin::texture_load(ctx, "assets/ramp.png"));
njin::shader_set_texture(ctx, scene, "noise", njin::texture_load(ctx, "assets/noise.png"));
// Áp lên cả thế giới mà camera vẽ. UI không bị ảnh hưởng.
}
// Đặt uniform mỗi frame, trước khi thế giới được vẽ.
void update_scene(njin::context &ctx) {
const float t = njin::elapsed(ctx);
njin::shader_set_f32(ctx, scene, "time", t);
njin::shader_set_vec2(ctx, scene, "resolution", njin::screen_size(ctx));
njin::shader_set_vec3(ctx, scene, "ambient", {0.10f, 0.13f, 0.24f}); // xanh đêm
njin::shader_set_f32(ctx, scene, "night", 1.0f);
njin::shader_set_f32(ctx, scene, "dusk", 0.0f);
njin::shader_set_f32(ctx, scene, "haze", 0.0f);
// Đèn tính bằng pixel màn hình: xy là vị trí, z là bán kính, w là độ sáng.
// Màu của từng đèn nằm ở mảng thứ hai, cùng chỉ số.
const njin::vec2 torch = njin::w2scr(ctx, njin::world(ctx).get<njin::transform>(hero).pos);
const njin::vec4 lights[2] = {
{torch.x, torch.y, 190.0f, 1.0f + 0.08f * std::sin(t * 11.0f)}, // đuốc, chập chờn
{njin::mouse_pos(ctx).x, njin::mouse_pos(ctx).y, 130.0f, 0.9f}, // đèn ở con trỏ chuột
};
const njin::vec4 colors[2] = {
{1.0f, 0.75f, 0.4f, 0.0f}, // vàng cam
{0.4f, 0.6f, 1.0f, 0.0f}, // xanh lam
};
njin::shader_set_i32(ctx, scene, "light_count", 2);
njin::shader_set_vec4_array(ctx, scene, "lights", lights, 2);
njin::shader_set_vec4_array(ctx, scene, "light_colors", colors, 2);
}
void setup(njin::context &ctx) {
njin::ecs_register(ctx, njin::phase_pre_render, update_scene, "update_scene");
}
} // namespace
njin::mod_desc scene_shader_module() {
return {.name = "scene_shader", .setup = setup};
}
vec2 w2scr(const context &ctx, vec2 pos)
Đổi một điểm từ thế giới sang pixel màn hình, qua camera_active().
void camera_set_post_shader(context &ctx, shader_handle shader)
Áp một shader hậu kỳ lên toàn bộ thế giới đi qua camera.
vec2 screen_size(const context &ctx)
Trả về kích thước màn hình mà game vẽ lên, tính bằng pixel.
vec2 mouse_pos(const context &ctx)
Vị trí con trỏ chuột, tính bằng pixel màn hình.
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.
entt::registry & world(context &ctx)
Trả về registry của EnTT chứa mọi entity và component của game.
@ phase_startup
Một lần, trước frame đầu tiên.
Definition _mod.h:32
@ phase_pre_render
Mỗi frame, trước khi vẽ thế giới.
Definition _mod.h:37
void shader_set_vec4_array(context &ctx, shader_handle handle, const char *name, const vec4 *values, u32 count)
Đặt uniform kiểu mảng vec4, ví dụ uniform vec4 lights[8];.
void shader_set_vec3(context &ctx, shader_handle handle, const char *name, vec3 value)
Đặt uniform kiểu vec3.
void shader_set_texture(context &ctx, shader_handle handle, const char *name, texture_handle texture)
Gắn một texture vào uniform sampler2D của shader, ngoài texture0.
void shader_set_f32(context &ctx, shader_handle handle, const char *name, f32 value)
Đặt uniform kiểu float.
void shader_set_i32(context &ctx, shader_handle handle, const char *name, i32 value)
Đặt uniform kiểu int.
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.
texture_handle texture_load(context &ctx, const char *path)
Nạp một texture từ file ảnh.
f32 elapsed(const context &ctx)
Thời gian đã trôi qua kể từ lúc mở cửa sổ, tính bằng giây.
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Định danh của một shader, tạo bởi shader_load().
Definition _types.h:101
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
Vector 4 chiều. Dùng để truyền giá trị vec4 vào uniform của shader.
Definition _types.h:54
Đêm (phím 7): đèn của nhân vật, một đèn ở con trỏ chuột và bốn đèn cố định, đều nằm trong một mảng vec4. Ngọn đèn chập chờn vì độ sáng đổi theo thời gian
Hoàng hôn (phím 8): độ sáng mỗi pixel được tra trong ảnh bảng màu 256 x 1, nên cả cảnh có một dải màu duy nhất
Sương mù (phím 9): ảnh nhiễu đẩy chỗ đọc khung hình, cây và mặt nước gợn nhẹ
Sương mù, ảnh tĩnh

Ba phím chạy cùng một shader, đặt night, dusk, haze về 0 hoặc 1. Bật cả ba thì chúng chồng lên nhau. Khi cả ba đều 0, njin_render_demo tắt shader bằng camera_set_post_shader(ctx, {}), để không phải trả tiền cho một lượt vẽ thừa. Các hiệu ứng dựng sẵn (blur, bloom, CRT ở phím 4 đến 6) chạy trước, nên shader của bạn thấy kết quả của chúng.

Ảnh phụ: njin::shader_set_texture()

njin::shader_set_texture(ctx, scene, "ramp", njin::texture_load(ctx, "assets/ramp.png"));

Trong shader, khai báo uniform sampler2D ramp; và lấy mẫu như texture0. Gọi hàm một lần là đủ: engine gắn lại ảnh mỗi khi shader chạy (shader_begin, camera_set_post_shader, draw_instanced). Ảnh nạp lại khi hot reload thì shader thấy ảnh mới.

Những điều cần biết:

  • Tối đa 4 ảnh phụ mỗi shader; cái thứ năm bị bỏ qua và ghi một dòng cảnh báo. Đặt lại cùng tên thì thay ảnh.
  • Dùng ảnh riêng nạp bằng njin::texture_load(). Ảnh xếp trong atlas bị từ chối kèm cảnh báo, vì shader sẽ thấy cả trang atlas chứ không phải riêng ảnh đó.
  • Bộ lọc là của ảnh: bảng màu thì để filter_linear (mặc định) cho chuyển màu mượt; ảnh pixel art cần lấy mẫu chính xác thì njin::texture_set_filter() với filter_nearest.
  • Ảnh nhiễu nên lặp được (tiling): mặc định ảnh lặp lại khi toạ độ vượt quá 1, nên uv * 3.0 đọc ba lần ảnh liền mạch nếu ảnh không có đường nối. assets/noise.png được tools/make_assets.py sinh ra theo cách đó.
  • Render texture lưu ngược trục dọc. Khi gắn một render texture, đọc bằng vec2(uv.x, 1.0 - uv.y). Chính khung hình mà camera_set_post_shader đưa vào texture0 cũng ngược như vậy: trong ví dụ trên, pixel được tính bằng vec2(uv.x, 1.0 - uv.y) * resolution cho đúng chiều với toạ độ màn hình.
  • Đừng gắn render texture đang được vẽ vào (giữa njin::render_texture_begin() và njin::render_texture_end()).

Chỗ nào ảnh phụ đáng tin cậy

Cách bật shader Ảnh phụ
njin::camera_set_post_shader() Tốt: một lượt vẽ toàn màn hình
njin::draw_instanced() Tốt: engine tự gắn lại cho từng lệnh vẽ
njin::shader_begin() rồi vẽ sprite Chỉ sống đến lần raylib đẩy batch kế tiếp

Với shader_begin(), raylib gom các lệnh vẽ thành một batch và quên ảnh phụ mỗi lần đẩy batch: khi batch đầy (8192 hình), sau 256 lần đổi texture, hoặc khi có njin::draw_instanced() hay đổi render texture xen vào. Dùng cho vài lệnh vẽ lớn (một lớp nền, vài sprite), không cho hàng nghìn sprite. Cần hàng nghìn hình có ảnh phụ thì dùng njin::draw_instanced().

Mảng uniform: njin::shader_set_vec4_array()

const njin::vec4 lights[2] = {{320.0f, 180.0f, 190.0f, 1.0f}, {900.0f, 500.0f, 130.0f, 0.9f}};
njin::shader_set_vec4_array(ctx, scene, "lights", lights, 2);

Trong shader: uniform vec4 lights[8];. Tên truyền vào là lights, không kèm [0]. Vài điều:

  • count không được lớn hơn kích thước khai báo trong shader. Số phần tử tối đa còn do card đồ họa quyết định; vài chục là an toàn.
  • Mảng chưa dùng hết là bình thường: đặt thêm một int (như light_count) cho biết dùng bao nhiêu, và shader lặp đến đó.
  • vec2 và vec3 đóng vào vec4. Một vec3 màu thì dùng .rgb của phần tử vec4; hai vec2 thì xy và zw. Trong ví dụ, vị trí, bán kính và độ sáng của một đèn nằm chung một vec4, màu ở một mảng thứ hai cùng chỉ số.
  • Như mọi uniform, đặt trước khi shader chạy. Với shader hậu kỳ của camera, đặt ở phase_pre_render là đủ sớm.
  • Toạ độ đèn trong ví dụ là pixel màn hình (tính bằng njin::w2scr()), nên vệt sáng đúng chỗ dù camera zoom hay cuộn.

Nhiều lượt vẽ nối nhau

Muốn lượt sau đọc kết quả của lượt trước (mặt nạ ánh sáng, ảnh mờ của nền), vẽ lượt trước vào một render texture rồi gắn nó cho shader của lượt sau:

mask = njin::render_texture_load(ctx, 256, 256);
njin::shader_set_texture(ctx, sheet_shader, "mask", mask); // một lần, ở phase_startup
// mỗi frame, ở phase_post_update hoặc phase_post_render:
njin::render_texture_begin(ctx, mask, {0.0f, 0.0f, 0.0f, 1.0f});
// ... vẽ mặt nạ ...
void render_texture_end(const context &ctx)
Kết thúc vẽ vào render texture.
void render_texture_begin(const context &ctx, render_texture_handle handle)
Bắt đầu vẽ vào render texture, giữ nguyên nội dung cũ.
render_texture_handle render_texture_load(context &ctx, u32 width, u32 height)
Tạo một render texture: ảnh ngoài màn hình để vẽ vào, rồi vẽ lại như một texture.

njin::render_texture_begin() đặt lại phép biến đổi của camera, nên chỉ gọi ngoài phase thế giới. Quy tắc phase đầy đủ ở Hậu kỳ (post-processing). Nhớ đọc bằng 1.0 - uv.y trong shader (xem trên).

Sửa shader khi game đang chạy

Bật hot reload trong game của bạn (njin::hot_reload_enable(), xem Vẽ ảnh và shader) thì lưu file .fs là shader được nạp lại ngay; lỗi biên dịch thì shader cũ giữ nguyên và lỗi hiện trong log. Cách nhanh nhất để chỉnh độ rộng vệt sáng hay hệ số sương mù. njin_render_demo không bật hot reload: thêm một dòng vào main.cpp nếu muốn thử.

Chưa có

Những thứ sau chưa có trong API vì chưa game nào cần: uniform mat4, ảnh phụ bền qua mọi lần đẩy batch khi vẽ hàng nghìn sprite bằng shader_begin (dùng njin::draw_instanced() hoặc lượt vẽ toàn màn hình), và uniform dựng sẵn như thời gian, độ phân giải (tự đặt bằng njin::shader_set_f32() và njin::shader_set_vec2(), như trong ví dụ).