[Three.js] Three.jsでシェーダー入門

はじめに

ejgvt-861ga.gif デモページ

Three.jsBabylon.jsのようなWebGLライブラリで、シェーダーを利用すると表現力が向上します。

シェーダーは難解です。これから学習を始める人にとって、どこから手をつけていいのかわからなくなります。WEB上にはシェーダーを扱ったさまざまな記事がありますが、どのような読者を想定して書かれているかは千差万別です。著者が想定している前提知識を持っておらず、読者が内容を理解できないことがあります。

この記事ではThree.jsを利用することを前提に、シェーダーを利用してマテリアルを自作するまでの方法を解説します。

GitHub - MasatoMakino/threejs-shader-materials: Collection of shader materials for three.js
https://github.com
GitHub - MasatoMakino/threejs-postprocess: Collection of post process module for three.js
https://github.com

上の2つのリポジトリは、私がシェーダーの学習の過程で製作したものです。先頭のGIFアニメの元のマテリアルもこのリポジトリで公開しています。ご参考までに。

想定する読者

この記事は

  • 現在Three.jsを利用している。
  • WebGL, GLSLの知識はない。
  • シェーダーを扱いたい。

という読者を想定しています。そのため、Three.jsのインストールガイドなどの内容は取り扱いません。

想定する環境

この記事はThree.jsr108を前提にしています。

バージョンが異なる場合、この記事の内容はそのまま適用できない場合があります。お手元の環境のバージョンを確かめてからお読みください。

Three.jsとシェーダー

シェーダーとは

シェーダーとは、CGレンダリングをするプログラムを指します。Three.jsでいうシェーダーとは、GPU上で実行され、機能を書き換えることのできるプログラマブルシェーダーのことを指します。プログラマブルシェーダーのおかげで、GPUの膨大な処理能力を以前より簡単に利用できるようになりました。

GLSLとは

WebGLでは、このプログラマブルシェーダーのためにGLSL(OpenGL Shading Language)という言語を使います。この言語はC言語をベースに開発されました。

Three.jsでシェーダーを扱う方法

Three.jsではシェーダーを扱う方法が2つあります。ShaderMaterialとShaderPassです。

ShaderMaterial

ShaderMaterialはその名の通り、任意のシェーダーを組み込めるマテリアルです。

公式ドキュメント : ShaderMaterial

ShaderPass

ShaderPassは出力した画像をポストプロセス(後処理)する仕組みです。まずWebGLRendererで画面出力し、その結果をテクスチャに格納します。続くShaderPassはそのテクスチャに対してエフェクトをかけ、次のShaderPassに結果を渡します。このテクスチャのバケツリレーによって複雑なエフェクトをかけられます。 普段PhotoShopをあつかうデザイナーの方には、調整レイヤーのようなものだと考えていただければわかりやすいと思います。

Three.jsではこのバケツリレーを補助するEffectComposerというクラスがあります。ShaderPassとEffectComposerを併用することで、Three.jsでは比較的簡単にポストプロセスが実現できます。

シェーダー開発を始める前に用意するもの

実際にThree.jsでシェーダー開発を始める前に、用意するべきものをご紹介します。

Visual Studio Code + 機能拡張「Shader Toy」

Shader Toy

Shader ToyはVisual Studio CodeでのGLSL開発を補助する機能拡張です。GLSLを記述すればリアルタイムプレビューを表示してくれます。また、エラー個所をわかりやすく表示してくれます。

Three.jsでシェーダー開発をすると、画面表示にたどり着くまである程度の工数が必要になります。シェーダーのアイデアを思いついたら、

  • この機能拡張でスケッチを作成
  • プレビューで試行錯誤
  • アイデアが固まったらThree.jsで実装

という手順を踏むと工数の削減とモチベーションが維持できます。

参考記事および画像出典 : GLSL Compoに役立つ!GLSL Sandbox互換のVSCode拡張『Shader Toy』の紹介

Web上の参考資料

wgld.orgはWebGLの基礎から独自のシェーダーの実装までを網羅したサイトです。Three.jsのようなライブラリを使わずゼロからシェーダーを実装するまでを解説しています。 情報量は膨大で、とても一度で理解できる量ではありません。疑問が生まれたら何度も読み返すサイトです。

