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
Vẽ ảnh và shader

Trang này nói về texture (ảnh từ file) và shader. Hình khối và chữ ở Vẽ hình và chữ, sprite có animation ở Sprite và animation, ảnh ngoài màn hình ở Hậu kỳ (post-processing).

Handle

Texture, shader và render texture được quản lý qua handle, một struct nhỏ chứa id:

  • id == 0 là handle không hợp lệ, ví dụ khi file không tồn tại.
  • Truyền handle không hợp lệ hoặc đã unload vào bất kỳ hàm nào thì hàm đó không làm gì, không gây lỗi.
  • Handle đã unload không bao giờ trỏ nhầm sang tài nguyên mới.

Tài nguyên được giải phóng tự động khi gọi njin::destroy(). Chỉ cần gọi *_unload khi muốn giải phóng sớm.

Texture

njin::texture_handle tex = njin::texture_load(ctx, "assets/player.png");
njin::vec2 size = njin::texture_size(ctx, tex);
njin::texture_draw(ctx, tex, {100.0f, 100.0f}, {1.0f, 1.0f, 1.0f, 1.0f});
vec2 texture_size(const context &ctx, texture_handle handle)
Kích thước texture (pixel).
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.
Đị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
  • njin::texture_load() nạp ảnh; đường dẫn tính từ thư mục làm việc khi chạy game.
  • njin::texture_draw() vẽ với góc trên trái tại vị trí cho trước.
  • Tham số cuối là màu nhân (njin::rgba, mỗi kênh 0..1). Trắng {1,1,1,1} giữ nguyên ảnh; {1,0,0,1} chỉ giữ kênh đỏ.

Vẽ texture trong phase_render (không gian thế giới, chịu ảnh hưởng camera) hoặc phase_post_render (không gian màn hình). Xem Vòng lặp game.

Atlas: ghép nhiều ảnh vào một texture

Raylib gom các lệnh vẽ liền nhau dùng chung texture thành một lệnh. Sprite xếp theo layer hoặc theo y (top-down) mà dùng nhiều texture khác nhau thì bị ngắt mỗi lần đổi texture: 90 sprite xen kẽ ba ảnh là 90 lệnh vẽ. Ghép các ảnh nhỏ vào một atlas thì chúng cùng một texture, và số lệnh còn hai.

const njin::atlas_handle atlas = njin::atlas_create(ctx, {.size = 2048});
const njin::texture_handle hero = njin::atlas_load(ctx, atlas, "assets/hero.png");
const njin::texture_handle tree = njin::atlas_load(ctx, atlas, "assets/tree.png");
atlas_handle atlas_create(context &ctx, const atlas_desc &desc={})
Tạo một atlas: nơi ghép nhiều ảnh nhỏ vào một texture lớn.
texture_handle atlas_load(context &ctx, atlas_handle atlas, const char *path)
Nạp một file ảnh và xếp vào atlas.
Định danh của một atlas, tạo bởi atlas_create().
Definition _types.h:300

Kết quả là njin::texture_handle thường: đưa vào njin::sprite, njin::tilemap, njin::particle_emitter, UI hay njin::texture_draw() đều được. Vẽ ảnh không co giãn, không xoay thì giống hệt từng pixel so với nạp riêng.

Việc Cách xử lý
Ảnh lem sang ảnh bên cạnh khi phóng hoặc xoay Mỗi ảnh có viền padding (mặc định 1 pixel) chép lại điểm ảnh ngoài cùng
Trang đầy Atlas tự thêm trang mới; ảnh trên trang khác nhau vẫn là hai texture
Ảnh lớn hơn cả trang Nạp như njin::texture_load(), có cảnh báo trong log
njin::texture_size() Kích thước của ảnh, không phải của cả trang
Shader riêng của game Lấy mẫu theo toạ độ của cả trang, không phải của ảnh. Ảnh dùng với shader kiểu đó thì nạp bằng njin::texture_load()
njin::texture_set_filter() Đổi bộ lọc của cả trang
Hot reload, njin::texture_unload() Không áp dụng cho ảnh trong atlas; chỗ đã xếp không được thu hồi

