[Astro #06] R3Fで構築する3Dサイバーアーカイブの実装録と最適化

はじめに

PROTOCOL.LAINの TOOLS セクションにおいて、過去のYouTube Shortsや動画コンテンツを3D空間上に配置し、スワイプで回転・クリックで実体化するカルーセルUI(ShortsCarousel.tsx)を実装しました。

本記事は、React Three Fiber (R3F) と @react-three/drei を用いた実装の要点と、デバイス(特にMac環境等)のパフォーマンス制約と戦う中で生まれた「妥協と表現のトレードオフ」を、未来の開発環境へ引き継ぐための備忘録です。

前回の記事:

スクリーンショット:

動画(PC):

1. 軌道計算と自律的なステート管理

シーン全体の制御は、親コンポーネントである ShortsCarousel で管理する少数のStateとRefに集約させています。極力再レンダリングを防ぐため、アニメーションに直結する値は useRef で保持し、useFrame ループ内で直接ミューテーションを行っています。

  • activeId: 現在フォーカスされている(中央に引き寄せられた)カードのID
  • flippedId: 裏返してターミナル情報を表示しているカードのID
  • rotationRef / velocityRef: スワイプ(ドラッグ)による全体の回転角度と慣性速度

各 Card コンポーネントは「全体における自身のインデックス」から基本軌道を計算し、「自分が今フォーカスされているか(isFocused)」を自律的に判定します。

// Cardコンポーネント内の軌道計算とLerp処理
useFrame(() => {
  // 楕円軌道の算出 (X軸半径: 7, Z軸半径: 4)
  const angle = (index / total) * Math.PI * 2 + rotationOffsetRef.current
  const orbitX = Math.cos(angle) * radiusA
  const orbitZ = Math.sin(angle) * radiusB

  // ターゲット座標の決定(フォーカス時は手前中央へ)
  const targetX = isFocused ? 0 : orbitX
  const targetY = isFocused ? 0.7 : 0
  const targetZ = isFocused ? 7.1 : orbitZ

  // 座標の補間(Lerp)による滑らかな遷移
  if (isFirstFrame.current) {
    meshRef.current.position.set(targetX, targetY, targetZ)
    isFirstFrame.current = false
  } else {
    meshRef.current.position.x = THREE.MathUtils.lerp(meshRef.current.position.x, targetX, 0.1)
    meshRef.current.position.y = THREE.MathUtils.lerp(meshRef.current.position.y, targetY, 0.1)
    meshRef.current.position.z = THREE.MathUtils.lerp(meshRef.current.position.z, targetZ, 0.1)
  }
})

マウント直後の1フレーム目(isFirstFrame)のみLerpをバイパスすることで、初期描画時に原点からカードが放射状に飛んでくる意図せぬ挙動を防いでいます。

2. イベントハンドリングと慣性スクロール

ユーザーのポインター操作(スワイプ/ドラッグ)は、最上位の <div> で捕捉し、移動量(deltaX)を velocityRef に変換しています。

const onPointerMove = (e) => {
  if (!isDragging.current || activeId) return
  const deltaX = e.clientX - lastX.current
  velocityRef.current = -deltaX * 0.01 // 移動量を速度に変換
  lastX.current = e.clientX
}

R3Fの useFrame 内では、この velocity に対して毎フレーム摩擦(friction = 0.95)を掛け合わせることで、自然な慣性減速を表現します。また、速度が一定以下になった場合は 0.005 という微小な速度へLerpさせ、宇宙空間を漂うような恒久的な自動回転(オートスクロール)へシームレスに移行させています。

3. 【最重要】DOMとWebGLの負荷最適化

今回の実装で最も苦労したのがパフォーマンスの最適化です。@react-three/drei の <Html> は、内部的にCSS3DTransformsを用いてWebGLのカメラ行列とDOM要素を毎フレーム同期させるため、非常にリッチである反面、パフォーマンス上のボトルネックになり得ます。

① iframeの遅延レンダリング(ブラウザプロセス枯渇対策)

3D空間に複数の <Html> コンポーネントを配置し、その中にYouTubeの iframe を直接埋め込むと、Mac環境等でブラウザのプロセス上限に達し、深刻な描画遅延やクラッシュを引き起こします。

【解決策】 非フォーカス状態のカードは iframe をマウントせず、YouTubeの軽量なサムネイル画像(img)に差し替えるフォールバック処理を実装しました。

{/* isFocused が true の時のみ iframe を展開。それ以外は軽量な img で偽装 */}
{isFocused ? (
  <iframe src={`https://www.youtube.com/embed/${data.id}?autoplay=1`} frameBorder="0" style={{ width: '100%', height: '100%' }}></iframe>
) : (
  <div style={{ width: '100%', height: '100%', background: '#000', border: '1px solid #00f2fe' }}>
    <img src={`https://img.youtube.com/vi/${data.id}/hqdefault.jpg`} style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.5 }} />
  </div>
)}

