LoginSignup
44
24

More than 3 years have passed since last update.

p5.jsでトゥーンシェーダーを書く

Posted at

この記事はProcessing Advent Calendar 2020 6日目の記事です。

0. はじめに

0. 1 シェーダーって何?

シェーダー(shader)とは、動詞"shade"「陰をつける,明暗(濃淡)をつける」に"-er"「~するもの」がついた名前の通り、「3DCGで陰影処理を行うプログラム」のことです。
p5.jsのWEBGLモードにおいては、loadshader()で外部ファイルとして読み込む、またはcreateshader()でString型として記述したものを読み込むことによって、自分で書いたシェーダーを使うことができます。
自作のシェーダーを使わない場合には、p5.jsライブラリ標準のシェーダーが使われます。シェーダーを自分で書く際には、はじめのうちは標準シェーダーを書き換えるような形で行うことがおすすめです。

0. 2 トゥーンシェーダーって何?

「トゥーンシェーダー」とは、通常ではグラデーションのようになる陰をベタ塗りにすることでカートゥーン調(セルアニメ調)に見せる「トゥーンシェーディング」を行うためのシェーダーのことです。
トゥーンシェーダーによって、↓のような陰をつけることができます。

 

1. トゥーンシェーダーを書く

ここでは、loadshader()を使った方法で、トゥーンシェーダーの書き方を説明していきます。

1. 1 メインのコード

メインとなるコードは下の通りです。

sketch.js
let toonShader;

function preload() {
  toonShader = loadShader("toon.vert", "toon.frag"); // シェーダーファイルの読み込み
}

function setup() {
  createCanvas(800, 600, WEBGL); // WEBGLモードにする
  shader(toonShader); // シェーダーの設定
}

function draw() {
  // 背景・光の設定
  background(250, 200, 200);
  directionalLight(255, 255, 255, 0.5, 0.5, -1);

  // 回転するトーラスの描画
  rotateX(millis() / 1000);
  rotateY(millis() / 3000);  
  noStroke();
  fill(240, 220, 120);
  torus(120, 60, 40, 40);
}

まず、preload()内でloadShader()を使い、変数toonShaderに後述する外部ファイル"toon.vert""toon.frag"を読み込ませます。
次に、setup()内でshader()を使い、レンダラーのシェーダーを、自作シェーダーtoonShaderに設定します。
今回作るトゥーンシェーダーは、平行光源に対応したものなので、directionalLight()を設定します。

必要な設定は以上で、あとは好きなようにtorus()sphere()などの立体を描画するコードを書きます。

1.2 バーテックスシェーダー

バーテックスシェーダー(vertex shader)とは、立体の頂点ごとに位置や法線ベクトルを計算するシェーダーです。
今回は、外部ファイル"toon.vert"にバーテックスシェーダーを記述します。

標準シェーダーのうちの一つ、"normal.vert"に加筆する形で記述していきます。

toon.vert
// ☆: 加筆箇所
attribute vec3 aPosition; // 頂点の位置ベクトル
attribute vec3 aNormal; // 頂点の法線ベクトル
attribute vec2 aTexCoord; // 頂点のUV座標(テクスチャーを貼る座標)

uniform mat4 uModelViewMatrix; // 位置ベクトルを座標変換する行列その1
uniform mat4 uProjectionMatrix; // 位置ベクトルを座標変換する行列その2
uniform mat3 uNormalMatrix; // 法線ベクトルを座標変換する行列

uniform vec3 uLightingDirection[5]; // ☆平行光源の方向ベクトル

varying vec3 vVertexNormal; // 座標変換後の法線ベクトル
varying highp vec2 vVertTexCoord; // UV座標(テクスチャーを貼る座標)
varying vec3 vLightDirection; // ☆平行光源の方向ベクトルの逆

void main(void) {
  vec4 positionVec4 = vec4(aPosition, 1.0);
  gl_Position = uProjectionMatrix * uModelViewMatrix * positionVec4;
  vVertexNormal = normalize(vec3( uNormalMatrix * aNormal ));
  vVertTexCoord = aTexCoord;
  vLightDirection = -uLightingDirection[0]; // ☆平行光源の方向ベクトルをフラグメントシェーダーに渡す
}

void main(void){}の前に並んでいるのが、このシェーダーが他のプログラムとやり取りをする変数です。

ここで、シェーダーの変数について簡単に解説します。
例えば、下記のような変数について、

attribute vec3 aPosition;

1番最初のattributeを修飾子、2番目のvec2をデータ型、3番目のaPositionを変数名と呼びます。
修飾子には3種類あり、プログラム間でどのようなやり取りをするかによって変わります。

修飾子名 役割
attribute javascriptのプログラムからバーテックスシェーダーに送られる各頂点が持つ変数
uniform javascriptのプログラムからいずれかのシェーダーに送られる全頂点で一律の変数
varying バーテックスシェーダーからフラグメントシェーダーに送られる変数

