風呂瀬 信五

GLSLシェーダーで光を彫刻する:p5.jsが拓くピクセルアートの哲学

p5.jsのキャンバスに円や四角形を描くことには慣れてきた。しかし、画面全体を覆うような複雑な模様や、ビデオエフェクトのような流体的な表現を試みると、draw() ループの速度限界に突き当たることがあります。膨大な数のピクセル一つひとつをCPUで制御するのではなく、GPUの並列処理能力を直接解き放つことで、リアルタイムに光と色彩を操る手法、それが シェーダー です。GLSLという未知の言語に戸惑い、その一歩を踏み出せずにいるかもしれません。この記事では、p5.jsを使い、シェーダーという名の筆を手にして、静的なピクセルを躍動する光の詩へと変えるための、最初の扉を開きます。

静寂なピクセルの海で、光を刻む:シェーダーアートの深淵へ

p5.jsで描く線一本、点一つは、CPUが一つずつ計算し、画面上の対応するピクセルに色を配置する命令の結果です。しかし、画面が1920x1080ピクセルであれば、そこには200万以上のピクセルが存在します。これらすべてに個別の、しかし相互に関連した計算を毎フレーム施そうとすれば、CPUの限界はすぐに訪れるでしょう。frameRate() が著しく低下し、私たちの創造的な衝動は、計算資源の壁に阻まれてしまいます。

シェーダーは、このパラダイムを根本から覆します。それは、個々のピクセルに「君はどのような色になるべきか」という問いを投げかけ、その答えをGPUに並列で計算させるためのプログラムです。何百万ものピクセルが、それぞれ独立した計算ユニットとして同時に動作する。このアプローチにより、私たちはCPUの逐次的な命令から解放され、画面全体を一つの連続したフィールドとして扱えるようになります。それはまるで、無数の点描を一つずつ打つのではなく、キャンバス全体に光を吹き付け、その干渉や回折を直接デザインするような行為に近いかもしれません。

この技術は、単なる高速化以上の意味を持ちます。それは表現の質的な変化です。ピクセルを個別に制御するのではなく、ピクセルの色を決定する「法則」そのものを記述する。これにより、私たちはより抽象的で、数学的な美しさを内包した ジェネラティブアート の領域へと足を踏み入れることができます。静寂なピクセルの海に、コードという名の彫刻刀で、光の波紋を刻み始めるのです。

GPUの詩人たち:シェーダーとは何か、なぜ魅了されるのか

シェーダー (Shader) とは、グラフィックスパイプラインの特定ステージで実行されるプログラムであり、主にGPU (Graphics Processing Unit) 上で動作します。GPUは、単純な計算を大量に、同時に(並列で)実行することに特化したプロセッサです。この特性が、画面上のすべてのピクセルの色を同時に計算する、というタスクに最適なのです。

p5.jsで主に扱うのは フラグメントシェーダー (Fragment Shader) です。これはピクセルシェーダーとも呼ばれ、画面上の各ピクセル(フラグメント)の色を最終的に決定する役割を担います。フラグメントシェーダーに渡される情報としては、そのピクセルが画面上のどこに位置するかを示す座標 (vec2) が特に重要です。これは、頂点シェーダーから補間されて渡されるテクスチャ座標 (vTexCoord) や、組み込みのスクリーン座標 (gl_FragCoord) といった形で利用されます。シェーダープログラムは、その座標を基に計算を行い、最終的な色 (vec4、RGBA値) を出力します。重要なのは、シェーダーは他のピクセルの計算結果を知ることができず、与えられた情報だけで自分の色を決定しなければならない、という点です。

この制約こそが、シェーダープログラミングの面白さであり、難しさでもあります。私たちは、個々のピクセルに対するミクロな命令ではなく、座標という入力から色という出力を生み出す「関数」や「数式」を記述します。その数式が、画面全体というマクロな視点で見ると、美しいグラデーションや複雑な模様、流れるような動きとして立ち現れるのです。それは、自然界の法則が個々の分子の振る舞いを定め、結果として壮大な現象を生み出す様に似ています。シェーダープログラマは、いわばこのデジタルの世界の物理法則を記述する、GPUの詩人なのです。

p5.jsで始めるGLSLの魔法:最小のコードで最大の効果を

理論はさておき、実際にp5.jsでシェーダーを動かしてみましょう。p5.jsは createShader() 関数と shader() 関数を通じて、驚くほど簡単にGLSLシェーダーを扱う環境を提供してくれます。必要なのは、HTMLファイルとJavaScriptファイル、そしてシェーダーを記述するための2つのファイル(頂点シェーダーとフラグメントシェーダー)です。

まず、p5.jsのスケッチ (sketch.js) です。ここではシェーダーをロードし、それを適用する図形(ここでは画面全体を覆う四角形)を描画します。

let myShader;

function preload() {
  // 頂点シェーダーとフラグメントシェーダーをロード
  myShader = loadShader('shader.vert', 'shader.frag');
}

