Rust ve WebAssembly ile Canvas API Kullanarak Tarayıcıda Grafik Çizme
Performans kritik web uygulamaları geliştirirken bir noktada JavaScript’in sınırlarına daşıyorsunuz. Özellikle gerçek zamanlı veri görselleştirme, oyun grafikleri ya da sinyal işleme gibi hesaplama yoğun senaryolarda bu sınır çok hızlı geliyor. İşte tam bu noktada Rust + WebAssembly ikilisi sahneye çıkıyor. Canvas API ile birleşince ortaya gerçekten etkileyici şeyler çıkabiliyor. Bu yazıda sıfırdan bir Rust WASM projesi kurarak Canvas üzerinde grafik çizmeyi, performans optimizasyonlarını ve production’a hazır bir mimari kurmayı anlatacağım.
Neden Rust + WASM + Canvas?
JavaScript ile Canvas üzerinde grafik çizebilirsiniz, bunu herkes biliyor. Ama 60 FPS’de on binlerce nokta çizen bir gerçek zamanlı telemetri dashboard’u yazmaya çalıştığınızda ya da FFT tabanlı bir ses spektrum analizörü geliştirirken JavaScript’in garbage collector’ı tam yanlış anda devreye giriyor ve frame’ler düşüyor.
Rust’ın bellek yönetimi modeli bu sorunu kökeninden çözüyor. GC yok, beklenmedik duraklamalar yok. WASM’a derlenen Rust kodu JavaScript’e kıyasla bellek kullanımı öngörülebilir, CPU kullanımı tutarlı. Bir grafik pipeline’ı için istediğiniz tam da bu.
Şunu da söylemek lazım: Her şey için Rust WASM kullanmak saçmalık olur. Basit bir bar chart için jQuery dönemi araçları bile yeterli. Ama gerçekten yoğun hesaplama gerektiren görselleştirmeler için bu kombinasyon rakipsiz.
Ortam Kurulumu
Önce araçları kuralım. Rust toolchain’i yoksa:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustup target add wasm32-unknown-unknown
wasm-pack olmadan bu işleri yapmak ciddi zahmet. Direkt kuralım:
cargo install wasm-pack
cargo install cargo-generate
Proje iskeletini oluşturalım:
cargo generate --git https://github.com/rustwasm/wasm-pack-template
# Proje adı: canvas-renderer
cd canvas-renderer
Şimdi Cargo.toml dosyasına bakacağız. Bu dosyayı doğru yapılandırmak kritik, aksi halde WASM boyutu gereksiz yere şişiyor:
cat Cargo.toml
[package]
name = "canvas-renderer"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"Window",
"Document",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"console",
"Performance",
"ImageData",
] }
console_error_panic_hook = { version = "0.1", optional = true }
wee_alloc = { version = "0.4", optional = true }
[profile.release]
opt-level = "s"
lto = true
opt-level = "s" boyutu optimize eder, lto = true ise link-time optimization açıyor. Production WASM dosyalarında bu ikisi olmazsa olmaz.
İlk Canvas Bağlantısı
Rust tarafında Canvas elementine erişmek JavaScript’e göre biraz verbose görünüyor ama aslında oldukça temiz:
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement};
#[wasm_bindgen]
pub struct Renderer {
context: CanvasRenderingContext2d,
width: f64,
height: f64,
}
#[wasm_bindgen]
impl Renderer {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str) -> Result<Renderer, JsValue> {
let window = web_sys::window().expect("window bulunamadi");
let document = window.document().expect("document bulunamadi");
let canvas = document
.get_element_by_id(canvas_id)
.expect("canvas elementi bulunamadi")
.dyn_into::<HtmlCanvasElement>()
.expect("HtmlCanvasElement'e cast basarisiz");
let width = canvas.width() as f64;
let height = canvas.height() as f64;
let context = canvas
.get_context("2d")?
.unwrap()
.dyn_into::<CanvasRenderingContext2d>()?;
Ok(Renderer { context, width, height })
}
pub fn clear(&self) {
self.context.clear_rect(0.0, 0.0, self.width, self.height);
}
}
Bu yapı bir kez oluşturuluyor ve JavaScript tarafına geçiriliyor. Her frame’de yeni bir context almak ciddi performans kaybı yaratır, bu yüzden Renderer struct’ını uzun ömürlü tutuyoruz.
Gerçek Zamanlı Sinyal Grafiği Çizme
Benim en çok kullandığım senaryo: IoT cihazlarından gelen sensör verilerini gerçek zamanlı olarak görselleştirmek. WebSocket’ten saniyede yüzlerce veri noktası geliyor ve bunları smooth bir şekilde çizmek gerekiyor.
Veri buffer’ını Rust tarafında tutmak büyük avantaj sağlıyor. Her veri gelişinde JavaScript’ten Rust’a geçiş maliyeti minimal:
use std::collections::VecDeque;
const BUFFER_SIZE: usize = 1024;
#[wasm_bindgen]
pub struct SignalRenderer {
context: CanvasRenderingContext2d,
width: f64,
height: f64,
data_buffer: VecDeque<f64>,
min_val: f64,
max_val: f64,
}
#[wasm_bindgen]
impl SignalRenderer {
#[wasm_bindgen(constructor)]
pub fn new(canvas_id: &str) -> Result<SignalRenderer, JsValue> {
// canvas bağlantısı yukarıdaki gibi...
let context = /* ... */;
Ok(SignalRenderer {
context,
width: 800.0,
height: 400.0,
data_buffer: VecDeque::with_capacity(BUFFER_SIZE),
min_val: f64::MAX,
max_val: f64::MIN,
})
}
pub fn push_data(&mut self, value: f64) {
if self.data_buffer.len() >= BUFFER_SIZE {
self.data_buffer.pop_front();
}
self.data_buffer.push_back(value);
// Min/max güncelle
if value < self.min_val { self.min_val = value; }
if value > self.max_val { self.max_val = value; }
}
pub fn render(&self) {
self.context.clear_rect(0.0, 0.0, self.width, self.height);
if self.data_buffer.len() < 2 {
return;
}
let range = self.max_val - self.min_val;
if range == 0.0 { return; }
let step_x = self.width / (self.data_buffer.len() - 1) as f64;
self.context.begin_path();
self.context.set_stroke_style(&JsValue::from_str("#00ff88"));
self.context.set_line_width(2.0);
for (i, &val) in self.data_buffer.iter().enumerate() {
let x = i as f64 * step_x;
let normalized = (val - self.min_val) / range;
let y = self.height - (normalized * self.height * 0.9 + self.height * 0.05);
if i == 0 {
self.context.move_to(x, y);
} else {
self.context.line_to(x, y);
}
}
self.context.stroke();
}
}
VecDeque burada önemli bir seçim. Ring buffer semantiği ile en eski veriyi O(1) maliyetle atıyoruz. Array shift operasyonu gibi O(n) maliyetli işlemlerden kaçınıyoruz.
Doğrudan Pixel Manipülasyonu
Canvas API’nin en güçlü özelliklerinden biri doğrudan pixel buffer’a yazmak. Bu yöntem özellikle heatmap, histogram yoğunluk haritası ya da particle simülasyonu için çok daha hızlı:
#[wasm_bindgen]
pub fn render_heatmap(
canvas_id: &str,
data: &[f64],
cols: usize,
rows: usize,
) -> Result<(), JsValue> {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let canvas = document
.get_element_by_id(canvas_id)
.unwrap()
.dyn_into::<HtmlCanvasElement>()?;
let ctx = canvas
.get_context("2d")?
.unwrap()
.dyn_into::<CanvasRenderingContext2d>()?;
let cell_w = canvas.width() as f64 / cols as f64;
let cell_h = canvas.height() as f64 / rows as f64;
// RGBA pixel buffer oluştur
let mut pixel_data: Vec<u8> = vec![0u8; cols * rows * 4];
let max_val = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_val = data.iter().cloned().fold(f64::INFINITY, f64::min);
let range = (max_val - min_val).max(1e-10);
for (idx, &val) in data.iter().enumerate() {
let normalized = ((val - min_val) / range).clamp(0.0, 1.0);
// Viridis-benzeri renk skalası
let r = (normalized * 255.0) as u8;
let g = ((1.0 - (2.0 * normalized - 1.0).abs()) * 200.0) as u8;
let b = ((1.0 - normalized) * 255.0) as u8;
let base = idx * 4;
pixel_data[base] = r;
pixel_data[base + 1] = g;
pixel_data[base + 2] = b;
pixel_data[base + 3] = 255; // tam opak
}
// ImageData oluştur ve canvas'a aktar
let image_data = web_sys::ImageData::new_with_u8_clamped_array_and_sh(
wasm_bindgen::Clamped(&pixel_data),
cols as u32,
rows as u32,
)?;
ctx.put_image_data(&image_data, 0.0, 0.0)?;
Ok(())
}
Bu yöntemin püf noktası: pixel buffer’ı Rust’ta hesaplayıp tek bir put_image_data çağrısıyla canvas’a yazıyoruz. JavaScript-WASM geçiş maliyetini minimize ediyoruz. Her pixel için ayrı Canvas API çağrısı yapmak felakete davet çıkarmaktır.
JavaScript Tarafı ve requestAnimationFrame
Rust tarafı ne kadar iyi olursa olsun, animasyon döngüsünü doğru kurmak şart. JavaScript tarafında closure trap’e düşmemek önemli:
import init, { SignalRenderer } from './pkg/canvas_renderer.js';
async function main() {
await init();
const renderer = new SignalRenderer('myCanvas');
// WebSocket'ten gerçek veri
const ws = new WebSocket('ws://localhost:8080/telemetry');
ws.onmessage = (event) => {
const value = parseFloat(event.data);
renderer.push_data(value);
};
// Animasyon döngüsü
let lastTime = 0;
const TARGET_FPS = 60;
const FRAME_INTERVAL = 1000 / TARGET_FPS;
function animate(timestamp) {
if (timestamp - lastTime >= FRAME_INTERVAL) {
renderer.render();
lastTime = timestamp;
}
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
}
main().catch(console.error);
requestAnimationFrame ile FPS sınırlaması yapmak önemli. Browser zaten 60 FPS’e sync ediyor ama timestamp kontrolü ile gereksiz render çağrılarını engelleyebilirsiniz.
Derleme ve Optimizasyon
# Development build
wasm-pack build --target web --dev
# Production build (küçük boyut için)
wasm-pack build --target web --release
# WASM dosyasını daha da küçültmek için
wasm-opt -Os -o pkg/canvas_renderer_bg_opt.wasm pkg/canvas_renderer_bg.wasm
wasm-opt tool’u binaryen paketinin bir parçası. Ubuntu/Debian’da:
sudo apt-get install binaryen
Release build sonrası WASM dosya boyutunuz 50-100KB civarında olmalı. Bu boyutu daha da küçültmek için Cargo.toml‘a şunu ekleyebilirsiniz:
[profile.release]
opt-level = "z" # boyut odaklı optimizasyon
lto = true
codegen-units = 1
panic = "abort" # unwind mekanizmasını kaldır
strip = true # debug sembollerini sil
panic = "abort" önemli bir seçim. WASM’da panic unwind mekanizması oldukça ağır. Eğer uygulamanızın production’da panic yapmaması gerekiyorsa (ki gerekmiyor) bu ayar hem boyutu hem de runtime performansını iyileştirir.
Gerçek Dünya Senaryosu: Network Monitoring Dashboard
Şirketteki monitoring sistemimizde Grafana yetmez olduğunda kendi canvas tabanlı dashboard’umuzu yazdık. Sorun şuydu: 500 node’dan gelen, 100ms aralıklarla güncellenen latency verilerini aynı anda görselleştirmemiz gerekiyordu. Pure JavaScript çözüm tutarlı 60 FPS veremiyor, frame’ler 80-120ms arasında gidip geliyordu.
Rust WASM çözümüyle frame süresi 12-16ms’de kaldı. Garbage collector duraklamaları ortadan kalktı.
Bu senaryoda kritik olan nokta: tüm veri işleme (normalizasyon, ölçekleme, renk hesaplama) Rust’ta yapılıyor. JavaScript sadece WASM modülünü başlatıyor ve WebSocket verilerini geçiriyor.
Birden fazla serinin farklı renklerde çizilmesi için basit bir multi-series yaklaşımı:
#[wasm_bindgen]
pub struct MultiSeriesRenderer {
context: CanvasRenderingContext2d,
width: f64,
height: f64,
series: Vec<(VecDeque<f64>, String)>, // (data, color)
}
#[wasm_bindgen]
impl MultiSeriesRenderer {
pub fn add_series(&mut self, color: &str) {
self.series.push((
VecDeque::with_capacity(512),
color.to_string(),
));
}
pub fn push_to_series(&mut self, series_idx: usize, value: f64) {
if let Some((buffer, _)) = self.series.get_mut(series_idx) {
if buffer.len() >= 512 {
buffer.pop_front();
}
buffer.push_back(value);
}
}
pub fn render_all(&self) {
self.context.clear_rect(0.0, 0.0, self.width, self.height);
for (buffer, color) in &self.series {
if buffer.len() < 2 { continue; }
// Global min/max hesapla
let max = buffer.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min = buffer.iter().cloned().fold(f64::INFINITY, f64::min);
let range = (max - min).max(1e-10);
let step = self.width / (buffer.len() - 1) as f64;
self.context.begin_path();
self.context.set_stroke_style(&JsValue::from_str(color));
self.context.set_line_width(1.5);
for (i, &val) in buffer.iter().enumerate() {
let x = i as f64 * step;
let y = self.height - ((val - min) / range * self.height * 0.85 + self.height * 0.075);
if i == 0 { self.context.move_to(x, y); }
else { self.context.line_to(x, y); }
}
self.context.stroke();
}
}
}
Bellek Yönetimi ve Sızıntı Önleme
WASM bellek yönetimi konusunda dikkat edilmesi gereken bir nokta var: JavaScript tarafında oluşturulan Rust nesneleri, JavaScript GC tarafından yönetilmiyor. wasm-bindgen tarafından oluşturulan wrapper’ların free() metodunu çağırmanız gerekiyor ya da modern tarayıcılarda FinalizationRegistry kullanabilirsiniz:
const registry = new FinalizationRegistry((heldValue) => {
// Rust nesnesi serbest bırak
heldValue.free();
});
async function createRenderer(canvasId) {
const renderer = new SignalRenderer(canvasId);
// GC bu nesneyi topladığında Rust tarafını da temizle
registry.register(renderer, renderer, renderer);
return renderer;
}
// Manuel temizleme (daha güvenilir)
function cleanup(renderer) {
renderer.free();
}
Production’da FinalizationRegistry‘ye güvenmek yerine explicit free() çağrısı yapmayı tercih ediyorum. Özellikle Single Page Application’larda route değişimlerinde canvas renderer’ı temizlemek şart.
Sonuç
Rust WASM ile Canvas API kombinasyonu her proje için değil, ama gerçekten ihtiyaç duyduğunuzda başka hiçbir şeyle kıyaslanamayacak kadar güçlü. Kurulum maliyeti var, öğrenme eğrisi var, ama gerçek zamanlı görselleştirme, yüksek frekanslı veri akışları ve hesaplama yoğun grafikler söz konusu olduğunda bu yatırım fazlasıyla geri dönüyor.
Özetle pratik tavsiyelerim şunlar:
- Veri buffer’larını Rust tarafında tutun, her frame’de JavaScript’ten veri geçirmeyin.
put_image_dataile toplu pixel yazımı, tek tek API çağrılarından her zaman daha hızlı.panic = "abort"velto = truerelease build’lerde şart.- Explicit
free()çağrısı bellek sızıntısını önlemek için kritik. wasm-optproduction’a göndermeden önce muhakkak çalıştırın.- requestAnimationFrame döngüsünü JavaScript’te tutun, WASM’da animasyon zamanlama mantığı kurmaya çalışmayın.
Bu mimariyle başlangıç için biraz zahmetli ama ilk grafik ekrana düştüğünde ve frame süresinin tutarlı kaldığını gördüğünüzde neden bu yola girdiğinizi anlıyorsunuz.
