高性能シーケンスビューワー — WebAssembly を使用して、100 万 bp をブラウザでリアルタイムにレンダリング
このトピックを終えると
教科書で学んだ WebAssembly、DOM、Big-Oの計算量を組み合わせて、大規模なDNA配列をブラウザでスムーズにレンダリングするビューワーを自分で作成できます。IGV.jsのような実用的なツールの背後にある原理をコードで理解します。
この記事は教育目的の一般的な例です。実用的な配列ビューワーはさらに高度ですが、その中核となるパフォーマンス最適化の原理をここで扱います。
「ブラウザがフリーズした」-素朴なレンダリングの落とし穴
100万bpのDNA配列をブラウザに表示するとしましょう。
最初の試み:
function renderSequence(sequence) {
const container = document.getElementById("viewer");
for (let i = 0; i < sequence.length; i++) {
const span = document.createElement("span");
span.textContent = sequence[i];
span.className = `base-${sequence[i]}`;
container.appendChild(span);
}
}
renderSequence(oneMillionBp);このコードを実行すると、ブラウザが5秒以上フリーズした後、ようやくレンダリングされます。そして、ページスクロールもひどくカクカクします。
問題点:
問題1: DOMノードが100万個。各spanのメモリオーバーヘッドは最小でも数KB。全体で数GB。
問題2: DOMの挿入はO(n²)に近い。appendChildの操作が、それまでのノードのレイアウトを再計算する。
問題3: 文字列の反復処理がJSで遅い。100万文字の反復処理は、ネイティブコードよりもはるかに遅い。
真の解決策は、次の3つの軸にあります。
- DOMの代わりにCanvasでレンダリング(100万のノードは不要)
- 仮想化 – ビューポート内の配列のみをレンダリング(O(ビューポート)の複雑さ)
- WebAssembly – パフォーマンスが重要なロジックをネイティブ速度で実行
ブラックボックスからコンポーネントへ
コンポーネント 1: Big-O の観点からのシーケンスビューワー
シーケンスビューワーの各フレームで必要な計算量を分析します。
単純なアプローチ: 各フレームでシーケンス全体をスキャン。O(n)。
仮想化: ビューポートに入る部分だけをレンダリング。O(ビューポート)。
インデックス: 特定の位置へのアクセスを O(1) に。
100万 bp で、ビューポートが例えば 200 bp の場合:
- O(n) = 100万回の演算
- O(ビューポート) = 200回の演算
- 5,000倍高速化
この差が、60fps のリアルタイムレンダリングの鍵です。
コンポーネント 2: Canvas ベースのレンダリング
DOM の代わりに、単一の canvas を使用します。
function initCanvas(container) {
const canvas = document.createElement("canvas");
canvas.width = container.clientWidth;
canvas.height = 100;
container.appendChild(canvas);
return canvas.getContext("2d");
}
function renderViewport(ctx, sequence, startBp, endBp, bpWidth) {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const colors = { A: "#66c2a5", C: "#fc8d62", G: "#8da0cb", T: "#e78ac3" };
for (let i = startBp; i < endBp && i < sequence.length; i++) {
const x = (i - startBp) * bpWidth;
const base = sequence[i];
ctx.fillStyle = colors[base] || "#ccc";
ctx.fillRect(x, 20, bpWidth, 40);
if (bpWidth >= 8) {
ctx.fillStyle = "black";
ctx.font = "12px monospace";
ctx.fillText(base, x + 2, 45);
}
}
}単一の canvas がレンダリングされた部分を画像として保持します。スクロールイベントで startBp と endBp のみを更新し、再度描画するだけです。
コンポーネント 3: WebAssembly でパフォーマンスクリティカルなロジックをオフロード
canvas レンダリング自体は JavaScript で十分です。ただし、GC (グアニン-シトシン) 含有量の計算、モチーフ検索、ソート などのパフォーマンスクリティカルなタスクがある場合は、WASM を使用する方が有利です。
Rust で GC 含有量の計算の例:
// src/lib.rs
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn gc_content_window(sequence: &str, window_size: usize) -> Vec<f32> {
let bytes = sequence.as_bytes();
let n = bytes.len();
if n < window_size {
return vec![];
}
let mut result = Vec::with_capacity(n - window_size + 1);
let mut gc_count: usize = 0;
for i in 0..window_size {
if bytes[i] == b'G' || bytes[i] == b'C' {
gc_count += 1;
}
}
result.push(gc_count as f32 / window_size as f32);
for i in window_size..n {
if bytes[i] == b'G' || bytes[i] == b'C' {
gc_count += 1;
}
if bytes[i - window_size] == b'G' || bytes[i - window_size] == b'C' {
gc_count -= 1;
}
result.push(gc_count as f32 / window_size as f32);
}
result
}ビルド:
wasm-pack build --target webpkg/ フォルダに .wasm と JavaScript ブリッジが出力されます。
ブラウザでの使用:
import init, { gc_content_window } from "./pkg/sequence_viewer.js";
async function main() {
await init();
const sequence = "ATGCGATCGATCG...".repeat(100000);
console.time("WASM GC");
const gcArray = gc_content_window(sequence, 100);
console.timeEnd("WASM GC");
console.log(`Computed GC for ${gcArray.length} windows`);
}100万 bp での、スライディングウィンドウによる GC 含有量の計算:
- 純粋な JavaScript: ~500ms
- WASM: ~15ms
- ~30倍高速化
コンポーネント 4: スクロールイベントとレンダーループ
スムーズなスクロール = 60fps = 1フレームあたり 16ms 以内。
class SequenceViewer {
constructor(container, sequence) {
this.ctx = initCanvas(container);
this.sequence = sequence;
this.startBp = 0;
this.bpWidth = 4;
this.viewportBp = Math.floor(this.ctx.canvas.width / this.bpWidth);
this.attachEvents(container);
this.render();
}
attachEvents(container) {
container.addEventListener("wheel", (e) => {
e.preventDefault();
const delta = Math.sign(e.deltaY) * 10;
this.startBp = Math.max(0, Math.min(this.sequence.length - this.viewportBp, this.startBp + delta));
requestAnimationFrame(() => this.render());
});
}
render() {
const endBp = Math.min(this.sequence.length, this.startBp + this.viewportBp);
renderViewport(this.ctx, this.sequence, this.startBp, endBp, this.bpWidth);
}
}重要な点: requestAnimationFrame を使用して、ブラウザのレンダリングサイクルに同期します。これにより、60fps が自動的に実現されます。
フェーディング — 埋めるべき3つの空白
空白1:マルチトラックレンダラー
配列の上に、リードアライメントや遺伝子アノテーションなど、複数のトラックを重ねて描画します。
class MultiTrackViewer extends SequenceViewer {
constructor(container, sequence, tracks) {
super(container, sequence);
this.tracks = tracks;
this.ctx.canvas.height = 100 + tracks.length * 30;
}
render() {
super.render();
// TODO: 各トラックを配列の下に描画する
// トラック = { name: string, features: [{start, end, color, label}] }
// ビューポートの範囲内にあるフィーチャーのみを描画する
for (let i = 0; i < this.tracks.length; i++) {
// TODO: 各フィーチャーの (start, end) をキャンバス座標に変換し、長方形を描画する
}
}
}ヒント: const xStart = (feature.start - this.startBp) * this.bpWidth; const xEnd = (feature.end - this.startBp) * this.bpWidth; if (xEnd < 0 || xStart > canvas.width) continue;.
空白2:WASMモチーフ検索
先に扱ったトライをRustで実装し、WASMとして公開します。
#[wasm_bindgen]
pub fn find_motif(sequence: &str, motif: &str) -> Vec<usize> {
// TODO: シーケンスからモチーフが出現するすべての位置を返す
// ヒント: sequence.match_indices(motif).map(|(i,_)| i).collect()
vec![]
}ブラウザでの使用:
const positions = find_motif(sequence, "GAATTC"); // EcoRI切断部位
positions.forEach(pos => drawMarker(pos));空白3:パフォーマンスベンチマーク
純粋なJS実装とWASM実装のパフォーマンス比較。
async function benchmark() {
const sequence = "ATCGATCG".repeat(125_000); // 100万bp
// TODO 1: 純粋なJS GC計算関数を実装する(参考)
function gcContentJs(seq, window) {
// あなたの実装
}
// TODO 2: JSとWASMそれぞれで時間を計測する
// console.time / console.timeEnd
// TODO 3: 結果が同じであることを検証する(samples[0], samples[500000], samples[999900]など)
// TODO 4: 結果を表示する
}考察:実戦的な配列ビューアとの違い
IGV.js: Integrative Genomics ViewerのJavaScript版。BAM、VCF、BEDなど、複数の標準フォーマットをサポート。バックエンドサーバーなしで、クライアントだけで動作する。
Ensembl・UCSC Genome Browser: ウェブベースのゲノムブラウザの代表。バックエンドのインフラが洗練されており、複数のトラックデータがキャッシュされている。
JBrowse 2: 現代的なアーキテクチャを持つゲノムブラウザ。WebWorker、WASM、Reactの組み合わせ。非常に大きなデータセットをサポートする。
深層学習の可視化: 近年、AlphaFoldのようなツールが3D構造の可視化を必要とする。WebGL/WebGPUが必要。
ストリーミング処理: 実践的な場面では、配列全体をブラウザにロードしない。必要な部分だけをサーバーからリクエストするレンジリクエストパターン。Web Sockets/HTTP2 push。
拡張プロジェクト
1. FASTAローダー: ユーザーがFASTAファイルをドラッグ&ドロップすると、ビューアーにロードする。
2. GFF/BEDパーサー: 遺伝子アノテーションファイルを解析し、トラックとして表示する。
3. 検索UI: テキスト入力でモチーフを検索する。結果をミニマップにハイライト表示する。
4. エクスポート: 現在のビューポートをPNG/SVG形式で保存する。
この機能の構成要素
- [F] WebAssembly: Rust → wasm-pack → ブラウザにロード。JS-WASMブリッジ。
- [F] DOM: 最小限のDOM操作。Canvasを1つ使用して100万個の要素を代替。
- [F] Big-O: O(n) → O(ビューポート) 仮想化。パフォーマンス予算の概念。
- [W] HTML/JSの基礎: イベントハンドリング、
requestAnimationFrame(完成したスクリプトを提供)。
[F] = 自分で実装する / [W] = 完成したコードとして提供。