背景はサイトの中で真っ先に目に入る一方で、本来もっとも気に留めるべきでないものです。ただの装飾そうしょく——ゆっくり回る星空ほしぞらと、右側に掛かった土星どせいだけ。ですが、まさにこの装飾が、無邪気な小さなおもちゃから、トップページ全体を危うく真っ白にしかねない怪物へと育っていきました。前後あわせて、三つの段階と二十いくつの commit を費やしています。

この記事では、koimsurai.com のあの星空がたどった三段階の変身記を、余さず記録したいと思います。 は多かれ少なかれ耳にしたことがあるでしょうが、 はごく新しいもので、多くの人はそれが結局何に使えるのかを知りません。だから、物語を語りながらついでに解説していきます。判断を誤ったところも含めて、できるだけ忠実に過程をお見せするつもりです。

NOTE

三つの段階を一言で Stage 1 それぞれ勝手に動く three.js の装飾エフェクトの寄せ集め(無邪気、積み重なって後から重くなった)→ Stage 2 OffscreenCanvas Worker(星空をメインスレッドから切り離す)→ Stage 3 WebGPU/TSL 単一 canvas への書き直し(GPU 85–90% を一気に 25% へ)。

出発点:無邪気な宇宙背景(Stage 1)#

正直に言うと、最初はいわゆる「パフォーマンス案件」をやるつもりなど、まったくありませんでした。この背景は少しずつ積み上げてできたものです。まずは で作ったブラックホールの粒子(BlackHole3D)、続いて漂う粒子のレイヤー(SpaceParticles)、スペースシャトル、「無重力図書館」、星空テーマの背景、そして最後に輪と衛星を持つ土星(Saturn3D)を置きました。どれも「お、これなんだかカッコよさそう」というノリで、そのつど手を伸ばして加えていったものです。

当時の私は、のちに使うことになるパフォーマンスの手練手管——worker、適応的な解像度、タブの可視性制御——についてほとんど何も知りませんでした。そして正直に言えば、ごく最初のうちは重くなかったのです。数が少ないうちは GPU 使用率もたいしたことはなく、私のマシンではなめらかに動いていて、パフォーマンスなど気に留めもしませんでした。

問題は少しずつ積み重なり、少しずつ重くなっていったことです。エフェクトが積み上がり、私もようやく本気で計測したとき、その兆しがはじめて現れました。人生で初めてこの背景を「本気で最適化」したのは、実はかなり後のことで、手法もどれも対症療法的な絆創膏でした。

  • (useInView)で重量級のエフェクトを LazyComponent に包み、表示領域に入ってはじめて読み込むようにする。
  • Page Visibility API につなぎ、タブを切り替えて離れたときにすべてのアニメーションの requestAnimationFrame ループを完全に停止する。
  • 粒子数を一巡り削り、アンチエイリアスを切り、高性能 GPU モードをオンにする。

第二段階:星空を OffscreenCanvas Worker へ移す#

本当に私を追い詰めたのは、ある一度の Lighthouse でした。スマホのスコアは目も当てられないほどで、 がとんでもなく高い。pagespeed.web.dev で実機に計測しなおすと、デスクトップの数字はさらに大げさでした——TBT 17,470 ミリ秒——メインスレッドが three.js に、まるまる 17 秒も凍りつかされていたのです。

面白いのは、FCP / LCP はどちらも見事だった(コンテンツ自体はすぐに描かれていた)のに、描き終わったあとのページ全体がまるで貼りついたように固まっていたことです。診断してみると、元凶は初期化ではなく、あの 60fps で回りつづける WebGL のレンダリングループでした。Lighthouse の 4× CPU スロットリング下では、1 フレームごとに 50ms を超え、17 秒の計測がそのまま 17 秒の TBT へと積み上がっていたのです。結論は動かしがたいものでした——60fps と一万八千個の星を保ったまま TBT をゼロ近くまで押し下げるには、唯一の道はレンダリングをメインスレッドの外へ、 へ移すことです。

壊れた物差し#

