※この記事は 情報系を勉強する女子大生 Advent Calendar 2017 21日目投稿記事です。
はじめに
こんにちはー。
周囲にCGやってるおじさんはいてもJDはいないので、CG布教のために書きました。対象読者層には、プログラミングをやったことがあり、かつ3DCGをあまり触ったことがない人を想定しています。
個人的に、CGの楽しさは紙の上で式を立てて考えながら実装したものがすぐに画面に反映されるところだと思っているので、特に変化がわかりやすいシェーダプログラミングをテーマに記事を書くことにしました。
ただ、ちゃんと理解してシェーダを書くにはまず座標系やアフィン変換などのちょっとめんどくさいお話をしないといけません。今回はその辺の話を一旦カッ飛ばすため、あまり面倒なことを考えなくても書けるシェーダを実装することにします。あんまり高度なことはしていないので、「え、物理ベースレンダリングじゃなきゃヤダ!」という方は回れ右してください。
目標
タイトルにもあるように、かまいたちの夜2っぽいシェーダを作ります。なぜかまいたちの夜2かというと私がかまいたちの夜2のファンだからです。
上図と同じような感じで人物とかを描画できるようにするのが目標です。
そもそもシェーダって何
雑に説明すると、与えられた頂点の位置情報から画面上での位置を決めたり影をつけたりしてくれるものです。普通に陰影をつけてもいいのですが(普通ってなんだ)、工夫するといろいろ楽しいことができるので今回はちょっと変なシェーダを書きます。
なぜthree.jsか
生のOpenGLならびにWebGLはハロワまでが長いし、グローバル変数的に状態を管理するので時折事故が発生して気分が悪くなるし、知識がないとよく分からない設定も多いし、ということでその辺いい感じにやってくれるthree.jsを採用します。自分で書かなくてもサンプルでいろいろ揃っているので、手軽にobjを読み込んだりできますしね。
今回は主にシェーダーをいじるのでthree.js自体についてはあまり説明しません。ちゃんと知りたいという方は、日本語のすばらしい入門記事が既にたくさんあるのでそちらを参照してください。
JavaScriptわからない / かきたくない
大抵の場合において、three.jsの公式ドキュメントをコピペすればいいことが知られています。
ハロワする
作業用のディレクトリを作り、htmlファイル及び実行用のjsファイルを作成します。
https://threejs.org/ からthree.jsの最新版を落としてきて解凍し、解凍後のフォルダに入っている build/three.min.js 及び examples/js/TrackballControls.js をjsファイルを置いた場所と同じ場所にコピーします。
TrackballControlsは視点の回転・ズームを簡単に行えるようにしてくれるものです。どうせデバッグしているうちに必要になるので先に導入しておきます。この程度なら自分で書いてもいいのですが、折角あるので使ってしまいましょう。
とりあえず、視点中央に真っ赤なトーラスを表示するだけのコードを書いてみます。js側はこんな感じになります。
var ModelCanvas = function (w, h) {
this.width = w;
this.height = h;
this.scene = null;
this.camera = null;
this.controls = null;
this.renderer = null;
}
ModelCanvas.prototype.init = function () {
// 空のシーンを作成
this.scene = new THREE.Scene();
// カメラの作成・設定(今回は透視投影)
this.camera = new THREE.PerspectiveCamera( 45, this.width / this.height, 0.1, 1000 );
// カメラの位置を決める
this.camera.position.set(0, 0, 30);
// レンダラの作成
this.renderer = new THREE.WebGLRenderer();
// canvasの大きさを決める
this.renderer.setSize(this.width, this.height);
// 背景色(何も描画されていない部分の色)を決める
this.renderer.setClearColor(0xffffff, 1.0);
// レンダラをhtmlのbodyに追加
document.body.appendChild(this.renderer.domElement);
// TrackballControls オブジェクトを作成
this.controls = new THREE.TrackballControls(this.camera, this.renderer.domElement);
// シーンの初期化
this.initScene();
// 描画
this.updateCanvas();
};
ModelCanvas.prototype.initScene = function () {
// ジオメトリの作成
var geom = new THREE.TorusGeometry(5, 2, 8, 16);
// マテリアルの作成
var material = new THREE.MeshBasicMaterial({color: 0xff0000});
// 作成したジオメトリとマテリアルからオブジェクトを作成
var torus = new THREE.Mesh(geom, material);
// オブジェクトに回転を設定
torus.rotation.x = -Math.PI/4;
// オブジェクトをシーンに追加
this.scene.add( torus );
};
ModelCanvas.prototype.updateCanvas = function () {
this.controls.update();
this.renderer.render(this.scene, this.camera);
};
簡単に説明すると、シーンに描画したいオブジェクトを置き、カメラを設定してレンダラでレンダリングします。オブジェクトはジオメトリ(形状)とマテリアル(素材)からなります。今回はトーラス型のジオメトリと指定色をベタ塗りするだけの簡単なマテリアルをオブジェクトに設定し、適当に回転させてシーンに置いてみました。
html側ではModelCanvas.js、three.min.js、TrackballControls.jsを読み込んでおきます。また、<body>内に以下の記述を追加してください。ModelCanvasのコンストラクタの引数には画面の横幅と縦幅をとります。
<script type="text/javascript">
var modelCanvas = new ModelCanvas(400, 400);
modelCanvas.init();
</script>
これだけで描画されます。ドラッグで回転もできます。拍手!
とりあえず描画まではできたので、次は自作シェーダを使うように変更していきます。
自作シェーダを使う
シェーダについて
GLSLについて
シェーダを記述するにはGLSLという言語を用いるのですが、C言語をベースにしているので文法の習得は易しいと思います。
GLSLに組み込みのベクトル型としてvec2、vec3、vec4があり、
vec4 a = vec4(1.0); vec4 b = vec4(0.5); vec2 c = vec2(a.x + b.y, a.y + b.x);
と書くところを
vec4 a = vec4(1.0); vec4 b = vec4(0.5); vec2 c = vec2(a.xy + b.yx);
のように書けたりします。便利ですね。あとは4x4配列のmat4とか、各種の便利な関数とか、3Dの計算で必要そうなものが揃っています。詳しくは他に優れた解説記事がゴロンゴロン転がっているのでそちらを参照してください。
二種類のシェーダ
今回はvertex shaderとfragment shaderの二種類を扱います。処理はvertex shader→fragment shaderの順で行われます。
- vertex shader
頂点情報を扱い、出力をfragment shaderに渡します。主には、各頂点の座標と変換行列などを受け取り、モデル座標からスクリーン座標への変換を行います。他にも頂点の色情報や法線ベクトルなどを受け取って色々なことができます。 - fragment shader
vertex shaderから受け取ったスクリーン座標などの情報をもとに各ピクセルの色などを決め、出力します。fragment shaderは座標などの情報を直接受け取れないのでvertex shaderから渡してもらう必要があります。
説明されても何のこっちゃという感じですね。
座標系について簡単に
理解を深めるため、ここで少し座標系の話をします(しないって言ったのに……)。
今vertex shaderに渡されているのは各頂点のモデル座標だと説明しました。モデル座標とは何かというと、それぞれのオブジェクトに対して与えられる座標系です。これを行列計算によってワールド座標系、スクリーン座標系へと変換することで画面上での位置を決めることができます。
例えるなら、花子さんの手の位置を説明するとき、「花子さんの右手は花子さんの体の右端かつ真ん中あたりにある」というのがモデル座標系、「花子さんの右手はこの地球上の北半球のこの辺にある」というのがワールド座標系、「ある位置に置いたカメラから撮影すると花子さんの右手は写真の左側ちょい下あたりに現れる」というのがスクリーン座標系です。ちょっとうそっぽいですが、まあそんなもんだと思っておけばプログラムを書くのには十分です(これは嘘ですが、この記事の範囲では十分です)。
three.jsでの自作シェーダの使い方
シェーダはhtmlの<head>内に記述します。まずは、トーラスを青色で塗りつぶすだけの簡単なシェーダを書いてみます。vertex shaderはこんな感じ。
<script type="x-shader/x-vertex" id="vshader">
void main()
{
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
three.jsではプロジェクション行列はprojectionMatrix、モデルビュー行列はmodelViewMatrix、各頂点のモデル座標はpositionで受け取れます。座標変換はこれらの行列を (モデル座標のxyz, 1.0) からなる四次元のベクトルに掛けるだけなので、これだけ書いておけば大丈夫です。
今回、vertex shaderはほぼいじらないので、よくわからない人はおまじないと思っておいていただければ大丈夫です。
fragment shaderはこんな感じで。
<script type="x-shader/x-fragment" id="fshader">
void main()
{
gl_FragColor = vec4(0.0, 0.5, 1.0, 1.0);
}
</script>
すべてのピクセルについて、色として (r, g, b, a) = (0.0, 0.5, 1.0, 1.0) を渡しています。
シェーダをhtml内に記述できたら、先ほどのModelCanvas.jsでのmaterialの設定を下のように書き換えます。
//var material = new THREE.MeshBasicMaterial({color: 0xff0000});
var material = new THREE.ShaderMaterial({
vertexShader: document.getElementById('vshader').textContent,
fragmentShader: document.getElementById('fshader').textContent
});
これだけで自作シェーダが使えるようになりました。青色のトーラスが表示されることを確認できたら、早速シェーダを書き換えて遊んでみましょう。
シェーダを書き換える
各点のモデル座標系での座標を色で可視化してみる
かまいたちの夜2の画像を見てみると、人物の頭の方は薄い青、足元の方は濃い青になっているのがわかります。
先の座標系についての説明を踏まえて、これを実現するには、モデル座標系でのy座標の値によってグラデーションをつけさせればよさそうですね(一般にy座標が上下に対応することが多いので)。正しくグラデーションしているかを確かめるため、まず、モデル座標系での各点の座標を把握する必要があります。
というわけで、それぞれの点がどれくらいの位置にあるか、可視化してみましょう。これにはvertex shaderからfragment shaderに頂点のモデル座標を渡してやる必要があります。このためには、varying変数を宣言します。
varying vec3 pos;
void main()
{
pos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
これでposにモデル座標が渡されるようになりました。
fragment shaderでも同様にvarying変数としてposを宣言することでvertex shaderから受け取った値を使えるようになります。というわけで、試しにposをgl_FragColorに与えてみましょう。
varying vec3 pos;
void main()
{
gl_FragColor = vec4(pos, 1.0);
}
するとこうなります。
ぱっと見よくわかりませんが、これは各点のモデル座標を表しています。r成分がx座標、g成分がy座標、b成分がz座標に相当します。そのため、ちょうど右上のあたりが(x,y,z)=(1, 1, 1)で、手前側に行くと(x,y,z)=(1,0,1)になるのかなあと想像がつきます。というわけで、手前に行くほど濃い青になっていれば正しい動作と言えそうだとわかります。
※r,g,bは0.0-1.0の間の値をとり、その範囲に収まらなかったものはclampされてしまうので、実際には右上の点なんかはx,y,zがそれぞれ1.0以上の値を取っています。
シェーダを書いていてうまく動かない時はこんな感じで色などを使って各種の数値を可視化します。「法線ベクトルちゃんと設定できてなかった」とか「座標系間違えてた」とかのミスが一瞬で発見できるのでおすすめです。
モデル座標をもとにグラデーションをつけてみる
渡された頂点情報を利用してy座標の値に応じたグラデーションをつけてみましょう。fragment shaderを書き換えます。パラメータは適当に調整します。
varying vec3 pos;
void main()
{
float gradation = (pos.y + 10.0) / 13.0;
gl_FragColor = vec4(0.7 - 0.7 * gradation, 0.9 * gradation, 1.0, 1.0);
}
こうなりました。少しそれっぽくなった気がします。
パーリンノイズを被せる
これだけだとまだ不十分です。本家を観察してみると、頭から足元にかけてのグラデーションだけでなく、全体に薄く水面のような斑模様がついているのがわかります。パーリンノイズでいけそうな気がしたので、座標からノイズを生成して重ねてみることにします。
varying vec3 pos;
vec2 random2(vec2 st){
st = vec2( dot(st,vec2(127.1,311.7)),
dot(st,vec2(269.5,183.3)) );
return -1.0 + 2.0*fract(sin(st)*43758.5453123);
}
// Value Noise by Inigo Quilez - iq/2013
// https://www.shadertoy.com/view/lsf3WH
float noise(vec2 p, float scale) {
vec2 st = p / scale;
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f*f*(3.0-2.0*f);
return mix( mix( dot( random2(i + vec2(0.0,0.0) ), f - vec2(0.0,0.0) ),
dot( random2(i + vec2(1.0,0.0) ), f - vec2(1.0,0.0) ), u.x),
mix( dot( random2(i + vec2(0.0,1.0) ), f - vec2(0.0,1.0) ),
dot( random2(i + vec2(1.0,1.0) ), f - vec2(1.0,1.0) ), u.x), u.y);
}
void main()
{
float gradation = (pos.y + 10.0) / 13.0;
float scale = 1.5;
float ampl = 0.3;
gl_FragColor = vec4(0.7 - 0.7 * gradation, 0.9 * gradation, 1.0, 1.0) + ampl * vec4(vec3(noise(pos.xy, scale)), 0.0);
}
ノイズの生成は The Book of Shaders を参考にしました。
乱数には各ピクセルの値から決まる擬似乱数(random2(vec2)
)を用いています。単に各ピクセルの座標から擬似乱数を生成するだけだとテレビの砂嵐のような模様になってしまうので、工夫します。
平面上に幾つかの格子点をおき、格子点の座標から二次元の乱数を生成し、勾配ベクトルとします。各ピクセルについて周囲の4つの格子点の勾配ベクトルと格子点からそのピクセルへのベクトルの内積を取り、これを格子点の各ピクセルへの寄与とします。4つの格子点の寄与をピクセルの位置から適切に重み付けして各ピクセルの値を決めます。これは、各ピクセルと格子点のx座標、y座標の差f
から関数f*f*(3.0-2.0*f)
により重み付け用の距離の比を求め、線形補間を行う組み込み関数mix()
で線形補間することで実現されます。これは二次元なので、x座標、y座標について二重に線形補間を行います。これで近傍ピクセル間での値の変化がスムーズになります。
scaleでノイズの細かさを、amplでノイズの影響の大きさを調整します。ノイズパターンを見るため、scaleは少し小さめに設定します。下のようになりました。
格段にそれっぽくなりました。
パーリンノイズを三次元に変更
それっぽくはなりましたが、実は少々難があります。さっきのトーラスを横から見てみましょう。
シマウマかな?
実は、現在のノイズの生成にはモデル座標の(x, y)成分を使っているので、z方向に変化があっても同じノイズが生成されてしまうのです(金太郎飴を想像してください)。
というわけで、三次元座標からノイズを生成するように変更してみましょう。
fragment shaderを書き換えます。
varying vec3 pos;
// 三次元の値から三次元の乱数を返す
vec3 random3(vec3 st){
st = vec3( dot(st,vec3(127.1,311.7,231.3)),
dot(st,vec3(269.5,183.3,112.6)),
dot(st,vec3(87.12,103.1,193.6)) );
return -1.0 + 2.0*fract(sin(st)*43758.5453123);
}
// 三次元パーリンノイズ
float noise3d(vec3 pos, float scale) {
vec3 p = pos / scale;
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f * f * (3.0 - 2.0 * f);
float g000 = dot(random3(i + vec3(0.0,0.0,0.0)), f - vec3(0.0,0.0,0.0));
float g100 = dot(random3(i + vec3(1.0,0.0,0.0)), f - vec3(1.0,0.0,0.0));
float g010 = dot(random3(i + vec3(0.0,1.0,0.0)), f - vec3(0.0,1.0,0.0));
float g001 = dot(random3(i + vec3(0.0,0.0,1.0)), f - vec3(0.0,0.0,1.0));
float g110 = dot(random3(i + vec3(1.0,1.0,0.0)), f - vec3(1.0,1.0,0.0));
float g101 = dot(random3(i + vec3(1.0,0.0,1.0)), f - vec3(1.0,0.0,1.0));
float g011 = dot(random3(i + vec3(0.0,1.0,1.0)), f - vec3(0.0,1.0,1.0));
float g111 = dot(random3(i + vec3(1.0,1.0,1.0)), f - vec3(1.0,1.0,1.0));
float g00 = mix(g000, g100, u.x);
float g01 = mix(g001, g101, u.x);
float g10 = mix(g010, g110, u.x);
float g11 = mix(g011, g111, u.x);
float g0 = mix(g00, g10, u.y);
float g1 = mix(g01, g11, u.y);
return mix(g0, g1, u.z);
}
void main()
{
float gradation = (pos.y + 10.0) / 13.0;
float scale = 1.5;
float ampl = 0.3;
gl_FragColor = vec4(0.7 - 0.7 * gradation, 0.9 * gradation, 1.0, 1.0) + ampl * vec4(vec3(noise3d(pos.xyz, scale)), 0.0);
}
気分が悪くなりそうですが、参照する格子点が三次元空間中での周囲の8点になり、その分乱数生成が三次元→三次元になったり線形補間が三重になっただけでやっていることは同じです。
擬似乱数のパラメータはすごく適当に決めました。とりあえずそれっぽく見えればいいんじゃないかと思います。不安な時は適当なグラフ作成ソフトでプロットしてみるといいかと(まあ自分はそれ見てもよくわからないんですが)。
こんな感じになりました。
どこから見てもイケメンですね。
周波数の異なるノイズを重ねる
さて、ここで終わりでもいいのですが、今回はよりそれっぽくするために異なる周波数(ここではscale
)のノイズを重ねてみましょう。周波数はそれぞれ元のノイズの1, 1/2, 1/4, 1/8とし、周波数の小さいものは寄与が小さくなるようにします。
イメージとしては、大きく粗いノイズの上に小さく細いノイズが乗り、細かい山・谷のある山脈を作るような形になります。こうして生成される模様は、一般にフラクタルノイズなどと呼ばれます。
fragment shaderは下のようになります。
//略
void main()
{
float gradation = (pos.y + 10.0) / 13.0;
float scale = 3.0; //見栄えが良くなるように調整
float ampl = 0.3;
vec4 noise = vec4(vec3(noise3d(pos.xyz, scale)), 0.0) + 0.5 * vec4(vec3(noise3d(pos.xyz, 0.5 * scale)), 0.0)
+ 0.25 * vec4(vec3(noise3d(pos.xyz, 0.25 * scale)), 0.0) + 0.125 * vec4(vec3(noise3d(pos.xyz, 0.125 * scale)), 0.0);
gl_FragColor = vec4(0.7 - 0.7 * gradation, 0.9 * gradation, 1.0, 1.0) + ampl * noise;
}
ex: 背景と重ねてみる
シェーダは完成したので、ここから先はインスタ映えする画像を作るためのエクストラステージです。「なんかやってんなコイツ」と読み飛ばしてください。
本家よろしくアルファブレンディングで背景と重ねてみます。そのままアルファブレンディングをかけると立体の裏側が見えて芳しくないので、一度テクスチャに対し半透明にしたいオブジェクトをレンダリングし、レンダリング結果を用意した背景の画像と適当な割合で重ねる方法をとります。
ついでにサンプルの中からMMDLoaderを拾ってきて、6666氏作成の八頭身モナーのモデルを表示させたりしてみました。結果はこんな感じです。
殺人事件が起きそうですね!
ただ、まだ調整は不十分だと思います。また悲しいことに、gradationのオフセットとかはモデルに合わせて適当にいじる必要があります。多分シェーダにモデルごとに頂点のy座標の上限値下限値を渡せばいいと思うんですが、まあ今後の課題ということで。
おわりに
少しは3Dをいじる楽しさが伝わったでしょうか。ノイズなんかは特に遊んでみると楽しいです。今回はやりませんでしたが、vertex shaderで頂点位置を変えるのに使うと図形をぐにゃぐにゃ変形させるといったこともできます。
ここから影をつけたり光の反射を考えてリアルな絵を作ろうとすると物理や数学の知識が必要になり難易度も上がりますが、その分実装できるとうれしいし、逆に物理や数学のモチベも上がるのでとってもオススメです。
また、私も最近CGの勉をしたりGLSLをいじりはじめたばかりなので、おかしいところがあるかもしれません。気づいた方はTwitterなりコメント欄なりで教えていただけると嬉しいです。