Nên xếp vào atlas những ảnh nhỏ hay xuất hiện cùng nhau (nhân vật, kẻ địch, vật phẩm, đạn), và để tileset hoặc ảnh nền lớn nạp riêng.

Cắt bỏ ngoài màn hình và số lệnh vẽ

Module sprite bỏ qua sprite và emitter hạt nằm hoàn toàn ngoài camera: không sắp xếp, không tạo đỉnh. 40.000 sprite rải khắp bản đồ mà chỉ vài trăm nằm trong khung hình mất khoảng 1 ms thay vì 9 ms. Khi camera đang rung thì việc cắt tạm tắt, vì rung để lộ một chút phần ngoài khung.

njin_inspector hiện ở cửa sổ Performance những gì frame vừa rồi đã vẽ: số sprite (và số bị cắt), số chunk tilemap, số hạt (bao nhiêu trên GPU), và số lệnh vẽ ước tính. Raylib không báo số lệnh vẽ thật, nên con số này được tính từ các lần đổi texture và blend mode; nó tăng lên khi sprite khác texture xen kẽ nhau, và giảm khi dùng atlas.

Card đồ họa rời trên laptop có hai card

Laptop có card onboard (tích hợp) và card rời thường khởi động chương trình trên card onboard cho đỡ tốn pin. Game njin xin chạy trên card rời khi máy có, trên mọi hệ điều hành:

Hệ điều hành Cách làm
Windows File .exe xuất hai biến NvOptimusEnablement và AmdPowerXpressRequestHighPerformance mà driver NVIDIA và AMD tìm. Hai biến phải nằm trong chính .exe, nên game link target njin::gpu (biên dịch thẳng vào exe): target_link_libraries(my_game PRIVATE njin::rt njin::gpu)
Linux Chỉ trên laptop (nhận bằng loại khung máy SMBIOS, hoặc có pin): đặt DRI_PRIME=1 (Mesa), và với driver NVIDIA riêng thì __NV_PRIME_RENDER_OFFLOAD=1 cùng __GLX_VENDOR_LIBRARY_NAME=nvidia nếu thư viện GLX của NVIDIA có trên máy. Biến nào người dùng đã đặt rồi thì được giữ nguyên
macOS Không cần làm gì: máy Mac hai card dùng card rời cho mọi app không xin chuyển card tự động

Máy chỉ có một card thì không có gì thay đổi.

PC để bàn khác laptop: card nào chạy game là card mà màn hình cắm vào. Màn hình cắm vào card rời thì game đã chạy trên card rời; cắm vào cổng của bo mạch chủ (card onboard) thì game chạy trên card onboard, và engine không đổi được điều đó. Trên Linux engine cố ý không đặt biến offload cho PC để bàn, vì ở đó "card còn lại" chính là card onboard. Với PC có hai card, cách chắc chắn là cắm màn hình vào card rời. Người chơi vẫn quyết định cuối cùng: mục Graphics settings của Windows hoặc bảng điều khiển driver ghi đè yêu cầu này cho từng game. Tắt hẳn khi build bằng -DNJIN_PREFER_DISCRETE_GPU=OFF. Game không link njin::gpu thì trên Windows không có yêu cầu này. Log lúc khởi động ghi rõ card nào đang được dùng (dòng Renderer:).

Shader

Shader biến đổi cách mọi thứ được vẽ. njin dùng GLSL 330.

#include <njin.h>
namespace {
void load(njin::context &ctx) {
player_tex = njin::texture_load(ctx, "assets/player.png");
// nullptr: giữ vertex shader mặc định, chỉ thay fragment shader.
gray = njin::shader_load(ctx, nullptr, "assets/shaders/gray.fs");
}
void draw(njin::context &ctx) {
const njin::rgba white{1.0f, 1.0f, 1.0f, 1.0f};
// Đặt uniform TRƯỚC shader_begin.
njin::shader_set_f32(ctx, gray, "amount", 1.0f);
njin::shader_begin(ctx, gray);
njin::texture_draw(ctx, player_tex, {100.0f, 100.0f}, white);
// Ngoài begin/end: vẽ bình thường, không có shader.
njin::texture_draw(ctx, player_tex, {300.0f, 100.0f}, white);
}
void setup(njin::context &ctx) {
}
} // namespace
njin::mod_desc texture_demo_module() {
return {.name = "texture_demo", .setup = setup};
}
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_startup
Một lần, trước frame đầu tiên.
Definition _mod.h:32
@ phase_render
Mỗi frame, vẽ trong không gian thế giới.
Definition _mod.h:38
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.
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
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

