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
Particle và hiệu ứng

Những thứ nhỏ làm game "có lực": bụi khi tiếp đất, tia lửa khi chém trúng, camera rung khi nổ, khung hình dừng một nhịp khi đòn đánh trúng. njin có sẵn tất cả, và một bộ mẫu dùng ngay.

#include <njin.h>
namespace {
entt::entity player = entt::null;
void spawn(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
// Một đống lửa phát liên tục, khói bay lên phía trên.
const entt::entity campfire = reg.create();
reg.emplace<njin::transform>(campfire, njin::transform{.pos = {400, 300}});
reg.emplace<njin::particle_emitter>(campfire, njin::fx::fire());
const entt::entity smoke = reg.create();
reg.emplace<njin::transform>(smoke, njin::transform{.pos = {400, 260}});
// Emitter tự làm: bong bóng nổi lên từ một vùng rộng.
bubbles.rate = 8.0f;
bubbles.area = {200.0f, 10.0f};
bubbles.angle = -90.0f;
bubbles.spread = 20.0f;
bubbles.speed = {20.0f, 40.0f};
bubbles.life = {2.0f, 3.0f};
bubbles.size_start = 6.0f;
bubbles.size_end = 10.0f;
bubbles.color_start = {0.7f, 0.9f, 1.0f, 0.8f};
bubbles.color_end = {0.7f, 0.9f, 1.0f, 0.0f};
const entt::entity pond = reg.create();
reg.emplace<njin::transform>(pond, njin::transform{.pos = {700, 400}});
reg.emplace<njin::particle_emitter>(pond, bubbles);
player = reg.create();
reg.emplace<njin::transform>(player, njin::transform{.pos = {200, 300}});
}
// Gọi khi đòn đánh trúng kẻ địch tại `at`.
void on_hit(njin::context &ctx, entt::entity enemy, njin::vec2 at) {
sparks.angle = -45.0f; // bắn chéo lên
sparks.spread = 90.0f;
njin::particles_spawn(ctx, sparks, at, 16);
njin::sprite_flash(ctx, enemy); // nháy trắng 0.1 giây
njin::hitstop(ctx, 0.06f); // dừng hình một chút cho "đã tay"
njin::camera_shake(ctx, 0.35f); // rung nhẹ
}
// Gọi khi thùng thuốc nổ phát nổ.
void on_explode(njin::context &ctx, njin::vec2 at) {
njin::camera_shake(ctx, 0.8f);
njin::screen_flash(ctx, {1.0f, 0.95f, 0.8f, 0.5f}, 0.25f);
}
void control(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
const njin::vec2 pos = reg.get<njin::transform>(player).pos;
on_hit(ctx, player, pos + njin::vec2{30, 0});
on_explode(ctx, pos + njin::vec2{150, 0});
// Bụi dưới chân mỗi lần tiếp đất.
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc particles_fx_module() { return {.name = "particles_fx", .setup = setup}; }
void camera_shake(context &ctx, f32 trauma)
Rung camera.
void hitstop(context &ctx, f32 seconds)
Dừng hình trong chốc lát (hitstop, freeze frame) để cú đánh có "lực".
void screen_flash(context &ctx, rgba color, f32 duration)
Nháy cả màn hình một màu rồi mờ dần: trắng khi nổ lớn, đỏ khi bị thương.
void sprite_flash(context &ctx, entt::entity entity, rgba color={1.0f, 1.0f, 1.0f, 1.0f}, f32 duration=0.1f)
Nháy sprite của entity.
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_k
Phím K.
Definition _types.h:354
@ key_j
Phím J.
Definition _types.h:353
@ key_l
Phím L.
Definition _types.h:355
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_update
Mỗi frame, logic chính của game.
Definition _mod.h:35
@ phase_startup
Một lần, trước frame đầu tiên.
Definition _mod.h:32
entt::entity particles_spawn(context &ctx, const particle_emitter &preset, vec2 pos, i32 count)
Tạo một entity tại pos chỉ để phát một loạt count hạt, theo mẫu preset, rồi tự hủy khi hạt cuối cùng ...
particle_emitter fire()
Lửa: ngọn lửa phát liên tục, bay lên, vàng chuyển đỏ, sáng (additive).
Definition njin_fx.h:225
particle_emitter dust()
Bụi: bay lên chậm, xám nâu, mờ dần.
Definition njin_fx.h:187
particle_emitter smoke()
Khói: bay lên, nở to, mờ dần.
Definition njin_fx.h:206
particle_emitter explosion()
Vụ nổ: tỏa mọi hướng, nhanh, vàng cam chuyển đỏ sẫm, sáng (additive).
Definition njin_fx.h:151
particle_emitter debris()
Mảnh vỡ: khối vuông văng lên rồi rơi, xoay.
Definition njin_fx.h:265
particle_emitter sparks()
Tia lửa: nhỏ, rất nhanh, rơi theo trọng lực.
Definition njin_fx.h:169
Mô tả một module: một nhóm system có tên.
Definition _mod.h:67
Nguồn phát hạt: nổ, bụi, tia lửa, khói.
Definition njin_particles.h:57
f32 spread
Độ tỏa quanh angle, độ. 360 là mọi hướng.
Definition njin_particles.h:76
f32 angle
Hướng phát, độ (0 là sang phải, 90 là xuống dưới), cộng với góc xoay của transform.
Definition njin_particles.h:74
f32 rate
Số hạt mỗi giây khi phát liên tục. 0 là chỉ phát theo loạt.
Definition njin_particles.h:60
Vị trí, góc xoay và tỉ lệ của một entity trong thế giới.
Definition _comps.h:14
Vector 2 chiều. Dùng cho vị trí, kích thước, độ dời.
Definition _types.h:39
Vụ nổ hạt trong njin_render_demo: mỗi lần bấm Space là một vụ nổ tại con trỏ chuột, hạt nở ra rồi mờ dần

Particle

Gắn njin::particle_emitter vào entity có njin::transform. Module particle của engine sinh và cập nhật hạt trong phase_post_update theo delta() (nên dừng khi pause và hitstop, chậm lại theo time_set_scale()); module sprite vẽ chúng cùng sprite, theo layer. Cùng một lớp, hạt vẽ sau sprite.

Hai cách phát:

Cách Làm thế nào Dùng cho
Liên tục rate > 0, emitting = true Lửa, khói, mưa, vệt đuôi
Một loạt njin::particles_burst() trên emitter có sẵn Bắn tia lửa từ một khẩu súng
Một lần rồi tự hủy njin::particles_spawn() Nổ, bụi, máu: tạo entity, nổ, tự hủy khi hết hạt

Entity do njin::particles_spawn() tạo thuộc scene đang chạy, nên rời scene là mất.

Các trường chính

Nhóm Trường
Phát rate, emitting, max_particles, area (vùng sinh quanh transform)
Chuyển động life, speed (khoảng min–max), angle + spread (hướng và độ tỏa, độ), gravity, drag, spin, local_space
Hình ảnh size_start → size_end, size_jitter, color_start → color_end, shape hoặc texture + source, blend, layer

Hướng: 0 độ là sang phải, 90 là xuống (trục y hướng xuống), -90 là lên. Góc xoay của transform được cộng vào angle, nên xoay entity là xoay luôn hướng phát.

local_space = true làm hạt đi theo emitter (lửa ở đuôi tên lửa); mặc định hạt ở lại trong thế giới và để lại dấu vết.

blend_additive làm các hạt chồng lên nhau sáng rực lên: dùng cho lửa, tia lửa, phép thuật.

CPU hay GPU

Engine tự chọn khi game khởi động. Máy có card đồ họa (OpenGL 3.3 trở lên, không phải bộ vẽ bằng phần mềm như llvmpipe, SwiftShader hay Microsoft Basic Render Driver) thì hạt chạy trên GPU; máy không có thì chạy trên CPU, đúng như cũ. Game không phải làm gì, và cả hai cách cho gần như cùng một hình.

Trên GPU, CPU chỉ ghi lại trạng thái lúc sinh của từng hạt. Mỗi emitter có một vertex buffer riêng, chỉ được ghi khi có hạt mới hoặc khi dọn hạt chết (khoảng mỗi 0,25 giây), nên một frame không tốn việc gì theo từng hạt trên CPU. Vertex shader tính vị trí hiện tại bằng công thức (gia tốc và lực cản có nghiệm đóng), và mỗi emitter được vẽ bằng một lệnh instanced thay vì một lệnh vẽ cho mỗi hạt. Kết quả lệch so với CPU dưới nửa pixel ở 60 FPS, vì CPU cộng dồn từng bước Euler còn GPU tính chính xác.

Thử trên Intel Iris Xe, bản Release, hạt hình tròn, 10.000 hạt sống cùng lúc: khoảng 42 ms mỗi frame trên CPU, khoảng 1 ms trên GPU (phép thử giới hạn ở 1000 FPS, nên đó là cận dưới). Ngay cả 200 emitter nhỏ, mỗi cái 20 hạt, GPU vẫn nhanh hơn nhiều, nên không có ngưỡng "emitter đủ lớn".

Vài điều cần biết:

  • njin::particle_emitter::gpu cho biết emitter đang chạy ở đâu. Khi nó là true, pos, velocity, rot trong particles giữ giá trị lúc sinh, và age là thời điểm sinh theo đồng hồ riêng của emitter chứ không phải tuổi. particles còn giữ cả hạt chết chưa được dọn, nên đếm hạt sống bằng inspector (trường alive) hoặc kiểm tra gpu trước khi đọc trực tiếp.
  • Emitter chỉ đổi nơi chạy khi hết hạt, nên hạt đang bay không bị giật.
  • Shader hậu kỳ của game (njin::camera_set_post_shader()) chạy trên cả khung hình đã vẽ xong, nên vẫn thấy hạt GPU như mọi thứ khác. Riêng hạt hình tròn (không có texture) trên GPU có mép khử răng cưa, còn CPU vẽ đa giác không khử.
  • njin::particles_set_backend() với particle_backend_cpu ép chạy trên CPU, để so sánh hoặc tìm lỗi. njin::particles_gpu_available() cho biết máy này dùng được GPU không.

Emitter nằm ngoài camera không được vẽ (mô phỏng vẫn chạy để hạt đúng chỗ khi quay lại). Engine biết vùng hạt có thể tới từ nơi chúng được sinh, tốc độ, tuổi thọ và gia tốc, nên emitter chỉ bị bỏ qua khi chắc chắn không hạt nào trong khung hình.

Mẫu có sẵn

Trong namespace njin::fx, mỗi hàm trả về một emitter đã chỉnh sẵn, sửa tùy ý trước khi dùng:

Mẫu Kiểu phát Gợi ý
njin::fx::explosion() Một lần 40–80 hạt
njin::fx::sparks() Một lần 10–25 hạt, chỉnh angle/spread theo hướng va chạm
njin::fx::dust() Một lần 6–12 hạt dưới chân khi chạy, tiếp đất
njin::fx::debris() Một lần 8–16 hạt khi vỡ thùng, vỡ đá
njin::fx::splash() Một lần Máu hoặc nước bắn theo hướng đòn đánh
njin::fx::sparkle() Cả hai Nhặt đồ, hồi máu, phép thuật
njin::fx::smoke() Liên tục Ống khói, xe cháy
njin::fx::fire() Liên tục Đuốc, đống lửa
njin::fx::rain(), njin::fx::snow() Liên tục Gắn lên entity đi theo camera, area rộng bằng màn hình
njin::fx::trail() Liên tục Gắn lên đạn, tên lửa, phi tiêu

Hiệu ứng màn hình và thời gian

Hàm Việc làm Giá trị hay dùng
njin::camera_shake() Rung camera. Cộng dồn, tự giảm dần 0.2 bước chân nặng, 0.4 trúng đòn, 0.8 vụ nổ
njin::hitstop() Dừng hình: delta() bằng 0 trong chốc lát 0.03–0.12 giây
njin::screen_flash() Nháy cả màn hình một màu rồi mờ dần trắng khi nổ, đỏ khi bị thương
njin::sprite_flash() Tô sprite thành một màu (thường là trắng) 0.1 giây khi trúng đòn
njin::sprite_dissolve() Cho sprite tan biến từng mảng, có viền cháy 0.5–1 giây khi kẻ địch chết

Rung camera chỉ dịch hình vẽ ra: transform của camera, njin::camera_active(), njin::w2scr() và njin::scr2w() không đổi, nên bấm chuột vẫn trúng chỗ. Cách rung chỉnh bằng njin::camera_shake_config().

Rung, nháy màn hình và hitstop tính theo giờ thật, nên camera vẫn rung trong lúc hitstop. Nháy sprite tính theo delta(), nên nó "đứng hình" cùng game trong hitstop, đúng như mong đợi.

Nháy sprite khác sprite.tint: tint chỉ nhân màu nên không thể làm sprite sáng trắng lên; nháy thay hẳn màu từng điểm ảnh nhưng giữ hình dáng sprite.

Tan biến

njin::sprite_dissolve() cho một sprite tan dần từng mảng nhỏ, kèm viền cháy màu cam ở chỗ đang tan. Dùng khi kẻ địch chết hoặc vật phẩm biến mất. Sprite vẫn giữ hình dáng, chỉ bớt mảng đi, nên nhìn ra ngay là con gì đang tan.

#include <njin.h>
namespace {
entt::entity enemy = entt::null;
entt::entity spawn_enemy(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
const entt::entity e = reg.create();
reg.emplace<njin::transform>(e, njin::transform{.pos = {480.0f, 270.0f}, .scale = 6.0f});
reg.emplace<njin::sprite>(e, njin::sprite{.texture = sheet, .source = {{64.0f, 96.0f}, {32.0f, 32.0f}}});
return e;
}
void startup(njin::context &ctx) {
sheet = njin::texture_load(ctx, "assets/sprites.png");
enemy = spawn_enemy(ctx);
}
void update(njin::context &ctx) {
entt::registry &reg = njin::world(ctx);
if (njin::key_pressed(ctx, njin::key_space) && reg.valid(enemy)) {
// Kẻ địch chết: nháy trắng rồi tan biến, viền cháy màu cam.
njin::sprite_flash(ctx, enemy, {1.0f, 1.0f, 1.0f, 1.0f}, 0.15f);
njin::sprite_dissolve(ctx, enemy, 0.8f);
}
// Hiện lại dần: cùng component, đặt `reverse`. Xong thì component tự được gỡ.
if (reg.valid(enemy))
reg.destroy(enemy);
enemy = spawn_enemy(ctx);
reg.emplace<njin::dissolve_fx>(enemy, njin::dissolve_fx{.duration = 0.8f, .reverse = true});
}
}
void setup(njin::context &ctx) {
}
} // namespace
int main() {
njin::context *ctx = njin::create({.title = "Tan biến", .width = 960, .height = 540, .target_fps = 60,
.clear_bg_color = {0.37f, 0.80f, 0.89f, 1.0f}});
njin::mod_register(*ctx, {.name = "game", .setup = setup});
njin::run(*ctx);
}
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 sprite_dissolve(context &ctx, entt::entity entity, f32 duration=0.6f, rgba edge_color={1.0f, 0.55f, 0.1f, 1.0f})
Cho sprite của entity tan biến.
@ key_space
Phím cách.
Definition _types.h:411
@ key_r
Phím R.
Definition _types.h:361
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.
void texture_set_filter(context &ctx, texture_handle handle, texture_filter filter)
Đổi cách lấy mẫu của texture.
texture_handle texture_load(context &ctx, const char *path)
Nạp một texture từ file ảnh.
@ filter_nearest
Giữ nguyên từng pixel, sắc cạnh. Dùng cho pixel art.
Definition njin_draw.h:198
Cho một sprite tan biến từng mảng nhỏ, có viền cháy sáng ở chỗ đang tan: kẻ địch chết,...
Definition njin_fx.h:111
Ảnh vẽ tại vị trí của entity.
Definition _comps.h:44
Định danh của một texture, tạo bởi texture_load().
Definition _types.h:146
Bấm Space: nháy trắng rồi tan biến với viền cam. Bấm R: hiện lại dần

Mỗi mảng có một số ngẫu nhiên cố định tính từ tọa độ pixel của nó (bằng hàm băm, không cần ảnh nhiễu); mảng nào có số thấp hơn ngưỡng đang chạy từ 0 đến 1 thì mất. Muốn chỉnh thêm thì tự gắn njin::dissolve_fx:

Trường Ý nghĩa
duration Thời gian tan hết, giây. Tính theo delta(), nên đứng hình trong hitstop
edge_color, edge_width Màu và độ dày viền cháy. edge_color.a = 0 hoặc edge_width = 0 là bỏ viền
grain Cỡ mỗi mảng, tính bằng pixel của ảnh sprite. 1 là từng pixel, 4 là mảng to, hợp pixel art
seed Đổi hình mẫu tan biến. Cho mỗi kẻ địch một giá trị riêng để chúng không tan giống hệt nhau
reverse Hiện ra dần thay vì tan biến
destroy_when_done Hủy entity khi tan hết (không áp dụng cho reverse)

Khi hết giờ:

  • tan biến: sprite ẩn hẳn và component ở lại, nên nó không hiện lại. Gắn destroy_when_done để hủy luôn entity, hoặc tự gỡ component để sprite hiện lại;
  • hiện ra dần (reverse): component tự được gỡ và sprite hiện đủ.

Dùng chung được với njin::sprite_flash(): gọi cả hai thì sprite nháy rồi tan trong cùng một lần vẽ. Gọi njin::sprite_dissolve() lần nữa khi đang tan thì bắt đầu lại. Gọi với entity đã hủy hoặc entt::null thì bỏ qua.

Ghi chú
Hình mẫu tan biến lấy theo vị trí pixel trong ảnh (atlas), nên hai kẻ địch dùng cùng một frame sẽ tan giống hệt nhau nếu cùng seed. Đặt seed khác nhau, ví dụ theo id của entity.

Xem thêm Hậu kỳ (post-processing) cho các hiệu ứng toàn màn hình như vignette đỏ khi máu thấp.