トップページの土星の横に出るテキストカード(つぶやきやノートなど)は、これまでずっと 3D 画面の上に浮かぶ DOM だった。今週 Chrome 157 で HTML-in-Canvas が正式リリースされた。使えるのは Chrome 157 以上だけだが、いずれ対応は広がるし、フォールバックさえきちんと作ればいい。だから私は最初から、それらを土星の canvas の中へ移すと決めていた。文字と衛星は同じ worker が同じフレームで描くようになり、トップページのアイドル時のメインスレッドの仕事は 7 割近く減った。
上の数字は Chrome 158、CPU 4 倍スロットリング(遅いスマホを想定)、トップページを 10 秒アイドルにした状態で測ったものだ。しかも dev モードなので、本番の絶対値は変わるが、比率はほぼ同じはずである。
HTML-in-Canvas とは何か#
これはここ数年で数少ない、Web 開発のやり方を本当に変えるプラットフォーム更新だと思う。前回同じ感覚を覚えたのは WebGPU(Chrome 113、2023 年)で、ブラウザから現代的な GPU を直接使えるようにした。HTML-in-Canvas が埋めるのはもう半分、つまり GPU に「本物の HTML」を描かせることだ。WebMCP も最近の大きなニュースだが、あれは AI エージェントにサイトを操作させるもので、画面の描き方とは別の話である。とにかくこういうものが出てきたのは大歓迎で、十数年続いてきた古い問題を解決してくれる。
これまでは二つの道しかなかった#
これまで、canvas と HTML は別の世界だった。canvas(2D、WebGL、WebGPU)の中にレイアウトされた文章やボタンを置きたければ、方法は二つしかなく、それぞれに代償があった。
| 方法 | 内容 | 代償 |
|---|---|---|
| 文字をピクセルとして描く | fillText()、または文字をテクスチャや SDF フォントにする | スクリーンリーダーが読めないので、見えない DOM を別に保守する必要がある。Ctrl+F で検索できず、選択してコピーもできず、右クリックメニューもなく、ブラウザの翻訳やスペルチェックも効かない。多言語の改行、ハイフネーション、縦書きは自前の組版エンジンが要る |
| DOM を canvas の上に重ねる | 毎フレーム JavaScript で 3D 座標を計算し、HTML 要素を動かす | canvas と同じフレームにならず、カメラが動くとずれて揺れる。HTML は常に canvas 全体の上に乗るので、3D オブジェクトに隠されることも shader をかけることもできない。毎フレームのスタイル変更がメインスレッドを食う |
私のトップページは二つ目の方法を使っていて、この記事で後から測る問題はすべてここから来ている。
三つ目の道ができた#
HTML-in-Canvas は、ブラウザが普通の DOM 要素を一枚の画像として「撮影」し、canvas に渡して描かせる仕組みだ。ポイントは要素自体は DOM に残っていることで、そのため次のようになる。
- ブラウザがもともと持つ機能はすべて残る:クリック、Tab キーのフォーカス、スクリーンリーダー、テキスト選択、Ctrl+F、翻訳、フォームの自動入力がどれも普段どおり動く。
- 組版はブラウザに任せられる:CSS、フォント、改行、多言語、
:hoverのようなスタイル変化も、描かれるのはブラウザがレイアウトした結果そのもので、自前で組版を書く必要がない。 - 描画時は GPU テクスチャ:3D シーンに置いたり、オブジェクトに隠されたり、透視に合わせて変形したり、shader で歪みや光のにじみをかけたりできる。DOM の重ね合わせではどれも無理だ。
- worker に任せられる:メインスレッドはスナップショットを撮るだけで、描画は worker の OffscreenCanvas で行い、ほかの 3D コンテンツと同じフレームに出せるので、もうずれない。
- クリック位置も追従できる:どこに描いたかをブラウザに伝えれば、クリックとアクセシビリティの位置が描かれた場所に合う。
提案に挙がっている用途には、グラフの凡例や座標軸、制作ツールのテキストボックス、ゲームのメニュー、3D シーン内のスクリーンや看板、shader による HTML の加工、HTML を画像や動画として書き出すことなどがある。Babylon.js、PlayCanvas、luma.gl にはすでに対応する書き方がある。
制約もある。プライバシー上の理由で、クロスオリジンの画像や iframe、システムカラー、スペルチェックの赤い波線、訪問済みリンクの色は描かれない。文字にはがかからないので、元の DOM より少し薄く見える。
まずブラウザと API を確認する#
正式版で名前が変わった#
この API は を経て名前が変わった。私が調べたときに見つけたチュートリアルや記事、さらにはフレームワークの実装まで、多くはまだ旧い書き方のままだった。
| 用途 | 正式版の書き方 | 旧い書き方 |
|---|---|---|
| 有効化 | <canvas content="drawable">、子要素に drawable | layoutsubtree |
| 2D | ctx.drawElementImage(el, x, y) | 同名 |
| WebGL | gl.texElementSubImage2D(...) | texElementImage2D |
| WebGPU | device.queue.drawElementImageToTexture(...) | copyElementImageToTexture |
| 再描画のタイミング | paint イベント、撮り直すなら canvas.requestPaint() を呼ぶ | |
| 3D でのクリック | canvas.updateElementGeometry(el, { canvasTransform }) | |
| worker で使う | メインスレッドで captureElementImage(el) でスナップショットを撮り、transfer する |
旧い名前は Chrome 158 ではすでに undefined で、旧いチュートリアルどおりに書くと何も起きない。判定は属性を一度設定し、それが反映されるかを見ればいい。
function supportsHtmlInCanvas(): boolean {
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable' && typeof canvas.captureElementImage === 'function';
}TypeScript の lib.dom にはまだこれらの API がないので、型は自分で宣言する。しかも全部オプショナルにしておく。非対応のブラウザではどれも存在しないからだ。
いちばん簡単な使い方#
まず 2D 版を見る。API 全体の骨格はここにある。canvas に content="drawable" を付け、描きたい子要素に drawable を付ける。
<canvas id="c" width="600" height="300" content="drawable">
<div id="card" drawable>
<h2>手記 NOTES</h2>
<a href="/notes">最新一篇</a>
</div>
</canvas>const canvas = document.querySelector<HTMLCanvasElement>('#c')!;
const ctx = canvas.getContext('2d')!;
const card = document.querySelector<HTMLElement>('#card')!;
// 要素の内容やスタイルが変わると(:hover も含む)ブラウザが paint を発火するので、ここで描き直す
canvas.addEventListener('paint', () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawElementImage(card, 40, 60);
});
canvas.requestPaint(); // 毎フレーム動かすなら、paint の中でもう一度呼ぶいくつかのルール:
drawableは独立した描画単位である。 その下の子孫は一緒に描かれるが、子孫の中にさらにdrawableがあれば、それは除外されて別に描く必要がある。- 2D ではクリック位置が自動で合う。
drawElementImageの引数に canvas の現在の変形を加えれば、ブラウザは要素がどこに描かれたかがわかり、クリック、getBoundingClientRect()、拡大鏡もそれに合わせて動く。 - WebGL/WebGPU では自分でブラウザに伝える。 テクスチャが shader でどこに貼られたかはブラウザにはわからないので、行列を
updateElementGeometry()に渡す必要がある。更新しないと、物が回って移動してもクリックは元の場所に落ちる。clearElementGeometry()はクリックできなくし、アクセシビリティツリーで画面外として扱わせる。
手元にテストできるブラウザがない#
私のサーバーの Edge は 148、apt の安定版もまだ 156 で、どちらも非対応だった。Edge 157 は beta しかなく、sudo でインストールする必要がある。結局 npx @puppeteer/browsers install chrome@beta で Chrome for Testing 158 を入れた。ホームディレクトリに置けて sudo は要らない。サーバーには画面がないので、xvfb でウィンドウありのモードを起動し、Vulkan のフラグを付けて、ようやく本物の WebGPU が使えた。
three.js の HTMLTexture は使えない#
three.js には r184 から HTMLTexture があり、本来ならそのまま使えばいいはずだ。プロジェクトに入っている r186 のソースを読んだところ、問題が二つあった。
layoutsubtree を設定し、texElementImage2D と copyElementImageToTexture を呼んでいるが、これらは 157 以降もう存在しない。改名の修正は three.js PR #34732 にあり、作者は Chrome 157 の告知を書いたまさにその Chrome DevRel の人なのだが、私が作業した時点ではまだ誰もレビューしていなかった。ついでに出たばかりの r187 もダウンロードして見たが、やはり旧い名前だった。
要素を renderer.domElement に appendChild し、paint イベントを登録する。しかし私のトップページの星空と土星は と の上で動いていて、worker からは DOM が取れない。たとえあの PR がマージされても、スナップショットは domElement から撮るので、私の構成ではその経路にそもそも到達しない。
そこでアップロードの流れは自分で書くことにした。
書き込み先には three の render target のテクスチャを使った。r187 でちょうど renderer.initRenderTarget() が追加され、先に GPU テクスチャを確保してから書き込めるので、40 行ほどで HTMLTexture を置き換えられた。核心はこの部分だ。
const rt = new THREE.RenderTarget(texW, texH, { depthBuffer: false });
renderer.initRenderTarget(rt);
const gpuTexture = renderer.backend.get(rt.texture).texture;
device.queue.drawElementImageToTexture(
{ source: image }, // メインスレッドから送られてきた ElementImage
{ texture: gpuTexture, size: { width: texW, height: texH } },
);
image.close(); // 使い終わったら解放するimage.close() はあの PR から学んだもので、私のプロトタイプでは最初これを書いておらず、paint のたびに解放されないスナップショットが一つずつ残っていた。
プロトタイプ:土星の周りを回るカード#
サイトのコードに手を入れる前に、私はスクラッチ領域で独立したプロトタイプを作り、トップページの構成をそのまま再現した。worker の中の three.js WebGPU が土星を一つ描き、いくつかの DOM カードがその周りを回る。
右のカードはカメラに近いので透視がかかって大きく、マウスを乗せているので枠が琥珀色になっている。つまり CSS の :hover が即座にテクスチャへ反映されるということだ。奥のカードが土星に隠れているのは、深度テストで自然にそうなる。クリックも正しい。投影後の位置をクリックすると elementFromPoint は正しいカードを返し、隠れたカードはジオメトリをクリアしたので、同じ位置では canvas にしか当たらない。
プロトタイプでもいくつか落とし穴を踏んだ。
- テクスチャの色空間と UV。 最初は下地が明るく青っぽくなり、文字も上下逆だった。色空間を処理し、平面の も上下反転させる必要があった。
- ときどき出るエラー。
drawElementImageToTextureはおよそ 9 回に 1 回「No context found for ElementImage」を投げる。要素を入れた直後でまだ一度も描かれていない瞬間は撮影できないので、catch してからrequestPaint()で撮り直せばいい。 - Tab フォーカスが土星の裏に行く。 キーボードフォーカスが隠れたカードに当たるので、その挙動は別途決める必要がある。
- 遠くの文字がぼやける。 カードがカメラから遠いとテクスチャが縮小されるので、2 倍の解像度で撮る。
まず小さなラベルを移す:利点は一つだけ#
プロトタイプで行けそうだったので、新しい worktree を切り、第一歩としてトップページに常に出ている五つの小さなラベル(WORK、NOTES など)で様子を見た。実際にサイトへ組み込むと何が得られるのかを確かめるためで、ホバーで出るカードはひとまず DOM のままにした。?hic=0(元の DOM 版を強制する)と比べると、結果は少し意外だった。
- メインスレッドが止まっても、ラベルは衛星に付いたまま。 はっきりした利点はこれだけだった。わざとメインスレッドを 3.5 秒止めても、衛星は worker の中で普段どおり回る。DOM のラベルはその場で固まり、点と文字が離れてしまう。canvas に描いたラベルはずっと衛星について動いた。
- メインスレッドは 13% しか減らず、スタイル再計算も 20% 減っただけだった。
- 「惑星に本当に隠される」場面はトップページではほとんど起きない。 いちばん内側の軌道の奥側は惑星の上端をかすめるだけで、ラベルは常に土星から離れる方向に伸びる。2 分(ほぼ二周)待っても一度も遭遇しなかった。
代償もある。描いたラベルをクリックできるようにするには、canvas の上にある三つの層(.horbit、<main>、z-index 10 のコンテンツコンテナ)すべてでマウスを素通りさせなければならず、かなり深いところまで手を入れる変更になった。
メインスレッドは本当はどこで詰まっているのか#
13% では少なすぎるので、残りの時間がどこに使われているのか知りたくなった。 でパフォーマンストレースを記録し、スタイル無効化のトレースを有効にして CPU を 4 倍スロットリングし、各をそれを引き起こした要素に帰属させ、一つずつ外して測った。
| トップページのアイドル、CPU 4 倍スロットリング(毎秒 ms) | メインスレッド | スタイル再計算 |
|---|---|---|
| 元の状態(DOM ラベル) | 580–660 | 420–494 |
| ラベルを canvas に移した | 560–570 | 360–400 |
| さらにカードを外した | 約 175 | 約 50 |
大きいのはカードで、ラベルではなかった。カードがスタイル再計算の 9 割近くを占め、その約 6 割はとても間抜けな原因から来ていた。アイドル時に順番にヒントを出すため、カードの opacity が毎フレーム変わっているのに、CSS に transition: opacity 0.3s が書いてあったので、書き込むたびに古いトランジションを取り消して新しいトランジションを作っていたのだ。
この部分は HTML-in-Canvas がなくても直せるので、まずフェードイン・フェードアウトを JavaScript で計算するように変えた。これはすべてのブラウザに効く。残りの半分は、毎フレーム十数個の要素に inline style を書き込むこと自体で、これはカードも canvas に描かせない限り消せない。小さなラベルだけを移しても節約が少なかった理由もこれで説明がつく。毎フレームスタイルを書き換えられていた本当の大口はカードだったのだ。
カードも移す#
トランジションを直したら、次はカードも移す番だ。恩恵を受けるのは当面 Chrome 157 以上だけだが、対応は増える一方で、フォールバックさえきちんとしていれば、今日はまず新しい Chrome の人が得をし、いずれは全員が得をする。
方針は「見た目を今と寸分違わずに」なので、本番版にはプロトタイプとの決定的な違いが一つある。文字は 3D シーンに入れない。画像全体の最上層に、スクリーン座標で、デバイスピクセルに揃えて描き、スナップショットを 1 ピクセル対 1 ピクセルで貼る。文字が再サンプリングされてぼやけることはない。どうせトップページでは本当の遮蔽はほとんど起きないので、その代わりに DOM 版と見分けがつかない文字が手に入る。
位置、フェードイン・フェードアウト、いつ道を譲るかは純粋関数のモジュール(lib/orbitLabels)に切り出し、DOM 版と worker で同じものを共有しているので、両者の計算結果は必ず一致する。
行ごとに揃える#
カードの各行は衛星の位置に合わせて揃え方が変わる。衛星が右にあれば左揃え、左にあれば右揃え、下にあれば中央揃えで、しかも連続的に変化する。これまでどおり毎フレーム各行の translate を書き換えると、またスタイル再計算とスナップショットの撮り直しが起き、元の木阿弥になる。
私のやり方は、カード全体を一度だけ撮影し、各行の上端と幅も一緒に worker へ送るというものだ。worker は描くときにスナップショットを横長の帯に切り分け、各行は同じテクスチャの中の一つの部分領域として、それぞれ左右にずらす。外周の一回り(キーボードフォーカスの枠はそこに描く)はずらさない。こうすればカードの内容が変わらない限り撮り直しは要らず、毎フレーム動かすのは頂点だけになる。
いつ撮り直すかは paint イベントに任せる。このイベントは今回変化した要素(changedElements)を持ってくるので、マウスを乗せたとき、テーマを切り替えたとき、カードの内容が更新されたときに、変わった数枚だけを撮り直せる。
canvas.addEventListener('paint', (e) => {
const changed = e.changedElements;
const out = [];
let missed = false;
for (const el of labels) {
if (changed?.length && !changed.includes(el)) continue;
try {
out.push({ image: canvas.captureElementImage(el), w: el.offsetWidth, h: el.offsetHeight });
} catch {
missed = true; // 入れた直後でまだ描かれていない。次のフレームで撮り直す
}
}
if (out.length) worker.postMessage({ type: 'labels', labels: out }, out.map((l) => l.image));
if (missed) canvas.requestPaint();
});ElementImage は transfer できるので、worker に渡すときコピーは要らない。worker 側はアップロードが終わったら close() する。
色が 3 割違った#
最初の版を描いてみると、小さな文字が DOM 版より 3 割ほど暗かった。追っていくと原因は二つあった。
- スナップショットはなのに、私は乗算済みとして扱い、透明度を二回掛けていた。
- three は画面を canvas に出力するとき、まず透明度で割り、sRGB に変換してから掛け戻す。DOM の文字は canvas の上に重なるので、sRGB の値の上で合成される。もし線形空間で合成すると、淡い色の文字のアンチエイリアスの縁が細くなってしまう。
そこで文字を重ねる段階では three の出力規則から逆算する。まず canvas 上の本来の sRGB 値を求め、その値の上で文字と合成し、それから出力に必要な線形値へ戻す。検証には色のわかっている半透明の下地を使った。白 50% を重ねたとき、期待値 134.5 に対して実測は 132 だった。
上下の二枚は、一枚が DOM 版、もう一枚が canvas に描いた版で、拡大してもほとんど見分けがつかない。唯一の違いは、DOM の文字は小数ピクセルに乗るのに対し、canvas 版は整数ピクセルに揃うので、輪郭がわずかにシャープなことだ。
クリックとキーボード#
worker は毎フレーム、カードの画面上の枠を計算してメインスレッドに返し、updateElementGeometry に渡す。これでクリックは描かれた位置に落ちる。枠が前のフレームと同じなら送らない。
キーボードとスクリーンリーダーはページ上の見えないリンク群を経由するようにし、Tab の順番は以前と同じにした。canvas に描いたカードは aria-hidden にして、二重に読み上げられないようにしている。フォーカス枠は canvas 内のカードの上に描く。
危うく見落としかけた細かい点が一つある。フォーカス枠を描くためにスナップショットの外周を一回り余分に取っていたので、マウスのクリック範囲も 7px 広がっていた。その後、キーボードでフォーカスしているときだけ外周を含めるように変え、マウスのクリック範囲は DOM 版とまったく同じになった。
結果#
| 毎秒のメインスレッド(Chrome 158、CPU 4 倍スロットリング、トップページのアイドル) | DOM 版(?hic=0) | canvas に描画 |
|---|---|---|
| 総作業 | 460 ms | 145 ms(−69%) |
| スタイル再計算 | 310 ms | 24 ms(−92%) |
| スクリプト | 60 ms | 35 ms |
メインスレッドが毎フレームやるのは「どの衛星が光っているか」の計算だけになり、値が変わらなければ送らない。リリース後に本番サイトで確認した。Chrome 158 は HTML-in-Canvas を通り、Edge 148 は従来どおり DOM で、どちらもエラーはなかった。Firefox、Safari、WebGL2 しかない端末、3D が動かなかった場合も、すべて DOM 版になる。
文字がテクスチャになったら#
性能の作業が終わって、私が本当に気になっていたのは別のことだった。文字はいまや GPU 上の一枚のテクスチャなのだから、DOM の重ね合わせではできない効果を作って、この宇宙に本当に溶け込ませられないだろうか。アイデアを五つ挙げ、それぞれ試作を一つずつ作った。この部分はまだ試作ブランチにあり、公開していない。
元々はカードをクリックすると、まず文字が消え、それから土星がその衛星の中へ収縮していた。今は文字も一緒に吸い込まれ、収縮するにつれて細長くなり、最後のわずかな区間でようやく消える。逆に、戻るボタンでトップページに戻ると、各ラベルが自分の衛星から「吐き出され」、最初は細長く、それから元の形に広がる。しかも衛星が飛び出す順番に合わせて一つずつ出てくる。
カードが現れるとき、無線の信号を受信するように衛星の一点から外へ広がっていく。広がっていく縁には赤と青のずれと走査線が付き、半秒ほどでデコードが終わる。実際はとても速いので、下は 2 倍に拡大して 5 倍スローにした版だ。
英字の小見出しの周りに柔らかな金色の光を一回りまとわせ、衛星の光のにじみと同じ質感にした。最初は星空の をそのまま使おうとしたが、まったく効かなかった。光のにじみはキャンバスの透明な部分に落ち、そこは透明度が 0 なので、出力の段階で丸ごと消されてしまう。最終的に文字の層で自前のガウスぼかしをかけることにした。
カードの光源側はやや明るく、反対側はやや暗くして、土星の受光方向と一致させた。効果はごく淡く、拡大して比べないとわからないが、よりリアルに見える。
実は最初はもう一つ、「ブラックホールの重力レンズ」もあった。ブラックホールテーマで、文字がブラックホールの近くを通ると背後の星と一緒に曲げられる。技術的にはいちばん面白い。レンズの計算はもともとあるので、文字は偏向後の位置でサンプリングするように変えるだけでいいからだ。
しかし実際に見ると曲がりすぎて少し抽象的になり、文字が自分の衛星からかなり離れてしまううえ、クリック範囲は曲がる前の位置に残ったままだった。結局これは外し、「吐き出す」版に替えた。吸い込まれるのが引力のせいなら、戻るときは逆に吐き出されるのが、ちょうど話としてつながる。トップページに戻って土星が現れるとき、各ラベルは自分の衛星が飛び出すタイミングで、その一点から最初は細長く、それから元の形に広がる。所要時間はおよそ 0.7 秒だ。
最初の版は衛星が飛び出す進行度をそのまま流用したが、それは 0.25 秒しかなく、しかもラベルは透明からフェードインするので、何もしていないのと変わらなく見えた。その後、ラベル自身に一本アニメーションを走らせ、出てきた時点で不透明にし、形だけで一点から広がるようにした。戻るときは大抵 10px の小さなラベルしかないので、吐き出す方は吸い込む方より控えめで、拡大してスローにしないとはっきり見えない。
これらの効果は Chrome 157 以上でしか見えないので、あくまでプラスアルファであり、機能をこれに頼ることはできない。今後正式版にまとめるときは、吸い込み・吐き出しとデコードは prefers-reduced-motion のときに無効にする。
注意点#
- プラスアルファとしてだけ使う。 現時点では Chrome/Edge 157 以上しか対応していないので、DOM 版へのフォールバック経路は必須だ。私の場合、判定失敗、WebGL2、3D が動かなかった場合、または URL に
?hic=0が付いている場合は、すべて DOM を通る。 - 旧いチュートリアルどおりに書かない。 まず正式版の命名かを確認する。
layoutsubtree、texElementImage2Dの一式は 158 ではもう存在しない。 - three.js の
HTMLTextureはまだ使わない。 r187 の時点でも旧い命名のままで、canvas が worker にある場合は修正後も使えない。 - スナップショットは close する。
ElementImageは使い終わって解放しないと溜まり続ける。 - 撮れなかったら撮り直す。 ときどき出る「No context found for ElementImage」は catch して、
requestPaint()で次のフレームに撮り直す。 - 移す前に測る。 canvas に移す前に、メインスレッドの時間が本当にその要素に使われているかを確かめる。私が第一歩で小さなラベルだけを移したときは、節約できたのは 13% だけだった。
今回いちばん実感したのは、実はあのメインスレッドを止めた比較動画だった。文字を worker に描かせさえすれば、メインスレッドがどれだけ忙しくても引きずられない。「衛星に吸い込まれ、吐き出される」のも worker の中で動いているので、まったく影響を受けない。ページ遷移のトランジションがカクつく原因もまさに同じで、新しいページをマウントするときにメインスレッドが占有され、トランジションの点もメインスレッドで描いているからだ。そこで次は同じやり方で、トランジションの点と彗尾も worker に移すことにした。すでにブランチで着手しているので、できあがったら改めて書く。
- HTML-in-CanvasChrome 157 正式版の告知コンセプトの解説WebGPU での使い方WICG の提案
- three.jsPR #34732:HTMLTexture を正式版の命名に変更