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
Hậu kỳ (post-processing)

Hậu kỳ là áp shader lên cả khung hình sau khi đã vẽ xong: làm mờ, CRT, viền tối (vignette), đổi màu...

Các hiệu ứng hay dùng đã có sẵn, không cần viết shader: xem Hiệu ứng dựng sẵn ở cuối trang. Phần đầu trang dành cho khi bạn muốn tự viết shader.

Nguyên tắc:

flowchart LR
  A[Vẽ cảnh<br/>vào render texture]:::render --> B[Vẽ render texture<br/>ra màn hình<br/>qua shader]:::engine

Render texture là một ảnh ngoài màn hình. Bạn vẽ cảnh vào nó, rồi vẽ nó ra màn hình với một shader bật lên. Shader chạy trên từng pixel của cả cảnh.

Các shader dựng sẵn được biên dịch lúc game khởi động, không phải lúc bật hiệu ứng lần đầu, nên mở menu tạm dừng với blur không làm khựng frame. blur từ 3 pixel trở lên chạy ở nửa độ phân giải (chỉ chạm một phần tư số pixel) rồi phóng lại; nhỏ hơn thì chạy ở độ phân giải đầy đủ.

Ví dụ

#include <njin.h>
namespace {
void load(njin::context &ctx) {
const njin::vec2 size = njin::screen_size(ctx);
scene = njin::render_texture_load(ctx, (njin::u32)size.x, (njin::u32)size.y);
player_tex = njin::texture_load(ctx, "assets/player.png");
effect = njin::shader_load(ctx, nullptr, "assets/shaders/crt.fs");
}
// Bước 1: vẽ cảnh vào render texture. Đặt ở phase_post_update vì
// render_texture_begin đặt lại phép biến đổi của camera.
void draw_scene(njin::context &ctx) {
const njin::rgba black{0.0f, 0.0f, 0.0f, 1.0f};
const njin::rgba white{1.0f, 1.0f, 1.0f, 1.0f};
njin::render_texture_begin(ctx, scene, black); // xóa đen rồi vẽ
njin::texture_draw(ctx, player_tex, {100.0f, 100.0f}, white);
}
// Bước 2: vẽ render texture ra màn hình qua shader hậu kỳ.
void present(njin::context &ctx) {
const njin::rgba white{1.0f, 1.0f, 1.0f, 1.0f};
njin::shader_set_f32(ctx, effect, "time", njin::elapsed(ctx));
njin::shader_begin(ctx, effect);
njin::render_texture_draw(ctx, scene, {0.0f, 0.0f}, white);
}
void setup(njin::context &ctx) {
njin::ecs_register(ctx, njin::phase_post_render, present); // không gian màn hình
}
} // namespace
njin::mod_desc post_fx_module() {
return {.name = "post_fx", .setup = setup};
}
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.
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.
@ phase_post_update
Mỗi frame, sau khi cập nhật. Event được phát ngay sau phase này.
Definition _mod.h:36
@ 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 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ũ.
void render_texture_draw(const context &ctx, render_texture_handle handle, vec2 pos, rgba tint)
Vẽ nội dung render texture theo đúng chiều, góc trên trái tại pos.
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.
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_f32(context &ctx, shader_handle handle, const char *name, f32 value)
Đặt uniform kiểu float.
shader_handle shader_load(context &ctx, const char *vspath, const char *fspath)
Nạp và biên dịch một shader.
void texture_draw(const context &ctx, texture_handle handle, vec2 pos, rgba tint)
Vẽ texture với góc trên trái tại pos.
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.
std::uint32_t u32
Số nguyên không dấu 32 bit.
Definition _types.h:22
constexpr rgba white
Trắng.
Definition _math.h:234
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Định danh của một render texture, tạo bởi render_texture_load().
Definition _types.h:307
Màu gồm đỏ, xanh lá, xanh dương và alpha.
Definition _types.h:64
Định danh của một shader, tạo bởi shader_load().
Definition _types.h:101
Đị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

Hai bước ở hai phase khác nhau:

Bước Phase Vì sao
Vẽ cảnh vào render texture phase_post_update njin::render_texture_begin() đặt lại phép biến đổi của camera nên không được gọi trong lúc camera đang bật
Vẽ render texture ra màn hình phase_post_render Đây là không gian màn hình, không có camera, nên ảnh phủ đúng cả cửa sổ

Các hàm

Hàm Việc làm
njin::render_texture_load() Tạo render texture với kích thước cho trước
njin::render_texture_begin() Bắt đầu vẽ vào nó. Có bản nhận thêm màu để xóa trước
njin::render_texture_end() Kết thúc vẽ vào nó
njin::render_texture_draw() Vẽ nội dung của nó ra, theo đúng chiều
njin::render_texture_size() Kích thước (pixel)
njin::render_texture_unload() Giải phóng

