p5.jsで2つのシーンをシェーダーを使ってミックスする

2022/12/17に公開

最初に

こんにちは。独楽回しeddyです。 Processing Advent Calendar 2022の17日目を担当させていただきます。
今回のテーマは2つのシーンをシェーダーを使ってミックスして表示してみるという試みについてです。
自分は2つのシーンを混ぜて1つの描画結果として表示してみたら面白いのではと思い、過去に実践してみました。シェーダーの一つの使い方として今後誰かの参考になったら良いなと思い、それを備忘録的な意味も込めて記事にしてみます。

Processing Advent Calendar 2022 : https://adventar.org/calendars/7370

どんな感じになるのか?

下記のスケッチを参照していただけますと幸いです。
https://openprocessing.org/sketch/1773178

中央に球、周りに立方体が漂うというシーンの上を円がバウンドしながら動き回ってます。基本的にはワイヤーフレーム(線のみで塗りつぶしがない)で出来ているシーンなのに、円の内に入ると塗り潰されているのを確認できます。見え方としては1つのシーンだけ映っている時より変化があって面白い見え方になるのではと思います。

実現方法

今回はp5.jsとglslを使用してます。基本的には下記の手順で実装します。

  1. p5.jsでメインで描画するシーンを作成
  2. シーンを切り替える際の基準とするためのシーンの生成
  3. 1,2をシェーダー側にテクスチャとして送信
  4. シェーダー側でどちらの色を割り当てるかを決定するための計算を行い、出力する

1つずつ解説していきます。

p5.jsでメインで描画するシーンを作成

例示したスケッチで言うと「中央に球、周りに立方体が漂うシーン」の部分を作成します。このシーンの内容はアイディア次第なので自分が作りたいように作れば問題ないです。ただ、一つだけ注意して欲しいのはcreateGraphicsという関数を使って作成する必要があるということです。

createGraphicsを使ったシーンの書き方

createGraphicsは描画結果をメモリ上に生成するための関数です。呼び出し方としては下記のようになります。

createGraphics(width, height, [renderer]); // 描画領域(横、縦)、形式(3Dのシーンを作りたい場合とかは WEBGL と指定する必要がある

黒背景、中央に白い円を描く場合を例として、createGraphicsを使わないコードの書き方と使うコードの書き方を下記に記載します。

// createGraphicsを使わない書き方
function setup(){
// 一度だけ呼ばれる
    createCanvas(600, 600);  // 600×600 の画面を作る
    background(0);  // 黒背景
}

function draw(){
// 何度も呼ばれる(1秒につき30 ~ 60回)
    background(0);  // 黒背景
    fill(255);      // 白色に塗りつぶす
    noStroke();     // 線は書かない
    ellipse(width/2, height/2, 100, 100)  // 中央に100×100の円を描く
}
// createGraphicsを使う書き方
let graphics;  // createGraphicsで生成した描画領域を格納する変数
function setup(){
// 一度だけ呼ばれる
    createCanvas(600, 600);  // 600×600 の画面を作る
    background(0);  // 黒背景
    
    graphics = createGraphics(width, height);  // 600×600 の描画領域を作る
}

function draw(){
// 何度も呼ばれる(1秒につき30 ~ 60回)
    background(0);  // 黒背景
    
    // メモリ上に確保した描画領域に対して描きたい内容を反映する
    graphics.clear();        // 描画内容を初期化する
    graphics.background(0);  // 黒背景
    graphics.fill(255);      // 白色に塗りつぶす
    graphics.noStroke();     // 線は書かない
    graphics.ellipse(width/2, height/2, 100, 100)  // 中央に100×100の円を描く
    
    // 描画領域を実際に表示する
    imageMode(CENTER);  // 画像配置の基準を中央する
    image(graphics, width/2, height/2);  // 描画領域を実際に表示する
}

fill、noStrokeといった関数についての呼び方も後者の方は生成した描画領域に対してのものになっているため長くなっております。更に最後に描画領域を表示する部分も基本的に画像を表示する際に使用するimage関数を使います。プログラム上で画像を生成して、それを表示するイメージです。

