[Noise 入門 #54] プロシージャル・プラズマとエネルギーシールド — ノイズの等高線とフレネルが描くSF的力場

はじめに

第6集「神領域」、いよいよ中盤戦。
今回は【#54 プロシージャル・プラズマとエネルギーシールド】です。

これまでのノイズは「雲」や「地形」といった、自然界のモヤモヤした連続的なものを表現するために使ってきました。しかし、今回はそのノイズを「鋭利で幾何学的なSF的エフェクト」へと変換します。

ゲームやSF映画で、攻撃を弾くバリアや、コアを覆うプラズマシールドを見たことがあるはずです。あれをThree.jsとGLSLの力で錬成するロジックを解剖していきましょう。

前回の記事:

Youtube動画:

魔法の正体:ノイズの「等高線」を抽出する

モヤモヤした雲のようなノイズが、なぜ突如として鋭利なSF的レーザーラインに化けるのか。読者がその「数式の裏側」を直感的に理解できるよう、4つのステップに分けて解剖します。

ノイズが「光の線」に化ける4つのステップ

ノイズから等高線を抽出するプロセスは、単なる暗記ではなく「数式が形をどう変えるか」をイメージできると、途端に自由度が増します。先ほどのコードを4つのプロセスに分解して視覚的に捉えてみましょう。

Step 1: ベースノイズの生成(なだらかな丘)

float n = cnoise(position * frequency + time);

最初の状態は、おなじみのPerlin/Simplexノイズです。出力される値は滑らかに連続し、見えない空間に「なだらかな丘と谷」を作り出します。これにそのまま色を乗せれば、これまでの記事で作ってきた「雲」や「地形」になります。

Step 2: サイン波による周期化(波紋の付与)

float plasma = sin(n * banding);

ここが最初の魔法です。ノイズの値 $n$ をサイン関数($\sin$)の角度として渡します。 banding は「等高線の密度」を決める係数です。ノイズの丘を登っていく過程で、$\sin$ 関数を通すことで一定の高さごとに値が -1.0 から 1.0 へと周期的に波打ち始めます。これにより、単調な坂道が「波紋」のような縞模様に変換されます。

Step 3: 絶対値による「鋭い峰」への反転

float lines = 1.0 - abs(plasma);

サイン波のままでは、波の谷間がマイナスになり、グラデーションも滑らかすぎます。 そこで abs() を使ってマイナスの谷をプラスに折り返し(V字の谷を作る)、さらに 1.0 - で全体を上下反転させます。 すると、先ほどまで滑らかだった波の頂点が、鋭く尖った峰(ピーク)に変換されます。これが「線」の正体です。

Step 4: Smoothstepによる「光の凝縮」

lines = smoothstep(0.8, 1.0, lines);

最後の仕上げです。Step 3で作った線は、まだ根元がぼんやりと太い状態です。 smoothstep を用いて「値が0.8以下の部分はすべて0(黒)にする」という足切り(スレッショルド処理)を行います。これにより、不要なぼやけが完全に削ぎ落とされ、エネルギーの芯だけが抽出された「パキッとした発光ライン」が完成します。


視覚化ウィジェット:数式が描く「1D空間」の変容

この「なだらかな波が、鋭い線に変わる」という数学的なプロセスを、実際にスライダーを動かして確認できるグラフを用意しました。ブログの読者に説明する際、頭の中でこのグラフの変化を思い浮かべると、言葉の解像度がグッと上がるはずです。

世界を包み込む「フレネル効果(リムライト)」

ただの平面的な縞模様だったノイズが、どうやって「球状のエネルギーシールド」としての実体を持つのか。その鍵となるのが、3Dグラフィックスにおける光の表現の要、ベクトルの内積(Dot Product)を活用した「フレネル(Fresnel)効果」です。

ベクトルの内積が作る「光学迷彩」

ノイズで作った発光ラインをそのまま球体に貼り付けると、ただの「派手なスイカ」になってしまいます。SF映画に登場するバリアのように、「中心は透けていて、輪郭(エッジ)に行くほど光が凝縮する」という光学現象を作るために、Shader内で視線と法線を計算します。

Step 1: 視線と法線の内積(正面か、フチか)

float facing = dot(viewDir, normal);

viewDir は「カメラからそのピクセルへ向かう視線ベクトル」、normal は「そのピクセルの表面が向いている方向(法線ベクトル)」です。 この2つのベクトルの内積(dot)を取ると、「カメラの真正面を向いている部分は 1.0」「横(フチ)を向いている部分は 0.0」という値が取得できます。球体の中心が一番明るく、輪郭が暗い状態です。

Step 2: 空間の反転(フチを光らせる)

float inverted = 1.0 - max(facing, 0.0);

バリアとしては、中心ではなく「フチ」を光らせたいですよね。そこで 1.0 - を使って白黒を反転させます。(max を使っているのは、球体の裏側の計算結果がマイナスになるのを防ぐためです)。 これで、「真正面は 0.0(透明)、輪郭は 1.0(不透明)」になりました。

Step 3: べき乗による「バリアの鋭さ」の抽出

float fresnel = pow(inverted, edgeGlow);

反転させただけの状態だと、中心から輪郭にかけてのグラデーションが緩やかすぎて、「バリア」というより「ぼんやり光る綿毛」のように見えてしまいます。 そこで pow 関数(べき乗)を使います。例えば 0.5 を3乗すると 0.125 になりますが、1.0 は何乗しても 1.0 のままです。つまり、edgeGlow の数値を上げるほど、中途半端なグレーの部分が闇に沈み、純粋な輪郭の光(エッジ)だけが鋭く研ぎ澄まされていきます。

Step 4: ノイズとの融合

最終的に、前段で作った「ノイズの等高線(lines)」と、この「フレネル(fresnel)」を掛け合わせます。 これにより、「輪郭付近でのみノイズの模様が強く発光し、中心部は完全に透けて向こう側が見える」という、完璧なエネルギーシールドの質感が完成します。

Three.js × ShaderMaterial 実装コード

理論を実体化するためのGLSL(Vertex / Fragment Shader)と、それをThree.jsで読み込むためのマテリアル設定です。

コピペして手元の環境で動かし、パラメータを弄って遊んでみてください。

1. マテリアルのセットアップ (JavaScript)

import * as THREE from 'three';

// シールド用のジオメトリ(頂点数が多いほど綺麗に発光します)
const geometry = new THREE.SphereGeometry(2, 64, 64);

const shieldMaterial = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0.0 },
    uColor: { value: new THREE.Color(0x00ffff) }, // シアン系のプラズマ
    uFrequency: { value: 2.0 },
    uBanding: { value: 15.0 },
    uEdgeGlow: { value: 3.0 }
  },
  vertexShader: document.getElementById('vertexShader').textContent,
  fragmentShader: document.getElementById('fragmentShader').textContent,
  transparent: true,      // 透過を有効化
  blending: THREE.AdditiveBlending, // 加算乳化で光らせる
  depthWrite: false,      // 後ろのオブジェクトを隠さないようにする
  side: THREE.DoubleSide  // 内側からも見えるようにする
});

