Rust Elective 01: WebAssembly - Web向けRustの実践
課題説明
概要
WebAssembly (WASM) は、Web上でネイティブに近いパフォーマンスでコードを実行するための技術です。本課題では、Rustのコードをコンパイルしてブラウザで動作させ、JavaScriptと連携するWebアプリケーションを構築します。
背景と動機
WebAssemblyの重要性:
- パフォーマンス: ブラウザ上でネイティブに近い速度でコードを実行
- 言語の多様性: C/C++、Rust、Goなど、複数の言語から生成可能
- セキュリティ: サンドボックス環境で安全に実行
- 相互運用性: JavaScriptと双方向に連携可能
Rustの優位性:
- メモリ安全性保証(ガベージコレクションなし)
- 小さなバイナリサイズ
- wasm-bindgenによる型安全なJavaScript連携
- wasm-packによる簡単なビルド
- 画像処理ライブラリ:
課題要件
以下の機能を持つWASMアプリケーションを実装してください:
- JavaScript連携:
- パフォーマンス比較:
- Webアプリ統合:
制約条件
- wasm-bindgen、wasm-packを使用すること
- JavaScriptとの連携は型安全に実装すること
- エラーハンドリングを適切に実装すること
- 大きな画像(4K以上)でもスムーズに動作すること
- モダンなブラウザ(Chrome、Firefox、Safari)で動作すること
---
想定解答
プロジェクト構造
rust_wasm/
├── Cargo.toml
├── src/
│ ├── lib.rs
│ ├── image_processor.rs
│ ├── filters.rs
│ └── utils.rs
├── www/
│ ├── index.html
│ ├── index.js
│ ├── style.css
│ └── package.json
├── tests/
│ └── integration_tests.rs
└── README.md
Cargo.toml
[package]
name = "rust_wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
web-sys = { version = "0.3", features = [
"console",
"Document",
"Element",
"HtmlCanvasElement",
"CanvasRenderingContext2d",
"ImageData",
"Window",
"Performance",
] }
[dev-dependencies]
wasm-bindgen-test = "0.3"
[profile.release]
opt-level = 3
lto = true
src/lib.rs
use wasm_bindgen::prelude::*;
use web_sys::{console, ImageData};
mod filters;
mod image_processor;
mod utils;
// JavaScriptのconsole.logを使うためのマクロ
#[macro_export]
macro_rules! log {
($($t:tt)*) => {
web_sys::console::log_1(&format!($($t)*).into());
}
}
/// WASMモジュールの初期化
#[wasm_bindgen(start)]
pub fn init() {
utils::set_panic_hook();
log!("WASM module initialized");
}
/// メモリアドレスを返す(パフォーマンステスト用)
#[wasm_bindgen]
pub fn get_memory_buffer() -> *const u8 {
wasm_bindgen::memory().as_ptr()
}
/// 画像処理クラス
#[wasm_bindgen]
pub struct ImageProcessor {
width: u32,
height: u32,
data: Vec<u8>,
}
#[wasm_bindgen]
impl ImageProcessor {
/// 新しいImageProcessorインスタンスを作成
#[wasm_bindgen(constructor)]
pub fn new(width: u32, height: u32, data: Vec<u8>) -> Result<ImageProcessor, JsValue> {
if data.len() != (width * height * 4) as usize {
return Err(JsValue::from_str("Invalid data size"));
}
Ok(ImageProcessor {
width,
height,
data,
})
}
/// ImageDataから作成
pub fn from_image_data(image_data: &ImageData) -> Result<ImageProcessor, JsValue> {
let width = image_data.width();
let height = image_data.height();
let data = image_data.data().0;
Ok(ImageProcessor {
width,
height,
data,
})
}
/// ImageDataに変換
pub fn to_image_data(&self) -> Result<ImageData, JsValue> {
ImageData::new_with_u8_clamped_array_and_sh(
wasm_bindgen::Clamped(&self.data),
self.width,
self.height,
)
}
/// グレースケール変換
pub fn grayscale(&mut self) -> Result<(), JsValue> {
filters::grayscale(&mut self.data);
Ok(())
}
/// セピア変換
pub fn sepia(&mut self) -> Result<(), JsValue> {
filters::sepia(&mut self.data);
Ok(())
}
/// ぼかし効果(3x3 Gaussian Blur)
pub fn blur(&mut self) -> Result<(), JsValue> {
let blurred = filters::gaussian_blur(&self.data, self.width, self.height);
self.data = blurred;
Ok(())
}
/// エッジ検出(Sobel Filter)
pub fn edge_detect(&mut self) -> Result<(), JsValue> {
let edges = filters::sobel_filter(&self.data, self.width, self.height);
self.data = edges;
Ok(())
}
/// 画像データのクローンを返す
pub fn clone_data(&self) -> Vec<u8> {
self.data.clone()
}
/// 幅を返す
pub fn width(&self) -> u32 {
self.width
}
/// 高さを返す
pub fn height(&self) -> u32 {
self.height
}
}
/// パフォーマンステスト用の関数群
#[wasm_bindgen]
pub struct PerformanceTest;
#[wasm_bindgen]
impl PerformanceTest {
/// 大量の計算を実行(ベンチマーク用)
pub fn heavy_computation(n: u32) -> f64 {
let mut sum = 0.0;
for i in 0..n {
sum += (i as f64).sqrt().sin().cos();
}
sum
}
/// メモリアクセステスト
pub fn memory_access_test(size: usize) -> Vec<u8> {
let mut data = vec![0u8; size];
for i in 0..size {
data[i] = (i % 256) as u8;
}
data
}
}
src/filters.rs
/// グレースケール変換
pub fn grayscale(data: &mut [u8]) {
for pixel in data.chunks_exact_mut(4) {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
// 輝度を計算(ITU-R BT.709規格)
let gray = (0.2126 * r + 0.7152 * g + 0.0722 * b) as u8;
pixel[0] = gray;
pixel[1] = gray;
pixel[2] = gray;
// Alpha値はそのまま
}
}
/// セピア変換
pub fn sepia(data: &mut [u8]) {
for pixel in data.chunks_exact_mut(4) {
let r = pixel[0] as f32;
let g = pixel[1] as f32;
let b = pixel[2] as f32;
// セピア変換行列
let tr = (0.393 * r + 0.769 * g + 0.189 * b).min(255.0) as u8;
let tg = (0.349 * r + 0.686 * g + 0.168 * b).min(255.0) as u8;
let tb = (0.272 * r + 0.534 * g + 0.131 * b).min(255.0) as u8;
pixel[0] = tr;
pixel[1] = tg;
pixel[2] = tb;
}
}
/// Gaussian Blur(3x3カーネル)
pub fn gaussian_blur(data: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut result = vec![0u8; data.len()];
// 3x3 Gaussianカーネル(正規化済み)
const KERNEL: [[f32; 3]; 3] = [
[1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0],
[2.0 / 16.0, 4.0 / 16.0, 2.0 / 16.0],
[1.0 / 16.0, 2.0 / 16.0, 1.0 / 16.0],
];
for y in 1..(height - 1) {
for x in 1..(width - 1) {
for c in 0..3 {
// RGB各チャンネルに対して畳み込み
let mut sum = 0.0;
for ky in 0..3 {
for kx in 0..3 {
let px = (x + kx - 1) as usize;
let py = (y + ky - 1) as usize;
let idx = (py * width as usize + px) * 4 + c;
sum += data[idx] as f32 * KERNEL[ky][kx];
}
}
let idx = (y * width + x) as usize * 4 + c;
result[idx] = sum as u8;
}
// Alpha値をコピー
let idx = (y * width + x) as usize * 4;
result[idx + 3] = data[idx + 3];
}
}
// 端の処理(そのままコピー)
for y in 0..height {
for x in 0..width {
if y == 0 || y == height - 1 || x == 0 || x == width - 1 {
let idx = (y * width + x) as usize * 4;
for c in 0..4 {
result[idx + c] = data[idx + c];
}
}
}
}
result
}
/// Sobelフィルタによるエッジ検出
pub fn sobel_filter(data: &[u8], width: u32, height: u32) -> Vec<u8> {
let mut result = vec![0u8; data.len()];
// Sobelカーネル(X方向とY方向)
const SOBEL_X: [[i32; 3]; 3] = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
const SOBEL_Y: [[i32; 3]; 3] = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
// まずグレースケール化
let mut gray = data.to_vec();
grayscale(&mut gray);
for y in 1..(height - 1) {
for x in 1..(width - 1) {
let mut gx = 0.0;
let mut gy = 0.0;
for ky in 0..3 {
for kx in 0..3 {
let px = (x + kx - 1) as usize;
let py = (y + ky - 1) as usize;
let idx = (py * width as usize + px) * 4;
let intensity = gray[idx] as f32;
gx += intensity * SOBEL_X[ky][kx] as f32;
gy += intensity * SOBEL_Y[ky][kx] as f32;
}
}
// 勾配の大きさ
let magnitude = (gx * gx + gy * gy).sqrt().min(255.0) as u8;
let idx = (y * width + x) as usize * 4;
result[idx] = magnitude;
result[idx + 1] = magnitude;
result[idx + 2] = magnitude;
result[idx + 3] = 255;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_grayscale() {
let mut data = vec![100, 150, 200, 255]; // RGBA
grayscale(&mut data);
// すべてのRGB値が同じになるはず
assert_eq!(data[0], data[1]);
assert_eq!(data[1], data[2]);
assert_eq!(data[3], 255); // Alphaは変わらない
}
#[test]
fn test_sepia() {
let mut data = vec![100, 100, 100, 255];
sepia(&mut data);
// セピア調になっているはず(赤みがかる)
assert!(data[0] >= data[1]);
assert!(data[1] >= data[2]);
}
}
src/utils.rs
use wasm_bindgen::prelude::*;
/// パニック時のスタックトレースを表示
pub fn set_panic_hook() {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
}
/// JavaScriptのconsole.timeを使った計測
#[wasm_bindgen]
pub struct Timer {
label: String,
}
#[wasm_bindgen]
impl Timer {
#[wasm_bindgen(constructor)]
pub fn new(label: &str) -> Timer {
web_sys::console::time_with_label(label);
Timer {
label: label.to_string(),
}
}
pub fn end(&self) {
web_sys::console::time_end_with_label(&self.label);
}
}
www/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rust WASM Image Processor</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>🦀 Rust WASM Image Processor</h1>
<div class="controls">
<input type="file" id="imageInput" accept="image/*">
<div class="filters">
<button id="grayscaleBtn">Grayscale</button>
<button id="sepiaBtn">Sepia</button>
<button id="blurBtn">Blur</button>
<button id="edgeBtn">Edge Detect</button>
<button id="resetBtn">Reset</button>
</div>
<div class="benchmark">
<button id="benchmarkBtn">Run Benchmark</button>
<div id="benchmarkResults"></div>
</div>
</div>
<div class="canvas-container">
<div class="canvas-wrapper">
<h3>Original</h3>
<canvas id="originalCanvas"></canvas>
</div>
<div class="canvas-wrapper">
<h3>Processed (WASM)</h3>
<canvas id="processedCanvas"></canvas>
</div>
</div>
<div class="info">
<h3>Performance Metrics</h3>
<div id="metrics">
<p>Load an image to start</p>
</div>
</div>
</div>
<script type="module" src="./index.js"></script>
</body>
</html>
www/index.js
import init, { ImageProcessor, PerformanceTest } from '../pkg/rust_wasm.js';
let wasmModule;
let originalImageData;
let currentProcessor;
// WASM初期化
async function initWasm() {
wasmModule = await init();
console.log('WASM module loaded successfully');
}
// 画像読み込み
function loadImage(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
// オリジナルキャンバスに描画
const originalCanvas = document.getElementById('originalCanvas');
const ctx = originalCanvas.getContext('2d');
originalCanvas.width = img.width;
originalCanvas.height = img.height;
ctx.drawImage(img, 0, 0);
// ImageDataを取得
originalImageData = ctx.getImageData(0, 0, img.width, img.height);
// 処理用キャンバスも初期化
const processedCanvas = document.getElementById('processedCanvas');
processedCanvas.width = img.width;
processedCanvas.height = img.height;
const procCtx = processedCanvas.getContext('2d');
procCtx.putImageData(originalImageData, 0, 0);
updateMetrics('Image loaded', img.width, img.height);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
// フィルタ適用
async function applyFilter(filterName) {
if (!originalImageData) {
alert('Please load an image first');
return;
}
const start = performance.now();
try {
// ImageProcessorを作成
currentProcessor = ImageProcessor.from_image_data(originalImageData);
// フィルタ適用
switch(filterName) {
case 'grayscale':
currentProcessor.grayscale();
break;
case 'sepia':
currentProcessor.sepia();
break;
case 'blur':
currentProcessor.blur();
break;
case 'edge':
currentProcessor.edge_detect();
break;
}
// 結果を表示
const resultImageData = currentProcessor.to_image_data();
const canvas = document.getElementById('processedCanvas');
const ctx = canvas.getContext('2d');
ctx.putImageData(resultImageData, 0, 0);
const end = performance.now();
const duration = (end - start).toFixed(2);
updateMetrics(
`${filterName} applied`,
canvas.width,
canvas.height,
`${duration}ms (WASM)`
);
} catch (error) {
console.error('Filter error:', error);
alert('Error applying filter: ' + error);
}
}
// JavaScript版フィルタ(比較用)
function applyFilterJS(imageData, filterName) {
const data = imageData.data;
const start = performance.now();
switch(filterName) {
case 'grayscale':
for (let i = 0; i < data.length; i += 4) {
const gray = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
data[i] = data[i + 1] = data[i + 2] = gray;
}
break;
case 'sepia':
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2];
data[i] = Math.min(255, 0.393 * r + 0.769 * g + 0.189 * b);
data[i + 1] = Math.min(255, 0.349 * r + 0.686 * g + 0.168 * b);
data[i + 2] = Math.min(255, 0.272 * r + 0.534 * g + 0.131 * b);
}
break;
}
const end = performance.now();
return (end - start).toFixed(2);
}
// ベンチマーク実行
async function runBenchmark() {
if (!originalImageData) {
alert('Please load an image first');
return;
}
const results = [];
const filters = ['grayscale', 'sepia'];
for (const filter of filters) {
// WASM版
const wasmStart = performance.now();
const processor = ImageProcessor.from_image_data(originalImageData);
if (filter === 'grayscale') processor.grayscale();
if (filter === 'sepia') processor.sepia();
const wasmEnd = performance.now();
const wasmTime = wasmEnd - wasmStart;
// JavaScript版
const jsImageData = new ImageData(
new Uint8ClampedArray(originalImageData.data),
originalImageData.width,
originalImageData.height
);
const jsTime = applyFilterJS(jsImageData, filter);
const speedup = (jsTime / wasmTime).toFixed(2);
results.push({
filter,
wasm: wasmTime.toFixed(2),
js: jsTime,
speedup
});
}
// 結果表示
const resultsDiv = document.getElementById('benchmarkResults');
resultsDiv.innerHTML = `
<table>
<tr>
<th>Filter</th>
<th>WASM (ms)</th>
<th>JavaScript (ms)</th>
<th>Speedup</th>
</tr>
${results.map(r => `
<tr>
<td>${r.filter}</td>
<td>${r.wasm}</td>
<td>${r.js}</td>
<td>${r.speedup}x</td>
</tr>
`).join('')}
</table>
`;
}
// メトリクス更新
function updateMetrics(action, width, height, time = '') {
const metricsDiv = document.getElementById('metrics');
const pixels = width && height ? (width * height).toLocaleString() : '';
metricsDiv.innerHTML = `
<p><strong>Action:</strong> ${action}</p>
${width ? `<p><strong>Dimensions:</strong> ${width} x ${height} (${pixels} pixels)</p>` : ''}
${time ? `<p><strong>Processing Time:</strong> ${time}</p>` : ''}
`;
}
// リセット
function reset() {
if (originalImageData) {
const canvas = document.getElementById('processedCanvas');
const ctx = canvas.getContext('2d');
ctx.putImageData(originalImageData, 0, 0);
updateMetrics('Reset to original', canvas.width, canvas.height);
}
}
// イベントリスナー設定
document.addEventListener('DOMContentLoaded', async () => {
await initWasm();
document.getElementById('imageInput').addEventListener('change', (e) => {
if (e.target.files[0]) {
loadImage(e.target.files[0]);
}
});
document.getElementById('grayscaleBtn').addEventListener('click', () => applyFilter('grayscale'));
document.getElementById('sepiaBtn').addEventListener('click', () => applyFilter('sepia'));
document.getElementById('blurBtn').addEventListener('click', () => applyFilter('blur'));
document.getElementById('edgeBtn').addEventListener('click', () => applyFilter('edge'));
document.getElementById('resetBtn').addEventListener('click', reset);
document.getElementById('benchmarkBtn').addEventListener('click', runBenchmark);
});
www/style.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.controls {
margin-bottom: 30px;
}
input[type="file"] {
display: block;
margin-bottom: 20px;
padding: 10px;
border: 2px dashed #667eea;
border-radius: 8px;
width: 100%;
cursor: pointer;
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
button {
padding: 12px 24px;
border: none;
border-radius: 6px;
background: #667eea;
color: white;
font-weight: 600;
cursor: pointer;
transition: background 0.3s;
}
button:hover {
background: #5568d3;
}
button:active {
transform: translateY(1px);
}
#resetBtn {
background: #e74c3c;
}
#resetBtn:hover {
background: #c0392b;
}
#benchmarkBtn {
background: #27ae60;
}
#benchmarkBtn:hover {
background: #229954;
}
.canvas-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}
.canvas-wrapper {
text-align: center;
}
.canvas-wrapper h3 {
margin-bottom: 10px;
color: #555;
}
canvas {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.info {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
}
#metrics p {
margin: 8px 0;
color: #555;
}
.benchmark table {
width: 100%;
margin-top: 15px;
border-collapse: collapse;
}
.benchmark th,
.benchmark td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.benchmark th {
background: #667eea;
color: white;
font-weight: 600;
}
@media (max-width: 768px) {
.canvas-container {
grid-template-columns: 1fr;
}
}
www/package.json
{
"name": "rust-wasm-image-processor",
"version": "0.1.0",
"description": "Image processing with Rust and WebAssembly",
"scripts": {
"build": "webpack --mode production",
"dev": "webpack serve --mode development",
"serve": "http-server dist"
},
"devDependencies": {
"webpack": "^5.88.0",
"webpack-cli": "^5.1.0",
"webpack-dev-server": "^4.15.0",
"http-server": "^14.1.1"
}
}
ビルドスクリプト
#!/bin/bash
# build.sh
echo "Building WASM module..."
wasm-pack build --target web
echo "Building web assets..."
cd www
npm install
npm run build
echo "Done! Open www/index.html in a browser"
---
解説
実装のポイント
1. wasm-bindgenの活用
型安全なインターフェース:
#[wasm_bindgen]
pub struct ImageProcessor {
width: u32,
height: u32,
data: Vec<u8>,
}
#[wasm_bindgen]マクロでJavaScript互換の型を自動生成- RustとJavaScript間で安全に値を受け渡し
- エラーは
Resultで適切にハンドリング
2. メモリ管理
効率的なデータ転送:
pub fn to_image_data(&self) -> Result<ImageData, JsValue> {
ImageData::new_with_u8_clamped_array_and_sh(
wasm_bindgen::Clamped(&self.data),
self.width,
self.height,
)
}
- データのコピーを最小化
Clamped型で安全な値の範囲を保証- 大きな画像でもメモリ効率が良い
3. 画像処理アルゴリズム
Sobelフィルタ(エッジ検出):
const SOBEL_X: [[i32; 3]; 3] = [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]];
const SOBEL_Y: [[i32; 3]; 3] = [[-1, -2, -1], [0, 0, 0], [1, 2, 1]];
- 畳み込み演算による画像処理
- X方向とY方向の勾配を計算
- WASMの高速な数値計算を活用
4. パフォーマンス最適化
最適化設定:
[profile.release]
opt-level = 3
lto = true
- LTO(Link Time Optimization)で最大限の最適化
- サイズとパフォーマンスのバランス
- JavaScriptより2-5倍高速
- ImageDataとの連携: ブラウザ標準のImageDataを使うことで、キャンバスAPIと直接やり取りできる
- フィルタの分離: 各フィルタを独立した関数にすることで、テストとメンテナンスが容易
- エラーハンドリング: Result型を使って、失敗の可能性がある操作を明示的に扱う
- ベンチマーク機能: WASMとJavaScriptの比較で、パフォーマンス向上を実感できる
- 並列処理: Rayon + wasm-bindgen-rayonで並列化(複雑度が上がる)
- GPU処理: WebGPUを使った実装(より高速だが対応ブラウザが限定的)
- ストリーム処理: 大きな画像をチャンク単位で処理(実装が複雑)
- WebAssemblyの基礎:
設計判断
代替案
---
学習の意図
習得する概念
- Rustとブラウザの連携:
- 画像処理アルゴリズム:
- パフォーマンスチューニング:
CSの基礎との関連
コンパイラとランタイム:
- WASMはスタックマシン
- AOT(Ahead-of-Time)コンパイル
- サンドボックス実行環境
メモリ管理:
- 線形メモリモデル
- ヒープとスタックの分離
- ガベージコレクションなしの効率性
並行処理:
- Web Workersとの組み合わせ
- メインスレッドをブロックしない設計
数値計算:
- SIMD命令の活用
- 浮動小数点演算
- アルゴリズムの計算量
---
テスト方法
単体テスト
# Rustユニットテスト
cargo test
# WASMテスト
wasm-pack test --headless --firefox
統合テスト
// tests/integration_tests.rs
use wasm_bindgen_test::*;
use rust_wasm::ImageProcessor;
#[wasm_bindgen_test]
fn test_image_processor_creation() {
let data = vec![0u8; 400]; // 10x10 RGBA
let processor = ImageProcessor::new(10, 10, data);
assert!(processor.is_ok());
}
#[wasm_bindgen_test]
fn test_grayscale_filter() {
let mut data = vec![100, 150, 200, 255];
data.extend_from_slice(&[100, 150, 200, 255]);
let mut processor = ImageProcessor::new(1, 2, data).unwrap();
assert!(processor.grayscale().is_ok());
}
パフォーマンステスト
ブラウザテスト
---
評価基準
必須要件(60点)
標準要件(30点)
発展要件(10点)
ボーナス(+10点)
---