[Noise 入門 #24] Post-Processing × Noise — 世界を歪める「空間ハック」の魔法(水中の揺らぎ・陽炎・グリッチ)

はじめに

これまでの連載では、空間内にある「オブジェクト(平面や球体)」に対してノイズを適用し、炎やブラックホール、魔法陣といったエフェクトを作り出してきました。

しかし今回は、視点を変えます。 空間内の何かを描くのではなく、「すでにレンダリングされた世界(画面全体)」に対してノイズを適用し、カメラのレンズや空間そのものが歪んでいるかのような錯覚を作り出します。

これがポストプロセス(Post-Processing)の世界です。水中の揺らぎ、灼熱の陽炎(Heat Haze)、あるいは次元が崩壊するようなグリッチエフェクトまで。ノイズを用いて「世界(カメラ)そのものをハックする」手法を深掘りしていきましょう。

前回の記事:

ポストプロセス(Post-Processing)の真の仕組み:世界を「1枚の画像」に圧縮する

これまでの連載では、空間内に配置した「ジオメトリ(頂点)」に対してノイズを適用し、山脈を隆起させたり、炎の形を作り出したりしてきました。

通常のレンダリング(Forward Rendering)では、3D空間にあるモデルの座標を計算し、カメラの視点に合わせて直接画面(キャンバス)にピクセルを塗っていきます。しかし、もし「画面全体のピントをぼかしたい(被写界深度)」「水中にいるように画面全体を歪ませたい」と思った場合、空間内のすべてのオブジェクトのShaderに個別に歪みの計算を追加するのは、非現実的であり計算コストも破綻してしまいます。

そこで登場するのがポストプロセス(Post-Processing)という概念です。 ポストプロセスは、レンダリングの工程に「ある特殊なステップ」を挟むことで、この問題をエレガントに解決します。

1. オフスクリーン・フレームバッファ(FBO)という「見えないキャンバス」

ポストプロセスの最初のステップは、「最終的な画面(ディスプレイ)には直接描画しない」という決断から始まります。

代わりに、GPUのメモリ上に「見えない別のキャンバス」を用意し、そこに3Dの世界を描画します。この見えないキャンバスのことを、WebGLの用語で Framebuffer Object (FBO) 、Three.jsでは WebGLRenderTarget と呼びます。

この段階を経ることで、私たちが苦労して構築した3D空間、ライティング、オブジェクトのすべては、単なる「1枚の2D画像(テクスチャ)」へと圧縮(ベイク)されます。

2. 全画面を覆う「1枚の板(Full Screen Quad)」

3Dの世界が1枚の画像(テクスチャ)になったら、次はその画像をどうやって画面に出力するかです。 ここで非常に面白いハックが行われます。

  1. 3Dカメラの遠近法(Perspective)を無効化する。
  2. カメラの目の前に、画面全体にぴったりと重なる「巨大な長方形の板(Full Screen Quad)」を1枚だけ配置する。
  3. その板に対して、先ほどFBOに描き込んだ「世界を写したテクスチャ」を貼り付ける。

映画の撮影に例えるなら、一度カメラで現実の風景を録画し、その映像データを巨大なスクリーンに投影して、さらに別のカメラで撮影し直すような状態です。

3. Fragment Shader が「神」になる瞬間

この「画面を覆う1枚の板」を描画する際、Vertex Shader(頂点シェーダー)はほとんど仕事をしません。なぜなら、頂点は画面の四隅に固定されているだけで、動かす必要がないからです。

ここで独壇場となるのが Fragment Shader(ピクセルシェーダー) です。

Fragment Shaderに渡される情報は、3D空間の複雑な座標や法線ベクトルではなく、ただ一つ。画面の左下を (0.0, 0.0)、右上を (1.0, 1.0) とする UV座標(vUv)のみ です。

基本となるShaderコードは、たったこれだけです。

// u_tDiffuse: FBOに描画された「世界」のテクスチャ
uniform sampler2D u_tDiffuse;
varying vec2 vUv;

void main() {
    // 今見ているピクセルの色を、テクスチャからそのまま拾ってくる
    vec4 color = texture2D(u_tDiffuse, vUv);

    gl_FragColor = color;
}

このコードは「元の世界をそのまま表示する」だけですが、こここそが魔法の入り口です。

私たちはすでに、GLSLの中で数式やノイズを使って「座標を意図的にズラす(Domain Warping)」技術を持っています。この vUv に対してノイズを加算した瞬間、平穏だった世界はねじ曲がり、空間そのものが崩壊するようなエフェクトが生まれるのです。

歪みの正体:UV座標の「サンプリング偽装」

画面全体を歪ませる魔法の正体は、実は非常にシンプルで、そして少しだけ狡猾(こうかつ)です。 それは、テクスチャの色を拾ってくる座標、すなわち UV座標(vUv)をノイズによってズラす(オフセットする) ことに他なりません。

このプロセスを、私は「サンプリング位置の偽装」と呼んでいます。

通常のサンプリング(歪みのない世界)

通常、画面のピクセルを描画する際、Fragment Shaderは「自分自身の座標(UV)」のピクセルカラーを、素直にテクスチャから拾ってきます。

これを数式で表すとこうなります。

$$Color = \text{texture}(u_scene, uv)$$

この状態では、レンダリングされた世界(u_scene)は一切の歪みなく、そのまま画面に出力されます。

ノイズによる座標の改変(Domain Warpingの再来)

しかし、ここに時間($t$)で変化するノイズを加算するとどうなるでしょうか。 連載の第6回、第7回で解説した Domain Warping(座標のねじれ) の概念が、ここでそっくりそのまま再登場します。あの時は「ノイズの入力座標」を歪ませましたが、今回は「画像のサンプリング座標」を歪ませるのです。

$$uv_{new} = uv + \text{noise}(uv \cdot \text{frequency} + t) \cdot \text{amplitude}$$

そして、この改変された新しい座標($uv_{new}$)を使って、世界の色を拾ってきます。

$$Color = \text{texture}(u_scene, uv_{new})$$

視覚的な錯覚:なぜ「歪んで」見えるのか?

これが画面上で何を引き起こしているのか、具体的に想像してみてください。

本来なら、目の前のピクセルは「まっすぐ前」の色を表示するはずでした。しかし、ノイズが加算されたことによって、Shaderは「少し右」や「少し左上」のピクセルの色を強制的に拾わされます。

🪟 不均一なガラス越しに見る世界 これは、表面が波打った古いガラス窓を通して外の景色を見ている状態と全く同じです。 ガラスの凹凸(ノイズ)が光を屈折させ、本来そこにあるはずのない隣の景色の色をあなたの目に届けてしまう。結果として、景色全体がぐにゃぐにゃと曲がって見えます。

これを実際のGLSL(Fragment Shader)に落とし込むと、以下のような非常に簡潔なコードになります。

uniform sampler2D u_tDiffuse; // 元の世界のテクスチャ
uniform float u_time;         // 時間経過
varying vec2 vUv;             // 現在のピクセルのUV座標

// 過去の記事で実装したSimplex Noise関数などを想定
float snoise(vec2 v);

void main() {
    // 1. ノイズの周波数(細かさ)と振幅(強さ)を定義
    float frequency = 5.0;
    float amplitude = 0.05; // 0.05くらいでもかなり歪みます

    // 2. UV座標をベースにしたノイズを生成し、時間で動かす
    // ※ x方向とy方向で異なるノイズを使うとより自然になります
    vec2 offset = vec2(
        snoise(vUv * frequency + vec2(u_time, 0.0)),
        snoise(vUv * frequency + vec2(0.0, u_time))
    ) * amplitude;

    // 3. 元のUV座標にノイズ(offset)を加算して「偽装」する
    vec2 distortedUv = vUv + offset;

    // 4. 偽装された座標を使って、元の世界から色をサンプリング
    vec4 color = texture2D(u_tDiffuse, distortedUv);

    gl_FragColor = color;
}

このコードを実行した瞬間、静的だった画面は生命を持ったかのようにうねり始めます。

frequency(周波数)を小さくすれば「水中のようなゆったりとした大きな揺らぎ」になり、大きくすれば「細かいさざ波」になります。 そして amplitude(振幅)は、その歪みの「暴力性」を決定します。数値を上げすぎると、世界は原型を留めないほどに崩壊(ピクセルアウト)してしまうでしょう。

実践1:水中の揺らぎ・陽炎(Heat Haze)

前回のセクションで「UV座標をズラす」というポストプロセスの基本原理を押さえました。ここからは、その数式に魂を吹き込み、現実世界の物理現象をシミュレーションしていく実践編です。

自然界の滑らかで連続的な歪みを表現するには、第14回・第15回で学んだ Simplex Noise や、それを重ね合わせた FBM(Fractal Brownian Motion) が圧倒的な威力を発揮します。

ここでは代表的な2つの環境、「水中」と「陽炎」をGLSLでどう再現するかを深掘りしましょう。


実践1:自然界の歪みをシミュレーションする(水中の揺らぎ・陽炎)

単に画面全体をノイズで揺らすだけでは、「カメラが壊壊れた」ようにしか見えません。私たちが作りたいのは、媒質(水や空気)の密度変化によって光が屈折する現象の再現です。

ケースA:水中の揺らぎ(Underwater Wobble)

水没したカメラ越しに見る世界、あるいは水槽のガラス越しに見る景色の表現です。水は空気よりも密度が高く、その動きは重く、ゆったりとしています。

この「重さ」と「液体の流れ」を表現するためのパラメータ設定のコツは以下の通りです。

  1. 低周波数(Low Frequency): 波のうねりを大きくするため、ノイズのスケールを小さく(周波数を低く)設定します。
  2. 上方向へのベクトル(Upward Flow): 水中では泡や水流が水面(上)に向かって動く性質があります。これを表現するため、ノイズに加算する時間パラメータ($t$)を、Y軸に対して強く作用させます。
// 水中の揺らぎを表現するUVオフセットの計算
float frequency = 2.0;  // ゆったりとした大きな波
float amplitude = 0.03; // 歪みの強さ
float speed = 0.5;      // ゆっくりとした変化

// Y軸(上方向)に向かってノイズを流す
vec2 noiseCoord = vUv * frequency;
noiseCoord.y += u_time * speed; // ここがポイント:時間が経つにつれノイズが上に移動する

// Simplex Noise等でXとYのズレを生成
float distortionX = snoise(noiseCoord) * amplitude;
float distortionY = snoise(noiseCoord + vec2(100.0)) * amplitude; // 適当なオフセットで別のノイズ波形に

vec2 distortedUv = vUv + vec2(distortionX, distortionY);

この処理をポストプロセスとして全画面にかけるだけで、静止画のシーンであっても、まるで深いプールの底から水面を見上げているかのような「没入感」が生まれます。

ケースB:陽炎・ヒートヘイズ(Heat Haze)

次は、真夏の熱いアスファルトから立ち昇る「陽炎」や、ジェットエンジンの排気熱による激しい空間の揺らぎです。 水中とは対照的に、空気の膨張による屈折は非常に細かく、そして激しく動きます。

  1. 高周波数(High Frequency): 細かい空気の揺らぎを表現するため、ノイズを細かく刻みます。
  2. 高速な変化(High Speed): 時間パラメータを早く回します。

しかし、これだけでは「画面全体が激しく揺れる」だけで、熱気には見えません。陽炎を陽炎たらしめる最重要テクニックが「マスク(Masking)」の導入です。

🔥 熱源からの距離で「歪みの強さ」をコントロールする 陽炎は熱源(地面やエンジン)の近くほど強く歪み、上空(冷たい空気)に行くにつれて歪みが消えていきます。 つまり、UVのY座標(高さ)を利用して、ノイズの振幅(Amplitude)を減衰させる必要があります。

これをGLSLで表現すると、驚くほど直感的なコードになります。

// 陽炎を表現するパラメータ
float heatFreq = 20.0;    // 細かく激しい揺らぎ
float heatSpeed = 3.0;    // 変化のスピードを速く
float maxAmplitude = 0.01;// 最大の歪み幅(水中より小さめにする)

// ノイズの生成(上に立ち昇る動き)
float noiseVal = snoise(vec2(vUv.x * heatFreq, vUv.y * heatFreq - u_time * heatSpeed));

// 【最重要】マスクの生成
// 画面下部(vUv.y == 0.0)でマスク値が1.0、上部(vUv.y == 1.0)で0.0になるようにする
// smoothstepを使って、地面付近だけに強く効果が出るように調整
float mask = 1.0 - smoothstep(0.0, 0.5, vUv.y);

// ノイズの値に、最大振幅とマスクを掛け合わせる
float distortion = noiseVal * maxAmplitude * mask;

// X軸(横方向)の揺らぎとして適用する(熱気は横に揺らめきながら上に昇るため)
vec2 distortedUv = vec2(vUv.x + distortion, vUv.y);

この mask の計算が、ポストプロセスにおいて非常に強力な武器になります。 「画面のどこにノイズを適用し、どこに適用しないか」を数学的に制御することで、ただのランダムな歪みが、圧倒的なリアリティを持つ「環境エフェクト」へと進化するのです。

実践2:次元の崩壊(Glitch / クロマティック・アベレーション)

滑らかなノイズとは対極的に、「バグった世界」や「次元の崩壊」を表現するグリッチエフェクトも、実はノイズの応用によって作られています。

自然界のシミュレーションではノイズの値をそのまま波として使いましたが、デジタルな破壊表現においては、ノイズの値を「特定の閾値を超えた場所だけ、極端にUVをズラすためのスイッチ」として利用します。

1. step() 関数による「不連続」な破壊

滑らかなグラデーションを持つノイズ(例えばSimplex Noise)を、「0か1か」のパキッとしたブロック状のノイズに変換するために活躍するのが、GLSLの組み込み関数である step() です。

step(edge, x) は、xedge より小さければ 0.0、大きければ 1.0 を返します。

これを利用して、「ノイズの値が0.8を超えた(上位20%の)時だけ、画面を横に大きくズラす」という処理を書きます。すると、画面の一部だけが突発的に切り裂かれたような、デジタルのバグ特有の「ブロックノイズ的なズレ」が発生します。

2. 世界の色を分解する(色収差:Chromatic Aberration)

さらに、グリッチエフェクトを決定づける最重要テクニックが「RGBの分離」です。

通常、テクスチャから色をサンプリングする際は、1回の texture2D でRGBすべての色を同時に拾ってきます。しかし、不良品のレンズや壊れたモニターでは、光の波長(赤・緑・青)によって屈折率が異なり、色がズレて滲む現象が起きます。これを色収差(Chromatic Aberration)と呼びます。

GLSLでこれを意図的に引き起こすには、R、G、Bの各チャンネルを、それぞれ「少しずつ異なるUV座標」で独立してサンプリングし、後から合成します。

この2つのテクニック(閾値によるズレ + RGBの分離)を組み合わせたコードがこちらです。

// u_tDiffuse: 元の世界のテクスチャ
// u_time: 時間
varying vec2 vUv;

void main() {
    // 1. 高周波なノイズを生成(細かくランダムな値)
    // ※ 横方向の縞模様のバグを作りたいため、vUv.yを強く評価する
    float noiseVal = snoise(vec2(vUv.y * 50.0, u_time * 10.0));

    // 2. step関数で閾値を設定し、「突発的なバグ」を生成
    // noiseValが0.8以上の場所だけ1.0になり、それに0.05(ズレ幅)を掛ける
    float glitch = step(0.8, noiseVal) * 0.05;

    vec4 color;

    // 3. RGBのチャンネルごとに「ズラす方向」を変えてサンプリングする
    // 赤(r)はノイズの分だけ右(+X方向)のピクセルを拾う
    color.r = texture2D(u_tDiffuse, vUv + vec2(glitch, 0.0)).r;

    // 緑(g)はズラさず、そのままのピクセルを拾う
    color.g = texture2D(u_tDiffuse, vUv).g;

    // 青(b)はノイズの分だけ左(-X方向)のピクセルを拾う
    color.b = texture2D(u_tDiffuse, vUv - vec2(glitch, 0.0)).b;

    // アルファ値はそのまま
    color.a = 1.0;

    gl_FragColor = color;
}

サイバーパンクと「ダメージ演出」の完成

この「ノイズによるサンプリング位置の偽装」と「RGBの分離」が組み合わさった瞬間、画面には強烈な不穏さが生まれます。

赤い残像が右に、青い残像が左に激しくブレるこの視覚効果は、サイバーパンクな世界観の映像表現として欠かせないものです。また、ゲームにおいてプレイヤーがダメージを受けた瞬間や、電波障害が起きた際のUI演出としても、この数行の数式が裏で稼働しています。

ノイズは、自然を創造するだけでなく、デジタルな世界を破壊するためにも使えるのです。

まとめ:世界は「1枚のテクスチャ」に過ぎない

「ポストプロセス」という領域に足を踏み入れると、私たちがこれまで丹念に構築してきた3D空間や複雑なライティングの計算結果すらも、最終的には「1枚の平坦なテクスチャ(2D画像)」へと還元されます。

一見すると、これは三次元からの退化のように感じるかもしれません。 しかし、そのフラットに圧縮された世界の「UV座標」に対して、私たちがここまで学んできた「ノイズの数学」を適用した瞬間、劇的なパラダイムシフトが起こります。

空間内のオブジェクトを一つ一つ動かすのではなく、「世界を観測しているレンズ(カメラ)そのもの」を数式でハックする。 水中の重々しい屈折も、アスファルトを焦がす陽炎も、次元を切り裂くデジタルのバグも、すべては Fragment Shader 内の「サンプリング座標の偽装」という、たった数行の魔法によって生み出されているのです。

そして次なる舞台へ:第4集「Three.js 編」開幕へ向けて

これにて、第3集「Shader(GLSL)実践編」における主要な表現手法の解説は出揃いました。 基礎的な乱数から始まり、FBMによる自然の複雑さ、Domain Warpingの空間歪曲、そして今回のポストプロセスに至るまで、私たちは「ノイズ」という名の強力な呪文(アルゴリズム)を数多く手に入れました。

しかし、呪文は「唱えるための舞台」があって初めて世界に干渉できます。

次回(#25)からは、これらの純粋なGLSLの数式たちをWebブラウザ上で効率よく、かつ美しく統治するためのフレームワーク——いよいよ第4集「Three.js 編」へと突入するための架け橋となるトピックを扱います。

生のWebGLコードではなく、Three.jsの ShaderMaterial を使ってノイズをシームレスに組み込む方法や、今回学んだポストプロセスを実稼働させる EffectComposer の具体的なセットアップ手法など。数学とGPUの力を、実際のWebアプリケーションへと実装していく「エンジニアリング」のフェーズです。

ノイズが描くめくるめく世界は、いよいよあなたのブラウザ上で、インタラクティブに動き出します。次回からの展開も、ぜひお楽しみに!