The Book of Shadersはシェーダーによる表現を中心に解説したサイトです。基本的な色と形状からパターン、ノイズという順にシェーダーを代表する表現を学習できます。 文中に用意されたサンプルコードは質が高く、またコード自体がオンラインエディターになっています。コードを書き換えればブラウザー上で結果を表示できます。

オンラインコミュニティ

オンライン上でシェーダーを公開するコミュニティサイトがあります。最初は作品の高度さに圧倒されると思いますが「こんなことまで可能なのか」という刺激を受けると、開発のモチベーションが保てます。 前述の機能拡張「Shader Toy」は、これらのサイトで公開可能なシェーダーをオフラインで開発するためのものです。

ライセンスに注意

これらのサイトで公開されているシェーダーは、作者に著作権が維持されています。個別にライセンスが明記されていないシェーダーをコピーすると著作権侵害になります。ご注意ください。

Shadertoy : What license will my Shaders have?

動画資料

YouTubeにもシェーダー開発の資料となる動画がたくさん公開されています。とくにUnity関連のチュートリアルが豊富です。UnityのShader Graphで可能な表現はWebGLでも大部分実現可能です。そのまま利用はできませんが多くのアイデアを吸収できます。

グラフ電卓

シェーダー開発では各種関数を多用します。周期性のあるグラフはさまざまな表現に応用できます。グラフ電卓があると、式をざっと書いて視覚化できます。 現在ではブラウザ上でもグラフ電卓が利用できます。また、強力なフリーソフトもありますので、この機会にお気に入りのソフトを探してみるのもいいかもしれません。

ミニマムなShaderMaterial

まずはGLSLの大枠を理解するために、極小の構成で動作するShaderMaterialを製作してみます。 下のサンプルは、単純にジオメトリを真っ赤に塗りつぶすシェーダーです。

const mat = new ShaderMaterial({
  vertexShader:
    `
    void main() {
      vec4 worldPosition = modelMatrix * vec4( position, 1.0 );
      vec4 mvPosition =  viewMatrix * worldPosition;
      gl_Position = projectionMatrix * mvPosition;
    }
    `,
  fragmentShader:
    `
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
    }
    `
});

ShaderMaterialのコンストラクターオプション、もしくはメンバー変数vertexShaderfragmentShaderにGLSLコードをstring型で渡すとShaderMaterialは動作します。

vertexShader(頂点シェーダー)はジオメトリの頂点が2Dスクリーン上のどこにあるかを計算するシェーダーです。動的なジオメトリの変形はここで行います。

fragmentShaderはスクリーン上のピクセルを、どんな色で塗るかを計算するシェーダーです。

  • テクスチャの適用
  • 動的なテクスチャの生成
  • ライトや影の適用

はここで行います。

それぞれのシェーダーは必ずmain関数を持ち、それぞれgl_Positiongl_FragColorに処理結果を格納する必要があります。これさえ行えば最低限シェーダーとして機能します。

処理の順番

処理の順番は必ず

  1. JavaScript
  2. VertexShader
  3. FragmentShader

の順になります。

宣言していない変数はどこからやってきたのか

上記の例では宣言していないはずの変数が使用されており、なぜかそのまま動作しています。modelMatrixやworldPositionが宣言していない変数です。

Three.jsは極力やさしくWebGLを扱えるよう、3D処理に必須の変数をシェーダーに埋め込んでくれます。変数の中にはジオメトリやカメラに関する情報が埋め込まれています。ユーザーは処理を書かずにこの変数を利用できます。

参考記事 : three.jsの組み込みuniform/attributeの紹介

Tips - Language injection

JetBrains系統のIDEでは、string変数の直前の行に特殊なコメントを書き込むと、その変数の中身を別の言語として認識します。変数内では文法チェックやシンタックスハイライトが働くので、開発効率が大きく上がります。この機能をLanguage injectionといいます。

//language=GLSL
const shader = "...";

GLSLも上記のコメントでLanguage injectionが働きます。

const shader = /* glsl */`...`;