Kích thước thường bằng njin::screen_size(). Nếu đổi kích thước cửa sổ, hãy tạo lại render texture.

Hậu kỳ cho cả thế giới qua camera

Cách trên áp hậu kỳ lên những thứ bạn tự vẽ vào render texture. Muốn áp lên toàn bộ thế giới mà camera đang vẽ (sprite, tilemap, mọi thứ trong phase_render), chỉ cần một dòng:

njin::camera_set_post_shader(ctx, effect); // bật
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.

Engine vẽ thế giới vào một ảnh ngoài màn hình có kích thước bằng cửa sổ (tự tạo lại khi cửa sổ đổi kích thước), rồi vẽ ảnh đó ra màn hình qua shader. UI trong phase_post_render vẽ sau đó nên không bị ảnh hưởng. Đặt uniform như bình thường bằng các hàm shader_set_*().

Shader hậu kỳ đọc được thêm ảnh phụ (bảng màu, nhiễu) và mảng uniform (danh sách đèn): xem Shader nâng cao: ảnh phụ, mảng uniform, ánh sáng, có ví dụ đêm với đèn, hoàng hôn bằng bảng màu và sương mù bằng nhiễu.

Hiệu ứng dựng sẵn

njin::post_fx_set() bật các hiệu ứng có sẵn lên toàn bộ thế giới, không cần viết shader. Giống shader ở trên, chúng không đụng đến UI trong phase_post_render.

