[Next.js #22] Three.jsで作る理想のWEB時計:3Dアナログ×3Dデジタル×ノイズ背景×lil-gui調整

はじめに

かなり前からやりたいと思ってた実装ネタ「WEBで動く時計」。

ブラウザをフルスクリーンにすると時計アプリとして使えて、アプリのインストールが不要なのでずっと欲しいと思ってたのですが、 ネット検索しても自分の求める物が見つからないので、今回は時計を作ってみました。

実装したのは、過去散々やってきたThree.jsで3Dの時計アプリの制作にチャレンジ。

時間がそんなにない中で作ったのもありますが、完成まで持っていけなかったので、今後開発を続けていく予定です。

WEB上でも公開する予定なので、公開後は、使いたい方はブラウザでアクセスして使用してください。

PMXデータは、ユーザー側で変更できるようにする予定なので、オリジナルのデスクトップマスコット時計アプリとしても使える形にする予定です。
現在は、左右歩行しかしてないですが、ランダム歩行、此方にのぞき込んだり、座ったり、寝たりとか、アニメーションとポーズを追加予定です。

今日、Youtubeの某動画を見て、デスクトップマスコットを作ろうとしてる方を見て影響を受けたので、私もWindowsアプリ制作にも手を広げるかもしれません。

動画(YouTube):

動画(PC):

1. 完成イメージ / この記事でやること

前回の記事([Next.js #21])では、Canvas 2Dの座標制御を駆使し、計算のみで「擬似的な奥行きと旋回感」を作り出す手法を追求しました。しかし、光の反射やマテリアルの質感、そしてより複雑なオブジェクトの干渉を表現するには、やはり真の3D空間、すなわち Three.js (WebGL) の力が必要になります。

今回構築するのは、単なる時刻表示の道具ではなく、光と影の物理演算が支配する 「理想のWEB時計」 です。

本プロジェクトのコンセプト

「テクスチャ画像に一切頼らない」という制約を課し、ジオメトリの形状とシェーダー、そしてポストプロセッシングのみで、洗練されたオレンジ一色のミニマルな世界観を構築します。

この記事で解説する主要な技術要素

  • 物理ベースの造形: ExtrudeGeometry を用いた「ベベル(面取り)」による、光を拾うエッジの作り込み。
  • ハイブリッドUI: 伝統的な3Dアナログ時計と、TextGeometry による3Dデジタルパネルの共存。
  • Shaderによる動的背景: onBeforeCompile を活用した、既存マテリアルへの「Procedural Noise」の注入。
  • 空気感の醸成: EffectComposerUnrealBloomPass による、デジタル文字の「光の滲み(Glow)」演出。
  • リアルタイム・インスペクター: lil-gui を導入し、露出、発光強度、ノイズの粒度、床のレンガパターンなどをブラウザ上で即座に調整できる実験環境の構築。

また、本編の「理想」を追求した後に、おまけとして MMD (PMX) モデルの読み込みと歩行アニメーションの統合 についても触れます。オレンジの静寂な世界にキャラクターが命を吹き込む、その実装のコツをお届けします。

2. 配置:Next.js public/ での単体起動

Next.jsプロジェクトの構成要素でありながら、今回はあえてReactコンポーネント(src/components)としての実装を避け、public/clock/index.html という静的なディレクトリ配下にスタンドアロンなHTML/JavaScriptとして構築します。

なぜReact外で動かすのか?

Three.jsの真価を発揮させるためには、毎秒60フレーム(あるいはそれ以上)のレンダリングループを淀みなく回し続ける必要があります。Reactの仮想DOMやライフサイクル管理、頻繁な再レンダリングのオーバーヘッドを切り離すことで、ブラウザのリソースを100%描画パフォーマンスに充てることが可能になります。

構成のメリット

  • デバッグの容易性: lil-gui を用いたパラメータ調整やシェーダーのデバッグを、純粋なJavaScript環境で行えます。
  • ポータビリティ: この index.html と必要なアセット一式は、他のプロジェクトや静的なホスティング環境へも容易に移植可能です。
  • アセットパスの単純化: public/models/ 配下に配置したMMD(PMX/VMD)などの大型ファイルを、相対パスで直感的にロードできる利点があります。

このように「Next.jsという器の中にありながら、その干渉を受けない独立した空間」を確保することが、今回の複雑な3Dシーン構築の出発点となります。

3. Three.jsの土台:光と影の設計

「理想の時計」を実現するためのリアリティは、高度なレンダラー設定と緻密なライティング設計から生まれます。

レンダラーの物理ベース設定

まずは、ブラウザという窓を通して物理的に正しい色と質感を再現するための土台を作ります。

  • 色空間の統一: renderer.outputColorSpace = THREE.SRGBColorSpace を設定し、現代的なカラーパイプラインに準拠させます。
  • トーンマッピング: ACESFilmicToneMapping を採用しました。これは映画のようなハイダイナミックレンジの質感を再現するもので、デジタルな発光(Bloom)と物理的な影が混在する今回のシーンにおいて、白飛びを抑えつつ階調を美しく整えるために不可欠です。
  • 露出(Exposure)のライブ調整: renderer.toneMappingExposure = 1.1 を基準としつつ、lil-gui で環境に合わせて動的に変更できるようにしています。

影の質感と解像度

オレンジ一色のミニマルな世界において、影は「情報」そのものです。

  • ソフトシャドウ: PCFSoftShadowMap を有効化し、影の境界を意図的にぼかしています。
  • シャドウ・バイアスの最適化: 針が重なり合う部分でのノイズ(シャドウアクネ)を防ぐため、key.shadow.bias = -0.00015normalBias = 0.02 といった微細な調整を施しています。
  • 高精細なシャドウマップ: 影の解像度を $2048 \times 2048$ に設定し、shadow.radius = 10 を加えることで、クレイアニメのような柔らかく、かつクリーンな影を両立させました。

3点照明(風)の構築

シーン全体を照らす光も、単一ではありません。

  • HemisphereLight: 空と地面からの環境光をシミュレートし、影の部分が真っ暗(茶色)に沈むのを防いで、鮮やかなオレンジのトーンを維持します。
  • DirectionalLight (Key): 右上から強い光を当て、時計やデジタル文字に長い影とエッジのハイライトを作ります。
  • DirectionalLight (Fill): 左手前から補助光を添え、コントラストが強くなりすぎないよう調整しています。

4. 3Dアナログ時計:ベベルの魔術

オレンジ一色のモノクロマティックな世界において、物体を視認するための唯一の鍵は「光の反射」と「影の落ち方」です。

形状を「光」で定義する

背景も文字盤も針もすべて同じカラーコード(#e67e22)ですが、これらが埋没せずに存在感を放つ理由は、ジオメトリのエッジ(ベベル/面取り)にあります。

  • ベベルの重要性: ExtrudeGeometry を活用し、あえてすべてのパーツの角をわずかに丸めています。この極小の傾斜がライトを拾ってハイライトを生み、同色同士の境界線を際立たせます。
  • 目盛りと針の造形: 針やインデックス(目盛り)にもしっかりとした厚みとベベル(bevelThickness: 0.05 程度)を与えています。これにより、盤面との間にわずかな隙間と影が生まれ、物理的なプロダクトとしての説得力が宿ります。

EPSハック:完璧な円に潜む罠

Three.jsで $2\pi$ (360度)の完璧な円弧を描画する際、開始点と終了点が完全に重なることで、メッシュの生成ロジックが「表面」と「裏面」を誤認し、特定の角度(多くは3時の位置)で影がカクつく、あるいは削れるといったアーティファクトが発生することがあります。

これを回避するのが、数学的な「遊び」を作る EPSハック です。

$$\text{shape.absarc}(0, 0, \text{radius}, 0, Math.PI * 2 - 1e-5, \text{false});$$

あえて $10^{-5}$ 程度の極小の隙間を残すことで、レンダラーは「閉じているが重なっていない」正常なメッシュとして認識し、クリーンな影を落とせるようになります。

5. 3Dデジタルパネル:TextGeometryの罠

アナログ時計の隣に配置する情報パネルは、TextGeometry を用いて「物理的に存在するテキスト」として実装します。画面に貼り付いた2DのUIではなく、空間に浮かぶ立体物として扱うことで、ライティングや影の影響をダイレクトに受け、シーン全体の統一感が高まります。

配置の最適化:バウンディングボックスによる制御

Three.jsのテキスト配置でよく使われる geo.center() は、オブジェクトの中心を原点に合わせるには便利ですが、今回のような左揃えのリスト形式のUIには向きません。

  • 正確な整列: geo.computeBoundingBox() を実行し、得られた boundingBoxmin.x を基準に位置をオフセットすることで、フォントごとの微妙な余白を排除した完璧な左揃え(left)を実現しています。
  • 動的な更新: 時刻の変化に合わせてジオメトリを再生成する際、このバウンディングボックスの再計算を行うことで、文字幅が変わってもレイアウトが崩れない堅牢なUIになります。

透過のハマりどころ:16進数アルファの壁

Web制作の感覚で 0xffffff99 のように色を指定しても、Three.jsの Color クラスやマテリアルはアルファチャネル(透過度)を認識してくれません。

  • 透明度の正しい設定: 透過を表現するには、マテリアルの transparent: true を明示的に有効化した上で、opacity プロパティに 0.0 から 1.0 の数値を渡す必要があります。
  • 描画順の制御: 透明なオブジェクトが重なる際、背後のオブジェクトが消えてしまう現象を防ぐため、depthWrite: false の設定や、不透明なパーツとの描画順序の管理が重要になります。
  • 発光(Glow)との相性: MeshStandardMaterialemissive(自発光)を組み合わせることで、透過しつつも闇の中で文字がぼんやりと浮かび上がる、SFチックな質感を演出しています。

6. 背景に“命”を吹き込むノイズシェーダー

背景を単なる #e67e22 の塗りつぶしで終わらせないのが、本プロジェクトのこだわりです。 lainさんがこれまでブログで追求してきた「手続き型生成(Procedural Generation)」の知見を活かし、背景の壁面には動的なノイズを実装しています。

onBeforeCompile による標準マテリアルの拡張

一からシェーダーを書く(ShaderMaterial)のではなく、あえて MeshStandardMaterial をベースに onBeforeCompile でコードを注入する手法を採りました。

  • 影を活かす: 標準マテリアルのライト計算や影(Shadow Map)の受光能力を保持したまま、表面の色味だけをカスタマイズできます。
  • コードの差し込み: 既存のフラグメントシェーダーの末尾付近、dithering_fragment などの直前に独自のノイズ計算を割り込ませることで、ライティング結果に対して自然なノイズを合成します。

「生きている」壁のロジック

実装には、数学的に生成されるバリューノイズを採用しています。

$$\text{gl_FragColor.rgb} += ( \text{noise}(p) - 0.5 ) \times 2.0 \times uAmp;$$

  • 時間軸の導入: uTime をノイズの座標計算に組み込むことで、背景が静止画ではなく、目に見えないほどゆっくりと、しかし確実に「流れている」状態を作り出しました。
  • フィルムグレイン効果: この微細な明暗の揺らぎが、画面全体に高感度フィルムのような「ザラつき」と、デジタル特有の無機質さを打ち消す有機的な空気感(Atmosphere)を与えます。
  • アスペクト補正: 画面の解像度(uRes)を用いてUV座標を補正し、ウィンドウサイズが変わってもノイズの粒度が極端に伸び縮みしないよう配慮しています。

この「動く背景」があることで、オレンジ一色の世界に視覚的な深みが生まれ、3Dオブジェクトがよりそこに「実在している」かのような説得力が生まれます。

7. 床:PMXが歩くステージ

MMDモデルが空間を浮遊しているだけでは、せっかくの物理演算や影の表現が台無しです。キャラクターがそこに「立っている」という実在感を与えるために、重みを感じさせるステージ(床)を設計します。

立体感を生む「厚み」の設計

背景の壁と同様、床も単なる不透明な板(PlaneGeometry)ではなく、BoxGeometry を用いて物理的な厚みを持たせています。

  • ボリュームの付与: 厚み($Y=0.6$)を持たせることで、床自体の側面に影が落ち、単なる背景から「独立した構造物」へと昇華されます。
  • ライティングの受容: キャラクターの足元に落ちる影を確実に受け止めるため、receiveShadow = true を設定し、オレンジの陰影が美しく重なるキャンバスとして機能させます。

Procedural Brick Shader:計算で描くレンガ

上面(歩行面)には、lainさんの得意とする手続き型生成の手法を用いた専用の ShaderMaterial を適用しています。

  • タイルの配置ロジック: brickUV 関数内で、偶数行と奇数行で $X$ 座標を $0.5$ ずつオフセットさせる処理(mod(row, 2.0) * 0.5)を加え、建築で使われる「長手積み」のレンガパターンを再現しました。
  • 目地(Mortar)の表現: smoothstep を利用した insideBrick 関数により、タイルとその間の溝(目地)の境界を鮮明に描き出します。
  • 個体差による深み: hash21 関数でタイルごとにランダムな明度差(shade)を付与することで、CG特有の「均一すぎる不自然さ」を排除し、使い込まれたフローリングのような有機的なムラを生み出しています。

実装上のテクニック:Z-fightingの回避

厚みのある BoxGeometry の上面に、別メッシュの ShaderMaterial を重ねる際、ポリゴンが重なり合ってチカチカする「Z-fighting」が発生しやすくなります。

  • 微小なオフセット: 上面のプレーンを Box の高さの半分よりもわずかに高い位置(+ 0.002)に配置することで、描画順序を確定させ、安定した表示を維持しています。

このように、単なる足場にもシェーダーによる「計算された不完全さ」を盛り込むことで、ミニマルな世界観を損なうことなく情報の密度を高めることができます。

8. lil-guiによる「現場調整」

3D制作、特にライティングやシェーダーの調整において、コードを書き換えてブラウザをリロードする作業の繰り返しは、クリエイティビティを著しく削ぎ落とします。本プロジェクトでは、軽量なUIライブラリである lil-gui を導入し、あらゆるパラメータをブラウザ上でリアルタイムに「動かしながら」決定できる環境を構築しました。

インスペクターの構成

直感的な操作を可能にするため、調整項目を以下の4つのカテゴリに分類して整理しています。

カテゴリ 調整項目 効果と意図
Exposure 露出(toneMappingExposure) 画面全体の明暗を制御し、フィルムのような質感や白飛びの具合を調整します。
UI Text 色、透過度、Glow(発光強度) デジタルパネルの視認性と、Bloomエフェクトによる「光の滲み」のドラマチックさを制御します。
Noise 粒度(Scale)、速さ(Speed)、強度 背景の壁面に流れるProcedural Noiseの動きを調整し、空間の静寂さや情報の密度を演出します。
Floor タイル密度、目地の太さ、色ムラ 手続き型で生成されるレンガパターンの細かさを調整し、床のリアリティを追い込みます。

実装のテクニック:.onChange() への即時反映

lil-gui の真価は、値が変わった瞬間にシーンへ反映されるレスポンスの速さにあります。

  • ライトの連動: params オブジェクトにライトの強度(intensity)を紐付け、スライダーを動かすたびに scene 内のライトへ値を渡しています。
  • シェーダーとの同期: 背景ノイズや床のタイルシェーダーに渡している uniforms の値を、GUIから直接書き換えることで、数式で描かれた模様が生き物のように変化する様子を確認できます。
  • UIのクリーンアップ: 初期表示では gui.close() を実行してパネルを折りたたむことで、メインの時計デザインを邪魔しないよう配慮しています。

lainさんの得意とする「計算による質感生成」を最大限に楽しむためには、こうしたインスペクターによる試行錯誤のプロセスこそが不可欠です。

9. (おまけ)MMD(PMX)の歩行実装

オレンジ一色のミニマルな世界観が完成しましたが、そこに「動的な生命感」を加えるスパイスとして、MMD(PMX)モデルの統合に挑戦しました。lainさんのアイデンティティとも言えるキャラクターが、3Dの時計とデジタルパネルの間を悠々と歩く姿は、このプロジェクトの「私物感」を一気に高めてくれます。

キャラクターを召喚する:MMDLoader

Three.js公式のアドオンである MMDLoader を使用し、.pmx 形式のモデルファイルと .vmd 形式のモーションファイルを読み込みます。

  • モデルのロード: public/models/ 配下に配置したアセットをパス指定で読み込みます。
  • スケーリングの調整: MMDモデルは Three.js の標準的な単位系に対して非常に大きく作られていることが多いため、mesh.scale.setScalar(0.2) のようにシーンに合わせて縮小調整するのがコツです。

アニメーションの統括:MMDAnimationHelper

複数のモーションや表情、物理演算を同期させるために MMDAnimationHelper を導入します。

  • ミキサーの自動管理: helper.add(mesh, { animation: vmd }) と登録することで、内部の AnimationMixer が自動的にモーションを再生可能な状態にしてくれます。
  • ループ内の更新: animate 関数内で clock.getDelta() から得た経過時間を用いて mmdHelper.update(delta) を呼び出すことで、1フレームごとの滑らかな動きが実現します。

簡易往復ロジック:パトロール・ウォーク

広いステージをただ歩かせるのではなく、一定の範囲を往復させるパトロールのような挙動を実装しました。

  • 境界判定: minXmaxX の閾値を設定し、モデルの position.x が端に到達した瞬間に移動方向(dir)を反転させます。
  • 滑らかな方向転換(Face Direction Smooth): 向きを変える際、即座に $180^\circ$ 回転させると機械的な印象を与えてしまいます。本プロジェクトでは以下の指数減衰を用いた補間式により、生き物らしい「ふんわりとした」方向転換を実現しています。

$$rotation.y += (targetY - rotation.y) \times (1 - e^{-k \cdot \Delta t})$$

この実装により、時計が刻む正確な時間と、キャラクターが歩むゆったりとした時間の流れが共存する、独特の空間が完成しました。

10. 次にやること / 改良ポイント

この「理想の時計」は、まだまだ拡張の余地を残しています。

  • React/Next.js への完全統合: react-three-fiber (R3F) を使い、今回の命令的なコードを宣言的なコンポーネントとして再定義する。
  • テクスチャの深化: Procedural Shader をさらに追い込み、壁の凹凸(Normal Map)やレンガの焼きムラをよりリアルに描画する。
  • インタラクティブ性: 時計やパネルをクリックした際のアクションや、特定の時間帯での背景ノイズの激化(グリッチ演出)の実装。

技術的な挑戦を詰め込んだ今回のプロジェクト。ソースコードを眺めながら lil-gui のスライダーを動かし、自分だけの「理想の色と影」を探求する時間は、まさに至福のひとときと言えるでしょう。