ここでいったん脇道にそれて一つ話しておきます。これは Stage 2 全体の主軸だからです——私が計測に使っていたその物差しが、そもそも壊れていたのです。

自動化した Lighthouse を走らせていたその環境は headless で、しかもGPU につながっていませんでした(私自身のマシンには単体 GPU がありますが、あのスコア計測環境はそれを使えていなかった)。GPU のない環境で WebGL を走らせると、 に退避して CPU で力ずくに計算するため、three.js のコストが実機とはまるで似ても似つかないほど誇張され、しかも走らせるたびに大きくばらつきます。同じ code の TBT が 2860 / 2880 / 4320 / 5410ms のあいだをでたらめに飛び跳ねるのを、私はこの目で見ました。もっとも馬鹿げた瞬間はこうです——切ったのに、TBT がかえって高くなった。これは物理的にありえないことで、つまりこの区間であの環境が計測した WebGL の数字は、どれもこれもノイズだと証明したようなものでした。

CAUTION

当てにならない物差しを使うな GPU のない headless 環境は SwiftShader で WebGL をソフトウェアレンダリングし、実機より数十倍遅いうえ、そのコストを TBT に流し込みます。そんな環境で「トップページの 3D」のスコアを測っても意味はありません——本当の数字は、GPU を積んだ実機で本物の PageSpeed を走らせてこそ得られます。

そしてまさにこの壊れた物差しのせいで、星空を worker へ移したのにスコアの数字がほとんど動かなかったとき、私は一度推論の向きを誤り、ボトルネックはあの星々ではなく土星の bloom だと思い込み、あやうく worker の変更をまるごと revert しかけました。

分割:星空は worker へ、土星はメインスレッドに残す#

本当の解法は、シーンをひと太刀で真っ二つに切ることでした。

  • 星空(約 27,000 個の点:二層の星々 + かけら + またたく星)を へ移す。ほとんど DOM に依存しておらず、移すのに向いています。
  • 土星はメインスレッドのもう一枚の canvas に残す。テクスチャの読み込み、スクロール位置の読み取り、カーソルとの相互作用、bloom の後処理——これらはどれも worker には入れられません。ですが土星は単一のオブジェクトで、コストは二万七千個の星よりはるかに低いのです。

当時、公式に出来合いの橋がありました。@react-three/offscreen です。<Canvas worker={worker} fallback={<Scene/>}/> の一行でシーンを worker へ投げ込め、古い版の Safari でさえ自動的にメインスレッドへ退避して同じシーンを走らせてくれます。

worker はモジュールレベルのシングルトンにしました(アプリ全体で一度だけ起動し、あの 832K の chunk を重ねてダウンロードしない)。スマホ側はもっと思い切って、WebGL を丸ごと取り払い、純 CSS の星空に差し替え、ついでに hero もコンパクトなレイアウトに変えました。

実機の平手打ち(うれしいほう)#

あの壊れた物差しでは結論は出せず、頼れるのは実機の PageSpeed だけです。結果は圧倒的でした。

17,470 → 30ms
デスクトップ TBT
51 → 94
デスクトップ Lighthouse
1,450 → 50ms
スマホ TBT
33 → 65
スマホ Lighthouse

デスクトップの TBT は 17,470ms から 30ms へ落ちました。 星空がまるごとメインスレッドを離れ、メインスレッドはもうほとんど塞がれなくなりました。あの「ボトルネックは bloom だ」という推論は、あの壊れた物差しに騙されたもの——実機では、あの一万八千個の星こそがボトルネック本人だったのです。

TIP

ついでに拾った教訓:Lighthouse はシークレットウィンドウで測る のちにデスクトップが 53 点しか出なかったとき、Lighthouse 自身が「Chrome 拡張機能が読み込み性能の足を引っ張っている」と警告してきました——7.5MB はすべて拡張が注入したもの(AdBlock、Grammarly、wakatime…)で、私のサイト自身の code はほんの一部でした。シークレットで測りなおすと、デスクトップ 96、五項目の Core Web Vitals はすべて緑。スコアがふらつくのは、ときにあなたのサイトのせいではないのです。