function setup() {
  // WebGLモードでキャンバスを作成
  createCanvas(windowWidth, windowHeight, WEBGL);
  noStroke();
}

function draw() {
  // シェーダーをアクティブにする
  shader(myShader);

  // シェーダーを適用するジオメトリを描画
  // 画面全体を覆う四角形を正規化デバイス座標(-1から1)で描画
  // aPositionはすでに-1から1の範囲で渡されるため、頂点シェーダーでの複雑な変換は不要
  quad(-1, -1, 1, -1, 1, 1, -1, 1);
}

次に、頂点シェーダー (shader.vert) です。p5.jsで2Dのエフェクトを作る場合、多くは定型的なコードで十分です。これは、各頂点の座標を変換する役割を担います。

// WebGLのバージョンや精度に関するおまじない
#ifdef GL_ES
precision mediump float;
#endif

// p5.jsから渡される頂点属性
attribute vec3 aPosition;
attribute vec2 aTexCoord; // p5.jsはquad()使用時もaTexCoordを自動生成

// フラグメントシェーダーに渡す変数
varying vec2 vTexCoord;

void main() {
  vTexCoord = aTexCoord;
  // aPositionはquad(-1,-1,1,-1,1,1,-1,1)によって既に正規化デバイス座標であるため、
  // 追加の変換は不要。そのままgl_Positionに設定する。
  gl_Position = vec4(aPosition, 1.0);
}

そして、表現の核となるフラグメントシェーダー (shader.frag) です。ここでは、ピクセルの座標に基づいて単純なグラデーションを生成してみます。

#ifdef GL_ES
precision mediump float;
#endif

// 頂点シェーダーから受け取るテクスチャ座標
varying vec2 vTexCoord;

void main() {
  // vTexCoord.xは水平方向の位置 (0.0 - 1.0)
  // vTexCoord.yは垂直方向の位置 (0.0 - 1.0)
  float r = vTexCoord.x;
  float g = vTexCoord.y;
  float b = 0.5;

  // gl_FragColorに最終的な色 (RGBA) を設定
  gl_FragColor = vec4(r, g, b, 1.0);
}

このわずかなコードで、画面には滑らかな色のグラデーションが描画されます。CPUで for ループを回してピクセルを一つずつ描画する方法とは比較にならないほど高速です。これが、シェーダーの世界への第一歩。ピクセルの座標という最も基本的な情報から、いかに豊かな色彩を生み出すか。その探求がここから始まります。

時間と空間を操るユニフォーム:インタラクティブな表現への扉

シェーダーはそれ自体で完結したプログラムですが、p5.jsのスケッチから動的に情報を送り込むことで、その表現力は飛躍的に高まります。このp5.jsとGLSLシェーダー間の橋渡し役となるのが ユニフォーム変数 (uniform) です。ユニフォーム変数は、その名の通り、すべてのピクセルに対して「一様な (uniform)」値を渡すための変数です。

例えば、時間の経過やマウスの位置をシェーダーに伝え、アニメーションやインタラクションを実現できます。shader() を呼び出す前に setUniform() メソッドを使い、p5.js側から値を設定します。

sketch.js を少し変更してみましょう。時間の経過を表す millis() の値と、マウスのX座標をシェーダーに送ります。

// draw()関数の中
function draw() {
  shader(myShader);

  // 'u_time'という名前で経過時間をシェーダーに送る
  myShader.setUniform('u_time', millis() / 1000.0);
  // 'u_mouseX'という名前でマウスのX座標をシェーダーに送る
  myShader.setUniform('u_mouseX', mouseX);
  // 'u_resolution' という名前で解像度をシェーダーに送る
  myShader.setUniform('u_resolution', [width, height]);

  quad(-1, -1, 1, -1, 1, 1, -1, 1);
}

対応するフラグメントシェーダー (shader.frag) で、これらのユニフォーム変数を受け取ります。先頭で uniform キーワードを使って変数を宣言します。

#ifdef GL_ES
precision mediump float;
#endif

varying vec2 vTexCoord;

// p5.jsから受け取るユニフォーム変数
uniform float u_time;
uniform float u_mouseX;
uniform vec2 u_resolution;

void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
  
  float r = st.x;
  // 時間の経過(u_time)とサイン波を使ってGチャンネルをアニメーション
  float g = 0.5 + 0.5 * sin(u_time * 2.0 + st.x * 10.0);
  // マウスのX座標でBチャンネルを変化
  float b = u_mouseX / u_resolution.x;

  gl_FragColor = vec4(r, g, b, 1.0);
}

gl_FragCoord.xy はピクセルのスクリーン座標(左下が原点)を格納する組み込み変数です。これをキャンバスの解像度 u_resolution で割ることで、vTexCoord と同様に正規化された座標 (0.0 から 1.0) を得られます。

