☁️ Web・クラウド構築

HTML-in-Canvas 解説:canvas に本物の HTML を描く新 API と、ブラウン管風レトロコンソールのデモ

#HTML-in-Canvas#Canvas#WebGL#Chrome#シェーダー#Web標準

1. はじめに

2D Canvas や WebGL / WebGPU でリッチな表現を作る際、大きな壁になるのが「<canvas> の中でテキストや複雑な UI をどう描くか」という問題です。

標準の fillText() は単一行の文字列を描くだけで、自動改行や禁則処理、双方向テキスト(BiDi)などの国際化対応、アクセシビリティ(スクリーンリーダー対応)、テキスト選択、<input> へのキーボード・IME 入力といった機能を持ちません。一方、<canvas> の上に DOM 要素を重ねて座標を同期する従来の手法は、スクロールや拡縮時のズレが起きやすく、WebGL シェーダーの視覚効果を DOM 側にかけられないという限界がありました。

これを解決する提案が、<canvas> 内の本物の DOM を Canvas 2D・WebGL・WebGPU に直接描画し、ヒットテストやアクセシビリティまで統合する新 API HTML-in-Canvas です。本記事では API の仕組みと Chrome での提供状況を整理し、後半では WebGL シェーダーで走査線・湾曲・残光を再現したブラウン管風レトロコンソール「PHOSPHOR-80」を実装・検証します。


2. HTML-in-Canvas とは

HTML-in-Canvas は、WICG(GitHub: WICG/html-in-canvas)で「living explainer」として継続更新されている提案仕様です。ブラウザ標準のレイアウトや入力、アクセシビリティを保ったまま、DOM の描画結果を <canvas> に取り込めます。主なユースケースは以下の通りです。

  • チャートの凡例・ラベル:折り返しや CSS 装飾を含む凡例・ツールチップをグラフ内に描画
  • クリエイティブツール:IME 入力やテキスト選択がそのまま動くキャンバス上のテキストボックス
  • ゲーム内メニュー:WebGL / WebGPU のゲーム画面と HTML / CSS 製メニューの統合
  • HTML にシェーダーをかける:DOM 要素に対する歪み・発光・トランジションなどのエフェクト適用
  • 3D 空間への HTML 配置:3D シーン内のオブジェクト表面へのインタラクティブな HTML の貼り付け
  • 画像・動画への書き出し:DOM で組んだレイアウトの <canvas> 経由での画像・動画化

現在 Chrome で Origin Trial が実施されており、Chrome 公式ブログ「HTML-in-Canvas の Origin Trial の変更点」によると Origin Trial は Chrome 160 まで延長されました。ローカルで試すには chrome://flags/#canvas-draw-element を Enabled にして再起動するか、コマンドライン引数 --enable-features=CanvasDrawElement を指定します(筆者環境の Chrome 156.0.8078.80 で、このフラグにより requestPaint / texElementSubImage2D / drawElementImage / updateElementGeometry が有効になることを確認しました。フラグなしではいずれも未定義です)。


3. API の全体像

HTML-in-Canvas は、canvas の content 属性・drawable 属性・paint イベント・描画 API・ジオメトリ同期の 5 つのプリミティブで構成されます。あわせて、スナップショットに関するプライバシー保護の考え方と、実際のコード例を見ていきます。

3-1. content="drawable" と drawable 属性

<canvas content="drawable">(既定値は fallback)を指定すると、<canvas> の子孫が通常の DOM としてレイアウトされます(直下の子はブロック化・static 配置)。アクセシビリティツリーにも通常の DOM として公開されますが、初期状態ではジオメトリ情報がなく、そのままではヒットテストの対象にもなりません。

描画対象にする子孫要素には drawable 属性が必須です。drawable 要素は暗黙に isolation: isolate を持ち、子孫の包含ブロックになります。入れ子も可能で、入れ子の drawable はそれぞれ別に描けるため、親子を別々にアニメーションさせられます。

3-2. paint イベントと requestPaint()

paint イベントは、ブラウザの描画更新(update the rendering)の中、IntersectionObserver の処理の直後に発火します。event.changedElements で変化した drawable 要素の一覧を取得でき、子孫を先に描けるようツリーの逆順で発火します。

