Đoạn code Audio tưởng vô hại nhưng làm tụt FPS
Đây là đoạn code ban đầu dùng cho sound effect trong game:
const w = window.TANK300IQ_SOUND_PATH || "/sounds/",
k = {
shoot: new Audio(w + "/shoot-laser.wav"),
explode: new Audio(w + "/explode.wav")
};
function S(audio) {
if (!n.mute) {
try {
audio.currentTime = 0;
audio.play();
} catch (e) {}
}
}
k.shoot.volume = 0.6;
k.explode.volume = 0.7;
Nhìn qua thì khá ổn: tạo sẵn hai HTMLAudioElement, mỗi lần cần chơi sound chỉ việc currentTime = 0 rồi play(). Trên nhiều trình duyệt desktop, cách này vẫn “chạy được”.
Nhưng trên iOS/iPadOS (Safari và WebView), nếu bạn spam sound trong game (ví dụ spam bắn đạn), FPS tụt thấy rõ.
Tại sao HTMLAudioElement gây lag trên iOS/iPadOS?
Nguyên nhân chính không nằm ở JavaScript thuần mà ở cách nền tảng Apple triển khai HTMLAudioElement:
HTMLAudioElementkhông được thiết kế cho low-latency SFX: nó phù hợp cho nhạc, video, podcast… hơn là game cần spam sound nhiều lần trong một giây.- Mỗi lần
audio.play()+currentTime = 0là một “sự kiện nặng”: engine phải chuẩn bị pipeline phát, đồng bộ lại time, có thể wake up audio thread, tương tác với media subsystem. Trên iOS, phần này chạm vào nhiều layer hệ thống nên dễ gây spike CPU. - Block main thread đúng lúc bạn đang render game loop: gọi play liên tục trong frame update (một loạt đạn bắn, đạn va chạm, nổ) khiến main thread bị chặn từng nhịp nhỏ – tích lại thành tụt FPS.
- Hạn chế concurrency: iOS giới hạn số lượng audio instance phát cùng lúc; khi bạn liên tục reset
currentTimeđể “fake” multi-shot, hệ thống phải dọn và xếp lại queue audio, càng nặng. - Autoplay / suspend behavior: Safari thường suspend audio cho tới khi có tương tác user; nếu không quản lý state và resume đúng cách, bạn sẽ bị thêm chi phí xử lý mỗi lần cố play.
Tóm lại: với HTML5 Audio, mỗi lần bạn “bắn” là một lần bạn kéo cả hệ thống media dậy, trong khi game loop cần mọi thứ phải siêu nhẹ. Trên nền tảng Apple, cái giá đó đặc biệt đắt – và FPS là thứ lên thớt.
Giải pháp 1: Dùng Web Audio API – preload buffer, tạo source cực nhẹ
Cách mình chọn cho Tank 300IQ là chuyển toàn bộ sound effect sang Web Audio API. Ý tưởng:
- Dùng một
AudioContextduy nhất cho cả game. - Preload file âm thanh → decode sang
AudioBuffermột lần. - Mỗi lần chơi sound: tạo
AudioBufferSourceNodemới, connect quaGainNode(volume) →audioCtx.destinationvàstart(). - Trên iOS: nếu context ở trạng thái
suspended, gọiaudioCtx.resume()sau tương tác user.
Đây là phiên bản code hoàn chỉnh (rút gọn cho blog, nhưng logic giống hệt bản mình dùng trong game):
const SOUND_PATH = window.TANK300IQ_SOUND_PATH || "/sounds/";
// Web Audio Context (chuẩn cho game)
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
// Buffer storage
const audioBuffers = {
shoot: null,
explode: null
};
// Load và decode audio files
async function loadSound(name, path, volume = 1.0) {
try {
const response = await fetch(path);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
audioBuffers[name] = { buffer: audioBuffer, volume };
} catch (err) {
console.warn(`Failed to load sound: ${name}`, err);
}
}
// Khởi tạo sounds (gọi sau tương tác user đầu tiên)
async function initAudio() {
await Promise.all([
loadSound("shoot", SOUND_PATH + "/shoot-laser.wav", 0.6),
loadSound("explode", SOUND_PATH + "/explode.wav", 0.7)
]);
}
// Play sound (cực mượt, không lag iOS)
function playSfx(name) {
if (settings.mute) return;
if (!audioBuffers[name] || !audioBuffers[name].buffer) return;
try {
// Resume context nếu bị suspend (iOS yêu cầu)
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
// Tạo source mới (lightweight, iOS handle tốt)
const source = audioCtx.createBufferSource();
source.buffer = audioBuffers[name].buffer;
// Volume control
const gainNode = audioCtx.createGain();
gainNode.gain.value = audioBuffers[name].volume;
// Connect: source -> gain -> destination
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
// Play (non-blocking)
source.start(0);
} catch (err) {
console.warn("Audio play error:", err);
}
}
Một vài điểm quan trọng giúp FPS ổn định:
- Decode audio chỉ một lần:
decodeAudioDataxảy ra lúc init, không còn cost decode giữa game loop. - Mỗi shot chỉ tạo một node rất nhẹ:
AudioBufferSourceNodelà đối tượng native tối ưu cho playback ngắn, không kéo cả media stack giống HTMLAudioElement. - Không reset
currentTime: buffer luôn phát từ đầu, không ép engine seek lại liên tục. - Quản lý state của
audioCtx: iOS thường tự chuyển context sangsuspended, nênaudioCtx.resume()giúp đảm bảo âm thanh chạy mà không gây lỗi lặp. - Mute logic nằm ngoài: mỗi lần bắn, hàm
playSfxchỉ làm đúng một việc nhỏ rồi thoát.
Sau khi chuyển Tank 300IQ sang Web Audio theo pattern này, FPS trên iOS ổn định hẳn, ngay cả khi spam bắn + nổ dồn dập.
Giải pháp 2: Dùng Howler.js để đơn giản hóa audio engine
Nếu bạn không muốn tự tay quản Web Audio API, Howler.js là một lựa chọn rất hợp lý cho game HTML5: nó wrap Web Audio (và fallback), xử lý sẵn hầu hết edge case cross-browser, bao gồm cả iOS.
Ý tưởng:
- Import Howler.
- Tạo từng
Howlcho mỗi sound effect. - Tạo hàm
playSfx(name)map vào từng instance.
Ví dụ setup đơn giản:
<script src="https://cdnjs.cloudflare.com/ajax/libs/howler/2.2.4/howler.min.js"></script>
<script>
const SOUND_PATH = window.TANK300IQ_SOUND_PATH || "/sounds/";
const sounds = {
shoot: new Howl({
src: [SOUND_PATH + "/shoot-laser.wav"],
volume: 0.6
}),
explode: new Howl({
src: [SOUND_PATH + "/explode.wav"],
volume: 0.7
})
};
function playSfx(name) {
if (settings.mute) return;
const sfx = sounds[name];
if (!sfx) return;
sfx.play();
}
</script>
Bạn vẫn được hưởng các lợi ích chính:
- Howler tự ưu tiên Web Audio API, fallback sang HTML5 Audio khi cần.
- Đã tối ưu cho low-latency SFX và concurrency.
- Cú pháp cực gọn, dễ maintain hơn so với việc tự manage node, gain, buffer.
- Có sẵn hỗ trợ loop, sprite, cross-fade, group volume… cho game lớn.
Nếu project của bạn đã có nhiều thư viện, thêm Howler.js không phải vấn đề lớn và sẽ tiết kiệm khá nhiều thời gian debug audio trên iOS.
Nên chọn giải pháp nào cho game web của bạn?
Tùy vào scope và mức độ kiểm soát mong muốn:
- Dùng Web Audio API thuần nếu bạn muốn:
- Kiểm soát chi tiết pipeline audio.
- Tối ưu từng node cho performance.
- Giảm dependency ở client.
- Sẵn sàng xây thêm effects, filter, spatial audio tùy biến sâu.
- Dùng Howler.js nếu bạn:
- Muốn audio “chạy ngon” trên mọi trình duyệt, nhất là iOS, càng sớm càng tốt.
- Ưu tiên tốc độ phát triển và maintain hơn tối ưu siêu vi mô.
- Cần nhiều tính năng sẵn có: sprite, group, global mute…
Điểm chung quan trọng: đừng dùng HTMLAudioElement kiểu “reset currentTime rồi play liên tục” cho game loop. Trên Apple platform, cách đó rất dễ kéo FPS xuống, nhất là khi bạn spam sound trong những phân đoạn combat dày đặc.
Nếu bạn đang làm game web, đặc biệt là top-down shooter, tank game hay bất kỳ thứ gì có nhịp bắn cao, hãy chuyển sớm sang Web Audio hoặc một thư viện như Howler – bạn sẽ thấy sự khác biệt rõ rệt về độ mượt, nhất là trên iOS.
Bình luận