#include <njin.h>
namespace {
struct player_state {
njin::i32 hp = 10;
njin::i32 max_hp = 10;
bool paused = false;
njin::f32 pause_blend = 0.0f; // 0 đang chơi, 1 menu pause hiện hẳn
};
player_state p;
void update(njin::context &ctx) {
p.paused = !p.paused;
njin::time_set_paused(ctx, p.paused);
}
// Chuyển mượt vào và ra menu pause, theo giờ thật vì game đang dừng.
const njin::f32 step = njin::delta_real(ctx) * 4.0f;
p.pause_blend = njin::move_toward(p.pause_blend, p.paused ? 1.0f : 0.0f, step);
// Máu càng thấp, viền đỏ càng đậm.
const njin::f32 lost = 1.0f - (njin::f32)p.hp / (njin::f32)p.max_hp;
hurt.vignette *= lost;
hurt.saturation = njin::lerp(1.0f, hurt.saturation, lost);
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc post_builtin_module() { return {.name = "post_builtin", .setup = setup}; }
bool key_pressed(const context &ctx, key_code key)
Đúng ở frame mà phím vừa được nhấn xuống (chỉ một frame).
@ key_p
Phím P.
Definition _types.h:359
constexpr f32 move_toward(f32 from, f32 to, f32 max_step)
Di chuyển from về phía to một đoạn tối đa max_step, không vượt quá.
Definition _math.h:203
constexpr vec3 lerp(vec3 a, vec3 b, f32 t)
Nội suy tuyến tính giữa hai vector.
Definition _math.h:158
@ phase_update
Mỗi frame, logic chính của game.
Definition _mod.h:35
void post_fx_set(context &ctx, const post_fx &fx)
Đặt các hiệu ứng post-processing dựng sẵn.
post_fx post_fx_lerp(const post_fx &a, const post_fx &b, f32 t)
Nội suy giữa hai bộ hiệu ứng, để chuyển mượt (ví dụ vào menu pause).
f32 delta_real(const context &ctx)
Thời gian thật của frame trước, không bị tốc độ thời gian hay tạm dừng ảnh hưởng.
void time_set_paused(context &ctx, bool paused)
Tạm dừng hoặc chạy tiếp thời gian của game.
std::int32_t i32
Số nguyên có dấu 32 bit.
Definition _types.h:14
float f32
Số thực 32 bit.
Definition _types.h:28
post_fx paused()
Menu pause: thế giới mờ và tối phía sau UI.
Definition njin_post.h:186
post_fx hurt()
Bị thương hoặc máu thấp: viền đỏ, hơi nhạt màu.
Definition njin_post.h:175
Các hiệu ứng post-processing dựng sẵn, áp lên thế giới (không lên UI).
Definition njin_post.h:20
f32 saturation
Độ bão hòa. 0 là đen trắng, 1 là giữ nguyên.
Definition njin_post.h:25
f32 vignette
Độ đậm, 0..1. 0 là tắt.
Definition njin_post.h:32
Hiệu ứng Trường Tắt khi
Chỉnh màu brightness, contrast, saturation, sepia, tint 0, 1, 1, 0, trắng
Vignette (tối viền) vignette, vignette_radius, vignette_softness, vignette_color vignette = 0
Bloom (quầng sáng) bloom, bloom_threshold, bloom_radius bloom = 0
Làm mờ blur (pixel) 0
Độ sâu trường ảnh (3D) dof (pixel), dof_focus, dof_range, dof_falloff, dof_center, dof_radius, dof_haze dof = 0
Tách màu chromatic (pixel) 0
Sọc CRT scanlines, scanline_size scanlines = 0
Cong CRT crt_curve 0
Pixel hóa pixelate (cỡ ô, pixel) dưới 2
Nhiễu hạt grain 0

Ảnh dưới đây chụp từ njin_render_demo: không hậu kỳ, rồi bật CRT (phím 6), bloom (phím 5) và blur (phím 4). Bloom chỉ lộ ra ở chỗ có vùng sáng, nên ảnh của nó ghép hai bản (tắt và bật) của một cảnh có hạt sáng.

Không hậu kỳ
CRT (phím 6): sọc ngang và màn hình cong ở các cạnh
Bloom (phím 5), cùng một cảnh: trái là tắt, phải là bật. Vùng sáng tỏa ra xung quanh, thấy rõ nhất ở cụm hạt vàng góc dưới phải, và quầng xanh quanh đài phun rộng hơn
Blur: làm mờ cả cảnh

Bộ có sẵn trong namespace njin::post: njin::post::crt(), njin::post::noir(), njin::post::vintage(), njin::post::dream(), njin::post::glow(), njin::post::retro(), njin::post::hurt(), njin::post::paused().

Độ sâu trường ảnh

dof làm mờ những gì gần hơn hay xa hơn một khoảng cách, như ống kính máy ảnh lấy nét: nét trong khoảng dof_focus ± dof_range, rồi mờ dần trên quãng dof_falloff tới mức dof pixel. Engine đọc độ sâu mà lần vẽ 3D (begin_3d() ... end_3d()) của frame để lại, nên hiệu ứng chỉ áp cho cảnh 3D; frame không vẽ 3D thì bỏ qua. Khoảng cách đo theo hướng nhìn của camera, bằng đơn vị 3D.

Lấy nét vào một vật: khoảng cách là hình chiếu của vector từ camera tới vật lên hướng nhìn.

const njin::vec3 look = njin::normalize(cam.target - cam.position);
njin::post_fx fx{};
fx.dof = 7.0f; // mờ nhất 7 pixel
fx.dof_focus = njin::dot(hero_pos - cam.position, look);
fx.dof_range = 2.0f; // nét trong ±2 đơn vị quanh vật
fx.dof_falloff = 6.0f; // mờ dần trên 6 đơn vị tiếp theo
njin::post_fx_set(ctx, fx);

Lấy nét quanh một điểm thay vì theo khoảng cách tới camera: đặt dof_radius > 0. Độ nét khi đó tính theo khoảng cách từ mỗi điểm của cảnh tới dof_center (engine dựng lại vị trí thế giới của từng pixel từ độ sâu), nên vùng nét là một quả cầu quanh vật, nhìn từ trên xuống là một vòng tròn, mờ dần trên dof_falloff. dof_haze phủ thêm một màu lên phần ngoài tiêu điểm, đậm theo độ mờ, như sương.

njin::post_fx fx{};
fx.dof = 7.0f;
fx.dof_center = house_pos; // tâm vùng nét, đơn vị 3D
fx.dof_radius = 2.5f; // nét trong 2,5 đơn vị quanh nó
fx.dof_falloff = 4.0f; // mờ dần trên 4 đơn vị tiếp theo
fx.dof_haze = {0.8f, 0.8f, 0.78f, 0.35f}; // sương màu trời, đậm 35% ở chỗ mờ nhất
njin::post_fx_set(ctx, fx);

Cảnh được làm mờ một lần ở nửa kích thước rồi trộn với ảnh nét theo độ sâu từng pixel: bốn lượt vẽ toàn màn hình, như blur rộng. UI vẽ trong phase_post_render không bị mờ.

Thứ tự áp:

flowchart LR
  A[Thế giới]:::render --> B[blur]:::engine --> B2[độ sâu trường ảnh]:::engine --> C[bloom]:::engine --> D["một lượt: cong CRT,<br/>pixel hóa, tách màu,<br/>chỉnh màu, sọc,<br/>vignette, nhiễu"]:::engine --> E[shader riêng<br/>của game]:::render --> F[Màn hình]:::once

Bloom, blur và độ sâu trường ảnh tốn thêm vài lượt vẽ toàn màn hình; các hiệu ứng còn lại gộp trong một lượt nên gần như miễn phí. Mọi trường sửa được mỗi frame: njin::post_fx_lerp() giúp chuyển mượt giữa hai bộ.