Canvas 2Dで大量の発光ラインを描いたら重くなった話 — バッチ化とWeb Workerでドラッグのカクつきを解消する
光学サンドボックス「PrisMap」で、白色光を拡散モードにしたときだけ素子のドラッグがカクつくようになった。原因を探り、改善が得られたのでその経緯を書く。
対象は Vue 3 + Vite で作っている光学シミュレータ PrisMap。EMUYN LLC が教育・学習ツールブランド「EDUYN」の一つとして公開しているアプリで、プリズムやレンズに光を当てて屈折・分散・回折を観察できる。光線は「強度つきの線分」の集まりとして描いている。
何が起きたか
各光線セグメントは { a, b, intensity, rgb } を持っていて、globalCompositeOperation = 'lighter' (加算合成) で重ね描きしている。これで白色光の芯が白く光ったり、拡散した光線が滑らかな帯に見えたりする。発光っぽさを出すために、1本のセグメントを太さ違いの3パス (グロー・中間・芯) で描いていた。
// 改善前 (抜粋): セグメントごとに beginPath → stroke
function strokeSeg(c2, s, width, alpha, c) {
c2.strokeStyle = `rgba(${c.r},${c.g},${c.b},${alpha})`
c2.lineWidth = width
c2.beginPath()
c2.moveTo(s.a.x, s.a.y)
c2.lineTo(s.b.x, s.b.y)
c2.stroke()
}
function drawSegments(c2, segs) {
c2.globalCompositeOperation = 'lighter'
for (const s of segs) strokeSeg(c2, s, 10, 0.09 * s.intensity * bright, s.rgb) // グロー
for (const s of segs) strokeSeg(c2, s, 4.5, 0.28 * s.intensity * bright, s.rgb) // 中間
for (const s of segs) strokeSeg(c2, s, 2.2, 0.62 * s.intensity * bright, s.rgb) // 芯
}
普段はこれで問題ない。ただ、白色光+拡散 (発散角つき) にすると光源から多数の波長×多数の方向に光線が出て、素子との交差で屈折・反射・フレネル分岐が起きるたびにセグメントが増える。この状態で素子をドラッグすると、動かした瞬間からはっきりカクつく。
計算が重い可能性も考えたが、違った
「光線追跡の計算が重いんだろう」というのが最初の予想だった。光学コアはVueに依存しない純関数として書いてあるので、Nodeから直接ベンチが取れる。とりあえず測ってみた。
白色+プリズムだけなら192セグメントで0.5ms未満。拡散をつけて1902セグメントまで増やしても約1.9ms。プリズムを全位置・全角度でスイープした最悪ケースでも3ms未満だった。
つまり計算はまったく遅くない。V8のJITがホットループを十分速くしてくれている。最適化すべき対象はここではなかった。
遅かったのは描画のほうだった。白色×拡散だと1フレームあたり
1902セグメント × 3パス = 5706回の stroke()
が走っている。stroke() はCanvas 2Dの中でも特に重い呼び出しで、状態確定とラスタライズが毎回発生する。ここが犯人だった。
本命1: ストロークをバッチ化する
stroke() の回数を根っこから減らす方法を考えた。手がかりは加算合成の性質にある。
- 色は波長の数だけしかない (拡散モードで9波長、非拡散で32波長程度)
- alphaは
intensity × brightnessで決まるので、量子化してしまえばバケット数は少なく抑えられる
つまり、パスごとに「同じ色×同じalphaバケット」のセグメントを1本の Path2D にまとめてしまえば、stroke() は1回で済む。
const DRAW_PASSES = [
{ width: 10, coef: 0.09 }, // グロー
{ width: 4.5, coef: 0.28 }, // 中間
{ width: 2.2, coef: 0.62 }, // 芯
]
const ALPHA_BUCKETS = 16
export function renderBeam(c2, segments, brightness = 1) {
if (!segments || !segments.length) return
c2.save()
c2.lineJoin = 'round'
c2.lineCap = 'round'
c2.globalCompositeOperation = 'lighter'
for (const pass of DRAW_PASSES) {
c2.lineWidth = pass.width
const groups = new Map()
for (const s of segments) {
const a = Math.min(1, pass.coef * s.intensity * brightness)
if (a < 0.004) continue // ほぼ透明はスキップ
const bucket = Math.max(1, Math.round(a * ALPHA_BUCKETS))
const { r, g, b } = s.rgb
// 色 + alpha バケットを整数キー化
const key = (((r << 8) | g) << 8 | b) * (ALPHA_BUCKETS + 1) + bucket
let grp = groups.get(key)
if (!grp) {
grp = { path: new Path2D(), style: `rgba(${r},${g},${b},${bucket / ALPHA_BUCKETS})` }
groups.set(key, grp)
}
grp.path.moveTo(s.a.x, s.a.y)
grp.path.lineTo(s.b.x, s.b.y)
}
for (const grp of groups.values()) {
c2.strokeStyle = grp.style
c2.stroke(grp.path) // このグループの全線分を1回で描く
}
}
c2.restore()
}
白色×拡散 (1902セグメント) で実測すると、stroke() の回数は5706回から36回まで落ちた。約158分の1。
一つだけ注意点がある。lighter は重なりが積算される合成モードなので、1回の stroke() にまとめたバッチ内の重なりは1回分しか積算されない。別々に stroke() していたときは重なりが積み上がっていたので、厳密には見た目が変わりうる。ただし異なる色は別バッチ=別 stroke() になるので、白色の芯 (複数波長の重なり) は従来通り加算される。影響が出るのは「同色・同alphaが密に重なる箇所」だけで、実際に見比べてもほとんど判別できなかった。
ALPHA_BUCKETS の量子化段数は見た目の厳密さとのトレードオフだが、今回は16段で十分自然に見えた。
本命2: Web Worker + OffscreenCanvasに逃がす
バッチ化だけでもかなり軽くなったが、追跡と描画そのものをワーカースレッドに逃がして、メインスレッドは操作専任にすることにした。
やってみて助かったのは、既存の構造がほぼそのまま乗ったこと。ビームはもともと「ビュー非依存のWORLD座標でオフスクリーンに焼く→メインがビュー変換して drawImage で合成する」という形になっていたので、ワーカー側は「WORLD座標のビーム画像を作るだけ」でよく、キャンバスを分割したりレイヤーを重ねたりする必要がなかった。ズーム/パンのビュー変換はメイン側の合成で吸収するので、パン中にワーカーへ往復する必要すらない (同じ画像を再blitするだけ)。
sceneSegments (追跡) と renderBeam (描画) はどちらもDOMやVueに依存しない純関数なので、ワーカーからそのままimportできた。
// beam.worker.js
import { sceneSegments } from '../optics/tracer.js'
import { renderBeam } from '../optics/beamRender.js'
let off = null, offctx = null
self.onmessage = (e) => {
const { reqId, source, elements, world, dispersion, size } = e.data
if (!off || off.width !== size.w || off.height !== size.h) {
off = new OffscreenCanvas(size.w, size.h)
offctx = off.getContext('2d')
}
// 追跡 (純関数)
const segments = sceneSegments(source, elements, world, dispersion)
// WORLD座標 → キャッシュpx変換で描画
const s = size.w / world.w
offctx.setTransform(1, 0, 0, 1, 0, 0)
offctx.clearRect(0, 0, size.w, size.h)
offctx.setTransform(s, 0, 0, s, -world.x * s, -world.y * s)
renderBeam(offctx, segments, source.brightness ?? 1)
// 所有権ごと転送 (コピーなし)
const bmp = off.transferToImageBitmap()
self.postMessage({ reqId, bmp }, [bmp])
}
Viteならワーカーのimportはこれだけで済む (専用チャンクにバンドルしてくれる)。
import BeamWorker from '../workers/beam.worker.js?worker'
// ...
beamWorker = new BeamWorker()
ドラッグ中は毎フレーム素子の位置が変わるので、素朴に postMessage するとメッセージが溢れてしまう。そこで「1件ずつ処理し、送信中に状態が変わったら応答が返ってきた後にまとめて追送する」という形でコアレスした。
let beamInFlight = false, beamDirty = false
function requestBeam() {
if (!beamWorker || !isHeavyMode()) return // 軽いモードはメインでライブ描画
if (beamInFlight) { beamDirty = true; return } // 送信中なら後でまとめて追送
postBeam()
}
function postBeam() {
beamInFlight = true; beamDirty = false
beamWorker.postMessage({
reqId: ++beamReqId,
source: { ...source },
elements: elements.map((e) => ({ ...e })), // reactive proxy を plain 化
world: { x: WORLD.x, y: WORLD.y, w: WORLD.w, h: WORLD.h },
dispersion: settings.dispersion,
size: { w: BEAM_W, h: BEAM_H },
})
}
beamWorker.onmessage = (e) => {
beamInFlight = false
beamBitmap?.close() // 直前のImageBitmapを解放
beamBitmap = e.data.bmp
draw() // 最新ビームを合成
if (beamDirty) postBeam() // 送信中の変更を追送
}
合成側 (blit) はWORLD座標の画像にビュー変換をかけて drawImage するだけなので、ワーカー版でもフォールバック版でも同じ関数を使い回せる。
function blitBeamImage(img) { // img = ImageBitmap or 通常 canvas
const s = BEAM_W / WORLD.w
const k = (view.scale / s) * dpr
ctx.setTransform(k, 0, 0, k,
(WORLD.x * view.scale + view.tx) * dpr,
(WORLD.y * view.scale + view.ty) * dpr)
ctx.globalCompositeOperation = 'lighter'
ctx.drawImage(img, 0, 0)
ctx.globalCompositeOperation = 'source-over'
}
シーン (光源・素子・分散) が変わったら requestBeam() を呼ぶだけでよく、Vueならwatchで拾える。
if (useWorker) {
watch([source, elements, () => settings.dispersion], requestBeam, { deep: true })
}
これでメインスレッドは素子のドラッグ=オーバーレイ描画に専念できるようになり、重いモードでもドラッグの体感がだいぶ軽くなった。ビームの更新はワーカーがメインを塞がずに行うので、ドラッグ中も遅れを引きずらずに追従する感触になった (フレーム時間そのものは計測していないので、あくまで体感ベースの話ではある)。
OffscreenCanvas と transferToImageBitmap はChrome/Edge/Firefoxでは使えるが、Safariは16.4以降。未対応環境ではメインスレッド描画に自動で落とすようにしている。
const useWorker =
typeof Worker !== 'undefined' &&
typeof OffscreenCanvas !== 'undefined' &&
typeof OffscreenCanvas.prototype.transferToImageBitmap === 'function'
renderBeam を共有モジュールとして切り出しておいたおかげで、メイン (フォールバック/軽いモード) とワーカーがまったく同じ描画関数を使う。どの経路を通っても見た目と物理が一致するのはありがたい。
最終的な描画の分岐
バッチ化とワーカー化を経て、描画側の分岐は最終的にこれだけまで単純にできた。
if (workerBeam) {
// 重いモード & OffscreenCanvas対応: workerが作ったビーム画像をblitするだけ
if (beamBitmap) blitBeamImage(beamBitmap)
} else {
// 軽いモード / 非対応: メインで毎フレーム描く (バッチ化済みで十分軽い)
renderBeam(ctx, lightSegments.value, source.brightness ?? 1)
}
重いモードではワーカーが作った画像をblitするだけ、それ以外はバッチ化済みの renderBeam を毎フレーム呼ぶだけ。どちらの経路でも1フレームあたりのコストは小さく抑えられている。
全体の流れ
[メインスレッド] [ワーカースレッド]
シーン変更 ──requestBeam──▶ postMessage ──▶ sceneSegments(追跡)
素子ドラッグ = オーバーレイ描画(遅延なし) renderBeam → OffscreenCanvas
◀── ImageBitmap(transfer) ◀── postMessage ── transferToImageBitmap()
drawImage でビュー変換合成
パン/ズーム = 直近ビームを再blit(往復なし)
打った手を時系列でまとめるとこうなる。
| 対策 | 効いたところ | 結果 |
|---|---|---|
| ストロークのバッチ化 |
stroke() を5706回→36回に |
採用 (メイン/worker共有) |
| Worker/OffscreenCanvas | 重い処理をメインスレッドから退避 | 採用 (重いモード) |
振り返って
一番大きかったのは、最適化を始める前に計測したことだと思う。計算が重い可能性も候補として考えていたが、実際に測ってみるとそちらではなく、犯人は stroke() の呼び出し回数だった。
Canvas 2Dは呼び出し回数がそのまま支配的なコストになる。同じ状態 (色・太さ・alpha) で描くものはまとめて1本の Path2D に押し込んで stroke() するだけで、桁単位で効いてくる。
描画そのものが重い場合は、Web Worker + OffscreenCanvasでメインスレッドから追い出せる。「ビュー非依存の画像を作る側」と「メインでビュー変換して合成する側」を最初から切り分けておくと、キャンバスを分割せずにワーカー化がすっと入る。今回もこの設計が既にあったおかげで移行がかなり楽だった。
同じように「大量の発光ライン/点をCanvas 2Dで描く」系 (パーティクル、可視化、軌跡描画など) には応用が効くはず。もっと本気で速くするならWebGLという選択肢もあるが、まずはバッチ化とWorkerだけで足りるケースは多いと思う。
PrisMapは光学現象を操って学べる教育用サンドボックスです。よければ触ってみてください: https://app.emuyn.net/prismap/