Stage 2 はきれいな場所に着地しましたが、締めのメモに一言残していて、それが予言になりました。あの @react-three/offscreen は RC かつ事実上更新停止で、いつか three のメジャーバージョンを上げれば吹き飛ぶかもしれない。依存のリスクを根絶するなら、いつかは**純 three で自分で worker を書く(大仕事)**しかない、と。その「大仕事」は、二か月後にやってきました。

第三段階:WebGPU/TSL で一から書き直す#

まずは解説:WebGPU とは一体何なのか#

先へ進む前に、三十秒だけこの主役をはっきりさせておきます。先に述べたとおり、この背景は three.js が描いていて、three.js の下層が踏んでいるのは WebGL——2011 年に登場し、OpenGL ES に対応する古い API です。どこでも動きますが、構造が古く、GPU とやり取りするときに CPU がやたらとおしゃべりです。

はその後継者です。ブラウザを現代のネイティブグラフィックス API に直接つなぎ、GPU をより細かく制御でき、CPU のオーバーヘッドはより低く、さらに を解禁します——GPU に「ものを描く」だけでなく、大量の並列演算をやらせられるのです。ほとんどのサイトにとって、これは鶏を割くに牛刀を用いるようなもの。私が本当に気になっていたのは、画面いっぱいで、動きつづける背景にとって、それが結局のところ役に立つのかどうかでした。答えは思っていたよりずっと微妙です——先にネタバレすると、最初にそれを欲しがった理由は、間違っていました。

セットになるのが です。JS で shader を書き、二つのバックエンドへ自動でコンパイルするノード式の言語で、今回の書き直しの主な道具になりました。

導火線:私が持っていないグラフィックカード#

ある日、友人がスクリーンショットを送ってきました。彼のマシンで koimsurai.com を開くと、トップページ全体が吹き飛んで真っ白。エラーは THREE.WebGLRenderer: Error creating WebGL context です。彼のマシンは RTX 5060 + Edge 150——私のより新しいのに、落ちてしまったのです。

根本原因は二層あり、しかも二層目は私自身が空けた穴でした。

  1. Chromium が WebGL の SwiftShader ソフトウェアフォールバックを削除した(Chrome 130 で非推奨、137 で削除)。以前はハードウェアの context 生成に失敗すると、ブラウザは静かに CPU のソフトウェアレンダリングへ落ちていました。いまは getContext() がそのまま null を返します。
  2. 私のアプリには がゼロ個。worker が失敗するとメインスレッドの Canvas へ fallback し、メインスレッドの WebGL も死ぬ → React の render がそのまま throw → 誰も受け止めない → root がまるごとアンマウントされる → ページ全体が真っ白

言い換えれば、純粋な装飾でしかない背景が、ページ全体を殺せる単一障害点たんいつしょうがいてんになったのです。かつてそれは SwiftShader という見えない安全網にずっと覆われていて、その網が取り払われた途端、穴が露わになりました。

IMPORTANT

まず直すのは性能ではなく、「装飾にページ全体を殺させない」こと この先に技術を差し替えるかどうかにかかわらず、まず取りかかるべきことはレンダリングスタックとは無関係でした——堅牢性を補うことです。WebGL の検知 + ErrorBoundary + worker のエラー経路——GPU がまったくないマシンでは、白い画面ではなく、純 DOM のエフェクトへとそのまま降格させます。

tsx
// 背景の装飾が、アプリ全体を殺すことは決して許されない。
export default class BackdropErrorBoundary extends Component<Props, State> {
  static getDerivedStateFromError(): State {
    return { failed: true };
  }
  render() {
    return this.state.failed ? this.props.fallback : this.props.children;
  }
}

計測台:?debug=perf#

書き直す前に、まず数字が要ります。でなければ「書き直したら良くなった」という言葉に意味はありません。自前でこしらえるのではなく、2026 年の標準的なやり方である stats-gl をつなぎました——GPU timer query を使い、本当の GPU 時間を計れて、WebGL / WebGPU の両方に対応しています。