今回はcreateGraphicsを使う書き方を使用します。使わない書き方と比べるとコードの量は増えますが、こうすることで描画結果を画像として扱うことが可能になります。画像として扱えるようになることによって、シェーダー側にこの出力結果を送ることができ、様々な変化を加えることが可能な状態にできます。

自分の場合はこの書き方を使って「中央に球、周りに立方体が漂うシーン」の部分を作成しました。また、それを黒背景&塗り潰しなし(ワイヤーフレーム)のパターンと白背景&塗り潰しありのパターンの2種類作りました。

シーンを切り替える際の基準とするためのシーンの生成

例示したスケッチで言うと「白い円がバウンドしながら動き回るシーン」の部分を作成します。このシーンも前述のcreateGraphicsを使った書き方で作成します。こちらも内容はアイディア次第なので自分が作りたいように作れば問題ないです。ただ、先ほどに加えてもう一つ注意点があります。それは「必ず黒と白だけを使って作成すること」です。(理由は後述)

シェーダー側にテクスチャとして送信

p5.jsではシェーダー(glsl)を使用することが出来ます。シェーダーとは3次元物体に対して陰影や物体の質感を表現するために使用されるプログラムです。GPUというグラフィックスの処理に特化した演算装置を用いた並列計算を用いている点が特徴で、これによって同時並行で大量に処理を行う事が出来ます。
p5.jsでシェーダーを使用する場合、頂点の座標計算部分を担うバーテックスシェーダー、色情報の計算部分を担うフラグメントシェーダーの2つが必要になります。下記に簡単な流れを書いた図を示します。

p5.jsでシェーダーを使用する場合のコードを記載します。

// バーテックスシェーダー
precision mediump float;  // どれくらいの精度で計算するかを定義
 
attribute vec3 aPosition;       // 頂点の位置
attribute vec2 aTexCoord;       // 画像のuv座標
uniform mat4 uProjectionMatrix; // プロジェクション変換行列(カメラに映る範囲の決定に使う)
uniform mat4 uModelViewMatrix;  // ビュー変換行列(カメラの視点の決定に使う)
 
varying vec2 vTexCoord;         // 画像のuv座標(フラグメントシェーダーに送る)
 
void main() { 
    vec4 positionVec4 = vec4(aPosition, 1.0);  // 位置座標をvec4型にして格納
 
    gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;  // 座標変換を実行(プロジェクション変換行列×ビュー変換行列×頂点座標)
    vTexCoord = aTexCoord;  // フラグメントシェーダー側に送るためのuv座標受け渡し
}
// フラグメントシェーダー
#ifdef GL_ES
precision mediump float;  // どれくらいの精度で計算するかを定義
#endif
 
varying vec2 vTexCoord;  // バーテックスシェーダー側から送られた画像のuv座標
 
uniform sampler2D tex;   // 送られた画像の情報
 
void main() {
    // 送られた画像の色情報をそのまま返す
    vec2 uv = vTexCoord;
    vec4 texture = texture2D(tex, uv);
    gl_FragColor = texture;            
}

上記のバーテックスシェーダー、フラグメントシェーダーは頂点情報、色情報をそのまま処理して返すシンプルな内容になります。バーテックスシェーダーについては今回は特に変更は加えませんが、フラグメントシェーダーについては後ほど少し改造をします。
p5.jsでシェーダーを使用する方法についてですが、今回は下記を使用します。

createShader(vertexShaderStr, fragmentShaderStr);

先ほど記載したバーテックスシェーダー、フラグメントシェーダーのコードをそのまま文字列として変数に格納し、それを引数にしてcreateShaderを使用することで書いたシェーダーを動かすことが可能になります。シェーダーを別ファイルにしてloadShaderを呼ぶ方法もありますが、別ファイルとして用意する事自体が面倒なところがあるのでcreateShaderを使う方法の方が手軽だと思います。
実際に使用した場合のコードは下記になります。

