0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

WKWebView で React Flow キャンバスの文字がボヤける問題を根治するまで(Wails + React Flow)

0
Posted at

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点だけ押さえておくと分かりやすいです。

  1. transform を持つ要素はコンポジットレイヤーに昇格することがある。 レイヤーは GPU 上のテクスチャ(ビットマップ)として保持され、transform の変更はテクスチャの移動・拡縮で処理される(再描画しない = 速い)。
  2. ラスタライズ解像度は「レイヤー生成時」に決まる。 生成後に scale(1.2) されても、テクスチャを GPU が引き伸ばすだけ。文字はビットマップ拡大なので滲む。
  3. 小数ピクセルの 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)

同じ構成で「なぜかキャンバスの文字が甘い」と悩んでいる方の参考になれば嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?