【React Three Fiber】魚眼レンズのようなVertex Shaderの実装

<!-- ==================================================== -->
# 概要
<!-- ==================================================== -->
マウスを中心に魚眼レンズのように頂点座標が移動するVertex Shaderの実装方法をまとめました。

https://uqy1l.csb.app/
![output(video-cutter-js.com) (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/9b3ce8ca-89ef-7ff0-c026-fceedaaec900.gif)

:::note info
React Three Fiber（Three.jsのReact用ラッパーパッケージ）を使用しています。
:::

<!-- ==================================================== -->
# 実装
<!-- ==================================================== -->
実装の全容は、【CodeSandbox】のリンクを確認してください。
ここでは、Shaderに関係するコードの設計手順を説明します。

### イメージを作る
どんなコーディングでもそうですが、実装に入る前に**どんなものを実装したくて**、それには**技術的に何が必要なのか**を整理します。

今回の場合だと、作りたいものは、**魚眼レンズのようにマウスを中心に頂点座標が移動するShader**です。
そして、それを実現させるためには大きく分けて2つ必要な技術があります。

* マウスがMesh上にあるとき、その座標を取得する。
* マウス座標を中心に頂点を移動させる。マウス座標に近い頂点ほど大きく移動させる。

これをイメージにしたものが、以下になります。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/fead845b-2230-4a7c-a1bb-4d5695639087.png" width="500" />
Mesh（Plane）上にマウスがあります。例えば、このときの "<font color="#FFAAAA">●</font>" と、 "<font color="#bdd6aa">●</font>" の頂点の動きを考えます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/5546d2a8-a785-6a9e-86a5-71dc133c1e96.png" height="500">
どちらの頂点も、マウス座標から頂点座標のベクトル方向（**<font color="#22A7F2">- - -</font>**）に移動します。
また、移動量は、マウスに近いほど大きくなります。

### コード
```.tsx
/* eslint-disable @typescript-eslint/no-use-before-define */

import { gsap, Linear } from 'gsap'
import { folder, useControls } from 'leva'
import React, { useMemo, VFC } from 'react'
import * as THREE from 'three'
import { ThreeEvent } from '@react-three/fiber'

export const ShaderObject: VFC = () => {
	// ----------------------------
	// controller
	const datas = useControls({
		geometry: { options: ['plane', 'sphere', 'cube'] },
		inner: true,
		uniforms: folder({
			range: { value: 0.2, min: 0.1, max: 1, step: 0.1 },
			scale: { value: 0.1, min: 0, max: 0.2, step: 0.01 }
		})
	})

	// ----------------------------
	// mesh relation
	const material = useMemo(
		() =>
			new THREE.ShaderMaterial({
				uniforms: {
					u_mouse: { value: [0, 0, 0] },
					u_range: { value: datas.range },
					u_scale: { value: 0 }
				},
				vertexShader: vertexShader,
				fragmentShader: fragmentShader,
				wireframe: true
			}),
		[datas.range]
	)

	const geometry = useMemo(() => {
		let geo: THREE.BufferGeometry
		switch (datas.geometry) {
			case 'plane':
				geo = new THREE.PlaneGeometry(1, 1, 20, 20)
				break
			case 'sphere':
				geo = new THREE.IcosahedronGeometry(0.5, 10)
				break
			case 'cube':
				geo = new THREE.BoxGeometry(1, 1, 1, 20, 20, 20)
				break
			default:
				geo = new THREE.PlaneGeometry(1, 1, 20, 20)
		}
		return geo
	}, [datas.geometry])

	// ----------------------------
	// event
	const handleClick = (e: ThreeEvent<MouseEvent>) => {
		// debug
		console.log('point', e.point)
		console.log('normal', e.face?.normal)
	}

	const handlePointerMove = (e: ThreeEvent<PointerEvent>) => {
		material.uniforms.u_scale.value = datas.scale
		material.uniforms.u_mouse.value = e.point
	}

	const handlePointerLeave = (e: ThreeEvent<PointerEvent>) => {
		const gsapOptions: gsap.TweenVars = {
			ease: Linear.easeOut,
			duration: 0.2
		}
		gsap.to(material.uniforms.u_scale, { value: 0, ...gsapOptions })
	}

	return (
		<>
			<mesh
				geometry={geometry}
				material={material}
				onPointerMove={handlePointerMove}
				onPointerLeave={handlePointerLeave}
				// onClick={handleClick}
			/>
			{datas.inner && (datas.geometry === 'sphere' || datas.geometry === 'cube') && (
				<mesh geometry={geometry} scale={0.98}>
					<meshBasicMaterial color="#000" />
				</mesh>
			)}
		</>
	)
}

// ====================================================
// shader
const vertexShader = `
uniform vec3 u_mouse;
uniform float u_range;
uniform float u_scale;
varying vec3 v_position;

void main() {
	vec3 pos = position;
	if (0.0 < u_scale){
		float dist = distance(u_mouse, pos);
		dist = clamp(dist, 0.0, u_range);
		vec3 vecDir = normalize(pos - u_mouse);
		pos += vecDir * (u_range - dist) / u_range * u_scale;
	}

	v_position = pos;

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

const fragmentShader = `
varying vec3 v_position;

void main() {
	vec3 color = v_position + 0.5;

	gl_FragColor = vec4(color, 1.0);
}
`
```

#### マウス座標の取得
React Three Fiberを使用すると、マウスがMeshにあたっているときの座標はとても簡単に取得できます。

```.tsx
const handleClick = (e: ThreeEvent<MouseEvent>) => {
	// debug
	console.log('point', e.point)
	console.log('normal', e.face?.normal)
}
```
このコードは、デバッグ用に書いているコードですが、Meshをクリックしたときに、クリックした位置の座標と法線ベクトルを取得しています。
同様に、Mesh上をマウスが移動するとき（`handlePointerMove`）でも座標を取得できます。

#### Shader
イメージはできているので、あとはそれをコードで表すだけです。

```.tsx
const vertexShader = `
uniform vec3 u_mouse;
uniform float u_range;
uniform float u_scale;
varying vec3 v_position;

void main() {
	vec3 pos = position;
	if (0.0 < u_scale){
		float dist = distance(u_mouse, pos);
		dist = clamp(dist, 0.0, u_range);
		vec3 vecDir = normalize(pos - u_mouse);
		pos += vecDir * (u_range - dist) / u_range * u_scale;
	}

	v_position = pos;

	gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
```
変数は、以下のようになっています。
`u_mouse`：Mesh上のマウス座標
`u_range`：影響範囲の半径（範囲内の頂点しか移動しないようにする）
`u_scale`：どれくらい拡大するか
`v_position`：Fragment Shaderに渡して、色を付けるために使っているので特に触れません

まず、`if (0.0 < u_scale)`で、スケールが0より大きいときだけ処理を行います。これは、マウスがMeshから離れたときに処理を行わないようにするための判定です。
`handlePointerLeave`は、Meshからマウスが離れたときに発生するイベントで、ここでスケールを0に設定しています。

```.tsx
const handlePointerLeave = (e: ThreeEvent<PointerEvent>) => {
	const gsapOptions: gsap.TweenVars = {
		ease: Linear.easeOut,
		duration: 0.2
	}
	gsap.to(material.uniforms.u_scale, { value: 0, ...gsapOptions })
}
```
さらに、[gsap](https://greensock.com/)を使用して、マウスが離れても移動した頂点が元の位置に一瞬で戻らないように、アニメーションさせています。

頂点の座標を移動する処理は、以下のようになっています。

```.ts
float dist = distance(u_mouse, pos);
dist = clamp(dist, 0.0, u_range);
vec3 vecDir = normalize(pos - u_mouse);
pos += vecDir * (u_range - dist) / u_range * u_scale;
```

* `distance(u_mouse, pos)`では、マウス座標と対象の頂点座標の距離を取っています。
* `clamp(dist, 0.0, u_range)`では、距離が収まる範囲を制限しています。（距離は、`0 ~ u_range`以内）
* `normalize(pos - u_mouse)`では、マウスから頂点のベクトルを求めて、それを単位ベクトルに基準化しています。基準化しているのは、ベクトルの方向成分だけが欲しいからです。（ベクトルの大きさはいらいない）
* `pos += vecDir * (u_range - dist) / u_range * u_scale`では、頂点座標をマウスからのベクトル方向（`vecDir`）に移動しています。その移動量は、`(u_range - dist) / u_range`で頂点がマウスに近いほど1、遠いほど0になり、`u_scale`を掛けて倍率を決めています。

<!-- ==================================================== -->
# 色々なGeometryで試してみる
<!-- ==================================================== -->
Shader内で頂点位置の移動に使用しているのは、マウス座標と頂点座標だったので、このShaderは形状によらず使うことができます。

**Sphere**
![スクリーンショット 2021-12-22 181409.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/dfe84d35-e3c6-b135-51d0-3df2f48f3dc4.png)

**Cube**
![スクリーンショット 2021-12-22 181536.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/a0118a1a-5cfa-72fe-48ce-a824645fe3e6.png)

<!-- ==================================================== -->
# Textureを貼ってみる
<!-- ==================================================== -->
PlaneにTextureを貼ると、本当に魚眼レンズのようになります。

https://jimpy.csb.app/
![output(video-cutter-js.com).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/1272fd64-1b60-c454-b5b9-979f1b01618a.gif)



<!-- ==================================================== -->
# CodeSandbox
<!-- ==================================================== -->

https://codesandbox.io/s/fisheye-like-vertexshader-with-r3f-uqy1l

https://codesandbox.io/s/fisheye-lens-using-vertexshader-with-r3f-jimpy

<!-- ==================================================== -->
# まとめ
<!-- ==================================================== -->
VertexShaderを扱うには慣れが必要ですが、慣れれば面白い表現が色々できそうです:slight_smile: