概要
蠕動は、Shaderを学習する過程でうまれたアプリケーションです。
この記事では、蠕動に使用しているShaderを例に、実装の考え方をまとめました。
蠕動(ぜんどう、英語: peristalsis)とは、筋肉が伝播性の収縮波を生み出す運動である。
https://ja.wikipedia.org/wiki/%E8%A0%95%E5%8B%95
Meshに動きをつける
蠕動は、言ってしまえば、球体をサイン波で動かしてるだけの単純なShaderで成り立っています。
ここでは、動きをつけているShaderの実装についてまとめています。
MeshのMaterialに対してShaderを割り当てる方法は、以下の記事で詳細にまとめています。
【React Three Fiber】Shaderを使用してMaterialを変更する
Shaderの設計手順
const vertexShader = `
uniform float u_time;
uniform float u_amplitude;
uniform float u_frequency;
varying vec3 v_position;
varying vec3 v_normal;
void main() {
// wave
//----------------------------------
// f1 = sin(t + x * f) -> -1 ~ 1
// f2 = (f1 + 1) / 2 -> 0 ~ 1
// f3 = a * f2 -> 0 ~ a
// f4 = f3 + (1 - a) -> (1 - a) ~ 1
//----------------------------------
float ratio = u_amplitude * (sin(u_time + position.y * u_frequency) + 1.0) / 2.0 + (1.0 - u_amplitude);
vec4 pos = vec4(vec3(position.x * ratio, position.y, position.z * ratio), 1.0);
// glow
pos = modelViewMatrix * pos;
v_position = pos.xyz;
v_normal = normalMatrix * normal;
gl_Position = projectionMatrix * pos;
}
`
const fragmentShader = `
uniform vec3 u_color;
uniform float u_start;
uniform float u_end;
uniform float u_alpha;
varying vec3 v_position;
varying vec3 v_normal;
void main() {
// glow
vec3 normal = normalize(v_normal);
vec3 eye = normalize(-v_position);
float rim = smoothstep(u_start, u_end, 1.0 - dot(normal, eye));
float ratio = clamp(rim, 0.0, 1.0);
// gl_FragColor = vec4(ratio * u_alpha * u_color, 1.0);
gl_FragColor = vec4(u_color * ratio, u_alpha + ratio);
}
`
注目したいのは、vertexShader
のwaveの箇所です。
最終的に、算出したratio
を、Sphereの各頂点のxz座標に掛けて動きをつけています。
周期性のある関数を各頂点の座標に掛けると、掛けた座標に関して法線ベクトル方向に伸縮します。
Shaderで関数を作成する場合、イメージを持ちながら順を追って関数を作っていくことが重要だと思います。
Step 1:時間経過で振動するsin関数を作成する
float ratio = sin(u_time);
u_time
は、経過時間を格納している変数です。
これはどのような関数でしょうか。それを知る便利なツールがあります。
Graphtoyのすごいところは、GLSLで使用できる関数が揃っていて、さらに時間変数を使用できるところです。
上記のコードは、以下のように表せます。
時間経過で、yの値が-1 ~ 1
まで反復していることがわかります。
ただし、このraito
を掛けても、Sphereのすべての頂点のxz座標が均一に伸縮するだけになります。
Step 2:Sphereのy座標によって波形を変える
y座標によって波形を出すためには、y座標をsin関数の位相として扱います。
float ratio = sin(u_time + position.y * u_frequency);
u_frequency
は、振動数です。
これを、Graphtoyで確認してみます。
y座標(position.y)の代わりに、xを使用しています。xの値で振動しているのがわかります。
ただし、振幅(raito
の取る範囲)が-1 ~ 1
になっているので、xz座標がy軸対称に反転する(法線ベクトルの向きが逆になる)周期が出てきてしまいます。
Step 3:振幅を0~1にする
raito
がとる範囲を0 ~ 1
にして、座標をスケーリングしたときにy軸に対して反転しないようにします。
float ratio = (sin(u_time + position.y * u_frequency) + 1.0) / 2.0;
Graphtoyで確認してみましょう。
yの値が、0 ~ 1
になっていることがわかります。
raito
(Sphereのxz座標にかかるスケール・振幅)は、0 ~ 1
に固定されているので、この振幅を変えられるようにします。
Step 4:振幅を変える
振幅を変えて、Sphereの動きにバリエーションをつけます。
float ratio = u_amplitude * (sin(u_time + position.y * u_frequency) + 1.0) / 2.0;
Graphtoyで確認してみましょう。
グラフを見ると、振幅は0 ~ 0.2
となっています。ここで、蠕動の場合、振幅がとる範囲は0 ~ 1
としています。
しかし、このままraito
を使用してしまうと、Sphereのxz座標は0 ~ u_amplitude
の範囲で振動してしまいます。
Step 5:振動の基準を「1」にする
振動の基準を「1」に変えます。ratio = 1
は、つまりSphereの元の座標です。
float ratio = u_amplitude * (sin(u_time + position.y * u_frequency) + 1.0) / 2.0 + (1.0 - u_amplitude);
Graphtoyで確認してみましょう。
これで、Sphereの元のxz座標を基準に、振幅が大きくなるほどxz座標が0に近付くratio
が得られました。
一様な見た目をつける
蠕動の見た目もShaderで作成しています。
こちらは、以下を引用させていただいたコードになっています。
このShaderの特徴は、どんなアングルからみても一様な効果の見た目になるところです。
ここで言う一様な効果とは、uv座標を使用しないことで端部に切れ目が発生しない、引き延ばされたりしないことを意味しています。
このようなShaderは、カメラから見た位置座標を利用することで描画することができます。
uv座標を使用した描画の例として、以下のようなのすごいShaderがあります。
Planeで見る分にはいいですが、これをSphereに割り当てると、uv座標の端部で連続性がなくなります。
また、より複雑な形状のMeshに使用すると、uv座標の関係で描画自体が引き延ばされたりします。
イメージとしては、
uv座標を使用したShaderは描画、Meshの位置座標を使用したShaderはエフェクトという感じです。
Shaderの設計手順
const vertexShader = `
uniform float u_time;
uniform float u_amplitude;
uniform float u_frequency;
varying vec3 v_position;
varying vec3 v_normal;
void main() {
// wave
//----------------------------------
// f1 = sin(t + x * f) -> -1 ~ 1
// f2 = (f1 + 1) / 2 -> 0 ~ 1
// f3 = a * f2 -> 0 ~ a
// f4 = f3 + (1 - a) -> (1 - a) ~ 1
//----------------------------------
float ratio = u_amplitude * (sin(u_time + position.y * u_frequency) + 1.0) / 2.0 + (1.0 - u_amplitude);
vec4 pos = vec4(vec3(position.x * ratio, position.y, position.z * ratio), 1.0);
// glow
pos = modelViewMatrix * pos;
v_position = pos.xyz;
v_normal = normalMatrix * normal;
gl_Position = projectionMatrix * pos;
}
`
const fragmentShader = `
uniform vec3 u_color;
uniform float u_start;
uniform float u_end;
uniform float u_alpha;
varying vec3 v_position;
varying vec3 v_normal;
void main() {
// glow
vec3 normal = normalize(v_normal);
vec3 eye = normalize(-v_position);
float rim = smoothstep(u_start, u_end, 1.0 - dot(normal, eye));
float ratio = clamp(rim, 0.0, 1.0);
gl_FragColor = vec4(u_color * ratio, u_alpha + ratio);
}
`
カメラからの位置座標を取得する
どんなアングルからでも一様な見た目にするには、カメラからの位置座標を取得する必要があります。
pos = modelViewMatrix * pos;
v_position = pos.xyz;
【Meshに動きをつける】で動きをつけたあとの座標に、モデル変換とビュー変換を行います。
この変換は、座標にmodelViewMatrix
を掛けることで行えます。
また、法線ベクトルはnormalMatrix
を掛けたものを取得します。
v_normal = normalMatrix * normal;
normalMatrix
は、元の法線ベクトルに座標変換の結果を適用させるために使用します。詳しくは以下を参照してください。
法線を正しい向きにするときなぜ逆転置行列なのか
position,normal,modelViewMatrix,normalMatrix
はいずれもThree.jsのVertex Shaderに組み込まれている変数なので、Fragment Shaderにはv_position,v_normal
変数として渡す必要があります。
Built-in uniforms and attributes
最終的に、Shpereのある座標(●)に着目したときのv_position
は青の矢印、v_normal
は緑の矢印のようなイメージになります。
色を決定する
Fragment Shaderに渡されたv_position
、v_normal
を使用して色を決定していきます。
void main() {
// glow
vec3 normal = normalize(v_normal);
vec3 eye = normalize(-v_position);
float rim = smoothstep(u_start, u_end, 1.0 - dot(normal, eye));
float ratio = clamp(rim, 0.0, 1.0);
gl_FragColor = vec4(u_color * ratio, u_alpha + ratio);
}
normal
は、v_normal
を長さ1に基準化した単位ベクトルです。
eye
は、v_position
を逆方向にして、長さ1に基準化した単位ベクトルです。
rim
では、GLSLの組み込み関数smoothstep
を使用して、normalとeyeの内積で色に掛ける係数を決めています。
float rim = smoothstep(u_start, u_end, 1.0 - dot(normal, eye));
Graphtoyで確認してみましょう。u_start
とu_end
は変数にしてますが、蠕動では0と1を使っています。
yの値は0 ~ 1
で変動し、xの0 ~ 1の範囲(u_start ~ u_end)でスムーズに補間されていることがわかります。
xの値、つまりnormal
とeye
の内積は、ベクトル同士が直行する場合が「0」、同一方向を向いていて重なる場合は「1」になります。
俯瞰図をみると、Sphereの真ん中ほど内積は「1」に近付き、端(視界に入る範囲の端)に行くほど内積は「0」に近付きます。
最終的には、Sphereの真ん中ほど黒く、端ほど色を見せたいので真偽値を逆にしています。
1.0 - dot(normal, eye)
一様な見た目をとるShaderのサンプル
他の実装例として、以下の素晴らしいサンプルががあります。
影を描画する
蠕動では影もShaderを使用して描画しています。
蠕動する球体の動きに合わせて、影の濃さをコントロールしています。
Shaderの設計手順
const fragmentShader = `
varying vec2 v_uv;
uniform vec3 u_color;
uniform float u_radius;
uniform float u_smooth;
uniform float u_time;
uniform float u_amplitude;
uniform float u_frequency;
void main() {
float dist = distance(vec2(0.5), v_uv);
float circle = 1.0 - smoothstep(u_radius - u_smooth, u_radius + u_smooth, dist);
//----------------------------------------------------------------------------------------------------
// The top(1.0) and bottom(-1.0) of the sphere have a radius around the y-axis that is close to zero,
// so the shadow is affected at -0.8 from the center.
float ratio = u_amplitude * (sin(u_time - 0.8 * u_frequency) + 1.0) / 2.0 + (1.0 - u_amplitude);
//----------------------------------------------------------------------------------------------------
gl_FragColor = vec4(u_color * circle * ratio, 1.0 * circle * ratio);
}
`
今までの説明で理解できると思うので、要点だけまとめます。
- PlaneにShaderを使用して影を描画しているだけなので、uv座標を使用しています。
- uv座標は(0, 0)~(1, 1)をとり、中心は(0.5, 0.5)です。そのpixelに影を描画するかは中心からの距離で決めています。
- 影の濃さは、【Meshに動きをつける】で作成した関数とおなじものを使用します。ただし、影は平面なので
position.y
としていたところを-0.8
にしています。
Vertex Shaderでは、positionは(-1, -1)~(1, 1)の範囲で値を取るので、中心からy軸方向に0.8だけ下がった位置の振幅に併せて、影の濃さを決めています。
ちなみに、Drei(@react-three/drei)にもほぼ同じことができるコンポーネントが用意されています。
こちらは、ShaderではなくCanvas Textureを使用して影を描画しているようです。
リポジトリ
まとめ
Shaderを作成するときは、小さなところから実験しながら実装していくのがいいと思います。
また、複雑な数式がバーンと書かれているとどうしても、自分で作ったもの以外は読む気が失せるので、その設計趣旨も何らかの形で残しておくのがいいと思いました。