let vs = `
// バーテックスシェーダー
precision mediump float;  // どれくらいの精度で計算するかを定義
 
attribute vec3 aPosition;       // 頂点の位置
attribute vec2 aTexCoord;       // 画像のuv座標
uniform mat4 uProjectionMatrix; // プロジェクション変換行列(カメラに映る範囲の決定に使う)
uniform mat4 uModelViewMatrix;  // ビュー変換行列(カメラの視点の決定に使う)
 
varying vec2 vTexCoord;         // 画像のuv座標(フラグメントシェーダーに送る)
 
void main() { 
    vec4 positionVec4 = vec4(aPosition, 1.0);  // 位置座標をvec4型にして格納
 
    gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;  // 座標変換を実行(プロジェクション変換行列×ビュー変換行列×頂点座標)
    vTexCoord = aTexCoord;  // フラグメントシェーダー側に送るためのuv座標受け渡し
}`;

let fs = `
// フラグメントシェーダー
#ifdef GL_ES
precision mediump float;  // どれくらいの精度で計算するかを定義
#endif
 
varying vec2 vTexCoord;  // バーテックスシェーダー側から送られた画像のuv座標
 
uniform sampler2D tex;   // 送られた画像の情報(p5.jsから送られる)
 
void main() {
    // 送られた画像の色情報をそのまま返す
    vec2 uv = vTexCoord;
    vec4 texture = texture2D(tex, uv);
    gl_FragColor = texture;            
}`;

let graphics;      // 描画する内容
let useShader;  // シェーダー

function setup(){
// 一度だけ呼ばれる
    createCanvas(600, 600, WEBGL);  // 600×600 の画面を作る
    background(0);  // 黒背景
    
    graphics = createGraphics(width, height);  // 600×600 の描画領域を作る
    useShader = createShader(vs, fs);  // シェーダーを作る
}

function draw(){
// 何度も呼ばれる(1秒につき30 ~ 60回)
    background(0);  // 黒背景
    
    // メモリ上に確保した描画領域に対して描きたい内容を反映する
    graphics.clear();        // 描画内容を初期化する
    graphics.background(0);  // 黒背景
    graphics.fill(255);      // 白色に塗りつぶす
    graphics.noStroke();     // 線は書かない
    graphics.ellipse(width/2, height/2, 100, 100)  // 中央に100×100の円を描く
    
    shader(useShader);  // シェーダーの実行を宣言する
    useShader.setUniform("tex", graphics);    // 作った画像(graphics)をシェーダー側に送る
    
    // 描画領域を実際に表示する
    rectMode(CENTER);
    rect(0, 0, width, height);
}

シェーダー側で頂点情報、色情報に変化を加えずにそのまま返しているため出力される結果はcreateGraphicsで書いた黒背景に白い円の絵になります。

シェーダー側でどちらの色を割り当てるかを決定するための計算を行い、出力する

ここまでcreateGraphicsを使った描画領域、p5.jsでのシェーダーの準備について説明してきました。ここまでの内容に追加でコードを加えることで今回のテーマである「2つのシーンをシェーダーを使ってミックスする」ことが出来ます。
この記事の上で用意したものとそのコードについて説明していきます。

  1. 表示するシーンの1つ目(createGraphicsで作ったものならなんでもOK)
  2. 表示するシーンの2つ目(createGraphicsで作ったものならなんでもOK)
  3. 表示するシーンを切り替えるためのシーン(createGraphicsで作ったものでかつ色が黒と白のみならなんでもOK)

これら3つの画像をシェーダー側に送ります。そして3の画像の色情報を元にして1の画像か2の画像を表示するかを制御します。
コードは下記になります。

let vs = `
// バーテックスシェーダー
precision mediump float;  // どれくらいの精度で計算するかを定義
 
attribute vec3 aPosition;       // 頂点の位置
attribute vec2 aTexCoord;       // 画像のuv座標
uniform mat4 uProjectionMatrix; // プロジェクション変換行列(カメラに映る範囲の決定に使う)
uniform mat4 uModelViewMatrix;  // ビュー変換行列(カメラの視点の決定に使う)
 
varying vec2 vTexCoord;         // 画像のuv座標(フラグメントシェーダーに送る)
 
void main() { 
    vec4 positionVec4 = vec4(aPosition, 1.0);  // 位置座標をvec4型にして格納
 
    gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;  // 座標変換を実行(プロジェクション変換行列×ビュー変換行列×頂点座標)
    vTexCoord = aTexCoord;  // フラグメントシェーダー側に送るためのuv座標受け渡し
}`;

