本稿は「How to write a shader」ページを翻訳したものです。
目次
シェーダーの書き方
ロードするシェーダーファイルについて見ていきましょう。
シェーダーを作成するには、p5スケッチフォルダ内に2つのファイル、”shader.vert”ファイルと”shader.frag”ファイルを作成する必要があります。ファイル名には好きな名前を付けることができます。”myAwesomeShader.vert”と”myAwesomeShader.frag”も有効な名前です。2つが.vertと.fragファイルである限り、何ら問題はありません。
まずは、p5のfill()と同じ1色塗りから始めましょう。ファイル名は”onecolor.vert”と”onecolor.frag”でよいでしょう。
下の青い画面はこのサンプルの実行結果です。画面下にある[View Source]ボタンをクリックすると、左にファイル名、右にその内容が表示されます。[View App]ボタンをクリックすると実行画面に戻ります。
重要なのは、.vertと.fragファイルはGLSL(OpenGLシェーディング言語)で書かれていることに気づくことです。GLSLはJavaScriptよりも低レベルの言語です。これは、GLSLがJavaScriptよりもコンピュータ、特にGPUに直接話しかける、ということを意味しています。GLSLのコードは最初外国語のように見え、困惑するかもしれませんが、このガイドを読み終えるころには、そこで何が行われているかよく理解できるようになるでしょう。
シェーダーの構造
.vertファイルは頂点(vertex)、つまり、ジオメトリ(シェイプ)のすべてとジオメトリのキャンバス上の位置に関するすべての処理を行います。頂点はシェイプの端点の別名なので、rect(矩形)なら4つの頂点があることになります。3Dモデルのようなもっと複雑なシェイプにはもっと多くの頂点があり、全部が接続されているときには”メッシュ”と呼ばれます。sphere(球体)はメッシュの分かりやすい例です。.vertファイルは”頂点ごと”の操作を処理するので、ここには慣習として、メッシュ上でのピクセルの位置を扱うコードを記述するようにします。最初のサンプルでは、キャンバス全体を覆う矩形を使用するので、.vertファイルはいたって単純です。.vertファイルは最後、gl_Positionという名前の組み込み変数を計算に等しくなるよう設定します。これにより、.fragファイルで頂点の位置が自動的に使用できるようになります。
.fragファイルはピクセルの実際の着色に関するすべてを処理し、最後gl_FragColorという名前の組み込み変数をカラーに等しくなるよう設定します。.fragファイルは”フラグメントごと(ピクセルごと)”の操作を処理します。このシェーダーはピクセルの着色だけに使用するのが優れたプラクティスとされます。
シェーダープログラムはこのように動く
まず.vertファイルが実行され、つづいてキャンバスのジオメトリ(シェイプ)について行う計算が.fragファイルに渡されます。フラグメントファイルはピクセルをその位置にしたがって色付けします。
.vertファイルはジオメトリの各頂点について実行され、.fragファイルは各ピクセルについて実行されます。この2つのファイルの最終結果は、前にMythBustersビデオで見たように、全ピクセルに対して同時に適用されます。
ここではフラグメントシェーダだけ見ていくので、.vertファイルについて考える必要はありません。これは、行おうとするすべての計算は.fragファイルに記述するということです。.vertファイルで行うのはジオメトリの各ピクセルに位置を.fragファイルに渡すことです。これについてはこの後見ていきます。
とは言え違いを説明しておきましょう。必要な計算を.vertファイルに書いたとすると、ピクセルの着色はジオメトリの頂点間の補間で行われます(コードは文字通り、メッシュのこの2点間にあるこのピクセルは何色にするか? とたずねます)。結果は、.fragシェーダーの計算ほど細かくなく、(頂点ごとでなく)ピクセルごとに行われます。.fragファイルでの計算は、シェーダーのいかれたほどのクールな見栄えによっては、パフォーマンスに影響する可能性があります。そして、ほかのすべてのプログラミングと同様、追加するコード行が多ければ多いほど(またピクセル当たりの計算が多いほど)、プログラムは遅くなります。しかしそれこそがまさにシェーダーをGPUで実行する理由であり、だからこそ、こうしたいかれた計算が同時に行えるのです。
下図は、光沢のあるライティング(スペキュラハイライト)を計算するシェーダーを .vertファイルと.fragファイルで実行したときの比較の例です。
Image source: by Maarten Everts in The Unity CG shader programming guide
.vertの計算は時代遅れのように見えますか? しかしグラフィックカードや美的センスによっては、追究する価値のあるものに見える可能性があります。その場合には、サンプルに含めるので、そのスケッチを送ってください。
.vertと.fragファイルの実行時に何が起こっているのか興味のある方には、OpenGLに関するこのwikibookページが参考になるでしょう。
shader.vertファイルの中身
冒頭には、シェーダーをレンダリングする方法をGPUに伝えるための必要な定義を記述します。シェーダーから始める場合には心配はあまりいりませんが、知っておくと役立つので簡単に説明しておきましょう。
GL_ESは、シェーダーをブラウザやモバイルプラットフォームで表示している場合にGPUが自動的に使用するシェーダーAPIです。#ifdefは”定義されていれば”という意味で、グラフィックスのレンダリング精度を、表示する場所に応じてグローバルに設定します。ブラウザで見ている場合に、ここで定義した精度レベルが得られます。ここでは、コード内のすべての浮動小数点数を”medium”精度に設定しています。この設定は滑らかなカラーグラデーションの作成に非常に重要です。
“シェーダーでは浮動小数点型が不可欠なので、精度レベルは非常に重要です。精度が低いほどレンダリングは高速になりますが、品質が犠牲になります。精度は、浮動小数点を使用する変数ごとに指定できます。最初の行 (precision mediump float;)では、すべての浮動小数を中精度に設定していますが、低(precision lowp float;)や高(precision highp float;)を選択して設定することもできます”(「The Book of Shaders」)
#ifdef GL_ES
precision mediump float;
#endif
次にattribute(属性)と呼ばれるものを作成します。これには、p5から自動的にシェーダーに送られてくる情報が含まれます。詳細はここを参照。
p5のシェーダーでは、.vertファイルで必ず記述しなくてはいけない事柄があります。それは、そのピクセルがキャンバスのどこにあるかということです。この属性はvec3 aPositionと呼ばれます。この名前を変えることはできません。また読み取りのみです。これは上書きできないということです。属性の名前には通常、 “aSomething”のように、頭に”a”を付けます。
属性には位置情報が含まれています。vec3型で、x,y,z値を持っているという意味です。
attribute vec3 aPosition;
すべてのシェーダーはvoid main()関数を持たなければなりません。これはプログラムがスタートする場所です。ここに書いたことは全部、キャンバスのピクセル1つ1つに関して実行されることを思い出してください。したがって、考え方を、1ピクセルを対象にしたコードの記述に変える必要があります。
p5のシェーダーには、main()関数で行う必要のある不可思議なことがあります。それは、.fragファイルに渡す前に、属性aPositionをスケーリングすることです。これは以降のバージョンで解決されるバグかもしれませんが、現時点では、単に全ピクセルの位置をスケーリングし正しい位置に移動させるだけで回避できます。フレーム(グラフィックス上のキャンバス)があり、そこにイメージ(ピクセル)が置かれているところを想像してください。何らかの理由によって、フレームがイメージをフレームの中央に置いて、そして中央から右上隅に合わせるべきだと判断したので、フレームが埋められないのです。この修正には数学が少し必要になります。
そのためにはまず、位置データをvec4にコピーします。これは、この後に数値が入るということです((x,y,z) => (x,y,z,w))。今ここでは2次元(xとy)しか扱っていないのでzは使っていません。wパラメータとして1.0を入れます(ベクトルは、w = 1.0のとき、位置として扱われ、w = 0.0のとき方向として扱われます)。これは標準的なベクトル計算です。基本的なベクトル計算はここが参考になります。
次に、サイズを2倍にするために、ピクセル位置を2倍します。これにより、右上隅がフレーム(キャンバス)よりも大きくなるので、-1で左下に移動します。こうした奇妙な小さな数値(0や1)にはまだぴんとこないかもしれませんが、「Important shader concepts」を読んだ後なら少し分かるようになります。
// .xyを実行すると、x位置とy位置の両方に同じ計算が行われる
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
この位置計算は、.fragファイルで使用するかどうかに関係なく、ここに記述する必要があります。
最後、頂点シェーダーには、gl_Positionと呼ばれるvec4出力が必要です。これはフラグメントシェーダファイルに自動的に位置情報を送るもので、.vertファイルの最終行に書くのが適切です。gl_Positionがスケーリングした計算に等しくなるよう設定します(gl_Position = positionVec4)。
void main() {
// 位置データをvec4にコピーし、wパラメータとして1.0を加える
vec4 positionVec4 = vec4(aPosition, 1.0);
// 出力をキャンバスにフィットさせる
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
// 計算結果をgl_Positionに代入する。これにより頂点情報が.fragファイルに送られる
gl_Position = positionVec4;
}
shader.vertの全コード
// グラフィックカードにシェーダーのレンダリング方法を知らせる定義が必要
#ifdef GL_ES
precision mediump float;
#endif
// “vec3 aPosition”はビルトインのシェーダー機能で、名前を変えてはいけない。
// キャンバス上のすべての頂点の位置を自動的に取得する
attribute vec3 aPosition;
// 頂点シェーダーでは少なくとも1つの事柄を行う必要がある。
// それは、ピクセルがキャンバスのどこにあるかを伝えること
void main() {
// w成分として1.0を使って、位置データをvec4にコピーする
vec4 positionVec4 = vec4(aPosition, 1.0);
// 矩形を2倍拡大し、画面センターに移動する
// こうしない場合、矩形の左下隅がスケッチのゼンターに表示される、
positionVec4.xy = positionVec4.xy * 2.0 - 1.0;
// 頂点情報を、フラグメントシェーダに送る
gl_Position = positionVec4;
}
shader.fragファイルの中身
.vertファイルからの位置情報の使い方に進む前に、これを無視し代わりに1色で着色してみましょう。.fragファイルには、前と同じ定義があり、プログラムを実行するmain()関数もあります。
colorという名前で新しいvec3を作成します。colorはただの変数なのでどのようにでもできます。色は青にしましょう。シェーダーではRGBカラーは0-255ではなく0-1の間を変化します。
vec3 color = vec3(0.0, 0.0, 1.0);
フラグメントシェーダでは、gl_FragColorという名前のvec4出力が必要です。この行はピクセルに着色方法を伝えるもので、.fragファイルの最終行に記述する必要があります。この行より後のコードは全部効果がなく、”code not reached”エラーの発生する可能性があります。
gl_FragColorはvec4 (r,g,b,a)の形式を期待するので、アルファとして1.0、つまり不透明を指定しています。
gl_FragColor = vec4(color, 1.0);
shader.fragの全コード
// グラフィックカードにシェーダーのレンダリング方法を知らせる定義が必要
#ifdef GL_ES
precision mediump float;
#endif
void main() {
// 青色
// シェーダーでは、RGBカラースペクトルは、0から255ではなく0から1までの変化
vec3 color = vec3(0.0, 0.0, 1.0);
// gl_FragColorはビルトインのシェーダー変数で、 .fragファイルに必ず記述する。
// ここでは、vec3のcolorを、1の透明度を持つ新しいvec4に設定している
gl_FragColor = vec4(color, 1.0);
}
不可思議なことの不可思議なこと
以下は訳者による記述です。
rect()やellipse()の引数が無効になる
上記サンプルのsketch.jsには、rect(0,0,width,height); と書かれていて、矩形がキャンバス一杯に描かれるように思えますが、実はrect(0,0,0,0)でも同じ結果になります。ellipse()の場合も同様で、引数は無効になります。
2Dシェイプと3Dジオメトリで結果が異なる
sketch.jsで、キャンバスサイズを400 x 300にし背景色を設定して、rect(0,0,0,0)を実行します。.vertファイルでpositionVec4.xyに2.0を掛け1.0を引くコードをコメントアウトします。つまりpositionVec4.xyは変更しません。これを実行すると、下図の左になります。これは、rect(0,0,0,0)によって、左下隅がキャンバスセンターに揃う位置に、キャンバスの幅と高さの半分のサイズで矩形が描かれる、ということです。
.vertファイルで、positionVec4.xyの行のコードを、positionVec4.xy = positionVec4.xy – 0.5;に変更します。すると、下図に右に示すように、矩形のセンターがキャンバスセンターに揃って描画されます。これは、左の矩形の頂点の座標から、0.5つまり幅の半分と高さの半分を引くことで、矩形が左下に移ったということです。
同様のことをplane()で行います。plane()の引数も効果はなく、キャンバスの幅と高さの半分のサイズで平面が描かれます。下図の左がpositionVec4.xyの行をコメントアウトした結果で、右が positionVec4.xyから0.5引いた結果です。
shader()関数は、リファレンスによると、シェイプを塗るカスタムシェーダーを設定する、とあるので、2Dのrect()でも3Dのplane()でも結果は変わらないはずだと思うのですが、不思議です。
p5.jsの3Dジオメトリはデフォルトでそのセンターがキャンバスセンターに揃うので、plane()の場合はこれでよいのかもしれません(.vertでのpositionVec4.xyの変更は不要)。しかし引数に効果がないのは解せません。