fire-paper-loader について
Vanilla JavaScript、WebGL、Worker で燃える紙のリングを描くローダーのスキルです。ページ構築でメインスレッドが塞がる間も OffscreenCanvas でアニメーションを続け、ウォームアップ、進行状況の引き継ぎ、フェード、アクセシビリティを扱います。
主な機能
- 01Worker と OffscreenCanvas による描画
- 02メインスレッド停止中のアニメーション
- 03シェーダーの並列コンパイルと準備
- 04ページアニメーションへの進行状況引き継ぎ
- 05動きを減らす設定と進行状況表示
活用シーン
- 01構築に時間がかかるページのローダー制作
- 02メインスレッド停止中もアニメーションを維持
- 03ローダーからページ導入アニメーションへ接続
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ mengto/ skills --skill fire-paper-loaderインストール後、Claude Code で/fire-paper-loaderを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
demo.htmlPROMPT.mdagents/openai.yaml詳細
Fire-Paper Loader
ページの構築中、黒い背景に燃える紙の輪が開きます。**仕組み:炎は<head>から起動するOffscreenCanvas上でWorkerが描画するため、メインスレッドが数百ミリ秒ブロックされても輪は燃え続けます。**炎の見た目は演出です。どんな見た目でも、450 ms停止するローダーはフリーズしたように感じられます。
関連するスキル:web-design-performanceは一般的なページ速度を扱います。このスキルは、スレッドがブロックされても動き続けるローダーに特化しています。スクロール遷移で紙が燃える演出(ローダーではありません)には、同じkbFlameの炎のGLSLを使いますが、ページが描画します。スピナーやAIステータスのオーブには、thinking-orbsを使ってください。
ソースで確認したスタック:Vanilla JavaScript、生のWebGL 1(GLSL ES 1.00、ローダーではThree.jsを使用しません)、WorkerとOffscreenCanvas、ラベルとフェード用のCSS。
bodyではなくheadから開始する
症状:ローダーの表示が1~2秒遅れる、WorkerがGLコンテキストを取得できない、またはthree.jsのダウンロード中に炎が止まる。Workerの起動とWebGLコンテキストの作成が可能なのは、メインスレッドが空いているときだけです。それは<head>内でスタイルシートを読み込んでいる間であり、ページの解析が終わるまでは再び空きません。
<head>内の<script>でcanvasを作成し、transferControlToOffscreen()を呼び出します。小さなブートスクリプトをBlob URLからWorkerとして起動し、{kbPre, canvas}をpostして、Worker内でgetContext('webgl', {antialias:false, alpha:false, depth:false, stencil:false})をすぐに呼び出します。canvasとWorkerはwindow.__kbPreに保持します。- ブートスクリプトはシェルにすぎません。ローダー自身の
<script>(後でbody内に記述)から、{kbInit, src, canvas:null, t0, origin: performance.timeOrigin, reduced, view, target}を付けて、そのtextContentをWorkerに送信し、Workerがそれをevalします。1つのソーステキストを両方のスレッドで実行します:const IN_W = typeof document === 'undefined'。コードを2つに分けると、互いにずれていきます。 - 各ステップをすべてガードします。
Worker、OffscreenCanvas、transferControlToOffscreenが使えない場合、Workerのonerrorが発生した場合、またはfailメッセージを受信した場合は、同じウォームアップの後、同じコードでメインスレッドに描画します。 - クロックを変換します。Workerの
performance.now()はページのものと異なります。performance.timeOriginを送信し、Workerでperformance.timeOrigin - originを加算して、ページが読み取るすべてのタイムスタンプをページ側のものにそろえます。
並行してコンパイルし、パイプラインをウォームアップしてから表示する
症状:最初に表示されるフレームが、読み込み中で最も遅くなります(30~130 ms、Metalでは一度408 ms)。
- 2つのプログラム(ring、cinders)を作成し、シェーダーをアタッチして、両方に対して
linkProgramを呼び出してから、ステータスを確認します。KHR_parallel_shader_compileを使うと、ドライバーが独自のスレッドでコンパイルします。シェーダーごとにCOMPILE_STATUSを読み取ると、その効果が損なわれます。この拡張機能がある場合は、シェーダーごとのチェックを省略します。 warmUp():両方のプログラムを使った実際のフレーム描画を1x1のビューポートに対して実行し、1ピクセルをreadPixelsで読み取って待機します。最初の描画でGPUパイプラインが構築されるため、何も表示される前にこれを済ませます。- その後で初めて
requestAnimationFrameを開始します。Workerが描画すると(通常0.15~0.3秒、タイムアウトは1.2秒)、ページのLOAD.ready()が解決します。ビルド処理はこれを待機するため、炎が止まったままスレッドを1秒間占有することはありません。 - canvasは2つではなく1つだけ表示します。両方のスレッドで炎を描画すると、最初に表示されるフレームでコンパイル時間が発生しました。
リングの進み具合は、見た目の変化に合わせて計画します
症状:炭の大きさが1秒間変わらず、その後2.5倍に跳ね上がります。または、止まったリングを速いリングが追い越し、2つの炎のように見えます。
- 生の進行度
uではなく、S = gain + ln(radius / first radius)で成長を計画します。Sの等間隔なステップは、どのサイズでも同じような成長に見えます。フェードインはSの0.3を占めます(1では、0.9秒のあいだサイズが抑えられていました)。 Sを臨界減衰スプリング(角周波数5/s、速度を1.6/sに制限、1/120秒刻みで積分)で追従させます。目標は実際の進行度ですが、クリープを下回ることはありません。creep = 0.38*(1 - exp(-t/800 ms))とし、待機中は目標がWAIT0.290を超えないようにし、時定数2.5秒でCEIL0.335へ近づけます。強制的に停止させたあとに進行度がまとまって届くと、リングがいったん止まってから押し出されたように見えます。- 動きを止めないでください。速度の最小値は
VMIN0.016/s(8 fpsで1フレームあたり約5 px)、最大値はVMAX0.05/sです。見た目の半径は、進行度0で4 pxの小さな点、0.15までに5.8 pxとなり、その後は炎本来の半径になります。 - ページに何も表示される前から、進行度は独自のクロックでゆっくり増えていくため、three.jsがまだ読み込み中でもリングは表示されます。
滞りなく引き継ぐ
症状:リングがページのイントロに飛び込むように移る、またはローダーが消えるときにページが55~240 ms停止します。
done()は、クリープの速度からイントロの速度V_HAND0.24(u/s)へ、S上でエルミート補間を行います。所要時間はds = clamp(2.8*du/vH, 0.30, 1.2)秒で、リムが引き継ぎ位置の進行度HAND0.36に達したときに通知します。ページはその進行度とクロックから、イントロを正確に再開します。rAFが停止した場合も解決されるよう、安全タイマー(doneDur + 400 ms、ページ側では2.4秒)を設けます。- ラベルは100に達し、コンポジター上でフェードします(
transition: opacity .40s linear .12s)。最初のフレームが混み合っていても、フェードが途中で打ち切られることはありません。 close():alive = falseに設定し、描画を停止するようワーカーに伝えます。フェード中に2つのコンテキストが描画するとGPUを共有し、フェードのフレーム時間は35~67 msになりました。最後のフレームを表示したままにして、CSSのopacityでフェードさせます(120 ms、視差効果を減らす設定時は250 ms)。イントロ本来のカーブに沿ったWeb Animationsのキーフレームで拡大縮小するため、フェード中もページ上のリムに沿った状態を保てます。- ページが表示されているあいだは、
worker.terminate()を呼び出さないでください。 呼び出すタイミングを問わず、メインスレッドが55~240 ms停止しました。freeを送信し(ワーカーのスレッド上でワーカー内のGLコンテキストを破棄し)、終了するのはvisibilitychangeで非表示になったとき、またはpagehideのときだけにします。メインスレッド上でloseContext()を呼び出すのも避けてください。イントロの最初のフレーム(100 ms)を含む、キューに入ったすべてのGPU処理の完了を待つことになります。 - ビルドの各工程の間で処理を譲ります。
await LOAD.step(p)は進行度を設定し、最大45 msごと(ワーカーが描画する場合は、カウントに必要なだけなので90 msごと)に、requestAnimationFrameを1回とsetTimeout 0を待ちます。非表示のタブではrAFが実行されないため、待機しません。
燃えるような見た目:フッターが基準です
オーナーが挙げた症状の順番:「動きが少なすぎる」、「黒い部分はアルファマスクにするべき」、その後、4段階にポスタライズしたグラデーションを試した結果、「非現実的すぎる。前のほうがよかった。フッターの燃え方のほうがよかった」。
- ポスタライズした段階ではなく、発光するグラデーション。 残り火のゾーンは黄色からオレンジ、濃い赤へとなめらかに変化し、熱い部分は黄色がさらに後方まで伸び、最後はくっきりとした裂けた縁で終わります。はっきり分かれた段差はポスターのように見えます。
- 先端に明るく粒立つ糸状の光を配置し、幅は0.7~5 px、長さに沿って細くなったり膨らんだりさせます(均一な線はネオンのように見えます)。2~5 pxのタイトな加算グローを加えます。それ以上広くしてはいけません。太いブルームを加えると、縁がネオンの輪になってしまいます。
- 動かす必要があります。 熱の脈動が縁に沿って両方向に走り(
run)、パッチが7 Hzでちらつき(flk)、火の粉が漂い、ロールがゾーンを後方へ移動します。静止画では問題なく見えても、ローダーは動いている状態で評価されます。 - クリーム色ではなく、ゴールド。 最も高温のコアカラーは
(1.85, 1.02, 0.30)、残り火の色は最大でも(1.10, 0.64, 0.17)で、熱い部分だけに使います。クリーム色やカーキ色になったのは、ホットエンドを白くしたり、暗くするために全体を暗くしたりしたためです。暗い部分はその代わりに、グラデーションに沿って濃い赤へと変化させます。 - 縁取りのない、多孔質の炭化部分。 残り火の後ろにある炭化帯は、繊維の粒子感を持つ、ほぼ黒の
(0.0085, 0.0062, 0.0048)です。時間の経過とともにその中の穴が開き、下にあるフレームが見えるようになります。穴の周りを発光する縁で囲んではいけません。穴同士がつながり、縁に沿った単一の輪郭線になってしまいました。 - 輪は、円盤や「o」の形から始めてはいけません。 3.5~7 pxの厚さで、半周を少し超える、裂けた残り火の弧から始めます。その両端が這うように進んで6.5~12 pxの間で合流します。輪の幅を3.5~7 px未満にすると、描いた輪郭線のように見えてしまいます。
- 火の粉:4つのインスタンスセット(1800、720、360、160個のクワッド、mulberry32
0x2f6b9d1でシード固定)。上部の弧(20~160度)からだけ発生し、寿命は短く、色は熱を帯びたオレンジから濃い赤までで、白にはなりません。ACESでトーンマッピングした後、シェーダーでsRGBに変換する(aces、次にsrgb)ため、gl_FragColorのアルファ値は1、キャンバスはalpha:falseにしてください。
実測コスト
- メインスレッド上で描画すると、ビルドによって一度に250~450 msブロックされ、炎のフレーム間の最長時間は450 msでした。同じページでワーカーを使うと9 msでした。
- DPRは最大1.5で描画します(ソースでの上限値。一般的なウェブの上限は2)。輪は全画面三角形1つと火の粉のクワッド3040個で構成されます。コストがかかるのはフラグメントシェーダーであり、火の粉ではありません。
- タイムステップ:ワーカーは独自のフレームで描画します。ディスプレイのクロックはページ側にあるため、アニメーションのdtには設計上クランプが適用されます(
dtpの最大値は1.5秒、ばねのサブステップは1/120秒)。
ライフサイクルとアクセシビリティ
- ラベルに
role="progressbar"を設定し、aria-valuenowを進捗に合わせて更新します。キャンバスにはaria-hiddenを設定します。カウントはテキストなので、炎がなくても読み取れます。 prefers-reduced-motion: reduceの場合:時間を進めず(tSec = 0)、火の粉を表示せず、コンポジターによる拡大縮小も行いません。それでも輪は進捗に合わせて開き、静止した粒子感のある縁として表示され、フェードは250 msです。フレームは非表示にするのではなく、静止画として設計されています。- タブが非表示の場合:両方のスレッドでrAFが一時停止し、
step()は待機を止めます。done()はすぐに解決され、ワーカーはその後にのみ終了されます。 - 12秒経過しても進捗が40パーセント未満の場合、ラベルに「まだ着火中」と表示します。接続が遅い場合、いつまでも静かに燃やし続けるのではなく、そのことを知らせる必要があります。
- リサイズ時:新しい
view(w、h、DPR)をワーカーに送信します。キャンバスは次のフレームでリサイズされます。
このスキルでできることの範囲
仕組み(維持):先行起動するワーカー、2つのスレッドで共有する1つのソース、並列コンパイルと完了を待つ1 pxのウォームアップ、S空間でのペース配分、Hermiteによる引き継ぎ、停止後のCSSフェード、早期終了なし。演出(自由に置き換え可能):炎の配色と形状、ラベルの文言、引き継ぎ先のページ。デモではページのイントロを模しています。done()の後、ローダーの輪はイントロのカーブに沿って1.8秒間動き続け、フレームの外へ出ます。その時点で実際のページが、引き継いだ進捗から独自の縁を描画します。
ソース:KIBORI 木彫のKINDLINGローダーから抽出。KIBORI 木彫はスクロール連動型のThree.js木工ページで、1.1 MBのビルドがメインスレッドを最大1.2秒間ブロックしていました。