This programming guide is only available in Vietnamese. Switch to Vietnamese to read the full article.
Sprite animation là kỹ thuật nền tảng đứng sau mọi nhân vật chuyển động trong game 2D, từ Mario chạy
nhảy cho tới các hiệu ứng nổ. Thay vì tính toán hình học từng khung hình, ta lưu sẵn một chuỗi ảnh
tĩnh (frame) rồi lần lượt hiển thị chúng đủ nhanh để mắt người cảm nhận thành chuyển động liền mạch.
Bài học này đi từ khái niệm spritesheet cơ bản, kỹ thuật cắt frame bằng drawImage, tách
biệt animation FPS khỏi render FPS, xây dựng state machine, đọc texture atlas JSON, cho tới các thủ
thuật tối ưu pixel art chuyên nghiệp.
1. Spritesheet là gì
Một spritesheet (hay sprite atlas) là một ảnh đơn duy nhất chứa nhiều frame nhỏ được xếp cạnh nhau theo lưới. Mỗi frame là một tư thế (pose) khác nhau của nhân vật: chân trái bước, chân phải bước, tay vung lên... Khi phát lần lượt 8–12 frame mỗi giây, mắt người sẽ ghép chúng thành một vòng đi (walk cycle) mượt mà nhờ hiện tượng persistence of vision.
Vì sao phải gộp tất cả frame vào một ảnh thay vì lưu mỗi frame một file PNG riêng? Có ba lý do kỹ thuật quan trọng:
- Giảm số HTTP request: tải một ảnh 64 frame chỉ tốn đúng 1 request, thay vì 64 request riêng lẻ. Mỗi request đều có overhead (handshake, header), nên gộp lại giúp trang tải nhanh hơn rất nhiều.
- Giảm texture binding trên GPU: trong WebGL/Canvas tăng tốc phần cứng, việc đổi texture (texture switch) tốn chi phí. Một atlas duy nhất cho phép vẽ hàng trăm sprite mà GPU chỉ cần bind một texture.
- Quản lý gọn gàng: toàn bộ animation của một nhân vật nằm trong một file, dễ phiên bản hóa và đóng gói.
Về mặt giải phẫu (anatomy), một spritesheet lưới đều được mô tả bởi: chiều rộng frame
(frameWidth), chiều cao frame (frameHeight), số cột (columns)
và tổng số frame. Từ chỉ số frame, ta suy ra vị trí cắt:
// Mô tả một spritesheet lưới đều
const sheet = {
image: spriteImage, // ảnh atlas đã load
frameWidth: 64, // mỗi frame rộng 64px
frameHeight: 64, // mỗi frame cao 64px
columns: 8, // 8 frame mỗi hàng
totalFrames: 24 // tổng cộng 24 tư thế
};
// Tính tọa độ (hàng, cột) của frame thứ i trong lưới
function frameCell(i, columns) {
const col = i % columns; // dư -> chỉ số cột
const row = Math.floor(i / columns); // chia nguyên -> chỉ số hàng
return { col, row };
}
2. drawImage 9 tham số để cắt frame
Trái tim của sprite animation là phiên bản đầy đủ 9 tham số của drawImage. Đây là dạng
mạnh nhất, cho phép ta cắt một vùng chữ nhật bất kỳ từ ảnh nguồn (source) và vẽ nó vào một vùng chữ
nhật bất kỳ trên canvas (destination):
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
└─ SOURCE ─┘ └─ DESTINATION ─┘
sx, sy : góc trên-trái vùng cắt TRÊN ẢNH NGUỒN
sw, sh : rộng/cao vùng cắt trên ảnh nguồn
dx, dy : vị trí vẽ TRÊN CANVAS
dw, dh : rộng/cao khi vẽ ra canvas (có thể scale)
Điểm mấu chốt: từ frameIndex, ta tính sx và sy để "trượt cửa sổ
cắt" tới đúng frame cần hiển thị. Với spritesheet lưới đều, công thức rất gọn:
function drawFrame(ctx, sheet, frameIndex, dx, dy) {
const col = frameIndex % sheet.columns;
const row = Math.floor(frameIndex / sheet.columns);
const sx = col * sheet.frameWidth; // dịch ngang theo cột
const sy = row * sheet.frameHeight; // dịch dọc theo hàng
ctx.drawImage(
sheet.image,
sx, sy, sheet.frameWidth, sheet.frameHeight, // SOURCE: cắt 1 ô
dx, dy, sheet.frameWidth, sheet.frameHeight // DEST: vẽ nguyên kích thước
);
}
Khi muốn phóng to nhân vật (ví dụ scale gấp đôi) hoặc lật ngược chiều khi nhân vật quay trái, ta điều
chỉnh phần destination. Để lật ngang (flip horizontal), dùng scale(-1, 1) kết hợp
translate:
function drawFrameEx(ctx, sheet, frameIndex, dx, dy, scale, flipX) {
const col = frameIndex % sheet.columns;
const row = Math.floor(frameIndex / sheet.columns);
const sx = col * sheet.frameWidth;
const sy = row * sheet.frameHeight;
const dw = sheet.frameWidth * scale;
const dh = sheet.frameHeight * scale;
ctx.save();
if (flipX) {
// Lật quanh tâm sprite: dời gốc tới mép phải rồi scale âm
ctx.translate(dx + dw, dy);
ctx.scale(-1, 1);
ctx.drawImage(sheet.image, sx, sy, sheet.frameWidth, sheet.frameHeight, 0, 0, dw, dh);
} else {
ctx.drawImage(sheet.image, sx, sy, sheet.frameWidth, sheet.frameHeight, dx, dy, dw, dh);
}
ctx.restore();
}
3. Animation timing & frame rate
Sai lầm phổ biến nhất của người mới: tăng frameIndex mỗi lần
requestAnimationFrame gọi. Điều này gắn chặt tốc độ animation vào tốc độ màn hình — trên
màn 144Hz nhân vật sẽ chạy nhanh gấp 2.4 lần so với màn 60Hz. Nguyên tắc vàng:
tách rời animation FPS khỏi render FPS. Render chạy ở tốc độ tối đa màn hình cho
phép, nhưng frame của nhân vật chỉ đổi sau một khoảng thời gian cố định.
Kỹ thuật chuẩn là dùng delta time (thời gian giữa hai khung hình) cộng dồn vào một
biến tích lũy (accumulator). Khi accumulator vượt ngưỡng frameDuration (ví dụ 100ms cho
10 FPS animation), ta tăng frame và trừ bớt accumulator:
const anim = {
frameIndex: 0,
frameCount: 8,
fps: 10, // animation chạy 10 frame/giây
acc: 0, // accumulator thời gian (ms)
last: performance.now()
};
function update(now) {
const dt = now - anim.last; // delta time giữa 2 lần vẽ
anim.last = now;
anim.acc += dt;
const frameDuration = 1000 / anim.fps; // 100ms mỗi frame
while (anim.acc >= frameDuration) {
anim.frameIndex = (anim.frameIndex + 1) % anim.frameCount;
anim.acc -= frameDuration; // trừ bớt, giữ phần dư
}
requestAnimationFrame(update);
}
requestAnimationFrame(update);
Dùng vòng while (thay vì if) đảm bảo animation không bị "kẹt" khi có một
khung hình giật lag dài: nếu trình duyệt treo 350ms, accumulator sẽ nhảy bù 3 frame trong một lần
update. Để mượt mà hơn nữa, ta có thể giới hạn delta time tối đa nhằm tránh "spiral of death":
function update(now) {
let dt = now - anim.last;
anim.last = now;
// Giới hạn dt: tránh nhảy quá nhiều frame khi tab vừa được focus lại
if (dt > 250) dt = 250;
anim.acc += dt;
const frameDuration = 1000 / anim.fps;
while (anim.acc >= frameDuration) {
anim.frameIndex = (anim.frameIndex + 1) % anim.frameCount;
anim.acc -= frameDuration;
}
requestAnimationFrame(update);
}
4. Animation State Machine
Một nhân vật game thực tế có nhiều trạng thái: idle (đứng yên), walk (đi
bộ), run (chạy), jump (nhảy). Mỗi state ánh xạ tới một
dải frame riêng trên spritesheet, có số frame và tốc độ phát khác nhau. Quản lý điều
này bằng các biến rời rạc sẽ rối loạn rất nhanh — giải pháp sạch là một
Animation State Machine đóng gói trong class.
Ý tưởng cốt lõi: mỗi state là một object mô tả { row, frames, fps }. Khi chuyển state, ta
đổi con trỏ tới định nghĩa mới và reset frameIndex về 0 để animation bắt đầu lại từ đầu
(tránh hiện tượng nhân vật "đứng giữa pose" lạ mắt):
class SpriteAnimator {
constructor(sheet, states) {
this.sheet = sheet;
this.states = states; // { idle:{row,frames,fps}, walk:{...}, ... }
this.current = 'idle';
this.frameIndex = 0;
this.acc = 0;
}
setState(name) {
if (name === this.current) return; // tránh reset liên tục
if (!this.states[name]) return;
this.current = name;
this.frameIndex = 0; // bắt đầu lại animation mới
this.acc = 0;
}
update(dt) {
const st = this.states[this.current];
this.acc += dt;
const dur = 1000 / st.fps;
while (this.acc >= dur) {
this.frameIndex = (this.frameIndex + 1) % st.frames;
this.acc -= dur;
}
}
}
Phương thức draw dùng row của state để tính sy, và
frameIndex để tính sx. Logic chuyển state thường dựa trên input người chơi
hoặc vận tốc nhân vật:
SpriteAnimator.prototype.draw = function(ctx, dx, dy) {
const st = this.states[this.current];
const sx = this.frameIndex * this.sheet.frameWidth;
const sy = st.row * this.sheet.frameHeight;
ctx.drawImage(this.sheet.image,
sx, sy, this.sheet.frameWidth, this.sheet.frameHeight,
dx, dy, this.sheet.frameWidth, this.sheet.frameHeight);
};
const hero = new SpriteAnimator(sheet, {
idle: { row: 0, frames: 4, fps: 6 },
walk: { row: 1, frames: 8, fps: 10 },
run: { row: 2, frames: 8, fps: 16 },
jump: { row: 3, frames: 6, fps: 12 }
});
// Quyết định state theo vận tốc
function pickState(speed, onGround) {
if (!onGround) return 'jump';
if (speed > 4) return 'run';
if (speed > 0) return 'walk';
return 'idle';
}
hero.setState(pickState(player.vx, player.onGround));
5. Texture Atlas & JSON metadata
Lưới đều rất tiện nhưng lãng phí: nếu frame "nhảy" lớn hơn frame "đứng", mọi ô buộc phải to bằng frame lớn nhất, để lại nhiều khoảng trống. Công cụ như TexturePacker đóng gói các frame có kích thước khác nhau khít nhất có thể (atlas không đều), rồi xuất ra một file JSON metadata mô tả vị trí và kích thước từng frame theo tên.
Định dạng JSON (TexturePacker hash) trông như sau — mỗi frame có vùng frame chứa
{x, y, w, h}:
{
"frames": {
"walk_0.png": { "frame": { "x": 0, "y": 0, "w": 48, "h": 64 } },
"walk_1.png": { "frame": { "x": 48, "y": 0, "w": 50, "h": 64 } },
"jump_0.png": { "frame": { "x": 98, "y": 0, "w": 60, "h": 70 } }
},
"meta": { "image": "hero.png", "size": { "w": 256, "h": 256 } }
}
Để dùng atlas này, ta load JSON, dựng một bảng tra cứu theo tên frame, rồi vẽ bằng
drawImage với chính xác x, y, w, h của frame đó:
async function loadAtlas(jsonUrl, imageUrl) {
const data = await fetch(jsonUrl).then(r => r.json());
const image = await new Promise(res => {
const img = new Image(); img.onload = () => res(img); img.src = imageUrl;
});
return { image, frames: data.frames };
}
function drawNamed(ctx, atlas, name, dx, dy) {
const f = atlas.frames[name].frame; // tra cứu theo TÊN, không theo index
ctx.drawImage(atlas.image, f.x, f.y, f.w, f.h, dx, dy, f.w, f.h);
}
// Phát animation walk: ghép tên frame theo quy ước
const walkFrames = ['walk_0.png', 'walk_1.png', 'walk_2.png'];
drawNamed(ctx, atlas, walkFrames[frameIndex], 100, 100);
6. Tối ưu hóa
Với pixel art, kẻ thù số một là nội suy làm mờ. Mặc định Canvas dùng bilinear
smoothing khi scale ảnh, khiến các pixel sắc nét bị nhòe. Tắt nó bằng
imageSmoothingEnabled = false để giữ cạnh sắc nét đặc trưng. Ngoài ra, ba kỹ thuật giúp
tăng hiệu năng đáng kể: pre-render (vẽ trước các frame phức tạp lên offscreen canvas
một lần rồi tái sử dụng), sprite batching (gom nhiều sprite cùng atlas để giảm
texture switch), và tránh tạo object trong vòng lặp vẽ để giảm áp lực garbage collector.
// 1) Tắt smoothing cho pixel art (đặt lại sau mỗi lần resize canvas!)
ctx.imageSmoothingEnabled = false;
// 2) Pre-render: vẽ sẵn frame phóng to lên offscreen canvas
const off = document.createElement('canvas');
off.width = 64 * 4; off.height = 64 * 4;
const offCtx = off.getContext('2d');
offCtx.imageSmoothingEnabled = false;
offCtx.drawImage(sheet.image, 0, 0, 64, 64, 0, 0, 256, 256); // scale x4 một lần
// 3) Sprite batching: vẽ nhiều sprite từ CÙNG một atlas liên tiếp
function drawBatch(ctx, atlas, items) {
for (let i = 0; i < items.length; i++) {
const it = items[i]; // không tạo object mới trong loop
ctx.drawImage(atlas.image, it.sx, it.sy, it.w, it.h, it.dx, it.dy, it.w, it.h);
}
}
7. Câu hỏi trắc nghiệm ôn tập
Trắc nghiệm 1: drawImage 9 tham số
Trong drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh), bộ tham số nào quyết định VÙNG
CẮT trên ảnh spritesheet nguồn?
Trắc nghiệm 2: Animation Timing
Vì sao không nên tăng frameIndex mỗi lần requestAnimationFrame gọi?
Trắc nghiệm 3: Pixel Art
Để pixel art không bị mờ nhòe khi phóng to trên canvas, ta cần làm gì?
Tải file code thực hành minh họa bài học
File chứa lớp SpriteAnimator hoàn chỉnh, state machine, atlas loader và demo walk-cycle:
Tải về canvas_sprite.js
Comments
Bình luận