これで、私たちのシェーダーは静的な絵ではなくなりました。時間の流れに応じて色彩が波打ち、マウスを動かせばインタラクティブに色が変化する、生きたキャンバスが生まれます。ユニフォーム変数は、時間、空間、そしてユーザーの操作といった外部世界のコンテキストを、ピクセルの内なる法則へと接続するための、強力な手段なのです。

ノイズと色彩の錬金術:生成的な表現の可能性を解き放つ

シェーダープログラミングにおいて、予測不可能性と有機的な質感を作品に与えるための最も重要なツールの一つが ノイズ関数 です。特にパーリンノイズやシンプレックスノイズのような、滑らかに変化する擬似乱数は、自然界に見られる雲の模様、水面の揺らぎ、木目といった不規則なパターンを生成するのに非常に役立ちます。

GLSLには組み込みのノイズ関数がありませんが、コミュニティによって開発された高品質な関数が無数に存在します。これらの関数をシェーダーコードに含めることで、私たちは座標を入力として、連続的で構造的な乱数値を出力として得られます。この乱数値を、色の決定、図形の変形、時間のずらしなど、様々なパラメータに適用することで、無限の ジェネラティブアート のパターンを生み出すことが可能になります。

例えば、2Dのノイズ関数を使って、モノクロの雲のような模様を生成してみましょう。

#ifdef GL_ES
precision mediump float;
#endif

varying vec2 vTexCoord;
uniform float u_time;
uniform vec2 u_resolution;

// 2Dのランダム関数
float random (vec2 st) {
    return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}

// ここに高品質なノイズ関数(例: Ashima Artsのcnoise)を実装
// ... (コードは長いため省略)

void main() {
  vec2 st = gl_FragCoord.xy / u_resolution.xy;
  st.x *= u_resolution.x / u_resolution.y; // アスペクト比を補正

  // ノイズ関数を呼び出す
  // 座標と時間を入力にして、滑らかな値を得る
  float n = noise(st * 5.0 + u_time * 0.5);

  // ノイズの値をグレースケールカラーに変換
  vec3 color = vec3(n);

  gl_FragColor = vec4(color, 1.0);
}

このコードでは、ピクセルの座標 (st) と時間 (u_time) をノイズ関数に渡しています。時間に応じて入力値が変化するため、生成されるノイズパターンはゆっくりと移ろい、まるで生きているかのように見えます。ノイズ関数のスケール (* 5.0) や時間の進み方 (* 0.5) を変えるだけで、模様の細かさや動きの速さが劇的に変化します。

さらに、複数のノイズ関数を異なる周波数と振幅で重ね合わせる(フラクタルノイズ)ことで、より複雑で自然なテクスチャを作り出すことができます。色彩についても、ノイズの値を使って複数の色を滑らかに補間すれば、単なるグレースケールではない、豊かなカラーパレットを持つ作品が生まれます。シェーダーにおけるノイズは、秩序と混沌の境界線を自在に操り、無機質な計算から有機的な生命感を錬成する、現代の錬金術と言えるでしょう。

シェーダーが問いかける視覚の哲学:存在と知覚の再構築

シェーダープログラミングの探求は、単なる技術的な習熟にとどまりません。それは、私たち自身の視覚と知覚のプロセスを再考する旅でもあります。draw() ループでオブジェクトを一つずつ描く従来の方法は、世界を個別の「モノ」の集合として捉える、私たちの日常的な認識に近いかもしれません。机があり、椅子があり、カップがある、といったように。

しかし、フラグメントシェーダーは、そのようなオブジェクト中心の世界観とは異なる視点を提示します。そこにあるのは、連続的な「場 (フィールド)」です。画面上のあらゆる点は、独立したオブジェクトではなく、座標という入力から色という出力を導き出す、普遍的な数理法則の現れです。形は、色の不連続な変化によって生まれる境界線にすぎません。すべては光と場の相互作用であり、そこに本質的な「モノ」は存在しないのかもしれない。

この考え方は、物理学や東洋哲学の思想とも響き合います。すべてが相互に関連しあう一つの連続体として世界を捉える視点です。シェーダーを書くという行為は、この世界観をデジタルなキャンバスの上で実践する試みです。私たちは、ピクセルという最小単位が存在する法則を定義することで、マクロな現象としてのイメージを創発させます。

シェーダーを通じて生み出される リアルタイムエフェクト は、私たちの知覚を揺さぶります。レイマーチングで作られた無限に続くフラクタル図形、流体シミュレーションによって生成される渦巻く色彩。それらは、コンピュータの画面という限られた平面の中に、私たちが知覚しうる以上の次元と複雑さを内包しているように見えます。コードが光を彫刻し、ピクセルが詩を紡ぐこの領域で、私たちは単なる画像の制作者ではなく、新たな視覚体験と思考の可能性を探る、探求者となるのです。

p5.jsとGLSLシェーダーの組み合わせは、その深淵への、最も身近で開かれた入り口の一つです。GPUの力を借りて、あなた自身の視覚哲学を、コードで表現してみてはいかがでしょうか。そこには、まだ誰も見たことのない光景が広がっているはずです。

関連記事