attribute修飾子付き変数は、p5.jsライブラリで定義されたもののみが使え、uniform修飾子付き変数は、ライブラリで定義されたもののほか、setUniform()を使い、jsコード内で自分で定義することも可能です(p5.jsで使えるuniform変数名は、ライブラリ内の_setFillUniform()から読み解くことができます)。また、varying修飾子付き変数は、シェーダー間でのみ使われるため、全て自分で定義する必要があります。
データ型は、ベクトルを表すvec*、行列を表すmat*の他、浮動小数点数を表すfloatなどが使えます。

今回のバーテックスシェーダーの加筆箇所では、平行光源の方向ベクトルが格納されたuniform修飾子付き変数uLightingDirection[5]のうちの1番目の要素をフラグメントシェーダーに渡すために、varying修飾子付き変数vLightDirectionに代入しています。
マイナスをつける意味は、この後のフラグメントシェーダーの項で説明します。

1.3 フラグメントシェーダー

フラグメントシェーダー(fragment shader)とは、画面のピクセルごとの色を計算するシェーダーです。
今回は、外部ファイル"toon.frag"にフラグメントシェーダーを記述します。

今回のトゥーンシェーダーのメインとなる計算をするのが、このフラグメントシェーダーです。

toon.frag
precision highp float;

uniform vec4 uMaterialColor; // fill()で指定された色(RGBA)
uniform sampler2D uSampler; // texture()で指定された画像
uniform bool isTexture; // textureを使うか否か

varying vec3 vVertexNormal; // 座標変換後の法線ベクトル
varying highp vec2 vVertTexCoord; // UV座標(テクスチャーを貼る座標)
varying vec3 vLightDirection; // ☆平行光源の方向ベクトルの逆

void main(void) {
  vec3 direction = normalize(vLightDirection);
  vec3 normal = normalize(vVertexNormal);
  float intensity = max(0.0, dot(direction, normal)); // 平行光源と法線が成す角をθとしたときのcosθ

  vec4 tintColor; // 影の色
  if (intensity > 0.95) {
      tintColor = vec4(1.0, 1.0, 1.0, 1.0);
  } else if (intensity > 0.5) {
      tintColor = vec4(0.9, 0.8, 0.8, 1.0);
  } else if (intensity > 0.25) {
      tintColor = vec4(0.7, 0.5, 0.5, 1.0);
  } else {
      tintColor = vec4(0.5, 0.2, 0.2, 1.0);
  }

  if(!isTexture) { // テクスチャーを使わないとき
    gl_FragColor = uMaterialColor * tintColor;
  }
  else { // テクスチャーを使うとき
    gl_FragColor = texture2D(uSampler, vVertTexCoord) * tintColor;
  }
}

void main(void){}内の計算を、上から順に説明していきます。

  vec3 direction = normalize(vLightDirection);
  vec3 normal = normalize(vVertexNormal);
  float intensity = max(0.0, dot(direction, normal)); // 平行光源と法線が成す角をθとしたときのcosθ

GLSLにおいて、normalize(v)はベクトルの正規化(向きは同じのまま、大きさを1にする)、dot(v1, v2)はベクトルの内積(出力はfloat型)、max(a, b)は2つの値の大きい方を表します。
つまり、上記のコードの1, 2行目は平行光源の方向ベクトルの逆と、法線ベクトルを正規化し大きさを1にする計算だとわかります。
3行目のdot(direction, normal)では、1, 2行目で正規化した2つのベクトルの内積を計算していますが、ベクトルの内積は、$\vec{a} \cdot \vec{b} = |\vec{a}| |\vec{b}| \cos θ$ となることから、dot(direction, normal)が表すのは、2つのベクトルの成す角をθとしたときのcosθだとわかります。これにmax(0.0, dot(direction, normal))がついているため、cosθが0以下のの時はintensityは0、0以上の時はintensityはcosθになります。よって、intensityは法線ベクトルが平行光源の方向ベクトルの逆と同じ向きになるときに最大値1をとり、この2つのベクトルが成す角が90度を超えると0になる値だとわかります。
この計算を行うため、バーテックスシェーダーでは平行光源の方向ベクトルにマイナスをつけました。

次のif文が使われるかたまりが、影の色を計算する部分です。

  vec4 tintColor; // 影の色
  if (intensity > 0.95) {
      tintColor = vec4(1.0, 1.0, 1.0, 1.0);
  } else if (intensity > 0.5) {
      tintColor = vec4(0.9, 0.8, 0.8, 1.0);
  } else if (intensity > 0.25) {
      tintColor = vec4(0.7, 0.5, 0.5, 1.0);
  } else {
      tintColor = vec4(0.5, 0.2, 0.2, 1.0);
  }

