概要
Icosahedron Sphere(ICO球)の各面に、Textureを断片的に映し出すShaderの実装方法をまとめました。
https://nemutas.github.io/r3f-icosahedron-screen/
このアプリケーションには元ネタとなるサイトが存在します。
このサイトで使われている(であろう)実装技術を解説してくださっている動画があります。
【リポジトリ】は、この動画をReact Three Fiber + TypeScriptでまとめたものになっています。
ただし、動画内で扱われているワイヤーフレーム
とマウスモーション
に関しては、実装をしていません。
実装
ICO球の各面に、Textureを断片的に映し出すためには、以下の2つの技術を組み合わせます。
- フラットシェーディング
- 屈折表現
フラットシェーディング
Three.jsのMaterialの一部(MeshNormalMaterialなど)には、flatShadingというプロパティがあります。
これは、切片をスムーズに補間する(角ばらないようにする)設定で、デフォルトはfalse(スムーズにする)です。
このflatShadingがture
の状態、つまり切片が補間されず角ばった状態と同等の表現をShaderで実装します。
実装概要は、以下のサイトを見て頂くのが早いと思います。
要約すると、
dFdx・dFdy(偏微分)を使用して、頂点座標から面の勾配を算出して、その外積をとることで面単位での法線ベクトルを求める。
ということです。
以下のICO球は、左がMeshNormalMaterial
にflatShading = true
を割り当てたもの、右がShader
でdFdx・dFdy
から面の法線ベクトルを取得してフラットシェーディング表現をしたものになっています。
色味は若干ことなりますが、ほぼ同じ表現ができています。
Shader部分のコードは、以下のようになっています。
varying vec3 v_normal;
void main() {
v_normal = normalMatrix * normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
varying vec3 v_normal;
void main() {
vec3 x = dFdx(v_normal);
vec3 y = dFdy(v_normal);
vec3 normal = normalize(cross(x, y));
gl_FragColor = vec4(normal, 1.0);
}
屈折表現
GLSLを使用した屈折表現については、以下のサイトでまとめられています。
要点として、屈折は以下のように求められます。
vec3 refracted = refract(eyeVector, normal, 1.0 / ior);
uv += refracted.xy;
- refractは組み込み関数です。
-
eyeVector
は、カメラから頂点へのベクトルです。(ModelView変換した頂点とは異なります) -
normal
は、フラットシェーディングで求めた面の法線ベクトルです。 -
1.0 / ior
は、屈折率の比率で、空気の屈折率(ior)が1.0
、素材の屈折率が変数ior
となっています。例えば、素材がガラス(を模したもの)ならior = 1.45
になります。水なら1.33
、ダイアモンドなら2.42
になります。
Shader部分のコードは、以下のようになっています。
varying vec3 v_normal;
varying vec3 v_eye;
void main() {
v_normal = normalMatrix * normal;
vec4 mPos = modelMatrix * vec4( position, 1.0);
v_eye = normalize(mPos.xyz - cameraPosition);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
uniform sampler2D u_texture;
varying vec3 v_normal;
varying vec3 v_eye;
void main() {
// flat shading
vec3 x = dFdx(v_normal);
vec3 y = dFdy(v_normal);
vec3 normal = normalize(cross(x, y));
vec2 uv = gl_FragCoord.xy / vec2(1000.0);
// refraction
float ior = 1.45;
vec3 refracted = refract(v_eye, normal, 1.0 / ior);
uv += refracted.xy * 0.5;
vec4 tex = texture2D(u_texture, uv);
gl_FragColor = tex;
}
uvについて
uvは、Textureのどの位置でpixel値(vec4情報、通常はrgbaの色情報)を取得するかを表します。
uv値は(x, y)の2次元情報で、値の取る範囲は(0, 0)~(1, 1)
となっています。
Fragment Shaderでのuvの取り方はいくつか方法があります。
1)VertexShaderから取得する
Three.jsでVertexShaderを使用する場合、頂点attribute の uvを参照することができます。
これをvaryingでFragmentSahderに渡すことで、FragmentSahderでも頂点のuvを参照することができます。
varying vec2 v_uv;
void main() {
v_uv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
2)画面座標で取得する
FragmentShaderでは、gl_FragCoord
で現在GPUが処理しているウィンドウ上のpixel位置を取得することができます。
これをウィンドウサイズで割ることで、(0, 0)~(1, 1)のuvを取得することができます。
この手法は、Scene全体にEffectをかけるPost-processingでよく使います。
vec2 uv = gl_FragCoord.xy / resolution;
今回の場合は、1)の方法で取得すればいいように思いますが、これだと以下の要因でうまくいきません。
今回使用しているTexture画像は、unsplashの1024×1024の正方形の画像です。
※ 以下のURLにアクセスすると、ランダムなランドスケープの画像(1024×1024)が入手できます。
https://source.unsplash.com/random/1024x1024?landscape
ICO球のuv展開をBlenderで確認します。
このように、正方形のTextureを使用する場合、ICO球のデフォルトのuv展開では、画像の下半分しか使われません。(BlenderとThree.jsでuv展開に差異があるかもしれませんが...)
このため、1)の方法で頂点のuvを取得しても、画像のすべての範囲が使用されず、またICO球の面に映し出されるTextureの断片は、間延びしたものになります。
では、2)の方法ではどうでしょう。
ICO球のuv展開によらないので良さそうですが、ウィンドウの縦横比は常に1ではないので、正方形のTextureに使用した場合、こちらも間延びしてしまいます。
例えば、ウィンドウサイズが横長の場合、uv(白枠)と画像は下図のような関係になり、画像が横に間延びすることがわかります。
そのため、uvは以下のように設定します。
vec2 uv = gl_FragCoord.xy / vec2(1000.0);
vec2(1000.0)
で固定値にすることで、正方形のTextureをその比率で取得します。
ただし、ウィンドウサイズが1000px以上の場合、uvが1を超えます。この整合性をとるため、読み込んだTextureのWrapping ModesをTHREE.MirroredRepeatWrapping
にします。
texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping
Fresnel反射
Fresnel反射は、材料の屈折率に依存し、入射ベクトルと表面法線の間の角度が臨界角より大きいとき、光波は反射されます。
Real-time Multiside Refraction in Three Steps
Fresnel反射は、以下の式で求められます。
float Fresnel(vec3 eyeVector, vec3 worldNormal) {
return pow(1.0 + dot(eyeVector, worldNormal), 3.0);
}
どのアングルから見ても同じ効果を得たいので、VertexShaderからModelView変換をした頂点座標を、varyingを使用してFragmentShaderに渡します。
varying vec3 v_mvPos;
void main() {
vec4 mvPos = modelViewMatrix * vec4( position, 1.0);
v_mvPos = normalize(mvPos.xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
varying vec3 v_mvPos;
float Fresnel(vec3 eyeVector, vec3 worldNormal) {
return pow(1.0 + dot(eyeVector, worldNormal), 3.0);
}
void main() {
// fresnel reflection
float fresnel = Fresnel(v_mvPos, normal);
vec4 color = mix(tex, vec4(1.0), fresnel);
gl_FragColor = vec4(vec3(fresnel), 1.0);
}
コードでは、fresnel
をそのままgl_FragColor
として出力していますが、実際にはmix
関数を使用して、読み込んだtexture(tex)の色と白色を混ぜて反射を表現しています。
ランダマイズ
ここまでで、この記事の趣旨はまとめ終わりました。
ただ、このままだとあまり面白みがないので、時間経過で面に表示される画像の位置を切り替える実装をします。
この実装は、冒頭の解説動画に基づいています。私自身完全に理解できていないので、実装の詳細には触れません。
実装の全体像は、【リポジトリ】を参照してください。
vec2 hash22( vec2 p ){
p = vec2( dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3)));
return fract(sin(p)*43758.5453);
}
void main() {
// 省略
// uv switcher
float diffuse = dot(normal, vec3(1.0));
float periodicity = (sin(u_time) + 1.0) / 2.0;
vec2 seed = vec2(floor(diffuse * 5.0 + periodicity * 1.5));
vec2 rand = hash22(seed);
rand -= 0.5;
vec2 switcher = sign(rand) * 1.0 + rand * 0.6;
uv *= switcher;
// 省略
}
動画では、periodicity
はなく、単純にICO球の回転させることで法線ベクトルを変化させ、それによって画像が切り替わる処理になっています。
hash22
は、以下のリポジトリから引用させて頂きました。
hash22の命名規則ですが、最初の2は引数の次元、次の2は返り値の次元となっています。
Post-processing
Post-processingについても、この記事では深く掘り下げません。
実装している内容は、以下のようになっています。
- gray scale
- rgb shift
- noise
ご興味のある方は、動画や【リポジトリ】を参照してください。
リポジトリ
まとめ
0 ~ 1の値をとるだけのuvが奥深すぎる...