VOYAGE GROUP VR室ブログ

VOYAGE GROUP VR室のブログです。コンテンツの紹介や制作方法、イベントレポートなどについて書きます。原則毎週水曜日更新。

恐れるに足らず!A-Frame × GLSL でWeb 3Dコンテンツ用のシェーダーを書く。

こんにちは。

低温調理機を自作しようと思っているjujunjun110 です。生活を豊かにする電子工作素晴らしい。

https://media.giphy.com/media/26xBTs8sdYmXxgubK/giphy.gif

今回はA-Frameでシェーダーを書く方法について調べたのでまとめてみたいと思います。A-FrameというよりはどちらかというとGLSL の説明という感じなので、React-VRなどThree.jsのラッパーであるWeb VRフレームワーク全般で使える内容だと思います。

目次

シェーダーとは?

ざっくりいうと、シェーダーとは、3Dのコンテンツにおいて画面への描画を実際に行う処理群のことを言います。

3Dコンテンツは、まずアプリのロジックや物理演算によって仮想的な3D空間でのオブジェクトの位置が決まり、それをシェーダーが2次元のディスプレイ上の各ピクセルに毎フレーム描画することで実現されます。

より詳しくは以下の記事などが分かりやすいかもしれません。

説明しよう!シェーダーとはッ! - Master of None

何のためにシェーダーを書くのか

UnityやA-Frameの3Dエンジンではデフォルトのシェーダーが用意されているので、シェーダーを自作しなくても3Dコンテンツは作成できますが*1、自分でシェーダーを書くことで、アプリケーション側のロジックでは実現しにくい表現ができたり、ロジックで実現すると複雑になってしまうことが簡潔に実装できたりします。

あくまでゲームロジックの計算後に走る描画処理なので、ゲームロジックに影響を与えにくいのも嬉しい点ですね😉

A-Frameで使える2種類のシェーダー

A-Frame(Three.js)で使えるシェーダーには主に以下の2種類があります。

  • VertexShader … 頂点シェーダーともいう。オブジェクトの各頂点の位置を操作する。
  • FragmentShader … ピクセルシェーダーともいう。オブジェクトが画面に描画される際の各ピクセルの色を操作する。

ざっくりいうと、VertexShader「形」を、FragmentShader「色」をいじれると考えておけば大丈夫です。

処理は vertexShaderfragmentShader の順で行われます。シェーダーというと、色や影をいじるもののような印象をうけますが、実際には頂点を移動させることまでできるんですね。

一番シンプルなシェーダーを書いてみる

まずは、一番シンプルな例として、物体を赤く表示するだけのシェーダーを書いてみましょう。

<a-scene>
    <a-box material="shader:red-shader" position="-3 1.6 0"></a-box>
</a-scene>
AFRAME.registerShader('red-shader', {
    vertexShader: [
        'varying vec2 vUV;',
        'void main(void) {',
        '  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);',
        '  vUV = uv;',
        '}'
    ].join('\n'),

    fragmentShader: [
        'void main(void) {',
        '    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); //(Red, Green, Blue, Alpha)',
        '}'
    ].join('\n')
})

これでシーンを実行すると…

f:id:jujunjun110:20170213183845p:plain

a-boxが真っ赤になりました。光の反射などは定義していないのでのっぺりとした真っ赤ですね。

HTML側ではmaterial属性にshader名を指定し、javascript側で、AFRAME.registerShader( ... )メソッドでシェーダーを定義します。

registerShader( ... ) 内では、vertexShaderfragmentShader処理をそれぞれ直接文字列で定義するというなかなかカオスなことをしています。

この処理は、GLSLという、C言語ベースでシェーダーを扱うのに特化した言語で記述する必要があり、javascriptの文法で解釈できないので文字列で直接渡す方法をとっているんですね。

このGLSLがWebGL APIを通じてGPUを使ってレンダリングをしてくれるというわけです。


さっそく中身を見ていきましょう。

vertexShader

gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

ここでは、3D空間上の頂点座標の位置を画面上の座標に変換しています。

vUV = uv;

vUVはfragmentShaderに渡すuvマップです。デフォルトのuvマップをそのまま渡しています。

この2文が余計なことを一切しないvertexShaderの基本形となります。

つぎにfragmentShaderのほうでは色を赤くするために、

gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); //(Red, Green, Blue, Alpha)

としています。

gl_FragColor(R, G, B, α) (閾値はそれぞれ0.0〜1.0)のvec4形式で各ピクセルの色を代入できます。vec4(1.0, 0.0, 0.0, 1.0)は、ピクセルの位置に関わらず「赤くて透明度0」ということを意味するわけですね。

これで、はじめてのシェーダーが完成しました。

ちなみにGLSLについては、この連載が神がかって素晴らしいので、シェーダーを書く方は通して読むことを強くオススメいたします。

やってみれば超簡単! WebGL と GLSL で始める、はじめてのシェーダコーディング

また、GLSLはオンラインのリアルタイム実行環境 GLSL Editorで試しながらやるとPDCA回しやすくておすすめです。

Fragment Shader でグラデーションを実現する

ここまでだと、デフォルトのシェーダーで赤色を設定するほうがマシなので、シェーダーならではの表現としてグラデーションを実装してみましょう。

グラデーションは「色」だけの問題なので、VertexShaderには何もせずFragmentShaderだけ実装すれば大丈夫です。


その前に、上のようにjavascript内の文字列でGLSLを書く方法は早々にきつくなってくるので、browserify-shaderでGLSLを外部ファイルとして読み込めるようにしましょう。

まず、browserifybrowserify-shaderをインストールします。

$ npm install -g browserify
$ npm install -g browserify-shader

GLSL には決められた拡張子はありませんが、vertexShaderは.vert、fragmentShaderは.fragという拡張子にするのが一般的ということなので、別ファイルとしてdefault.vertgradation.fragを作成し、

app.js

AFRAME.registerShader('gradation-shader', {
    vertexShader: require('./shader/default.vert')(),
    fragmentShader: require('./shader/gradation.frag')()
})

こんな感じでjsファイルから読み込むように記述したのち、

$ browserify app.js -t browserify-shader -o build.js

のようなコマンドを打つことで、生成されるbuild.jsに、ブラウザで実行可能な形でGLSLが組み込まれます。あとはHTMLからはbuild.jsを読み込めばOKです。


さて、GLSLの中身ですが、

default.vert

varying vec2 vUV;

void main(void) {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  vUV = uv;
}

上記のようにVertexShaderはさっきと全く同じにした上で、

gradation.frag

varying vec2 vUV; // [0.0, 0.0] ~ [1.0, 1.0]

void main(void) {
    float x = vUV[0];
    float y = vUV[1];
    gl_FragColor = vec4(x, y, 0.5, 1.0); //(Red, Green, Blue, Alpha)
}

fragmentShaderを上のように変えてみると…

f:id:jujunjun110:20170213183753p:plain

綺麗なグラデーションが実現されました。

vUVというのはvertexShaderから渡されてきた値で、そのオブジェクト上のピクセル座標(x, y)を、左下が(0.0, 0.0)、右上が(1.0, 1.0)になるように正規化された形の2次元配列で取得できます。

float x = vUV[0];
float y = vUV[1];
gl_FragColor = vec4(x, y, 0.5, 1.0); //(Red, Green, Blue, Alpha)

すなわち、この文は、

一番左下では vec4(0.0, 0.0, 0.5, 1.0) すなわち「青」に、一番右上ではvec4(1.0, 1.0, 0.5, 1.0)すなわち「黄色」に解釈され、その中間地点がグラデーションとして表現されるということになります。

Fragment Shaderに引数を渡すことで、時間で色が変わるようにしてみる

さて、次は外部からの引数を取れるようにしてみましょう。

shader自体はデフォルト以外の変数を管理したりできないので、外部コンポーネントから引数を渡してあげる必要があります。

今回は、アプリケーション開始時からの経過時間を取れるtime-counterコンポーネントを作成します。

<a-box material="shader:time-gradation-shader; time: 0" time-counter position="1 1.6 0"></a-box>
AFRAME.registerComponent('time-counter', {
    schema: {},
    init: function () {
        this.data.count = 0
    },
    tick: function () {
        this.data.count += 1
        this.el.setAttribute('material', 'time', this.data.count * 0.01)
    }
})

tick は毎フレーム呼ばれるので、1秒におよそ60回程度、materialのtimeという変数をインクリメントします。

