Image processing di browser artinya: ambil piksel foto dari canvas, hitung piksel baru, lalu gambar lagi. Wasm cocok untuk bagian hitungnya, karena tiap foto berisi jutaan byte yang diproses dengan loop yang sama. Bayangin Bu Sri menyiapkan foto Kamar Teras untuk tiga keperluan: versi hitam-putih untuk brosur fotokopian, versi kecil untuk daftar kamar, dan versi buram untuk latar halaman booking. Fotonya dibawa dari etalase ke meja tukang yang cepat, dikerjakan di sana, lalu dibawa balik. Kalau tukangnya bekerja di ruang belakang, kasir di depan tetap bisa melayani tamu. Etalasenya canvas, meja tukangnya memori Wasm, membawa foto bolak-balik itu menyalin byte, dan ruang belakangnya Web Worker.
🧪 Tebakan cepat: foto 1200 × 800 dikecilkan jadi thumbnail 400 × 267. Dinding bambunya punya bilah selebar 2 piksel, terang dan gelap bergantian. Kalau dikecilkan dengan bilinear (campuran 4 piksel terdekat), dindingnya jadi warna rata, jadi garis-garis palsu, atau sama saja dengan cara nearest? Tebak dulu, jawabannya ada di bagian "Thumbnail 400 × 267".
Alurnya: canvas, memori Wasm, canvas lagi
Pelajaran ini memakai olahfoto.wasm: crate Rust no_std berisi grayscale, tiga cara resize, dan blur, 4.177 byte setelah build release. Semua hitungannya pakai angka bulat, jadi hasilnya sama byte demi byte dengan versi JavaScript yang ada di Lab.
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const img = ctx.getImageData(0, 0, 1200, 800); // RGBA, 3.840.000 byte
WebAssembly.instantiate(bytes).then(({ instance }) => {
const { memory, grayscale } = instance.exports;
const ptr = memory.buffer.byteLength; // taruh foto setelah memori awal modul
memory.grow(Math.ceil(img.data.length / 65536)); // 59 halaman lagi
new Uint8Array(memory.buffer).set(img.data, ptr); // salin masuk
grayscale(ptr, img.data.length); // hitung di Wasm
img.data.set(new Uint8Array(memory.buffer, ptr, img.data.length)); // salin keluar
ctx.putImageData(img, 0, 0);
});
getImageDatamemberiImageData: lebar, tinggi, dandata, sebuahUint8ClampedArrayberisi R, G, B, A untuk tiap piksel.- Wasm gak bisa membaca canvas. Byte-nya harus disalin ke linear memory dulu, lalu hasilnya disalin keluar.
willReadFrequently: truememinta canvas disimpan di CPU. Tanpa itu,getImageDatayang sering dipanggil harus menarik piksel dari GPU tiap kali.- Diukur di Chromium 147:
getImageData1,1 ms,putImageData0,2 ms, salin masuk 0,1 ms, salin keluar 0,4 ms.
Kenapa Wasm cocok di sini: foto ini 960.000 piksel, dan tiap piksel butuh perkalian dan penjumlahan yang sama. Itu murni kerja CPU. Wasm membaca dan menulis byte langsung di linear memory, tanpa objek dan tanpa garbage collector, dan hasil compile-nya stabil di semua browser.
Grayscale, sekarang dengan SIMD
Rumusnya sama dengan pelajaran sebelumnya: abu = (r × 77 + g × 150 + b × 29) >> 8. SIMD (single instruction, multiple data) mengerjakan satu instruksi untuk 16 byte sekaligus, jadi 4 piksel per langkah. Wasm punya SIMD 128-bit sejak Chrome 91, Firefox 89, dan Safari 16.4.
#[unsafe(no_mangle)]
#[target_feature(enable = "simd128")]
pub unsafe extern "C" fn grayscale_simd(ptr: *mut u8, len: usize) {
use core::arch::wasm32::*;
let bobot = i16x8(77, 150, 29, 0, 77, 150, 29, 0);
let alfa = u32x4_splat(0xFF00_0000);
let tiga = u32x4_splat(0x0001_0101); // abu × 0x010101 = abu di byte R, G, B
let mut i = 0;
while i + 16 <= len {
let p = unsafe { ptr.add(i) } as *mut v128;
let v = unsafe { v128_load(p) };
let a = i32x4_dot_i16x8(u16x8_extend_low_u8x16(v), bobot);
let b = i32x4_dot_i16x8(u16x8_extend_high_u8x16(v), bobot);
let jumlah = i32x4_add(i32x4_shuffle::<0, 2, 4, 6>(a, b), i32x4_shuffle::<1, 3, 5, 7>(a, b));
let abu = u32x4_shr(jumlah, 8);
unsafe { v128_store(p, v128_or(i32x4_mul(abu, tiga), v128_and(v, alfa))) };
i += 16;
}
unsafe { grayscale(ptr.add(i), len - i) }; // sisa byte: versi biasa
}
u16x8_extend_low_u8x16melebarkan 8 byte pertama jadi 8 angka 16-bit.i32x4_dot_i16x8mengalikan dengan bobot lalu menjumlahkan berpasangan:r × 77 + g × 150danb × 29.- Dua shuffle dan satu
i32x4_addmenggabungkan pasangan itu jadi 4 nilai abu.v128_andmenjaga alfa. #[target_feature(enable = "simd128")]menyalakan SIMD untuk fungsi ini saja.RUSTFLAGS="-C target-feature=+simd128"menyalakannya untuk seluruh crate, dan compiler mencoba sendiri (auto-vectorization).
Diukur di laptop yang sedang sibuk, median, dalam milidetik (Chromium 147 / WebKit 26.4):
| Grayscale 1200 × 800 | Waktu |
|---|---|
| JavaScript | 1,95 / 1,13 |
| Wasm | 0,85 / 0,88 |
| Wasm, SIMD ditulis tangan | 0,47 / 0,44 |
| Wasm, SIMD dari compiler | 0,84 / 0,93 |
Ketiga versi Wasm hasilnya sama persis dengan JavaScript. Modul yang berisi instruksi SIMD gagal di-compile di engine tanpa SIMD, jadi cek dulu dengan WebAssembly.validate pada modul kecil berisi SIMD (cara yang dipakai library wasm-feature-detect), lalu sediakan dua build.
Thumbnail 400 × 267: nearest, bilinear, rata-rata area
- Nearest: tiap piksel thumbnail mengambil satu piksel asli yang paling dekat dengan pusatnya:
sx = floor((2x + 1) × 1200 / 800), jadi piksel 1, 4, 7, dan seterusnya. - Bilinear: mencampur 4 piksel asli terdekat, bobotnya dari jarak.
- Rata-rata area: merata-ratakan semua piksel asli yang ditutupi satu piksel thumbnail, di sini 3 × 3.
1200 dibagi 400 pas 3, jadi pusat tiap piksel thumbnail jatuh tepat di piksel asli 3x + 1. Bobot bilinear arah datarnya 0, dan hasilnya hampir sama dengan nearest: cuma 7.304 dari 427.200 byte yang beda. Dinding bambu (bilah 2 piksel, nilai merah 150 dan 105) diambil tiap 3 piksel, dan muncul garis-garis palsu yang lebarnya 3 kali garis asli. Itu jawaban tebakannya: bilinear cuma melihat 4 tetangga, jadi untuk mengecilkan 3 kali dia gak lebih baik dari nearest. Gejala ini namanya aliasing (moiré).
| Cara | Chromium 147 | Naik-turun warna di dinding |
|---|---|---|
| Nearest, JS / Wasm | 0,21 / 0,08 ms | 22,5 (sama dengan aslinya) |
| Bilinear, JS / Wasm | 3,02 / 0,87 ms | 22,5 |
| Rata-rata area, JS / Wasm | 5,62 / 2,07 ms | 7,5 |
| Blur σ 1,5 dulu, lalu bilinear | 3,0 |
"Naik-turun warna" adalah simpangan baku nilai merah di area dinding: makin kecil, makin rata, makin benar. Untuk mengecilkan banyak, rata-ratakan atau blur dulu.
Blur: kernel yang dihitung, dua langkah
Blur Gaussian mengganti tiap piksel dengan rata-rata berbobot tetangganya. Bobotnya dihitung dari σ (sigma, lebar blur):
function kernelGauss(sigma) {
const r = Math.ceil(sigma * 3); // tetangga sejauh 3σ
const w = [];
let total = 0;
for (let i = -r; i <= r; i++) {
const v = Math.exp(-(i * i) / (2 * sigma * sigma));
w.push(v);
total += v;
}
const k = w.map((v) => Math.round((v / total) * 4096)); // bobot bulat, jumlahnya 4096
k[r] += 4096 - k.reduce((a, b) => a + b, 0); // sisa pembulatan masuk ke tengah
return { k, r };
}
// kernelGauss(2).k = [9, 36, 111, 266, 496, 722, 816, 722, 496, 266, 111, 36, 9]
- Box blur adalah kernel yang semua bobotnya sama. Gaussian memberi bobot paling besar ke piksel tengah, jadi hasilnya lebih halus.
- Separable: blur 2D Gaussian sama dengan blur datar lalu blur tegak. Dengan σ 2, tiap piksel butuh 13 + 13 = 26 perkalian per channel, bukan 13 × 13 = 169. Di Node 22, versi 2D dalam JavaScript butuh 1.014 ms, versi dua langkah 143 ms.
- Rust-nya punya
blur_hdanblur_vdengan kernel yang sama, dikirim JS ke memori Wasm sebagaiInt32Array. Tepi gambar diulang (clamp).
| Blur Gaussian σ 2 | Chromium 147 | WebKit 26.4 |
|---|---|---|
| JavaScript | 155 ms | 127 ms |
| Wasm | 49 ms | 49 ms |
Wasm, build +simd128 |
37 ms | 39 ms |
Di sini Wasm 2,6 sampai 3,2 kali lebih cepat, karena hitungannya jauh lebih banyak daripada ongkos salinnya.
Worker dan OffscreenCanvas: UI tetap jalan
Blur 49 ms di thread utama berarti halaman beku 49 ms: klik dan scroll menunggu. Pindahkan hitungannya ke Web Worker, dan kirim byte-nya sebagai transferable: ArrayBuffer-nya dipindahkan, bukan disalin.
// halaman
const worker = new Worker('olah-worker.js');
worker.onmessage = (e) => ctx.putImageData(new ImageData(new Uint8ClampedArray(e.data), 1200, 800), 0, 0);
const img = ctx.getImageData(0, 0, 1200, 800);
worker.postMessage({ pixels: img.data.buffer, sigma: 2 }, [img.data.buffer]); // dipindah
// olah-worker.js: pasang olahfoto.wasm sekali, blur, kirim balik dengan transfer juga
onmessage = (e) => {
const hasil = blurDenganWasm(e.data.pixels, e.data.sigma); // ArrayBuffer baru
postMessage(hasil, [hasil]);
};
Diukur dengan "detak" MessageChannel di thread utama, jeda terpanjangnya (median 5 kali, Chromium 147 / WebKit 26.4):
| Blur σ 2 dengan Wasm | Jeda terpanjang di thread utama | Total (Chromium) |
|---|---|---|
| Di thread utama | 51 / 51 ms | 51 ms |
| Di Worker, hasil digambar halaman | 1,5 / 8,6 ms | 54 ms |
| Di Worker dengan OffscreenCanvas | 0,7 / 6,5 ms | 53 ms |
Totalnya sedikit lebih lama, tapi halaman gak pernah beku. Dengan canvas.transferControlToOffscreen(), canvas-nya diserahkan ke Worker, dan Worker menggambar sendiri. OffscreenCanvas 2D ada di Chrome 69, Firefox 105, dan Safari 16.4.
💡 Kapan gak perlu Wasm
- Cuma tampilan: CSS
filter: grayscale(1) blur(2px)di<img>dikerjakan GPU, tanpa kode. Kamu gak dapat pikselnya. ctx.filterdi canvas: ada di Chromium dan Firefox, di Safari cuma di balik flag (WebKit 26.4 gak punya). Rumusnya juga beda:grayscale(1)di Chromium beda sampai 14 dari rumus kita, karena CSS memakai bobot 0,2126, 0,7152, 0,0722.- Thumbnail biasa:
createImageBitmap(img, { resizeWidth: 400, resizeHeight: 267, resizeQuality: 'high' })selesai dalam 1,6 ms (Chromium) dan 2,6 ms (WebKit). Hasilnya beda per browser: naik-turun warna di dinding 10,5 di Chromium, 1,7 di WebKit. - Video 60 fps: WebGL atau WebGPU, shader di GPU.
- Encode JPEG, WebP, AVIF dengan kontrol penuh: di sinilah Wasm menang besar. Squoosh (squoosh.app) memakai codec MozJPEG, OxiPNG, WebP, dan AVIF yang di-compile ke Wasm, dan tersedia sebagai paket
@jsquash/*. Library lain: photon (@silvia-odwyer/photon, ditulis di Rust),wasm-vips(libvips),@imagemagick/magick-wasm, dan crate Rustimage.
⚠️ Jebakan yang sering ketemu
- Mengecilkan banyak dengan nearest atau bilinear. Garis halus jadi garis palsu. Rata-ratakan area, atau blur dulu.
- Mengukur hitungannya saja.
getImageData, salin masuk, salin keluar, danputImageDataikut dibayar tiap kali. - Blur 2D penuh. 169 perkalian per piksel padahal 26 cukup. Pakai dua langkah.
- Menjalankan filter berat di thread utama. 49 ms = halaman beku 49 ms. Pindahkan ke Worker, kirim
ArrayBuffersebagai transferable. - Mengirim modul SIMD tanpa cadangan. Engine tanpa SIMD menolak seluruh modul. Cek dengan
WebAssembly.validate.
TL;DR: Alur image processing dengan Wasm: getImageData dari canvas, salin byte RGBA ke linear memory, panggil fungsi Wasm sekali untuk seluruh foto, salin hasilnya keluar, putImageData. Wasm cocok karena kerjanya murni CPU di jutaan byte, dibaca langsung dari memori tanpa garbage collector. Grayscale 1200 × 800 di Chromium 147: JS 1,95 ms, Wasm 0,85 ms, SIMD 0,47 ms (cek dukungan dengan WebAssembly.validate). Thumbnail 400 × 267: nearest dan bilinear sama-sama memunculkan garis palsu, rata-rata area atau blur dulu yang benar. Blur Gaussian: kernel dihitung dari σ (r = ceil(3σ), bobot bulat berjumlah 4096), dikerjakan dua langkah (26 perkalian, bukan 169): JS 155 ms, Wasm 49 ms, +simd128 37 ms. Taruh kerja berat di Worker (jeda thread utama 51 ms jadi 1,5 ms) dan kirim ArrayBuffer sebagai transferable, atau serahkan canvas lewat OffscreenCanvas. Untuk tampilan saja pakai CSS filter, untuk thumbnail biasa createImageBitmap, untuk video WebGL atau WebGPU, dan untuk encoder seperti MozJPEG atau AVIF pakai codec Wasm yang sudah ada (Squoosh, jSquash).