box-shadowについてどれだけ知っていますか?
(dgerrells.com)- CSS box-shadow は通常、UIに奥行きを与えるためのプロパティだが、単一の
divに数百〜数千個を重ねると、一種の 描画API のように機能する - ボックスという制約はあるものの、角丸、複数のシャドウチェーン、色・ぼかし・透明度の組み合わせによって、円形の点やレイヤー効果を作れる
- 実験では ぼかしと透明度 によってアニメーション可能な数が大きく減少し、これらを除くと M1 ノートPCで数千個の box-shadow を描画できた
- ボールシミュレーション、画像ピクセル、キューブ・球の表面上の点、音楽反応型ビジュアライゼーションは、いずれも毎フレーム box-shadow 文字列 を更新する方式で実装されている
- 最後には CPU ベースの レイトレーサー を Web Workers で並列化し、単一の
divと box-shadow だけでもシーンをレンダリング可能であることを示している
box-shadowを描画ツールのように使う
- CSS
box-shadowは ドロップシャドウ (drop shadow) の一種で、もともとは画像や UI 要素の背後に影を描いて奥行きを与えるために使われる - 一般的なドロップシャドウは、画像のラスターを x/y 軸方向に移動し、単一色で元画像の背後に描くことで立体感を作る
- CSS
filter: drop-shadow()は x/y オフセットと色を受け取り、オプションで blur 値もサポートする - 複数のドロップシャドウフィルターをレイヤー化すると、画面構成に奥行きを加えられる
box-shadowの特性と制約
box-shadowの “Box” は、影の形状が コンテナの bounding box に制限されることを意味する- この制約は限定的に見えるが、ほとんどの UI はボックスで構成されているため、実際の UI では有用である
- CSS の box-shadow 実装は、角丸のボックスを低コストで描くための数学的最適化をサポートしており、角丸ボックスは円のように見せられる
- デザイナーは事前レンダリングした画像を使わずに box-shadow を活用することで、ダウンロードサイズの増加を避けられる
- 単一の
divには複数の box-shadow をチェーンのように連結でき、例ではこの方式で色と影を構成している - border-radius の調整は CSS border-radius generator で試せる
「間違った」box-shadowの使い方
- 一般的な UI デザインでは、影は余白、パディング、タイポグラフィ、アクセシビリティと並んで、状態やインタラクションを区別する補助要素として使われる
- しかし box-shadow は、任意個数の長方形を画面に塗り、必要に応じて blur を適用する ペインティング API のように扱うこともできる
- 初期の実験は 以前の記事のミニマルアート から始まり、シンプルなカラーブロックの設定だけで視覚的な結果を作っていた
- その後、より多くの box-shadow、blur、透明度がパフォーマンスに与える影響を見るために可視化ツールを作成した
- 大きな box-shadow 文字列を生成して 1 つの
divに設定する - アニメーションは 300ms ごとに box-shadow 文字列を変更し、
transition: allに任せる方式から始めた - この方式には jank があり、毎フレーム box-shadow を設定するより遅かった
- 大きな box-shadow 文字列を生成して 1 つの
- 100 個の box-shadow の例では、タップでカラーパレットをリミックスし、左側に直近 10 個のパレット履歴を表示する
- blur を適用するとアニメーション可能な数が減り、透明色も描画可能な個数を大きく減らした
divのサイズもパフォーマンスに影響し、アニメーション時にはソフトウェアラスタライザが関係しているように見えた- 透明度と blur を使わなければ、M1 ノートPCでは数千個の box-shadow を描画できた
box-shadowでボールシミュレーションを作る
- box-shadow は回転できないが、border-radius を使えば円のように見せられる
- 複数の円形シャドウをボールとして扱い、z 値に応じてサイズをスケーリングし、疑似的な 3D の奥行き感を作った
- 実装は
requestAnimationFrameでゲーム状態を更新し、毎フレーム大きな box-shadow 文字列 をdivに設定する方式である - レンダリング処理は次の通り
- ボールを z 値基準でソートする
- z 値に応じてサイズを計算する
- 各ボールの x/y 位置、spread、色を box-shadow 項目に変換する
- 項目をカンマで連結して 1 つの文字列にする
- 50 個のボールの例では、ドラッグでボールを動かし、ボックス内で跳ね返るようにしている
- 疑似 3D スケーリングはある程度の奥行きを与えるが、ボールが「カメラ」に近づくと円が崩れて見えることがある
- これは box-shadow を生成する基準
divが、スケーリング方式に対して小さすぎるためである - コンテナサイズを大きくすれば解決するが、コンテナが大きいほどパフォーマンスは低下する
- これは box-shadow を生成する基準
- ボール同士を衝突させるバージョンは
n^2の衝突判定を使い、衝突時には速度のみを反射させる- 実際の物理相互作用をシミュレートしているわけではない
- 見やすさのため z 位置は固定し、2D にしている
- ボールがランダムな開始位置へ戻ろうとする例も作られており、タッチの力でボールを引き寄せると、スポンジから破片をちぎるような効果が生まれる
画像と 3D 点群
- 次の実験では、画像ピクセルを 2D 平面上の点にマッピングし、各点を box-shadow で描画する
- 画像ピクセルの位置と色を読み取り、
state.particlesに保存し、各ピクセルを 1 つの粒子として使う - ソースは CodeSandbox の例 にある
- この例では、数千個の box-shadow を 3D 空間でレンダリングし、ドラッグすると画像を散らすようなインタラクションが可能である
- 自動回転するライブ版は
/box-shadow/v3?width=80&size=5&autoRotate=1で提供され、実行時にはバッテリー消費への警告が表示される - より多い個数の例では約 12,000 個の box-shadow 規模で、このレベルではカクつきが見られた
- M1 では非常に多くの box-shadow を処理できたが、デスクトップ、iPhone、古い Android では同じ水準を処理できなかった
キューブと球の表面に点を配置する
- 点をメッシュ表面に均一投影する実験も続けられた
- キューブは各面に沿って点を配置する方式で実装されている
- すべての辺の長さが等しいと仮定する
- 各面を走査し、与えられたサイズに応じて点を埋める
- 点数を増やすとキューブの精度が上がる
- キューブの例はタップでインタラクションでき、マウス位置を追従する小さな照明を追加して奥行き感を高めている
- 照明計算は正確ではなく、いくつかの “magic constants” を使っている
- キューブ粒子のマッピング関数と複数の数学補助関数は gypity を使って得た
- 最初の結果はランダム分布だった
- 2 回目の試行でキューブ表面の均一配置を得た
- 球表面の均一配置はより複雑で、spiral discretization 方式を使用した
- 球を下から上へ巻いていく線の上に点を均一配置するという考え方である
- ロープがボールを巻く様子にたとえられている
- この線形マッピングを利用して、音楽の周波数値配列と結びつけたアニメーションも作られた
- 点には時間とともに補間されるグラデーションパレットのアニメーションを適用しており、この部分は前の作業より難しかった
- 回転数の少ない球の例では、螺旋が均一分布を近似するものの、十分な回転数がないと崩れて見える
box-shadowレイトレーサー
- 三角形を box-shadow で描けるなら、ほぼあらゆる CGI を作れるのではないかという考えが レイトレーシング の実験につながった
- 目標は単一の
divと box-shadow だけでシーンを描くことだった - 以後の例では、低精度のライブレンダリングと高精度の画像レンダリングを併用しており、精度制限のない例もあるため実行には注意が必要である
- レイトレーサーとレイマーチャーは、正確だが遅い画像生成方式であり、CGI 業界全体で使われている
- ここでは GPU ではなく CPU ベースの tracer を使っている
- GPU を使うと実験の趣旨が薄れ、実装も難しくなるためである
- 初期の tracer は球だけをレンダリングするよう作られており、シーンのデータモデルにはカメラ、複数の球、マテリアル情報が含まれていた
- gypity から得た初期コードはリアルタイムで動作したが、問題があった
- サンプリング最適化で必要な bias coefficient を加えるコードが抜けていた
- 関連するコメントはあったが実際の 1 行がなく、サンプリング手法を調べた後で修正した
progressive rendering と Three.js
- その後コードは progressive rendering 構造に変わった
- progressive rendering では、多数の ray 計算を複数フレームに分散して実行し、レンダリング結果が徐々に “ground truth” に近づいていく過程を見せる
- この構造はカメラをインタラクティブに動かす機能と相性がよい
- カメラと orbit controls には Three.js ライブラリを使用している
- 行列計算やモバイル対応を自前実装しないためである
- このバージョンでは球しかレンダリングできず、シーン内のすべての要素がある程度スケーリングされた球である
- 例 はデフォルトで全解像度の約 6% を少し超える程度 で動作する
- 画面から遠ざかるほど低解像度のシーンはより鮮明に見え、近づくほど精度不足が目立つ
- クエリパラメータで解像度スケール、
pixelSize、ray の bounce 数、最大サンプル数を調整できる - 解像度とサンプル数を上げるほど結果は良くなるが、非常に遅くなる
tracer実装と性能ボトルネック
- レンダリングは、各ピクセルごとにカメラ基準の ray を飛ばし、色を計算してフレーム間で平均化するという標準的な方式である
- tracer は Three.js を使っているため、多数の新しいオブジェクトが生成され、すぐにガベージとして溜まる
- オブジェクト再利用も一部試したが、性能を極限まで絞り出すなら Three.js を使わない方がよかった
- プロファイラ上ではガベージコレクションが大きな差を生んではおらず、Three.js の使用は維持された
- tracer の基本的な考え方は、ray がシーン内のオブジェクトに当たり、光に到達するまで跳ね返り、そのオブジェクトや光源の属性に応じた色を返すというものである
- 多数の ray を飛ばす必要があるのは、ray が光源に届かない場合もあれば、届く場合もあるためである
- この tracer は単純なライティングモデルを使っている
- 物理的に正確な BRDF なし
- テクスチャなし
- subsurface scattering なし
- 単純な拡散光と specular reflection を使用
- レイトレーシングの学習資料として Ray Tracing in One Weekend を参照できる
- その後 plane をレンダリングできるようになり、floating point 値を bit 操作するミスを修正した後、照明用の長方形もレンダリング可能になった
Web Workersで並列化する
- 性能改善は Three.js の GC 問題を直す代わりに、Web Workers によるマルチスレッド化で進められた
- レイトレーシングは、各計算が単一の結果を返し副作用もないため、並列化に非常に向いている
- worker manager は worker プールを作成し、
renderとupdateSceneの 2 つのメソッドを提供して、実行時にシーンを変更できるようにしている - worker 側のコードは既存 tracer のコピーであり、シーン差し替えのためにデータのマーシャリングが必要だった
postMessageのオーバーヘッドを減らすため、worker は単一ピクセルではなくフレーム全体をレンダリングする- 全画面の例 は以前より大幅に高速に動作する
- 欠点は、カメラやシーンが変わると既存の ray 計算結果がすべて無効になり、画面が黒く見える場合があることだ
- 解決策として、入力イベントで
isDirtyフラグを立て、更新ループでフレームを消去するかどうか判断する方式を実装した - worker が以前のシーンデータで計算したフレームを、シーン更新直後に送ってくることがある
- timestamp や scene id を
postMessageに含めて破棄することはできる - 実験では 1 フレーム分の誤った ray データはすぐ平均化されるため、そのままにしている
- timestamp や scene id を
- 結果として box-shadow ベースの ray tracing は十分に機能し、ソースは CodeSandbox にある
- パフォーマンス統計には、総 ray 数の推定値、サンプル数、レンダリング解像度が表示される
- デフォルトのサンプル数は 1200 で停止するが、設定変更は可能である
- より高速だが精度の低い代替ライティングモデルも切り替えられる
- シーンデータは JSON なので直接編集しやすい
結論: 可能だが推奨はされない実験
- 単一の
divの box-shadow だけで、ボールシミュレーション、点群、画像ベースの粒子、キューブ・球の表面、レイトレーシングシーンまで描ける - ただし実験全体としては、実用的なユースケースのない「やってはいけない」方法に近い
- triangle mesh の読み込み、acceleration structure、より正確なライティングモデルを追加すれば改善の余地がある
- gypity は当初 box-shadow ray tracer は不可能だと答えたが、実際には動く例が作られた
- CSS は直感的ではないが、ときに明らかに動作する奇妙な可能性を持っている
1件のコメント
Hacker News のコメント
透明色を使うと GPU の描画バッチ処理が制限されるため遅くなる
不透明な描画は深度バッファを使って順序を自由に入れ替えられ、バッチ処理を最大化できる。透明な場合はブレンディングが正しく行われるように ペインター順で描画する必要がある
特に Chromium は全体のレイヤー数を最小化しようとし、各レイヤーをピクセルマップとしてレンダリングしたうえで、毎フレーム見えているレイヤーを最終画像に合成する。実際にはピクセルは変わらず位置だけが動くレイヤーが多いので、毎フレームラスタライズせず合成だけすればよい
透明度のない box-shadow が多い場合、Chromium は全体を一度に 1 つのレイヤーとしてラスタライズできるが、透明な box-shadow が多い場合はそれぞれを別レイヤーにすることもある。この場合は非効率になり得るが、半透明の box-shadow たちがページ上で独立して動く必要があるなら、必要な方式でもある
完全に透明なバッファ(α=0.0)から始め、前から後ろへ各面を走査し、各ピクセルで新しいピクセルの
1.0-buffer.α分を既存バッファにブレンドすればよい。buffer.α == 1.0なら深度バッファのように完全にスキップできるただし、透明オブジェクトの後ろにさらに別の透明オブジェクトがある場合の数学は再確認が必要で、面が循環的に重なったり互いに貫通したりする場合は厄介
透明テクスチャより三角形を多く描くほうが概してよい理由もここにある
不透明レンダリングは前から後ろへ描けるので、最終フレームバッファで実際に見えるものだけをレンダリングすればよく、深度パス後のピクセル数はフレームバッファのサイズに比例する
透明レンダリングは後ろから前へ描く必要があり、後から別のポリゴンに部分的に隠される場面まで多くレンダリングしなければならない。そのため、シェーダーパイプラインを通過するピクセル数がメッシュサイズに比例するほど大きくなり得る
互いに重ならない要素なら、透明度のせいでほとんど遅くならないはず。どうせ各ピクセルに一度ずつ触る必要があり、変わったのはシェーダーの式だけだから
不透明な描画が他の描画の上に重なる場合、最良の場合は重なる描画処理をすべて取り除けるし、最悪の場合でも個々の描画分の帯域幅だけで済む
透明度では、演算を何らかの形でまとめられない限り、重なった領域全体を再度読み込む必要があるため、透明な描画ごとに最終フレームバッファサイズの少なくとも 2 倍に相当するビットマップがメモリバスを行き来することになる
多くのモバイル機器が 60fps を維持できる時間内に画面全体を 2 回 blit するだけのメモリ帯域幅さえ不足していたことを考えると、かなり大きな問題になる
本当に面白い探求だった
レイヤリングが重要な言葉だというのもその通り。14 年前に作ったテキストシャドウプロジェクトの、ばかばかしいけれど時々かっこよく見える効果も、レイヤリングが肝だった: https://paulirish.github.io/mothereffingtextshadow/
最後の段落まで行ってようやく
gypityが ChatGPTを指していると気づき、少し気まずかったgyp関連のクライアントサイドツールか何かだと思っていた良き昔ながらの 実用性のないハックは大歓迎。ただし、すでに canvas があり、こういうことは canvas のほうが簡単で速く、うまくできるという点は覚えておくべき
音楽ビジュアライゼーションは確かにかっこよかった。昔の Winamp 時代のように、音楽を流してビジュアライゼーションを全画面で表示していた頃が本当に懐かしい
最近のストリーミングオーディオプレイヤーにも、こういう機能をやってほしい
ただ、ストリーミングオーディオプレイヤーが本当に基本機能しかないソフトウェアだというのはその通り
結局 Firefox と Chrome は、ブラウザズーム 150% で 1px box-shadowを今でも異なる形でレンダリングする
Baseline 2025 に期待している
「UI の人たちが好きな角丸ボックスを非常に安く描く数学的ハック」をもっと学べる参考資料があるのか気になる
https://www.folklore.org/Round_Rects_Are_Everywhere.html
http://wg20.criticalcodestudies.com/index.php?p=/discussion/...
現代的な 3D アクセラレーション方式としては、別の人が推測していたように SDF を使う記事がある
https://mortoray.com/quickly-drawing-a-rounded-rectangle-wit...
自分が好きな種類のハックだ
このテーマで読んだ Josh Comeau の記事とはほぼ正反対に思える: https://www.google.com/search?q=josh+comeau+shadows
今年読んだ中で最高かもしれない素晴らしい記事が、最後に
you'reではなく your welcomeで終わっていた早く直すべき。あるいは自分がジョークを理解できていないのかもしれない
この 30 年でプログラミングは上手くなったが、ゲームが好きではなかったのでグラフィックスはほとんどやってこなかった
今ではこれを大きな失敗だったと見て、1 年以上かけて追いつこうとしているが、本当に難しい