paint ハンドラ内で <canvas> に描いた内容は同じフレームに反映されますが、ハンドラ内で行った DOM 変更は次のフレームまで反映されません。また、DOM に変化がなくても次の描画機会に paint を 1 回発火させたい場合は canvas.requestPaint() を呼びます(requestAnimationFrame に相当)。

3-3. 描画 API:2D・WebGL・WebGPU・Worker 転送

要素の最新スナップショット(起点は CSS transform 適用前の border box)を描く API が各コンテキストに用意されています。drawable でない要素、初回スナップショット記録前、他の <canvas> の子孫に対して呼ぶと例外になります。

  • Canvas 2D:ctx.drawElementImage(element, x, y [, w, h])
  • WebGL:gl.texElementSubImage2D(target, level, xoffset, yoffset, element)
  • WebGPU:queue.drawElementImageToTexture(source, destination)
  • サイズ取得 / Worker 転送:canvas.getElementImageDefaultSize(element) で既定サイズを取得でき、canvas.captureElementImage(element) が返す ElementImage を Worker へ転送して OffscreenCanvas に描くことも可能です。

3-4. ヒットテストとアクセシビリティ:updateElementGeometry()

canvas.updateElementGeometry(element, { canvasTransform, canvasClip, preserveHitTestOrder }) は、要素が <canvas> 上のどこに描かれたかを DOMMatrix で伝え、ヒットテスト・フォーカスリング・スクリーンリーダーの位置を一致させます(解除は clearElementGeometry())。2D の drawElementImage() は自動で呼びますが({ preserveElementGeometry: true } で無効化可)、WebGL / WebGPU では自分で呼ぶ必要があります。

3-5. プライバシー保護(スナップショットから除外される情報)

プライバシー保護のため、スナップショットにはクロスオリジンの埋め込みコンテンツや URL 参照、汚染された <canvas>、システムカラー・テーマ・設定、スペルチェックの波線、訪問済みリンク情報、オートフィル候補、サブピクセルアンチエイリアス、字幕・動画コントロール・IME ポップアップなどは含まれません。一方、スクロールバーやフォーム部品の見た目、キャレットの点滅速度、ページ内検索のハイライトは対象外(含まれてよい)とされています。なお将来案として、<canvas> がコマンドバッファを保持してスクロールやアニメーションにメインスレッドを介さず追従する設計も explainer に記されています。

3-6. 最小コード例と Chrome 155 での変更点

以下は explainer をもとにした 2D の最小例です。

Canvas 2D での最小例
<canvas id="c" style="width: 400px; height: 200px;" content="drawable">
  <form drawable id="form">
    <label for="name">name:</label>
    <input id="name">
  </form>
</canvas>
<script>
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const form = document.getElementById('form');

  canvas.onpaint = () => {
    ctx.reset();
    ctx.drawElementImage(form, 100, 0);
  };

  // canvas の解像度をデバイスピクセルに合わせる
  new ResizeObserver(([entry]) => {
    canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
    canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
  }).observe(canvas, { box: 'device-pixel-content-box' });
</script>

また、Chrome 155 で行われた API 名の変更点は以下の通りです(Chrome 150 では WebGL texElementImage2D の引数が (target, internalFormat, element) の 3 つに簡略化されており、次章のデモはこの旧 API にもフォールバックしています)。

旧(Chrome 154 以前)新(Chrome 155+)
canvas の layoutsubtree 属性content="drawable"(子孫に drawable 属性)
drawElementImage() が DOMMatrix を返す戻り値なし(void)。2D ではヒットテスト位置をブラウザが自動同期
WebGL texElementImage2DtexElementSubImage2D(target, level, xoffset, yoffset, element)。領域は事前に texImage2D(..., null) で確保する
WebGPU copyElementImageToTextureGPUQueue.drawElementImageToTexture(source, destination)

4. デモ:ブラウン管風レトロコンソール「PHOSPHOR-80」

ここからは、HTML-in-Canvas と WebGL シェーダーで実装したブラウン管風レトロコンソール(筐体ロゴ「EGPL PHOSPHOR-80」、画面上部「EGPL-80 TERMINAL」)を解説します。

ライブデモ。HTML-in-Canvas 対応の Chrome では WebGL シェーダー版、非対応ブラウザでは CSS の簡易表示になります(別タブで開く)