Quy trình: đặt uniform, shader_begin, vẽ, shader_end. Mọi thứ vẽ giữa shader_begin và shader_end đi qua shader.

Lưu ý
Đặt uniform trước shader_begin.

Ví dụ fragment shader chuyển ảnh sang xám:

#version 330
// Các tên này do raylib quy định, đặt sai thì shader không nhận được dữ liệu.
in vec2 fragTexCoord;
in vec4 fragColor;
uniform sampler2D texture0;
uniform vec4 colDiffuse;
// Uniform của riêng bạn.
uniform float amount;
out vec4 finalColor;
void main() {
vec4 c = texture(texture0, fragTexCoord) * colDiffuse * fragColor;
float g = dot(c.rgb, vec3(0.299, 0.587, 0.114));
finalColor = vec4(mix(c.rgb, vec3(g), amount), c.a);
}

Một số điểm cần biết:

  • Truyền nullptr cho vertex shader (hoặc fragment shader) để giữ shader mặc định của raylib.
  • fragTexCoord, fragColor, texture0, colDiffuse là tên raylib quy định. texture0 là ảnh đang vẽ; fragColor là màu nhân bạn truyền cho texture_draw.
  • njin::shader_load() trả về handle id == 0 nếu file thiếu hoặc lỗi biên dịch, và ghi log lý do.
  • Uniform không tồn tại trong shader chỉ được ghi cảnh báo một lần rồi bị bỏ qua.

Các hàm đặt uniform:

Hàm Kiểu GLSL
njin::shader_set_i32() int
njin::shader_set_f32() float
njin::shader_set_vec2() vec2
njin::shader_set_vec3() vec3 (màu không alpha, hướng)
njin::shader_set_vec4() vec4
njin::shader_set_vec4_array() vec4[] (mảng, ví dụ danh sách đèn)
njin::shader_set_texture() sampler2D phụ ngoài texture0 (bảng màu, nhiễu, mặt nạ)

Cần đọc thêm một ảnh, nhận một danh sách đèn, hay chạy trên cả khung hình? Xem Shader nâng cao: ảnh phụ, mảng uniform, ánh sáng.

Instancing: hàng nghìn hình bằng một lệnh vẽ

Khi cần vẽ rất nhiều hình giống nhau mà mỗi hình khác một chút (một đám đông, một đàn chim, cỏ, mưa), vẽ từng cái bằng texture_draw_ex bắt CPU tạo 4 đỉnh cho mỗi cái, mỗi frame. Với instancing, game chỉ ghi vài số cho mỗi hình vào một bộ đệm; GPU tự dựng hình vuông cho từng cái, tất cả trong một lệnh vẽ.

