【React Three Fiber】ICO球の各面にTextureの断片を映し出すShaderの実装

<!-- ======================================================== -->
# 概要
<!-- ======================================================== -->
Icosahedron Sphere（ICO球）の各面に、Textureを断片的に映し出すShaderの実装方法をまとめました。

https://nemutas.github.io/r3f-icosahedron-screen/
![output(video-cutter-js.com) (6).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/0b420fe1-68e6-f319-b699-c110e26c91bc.gif)

このアプリケーションには元ネタとなるサイトが存在します。

https://rogierdeboeve.com/

このサイトで使われている（であろう）実装技術を解説してくださっている動画があります。

https://www.youtube.com/watch?v=dyvhB6UVxwE

:::note info
【リポジトリ】は、この動画を<strong>React Three Fiber + TypeScript</strong>でまとめたものになっています。
:::
ただし、動画内で扱われている`ワイヤーフレーム`と`マウスモーション`に関しては、実装をしていません。

<!-- ======================================================== -->
# 実装
<!-- ======================================================== -->
ICO球の各面に、Textureを断片的に映し出すためには、以下の2つの技術を組み合わせます。

* フラットシェーディング
* 屈折表現

### フラットシェーディング
Three.jsのMaterialの一部（MeshNormalMaterialなど）には、[**flatShading**](https://threejs.org/docs/?q=mesh#api/en/materials/MeshNormalMaterial.flatShading)というプロパティがあります。
これは、切片をスムーズに補間する（角ばらないようにする）設定で、デフォルトはfalse（スムーズにする）です。
このflatShadingが`ture`の状態、つまり**切片が補間されず角ばった状態**と同等の表現をShaderで実装します。

実装概要は、以下のサイトを見て頂くのが早いと思います。

https://wgld.org/d/webgl/w087.html

要約すると、

:::note info
<strong>dFdx</strong>・<strong>dFdy</strong>（偏微分）を使用して、頂点座標から面の勾配を算出して、その外積をとることで<strong>面単位での法線ベクトル</strong>を求める。
:::
ということです。
以下のICO球は、左が`MeshNormalMaterial`に`flatShading = true`を割り当てたもの、右が`Shader`で`dFdx・dFdy`から面の法線ベクトルを取得してフラットシェーディング表現をしたものになっています。
色味は若干ことなりますが、ほぼ同じ表現ができています。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/cc9d7916-f4a1-75ba-89d4-399a2e5b76e2.png" width="600">

https://codesandbox.io/s/flat-shading-using-dfdx-dfdy-with-r3f-kpzvg

Shader部分のコードは、以下のようになっています。

```.glsl:VertexShader
varying vec3 v_normal;

void main() {
	v_normal = normalMatrix * normal;

	gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
```

```.glsl:FragmentShader
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を使用した屈折表現については、以下のサイトでまとめられています。

https://tympanus.net/codrops/2019/10/29/real-time-multiside-refraction-in-three-steps/

要点として、屈折は以下のように求められます。

```.glsl
vec3 refracted = refract(eyeVector, normal, 1.0 / ior);
uv += refracted.xy;
```
* [refract](https://gist.github.com/gyohk/abf13dbcb5be750b3b021752b280ccd3#%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E8%A1%8C%E5%88%97%E9%96%A2%E6%95%B0)は組み込み関数です。
* `eyeVector`は、カメラから頂点へのベクトルです。（ModelView変換した頂点とは異なります）
* `normal`は、**フラットシェーディング**で求めた面の法線ベクトルです。
* `1.0 / ior`は、屈折率の比率で、空気の屈折率（ior）が`1.0`、素材の屈折率が変数`ior`となっています。例えば、素材がガラス（を模したもの）なら`ior = 1.45`になります。水なら`1.33`、ダイアモンドなら`2.42`になります。

この実装をまとめたものが、以下のSandboxになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/ad2d2dd5-ef2b-eefd-2d96-9a67048a74ca.png" width="600">

https://codesandbox.io/s/refract-icosahedron-using-fragment-shader-with-r3f-711ep

Shader部分のコードは、以下のようになっています。

```.glsl:VertexShader
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);
}
```

```.glsl:FragmentShader
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](https://threejs.org/docs/#api/en/renderers/webgl/WebGLProgram)を参照することができます。
これをvaryingでFragmentSahderに渡すことで、FragmentSahderでも頂点のuvを参照することができます。

```.glsl:VertexShader
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でよく使います。

```.glsl:FragmentShader
vec2 uv = gl_FragCoord.xy / resolution;
```

今回の場合は、1）の方法で取得すればいいように思いますが、これだと以下の要因でうまくいきません。
今回使用しているTexture画像は、[unsplash](https://unsplash.com/)の**1024×1024の正方形の画像**です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/950fdcc4-ff4c-63f8-e7f9-364742c7355f.jpeg" width="300
">
※ 以下のURLにアクセスすると、ランダムなランドスケープの画像（1024×1024）が入手できます。
https://source.unsplash.com/random/1024x1024?landscape

ICO球のuv展開をBlenderで確認します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/d8ee0e2d-3003-0387-a569-f24baf4c059c.png" width="300
">
このように、正方形のTextureを使用する場合、ICO球のデフォルトのuv展開では、画像の下半分しか使われません。（BlenderとThree.jsでuv展開に差異があるかもしれませんが...）

このため、1）の方法で頂点のuvを取得しても、画像のすべての範囲が使用されず、またICO球の面に映し出されるTextureの断片は、間延びしたものになります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/172b8b0a-b381-4722-ff21-6886f5a4261c.png" width="500
">

では、2）の方法ではどうでしょう。
ICO球のuv展開によらないので良さそうですが、ウィンドウの縦横比は常に1ではないので、正方形のTextureに使用した場合、こちらも間延びしてしまいます。

例えば、ウィンドウサイズが横長の場合、uv（白枠）と画像は下図のような関係になり、画像が横に間延びすることがわかります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/b5e161d1-c552-b697-96ef-bbb7acad7cba.png" width="600
">

そのため、uvは以下のように設定します。

```.glsl
vec2 uv = gl_FragCoord.xy / vec2(1000.0);
```
`vec2(1000.0)`で固定値にすることで、正方形のTextureをその比率で取得します。
ただし、ウィンドウサイズが1000px以上の場合、uvが1を超えます。この整合性をとるため、読み込んだTextureの[Wrapping Modes](https://threejs.org/docs/index.html?q=texture#api/en/constants/Textures)を`THREE.MirroredRepeatWrapping`にします。

```.ts
texture.wrapS = texture.wrapT = THREE.MirroredRepeatWrapping
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/cffe39c7-b2cd-03d5-0ebc-71769cde830e.png" width="600
">

### Fresnel反射

>Fresnel反射は、材料の屈折率に依存し、入射ベクトルと表面法線の間の角度が臨界角より大きいとき、光波は反射されます。
><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/dff78289-3fec-040c-dca7-9285b80ec49c.png" width="600
">
>[Real-time Multiside Refraction in Three Steps](https://tympanus.net/codrops/2019/10/29/real-time-multiside-refraction-in-three-steps/)

Fresnel反射は、以下の式で求められます。

```.glsl
float Fresnel(vec3 eyeVector, vec3 worldNormal) {
	return pow(1.0 + dot(eyeVector, worldNormal), 3.0);
}
```

![output(video-cutter-js.com) (7).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/e7dfc889-0aae-fdad-1356-7a8e8e9f439d.gif)

https://codesandbox.io/s/refract-icosahedron-using-fragment-shader-with-r3f-711ep

**どのアングルから見ても同じ効果を得たい**ので、VertexShaderからModelView変換をした頂点座標を、varyingを使用してFragmentShaderに渡します。

```.glsl:VertexShader
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);
}
```

```.glsl:FragmentShader
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）の色と白色を混ぜて反射を表現しています。

### ランダマイズ
ここまでで、この記事の趣旨はまとめ終わりました。
ただ、このままだとあまり面白みがないので、**時間経過で面に表示される画像の位置を切り替える**実装をします。

この実装は、冒頭の解説動画に基づいています。私自身完全に理解できていないので、実装の詳細には触れません。
実装の全体像は、【リポジトリ】を参照してください。

```.glsl:FragmentShader
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は返り値の次元となっています。

https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83#voronoise

### Post-processing
Post-processingについても、この記事では深く掘り下げません。
実装している内容は、以下のようになっています。

* gray scale
* rgb shift
* noise

![スクリーンショット 2022-01-05 231819.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/f61ea8b4-8387-96bd-5c11-da253067f549.png)

ご興味のある方は、動画や【リポジトリ】を参照してください。

<!-- ======================================================== -->
# リポジトリ
<!-- ======================================================== -->
https://github.com/nemutas/r3f-icosahedron-screen

<!-- ======================================================== -->
# まとめ
<!-- ======================================================== -->
0 ~ 1の値をとるだけのuvが奥深すぎる...:rolling_eyes: