見出し画像

three.js + Web Font + 頂点シェーダ芸 Vol.3 (シェーダ編)

※この記事はtkmh.me上で掲載している記事 (2016.12.06 掲載) を転載、加筆・修正したものです。

---------

さていよいよ最終回です。前回 (Vol.2 Geometry編)は主にBufferGeometryのお話でした。

Vol.1 (準備編)
Vol.2 (Geometry編)
・Vol.3 (シェーダ編)

やっとここからシェーダの話になります。シェーダのプログラムはHTMLファイルに記述されています。

まずはコード量の少ないフラグメントシェーダを見てみましょう。

フラグメントシェーダ

precision mediump float;

uniform sampler2D txtTexture;  // テクスチャ

varying vec4 vColor;  // 文字色
varying vec2 vUv;     // UV座標

// メインの処理
void main(){
 vec4 color = texture2D(txtTexture, vUv) * vColor;
 if(color.a == 0.0) {
   discard;
 } else {
   gl_FragColor = color;
 }
}

1行目のprecision宣言に関しては、何度も言いますがwgld.orgをご覧ください。

txtTextureはテクスチャです。複数の文字がグリッド状に並べられたものです。RawShaderMaterialのuniformsプロパティに設定したかと思います。

これです。これが渡されてきます。

varying vec4 vColor;  // 文字色
varying vec2 vUv;     // UV座標

これらは、頂点シェーダから渡される値です。頂点シェーダの解説の際にどんなデータが入ってくるか解説します。ひとまずコメントの通り、文字色とUV座標が入ってくると思ってください。

そしてmain関数がメインの処理です。フラグメントシェーダはこの関数内でgl_FragColorに代入された値が描画されます。

vec4 color = texture2D(txtTexture, vUv) * vColor;

この一行は何をしているかというと、テクスチャからUV座標に該当する部分の色を取ってきたものと、vColor (文字色)を乗算しています。

文字のテクスチャは、文字部分は白、それ以外の部分透明なテクスチャです。(上のテクスチャの画像は透明部分をわかりやすいように黒くしているだけです。)

この乗算は文字の部分だけをvColorにしてくれます。

ここで、すぐ

gl_FragColor = color;

としてしまってもいいんですが、これだと透明な部分も描画されてしまいます。

どういうことかというと、このように、色はないけど矩形の後ろは描画できないという現象が起こってしまいます。
なので、色が透明な部分はそもそも描画しない、という処理を入れてます。

それが以下の部分です。

if(color.a == 0.0) {
 discard;
} else {
 gl_FragColor = color;
}

計算した色のa成分が0の場合はdiscard;という部分に差し掛かり、discardの場合はフラグメントシェーダはなにも描画しないことになります。

そうすると、矩形内でも透明部分は描画されないので、不自然に他のオブジェクトが切り取られることがなくなります。

続いて頂点シェーダです。フラグメントシェーダに比べてコード量が多いですが、順に見ていけば大して難しいことはしていないです。

頂点シェーダ

頂点シェーダは長いので、小分けにしてみていきます。

・three.jsが予め定義してるuniform変数

// three.jsが予め定義してるuniform変数
uniform mat4 modelMatrix;  // モデル変換行列
uniform mat4 viewMatrix;  // ビュー変換行列
uniform mat4 projectionMatrix;  // プロジェクション変換行列
uniform vec3 cameraPosition;  // カメラの位置

まずは各種変数の宣言部分です。上記のものはthree.jsが勝手にシェーダに渡してくれているuniform変数です。なので、RawShaderMaterilのuniformsには定義していません。

・自分で定義したuniform変数

// 自分で定義したuniform変数
uniform float time;  // 経過時間
uniform float numChars;  // 文字数 (正方形の数)
uniform float numTextureGridRows;  // テクスチャの横方向の文字数
uniform float numTextureGridCols;  // テクスチャの縦方向の文字数
uniform float textureTxtLength;  // テクスチャに使っている文字の数
uniform float animationValue1;  // アニメーション適用度1
uniform float animationValue2;  // アニメーション適用度2
uniform float animationValue3;  // アニメーション適用度3

これらはRawShaderMaterialのuniformsに自分で設定したかと思います。

・attribute

// FloatingCharsGeometryで追加したattribute
attribute vec3 position;  // 座標
attribute vec3 randomValues;  // ランダム値
attribute vec2 uv;  //UV座標
attribute float charIndex;  // 何番目の文字(正方形に)属するか

これらはFloatingCharsGeometryで追加したattributeです。

・varying

// フラグメントシェーダに渡す値
varying vec4 vColor;  // 文字色
varying vec2 vUv;  // UV座標

これらはフラグメントシェーダに渡す値です。頂点ごとにこの変数に代入すると、値を補間してフラグメントシェーダに渡してくれます。

値を補間?って思う方もいるかと思いますが、頂点ごとに実行される頂点シェーダに対し、フラグメントシェーダは、頂点で囲まれた面の全てのピクセルに対して実行されます。

そこでvarying修飾子を付けた変数に値を入れてフラグメントシェーダに渡すと、勝手に頂点間のピクセルに対する値に補間してくれます。

・定数PI

// 定数としてPIを定義
const float PI = 3.1415926535897932384626433832795;

GLSLではPIが定義されていないので、自分で定数として定義しておきます。

・rotateVec3関数

// 3次元ベクトルを任意の軸で回転
vec3 rotateVec3(vec3 p, float angle, vec3 axis){
 vec3 a = normalize(axis);
 float s = sin(angle);
 float c = cos(angle);
 float r = 1.0 - c;
 mat3 m = mat3(
   a.x * a.x * r + c,
   a.y * a.x * r + a.z * s,
   a.z * a.x * r - a.y * s,
   a.x * a.y * r - a.z * s,
   a.y * a.y * r + c,
   a.z * a.y * r + a.x * s,
   a.x * a.z * r + a.y * s,
   a.y * a.z * r - a.x * s,
   a.z * a.z * r + c
 );
 return m * p;
}

rotateVec3はその名の通り、3次元ベクトルを任意の軸で回転させる関数です。

第一引数に頂点座標、第二引数に角度 (ラジアン)、第三引数に軸のベクトルを指定します。正方形を回転させるために使用します。

・map関数

// 範囲を設定し直す
float map(float value, float inputMin, float inputMax, float outputMin, float outputMax, bool clamp) {
 if(clamp == true) {
   if(value < inputMin) return outputMin;
   if(value > inputMax) return outputMax;
 }

 float p = (outputMax - outputMin) / (inputMax - inputMin);
 return ((value - inputMin) * p) + outputMin;
}

map関数は、ある範囲内の値を、別の範囲内にマッピングする関数です。言語によっては実装されていますが、GLSLではないので自分で定義します。

どういうことかというと、例えば

map(sin(x), -1.0, 1.0, 0.0, 2.0, true);

とするとします。valueにsin関数の値を入れた場合、この値の取りうる範囲は-1.0 ~ 1.0なので、inputMinとinputMaxにはそれぞれ-1.0と1.0を入れます。

これを、新しい範囲である0.0 ~ 2.0 (outputMin ~ outputMax)の範囲に変換します。sin(x)が-1.0だったら0.0, 1.0だったら2.0、0.5だったら1.0というような値が帰ってきます。

clampは、valueがinputMin ~ inputMaxの範囲外の値だった場合に、変換後の値をoutputMin ~ outputMaxの範囲外にならないようにするためのbool値です。

map(value, -1.0, 1.0, 0.0, 2.0, true);

例えばこの例だと、valueが2.0になった場合、clumpの値がtrueであれば2.0となり、falseであれば3.0になるはずです。

・hsv2rgb関数

// hsvの値をrgbに変換
vec3 hsv2rgb(vec3 c) {
 vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
 vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
 return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

hsv2rgbは、HSV色空間の値をRGBに変換する関数です。引数はHSVの3次元ベクトルです。今回は経過時間とrandomValuesの値を使用して、正方形ごとに別々の色相を割り当てるために使用してます。

・getAlpha関数

// 距離から透明度を計算
float getAlpha(float distance) {
 float da = abs(distance - 400.0) / 500.0;
 return clamp(1.0 - da, 0.0, 1.0);
}

こちらはカメラからの距離によって正方形の透明度を変化させるために使用する関数です。カメラから近すぎると透明になり、遠すぎても透明になります。これによって擬似的に奥行きを表現してます。

・getRad関数

// time, scale, offsetを使って角度を返す
// 範囲は -PI ~ PI
float getRad(float scale, float offset) {
 return map(mod(time * scale + offset, PI * 2.0), 0.0, PI * 2.0, -PI, PI, true);
}

こちらは経過時間timeの値と、引数で与えられた係数と位相差を使って、-PI ~ PIの範囲の角度を算出するための関数です。

単純にtime * scale + offsetだと値がどんどん大きくなってしまうので、mod関数を使用して0.0 ~ 2.0 * PIの間に値を収めたあと、map関数で-PI ~ PIの範囲に設定し直しています。

0.0 ~ 2.0 * PIでもいい気がしますが、何でこうしたかは忘れましたw

・main関数

やっとmain関数にたどり着きました。。

まずは3種類のアニメーションの箇所に注目してください。動きに関しては後述しますが、それぞれのアニメーションの計算において、要所要所でanimationValue1, 2, 3の値がかけられているのがわかるかと思います。

これらによって、

・animationValue1が1でanimationValue2, 3が0のときはanimation1が適用される
・animationValue2が1でanimationValue1, 3が0のときはanimation2が適用される
・animationValue3が1でanimationValue1, 2が0のときはanimation3が適用される

という動きを実現しています。

そしてMainVisualクラスで定義されていたanimateメソッドを使用してこれらの値をスムーズに切り替えれば、2つのアニメーション間をスムーズに切り替える動きが可能になるということです。

ではアニメーションの計算ではなくまずはフラグメントシェーダに渡すUV座標の計算部分を見てみます。

//
// フラグメントシェーダにに渡すUV座標を計算
//
// テクスチャ上の何番目の文字を使うか
float txtTextureIndex = mod(charIndex, textureTxtLength);
// テクスチャの横方向のグリッド単位
float uUnit = 1.0 / numTextureGridCols;
// テクスチャの縦方向のグリッド単位
float vUnit = 1.0 / numTextureGridRows;
// フラグメントシェーダに渡すUV座標を代入
vUv = vec2(
 (uv.x + mod(txtTextureIndex, numTextureGridCols)) * uUnit,
 (uv.y + floor(txtTextureIndex / numTextureGridCols)) * vUnit
);

txtTextureIndexはコメントにある通りテクスチャ上の何文字目を使うかの値です。例えば今回のサンプルでは62文字を使用しているので、mod関数でcharIndexをtextureTxtLengthで割れば、テクスチャの何番目の文字を使うかが算出できます。

たとえば、charIndexが62のときは、一周回ってtxtTextureIndexは0になるので、0文字目(1文字目)となります。

uUnitは横方向の1文字分、vUnitは縦方向の1文字分のUV座標の幅です。UV座標はそれぞれの成分が0.0 ~ 1.0なので、横方向に16文字、縦方向に4文字描画されたテクスチャだとすると、

0枚目正方形において

// 左上の頂点
vUv = vec2(0.0, 0.0);
// 右上の頂点
vUv = vec2(uUnit, 0.0);
// 左下の頂点
vUv = vec2(0.0, vUnit);
// 右下の頂点
vUv = vec2(uUnit, vUnit);

となります。つまり、一般化すると、

// 左上の頂点
vUv = vec2(0.0 + uUnit * [左から何個目の文字か], 0.0 + vUnit * [上から何個目の文字か]);
// 右上の頂点
vUv = vec2(uUnit + uUnit * [左から何個目の文字か], 0.0 + vUnit * [上から何個目の文字か]);
// 左下の頂点
vUv = vec2(0.0 + uUnit * [左から何個目の文字か], vUnit + vUnit * [上から何個目の文字か]);
// 右下の頂点
vUv = vec2(uUnit + uUnit * [左から何個目の文字か], vUnit + vUnit * [上から何個目の文字か]);

こんな感じになるかと思います。この計算を上記の部分のコードで実現してます。

次は座標の変換と、文字色の部分です。

//
// 最終的な座標とフラグメントシェーダに渡す文字色を計算
//
// モデル変換
vec4 modelPos = modelMatrix * vec4(pos, 1.0);
// ビュー変換
vec4 modelViewPos = viewMatrix * modelPos;
// ビルボードのための処理 (animation1)
modelViewPos += vec4(position, 0.0) * animationValue1;
// プロジェクション変換した座標をgl_Positionに代入
gl_Position = projectionMatrix * modelViewPos;
// カメラからの距離を算出
float d = distance(cameraPosition, modelPos.xyz);
// フラグメントシェーダに渡すcolorを計算
// 時間経過でHSVのHが徐々に変化
// 距離に応じてalphaの値が変化
vColor = vec4(hsv2rgb(
 vec3(
   (sin(getRad(2.0, randomValues.x * 2.0)) + 1.0) * 0.5,
   0.8,
   0.8
 )
), getAlpha(d));

posはアニメーションを適用した頂点座標です。

まずはposにモデル変換をしますが、今回はMesh (FloatingChars)に対して変形をかけてない (座標の移動はすべて頂点シェーダ内で行っている)ので、posにmodelMatrixをかけてもかけなくても結果は変わりません。がFloatingChars自体を変形した場合はこの処理ないと適用されないので、省略はしないでおきます。続いてビュー変換も行います。モデル変換した座標を変数に代入して保持しているのは、後ほどカメラとの距離を出すためです。

// ビルボードのための処理 (animation1)
modelViewPos += vec4(position, 0.0) * animationValue1;

ここは一旦飛ばします。animatoin1絡みなのはおわかりかと思います。

このあと、プロジェクション変換した最終的な座標をgl_Positionに代入します。

その下からは文字色 (vColor)を計算するための処理です。先程説明しましたがカメラとモデル変換した座標の距離を算出します。getAlpha関数を使って、距離から座標の透明度を算出し、vColorのalpha成分としてます。

vColorのrgb成分は、hsv2rgb関数で算出します。HSVのSとVは今回は固定しています (ここも時間経過などで変えたらさらに色にばらつきが出ます)。

HはgetRad関数の引数にrandomValues.xを使用して、文字によって色のばらつきをもたせつつ時間経過で色が変わるようにしてます。Hの値は0.0 ~ 1.0なので、1を足して2で割って(0.5をかけて)ます。map関数を使ってもいいと思いますが、使うまでもないでしょう。

ここで決定したvColorがフラグメントシェーダに渡されます。

さて、やっとアニメーション部分の解説です。もうしんどいです。読んでる方もしんどいかと思いますが。。これで最後です。

まず、animation2とanimation3はそんなに難しくない話なので、先に解説します。

・animation2

//
// animation2
// 8個のリング状
//
float numRings = 8.0;  // リングの個数
float ringIndex = mod(charIndex, numRings);  // どのリングに属するか
float numCharsPerRing = numChars / numRings;  // リングごとの文字数
// 4段階にリングの位置を設定
pos.y += animationValue2 * map(ringIndex, 0.0, numRings - 1.0, -60.0, 60.0, true);
pos.z += animationValue2 * radius;  // まず手前にradius分移動
// y軸を中心にリング状になるように文字を配置
theta = getRad(10.0, PI * 2.0 / numCharsPerRing * mod((charIndex - ringIndex) / numRings, numCharsPerRing));
pos = rotateVec3(pos, animationValue2 * theta, vec3(0.0, 1.0, 0.0));

animation2は、文字を8つのリング上に配置して、回転させるというものです。

まずは正方形がどのリングに属するかの値ringIndexを決めて、ringIndexによってリングのy座標を決めたあと、リング状に配置しています。

リング状に配置するには、まずz方向にリングの半径分移動し、そのあとy軸を中心に一文字ずつ角度をずらして配置していきます。その際の角度の計算にgetRad関数を使用して経過時間を絡めているので回転するアニメーションが付いています。

・animation3

//
// animation3
// 球面上の移動
//
pos.z += animationValue3 * radius;
theta = getRad(6.0, randomValues.x * 10.0);
pos = rotateVec3(pos, animationValue3 * theta, vec3(0.0, 1.0, 0.0));
theta = getRad(6.0, randomValues.y * 10.0);
pos = rotateVec3(pos, animationValue3 * theta, vec3(1.0, 0.0, 0.0));
theta = getRad(6.0, randomValues.z * 10.0);
pos = rotateVec3(pos, animationValue3 * theta, vec3(0.0, 0.0, 1.0));

続いてはanimation3です。これはanimaion2よりもずっと単純です。こちらもまずz方向に球の半径分移動し、そのあとx, y, z軸を中心に適当に回転させているだけです。

それぞれの軸の回転はgetRad関数にrandomValuesの値を引数として入れて算出しているので、文字ごとにランダムにアニメーションします。

・animation1

最後にずっと飛ばしてきたanimation1の解説をします。

//
// animation1
// ビルボード
//
pos -= animationValue1 * position;
theta = getRad(4.0, (randomValues.x + randomValues.y + randomValues.z) * 20.0);
pos.z += animationValue1 * (radius + radius * map(sin(theta), -1.0, 1.0, 0.0, 1.0, true));
theta = getRad(4.0, randomValues.x * 20.0);
pos = rotateVec3(pos, animationValue1 * theta, vec3(0.0, 1.0, 0.0));
theta = getRad(4.0, randomValues.y * 20.0);
pos = rotateVec3(pos, animationValue1 * theta, vec3(1.0, 0.0, 0.0));
theta = getRad(4.0, randomValues.z * 20.0);
pos = rotateVec3(pos, animationValue1 * theta, vec3(0.0, 0.0, 1.0));

animation1は他のアニメーションと違い、正方形の面が常にカメラの方向を向いているビルボードと呼ばれる手法を使ってます。

頂点シェーダは頂点ごとの処理ではありますが、正方形ごとに変形するイメージで考えてみてください。animation1は変形的にはanimation3に近いです。

まずz方向にせり出して、x, y, z軸を中心にランダムに回転してます。また、最初にz方向にせり出す距離も時間経過で変化させてます。

ただ、そのままだとanimation3のように面の方向がカメラの正面を向かないです。なので、最初に正方形の4頂点をギュッと潰して原点に移動させてしまいます。

pos -= animationValue1 * position;

そしてanimation3のように変形させます。その後、ビュー変換を行ったあとの座標に、潰した分を加算してもとに戻します。先程とばした以下の部分です。

// ビルボードのための処理 (animation1)
modelViewPos += vec4(position, 0.0) * animationValue1;

なんでこれでカメラの方に向くかというと、ビュー変換をした座標はカメラの見え方が反映された状態です。つまりカメラの目線とz軸が平行になっているイメージです。

その状態で潰していた正方形を元に戻すと、全部がカメラの方向に向いたビルボードを実現できる、というわけです。

ただこのやり方があっているかどうかはよくわかりませんが、なんとなくできてるような気がしますw

さて、死ぬほど長くなってしまいましたが、以上で解説を終わります。three.jsは使ったことあるけどシェーダはまだ、、という方はぜひ参考にしてみてください。






サポートいただければ、レッドブルを飲んでより頑張れると思います。翼を授けてください。