AFRAME.registerShader('time-gradation-shader', {
    schema: {
        time: { type: 'float', default: 0.0, is: 'uniform' }
    },
    vertexShader: require('./shader/default.vert')(),
    fragmentShader: require('./shader/time-gradation.frag')()
})

この変数をシェーダー側で利用するため、まずはregisterShaderschema内で明示的に変数を定義します。timeという変数を定義し、is: 'uniform'を設定することで、GLSL側からuniform句で呼び出せるようになります。

time-gradation.frag

varying vec2 vUV; // [0.0, 0.0] ~ [1.0, 1.0]
uniform float time;

void main(void) {
    vec2 p = (vUV * 2.0) - vec2(1.0, 1.0); // [-1.0, 1.0] ~ [1.0, 1.0]に正規化
    float x = p[0];
    float y = p[1];
    gl_FragColor = vec4(abs(x), abs(y), sin(time) * 0.5 + 0.5, 1.0); //(Red, Green, Blue, Alpha)
}

GLSL側では uniform句で time 変数を利用していきましょう。

今回は gl_FragColor = vec4(abs(x), abs(y), sin(time) * 0.5 + 0.5, 0.9); //(Red, Green, Blue, Alpha) という形で、青色にsin(time) * 0.5 + 0.5を代入してみます。

https://media.giphy.com/media/l3q2TpvKm74TtVuVi/giphy.gif

すると、このように時間で色が変化するBoxが実装されました。

渡されてくる引数timeはどんどん大きくなっていくので、まず三角関数のsinを使って-1.0〜1.0の間を繰り返すようにし、さらに色として 0.0 ~ 1.0 の間の値を入れなければいけないので sin(time) * 0.5 + 0.5 という風に処理することで常に有効な値が入るようにしています。

Vertex Shader で頂点を移動させてオブジェクトをぐにゃぐにゃ動かす

ここまではfragmentShaderを利用してきましたが、最後にvertexShaderも使ってみましょう。

varying vec2 vUV;
uniform float time;

void main(void) {
    // position: vec3([-0.5 ~ 0.5], [-0.5 ~ 0.5], [-0.5 ~ 0.5])
    float Pi = 3.141592;

    float tx = position.x * (abs(sin(position.y * Pi + time)) * 0.7 + 0.3);
    float ty = position.y;
    float tz = position.z * (abs(sin(position.y * Pi + time)) * 0.7 + 0.3);

    vec3 transform = vec3(tx, ty, tz);

    gl_Position = projectionMatrix * modelViewMatrix * vec4(transform, 1.0);
    vUV = uv;
}

ピクセルの色を操作するFragment Shaderに対して、Vertex Shaderは頂点の位置を直接いじることのできるシェーダーなので、よりダイナミックな表現が可能です。

座標位置positionは、(x, y, z)がそれぞれ-0.5 ~ 0.5の範囲で渡ってきます。これまではそのままgl_Positionに渡していましたが、今回それを途中で受け取って編集することで頂点の変化を表現できます。

今回は、x座標とz座標の値が、yのポジションとtimeによって、0.3 〜 1.0 倍の値に変化するように記述してみました。

https://media.giphy.com/media/26xBTs8sdYmXxgubK/giphy.gif

イソギンチャクのような気持ち悪い動きになるのがわかると思います。

(↑ぐりぐりできます。WASDキーで移動。)

フルスクリーンでページを閲覧する

githubでソースコードを見る

まとめ

今回調べてみるまで、シェーダーというとかなりとっつきづらい印象がありましたが、一つ一つ理解していけば、全く歯が立たないようなものでもないなというのが分かりました。

ちょっとしたインジケーターやクリックされた際のリアクションなど、ゲームロジックで書けるようなものも、シェーダーで書くようにするとロジックがシンプルになったり軽くなったりして便利そうなので、今後積極的に使っていきたいと思います。

まさかフロントエンドウェブ開発でC言語っぽいものを書くことになるとは思いませんでしたが、リアルタイムでグラフィカルに値が変化していくのはとても楽しいので、ぜひみなさんも試してみてください✌

*1:A-Frameの場合はstandard-shader(光を反射するデフォルトのシェーダー)とflat-shader(光の影響を受けにくいのっぺりした描画のシェーダー)が用意されています。