Gaming & Canvas dengan Wasm — WebAssembly

Gaming & Canvas dengan WebAssembly Game development adalah salah satu motivasi awal WebAssembly. Dengan Wasm, kamu bisa membuat game yang berjalan di browser…

Game di browser adalah sebuah loop: baca input, majukan dunia game sedikit, gambar satu frame, kira-kira 60 kali per detik. Wasm cocok untuk bagian memajukan dunia (gerak, tabrakan, ratusan sampai ribuan benda), sedangkan JavaScript dan Canvas yang menggambar. Bayangin pertunjukan wayang kulit. Dalang menggerakkan semua wayang di balik kelir mengikuti ketukan gamelan. Blencong, lampu minyaknya, memproyeksikan bayangan ke kelir, dan penonton cuma melihat bayangan itu. Dalangnya modul Wasm yang menyimpan dan menggerakkan semua posisi, blencongnya JavaScript yang membaca posisi itu lalu menggambar, kelirnya canvas, dan ketukan gamelannya fixed timestep: dalang bergerak per ketukan, bukan per kedip lampu.

🧪 Tebakan cepat: di game "Tangkap Kabut", kabut jatuh paling cepat 150 piksel per detik dan tampah Bu Sri tebalnya 8 piksel. HP tamu tersendat setengah detik. Kalau game dimajukan 0,5 detik sekaligus, ada kabut yang menembus tampah? Kalau dimajukan per 1/60 detik? Tebak dulu, jawabannya ada di bagian "Fixed timestep".

Game loop: input, update, gambar

requestAnimationFrame(fn) meminta browser memanggil fn tepat sebelum layar digambar ulang: 60 kali per detik di layar 60 Hz, 120 kali di layar 120 Hz, dan berhenti saat tab-nya di belakang. fn menerima waktu sekarang dalam milidetik.

Kenapa Wasm populer untuk game: gerak, fisika, dan tabrakan adalah hitungan angka yang sama berulang-ulang, dan Wasm menjalankannya mendekati kecepatan native dengan waktu yang stabil di semua browser. Engine game besar yang ditulis di C++, seperti Godot dan runtime Unity, di-compile ke Wasm dengan Emscripten, jadi game-nya bisa dibawa ke web tanpa ditulis ulang. Menggambarnya tetap lewat API browser: Canvas 2D, WebGL, atau WebGPU.

State di linear memory

Game "Tangkap Kabut": kabut turun dari Gunung Prau, dan tampah Bu Sri di bawah menangkapnya. Seluruh state-nya hidup di memori modul Wasm tangkap-kabut (Rust no_std, 1.690 byte setelah cargo build --release):

const MAKS: usize = 2000; // paling banyak 2.000 kabut

/// Tiap kabut 5 angka f32 berurutan: x, y, vx, vy, r.
static mut KABUT: [f32; MAKS * 5] = [0.0; MAKS * 5];

/// Dibaca JS tiap frame: tampah_x, lalu skor, lolos, tembus, langkah.
#[repr(C)]
pub struct Status { tampah_x: f32, skor: u32, lolos: u32, tembus: u32, langkah: u32 }

/// Maju sejauh dt detik. TAMPAH_Y = 176, tebal 8, lebar 56; LEBAR x TINGGI = 320 x 200.
#[unsafe(no_mangle)]
pub extern "C" fn langkah_dt(dt: f32) {
    unsafe {
        let s = &mut *core::ptr::addr_of_mut!(STATUS);
        let k = &mut *core::ptr::addr_of_mut!(KABUT);
        let mut tx = s.tampah_x + ARAH as f32 * TAMPAH_LAJU * dt;
        if tx < 0.0 { tx = 0.0; }
        if tx > LEBAR - TAMPAH_LEBAR { tx = LEBAR - TAMPAH_LEBAR; }
        s.tampah_x = tx;
        for i in 0..JUMLAH {
            let o = i * 5;
            let (x0, y0, r) = (k[o], k[o + 1], k[o + 4]);
            let mut x = x0 + k[o + 2] * dt;
            let y = y0 + k[o + 3] * dt;
            if x < 0.0 { x += LEBAR; }
            if x >= LEBAR { x -= LEBAR; }
            k[o] = x;
            k[o + 1] = y;
            let sejajar = x + r > tx && x - r < tx + TAMPAH_LEBAR;
            if sejajar && y + r >= TAMPAH_Y && y - r <= TAMPAH_Y + TAMPAH_TEBAL {
                s.skor += 1; // menyentuh tampah di posisi barunya
                lahirkan(i, true); // muncul lagi di atas
            } else if sejajar && y0 + r < TAMPAH_Y && y - r > TAMPAH_Y + TAMPAH_TEBAL {
                s.tembus += 1; // melompati tampah dalam satu langkah
            } else if y - r > TINGGI {
                s.lolos += 1;
                lahirkan(i, true);
            }
        }
        s.langkah += 1;
    }
}
WebAssembly.instantiate(bytes).then(({ instance }) => {
  const x = instance.exports;
  x.mulai(200, 2026); // 200 kabut, benih 2026
  const kabut = new Float32Array(x.memory.buffer, x.kabut_ptr(), 200 * 5);
  const tampahX = new Float32Array(x.memory.buffer, x.status_ptr(), 1);
  const angka = new Uint32Array(x.memory.buffer, x.status_ptr(), 5); // [1] skor, [2] lolos, [3] tembus

  function gambar() {
    ctx.clearRect(0, 0, 320, 200);
    ctx.beginPath();
    for (let i = 0; i < 200; i++) {
      const o = i * 5;
      ctx.moveTo(kabut[o] + kabut[o + 4], kabut[o + 1]);
      ctx.arc(kabut[o], kabut[o + 1], kabut[o + 4], 0, Math.PI * 2);
    }
    ctx.fill(); // satu fill untuk semua kabut
    ctx.fillRect(tampahX[0], 176, 56, 8);
    ctx.fillText('Skor ' + angka[1], 8, 14);
  }
});