物差しが手に入ると、診断の結論ははっきりしていました。ボトルネックは画面全体の / 帯域たいいきであって、 でも粒子数でもない。 シーン全体の draw call は片手で数え終わります。本当に GPU を燃やしているのは——二枚のフルスクリーン canvas がそれぞれに を一本ずつ走らせていること、しかもデフォルトで 8x が入っていること。これこそが、あの 60% GPU の正体でした。

MSAA の生死実験#

MSAA が大口だというなら、それを削るとどうなるのか。三つの段階を一巡り実測しました(4070 Ti Super、180Hz)。

MSAA は GPU 負荷全体のほぼ半分を占めていました。 ですが、ここには残酷なトレードオフがあります。MSAA を削ると星が落ちるのです。ピクセルを数えてみると、MSAA 4 も 0 も細かい星の輝核をおよそ半分落としていて(保持率はわずか 46–50%)、全部を保てるのは MSAA 8 だけでした。理由は微妙です。1 ピクセルより小さいあの星の点は、MSAA がないとピクセル中心を覆う(星まるごと明るい)か、覆わない(星まるごと消える)かのどちらか。MSAA のマルチサンプリングが、ちょうどサブピクセルの被覆検知の役目を果たしていたのです。

WebGPU は結局のところ割に合うのか:三ラウンドの攻防#

ここは書き直し全体でもっとも面白く、そしてもっとも自己満足におちいりやすい一節です。これをまるごと書き留めておきます。私はここで何ラウンドも続けて間違えたからです。

第一ラウンド。 私は最初、WebGPU を「2026 年で唯一の構造レベルの黒魔術」だと見なし、当然もっと速くなるものと思い込んでいました。

第二ラウンド、裏取り。 調べた途端に気が抜けました。WebGPU が主に勝るのは CPU のオーバーヘッドcompute の能力です。ですが私のコスト構造は「ピクセルごと × フレームごと」の fill-rate——API を替えても、描くピクセルが一つでも減るわけではありません。 が省く CPU の提出は、draw call が十しかないシーンではゼロに近づき、compute 粒子の恩恵は十万個の規模になってはじめて現れます。結論は——WebGPU は私のこのシーンのボトルネックに対して、恩恵ゼロ。 そこで私はそれを凍結しました。

第三ラウンド、自分の頬を張る。 論証の中で私はある PR を証拠に引きましたが、あとから自分で調べなおすと——それは 2024 年にすでにクローズされた RFC で、実際に three へマージされたのは別の番号の PR、しかも早くも r166 あたりで入っていて、「新しいもの」でも何でもなかったのです。

WARNING

PR を証拠に引く前に、その状態を確かめよ クローズ済みの RFC を論拠にするのは、正真正銘の誤りです。技術的な判断では、一次証拠の『状態』は、その『内容』と同じくらい重要です。

第四ラウンド、覆す。 本当に結論をひっくり返したのは、問題そのものを入れ替えたことでした。私がずっと答えていた設問は「同じ画面を、WebGPU はより安く出せるか?」で、答えは終始ノーでした。ですが、これを「同じ GPU 予算で、WebGPU はより多くの画面を買えるか?」に替えると——この設問の答えはイエス、しかも明白です。

  • 星を増やすのはほぼ無料:bloom は固定の画面全体のコストで、星数とは無関係
  • WebGPU の compute が本当に買えるのは「十万個の星を全部生かす」ことで、これは WebGL には出せない品質の上限です。
  • TSL の による selective bloom のおかげで、単一 canvas へ収束でき、二枚 canvas の合成税を一気に取り払えます。
  • 新しい は WebGL2 fallback を自前で備えていて、堅牢性はタダで補われるに等しい。

書き直し:自作の worker entry と Sprite instancing の血と涙#

まず土台を上げます。three を r175 から r185 へ。すると最初の構造的な決断が浮かび上がりました——@react-three/offscreen のあの道はもう通れなくなっていたのです。あのプロトコルでは、WebGPURenderer が必要とする async factory を渡せません。これこそ Stage 2 が予言した「大仕事」でした。私はそれをまるごと差し替え、極めてシンプルな worker entry を自分で書き、シーンとレンダリングのロジックをすべて一つの lib/starfieldGpu.ts(命令的な純 three/webgpu、React なし、R3F なし)に収めました。この code は一つで四役です。

そして、書き直し全体でもっとも厄介な地雷を踏みました。PoC を走らせると、GPU は 20% まで落ち、いかにも省エネに見える——ですが星は細いうえにずっとちらつき、しかも WebGPU backend に切り替えるとそのまま真っ黒で星がない、WebGL2 fallback のほうはちゃんと動くのです。

console を埋め尽くす一つのエラー。

worker 内を埋め尽くす GPUValidationError(抜粋)
THREE.AttributeNode: Vertex attribute "uv" not found on geometry. @ spaceGpuWorker-BiAVyl7F.js ...(同じ一行が何千回も繰り返される)

r185 の型定義ファイルを調べてやっとわかりました。この三つの症状は同じ一つの根だったのです。

CAUTION

THREE.Points は永遠に 1px これはプラットフォームの硬い制約で、size はすべて無効です。だから——星は細い(size が無視される)、サブピクセルがピクセル格子の上で跳ねる = ちらつき、GPU が異常に低い(実はそもそも quad を描いておらず、1px の点しか描いていない)。そして pointUV ノードは GLSL の gl_PointCoord をハードコードしている → WGSL ではそのまま検証エラーを吹く。退いて uv() を使うと、今度は points geometry に uv attribute がない → 全部 0 → 真っ黒、となります。

正解は three が公式に指定するパターンでした——THREE.Points ではなく、 + を使うこと。こうすれば両方の backend が「インスタンス化した billboard quad」を通り、size が効き、uv() に値が入ります——backend の分岐を丸ごと切り落とし、一つの経路ですべてをまかなえるのです。

WebGPU 上での点群の正解:Points → Sprite instancing+52
// WebGPU 上 THREE.Points 永遠 1px,size 全部無效、也沒有 quad uv
const stars = new THREE.Points(geometry, new THREE.PointsNodeMaterial())
// 官方模式:Sprite + instancing,兩個 backend 都走實例化 quad,單一路徑
const mat = new THREE.PointsNodeMaterial()
mat.positionNode = instancedBufferAttribute(new THREE.InstancedBufferAttribute(positions, 3), 'vec3')
const sprite = new THREE.Sprite(mat)
sprite.count = STAR_COUNT

quad に替えたあと、「サブピクセルの格子跳ね」によるちらつきは根本から消え、あの「MSAA 0 で保持率 ≥ 95%」という受け入れ条件も、ようやく達成の目が出てきました。門をくぐるまで収束させるのに、間で六つのイテレーションを回しました。

イテレーション変更星点の保持輝核の保持
v0硬い四角(ちらつく版)ちらつく、柔らかくない
v1柔光 pow3真っ黒 💀(uv() が points 上では 0)
v2point uv + pow2.4 に変更19.3%22.5%
v3輝度 ×2.6 + bloom 半径を拡大22.0%36.2%
v4円盤状の柔光の輪郭31.3%68.3%
v5星数 18k → 26k58.9%97.6% ✓ 門を通過

見た目が元に戻らない、あの細部たち#

星はちらつかなくなりましたが、「旧版と比べると、どこか味が一歩足りない」。この一節はすべて感覚の負債です。

  • ちらつきが速すぎる。 新しいパイプラインは 26,000 個の星が全部ちらついていて、旧スタックでは実は 800 個しかちらついていませんでした。26k 全部がちらつくと、せわしない「速い明滅感」が生まれます。直し方——ちらつきの周期を 10–25 秒のゆっくりした呼吸まで引き延ばし、振幅は per-star の分布にする(大半の星はほとんど動かず、ごく一部だけ目立つ)。
  • かすみが強すぎ、bloom が違う。 私は bloom のパラメータを「旧版から丸写し」すればよいと思っていましたが、pmndrs の BloomEffect と three TSL の BloomNode は別々の二つの実装で、パラメータ名すらそろっていません。「パラメータを丸写し」は「見た目を丸写し」とは違い、独立に組み直して釣り合いを取るしかありませんでした。
  • 色が違う(いちばん陰湿いんしつなやつ)。 bloom は加算合成で、重ねたあと canvas の alpha が 1 に叩き上げられる → 不透明の黒が、ページ本来の深い紫の下地したじをまるごと覆い隠してしまったのです。直し方は、出力時に rgb だけ重ね、alpha はシーンの元の値を保つこと。
