[Noise 入門 #31] 第4集開幕 — Three.js × GLSLで「触れるノイズ」を実装する

はじめに

これまでの連載で、私たちはFBMやDomain Warpingを駆使し、GPU上で壮大な自然現象や宇宙を「描画」してきました。しかし、それはまだガラス越しの景色に過ぎません。

第4集「Three.js編」では、このノイズの世界を3D空間に解き放ちます。第31回となる今回は、Three.jsのRaycasterを用いてユーザーの「マウスの動き(触覚)」をShaderに伝達し、ノイズの海に波紋や歪みを生み出すインタラクションの基礎を深掘りします。

前回の記事:

1. 鑑賞から「体験」へのシフト — “観測者”から“神”への昇格

前回の第30回で、私たちは数式だけを用いて圧倒的なスケールの「プロシージャルな宇宙」を錬成しました。しかし、それはどこまでいってもガラス越しに眺めるだけの、完成された映像作品(鑑賞物)でした。

第3集までの純粋なGLSL(あるいはShadertoy的なアプローチ)の世界では、GPUの中で時間が流れ、ノイズがうねり続けるだけです。そこにユーザーが介入する余地はありませんでした。

しかし、第4集「Three.js編」からは次元が変わります。Three.jsという強力なJavaScriptライブラリを「現実世界(ユーザー)とGPUの架け橋」として使うことで、数式が描く世界に直接触れ、干渉し、変化を与えることができるようになります。

観測者から、世界に干渉する存在へのシフト。これが「体験」を生み出すコアです。

CPU(現実)から GPU(数式)へデータを送り込む

GPUは超並列計算のバケモノですが、実は「今、ユーザーがマウスをどこに動かしているか」や「画面がどれくらいスクロールされたか」を自分から知る術を持っていません。GPUは完全に隔離された部屋で計算だけをしている職人のようなものです。

そこで、CPU側(JavaScript / Three.js)から、毎フレームごとに現実世界の最新の入力データを小包のように包んで、GPUの部屋(Shader)へ投げ込んでやる必要があります。この小包の受け渡し口が Uniform変数 です。

Three.jsを介することで、以下のような動的なデータをリアルタイムにShader(Uniforms)へ送り込むことができます。

  • ユーザーのマウスクリック・ホバー位置: カーソルを近づけると水面が波立ち、クリックすると炎が爆発するような直接的な干渉。
  • 空間のカメラ座標・角度: ユーザーが3D空間を歩き回ることで、視点に合わせてノイズのサンプリング位置を変え、無限に続く地形(Infinite Terrain)を生成する。
  • スクロール量やデバイスの傾き: Webサイトのスクロールに合わせて空間がねじ曲がったり、スマホのジャイロセンサー(傾き)に応じてノイズの重力方向が変わったりする没入感の演出。

今回の実装:空間を歪める「触覚」の付与

様々なインタラクションの中でも、最も直感的で、ユーザーに「この世界は生きている」と錯覚させやすいのが「マウスホバーによる局所的なノイズの歪曲」です。

今回は、静かにうねるノイズのキャンバスに対して、ユーザーのマウスカーソルが近づいた部分だけが、まるで水面を指でかき混ぜたかのようにグニャリと歪む(Domain Warpingが局所的に強くかかる)表現を実装します。

ただの「絵」が、触れる「インターフェース」へと進化する第一歩。ここから、いよいよThree.jsとGLSLの本当の連携が始まります。

2. アーキテクチャの全体像(JSからGPUへの伝達)

現実世界のマウスの動きを、GPUの奥深くで計算されているノイズの数式に届ける。この「次元を跨ぐデータ転送」を実現するのが、Three.jsが誇る強力なツール Raycaster(レイキャスター) です。

JavaScriptからGLSLへ「今、どこに触れているか」を正確に教えるには、以下の3つのステップを踏む必要があります。

  1. Raycasterの照射: カメラのレンズから、画面上のマウスポインタの方向に向かって、目に見えない光線(Ray)を撃ち出す。
  2. 交差点の取得: その光線が、ノイズを描画している3Dオブジェクト(PlaneやSphereなど)の表面を貫いた瞬間を検知し、その「交差点」の座標を取得する。
  3. Uniformsの更新: 取得した交差点の座標データを uMouse という変数に詰め込み、毎フレーム ShaderMaterial へ送り込み続ける。

Three.js側の実装要点とコードの解剖

まずは、JavaScript側のセットアップです。Raycasterを用いて取得した交差点のUV座標を、Shaderへ渡すための土台を作ります。

// Uniformsの定義(GPUへの窓口)
const uniforms = {
  uTime: { value: 0.0 },
  uMouse: { value: new THREE.Vector2(0.5, 0.5) }, // 初期位置はUV空間の中央
  uHoverState: { value: 0.0 } // 触れているかどうかの判定用(0.0 or 1.0)
};

// マウス移動時の処理(Raycasterの実行)
window.addEventListener('mousemove', (event) => {
  // ① マウスのピクセル座標を WebGLが理解できる -1.0 〜 1.0 の正規化デバイス座標(NDC)に変換
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // ② カメラからマウス位置へ向かってRay(光線)をセット
  raycaster.setFromCamera(mouse, camera);

  // ③ Mesh(ノイズを描画しているオブジェクト)との交差判定
  const intersects = raycaster.intersectObject(mesh);

  if (intersects.length > 0) {
    // ④ 交差した点の「UV座標」をShaderの uMouse にコピーして送る
    uniforms.uMouse.value.copy(intersects[0].uv);
    uniforms.uHoverState.value = 1.0; // 「今触れているぞ!」というフラグを立てる
  } else {
    // ⑤ ターゲットからカーソルが外れたらフラグを降ろす
    uniforms.uHoverState.value = 0.0;
  }
});

なぜ「3D座標(point)」ではなく「UV座標(uv)」を送るのか?

ここがShader連携の非常に重要なポイントです。

Raycasterの intersects[0] には、3D空間上の絶対座標である point(x, y, z)も含まれています。しかし、今回 Shader に送っているのは uv(u, v)です。

なぜなら、フラグメントシェーダーでノイズを計算する際の基準キャンバスは、画面上の絶対位置ではなく「UV空間(0.0 〜 1.0 のローカル座標)」だからです。

UV座標を直接渡すことで、「Meshが空間のどこに移動しても」「どんな角度に回転していても」、Shader側は「自分の表面のどの部分が触られたのか」を完璧に把握し、ズレることなくノイズを歪めることができます。

さらなる表現のためのプロのひと手間(イージング)

上記の基本的なコードでも動きますが、uHoverState1.00.0 で瞬時に切り替わると、ノイズの歪みが「パッ」と突然現れたり消えたりして不自然に見えることがあります。

これを防ぐため、実際の開発現場では requestAnimationFrame の描画ループ内で 線形補間(Lerp) を使い、uHoverStateuMouse の値を毎フレーム少しずつ目標値に近づける(イージングをかける)のが一般的です。これにより、マウスを乗せたときに「じわっ」と歪みが広がり、離したときに「すーっ」と余韻を残して消えていく、極上の手触りが生まれます。

3. Shader(GLSL)でのインタラクション実装 — 数式に「触覚」を組み込む

JavaScript(CPU側)から送られてきた uMouse という「現実世界からの介入データ」を、いよいよフラグメントシェーダー(GPU側)で受け取ります。

ここでの目標は、送られてきたマウス座標の周囲だけで、第6回で学んだDomain Warping(空間のねじれ)が強くなるような数式を組むことです。画面全体ではなく、「触れている部分だけ」が歪むことで、初めてそこにインタラクション(手触り)が生まれます。

距離による影響度の計算(Math in Shader)

特定の部分にだけエフェクトをかけるためには、「マウスから現在のピクセル(UV座標)までの距離」を測り、近づくほど影響力が強くなる「重み(Weight)」を作る必要があります。

数学的な線形の減衰関数(近づくほど1に、離れるほど0になる)は、一般的に以下の式で表されます。

$w = \max(0.0, 1.0 - \frac{d}{r})$

($d$ はマウスからの距離、$r$ は影響半径、$w$ は重み)

しかし、GLSLにはこの計算を圧倒的に美しく、かつ高速に処理してくれる魔法の組み込み関数があります。それが smoothstep です。

これを踏まえて、実際のGLSLコードを見てみましょう。

uniform float uTime;
uniform vec2 uMouse;
uniform float uHoverState; // 0.0 なら非アクティブ、1.0 ならアクティブ

varying vec2 vUv;

// ※ここにFBMなどのノイズ関数(fbm)が定義されている前提

void main() {
  vec2 uv = vUv;

  // 1. マウスとの距離を計算
  // 現在処理しているピクセルのUV座標と、マウスのUV座標の距離を測る
  float dist = distance(uv, uMouse);

  // 2. 影響範囲を計算(smoothstepで滑らかに減衰)
  float radius = 0.2; // マウスが影響を与える半径

  // dist が radius(0.2) から 0.0 に近づくにつれて、0.0 から 1.0 へ滑らかに補間される
  float influence = smoothstep(radius, 0.0, dist) * uHoverState;

  // 3. 局所的な空間歪曲(Domain Warping)の生成
  // まず、時間でうねる2次元の歪みベクトル(warp)を作る
  vec2 warp = vec2(
    fbm(uv + uTime * 0.5),
    fbm(uv - uTime * 0.5 + vec2(1.2, 3.4)) // ずらしてY軸用のノイズを作る
  );

  // 元のUV座標に、マウス影響度(influence)を掛けた歪み成分を足す
  // ここが核心!マウスが遠い(influence=0)なら、uv はそのまま。
  vec2 distortedUv = uv + warp * influence * 0.5;

  // 4. 歪んだUVを使って最終的なノイズ(形状)をサンプリング
  float finalNoise = fbm(distortedUv * 5.0);

  // 描画(青白い魔法のような色をベースにする)
  vec3 baseColor = vec3(0.1, 0.3, 0.8); // 深い青
  vec3 glowColor = vec3(0.4, 0.8, 1.0); // 発光する水色

  // finalNoise の値(0.0〜1.0)に応じて、2つの色をブレンド
  vec3 color = mix(baseColor, glowColor, finalNoise);

  gl_FragColor = vec4(color, 1.0);
}

コードの解剖と「魔法」のタネ明かし

この短いコードの中で、非常に高度な視覚操作が行われています。重要なポイントを分解してみましょう。

  • 滑らかなブラシ効果(smoothstep): smoothstep(radius, 0.0, dist) の部分が秀逸です。単なる直線的な減衰ではなく、エルミート補間によるS字カーブを描くため、歪みのフチが「パキッ」と割れることなく、エアブラシで吹いたような極めて自然で滑らかな境界線を作ってくれます。
  • 空間への局所的な介入(warp * influence): ここがこのShaderの心臓部です。warp は画面全体で計算されていますが、そこに influence(マウス周辺だけ 1.0、他は 0.0 になる係数)を掛け合わせています。結果として、カーソル付近のUV座標だけが FBM の波に引きずられてズレる現象が起きます。
  • 「0.5」という強度係数の意味: influence * 0.50.5 は、歪みの強さ(Strength)を決めるパラメータです。これを 2.0 のように大きくすると、空間が激しく引き裂かれるような表現になり、逆に 0.1 程度にすれば、水面にそっと息を吹きかけたような微かな揺らぎになります。

こうして作られた distortedUv を使って、最終的な finalNoise をサンプリング(取得)します。

画面全体としては静かに色が混ざり合っているだけなのに、マウスを乗せた部分のキャンバス(UV)だけがぐにゃりと伸び縮みするため、結果としてノイズの模様が吸い寄せられたり、弾かれたりしているように見えるのです。

4. この実装がもたらす「視覚体験」 — 数式が“生きた水面”に変わる瞬間

このコードをブラウザで実行した瞬間、画面には普段通り、静かにうねる青いノイズの海が広がります。しかし、そこにマウスカーソルを滑り込ませた途端、世界は一変します。

カーソルの周囲だけ空間がグニャリとねじ曲がり、まるで指先で粘度のある水面をかき混ぜているかのような、生々しいインタラクションが生まれるのです。数式と時間が作り出していた規則的な波が、あなたの手の動きによってかき乱され、また元の波紋へと戻っていく。これは単なる映像ではなく、ユーザーとノイズが対話する「体験」そのものです。

このアプローチは、今後のThree.js表現において2つの巨大な可能性を秘めています。

  • Vertex Shaderへの応用(形状への直接干渉): 今回はフラグメントシェーダー(ピクセルの色とUV)に対して歪みを加えましたが、全く同じロジックを頂点シェーダー(Vertex Shader)に適用することも可能です。そうすれば、カーソルを近づけた部分だけ3Dモデルの表面がボコッと隆起するインタラクティブな地形(Displacement)を作ったり、空間を漂う無数のパーティクルがマウスから逃げ出すように道をあける表現へと直結します。
  • 物理シミュレーションを凌駕する「圧倒的な軽さ」: 水面をかき混ぜる、あるいは煙をかき分けるような表現を行う際、本格的な流体シミュレーション(Fluid Simulation)を用いると計算負荷が跳ね上がります。しかし、今回の手法は「FBMと距離関数(smoothstep)を掛け合わせるだけ」という非常にシンプルな計算で成り立っています。物理的に厳密な流体力学を解かずとも、手続き型ノイズの力だけで「触れた感覚」を非常に軽量かつ美しく捏造できること。これこそが、リアルタイムのWeb表現におけるノイズ最大の強みです。

5. 次なる次元へ — 「触れる」から「探索する」へ

第4集のスタートとして、外界からの入力(マウス座標)をノイズの世界へ引き込むことに成功しました。

数式が描く冷たい世界に、Three.jsという架け橋を通して「ユーザーの介入」という熱を吹き込む。これにより、ノイズはもはやただディスプレイの奥で眺めるだけの環境映像ではなくなりました。ユーザーの操作に呼応し、まるで呼吸しているかのように反応する「体験のコア」へと進化したのです。

次回(#32)は、このインタラクションの概念をさらに拡張します。今回のように平面(Plane)を撫でるだけでなく、Three.jsのカメラワーク(OrbitControls)や3Dジオメトリと組み合わせることで、「ノイズによってプロシージャルに生成された惑星(Sphere)の表面を、自らの手で回しながら探索する」手法へとステップアップしていきます。

数式で未知の星の地形を隆起させ、そこにカメラを落とし込んで旅をする。Three.jsとGLSLが織りなす「動くノイズの天国」は、まだまだ底知れません。

次回の更新も、どうぞお楽しみに!