#include <njin.h>
#include <vector>
namespace {
// Dữ liệu của một instance: 8 số, shader đọc thành instance0 và instance1.
struct dot {
float x, y, size, unused; // instance0: tâm, cạnh
float r, g, b, a; // instance1: màu
};
njin::shader_handle dots_shader;
std::vector<dot> data;
void load(njin::context &ctx) {
dots_shader = njin::shader_load(ctx, "assets/shaders/dots.vs", "assets/shaders/dots.fs");
dots = njin::instance_buffer_create(ctx, 8); // 8 số một instance
for (int i = 0; i < 20000; ++i)
data.push_back({r.range(0.0f, 1280.0f), r.range(0.0f, 720.0f), r.range(2.0f, 6.0f), 0.0f,
r.unit(), r.unit(), r.unit(), 1.0f});
}
void draw(njin::context &ctx) {
// Ghi lại mỗi frame (ở đây dữ liệu không đổi, nhưng thường thì có), rồi
// vẽ cả 20.000 chấm bằng một lệnh vẽ.
njin::instance_buffer_upload(ctx, dots, &data.data()->x, (njin::u32)data.size());
njin::draw_instanced(ctx, dots, dots_shader, 0, (njin::u32)data.size());
}
void setup(njin::context &ctx) {
}
} // namespace
int main() {
njin::context *ctx = njin::create({.title = "Instancing", .width = 1280.0f, .height = 720.0f,
.target_fps = 60.0f});
njin::mod_register(*ctx, {.name = "dots", .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 instance_buffer_upload(context &ctx, instance_buffer_handle handle, const f32 *data, u32 count)
Ghi count instance vào bộ đệm, thay toàn bộ nội dung cũ.
instance_buffer_handle instance_buffer_create(context &ctx, u32 floats_per_instance)
Tạo một bộ đệm instance trên GPU: mỗi instance là floats_per_instance số thực, do game tự quyết định ...
void draw_instanced(context &ctx, instance_buffer_handle handle, shader_handle shader, u32 first, u32 count)
Vẽ count hình vuông, bắt đầu từ instance first, bằng một lệnh vẽ.
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.
rng & random(context &ctx)
Bộ sinh số ngẫu nhiên dùng chung của engine.
std::uint32_t u32
Số nguyên không dấu 32 bit.
Definition _types.h:22
Định danh của một bộ đệm instance, tạo bởi instance_buffer_create().
Definition _types.h:109
Bộ sinh số ngẫu nhiên PCG32: nhanh, chất lượng tốt, và lặp lại được: cùng hạt giống thì cùng dãy số.
Definition _random.h:13

Vertex shader nhận hình vuông đơn vị và dữ liệu của instance đang vẽ, rồi tự đặt nó vào thế giới:

#version 330
// vertexPosition: góc hình vuông đơn vị, (0, 0) đến (1, 1).
// instance0, instance1: dữ liệu của chấm đang vẽ.
in vec3 vertexPosition;
in vec4 instance0; // tâm x, tâm y, cạnh, (bỏ trống)
in vec4 instance1; // màu
uniform mat4 mvp;
out vec2 uv;
out vec4 color;
void main() {
uv = vertexPosition.xy;
color = instance1;
vec2 world = instance0.xy + (vertexPosition.xy - 0.5) * instance0.z;
gl_Position = mvp * vec4(world, 0.0, 1.0);
}
#version 330
in vec2 uv;
in vec4 color;
out vec4 finalColor;
void main() {
// Một hình tròn mịn trong hình vuông.
float d = length(uv * 2.0 - 1.0);
float edge = fwidth(d);
finalColor = vec4(color.rgb, color.a * (1.0 - smoothstep(1.0 - edge, 1.0, d)));
}

Một số điểm cần biết:

  • Mỗi instance có 4, 8, 12 hoặc 16 số thực. Shader đọc chúng thành vec4 instance0 đến instance3, theo thứ tự. Ý nghĩa của từng số do game quyết định.
  • vertexPosition.xy là góc hình vuông, từ (0, 0) đến (1, 1), y hướng xuống. mvp là camera hiện tại: instance đi theo camera như mọi thứ vẽ trong phase_render.
  • Instance sau đè lên instance trước. Cần sắp xếp (ví dụ theo y cho game top-down) thì sắp xếp dữ liệu trước khi ghi.
  • njin::draw_instanced() vẽ một đoạn của bộ đệm (first, count), nên có thể ghi một lần rồi vẽ nhiều đoạn xen giữa những thứ vẽ theo cách khác. Mỗi lần gọi là một lệnh vẽ.
  • Uniform khác của shader đặt bằng njin::shader_set_f32() và các hàm cùng loại như thường, nhưng không gọi njin::shader_begin(): njin::draw_instanced() tự bật shader.
  • Máy không có OpenGL 3.3 thì njin::instancing_available() trả về false và các hàm này không làm gì.
  • njin::draw_instanced() có thêm bản nhận njin::texture_handle hoặc njin::render_texture_handle: texture được gắn vào uniform sampler2D texture0 của shader (xem phần tiếp theo).

Vẽ trước một lần, đọc lại mỗi frame

Instance ít việc cho CPU, nhưng nếu fragment shader phải tính lại cả hình (một công thức SDF dài) cho từng pixel của từng instance, GPU vẫn gánh mọi frame. Khi số hình khác nhau thật sự ít (vài chục tư thế), hãy vẽ mỗi hình một lần vào một render texture, rồi để instance chỉ đọc lại:

  1. Tạo njin::render_texture_load() đủ chứa mọi khung, xếp thành lưới. Đặt njin::render_texture_set_filter() với filter_linear, vì mặc định là filter_nearest.
  2. Trong phase_post_update của frame đầu, ghi một instance cho mỗi khung (vị trí là tâm ô, kích thước là ô), njin::render_texture_begin(), vẽ bằng njin::draw_instanced() không kèm texture (đang vẽ vào chính nó), njin::render_texture_end().
  3. Mỗi frame, ghi instance của người chơi với số thứ tự khung thay vì tham số tư thế, và vẽ bằng njin::draw_instanced() có kèm render texture. Fragment shader lấy mẫu texture0 ở ô tương ứng.

Muốn xem sprite sheet, hoặc giữ lại làm asset, gọi njin::render_texture_save(): nó lưu render texture ra PNG ngay lập tức, theo đúng chiều đã vẽ và giữ nguyên từng byte (kể cả alpha), nên một sheet chứa dữ liệu chứ không phải màu vẫn lưu được đúng như nó chứa. Để có ảnh dễ nhìn, vẽ các khung qua đúng đường vẽ của game vào một render texture thứ hai, mỗi khung một màu, rồi lưu cả hai.

Vài điều dễ sai:

  • Toạ độ texture của render texture có gốc ở góc dưới trái của hình đã vẽ: v = 1 - y / chiều cao.
  • Khung không cần lưu màu. Lưu vài con số (độ phủ, phần nào lấy màu riêng của từng người) rồi nhân màu lúc vẽ, thì một sprite sheet dùng cho mọi màu. Lưu premultiplied (số đã nhân với độ phủ) để lọc tuyến tính không viền tối ở mép.
  • Hai khung liền kề trộn được để chuyển động mượt, nhưng chỉ khi tư thế đổi ít giữa hai khung. Nếu một chi đổi hình dạng nhanh (khuỷu tay bật ra khi tay đi ngang vai) thì trộn sẽ hiện chi ma: bake nhiều khung hơn và chọn khung gần nhất.
  • Zoom camera vượt độ phân giải đã bake thì hình mờ. Chuyển sang tính trực tiếp khi zoom gần, lúc chỉ còn vài instance trong khung nhìn.

Hot reload

Sửa ảnh hay shader trong lúc game đang chạy, lưu lại, và thấy kết quả ngay, không cần khởi động lại:

#ifndef NDEBUG
#endif
void hot_reload_enable(context &ctx, bool on, f32 interval=0.25f)
Bật hoặc tắt hot reload: sửa file ảnh, shader hoặc script Lua khi game đang chạy, lưu lại,...

Khi bật, engine kiểm tra thời gian sửa của mọi file texture và shader đã nạp, vài lần mỗi giây. File đổi được nạp lại vào đúng handle cũ: sprite, tilemap, ảnh của level, shader post đang dùng nó đổi theo ngay, không phải sửa code. Một file vừa đổi được nạp ở lần kiểm tra sau, khi nó đã thôi đổi, để không đọc nhầm file editor đang ghi dở.

Shader lỗi biên dịch thì giữ bản cũ, và lỗi của trình biên dịch nằm trong log: sửa shader sai không làm game hỏng, sửa đúng thì bản mới vào ngay.

Hàm / event Việc làm
njin::hot_reload_enable() Bật, tắt, và chọn khoảng giữa hai lần kiểm tra
njin::hot_reload_now() Kiểm tra và nạp lại ngay, kể cả khi đang tắt (ví dụ gắn vào phím F5)
njin::asset_reloaded Event gửi qua njin::events() sau mỗi lần nạp lại, kể cả khi thất bại

Mặc định tắt: kiểm tra file tốn một chút thời gian, và game đã phát hành không cần.