Wails(Go + WKWebView)で作っているデスクトップアプリで、React Flowのキャンバス上の文字がボヤける問題に遭遇しました。ズームしたあと、パン中、さらに隣のサイドバーをスクロールしただけでも滲みます。
原因はひとつではなさそうで、操作ごとに対策が必要でした。最終的には5つの対応で落ち着きました。
同じように、macOSのWKWebView上でtransformベースのキャンバスライブラリを使っている人向けのメモです。WailsやTauriでも似た症状に当たるかもしれないので、試したことを残します。
今回の構成
go test -json の実行をジョブネット風に可視化するデスクトップアプリ(go-paddock)です。
| レイヤ | 技術 |
|---|---|
| アプリ基盤 | Wails v2.12(macOSではWKWebView) |
| キャンバス | React Flow(reactflow v11) |
| フロント | React 18 + TypeScript |
React Flowは、キャンバス全体を
<div class="react-flow__viewport"
style="transform: translate(123.45px, -67.89px) scale(1.2)">
のようにCSS transform(translate + scale)で動かします。今回のボヤけは、どれもこのtransformで動くレイヤー上に文字が載っていることが原因になっていそうでした。
先に知っておくこと
対策の意味を追うには、WebKit(WKWebView)の描画モデルを3点だけ押さえておくと分かりやすいです。
- transform を持つ要素はコンポジットレイヤーに昇格することがある。 レイヤーは GPU 上のテクスチャ(ビットマップ)として保持され、transform の変更はテクスチャの移動・拡縮で処理される(再描画しない = 速い)。
-
ラスタライズ解像度は「レイヤー生成時」に決まる。 生成後に
scale(1.2)されても、テクスチャを GPU が引き伸ばすだけ。文字はビットマップ拡大なので滲む。 -
小数ピクセルの translate は滲む。
translate(123.45px, …)はテクスチャをピクセル境界の「間」に置くため、サンプリング補間で文字のエッジがぼやける。
文字がボヤけるパターンは大きく2つです。ひとつは、古い解像度のテクスチャが拡縮されているケース。もうひとつは、テクスチャが小数ピクセル位置に置かれているケースです。このあと出てくる対策は、このどちらか、または両方に対応しています。
この挙動はChromiumでは再現しませんでした。開発中のブラウザプレビューでは常に鮮明で、wails dev(WKWebView)でだけ滲みます。検証はWKWebView上で確認した方がよさそうでした。
対応 1: ズームすると文字が滲む
症状: ズームイン後、ノードの文字が甘い。ズーム 100% に戻すと鮮明。
原因: ほぼ前提知識 (2) でした。レイヤーが 100% 時点の解像度でラスタライズされたまま、scale() で GPU 拡大されていました。
対策: ズーム操作の確定後(onMoveEnd)に、レイヤーを作り直して現在の倍率で再ラスタライズさせました。レイヤーの作り直しは will-change の付け外しで誘発できました。
// will-change を 1 フレームだけ付けて外す → レイヤーが再生成され、
// 現在の拡大率でラスタライズし直される
function rerasterizeViewport() {
requestAnimationFrame(() => {
const root = document.querySelector<HTMLElement>(".react-flow__viewport");
if (!root) return;
root.style.willChange = "transform";
requestAnimationFrame(() => {
root.style.willChange = "";
});
});
}
これでビューポート直下の文字は直りました。ただ、まだ甘い部分が残りました。
対応 2: ノードだけまだ甘い
症状: 背景やエッジは鮮明になったのに、ノード内の文字が滲む。
原因: React Flowは各ノードにも個別のtransform(translate(x, y))を与えるため、ノードひとつひとつが独立したレイヤーに昇格することがあります。ビューポートを再ラスタライズしても、ノード側のレイヤーは古いままでした。
対策: 再ラスタライズの対象をノードとエッジにも広げました。
const targets: (HTMLElement | SVGElement)[] = [
root,
...root.querySelectorAll<HTMLElement>(".react-flow__node"),
...root.querySelectorAll<SVGElement>(".react-flow__edges"),
];
メモ: コンテナを再ラスタライズしても、個別transformを持つ子要素のレイヤーは古いまま残ることがあります。
対応 3: パン(キャンバスのドラッグ移動)中に滲む
症状: キャンバスをドラッグやスクロールで動かしている間、文字が薄ぼんやりする。止めると戻る。
原因: 前提知識 (3) です。React Flowの内部(d3-zoom)は、パン中に毎フレーム小数のtranslateをstyleへ書き込みます。操作の最後だけ丸めても、動いている間は滲み続けます。
対策: MutationObserver でviewportのstyle書き込みを監視し、毎フレームtranslateを整数へ丸めて書き戻しました。d3の内部状態には触れないので、操作感はほぼ変わりませんでした。
const VIEWPORT_TRANSFORM_RE =
/^translate\((-?[\d.]+)px, ?(-?[\d.]+)px\) scale\(([\d.]+)\)$/;
function installPixelSnapObserver(el: HTMLElement): MutationObserver {
const obs = new MutationObserver(() => {
const m = VIEWPORT_TRANSFORM_RE.exec(el.style.transform || "");
if (!m) return;
const [x, y] = [parseFloat(m[1]), parseFloat(m[2])];
const [rx, ry] = [Math.round(x), Math.round(y)];
if (rx !== x || ry !== y) {
// 丸め済みの値の書き戻しで再発火するが、既に整数なので何もせず終わる
el.style.transform = `translate(${rx}px, ${ry}px) scale(${m[3]})`;
}
});
obs.observe(el, { attributes: true, attributeFilter: ["style"] });
return obs;
}
あわせて、操作確定時にはズームを10%刻みへ、translateを整数へスナップする処理も入れました。117%のような中途半端な倍率を避けると、再ラスタライズ後の見た目が安定しました。
function snapZoom(zoom: number): number {
const stepped = Math.round(zoom / ZOOM_STEP) * ZOOM_STEP; // 10% 刻み
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, stepped));
return Math.round(clamped * 100) / 100; // 数値誤差も除去
}
ここまでで、キャンバス操作で起きる滲みはほぼ解消しました。最後に残ったのが、サイドバーのスクロールで滲む問題です。
対応 4: 隣のサイドバーをスクロールすると滲む
症状: キャンバスには触れていないのに、隣のテスト一覧(overflow: auto のサイドバー)をスクロールするとキャンバスの文字が滲む。
これが一番不可解でした。キャンバスのtransformは1pxも変わっていないので、対応1〜3の仕組み(transformの変化に反応する処理)では拾えません。
原因: 見ている限り、WKWebViewはスクロールが発生するとページのレイヤー構成を組み直すことがあります(スクロール要素のレイヤー昇格・降格に伴う再レイヤー化)。このときキャンバスのレイヤーも巻き込まれて破棄・再作成され、再作成直後に古い解像度のテクスチャが流用されていました。隣のスクロールが、キャンバスレイヤーの作り直しを誘発していたようです。
試行 4-1: containment で隔離 → 効かず
.canvas {
isolation: isolate;
contain: layout paint;
}
描画無効化の影響範囲は絞れるはずですが、レイヤー再構成への巻き込みは止まりませんでした。
試行 4-2: スクロール終了後に再ラスタライズ → 半分だけ成功
window にcapture指定でscroll listenerを付け、ページ内すべてのスクロールを拾う方式です。スクロールが止まってから150ms後に、対応1の rerasterizeViewport() を呼びます。
useEffect(() => {
let timer: number | undefined;
const onScroll = () => {
window.clearTimeout(timer);
timer = window.setTimeout(rerasterizeViewport, 150);
};
// capture: trueなら、サイドバー等どの要素のスクロールでも拾える
window.addEventListener("scroll", onScroll, true);
return () => {
window.clearTimeout(timer);
window.removeEventListener("scroll", onScroll, true);
};
}, []);
結果、スクロールを止めた直後にピントが合うようになりました。ただし、スクロール中は滲んだままです。描き直しで回復する方式では、ここが限界そうでした。
ボツ案: サイドバーを translateZ(0) でレイヤー化
スクロールする側に最初からレイヤーを持たせればキャンバスを巻き込まない、という案も検討しました。実装前の確認で問題が見つかりました。
サイドバーの中には、position: fixed のモーダル(削除確認ダイアログ等)がインラインで描画されています。transform(やwill-change: transform)を持つ祖先はfixed要素の containing block(座標計算の基準)になるため、サイドバーに translateZ(0) を付けると、画面全体に出るはずのモーダルがサイドバー内に閉じ込められます。この案は不採用にしました。
メモ: translateZ(0) / will-change: transform は fixed / absolute の座標基準を変えます。付ける前に、配下にfixed要素がないか確認した方がよさそうです。
対応 5(最終解): レイヤーを「作り直させない」
ここで方針を変えました。滲みはレイヤーの破棄と再作成の間に起きていそうです。ならば、キャンバス側のレイヤーを常時保持して、作り直し自体が起きにくい状態にします。
/* キャンバスの合成レイヤーを常時保持する。
スクロールのたびにレイヤーが作り直されると、その間は
古い解像度のテクスチャ流用で文字が滲む。常時昇格させておけば
サイドバーのスクロールに巻き込まれない。 */
.canvas .react-flow__viewport,
.canvas .react-flow__node,
.canvas .react-flow__edges {
will-change: transform;
}
ただ、ここで副作用が出ます。対応1の再ラスタライズは、will-changeを付けて外すことでレイヤーを作り直していました。CSSで常時付与するとこの操作が実質的に何もしなくなり、ズーム後の描き直しが効きません。そこでトグルの向きを反転します。
function rerasterizeViewport() {
requestAnimationFrame(() => {
const root = document.querySelector<HTMLElement>(".react-flow__viewport");
if (!root) return;
const targets: (HTMLElement | SVGElement)[] = [
root,
...root.querySelectorAll<HTMLElement>(".react-flow__node"),
...root.querySelectorAll<SVGElement>(".react-flow__edges"),
];
// 一時的に auto へ「降格」させ…
for (const el of targets) el.style.willChange = "auto";
requestAnimationFrame(() => {
// インライン値を外して CSS の will-change: transform へ復帰(再昇格)。
// この降格→再昇格でレイヤーが作り直され、現在の倍率で再ラスタライズされる。
for (const el of targets) el.style.willChange = "";
});
});
}
これでサイドバーをスクロールしてもキャンバスは滲まなくなりました。ズーム後の鮮明化もそのまま動きます。スクロール終了後の再ラスタライズ(試行 4-2)は、念のための保険として残しました。
最終形のまとめ
| 症状 | 原因 | 対策 |
|---|---|---|
| ズーム後に滲む | レイヤー生成時の解像度のままGPU拡縮 | 操作確定時にwill-changeトグルで再ラスタライズ |
| ノードだけ滲みが残る | ノードごとに個別レイヤー | 再ラスタライズ対象に node / edges を含める |
| パン中に滲む | d3-zoomが毎フレーム小数translateを書く | MutationObserverで毎フレーム整数へ丸め |
| 中途半端な倍率で不安定 | 任意倍率のラスタライズ | ズームを10%刻みへスナップ |
| 隣のスクロールで滲む | レイヤー再構成に巻き込まれ破棄→再作成 | CSSのwill-changeでレイヤー常時保持(+ スクロール終了後の再ラスタライズを保険に) |
トレードオフはメモリです。全ノードを常時レイヤー化するため、ノード数に比例してGPUテクスチャを保持します(目安: 100ノードで十数MB程度)。ジョブネット規模なら実用上問題なさそうですが、数千ノード級のキャンバスでは、可視範囲だけ昇格するなどの工夫が要るかもしれません。
今回わかったこと
- 文字のボヤけは、古い解像度テクスチャの拡縮か、小数ピクセル配置のどちらか(または両方)でした。まずどちらが起きているかを見るのがよさそうです。
- transformベースのキャンバスでは、コンテナ・ノード・エッジがそれぞれレイヤーに昇格します。コンテナだけ直しても、子レイヤーが古いまま残ることがありました。
- スクロールによるレイヤー再構成は、動いていない要素のレイヤーも作り直すことがあります。描き直しで戻すより、レイヤーを保持した方が安定しました。
-
translateZ(0)/will-change: transformは containing block を作り、配下のposition: fixedを壊すことがあります。付ける前に配下を見た方が安全です。 - Chromiumでは再現しませんでした。ブラウザプレビューではなく、WKWebView(
wails dev)で確認した方がよさそうです。
参考リンク
環境
- Wails v2.12.0(macOS / WKWebView)
- reactflow v11.11.4
- React 18 + TypeScript
- macOS(Apple Silicon)
同じ構成で「なぜかキャンバスの文字が甘い」と悩んでいる方の参考になれば嬉しいです。