let fs = `
// フラグメントシェーダー
#ifdef GL_ES
precision mediump float;  // どれくらいの精度で計算するかを定義
#endif
 
varying vec2 vTexCoord;  // バーテックスシェーダー側から送られた画像のuv座標
 
uniform sampler2D tex1;   // 送られた画像の情報1(p5.jsから送られる)
uniform sampler2D tex2;   // 送られた画像の情報2(p5.jsから送られる)
uniform sampler2D switchTex;   // 送られた画像の情報2(p5.jsから送られる)
 
void main() {
    // 送られた画像の色情報をそのまま返す
    vec2 uv = vTexCoord;
    vec4 graphics1 = texture2D(tex1, uv);  // 送られた画像1の色情報
    vec4 graphics2 = texture2D(tex2, uv);  // 送られた画像2の色情報
    vec4 switchGraphics = texture2D(switchTex, uv);   // 送られた画像3の色情報
    gl_FragColor = mix(graphics1, graphics2, switchGraphics);  // switchGraphicsの黒の部分がgraphics1の色、白の部分はgraphics2の色を使用する
}`;

let graphics1;  // 描画する内容1
let graphics2;  // 描画する内容2
let switchGraphics;   // 表示を切り替える元となる描画内容
let useShader;  // シェーダー
let balls = [];  // 動くボールを表示させるための配列

function setup(){
// 一度だけ呼ばれる
    createCanvas(600, 600, WEBGL);  // 600×600 の画面を作る
    background(0);  // 黒背景
    
    graphics1 = createGraphics(width, height);  // 600×600 の描画領域を作る
    graphics2 = createGraphics(width, height);  // 600×600 の描画領域を作る
    switchGraphics = createGraphics(width, height);  // 600×600 の描画領域を作る
    
    balls.push(new Ball());
    balls.push(new Ball());
    balls.push(new Ball());

    useShader = createShader(vs, fs);  // シェーダーを作る
}

function draw(){
// 何度も呼ばれる(1秒につき30 ~ 60回)
    background(0);  // 黒背景
    
    // メモリ上に確保した描画領域に対して描きたい内容を反映する
    graphics1.clear();        // 描画内容を初期化する
    graphics1.background(0);  // 黒背景
    graphics1.fill(255);      // 白色に塗りつぶす
    graphics1.noStroke();     // 線は書かない
    graphics1.ellipse(width/2, height/2, 100, 100)  // 中央に100×100の円を描く
    
    graphics2.clear();        // 描画内容を初期化する
    graphics2.background(255);  // 白背景
    graphics2.stroke(0);      // 線の色を黒色に
    let col = 10;    // 描画する四角形の列 
    let row = 10;       // 描画する四角形の列
    let len = width/col;     // 四角形の長さ
    // 四角形を配置していく
    for(let i = 0; i < col; i++){
	for(let j = 0; j < row; j++){
	    let x = len * i;
	    let y = len * j;
	    graphics2.rect(x, y, len, len);
	}
    }
    
    switchGraphics.clear();        // 描画内容を初期化する
    switchGraphics.background(0);  // 白背景
    // switchGraphics上にバウンドしながら動くボールを描画する
    for(let i = 0; i < balls.length; i++){
	balls[i].update();
	balls[i].boundary();
	balls[i].display(switchGraphics);
    }

    shader(useShader);  // シェーダーの実行を宣言する
    useShader.setUniform("tex1", graphics1);    // 作った画像(graphics1)をシェーダー側に送る
    useShader.setUniform("tex2", graphics2);    // 作った画像(graphics2)をシェーダー側に送る
    useShader.setUniform("switchTex", switchGraphics);    // 作った画像(switchGraphics)をシェーダー側に送る
    
    // 描画領域を実際に表示する
    rectMode(CENTER);
    rect(0, 0, width, height);
}