const shield = new THREE.Mesh(geometry, shieldMaterial);
scene.add(shield);

// アニメーションループ内で uTime を更新するのを忘れずに!
// shieldMaterial.uniforms.uTime.value += 0.01;

2. Vertex Shader (頂点シェーダー)

フレネル効果を計算するために、カメラから見た頂点位置(vViewPosition)と、法線(vNormal)をFragment Shaderへ送ります。

varying vec3 vNormal;
varying vec3 vPosition;
varying vec3 vViewPosition;

void main() {
  // 法線をカメラ空間に合わせて変換
  vNormal = normalize(normalMatrix * normal);

  // 頂点位置をカメラ空間へ変換
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);

  // Fragment Shaderへ渡す値
  vViewPosition = -mvPosition.xyz;
  vPosition = position;

  gl_Position = projectionMatrix * mvPosition;
}

3. Fragment Shader (フラグメントシェーダー)

先ほど解剖した数式を統合します。(※冒頭の snoise 関数は、過去のSimplex Noiseの記事で使用した関数をそのまま貼り付けてください)。

uniform float uTime;
uniform vec3 uColor;
uniform float uFrequency;
uniform float uBanding;
uniform float uEdgeGlow;

varying vec3 vNormal;
varying vec3 vPosition;
varying vec3 vViewPosition;