記事内でスクロールして画面がビューポートに入ってから電源が入り、BIOS 風ブートログ、メモリテストのカウントアップ、人工衛星のピクセルアート画像(同一オリジン PNG の <img>)とテレメトリ(SIGNAL バーが DOM 更新で伸縮)が表示されます。入力欄は本物の <input> で、<canvas> 上をクリックするとフォーカスし、ブロックキャレット(caret-shape: block)、IME での日本語入力、テキスト選択、↑↓キーの履歴がそのまま動きます。

コマンドは HELP / ABOUT(日本語の説明文を出力し、複雑なテキストもブラウザのレイアウトそのままに描けることを確認できる)/ DIR / TYPE README.TXT / VIEW SAT.IMG / COLOR G|A|W|RGB / FX [名前 ON|OFF] / DATE / CLS / REBOOT に対応しています。下部パネルでは蛍光体(緑・琥珀・白・カラー)と 7 種のエフェクトをなめらかな補間付きで切り替えられ、右下の電源ボタンでは画面が横一線から点に収束する電源 OFF 演出と再起動を試せます。

4-1. 構成と描画パイプライン

デモは public/demos/crt-console/ の index.html / style.css / console.js / crt.js で構成されます。描画パイプラインは以下の通りです。

PHOSPHOR-80 の描画パイプライン
  1. DOM(#screen)
    • ブートログ・<img>・<input> を通常の DOM として更新
  2. paint イベント発火
    texElementSubImage2D で srcTex へ転送
    • changedElements に #screen が含まれるフレームで最新スナップショットを転送
  3. パス 1(FBO ピンポン)
    残光パス(前フレームと max 合成、mipmap 生成)
    • 2 枚のテクスチャをピンポンして max(現在, 前フレーム × 0.8) を描き、generateMipmap を実行
  4. パス 2(画面出力)
    CRT パス → canvas
    • 湾曲・色収差・ブルーム・単色化・走査線・アパーチャーグリル・ノイズ・周辺減光を合成して <canvas> に出力

4-2. canvas の中に置く DOM

index.html の <canvas> 内の構造は以下の通りです。

public/demos/crt-console/index.html(抜粋)
        <canvas id="crt" content="drawable" layoutsubtree>
          <div drawable id="screen" class="screen" lang="en">
            <header class="status">
              <span>EGPL-80 TERMINAL</span>
              <span id="clock">--:--:--</span>
            </header>
            <div id="log" class="log" aria-live="polite"></div>
            <label class="prompt">
              <span class="ps1">A:\&gt;</span>
              <input id="cmd" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="コマンド入力">
            </label>
          </div>
        </canvas>

#screen は 640×480 の CSS px(幅 560px 以下では縮小後の文字を読みやすくするため 480×360)でレイアウトしています。DOM 側はグレースケール(#ededed 等)で描き、蛍光体の色はシェーダーで乗せる設計にしました。これにより、色切り替え時に DOM のスナップショットを撮り直さずに済むほか、<img> の画像も輝度ベースで自然に単色化されるメリットがあります。

4-3. paint イベントでテクスチャを更新する

crt.js のテクスチャ転送と paint リスナーは以下の通りです。

public/demos/crt-console/crt.js(抜粋)
  // #screen の最新スナップショットを srcTex に転送する
  function uploadScreen() {
    const size = canvas.getElementImageDefaultSize?.(screen);
    const [w, h] = screenSize();
    const width = Math.ceil(size?.width ?? w * devicePixelRatio);
    const height = Math.ceil(size?.height ?? h * devicePixelRatio);
    if (width !== srcW || height !== srcH) allocate(width, height);

    gl.bindTexture(gl.TEXTURE_2D, srcTex);
    if (gl.texElementSubImage2D) {
      // Chrome 155+:領域は texImage2D で確保済みなので、中身だけ転送する
      gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, screen);
    } else {
      // Chrome 150〜154 の旧 API
      gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, screen);
    }
    hasSnapshot = true;
  }

  // ...

  // 見えていない間は描画ループを止める
  let visible = true;
  new IntersectionObserver(([entry]) => {
    visible = entry.isIntersecting;
    if (visible) canvas.requestPaint();
  }).observe(canvas);

  // paint イベント:#screen の描画内容が変わったとき(と requestPaint() したとき)に発火する。
  // ここで canvas に描いた結果は、同じフレームの描画更新に反映される。
  canvas.addEventListener('paint', (event) => {
    const changed = event.changedElements ? Array.from(event.changedElements).includes(screen) : true;
    if (changed || !hasSnapshot) {
      try {
        uploadScreen();
      } catch (error) {
        // 初回スナップショットが記録される前は例外になるので、次の paint を待つ
        if (!hasSnapshot) console.debug('waiting for the first snapshot', error);
      }
    }
    if (hasSnapshot) render(performance.now());
    syncGeometry();
    // シェーダーのノイズや残光を動かし続けるため、毎フレーム paint を要求する
    if (visible) canvas.requestPaint();
  });

changedElements を見て #screen に変化があったときだけテクスチャを転送し、初回スナップショット記録前の例外は try / catch で待機します。また、ノイズや残光を動かすため毎フレーム requestPaint() を呼びつつ、IntersectionObserver で画面外では停止させています。

4-4. CRT シェーダーの実装

フラグメントシェーダー CRT_FRAG の要所は以下の通りです。

public/demos/crt-console/crt.js(CRT_FRAG 抜粋)
// 樽型歪み:中心から離れるほど外側をサンプリングし、ブラウン管の膨らみを表現する
vec2 barrel(vec2 uv) {
  vec2 c = uv * 2.0 - 1.0;
  c *= 1.0 + uCurve * vec2(0.075, 0.1) * (c.yx * c.yx);
  return c * 0.5 + 0.5;
}

// ...

void main() {
  vec2 tube = barrel(vUv);
  // ...
  // 色収差:R と B を中心から逆方向にずらす
  vec2 d = (tube - 0.5) * 0.006 * uChroma + vec2(0.0008 * uChroma, 0.0);
  vec3 col = vec3(sampleScreen(uv + d).r, sampleScreen(uv).g, sampleScreen(uv - d).b);

  // ブルーム:残光バッファの mipmap(縮小版)を足してにじませる
  vec3 glow = textureLod(uImg, uv, 2.5).rgb * 0.55 + textureLod(uImg, uv, 4.5).rgb * 0.45;
  col += glow * uBloom * 0.9;

  // 単色蛍光体:輝度に蛍光体の色を掛ける(強く光る部分は白っぽく飛ばす)
  float luma = dot(col, vec3(0.2126, 0.7152, 0.0722));
  vec3 mono = mix(uTint * luma, vec3(luma), pow(clamp(luma, 0.0, 1.0), 4.0) * 0.35);
  col = mix(col, mono, uMono);

  // 走査線:CSS 2px ごとに 1 本。明るい画素ほどビームが太り、暗い溝が目立たなくなる
  float s = abs(sin(uv.y * uLines.y * 1.5707963));
  col *= 1.0 - uScan * (1.0 - s) * (0.7 - 0.35 * clamp(luma, 0.0, 1.0));
  col *= 1.0 + 0.2 * uScan;

  // アパーチャーグリル:デバイスピクセル 3 本ごとに R/G/B の縦縞
  float m = mod(gl_FragCoord.x, 3.0);
  vec3 grille = m < 1.0 ? vec3(1.0, 0.72, 0.72) : m < 2.0 ? vec3(0.72, 1.0, 0.72) : vec3(0.72, 0.72, 1.0);
  col *= mix(vec3(1.0), grille * 1.15, uMask * 0.75);
  // ...
}

7 種のエフェクトの役割は以下の通りです(prefers-reduced-motion: reduce 環境ではノイズとちらつきの時間変化を止めています)。

エフェクト実装の仕組み
湾曲barrel() で中心から離れるほど外側をサンプリングする樽型歪み
走査線CSS 2px ごとに 1 本(高さ 480px で 240 本)。高輝度部ほど溝が浅くなる
マスクデバイスピクセル 3 本周期で R / G / B の縦縞(アパーチャーグリル)を乗算
ブルーム残光バッファの generateMipmap 結果(textureLod の 2.5 と 4.5)を流用し、追加のぼかしパスを省略
残光2 枚のテクスチャをピンポンし max(現在, 前フレーム × 0.8) で残像を保持
色ずれR と B のサンプル座標を中心から逆方向にずらす色収差
ノイズ水平同期の揺らぎ、流れる明帯、ちらつき、粒状ノイズを合成