Satu view, bukan satu panggilan per kabut

Cara lain membaca posisi: tiga fungsi export, kabut_x(i), kabut_y(i), kabut_r(i). Untuk 2.000 kabut itu 6.000 penyeberangan per frame, lawan 0 lewat view. Diukur:

Per frame, 2.000 kabut Waktu
Membaca lewat view (Node 22) 0,04 ms
Membaca lewat 6.000 panggilan (Node 22) 0,08 ms
langkah() di Wasm 0,013 ms
Menggambar 2.000 lingkaran sampai jadi piksel (Chromium 147 headless, dipaksa dengan getImageData) sekitar 5 ms

Di game ini, penyeberangan bukan yang paling mahal: export yang cuma mengembalikan angka murah sekali per panggilan (ingat pelajaran "Ongkos menyeberang": 960.000 panggilan 8 ms). Yang berat justru menggambar: merekam perintah Canvas-nya murah, tapi mengubahnya jadi piksel butuh sekitar 5 ms dari anggaran 16,7 ms per frame, jauh di atas 0,08 ms tadi. Polanya tetap: satu panggilan langkah() per langkah, state dibaca lewat view, gambar dikumpulkan jadi sesedikit mungkin draw call. Pola per entitas jadi mahal kalau tiap panggilan membuat objek JS, atau kalau Wasm memanggil Canvas sendiri untuk tiap kabut lewat glue seperti web-sys.

Kalau modulnya memanggil memory.grow, view lama putus. Panjangnya jadi 0, kabut[0] diam-diam undefined, dan method-nya melempar TypeError: Cannot perform %TypedArray%.prototype.fill on a detached ArrayBuffer. Buat view baru kalau kabut.buffer !== x.memory.buffer.

Fixed timestep

Frame gak pernah datang dengan jarak yang sama. Kalau tiap frame game dimajukan sejauh jarak frame itu (dt variabel), hasilnya bergantung pada HP-nya. Fixed timestep memajukan dunia dengan langkah tetap 1/60 detik, sebanyak yang perlu untuk mengejar waktu:

const DT = 1 / 60;
let sisa = 0;
let sebelumnya = performance.now();

function frame(sekarang) {
  sisa += Math.min((sekarang - sebelumnya) / 1000, 0.25); // paling banyak 15 langkah sekali kejar
  sebelumnya = sekarang;
  while (sisa >= DT) {
    x.langkah();
    sisa -= DT;
  }
  gambar();
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

Diukur dengan 200 kabut dan benih 2026, tampah diam:

Cara memajukan Skor Lolos Tembus
10 detik, fixed 1/60 (600 langkah) 147 556 0
10 detik, dt variabel di 20 fps (200 langkah) 145 554 0
10 detik, dt variabel di 120 fps 148 556 0
1 detik, lalu satu langkah dt 0,5 detik 23 83 8
1 detik, lalu lag 0,5 detik dengan kode di atas (15 langkah 1/60) 26 61 0

Jadi jawaban tebakannya: dengan dt 0,5 detik, ya, 8 kabut menembus tampah. Kabut 150 piksel per detik melompat 75 piksel dalam satu langkah, jadi di posisi lama dia di atas tampah dan di posisi baru sudah di bawahnya. Dengan fixed timestep, langkahnya paling jauh 2,5 piksel, gak ada yang tembus; karena batas 0,25 detik, cuma 15 langkah yang dijalankan dan game melambat sebentar. Tiga baris pertama juga menunjukkan dt variabel memberi skor beda di HP yang beda.

Input: keyboard dan sentuhan

const tombol = { ArrowLeft: -1, ArrowRight: 1 };
addEventListener('keydown', (e) => { if (e.key in tombol && !e.repeat) x.arah(tombol[e.key]); });
addEventListener('keyup', (e) => { if (e.key in tombol) x.arah(0); });

canvas.addEventListener('pointermove', (e) => {
  const kotak = canvas.getBoundingClientRect();
  x.taruh_tampah(((e.clientX - kotak.left) * 320) / kotak.width); // piksel layar ke koordinat game
});

📌 Engine: Godot, Unity, Bevy

📌 HP: layar, baterai, tab di belakang

⚠️ Jebakan yang sering ketemu

TL;DR: Game browser adalah loop requestAnimationFrame: input, update, gambar. Wasm populer untuk game karena gerak, fisika, dan tabrakan jalan mendekati native, dan engine C++ (Godot, runtime Unity) bisa di-compile ke Wasm dengan Emscripten; menggambar tetap lewat Canvas, WebGL, atau WebGPU. Arsitekturnya: Wasm menghitung state di linear memory (Tangkap Kabut, 1.690 byte, kabut sebagai deret f32 x, y, vx, vy, r), JS membaca view Float32Array sekali per frame dan menggambar semua kabut dengan satu path. Penyeberangan per entitas (6.000 panggilan, 0,08 ms) bukan yang paling mahal di sini; menggambar (sekitar 5 ms) yang mahal. Pakai fixed timestep 1/60 dengan batas 0,25 detik: satu langkah 0,5 detik membuat 8 kabut menembus tampah, langkah tetap membuatnya 0, dan hasilnya sama di semua layar. Input dikirim saat berubah (arah, taruh_tampah), canvas diskalakan devicePixelRatio dengan touch-action: none. Buat view baru setelah memory.grow. Pakai engine (Godot, Unity, Bevy) untuk game besar; untuk game kecil, modul sendiri jauh lebih ringan.

Yang akan kamu pelajari