一言で
感情を3D惑星で可視化するウェルネスWebプラットフォームです。 感情レベル・理由・日記を3段階で記録すると、カレンダーとチャートで感情の流れを確認できます。 AIを通じて感情を予測し、期間別の分析を提供します。
Emotion Recording
Data Visualization
AI-Powered Analysis
- GLSLシェーダー簡素化とLOD Subdivisionで三角形88%削減、29fps → 61fpsを達成しました。
- Barrel Export構造の改善とDynamic ImportでFirst Load JS 58%削減を達成しました。
- pnpmモノレポでNext.jsウェブアプリとChrome Extensionが3D惑星、時計、テーマを共有する構造を設計しました。
感情惑星がサービスより重かったです。
r3f-perfで計測した結果、三角形408,040個、フレームレート29fpsでした。
Zero Gravityは新しいタブを開くたびに感情惑星が迎えるサービスです。毎日開く画面が重いということは、サービスの第一印象が重いということでした。
事前に焼いておけばいいのでは?
毎フレームnoiseを計算する代わりに、事前に計算した値をテクスチャに保存して取り出せば? VAT(Vertex Animation Texture)を試みました。
VATは失敗しましたが、テストを通じて既存シェーダーの演算量が過剰であることがわかりました。「この演算は本当に必要か?」 という根本的な問いに転換しました。
不要な演算を発見しました。
シェーダーの演算量を数値で確認しました。
もともと表面が有機的にうねる球体のために設計されたシェーダーでした。表面を変形するには:
- 各頂点の位置をnoise(自然なパターンを生成する数学関数)で揺らす
- noise内部でより動的なパターンを作るために4D noiseを2回計算する
- 位置が変わるとライティング計算のための法線も再計算が必要なため、隣接する頂点2つも一緒に計算 = 3回呼び出し
4D noise 2回 × 3回呼び出し × 102,000個の頂点 = フレームあたり約61万回のnoise呼び出し
感情の強度を惑星表面のうねりで表現しようとしましたが、惑星の形を見ると木星や海王星のように表面を流れる色彩パターンが核心でした。表面変形がなくても感情は十分に伝わっていました。
不要な演算を除去し、色彩アニメーションだけを残しました。
per-vertexを減らした次は、vertex数。
頂点ごとのnoiseを減らしましたが、頂点が102,000個であれば依然としてフレームあたり約20万回のnoise呼び出しです。
IcosahedronGeometryの三角形数はsubdivisionの二乗に比例します。
subdivision 100 → 三角形 408,040個
メインページで惑星が画面いっぱいに拡大されるため、subdivisionが低いと表面がカクカクに見えました。クオリティのために100まで上げましたが、パフォーマンスとのtrade-offを考慮できていませんでした。
視覚的品質が維持される最小subdivisionを探し、LOD(Level of Detail)を適用しました。小さく表示される惑星はさらに下げても目立たず、モバイルは画面自体が小さいのでさらに削減できました。
* 408,040はshadow pass込みの数値です。subdivision 100の実際の三角形は約204,000個ですが、shadow passが同じgeometryをもう一度描画するため2倍になっていました。底のない宇宙空間に不要な影だったため除去しました。
4D noise 2回 × 1回呼び出し × 24,000個の頂点 = フレームあたり約4.8万回のnoise呼び出し
29fps → 61fps


フレームあたり61万回のnoise呼び出しを4.8万回に削減したのは、新しい技術を導入したのではなく、不要な演算を見つけて除去した結果でした。 最適化に正解はありませんが、「本当に必要か?」と問うことが最も効果的な出発点でした。
チャートページにThree.jsがなぜあるのか?
原因はBarrel Export構造でした。
@next/bundle-analyzerでバンドルを分析しました。Three.jsが712KBでバンドル全体の約35%を占めており、First Load JSはHome 447KB、Record 514KBに達していました。
Three.jsが原因だと判断しました。コンポーネントをDynamic Importで分離し、Custom Exportsで必要なモジュールだけを抽出し、drei個別importも試みました。すべて効果がありませんでした。
そこで1つ奇妙な点を発見しました。3Dをまったく使用しないチャートページにもThree.jsが含まれていたのです。
チャートページはグラフの色マッピング(EMOTION_COLORS_MAP)とラベル表示(EMOTION_TYPES)など純粋な定数のみをimportしていましたが、同じbarrelに3DコンポーネントがまとめられていたためThree.js 712KBが丸ごと引きずり込まれていました。

712KBを切り離した方法。
感情関連の定数と型(EMOTION_STEPS、EMOTION_COLORS、EMOTION_TYPESなど)はプロジェクト全体のドメインデータハブでした。スライダー、フォーム、カレンダー、チャート、Chrome Extensionまで約28ファイルがこのデータをimportしていました。
問題は、このドメインデータがモノレポのsharedパッケージで3D惑星コンポーネントと同じbarrelにまとめられていたことです。モノレポでsharedパッケージはpre-built状態で消費されるため、barrelの単位がそのままchunkの単位でありバンドル最適化の限界となります。
依存性の重さを基準にbarrelを分割することが構造的な解決でした。感情の「何」(名前、色、値)と「どう見せるか」(3Dメッシュ、シェーダー)を分離しました。
Three.jsコンポーネントはReact.lazyで遅延ロードしました。モノレポでwebとChrome Extensionがsharedを共有する構造のため、Next.js専用のnext/dynamicではなくフレームワーク非依存のReact.lazyを選択しました。
バンドル後の問題。
バンドルは半分になりましたが、ユーザーの目の前には依然として2~3秒のローディング画面が残っていました。
3DシーンはJSが到着した後もWebGL初期化、環境マップのロード、シェーダーコンパイルを経てからレンダリングされます。バンドルを減らしてもこの過程は短縮されませんでした。
3Dシーンがロードされる前でも惑星がすぐに見えるよう構造を変えました。
- Canvasのみlazy loadで分離し、Containerは即座にレンダリングしてレイアウト空間を確保 → CLS防止
- 感情ごとの静的placeholder画像 21枚(7感情 × 3サイズ) → 3Dロード中でも惑星がすぐに見える
First Load JS -58%
原因がわからないままページを一つずつ調べ、構造を追跡し、バンドルを削減した後もユーザー画面にローディングが残っていればまた掘り下げました。 最適化とは「もう十分だ」と思ったときにもう一度見直すことでした。
Chrome Extensionで3D惑星を表示したいと思いました。
しかしChrome ExtensionはSSRに対応しておらず、Next.jsをそのまま使うことができませんでした。
毎日より素早く感情を記録し確認できるよう、新しいタブを開いたらすぐに感情惑星が表示されるエントリポイントを作りたいと思いました。ウェブとExtensionで同じ体験を提供したかったため、コアUIコンポーネントを両環境で共有する方法を見つける必要がありました。
Next.js(SSR)とVite(Extension)のそれぞれの利点を維持しながら共有が可能なモノレポを選択しました。
pnpm Workspace 3パッケージ構造
Vite library modeでsharedパッケージをビルドし、webとextension両方からimportします。 Chrome Cookies APIでNextAuthセッションクッキーの存在を確認し、NextAuthセッションエンドポイントに検証を委譲することで、ウェブで一度ログインすればExtensionでも自動認証されるようにしました。
シェーダーコードを分析し、バンドルを追跡し、モノレポを設計したのはすべて同じ理由でした。 新しいタブを開いてサービスを使うとき、ユーザーが素早く自然に感情惑星と出会えるように。