4-5. ヒットテストの同期

WebGL ではヒットテスト位置を手動で同期する必要があるため、syncGeometry() で #screen から <canvas> 表示サイズへの拡大率を DOMMatrix で渡しています。

public/demos/crt-console/crt.js(syncGeometry 抜粋)
  let cssW = 0;
  let cssH = 0;
  function syncGeometry() {
    const [w, h] = screenSize();
    const transform = new DOMMatrix().scale(cssW / w, cssH / h);
    if (canvas.updateElementGeometry) canvas.updateElementGeometry(screen, { canvasTransform: transform });
    else screen.style.transform = transform.toString(); // Chrome 154 以前
  }

ただし DOMMatrix はアフィン変換のためシェーダーの樽型湾曲までは表現できず、湾曲 ON 時は画面端ほど描画位置とクリック位置がわずかにずれるという制約があります。

4-6. 非対応ブラウザへのフォールバック

非対応ブラウザでは <canvas> の子要素はそもそも描画されないため、startFallback() で #screen を <canvas> の外(div.fallback-screen)に取り出し、注意文とともに CSS の repeating-linear-gradient の走査線と text-shadow による簡易 CRT 表示へ切り替えています。


5. 検証結果

筆者環境(macOS / Chrome 156.0.8078.80、--enable-features=CanvasDrawElement)でのエフェクト ON / OFF 比較画像が以下です。

全エフェクトを有効にした PHOSPHOR-80。緑の蛍光体で、画面が湾曲し、走査線とにじみが乗っている
エフェクト ON(緑蛍光体)。湾曲・走査線・ブルーム・色ずれが乗り、入力行には四角いキャレットが見える
全エフェクトを無効にしてカラー表示にした PHOSPHOR-80。元の HTML がそのままくっきり描かれている
エフェクト OFF(カラー)。canvas に転送された DOM のスナップショットそのもの

検証で確認できた事実をまとめます。

検証項目結果と詳細
WebGL シェーダー描画ブートログ、ピクセルアート画像、日本語テキストが <canvas> 上に描画され、走査線・湾曲・残光などが適用された。
エフェクト OFF・カラー表示すべてのエフェクトを OFF・カラーにすると、元の DOM とほぼ同じくっきりした表示になった。
ヒットテストと入力<canvas> 上の入力行をクリックすると document.activeElement が #cmd になり、そのままタイプしたコマンドが実行された。
モバイル幅(390px)幅 390px でも 480×360 の画面に切り替わり、描画とクリック入力が正常に動作した。
非対応時のフォールバックフラグなしの Chrome では API が未定義のため、自動的に CSS フォールバックへ切り替わった。
湾曲時のヒットテスト精度湾曲 ON のとき、アフィン変換の限界により画面端ほど描画位置とヒットテスト位置がわずかにずれた。

6. 使いどころと注意点

  • 向いているケース:シェーダー効果や 3D 配置を行いつつ、複雑なテキスト折り返し・多言語表示・<input> 入力・アクセシビリティをブラウザ標準のまま維持したい場面に最適です。
  • まだ避けるべきケース:強い非線形歪みを伴う UI(端のクリック位置がずれるため)や、クロスオリジン <iframe>・:visited 等に依存する表示(プライバシー保護で除外されるため)には向きません。
  • パフォーマンス:changedElements で変化時のみテクスチャを転送し、IntersectionObserver で画面外では requestPaint() を止める設計が有効です。
  • フラグなしの読者に見せる方法:Chrome 160 まで延長された Origin Trial のトークンを取得し、<meta http-equiv="origin-trial" content="..."> を配置すればフラグなしの Chrome でも動作します(デモの index.html にもコメントで置き場所を用意しています)。

7. まとめ


📚 関連する技術記事

Web・クラウド構築の記事一覧 →

Cloudflare D1とは?従来型RDBMS・Cloud SQLとの徹底比較からコスト・パフォーマンス・設計の勘所まで解説

Cloudflareのエッジサーバーレスデータベース「Cloudflare D1」の内部アーキテクチャと性質を徹底解説。従来型RDBMS(PostgreSQL/MySQL)やGoogle Cloud SQL・AWS RDSとの比較、圧倒的なコスト優位性、レイテンシとパフォーマンスの真実、向いている用途・制約まで体系的にまとめます。