tintColorは後ほど立体の色に掛け合わせる4次元のベクトルで、4つの要素は前から順にRGBAを表します。
通常の陰影の計算においては、intensityのような連続した値を影の色として掛け合わせることでグラデーションのような陰影をつけますが、トゥーンシェーディングにおいては、intensityの値に応じて、tintColorを不連続的に変化させ、ベタ塗りの影を表現します。
if文内に使われている0.95や0.5のような数字は、出力の見た目がよくなるように自分で調整する値です。
tintColorの色は、intensityが小さくなるにつれて暗くなっていくように設定します。今回は、赤みを帯びた影になるよう、Rの値をGとBに比べて大きく設定してみました。

最後に、立体の色にtintColorを掛け合わせ、色の出力gl_FragColorに代入します。

  if(!isTexture) { // テクスチャーを使わないとき
    gl_FragColor = uMaterialColor * tintColor;
  }
  else { // テクスチャーを使うとき
    gl_FragColor = texture2D(uSampler, vVertTexCoord) * tintColor;
  }

gl_FragColorはRGBAの要素を持つ4次元ベクトルで、この値が出力される色の値となります。
テクスチャーを使わない場合においては、fill()で指定された色にそのまま掛ければよいのですが、テクスチャーを使う場合、texture2D()という関数が登場します。この関数では、第1引数にテクスチャー画像、第2引数にUV座標を指定することで、そのUV座標における画像のRGBAを取り出すことができます。テクスチャーを使う場合においては、こうして画像から取り出した色に、tintColorを掛けます。

1. 4 出力結果

donut_1.png

2. アウトライン(輪郭線)をつける

トゥーンシェーディングでは、しばしばアウトライン(輪郭線)をつけます。
シェーダーのコードは前述のものと同様のため、割愛します。

2. 1 アウトラインをつける仕組み

アウトラインをつけるためには、WebGLの「カリング」という機能を使います。
カリングとは、「面の表裏によって描画するか否かを変える機能」です。
このカリングを使ってどのようにアウトラインをつけるかについては、下記のサイトの図がとても参考になります。
wgld.org | WebGL: トゥーンレンダリング |
大雑把に説明すると、カリングで表面を描画する状態にしたうえで立体を描画し、そこにカリングで裏面を描画する状態にしたうえでちょっとだけ膨らませた黒の立体を描画させると、あたかも枠線がついたように見える、というわけです。

2. 2 メインのコード

メインとなるコードは、下の通りです。

sketch.js
let toonShader;
let gl;

function preload() {
  toonShader = loadShader("toon.vert", "toon.frag") // シェーダーファイルの読み込み
}

function setup() {
  createCanvas(800, 600, WEBGL); // WEBGLモードにする
  shader(toonShader); // シェーダーの設定

  gl = this._renderer.GL; // WebGLレンダリングコンテキストを取得
  gl.enable(gl.CULL_FACE); // カリングを有効にする
}


function draw() {
  // 背景・光の設定
  background(250, 200, 200);
  directionalLight(255, 255, 255, 0.5, 0.5, -1);

  // 回転するトーラスの描画
  rotateX(millis() / 1000);
  rotateY(millis() / 3000);
  noStroke();

  // トーラスの中身の描画
  gl.cullFace(gl.FRONT);
  fill(240, 220, 120);
  torus(120, 60, 40, 40);

  // トーラスのアウトラインの描画
  gl.cullFace(gl.BACK);
  fill(0);
  torus(120, 65, 40, 40);
}

カリングを有効にするためには、WebGLコンテキストを取得する必要があるため、glという変数を用意し、そこに格納します(this._renderer.GLを使うとWebGLコンテキストが得られることは、p5.jsライブラリのrendererについての記述から読み解くことができます)。
gl.enable(gl.CULL_FACE)でカリングを有効にしたのち、表面を描画する状態で中身用のトーラスを、裏面を描画する状態で黒に塗り小半径を少し大きくしたアウトライン用のトーラスを描画します。

2. 3 出力結果

donut_2.png

3. 最後に

3. 1 トゥーンシェーダーで私が作った作品の紹介

トゥーンシェーダーと、他の手法を組み合わせることで、p5.jsで下のような作品を作ることができます。

3. 2 参考になるサイト

WebGL 開発支援サイト wgld.org
WebGLの使い方が0から丁寧に説明されたサイトです。p5.jsのライブラリのコードと合わせて見ると、p5.jsのWEBGLモードの仕組みがよくわかります。

processing/p5.js: p5.js is a client-side JS platform that ... - GitHub
p5.jsライブラリのWEBGLモードのソースコードです。WebGLの機能を使いたいときに読み解いてみると、いろいろな発見があります。

3. 3 あとがき

シェーダーやWebGLの機能を使うことで、p5.jsにおける3D表現の幅がグンと広がります!
この記事が、p5.jsでのシェーダーの記述に挑戦するきっかけとなれば幸いです。

44
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
24