// ※ ここに 3D Simplex Noise 関数 (snoise) を挿入 ※

void main() {
  // --- 1. ノイズの等高線(プラズマ)生成 ---
  // 時間で空間をスクロールさせてアニメーションさせる
  float n = snoise(vPosition * uFrequency + uTime * 0.5);

  // サイン波に通して等高線を抽出
  float plasma = sin(n * uBanding);

  // 鋭い線にする
  float lines = 1.0 - abs(plasma);
  lines = smoothstep(0.8, 1.0, lines); // 足切り

  // --- 2. フレネル効果(リムライト)生成 ---
  vec3 normal = normalize(vNormal);
  vec3 viewDir = normalize(vViewPosition);

  // 内積を取る(正面が1、フチが0)
  float facing = dot(viewDir, normal);

  // 反転してべき乗(フチだけを鋭く光らせる)
  float fresnel = pow(1.0 - max(facing, 0.0), uEdgeGlow);

  // --- 3. 最終出力 ---
  // ベースカラー、ノイズライン、フレネルを掛け合わせる
  vec3 finalColor = uColor * lines * fresnel;

  // 透過度はフレネルとラインの強さに依存させる
  float alpha = (lines + 0.2) * fresnel; // 0.2を足して全体を薄く残す

  gl_FragColor = vec4(finalColor, alpha);
}

空間を歪める応用アイデア:不安定なエネルギー場

このコードをベースにすれば、アイデア次第で無限のバリエーションを生み出せます。lainさんなら、以下のような拡張がすぐに思いつくのではないでしょうか。

  • 色を時間で変化させる: uColor に sin(uTime) を混ぜ込んで、エネルギーが不安定にゆらぐRGBゲーミングシールドにする。
  • Vertex Shaderの操作 (Displacement): ノイズの値をFragment ShaderだけでなくVertex Shaderでも使い、position += normal * n * 0.1; のように頂点を押し出せば、「形状自体がボコボコと脈打つ不安定なバリア」になります。
  • 衝撃波の波紋: Fragment Shader内で、画面の中心からの距離(length(vPosition))に応じて広がるサイン波を追加し、プラズマに重なる「波紋」を作る。

視覚化ウィジェット:不安定に脈打つシールド

応用例として、先ほどのプラズマシールドに「Vertex Shaderによる頂点の隆起(Displacement)」を組み合わせた状態を作りました。頂点がノイズによって押し出されることで、完璧な球体だったシールドが、今にも弾け飛びそうな不安定なエネルギーの塊へと変化します。

実装の舞台裏

3D表現において、コードを一発書きして理想の絵が出ることは稀です。
今回も、AIとロジックの議論を重ねながら何十回とコードを書き換え、手動で細かな数値を追い込む泥臭い試行錯誤を繰り返しました。
しかし、時間の制約もあり、自分の中で「完璧だ」と断言できるレベルまで到達できなかったのが正直なところです。

このシリーズも54回目を数え、自分自身の目が肥えてきたことも影響しているかもしれません。
Redditなどに投稿される海外エンジニアのデモ作品は驚異的なクオリティであり、常にそこを目指してはいますが、プロのエフェクトエンジニアが作り出す表現の厚みには、今なお到底及ばないことを痛感させられます。

彼らの作品は決して短時間で組み上がったものではなく、何百、何千という膨大な時間を費やし、パラメータを極限までチューニングし続けた執念の結果なのだと思います。

初期段階のエフェクトは見るに堪えないほど稚拙なものでしたが、規則的な六角形(ヘックス)のグリッドパターンにノイズによる揺らぎを重ね合わせる「複合技」を用いることで、ようやく最低限のクオリティに漕ぎ着けました。

ライティングのさらなる追い込みや、過去回で実装したプロシージャルな雲の再統合など、試したいアイデアはまだ山ほどあります。しかし、表現を作り込むほど計算負荷は増大し、iPhoneなどのモバイル端末では熱暴走やパフォーマンス低下を招くという現実的な壁にも直面しました。

「どこまで突き詰めるか」という問いに対し、今回はこの地点で一つの妥協点を見出すことにしました。

なお、地球のテクスチャ素材は、世界中の天文ファンに愛される「Solar System Scope」のデータを使用させていただいています。