three.jsでシェーダーを使う

Blog

みなさまお久しぶりです。思い切って髪を染めたら社内の方々に驚かれた中田です。
入社から既に半年が経ったことに驚きを感じながら、今回2回目となるブログを書かせていただきます。

前回のブログでthree.jsというライブラリを使い球体をつくった私ですが、WebGLの学習のために最近通い始めたGLSLスクールで、シェーダーというものを学び、このシェーダーがWebGLを扱う上で重要な技術であることを知りました。
そこで今回は、three.jsにこのシェーダーを組み合わせて綺麗なグラデーションをつくってみようと思います!

とはいっても、いやいや…まずシェーダーってなんだよ…と、思った方がほとんどだと思いますので、まず最初にシェーダーについて説明し、その後でグラデーションのつくり方について説明していきたいと思います。

シェーダーとは

シェーダーは、GPUを利用することで高速に動作する、グラフィックスを描画するための概念や技術のことです。普段、私たちが書いているJavaScriptなどではCPUが利用されていますが、シェーダーではGPUの方が利用されています。
そして、このGPU側で動作するシェーダーをCPU側のJavaScriptなどから触れるようにするためにWebGLのようなAPIが存在し、そのWebGLでシェーダーを記述するためにGLSLという言語がある、という関係になっています。
図で表すと以下のような感じです。

次は、シェーダーの中でも基本となる2つのシェーダーについて説明していきます。今回のデモも次の2つのシェーダーのみを使っています。

頂点シェーダー

1つ目が頂点シェーダーです。名前にもあるように頂点を処理するためのシェーダーで、WebGL(というより3DCG)では必ず必要となるシェーダーになります。
ここで必ず必要と説明したのは、WebGLでは複数の頂点を組み合わせることで三角形や四角形、線などの図形をつくっているからです。(そもそも点がなかったら、線も面も何もつくれないですよね。)

フラグメントシェーダー

2つ目がフラグメントシェーダーです。頂点シェーダーとは異なり名前からは想像しにくいですが、要は頂点シェーダーを組み合わせることでできた図形のピクセルひとつひとつを処理するためのシェーダーになります。
今回つくるグラデーションをはじめ、近年サイトで見る色が変化する、歪む、粒子のようにバラバラになる等の動きのほとんどはこのフラグメントシェーダーでできています。

グラデーションをつくる

前置きが長くなってしまいましたが、ここからは実際にコードを見ながらグラデーションのつくり方について説明していきます…!
ただ、ひとつひとつ説明すると情報量が膨大になってしまうので、いくつか要点を絞りながら説明をしていきます。
以下が完成形になります。

See the Pen three.js Gradation by tomoyuki nakata (@nakata02576) on CodePen.

JavaScript

まず最初にJavaScriptから見ていきます。今回はクラス構文を使用して2つのクラスに分け、最終的にそれぞれのクラスのインスタンスを生成&各種処理の実行をしています。それぞれのクラスの中で行っている処理については以下の通りです。

class Stage {
  // シーンやレンダラー、カメラ等の生成
}

class Mesh {
  // メッシュと、メッシュをつくるために必要なジオメトリとマテリアルの生成
}