副産物としての演出: 実はこの切り替え時に発生する僅かなロードのラグや白いチラつきは、あえて修正していません。サイバー空間において「遠くの圧縮データが手元に引き寄せられ、解凍・実体化する瞬間のエフェクト」として世界観に合致したため、シームレスにせず「味」として許容しています。

② Canvasのハードウェア負荷軽減

Retinaディスプレイ等の過剰なピクセル計算を防ぐため、Canvasのプロパティを意図的にダウングレードしています。

<Canvas
  dpr={[1, 1.2]} // ピクセル比を制限し、重い環境でのFPS低下を防ぐ
  gl={{ antialias: false }} // アンチエイリアスを切り、サイバーパンク特有のジャギーを立たせる
>

4. 表現としての「データの澱み」と空間演出

床の反射面の実装において、完璧な鏡面反射を求めるとGPU負荷が跳ね上がってしまいます。そこで、MeshReflectorMaterial の解像度を極端に下げ(resolution={128})、強いblurをかけました。

<MeshReflectorMaterial
  blur={[300, 100]} // 強いブラーでディテールを潰す
  resolution={128}  // 低解像度による意図的なバグ的質感
  mixBlur={1}
  mixStrength={40}
  roughness={1}
  depthScale={1.2}
  color="#051015"
  metalness={0.5}
/>

この「正しくない構造(バグっぽい解像度)」が、結果として「重力を持ったデータクラスターが、暗い床に澱むように滲む」という、独自の重厚なアンビエンスを生み出すことに成功しました。これは技術的制約から生まれた、意図的なデジタル・グリッチの表現です。

また、底面に配置した AnimatedGrid は、剰余演算(% step)を用いることで、ポリゴンを無限に生成することなく、一定区間をループし続ける「終わりのないスキャンライン」を軽量に実現しています。

useFrame((state) => {
  const t = state.clock.getElapsedTime()
  const speed = .5
  const step = 2 // 1マスのサイズ単位でループ
  gridRef.current.position.z = (t * speed) % step
})

5. 情報の厚みを作るZ-fighting回避のレイヤー構造

各カードは物理的な BoxGeometry(厚み)を持たせず、複数の planeGeometry と <Html> を微小なZ軸のズレで重ねることで「情報の層(レイヤー)」を表現しています。

  • z: 0.05: 表面の動画 / サムネイル領域 (<Html>)
  • z: 0.00: ワイヤーフレームの装飾ベース (透明度0.2のシアン)
  • z: -0.01: 透過度を持つ黒い背景パネル (ベースの視認性確保)
  • z: -0.05: 裏面のターミナル情報 (<Html rotation={[0, Math.PI, 0]}> で反転配置)

物理的なメッシュの厚みではなく、データの重なりとしての厚みを持たせることで、より「サイバーなアーカイブ」としてのデジタルな質感を強調しつつ、Z-fighting(面が重なってチラつく描画バグ)を回避しています。

まとめ

正しいコードが、常に美しいUIを作るとは限りません。

ブラウザの限界、デバイスの制約、そして不完全な描画。それらを単なる「エラー」として排除するのではなく、WebGL特有の負荷やラグを演出として飲み込むことでしか到達できない「質感」が存在すると、今回の実装を通して強く感じました。

このコンポーネントは、単なる動画カルーセルではなく、試行錯誤の果てに辿り着いた、泥臭くも純度の高い実存的アーカイブです。