vscodeの場合、Comment tagged templatesという機能拡張を導入することで、テンプレートリテラル(`)のシンタックスハイライトが動作します。

JetBrains系統以外のIDE、エディターにも類似の機能が用意されています。お手元の開発環境ではどのようなコメントで機能するか調べてみてください。

参考記事 :

GLSLの基本的な文法

ここでは最低限のGLSL文法について解説します。 より深くGLSLを理解したい場合は、wgld.orgを読み解いていってください。

共有する変数

GLSLで、CPU→シェーダー、頂点シェーダー→フラグメントシェーダーで変数を共有する方法は

  • uniform
  • varying
  • attribute

の3種類があります。

この3つは変数の修飾子といいます。修飾子はGLSLコードのグローバル変数にのみ追加できます。

uniform

uniform修飾子はCPUからシェーダーに変数を共有するためのものです。すべての頂点で同じ変数が適用されます。

var shaderMaterial = new THREE.ShaderMaterial( {
  uniforms : {
    amplitude: { value: 0.0 }
  };
}

JavaScript側でShaderMaterial.uniforms["変数名"].valueに値を代入すると、シェーダー側から参照できます。uniformsのメンバーは直接値を代入するのではなくvalueという値を持ったオブジェクトでなくてはいけません。

uniformは再代入できない

uniformはCPU→シェーダーに一方通行で変数を送ります。そのため、GLSL上ではuniformには再代入できません。

uniform vec3 color;
void main(){
  color = color * 0.5;
}

uniformで受け取った値を変更したい場合は、別の変数に代入してください。

⭕️

uniform vec3 color;
void main(){
  vec3 processedColor = color;
  processedColor = processedColor * 0.5;
}

varying

varying修飾子は頂点シェーダーからフラグメントシェーダーに変数を渡すためのものです。UV座標などの受け渡しに使います。

attribute

attribute修飾子はCPUから頂点シェーダーへ、頂点ごとに異なる情報を渡すためのものです。前述の「宣言していない変数はどこからやってきたのか」で取り上げたpositionもこのattribute修飾子付きの変数です。

attribute修飾子付きの変数は、頂点シェーダーでしか使用できません。

uniformはすべての頂点で同じ情報を、attributeでは頂点ごとに異なる情報を渡します。

変数の型

uniformにJavaScriptから変数を渡した場合、GLSLでは次の表のように型が解釈されます。

| JavaScript type | GLSL type | | --------------- | --------- | | Boolean | bool | | Number | float | | THREE.Vector3 | vec3 | | THREE.Color | vec3 | | THREE.Texture | sampler2D |

より詳しい情報はThree.js Uniformをご参照ください。

GLSLは型に厳密

GLSLでは数値の型は厳密で、異なる型同士で演算するとエラーになります。 下の例ではfloat型のxに、int型の1を足したためエラーが発生します。

float x = 0.5;
float y = x + 1;

float型と演算する場合、相手の変数も必ず1.0とfloat型にしなくてはいけません。

️⭕️

float x = 0.5;
float y = x + 1.0;
              ^^^

もしくは型変換してもこのエラーを修正できます。

float f = float(1); // -> 1.0
vec3 v = vec3(1.0); // -> [1.0, 1.0, 1.0]
vec4 v4 = vec4(v, 2.0); //-> [1.0, 1.0, 1.0, 2.0]

プリプロセッサ

Three.jsではWebGLProgramクラスによりGPUへのシェーダー転送前に前処理が行われます。

前処理の対象になるのはdefineincludeの2つです。

処理結果はシェーダーに埋め込まれます。そのため修飾子付き変数と異なり、転送後に修正できません。

define

defineはシェーダーに展開される定数です。

const mat = new ShaderMaterial({
  defines : {
    DEFINE_NAME: true
  }
}

上の例のように

  • ShaderMaterialのコンストラクタオプション
  • メンバー変数defines

のどちらかに値を格納しておきます。WebGLProgramが、この値をシェーダー内に展開します。

defineの主な用途は#ifdefによる分岐です。

#ifdef DEFINE_NAME
 ...
#endif

#ifdefはdefineに応じて分岐します。 #ifdefはdefineと同時に展開され、条件に一致しない場合は分岐ごと取り除かれます。そのため実行時のパフォーマンスに影響を与えません。

WebGLProgram.jsの該当部分

include

includeはシェーダーに展開されるコードの塊です。

#include <common>

#include <チャンク名>の形式で書かれた場所にそのままの形で展開されます。シェーダー間で共通化したい処理をチャンクに括り出し、includeすることでGLSLコードの重複を防げます。

WebGLProgram.jsの該当部分

ループ

GLSLでもforループが使えます。しかし注意すべき点があります。

int n = 3;
for(int i = 0; i < n; i++){
  ...
}

上のコードは動きません。GLSLではループ終了条件に変数を使えません。上のコードの場合、変数nを終了条件に使っています。

️⭕️

for(int i = 0; i < 3; i++){
  ...
}

forループに定数をハードコーディングするか、defineを使って定数を代入する必要があります。

組み込みマテリアルの改造

ここからはThree.jsの機能を活用してマテリアルを作成します。

組み込みマテリアルの定義を探す

Three.jsにはさまざまなマテリアルが組み込まれています。そのマテリアルを実現するためのシェーダーはShaderLibというクラスに格納されています。 basic, lambert, phongと見慣れたマテリアルの名前が並んでいます。このオブジェクトが、ShaderMaterialのコンストラクタオプションに相当します。MeshPhongMaterialの定義には

  1. uniforms
  2. vertexShader
  3. fragmentShader

の3つが含まれています。 これらをShaderMaterialに読み込ませれば、組み込みマテリアルの機能が利用できます。

組み込みフラグメントシェーダーの改造

vertexShader: ShaderChunk.meshphong_vert,
fragmentShader: ShaderChunk.meshphong_frag

読み込まれているシェーダーはsrc/renderers/shaders/ShaderLib/に格納されています。

Three.js r132で組み込みシェーダーの修正が入りました。以前のバージョンで動いていたシェーダーが動かなくなる可能性があります。その場合、変更元の組み込みシェーダーとの差分を確認し、マージしてください。
Release r132 · mrdoob/three.js
https://github.com

試しにフラグメントシェーダーを覗いてみます。

  vec4 diffuseColor = vec4( diffuse, opacity );
  ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
  vec3 totalEmissiveRadiance = emissive;

...中略...

  vec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;

...中略...

  gl_FragColor = vec4( outgoingLight, diffuseColor.a );

長いコードですが「ミニマムなShaderMaterial」で作成したシェーダーと同じ構成をしていることがわかります。変数outgoingLightが素材とライトの反射を反映した色、reflectedLightがライトの色、diffuseColorがマテリアルの色を示しているようです。diffuseColorを操作することで、MeshPhongMaterialのライト機能を持ったカスタムマテリアルになります。

ここにThe Book of Shadersで学んだグリッドやノイズのパターンを組み込んで行けば、Three.jsのマテリアルとして扱えるフラグメントシェーダーになります。

組み込みUniformsの改造

Uniformsの定義を確認すると

mergeUniforms( [
  UniformsLib.common,
  UniformsLib.specularmap,
  UniformsLib.envmap,
  UniformsLib.aomap,
  UniformsLib.lightmap,
  UniformsLib.fog
] )

のような形で記述されています。

UniformsUtils.mergeUniforms()関数は、Objectの配列を結合しuniformオブジェクトを生成します。 UniformsLibクラスには組み込みマテリアルに対応するuniformオブジェクトが定義されています。 組み込みシェーダーで利用するuniformがまとめて定義されているので、重複して実装する必要はありません。

ShaderChunkの自作と追加

#include <common>
#include <packing>
#include <dithering_pars_fragment>
...

meshphong_frag.glsl.jsを覗くと、大量のチャンクがincludeされています。これらのチャンクはShaderChunkに登録されています。チャンクのソースコードは/src/renderers/shaders/ShaderChunkディレクトリに格納されています。

これらのチャンクをincludeしたり削除することで、ShaderMaterialの機能を調整できます。

チャンクは自作できます。ShaderChunkのソースコードを読むと、チャンク名とシェーダー文字列が対で登録されていることがわかります。そのため

ShaderChunk["チャンクの名前"] = "チャンクの内容";

と追加でチャンクを登録できます。GLSL側では

#include <チャンクの名前>

とincludeすることで、自作チャンクがシェーダー内に展開されます。

Chunk自作時の注意点

includeには名前空間のような仕組みはありません。同名の関数、変数が含まれるチャンクをincludeするとエラーが起きます。チャンクを作る場合、命名規則に注意する必要があります。

個人的な感想

Three.jsでのシェーダー自作には多くの工数がかかります。また、JavaScriptとはまったく違うGLSL言語を使用するため、学習コストも多大です。 しかし、シェーダーによって得られる表現力はそのコストを支払って余りあるものでした。 乱用すると工数ばかりが増えてしまうので、ここぞというポイントで使えば大きな視覚的インパクトを与えられます。

以上、ありがとうございました。