Zero Gravity フロントエンド

感情を3D惑星で可視化するウェルネスWebプラットフォーム。シェーダー簡素化とLODで29fps → 61fps、バンドル最適化でFirst Load JS 58%削減。

RoleFrontend Developer
Timeline2025.09 - Present
Team個人開発
SkillsNext.js, Three.js, GLSL, TypeScript
概要

一言で

感情を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惑星、時計、テーマを共有する構造を設計しました。
3Dレンダリング最適化

感情惑星がサービスより重かったです。

JS ダウンロード3D 初期ロード✨ リアルタイムレンダリング

r3f-perfで計測した結果、三角形408,040個、フレームレート29fpsでした。

Zero Gravityは新しいタブを開くたびに感情惑星が迎えるサービスです。毎日開く画面が重いということは、サービスの第一印象が重いということでした。

事前に焼いておけばいいのでは?

毎フレームnoiseを計算する代わりに、事前に計算した値をテクスチャに保存して取り出せば? VAT(Vertex Animation Texture)を試みました。

問題説明
保存容量の過大102,000個のvertex × Nフレームをテクスチャに保存する必要があり、容量が膨大すぎた
ループ不可時間とともに変化し続けるノイズのため、同じパターンが繰り返されない

VATは失敗しましたが、テストを通じて既存シェーダーの演算量が過剰であることがわかりました。「この演算は本当に必要か?」 という根本的な問いに転換しました。

不要な演算を発見しました。

シェーダーの演算量を数値で確認しました。

もともと表面が有機的にうねる球体のために設計されたシェーダーでした。表面を変形するには:

  1. 各頂点の位置をnoise(自然なパターンを生成する数学関数)で揺らす
  2. noise内部でより動的なパターンを作るために4D noiseを2回計算する
  3. 位置が変わるとライティング計算のための法線も再計算が必要なため、隣接する頂点2つも一緒に計算 = 3回呼び出し

4D noise 2回 × 3回呼び出し × 102,000個の頂点 = フレームあたり約61万回のnoise呼び出し

感情の強度を惑星表面のうねりで表現しようとしましたが、惑星の形を見ると木星や海王星のように表面を流れる色彩パターンが核心でした。表面変形がなくても感情は十分に伝わっていました。

不要な演算を除去し、色彩アニメーションだけを残しました。

// Before — 頂点ごとにnoise 6回、フレームあたり約61万回
float getWobble(vec3 position) {
  warpedPosition += simplexNoise4d(...)  // ← 4D noise 1回
  return simplexNoise4d(...)             // ← 4D noise 2回
}                                        // = getWobble 1回あたりnoise 2回
 
void main() {
  float wobble = getWobble(csm_Position);          // ← noise 2回 (自身)
  positionA += getWobble(positionA) * normal;       // ← noise 2回 (隣接A)
  positionB += getWobble(positionB) * normal;       // ← noise 2回 (隣接B)
  csm_Normal = normalize(cross(toA, toB));          // ← 法線再計算
}                                                   // 合計: 頂点ごとにnoise 6回
// After — 頂点ごとにnoise 2回、フレームあたり約20万回
void main() {
  float noise = getWobble(csm_Position);  // ← noise 2回 (1回だけ呼び出し)
  vWobble = noise;
}                                         // 合計: 頂点ごとにnoise 2回

per-vertexを減らした次は、vertex数。

頂点ごとのnoiseを減らしましたが、頂点が102,000個であれば依然としてフレームあたり約20万回のnoise呼び出しです。

IcosahedronGeometryの三角形数はsubdivisionの二乗に比例します。

subdivision 100 → 三角形 408,040個

メインページで惑星が画面いっぱいに拡大されるため、subdivisionが低いと表面がカクカクに見えました。クオリティのために100まで上げましたが、パフォーマンスとのtrade-offを考慮できていませんでした。

視覚的品質が維持される最小subdivisionを探し、LOD(Level of Detail)を適用しました。小さく表示される惑星はさらに下げても目立たず、モバイルは画面自体が小さいのでさらに削減できました。

BeforeAfter (Desktop)After (Mobile)
Large (メインページ)subdiv 100 / 408ksubdiv 48 / 48k (-88%)subdiv 32 / 22k (-95%)
Normal (record等)subdiv 50 / 104ksubdiv 32 / 22k (-79%)subdiv 28 / 17k (-84%)

* 408,040はshadow pass込みの数値です。subdivision 100の実際の三角形は約204,000個ですが、shadow passが同じgeometryをもう一度描画するため2倍になっていました。底のない宇宙空間に不要な影だったため除去しました。

4D noise 2回 × 1回呼び出し × 24,000個の頂点 = フレームあたり約4.8万回のnoise呼び出し

29fps → 61fps

最適化前のパフォーマンス
Before — 408,040 triangles, 29fps
最適化後のパフォーマンス
After — 48,020 triangles, 61fps
最適化BeforeAfter増減
noise呼び出し頂点ごとに6回頂点ごとに2回-67%
三角形数408,04048,020-88%
noise総呼び出しフレームあたり~61万回フレームあたり~4.8万回-92%
FPS2961+2倍

フレームあたり61万回のnoise呼び出しを4.8万回に削減したのは、新しい技術を導入したのではなく、不要な演算を見つけて除去した結果でした。 最適化に正解はありませんが、「本当に必要か?」と問うことが最も効果的な出発点でした。

バンドル最適化

チャートページにThree.jsがなぜあるのか?

✨ JS ダウンロード3D 初期ロードリアルタイムレンダリング

原因は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/index.ts (barrel)
├── export * from './constants'  → EMOTION_STEPS (定数)
├── export * from './scene'      → EmotionPlanetScene (Three.js 712KB)
└── export * from './decorations'

チャートページはグラフの色マッピング(EMOTION_COLORS_MAP)とラベル表示(EMOTION_TYPES)など純粋な定数のみをimportしていましたが、同じbarrelに3DコンポーネントがまとめられていたためThree.js 712KBが丸ごと引きずり込まれていました。

バンドルアナライザー Before
3Dを使用しないチャートページにもThree.js 712KBが含まれている様子

712KBを切り離した方法。

感情関連の定数と型(EMOTION_STEPSEMOTION_COLORSEMOTION_TYPESなど)はプロジェクト全体のドメインデータハブでした。スライダー、フォーム、カレンダー、チャート、Chrome Extensionまで約28ファイルがこのデータをimportしていました。

問題は、このドメインデータがモノレポのsharedパッケージで3D惑星コンポーネントと同じbarrelにまとめられていたことです。モノレポでsharedパッケージはpre-built状態で消費されるため、barrelの単位がそのままchunkの単位でありバンドル最適化の限界となります。

方法利点欠点
定数のみ別ファイルに分離最小限の変更構造的解決ではなく、同じ問題の繰り返し
Entity/Component分離関心の分離、再利用性importパス変更が必要
Barrel完全削除完璧なtree-shakingimportパスが長くなりDX悪化

依存性の重さを基準にbarrelを分割することが構造的な解決でした。感情の「何」(名前、色、値)「どう見せるか」(3Dメッシュ、シェーダー)を分離しました。

EntityComponent
本質ドメインデータUIレンダリング
依存性ゼロ (純粋なJSオブジェクト)Three.js, R3F, GLSL (712KB)
使用箇所約28ファイル (スライダー、フォーム、カレンダー、チャート、Extension)3D惑星をレンダリングする箇所のみ
変更頻度感情タイプの追加・修正時3Dエフェクトのチューニング時
SSR可能不可 (use client + WebGL)

Three.jsコンポーネントはReact.lazyで遅延ロードしました。モノレポでwebとChrome Extensionがsharedを共有する構造のため、Next.js専用のnext/dynamicではなくフレームワーク非依存のReact.lazyを選択しました。

バンドル後の問題。

JS ダウンロード✨ 3D 初期ロードリアルタイムレンダリング

バンドルは半分になりましたが、ユーザーの目の前には依然として2~3秒のローディング画面が残っていました。

3DシーンはJSが到着した後もWebGL初期化、環境マップのロード、シェーダーコンパイルを経てからレンダリングされます。バンドルを減らしてもこの過程は短縮されませんでした。

3Dシーンがロードされる前でも惑星がすぐに見えるよう構造を変えました。

  • Canvasのみlazy loadで分離し、Containerは即座にレンダリングしてレイアウト空間を確保 → CLS防止
  • 感情ごとの静的placeholder画像 21枚(7感情 × 3サイズ) → 3Dロード中でも惑星がすぐに見える

First Load JS -58%

ページBeforeAfter増減
/ (ホーム)447 KB187 KB-58%
/record/daily514 KB254 KB-51%
/record/moment514 KB254 KB-51%
/profile/calendar514 KB256 KB-50%
/profile/chart330 KB250 KB-24%

原因がわからないままページを一つずつ調べ、構造を追跡し、バンドルを削減した後もユーザー画面にローディングが残っていればまた掘り下げました。 最適化とは「もう十分だ」と思ったときにもう一度見直すことでした。

モノレポ設計

Chrome Extensionで3D惑星を表示したいと思いました。

しかしChrome ExtensionはSSRに対応しておらず、Next.jsをそのまま使うことができませんでした。

毎日より素早く感情を記録し確認できるよう、新しいタブを開いたらすぐに感情惑星が表示されるエントリポイントを作りたいと思いました。ウェブとExtensionで同じ体験を提供したかったため、コアUIコンポーネントを両環境で共有する方法を見つける必要がありました。

方法利点欠点
すべてViteに移行単一ビルドツールNext.jsの利点を放棄、既存コード全面書き直し
別リポ + コードコピー独立した管理同期不可、コード重複
モノレポ + 共有パッケージ各環境の最適ビルド維持 + 共有初期設定の複雑さ

Next.js(SSR)とVite(Extension)のそれぞれの利点を維持しながら共有が可能なモノレポを選択しました。

pnpm Workspace 3パッケージ構造

zerogravity-react/
├── packages/
│   ├── web/          # Next.js 15 — SSR、認証、全機能
│   ├── extension/    # Chrome Extension — Vite, Manifest V3
│   └── shared/       # 共有ライブラリ — 3D惑星、時計、テーマ
│       ├── entities/               # ドメイン定数・型
│       ├── components/ui/emotion/  # 3D Planet + GLSL
│       ├── hooks/                  # 共有フック
│       └── utils/                  # ユーティリティ

Vite library modeでsharedパッケージをビルドし、webとextension両方からimportします。 Chrome Cookies APIでNextAuthセッションクッキーの存在を確認し、NextAuthセッションエンドポイントに検証を委譲することで、ウェブで一度ログインすればExtensionでも自動認証されるようにしました。

シェーダーコードを分析し、バンドルを追跡し、モノレポを設計したのはすべて同じ理由でした。 新しいタブを開いてサービスを使うとき、ユーザーが素早く自然に感情惑星と出会えるように。

arrow_downwardさらに詳しく見るarrow_downward