(() => {
  // インスタンス生成および初期化やリサイズ、アニメーション等の処理の実行
}

three.js

次にthree.jsです。ほとんどは前回使ったものと同じですが、いくつか新しいものが登場しているので、それについて説明します。
1つ目がカメラです。今回つくるグラデーションは遠近法を考慮せず、カメラから見た時に常に平行になっていればいいので、遠近感のないOrthographicCameraを使っています。

class Stage {
  constructor() {
    ・・・
    this.cameraParam = {
      left: -1,
      right: 1,
      top: 1,
      bottom: 1,
      near: 0,
      far: -1
    };
    ・・・
  }
  _setCamera() {
    
    if( !this.isInitialized ){
      this.camera = new THREE.OrthographicCamera(
        this.cameraParam.left,
        this.cameraParam.right,
        this.cameraParam.top,
        this.cameraParam.bottom,
        this.cameraParam.near,
        this.cameraParam.far
      );
    }
    ・・・
  }

2つ目がuniformsという部分です。今回のようにthree.jsでシェーダーを使う際に、シェーダーの中にuniformで宣言した変数がある場合はこのuniformsの宣言の指定が必須になります。uniformsを指定する時は以下のように変数名、type(データ型)、value(値)の3つを指定します。変数についてはこの後説明します。

class Mesh {
  constructor(stage) {
    ・・・
    
    this.uniforms = {
      time: { type: "f", value: 1.0 }
    };
    ・・・
}

3つ目は最も重要なポイントであるメッシュを生成している部分です。ジオメトリではPlaneBufferGeometryを使って板状のオブジェクトを生成し、マテリアルではRawShaderMaterialを使って頂点シェーダーとフラグメントシェーダーの記述をもとにオブジェクトの見た目をつくっています。
要は、板状のオブジェクトの中にこの後説明するシェーダーを使ってグラデーションを入れてあげている、といった感じですね!

_setMesh() {
    const geometry = new THREE.PlaneBufferGeometry(2, 2);
    const material = new THREE.RawShaderMaterial({
      vertexShader: document.getElementById("js-vertex-shader").textContent,
      fragmentShader: document.getElementById("js-fragment-shader").textContent,
      uniforms: this.uniforms
    });
    
    ・・・
  }

シェーダー

最後に本題のシェーダーです。今回はHTML内にシェーダーを記述し、それを読み込むようにしています。読み込んでいる部分は上のコードで説明したRawShaderMaterialの部分になります。

<!-- vertexShader -->
<script id="js-vertex-shader" type="x-shader/x-vertex">
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv;

void main()	{
  vUv = uv;
  gl_Position = vec4(position, 1.0);
}
</script>

<!-- fragmentShader -->
<script id="js-fragment-shader" type="x-shader/x-fragment">
precision mediump float;
uniform float time;
varying vec2 vUv;
void main() {
  vec2 p = vUv * 1.0 - 0.5;
  float r = 1.0 + 0.5 * (sin(5.0 * p.x + time));
  float g = 1.0 + 0.5 * (sin(5.0 * p.y) + sin(time + 2.0 * p.x));  
  float b = 1.0 + 0.5 * (sin(5.0 + p.x * p.y * 17.0) + sin(time * 0.4  + 4.0 * p.y));
  gl_FragColor = vec4(r, g, b, 1.0);
}
</script>

はい、わけわからないコードが沢山でてきましたね。笑
頂点シェーダーとフラグメントシェーダーに分けて、詳しく見ていきます。

まずは頂点シェーダーです。<script id=”js-vertex-shader” type=”x-shader/x-vertex”>で囲まれている下記の部分が頂点シェーダーの記述になります。

<!-- vertexShader -->
<script id="js-vertex-shader" type="x-shader/x-vertex">
attribute vec3 position;
attribute vec2 uv;
varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = vec4(position, 1.0);
}
</script>


上から順に見ていくと、なにやらattributeやvaryingなどの名前で何かが宣言されているようになっているのですが、これが変数になります。
変数というと、JavaScriptだとvar hogeのように宣言しますが、GLSLの変数では変数の名前に加えて、ストレージ修飾子とデータ型というものが必要になります。ストレージ修飾子は変数の種類(変数の持つ意味合い)を表す修飾子で、データ型はそのままの意味で変数のデータ型を表すものになっています。
最初のattribute vec3 positionで例えると、attributeがストレージ修飾子、vec3がデータ型、positionが変数の名前となります。
ストレージ修飾子とデータ型については以下のようなものがあります。

次に関数についてです。void main(){}で囲まれている下記の部分が関数になります。

void main()	{
  vUv = uv;
  gl_Position = vec4(position, 1.0);
}

今回はattributeで宣言しているuvに対してグラデーションの処理を行いたいので、uvをvaryingで宣言したvUvに代入しています。上の図でも書いたようにvaryingはシェーダー間のやり取りに使う変数なので、これでuvがフラグメントシェーダーで使えるようになるわけですね!また、gl_Positionについては、頂点シェーダーでgl_Positionが必要不可欠なので記述していますが、今回は使っていないので気にしなくて大丈夫です。

最後にフラグメントシェーダーです。基本的には頂点シェーダーと同じように記述しますが、いくつか新しいものが出てきているので説明します。該当の部分は下記です。

<!-- fragmentShader -->
<script id="js-fragment-shader" type="x-shader/x-fragment">
precision mediump float;
uniform float time;
varying vec2 vUv;
void main() {
  vec2 p = vUv * 1.0 - 0.5;
  float r = 1.0 + 0.5 * (sin(5.0 * p.x + time));
  float g = 1.0 + 0.5 * (sin(5.0 * p.y) + sin(time + 2.0 * p.x));  
  float b = 1.0 + 0.5 * (sin(5.0 + p.x * p.y * 17.0) + sin(time * 0.4  + 4.0 * p.y));
  gl_FragColor = vec4(r, g, b, 1.0);
}
</script>

まず、最初に書かれているprecision mediump floatですが、これは精度修飾子と呼ばれるものになります。
精度修飾子とは、どの程度の精度でデータを扱うのかを指定するもので、今回使っているmediump以外に、lowpとhighpがあります。(lowp→mediump→highpの順で精度が高くなります)
ここであえてmediumpを使っているのは、highpを指定すると負荷がかかりすぎてしまう恐れがあるためです。
また、timeという変数はグラデーションをアニメーションさせるのに使うので宣言しています。これでシェーダーで入れたグラデーションが、時間経過とともに色が変化するようになります。

そして関数です。頂点シェーダーと同じですが、念のため以下に抜粋します。

void main() {
  vec2 p = vUv * 1.0 - 0.5;
  float r = 1.0 + 0.5 * (sin(5.0 * p.x + time));
  float g = 1.0 + 0.5 * (sin(5.0 * p.y) + sin(time + 2.0 * p.x));  
  float b = 1.0 + 0.5 * (sin(5.0 + p.x * p.y * 17.0) + sin(time * 0.4  + 4.0 * p.y));
  gl_FragColor = vec4(r, g, b, 1.0);
}

何やら複雑な計算をしていますね。
今回この関数の中で行っていることを説明すると、floatで宣言したr(赤)、g(緑)、b(青)のそれぞれの色に対してsin波の動きとtimeを使った値をかけることで、r、g、bのそれぞれの値が時間経過とともに変化するようになっている、といったところでしょうか。
そして最後に、この3つの値をgl_FragColorのr、g、bに入れているので、このような綺麗なグラデーションによる変化が起きています。(最後の1.0は透明度になります)
また、ここで足したりかけたりしている値は適当に入れているだけなので、自分で色々なところの値を変えて、グラデーションにどのような変化が起きるか試してみると面白いと思います…!

さいごに

いかがだったでしょうか?
見慣れない単語やコードばかりでとっつきにくい…と思った方もいると思いますが、シェーダーを使いこなせるようになると表現の幅がグッと広がり様々な表現ができるようになるので、興味が湧いた方は是非シェーダーを触ってみてください!
以上、中田でした〜

参考サイト