ts
// bloom の加算で alpha が全部 1 になると → 不透明の黒がページの深い紫の下地を覆い隠す(「色が違う」の元凶)
const combined = scenePassColor.add(bloomPass);
// rgb に bloom を重ね、alpha はシーンの元の値を使う → canvas は透明を保ち、ページの深い紫の下地に重なる
const pipeline = new THREE.RenderPipeline(renderer, vec4(combined.rgb, scenePassColor.a));

単一 canvas へまとめる#

最後の一歩は、土星もこちらへ移し、あの覆しの核心となる売りを実現すること——canvas 一枚です。星空と土星はいまや同じパイプラインを共有し、TSL の material ごとの mrtNode オーバーライドで selective bloom を行います——星の材質は bloom チャンネルに 1 を書き、土星は全体デフォルトの 0 のまま。こうして同じパイプラインの中で、二種類の材質が異なる bloom の扱いを受けるのです。

下のこのスライダー、左は最初期の裸の点群だけの WebGPU PoC(星はまばら、土星はまだ来ていない)、右は土星を移植し戻し、柔光の釣り合いを取ったあとの姿です——ちなみに、右のあの一枚が走らせているのは WebGL2 fallback の経路で、同じ code が WebGPU のないマシンでも見た目が一致することを証明しています。

最終的な成果#

この書き直しは全区間を同じ一日のうちに走り切った で、その日のうちにデフォルトへ切り替え、旧スタックは退役、あわせて 20 個の commit をすべて push しました。

85–90 → 25%
GPU(4070 Ti Super @180Hz)
800 → 26,000
呼吸する星
2 → 1
フルスクリーン canvas の数
114%
輝核の保持(旧スタック比)

GPU は 85–90% から 25% へ削られ、肉眼での比較では 99% 似ていて、しかもレンダリング経路の中の React はゼロ——シーンはすべて命令的な three/webgpu です。友人のあの落ちるマシンはどうなったか? いまは純 DOM のエフェクトへ優雅に降格し、エラーはゼロ、もう真っ白にはなりません。

まとめ:三つの帳簿#

振り返れば、この背景の長征ちょうせいは実のところ三つの異なるレベルの問題で、それぞれに固有の教訓がありました。

  1. メインスレッドは神聖である。 Stage 2 のすべては「メインスレッドで画面いっぱいのレンダリングループを回すな」を軸に回っていました。粒子を削る、アニメーションを止めるのはどれも周縁で、レンダリングを worker へ移すことこそ本質です。
  2. 実機で、シークレットウィンドウで測る。 私は GPU につながっていないスコア計測環境に、まるまる一区間だまされ、あやうく正しい解を revert しかけました。手にしたその物差しは壊れているかもしれません——信頼できる一本に替えるほうが、十回多く測るよりも大切です。
  3. 装飾を単一障害点にしてはならない。 純粋な背景一枚が、アプリ全体をアンマウントできる力を持ってよいはずがありません。ErrorBoundary + 検知 + 降格経路は、どんな「技をひけらかす」コンポーネントも、まず払っておくべき保険料です。
  4. 「パフォーマンス最適化」と「品質向上」の二つの帳簿を分けよ。 同じ WebGPU でも、違う帳簿につければ結論は正反対になります。判断する前に、自分がどの設問に答えているのかをまず確かめること。

まだ返し終えていない負債が一つあります。本棚のあの 3D の ZeroGravityLibrary は、いまだに古い fiber / drei / pmndrs のエコシステムにぶら下がっていて、それも移行し終えてはじめて、きれいに引き抜けます。長征はまだ終点ではありません。それでもあの星空は、ついに自分自身の手で一行ずつ書き上げたものになりました。

參考連結