HTMLを描画するHTML in Canvas API 最終回 10個のデモで見るユースケース
HTML in Canvasを活かしたデモ10個を使い、装飾・トランジション・3Dとの統合まで、さまざまな表現の応用例を紹介します。
発行
前回まで
このシリーズでは、HTML in CanvasのAPIの基本と、Canvas2DおよびWebGLでの使い方を紹介してきました。最終回となる本記事では、より具体的な「使い道」を探って、シリーズをまとめていきたいと思います。
HTML in Canvasを使った10個のデモを用意しました。シェーダーでテキストに表情をつける例、ページ遷移を演出する例、HTMLと3Dを混ぜる例など、さまざまです。Google ChromeかEdgeで開きながら、HTML in Canvasの可能性を体感していきましょう。
以降、デモの説明は実装のポイントだけを解説していますので、詳しい実装はソースコードで見てください。
HTML in Canvasのデモ集
1. 水面に沈めたようなテキスト
HTMLでマークアップしたテキストを、水面の下に沈めたように揺らめかす例です。テキストの読みやすさは保ちつつ、透明感とゆらめき、光のきらめきを重ねています。
CSSのfilterやtransformは、平行移動・回転・拡大、ぼかしや色味の調整など、さまざまな表現ができます。ですが、コンテンツを歪ませる機能はありません。このデモのように波打たせたり、水中のように揺らめかせる表現はCSSだけでは作れないのです。SVGフィルタでも、このレベルのリアルタイムな歪みは難しいでしょう。
一方、HTML in Canvasなら、シェーダーで作り出す歪みを、テクスチャ化したHTMLに適用できます。テキストはHTMLとしてマークアップしてあるので、選択もコピーも翻訳もそのまま動きます。
歪みに使っているのは、なめらかに変化するノイズフィールドです。PerlinノイズやSimplexノイズ、あるいはPhotoshopにおける「雲模様」と同種のもので、場所ごとに「どの方向にどれだけずらすか」をベクトルとして連続的に決める仕組みです。このベクトルに沿ってピクセルをずらし、時間でノイズ全体を少しずつ流すことで、波がうねるように見せています。
補足:PerlinノイズとSimplexノイズ
PerlinノイズもSimplexノイズも、急激な変化は起こらないが規則性もない、連続的になめらかに変わる疑似乱数を生成するアルゴリズムです。CGやゲームでテクスチャ・地形・煙・水面の揺らぎなどを作るときによく使われます。
「水感」を演出するために、コースティクス(水底の光の網目)のような表現も加えています。周囲8点の値を調べる近傍8サンプリングの結果を足し合わせて、波の傾きが強い部分だけ明るくしています。
2. テキストから舞い上がるパーティクル
テキストの周りから、文字の形に沿ってパーティクル(粒子)がふわっと立ち上がるデモです。
「水面に沈めたようなテキスト」のデモとは違い、HTMLTextureはシェーダーに直接渡しません。いったんレンダーターゲット(オフスクリーンのバッファ)に焼き付けて、readPixelsで不透明なピクセル(文字部分)だけを抜き出します。そのピクセル座標を、各パーティクルの発生位置として配列に保存しています。これにより「文字の形」からパーティクルが発生しているように見せています。パーティクルの動きには、渦を巻くように自然に動かすカールノイズを使っています。
HTML in Canvasに限ったことではないですが、readPixelsを行う際の注意点として、画面のDPR(Device Pixel Ratio: 物理ピクセルとCSSピクセルの比率)に気を配る必要があります。readPixelsはバックバッファ(CSSで縮小していないCanvasの実際のサイズ)のピクセルを返すので、たとえばRetinaディスプレイでは粒子数が4倍になってしまいます。
見かけの粒子密度を解像度によらず一定に保つには、CSSピクセル基準に換算してから密度を掛けます。readPixelsで抜き出した不透明ピクセルの個数をopaquePoolSize、CSSピクセル1個あたりに置きたい粒子密度をPARTICLE_DENSITYとすると、粒子数countは次のように求められます。
粒子数をCSSピクセル基準にそろえる
const dpr = window.devicePixelRatio;
const count = Math.round( opaquePoolSize * PARTICLE_DENSITY / ( dpr * dpr ) );
particleGeometry.setDrawRange( 0, count );
3. テキストから滴る液体
テキスト内の一部から、液体が滴り落ちていくデモです。
やや粘性のある液体の表現は、実はパーティクルでできています。つまり、1つ前の、パーティクルのデモと仕組みはよく似ています。HTMLの不透明領域からパーティクルを発生させるところは同じです。違うのは、粒子をそのまま描かないところです。
粒子をメタボールとして使い、点の集まりが連続した塊として見せています。メタボールは各粒子を「ふんわりとした光のにじみ」のような形で描いて、画面上で重ね合わせます。粒子が密集する場所ほどにじみが濃く重なり、まばらな場所は薄くなります。その濃さにしきい値を置いて、濃いところだけを「液体の内側」として描き分ける手法です。
サンプリングの対象は、テキスト全体ではなく、テキスト内にあるem要素のバウンディング矩形だけに絞っています。これにより、インラインの一部だけから液体が滴る表現ができています。em要素の位置はgetBoundingClientRect()で取り、readPixelsの読み取り範囲にそのまま渡しています。
4. ページ全体に走るグリッチ
長いページ全体に、断続的にデジタルノイズ風のグリッチが走るデモです。スクロールしても、グリッチは画面全体にかかり続けます。
これまでのデモは見出しやカード、ダイアログなど、一部の要素を対象にしていました。ここではページのコンテンツ全体を<canvas layoutsubtree>の中に入れています。
その上で、layoutsubtree配下の要素にheight: 100vhとoverflow: autoを適用し、その要素の中にコンテンツを置いています。Canvasの中で内部スクロールが効くので、長いページを覆い続けるグリッチエフェクトが成り立ちます。
グリッチ表現は、シェーダーで「ノイズを使ったランダムな横方向のずれ」と「色のチャンネルごとのずれ」を組み合わせて作っています。グリッチのシェーダーはインターネット上でもよく紹介されていますので、それらをそのまま使うこともできるでしょう。
5. ボタンにだけ視点を集めるズームブラー
デモの画面下部に配置したCTA(Call To Action)ボタンにポインタを近づけると、ボタンを中心に背景がブラーする(ボタン自体はシャープに残る)デモです。
「ボタンだけシャープ、それ以外はボケる」をシェーダー側で実現しています。シェーダー内でズームブラーのサンプルを取る際、サンプル点がボタンの「特定の座標内」であれば、そのサンプルの重みをゼロにしています。結果として、ボタンの中身はボケることなく表示されます。
「特定の座標内」の判定は、ボタンのDOM要素の座標と大きさをgetBoundingClientRect()で計測し、それをシェーダーにuniformで渡しています。
ホバー状態の判定は、pointerenter/pointerleaveイベントとしてDOM側が受け取り、このイベントを起点としてブラーの強度(uniform)を変化させています。シェーダーは見た目、DOMはイベント、という分業が行われているわけです。
6. ページがめくれる画面遷移
画面の切り替えに、紙がめくれる効果を伴ったトランジションをかけるデモです。ページのめくれはシェーダーで実現しています。
HTML in Canvasは画面切り替えのトランジションにも活用できます。CSSだけでは実現できないシェーダーならではの表現を適用できます。
画面切り替えのシェーダーは、GLTransitionsなどで公開されているシェーダーをそのまま使うこともできます。本デモではGLTransitionsのInvertedPageCurlシェーダーを使用しています。
画面が遷移する直前に、瞬時にCanvas内に遷移元画面のDOMのクローンを追加し、テクスチャ化してシェーダーを適用しています。
ここで陥りやすいのが、動的に挿入したDOMの中のimg要素は、初回onpaintの時点でデコードが間に合っていないことがある点です。クローンした要素を<canvas layoutsubtree>配下にappendChildした直後のonpaintでは、画像が抜けた状態でテクスチャに焼かれてしまいます。そのため、一瞬だけ画像の消えたページがめくれるように見えてしまうのです。
この問題は、最初のスナップショットを焼く前に<img>.decode()をawaitすることで回避できます。
decodeを待ってからHTMLTextureを作る
canvas.appendChild( clone );
const imgs = clone.querySelectorAll( 'img' );
await Promise.all( Array.from( imgs ).map( ( img ) => img.decode().catch( () => {} ) ) );
const texture = new THREE.HTMLTexture( clone );
デコード済みの画像なら即resolveするので、体感の遅延はありません。
7. レトロゲーム風に開くダイアログ
ボタンを押すと、レトロゲームのように波打った横方向の歪みを伴ってダイアログが開閉するデモです。
Canvasの横幅は、ダイアログ部分より広めに取っています。歪みのシェーダーは横方向にピクセルをずらすので、Canvasがダイアログ幅ぴったりだと歪みが画面の外で切れてしまいます。Canvasを広めに取ることで、本来のダイアログ幅をはみ出す波形まで含めて描けるようにしています。
歪みに合わせて、CSSでopacityをtransitionで変化させています。CSS Transitionとシェーダーの「合わせ技」によって、ダイアログの開閉がより自然に見えるようにできています。
dialog要素はclose()メソッドで閉じる際、普通は瞬時に閉じてしまい、このままだと閉じる際のアニメーションを再生できません。transition-behavior: allow-discreteを指定することで、close()による閉じる動作をCSS Transitionの完了まで遅延させることができます。その間に、シェーダーの歪みアニメーションを再生して、閉じる際の演出を加えています。
8. クリックで割れるガラス越しのページ
ページの上にモーダルが現れ、モーダルの背景(backdrop)越しに、下のページが「ガラス」で隔てられているように見えるデモです。backdrop部分をクリックするとヒビが入り、累積していくと最後はガラスが砕けてモーダルを閉じられます。
モーダルやbackdrop自体は、Canvasの外側のDOM(body要素直下)として作られています。Canvasは、その下に敷かれたページ全体を「ガラス越し」に見える表現でレンダリングしています。backdropのDOM要素をクリック(普通のDOMイベント)すると、クリック位置をシェーダーに渡し、Canvasのその位置にヒビと歪みを描画していきます。
要は、「Canvas外のDOMで状態を管理し、Canvasで表現を作る」というデモです。
クリック回数を重ねると、Canvas内に描画されたテクスチャ化されたHTMLの表示がボロノイ状に分割され、各破片を独立に回転・移動させて飛ばすことで、粉々に砕ける動きを作っています。その背面にも別のテクスチャ化されたHTMLがあるので、破片が飛んでいくとあらためて背面のページが見えます。
9. 文章の中に3Dオブジェクトを置く
長い小説風の文章の中に、3Dのドラゴンと柱のモデルを差し込んだデモです。スクロールすると、3Dモデルが対応する段落に追従し、文章の上に影も落とします。
Webの表現として、いわゆる「パララックス」と呼ばれる表現が古くから知られています。これは、画面縦スクロールに対して、要素の速度を遅くすることで擬似的に視差効果を実現する手法です。一方で、このデモは視差効果そのものによる、本物のパララックスと言ってもいいでしょう。
このデモは、HTMLをテクスチャ化して、それを板ポリゴンの平面とし、その「上」に、3Dオブジェクトを配置しています。
3Dオブジェクトの位置は、対応するプレースホルダーDOM要素のgetBoundingClientRect()を毎フレーム読んで合わせています。ここで陥りやすいのが、getBoundingClientRectは「現在のDOM位置」、HTMLTextureは「前回onpaint時点のDOM位置」を持っている点です。requestAnimationFrame内で位置を更新すると、スクロール中にテクスチャ(過去)と3D(現在)が1フレーム分ズレてしまい、スクロールに対して少し遅れて3Dオブジェクトがついてくる視覚的な遅延が発生します。
回避策として、位置の更新をonpaintの中に移します。テクスチャの再生成と同じタイミングで3Dオブジェクトも同期して動くので、視覚的な遅延が発生しません。(Mozillaは、同期の再描画をパフォーマンス懸念としていますが、このユースケースを考えると、同期によるレンダリングは必要そうです。)
3Dモデルの影は、HTML側のテキストに投影されています。テクスチャ化したHTMLは板ポリゴンとして表示され、この板ポリゴンは3D空間内のオブジェクトであるので、影の投影もできるわけです。
10. 3DシーンのモニターにHTMLページを貼る
90年代の個人ホームページを、3Dシーンに置かれた古いPCのモニターに貼ったデモです。ホームページはBBSフォームを含めて、3Dの中でもインタラクティブなままです。
HTML部分は、画面手前に置かれた定規のオブジェクトによりオクルージョン(遮蔽、背面が隠れる)されているのも見どころです。HTMLが3D空間の一部として溶け込んでいます。
これは前述の「HTMLの中に3Dオブジェクトを置く」デモとは対照的に、3Dシーンの中にHTMLを埋め込んだ例です。
ブラウン管モニターのスクリーンは少しだけ湾曲しています。本来、HTML in CanvasのgetElementTransformは、Canvas内の平面の変換に対してDOMのヒットエリアを同期するための仕組みです。そのため湾曲した面に対しては、単一の2D変換ではぴったり合わせられません。
このデモでは、スクリーンメッシュの4隅の頂点を3D空間からカメラに投影し、4点の対応関係からホモグラフィを計算しています。それをDOMのmatrix3dに渡しているのです。湾曲した面を「中央付近でもっとも合う平面」として近似することで、見た目もヒットエリアもおおむね一致しますね。
実際には縁のほうで少しずれが残ります。それでも、これくらいの湾曲であればフォームに文字を打ったり、リンクをクリックしたりという操作には大きな影響が出ない程度に抑えられています。HTMLそのものを3D空間の「表面」として扱うこともできるわけです。
まとめ
10個のデモを通して、HTML in Canvasの応用例を眺めてきました。
- HTMLにシェーダーで歪みをかける
- HTMLの形を起点にパーティクルを発生させる
- ページ全体をシェーダーで覆う
- DOMのイベントとシェーダーの状態を組み合わせる
- 画面遷移のトランジションにシェーダーを使う
- HTMLに3Dのオブジェクトを混ぜる
- 3D空間の中にHTMLを混ぜる
どれも、CSSやSVGだけでは難しい表現か、CPUでは重くて毎フレーム動かしにくい表現です。GPU上のシェーダーをHTMLとつなぐ、HTML in Canvasならではの領域なのです。
一方で、HTML in CanvasはまだOrigin Trialの段階です。仕様は確定していませんし、ベンダー間の合意もまだです。1回目で触れたとおり、SafariやFirefoxは慎重な姿勢を取っています。仕様が変わるかもしれませんし、この提案自体がなくなる可能性もあります。
かつて似た方向を目指した提案として、HTML要素にGLSLシェーダーを当てる「CSS Shaders」(後にCSS Custom Filtersとしてまとめられた仕様)がありました。しかし、セキュリティ上の懸念から、シェーダー部分は仕様から外されることになりました。HTML in Canvasは、そうした当時の課題に対する答えを設計に織り込もうとしている提案です。多くの開発者が触り、ユースケースを示し、議論を前に進められれば、Webの表現はもう一段先に進むかもしれません。