// 動き回るボール
function Ball(){
        this.pos = createVector(random(width), random(height));
        this.vel = p5.Vector.random2D().mult(random(3, 6));
        this.radius = random(140, 200);
	
        this.update = function()
        {
	this.pos.add(this.vel);
        }

        this.boundary = function(){
	if(this.pos.x > width - this.radius * 0.5){
	        this.pos.x = width - this.radius * 0.5;
	        this.vel.x *= -1;
	}
	if(this.pos.x <  this.radius * 0.5){
	        this.pos.x = this.radius * 0.5;
	        this.vel.x *= -1;
	}
	if(this.pos.y > height - this.radius * 0.5){
	        this.pos.y = height - this.radius * 0.5;
	        this.vel.y *= -1;
	}
	if(this.pos.y <  this.radius * 0.5){
	        this.pos.y = this.radius * 0.5;
	        this.vel.y *= -1;
	}
        }

        this.display = function(gr)
        {
                gr.fill(255);
	gr.noStroke();
	gr.ellipse(this.pos.x, this.pos.y, this.radius, this.radius);
        }
}

3種類の画像をそれぞれシェーダー側に送っているため、setUniformをする回数も増えております。setUniformはp5.jsで扱っている変数をシェーダー側に変数を送る時に使用します。

setUniform(valueName, value); 
...
shader(useShader);  // シェーダーの実行を宣言する
useShader.setUniform("tex1", graphics1);    // 作った画像(graphics1)をシェーダー側に送る
useShader.setUniform("tex2", graphics2);    // 作った画像(graphics2)をシェーダー側に送る
useShader.setUniform("switchTex", switchGraphics);    // 作った画像(switchGraphics)をシェーダー側に送る
...

それに伴い、フラグメントシェーダーのコードも変更します。

...
varying vec2 vTexCoord;  // バーテックスシェーダー側から送られた画像のuv座標
 
uniform sampler2D tex1;   // 送られた画像の情報1(p5.jsから送られる)
uniform sampler2D tex2;   // 送られた画像の情報2(p5.jsから送られる)
uniform sampler2D switchTex;   // 送られた画像の情報2(p5.jsから送られる)
 
void main() {
...

setUniformの第一引数で書いた文字列をそのままシェーダー側で変数名として使用しなければならないのが注意点です。
そして実際に表示する描画内容を切り替えているのが最後に呼ばれているmix関数です。

mix(x, y, a);  // x * (1 - a) + y * a を返す

引数のaは0 ~ 1の値を取り、0の場合はx、1の場合はyを返すように動きます。p5.jsではlerp関数が動作的に同じ関数になります。

...
void main() {
    // 送られた画像の色情報をそのまま返す
    vec2 uv = vTexCoord;
    vec4 graphics1 = texture2D(tex1, uv);  // 送られた画像1の色情報
    vec4 graphics2 = texture2D(tex2, uv);  // 送られた画像2の色情報
    vec4 switchGraphics = texture2D(switchTex, uv);   // 送られた画像3の色情報
    gl_FragColor = mix(graphics1, graphics2, switchGraphics);  // switchGraphicsの黒の部分がgraphics1の色、白の部分はgraphics2の色を使用する
}`;

まず3枚の画像の色情報を取得します。graphics1とgraphics2は表示したい描画内容、switchGraphicsは描画領域を切り替えるために使用する境界みたいなものです。switchGraphicsの色情報をmix関数の第3引数に使用することによって、switchGraphicsの黒い部分にgraphics1の色を、白い部分にgraphics2を表示させることが出来ます。切り替えるための画像が黒か白にした理由は、glslにおいてRGBが全て0の場合が黒、全て1の場合が白になるという部分ことから、その方がはっきりと切り替わるためです。
流れを図示すると下記になります。

最終的な出力結果は下記画像のようになります。

動き回る白い円の部分に画像2枚目の格子模様、それ以外は画像1枚目のシンプルな描画内容が表示されております。このように2つの出力結果をミックスして表示することができます。表示する内容を塗り潰し有無以外を同じにすることで一部分だけ塗り潰して見えるような表現も可能です。

最後に

少しでも記事がお役に立てれると幸いです。読んでくださった皆様、ありがとうございました!

Discussion