【React Three Fiber】Post-processing：Ripple Effect（波紋効果）・Distortion Effect（ねじれ効果）の実装

<!-- ============================================================== -->
# 概要
<!-- ============================================================== -->
React Three Fiberを使用して、**Ripple Effect（波紋効果）**と**Distortion Effect（ねじれ効果）**をPost-processingとして実装する考え方をまとめました。

https://nemutas.github.io/r3f-homunculus/
![output(video-cutter-js.com) (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/53700348-a13b-6193-d329-70ee7db965ca.gif)

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

https://homunculus.jp/

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

https://www.youtube.com/watch?v=qmRqgFbNprM&t=3127s

https://www.youtube.com/watch?v=vWAci72MtME&t=229s

:::note info
【リポジトリ】は、この動画での学習結果を<strong>React Three Fiber + TypeScript</strong>でまとめています。
:::
:::note warn
この記事では、実装に関して、すべてを解説しているわけではないです。
より詳しい実装手順を知りたい方は、是非動画を見てください。
:::

<!-- ============================================================== -->
# Ripple Effect（波紋効果）
<!-- ============================================================== -->
Ripple Effectの実装は、そこそこ複雑です。なので、最初に実装のイメージを掴むのが重要です。

ざっくりいうと、

:::note info
波紋のテクスチャーを貼ったMesh（Plane）を専用のRenderTargetに描画します。それをPost-processingのRenderTargetにTextureとして渡し、Textureのピクセルごとの明度によって描画を波紋状に歪ませます。
:::

例えば、スクリーンの中心に波紋のテクスチャーを貼ったMeshが下図のようにあります。これは専用のRenderTargetに描画されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/1f054c61-0d57-7092-616c-6f80849e139b.png" width="600">

このRenderTargetを、下図のシーンにTextureとして渡します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/0c9eecef-07fd-55c6-5b6c-fe6507f2b254.png" width="600">

Post-processingのRenderTargetでは、下図の波紋のところだけ画像が歪むように処理させます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/74f675ae-db05-c5e2-b946-63a32a52a20b.png" width="600">

実際には、波紋のテクスチャーを貼ったMeshは複数（100枚）あり、経過時間で回転、拡大、透過をします。
![output(video-cutter-js.com) (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/046ec124-c8cc-c3f6-de39-8bcd2610c843.gif)

※ **「専用のRenderTargetに描画するとは？」**と思った方は、以下のSandboxがヒントになると思います。

https://codesandbox.io/s/cube-in-cube-using-rendertarget-with-r3f-x1l0j

### ripple.ts
`ripple.ts`では、**RippleRenderer**クラスを提供しています。
深く掘り下げませんが、このクラスでは以下の処理を行っています。

* 画面いっぱいのRenderTargetを作成する
* 波紋のテクスチャーを貼ったMesh（Plane）を作成する
* マウス移動に追従するように、Meshの位置を変える
* 配置されたMeshを時間経過で、回転、拡大、透過する
* Meshを配置したSceneをRenderTargetに描画し、それをPost-processingのRenderTargetで使用するuniformにTextureとして渡す

```.ts:components/postprocessing/ripple.ts
import * as THREE from 'three';

export class RippleRenderer {
	private _scene: THREE.Scene
	private _target: THREE.WebGLRenderTarget
	private _camera: THREE.OrthographicCamera
	private _meshs: THREE.Mesh[] = []
	/** 波紋の最大描画数 */
	private _max = 100
	/** 1フレームでマウスがどれだけ移動したら描画するか */
	private _frequency = 5
	/** マウス座標 */
	private _mouse = new THREE.Vector2(0, 0)
	/** 前のフレームでのマウス座標 */
	private _prevMouse = new THREE.Vector2(0, 0)
	/** 現在のフレームで描画された波紋のインデックス */
	private _currentWave = 0

	/**
	 * コンストラクタ
	 * @param _texture 波紋のテクスチャー
	 */
	constructor(private _texture: THREE.Texture) {
		this._scene = new THREE.Scene()
		this._target = new THREE.WebGLRenderTarget(window.innerWidth, window.innerHeight)
		// camera
		const { width, height, near, far } = this._cameraProps()
		this._camera = new THREE.OrthographicCamera(-width, width, height, -height, near, far)
		this._camera.position.set(0, 0, 2)
		// mesh
		this._createMesh()
		// events
		window.addEventListener('mousemove', this._handleMouseMove)
		window.addEventListener('resize', this._handleResize)
	}

	private _cameraProps = () => {
		const frustumSize = window.innerHeight
		const aspect = window.innerWidth / window.innerHeight
		const [w, h] = [(frustumSize * aspect) / 2, frustumSize / 2]
		return { width: w, height: h, near: -1000, far: 1000 }
	}

	private _createMesh = () => {
		const size = 64
		const geometry = new THREE.PlaneGeometry(size, size)
		const material = new THREE.MeshBasicMaterial({
			map: this._texture,
			transparent: true,
			blending: THREE.AdditiveBlending,
			depthTest: false,
			depthWrite: false
		})
		for (let i = 0; i < this._max; i++) {
			const mesh = new THREE.Mesh(geometry.clone(), material.clone())
			mesh.rotateZ(2 * Math.PI * Math.random())
			mesh.visible = false
			this._scene.add(mesh)
			this._meshs.push(mesh)
		}
	}

	private _handleMouseMove = (e: MouseEvent) => {
		this._mouse.x = e.clientX - window.innerWidth / 2
		this._mouse.y = window.innerHeight / 2 - e.clientY
	}

	private _handleResize = () => {
		const { width, height } = this._cameraProps()
		this._camera.left = -width
		this._camera.right = width
		this._camera.top = height
		this._camera.bottom = -height
		this._camera.updateProjectionMatrix()
		this._target.setSize(window.innerWidth, window.innerHeight)
	}

	private _setNewWave = () => {
		const mesh = this._meshs[this._currentWave]
		mesh.visible = true
		mesh.position.set(this._mouse.x, this._mouse.y, 0)
		mesh.scale.x = mesh.scale.y = 0.2
		;(mesh.material as THREE.MeshBasicMaterial).opacity = 0.5
	}

	private _trackMousePos = () => {
		// 今のマウス座標と前回のフレームのマウス座標の距離
		const distance = this._mouse.distanceTo(this._prevMouse)
		if (this._frequency < distance) {
			this._setNewWave()
			this._currentWave = (this._currentWave + 1) % this._max
			// console.log(this._currentWave)
		}
		this._prevMouse.x = this._mouse.x
		this._prevMouse.y = this._mouse.y
	}

	/**
	 * 描画を更新する
	 * @param gl メインレンダラー
	 * @param uTexture 波紋の描画結果を格納するuniform
	 */
	update = (gl: THREE.WebGLRenderer, uTexture: THREE.IUniform<any>) => {
		this._trackMousePos()

		gl.setRenderTarget(this._target)
		gl.render(this._scene, this._camera)
		uTexture.value = this._target.texture
		gl.setRenderTarget(null)
		gl.clear()

		this._meshs.forEach(mesh => {
			if (mesh.visible) {
				const material = mesh.material as THREE.MeshBasicMaterial
				mesh.rotation.z += 0.02
				material.opacity *= 0.97
				mesh.scale.x = 0.98 * mesh.scale.x + 0.17
				mesh.scale.y = mesh.scale.x
				if (material.opacity < 0.002) mesh.visible = false
			}
		})
	}

	/**
	 * インスタンスを破棄する
	 */
	dispose = () => {
		window.removeEventListener('mousemove', this._handleMouseMove)
		window.removeEventListener('resize', this._handleResize)
	}
}
```
特に、`update`は、**フレーム単位の呼出しを想定しているメソッド**です。
呼び出されたときの波紋の状態をRenderTargetに描画して、それを引数で受け取った`uniform（uTexture）`にTextureとして渡しています。

```.tsx
gl.setRenderTarget(this._target)
gl.render(this._scene, this._camera)
uTexture.value = this._target.texture
gl.setRenderTarget(null)
gl.clear()
```
あとの処理は、波紋の状態を更新しているだけです。

### RipplePass.tsx
`RipplePass.tsx`は、Ripple EffectのPost-processingを作成するコンポーネントです。

```.tsx:components/postprocessing/RipplePass.tsx
import React, { Suspense, useEffect, useMemo, useRef, VFC } from 'react';
import { ShaderPass } from 'three-stdlib';
import { useTexture } from '@react-three/drei';
import { extend, useFrame } from '@react-three/fiber';
import { publicPath } from '../../utils/file';
import { RippleRenderer } from './ripple';

extend({ ShaderPass })

type RipplePassType = {
	enabled?: boolean
}

export const RipplePass: VFC<RipplePassType> = props => {
	const { enabled = true } = props

	return (
		<Suspense fallback={null}>
			<Ripple enabled={enabled} />
		</Suspense>
	)
}

// ========================================================
type RippleType = {
	enabled?: boolean
}

const Ripple: VFC<RippleType> = props => {
	const { enabled = true } = props

	const shaderRef = useRef<ShaderPass>(null)

	const rippleTexture = useTexture(publicPath('/assets/textures/brush.png'))
	const effect = useMemo(() => new RippleRenderer(rippleTexture), [rippleTexture])

	const shader: THREE.Shader = useMemo(() => {
		return {
			uniforms: {
				tDiffuse: { value: null },
				u_displacement: { value: null }
			},
			vertexShader: vertexShader,
			fragmentShader: fragmentShader
		}
	}, [])

	useEffect(() => {
		return () => effect.dispose()
	}, [effect])

	useFrame(({ gl }) => {
		effect.update(gl, shaderRef.current!.uniforms.u_displacement)
	})

	return <shaderPass ref={shaderRef} attachArray="passes" args={[shader]} enabled={enabled} />
}

// --------------------------------------------------------
const vertexShader = `
varying vec2 v_uv;

void main() {
  v_uv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`

const fragmentShader = `
uniform sampler2D tDiffuse;
uniform sampler2D u_displacement;
varying vec2 v_uv;

float PI = 3.141592653589;

void main() {
  vec2 uv = v_uv;

  vec4 disp = texture2D(u_displacement, uv);
  float theta = disp.r * 2.0 * PI;
  vec2 dir = vec2(sin(theta), cos(theta));
  uv += dir * disp.r * 0.1;

  vec4 color = texture2D(tDiffuse, uv);

  gl_FragColor = color;
}
`
```
コンポーネントをひとつ噛ませているのは、波紋テクスチャーを`useTexture`を使用して読み込んでいるため、**Suspense**で囲う必要があるからです。

```.tsx
export const RipplePass: VFC<RipplePassType> = props => {
	const { enabled = true } = props

	return (
		<Suspense fallback={null}>
			<Ripple enabled={enabled} />
		</Suspense>
	)
}
```
`RippleRenderer`は、波紋テクスチャーを引数にインスタンスを作成することができます。

```.tsx
const rippleTexture = useTexture(publicPath('/assets/textures/brush.png'))
const effect = useMemo(() => new RippleRenderer(rippleTexture), [rippleTexture])
```
`shader`では、uniformsとして`tDiffuse`と`u_displacement`をとります。
Post-processingの**tDiffuse**は特殊なuniformで、**いまの描画結果をshaderに渡す**役割を持ちます。
`u_displacement`は、RippleRendererで描画された波紋をTextureとしてshaderに渡します。

```.tsx
const shader: THREE.Shader = useMemo(() => {
	return {
		uniforms: {
			tDiffuse: { value: null },
			u_displacement: { value: null }
		},
		vertexShader: vertexShader,
		fragmentShader: fragmentShader
	}
}, [])
```
`useFrame`では、フレームごとに波紋を描画したRenderTargetを取得し、その結果を`u_displacement`に格納しています。

```.tsx
useFrame(({ gl }) => {
	effect.update(gl, shaderRef.current!.uniforms.u_displacement)
})
```
`fragmentShader`では、受け取った`u_displacement`の明度を元に、uv座標をコントロールして、取得するスクリーン画像のピクセルを歪ませ波紋を作っています。
このように、fragmentShaderでピクセルの位置をコントロールしたい場合は、**uv座標**を操作します。

```.ts
const fragmentShader = `
uniform sampler2D tDiffuse;
uniform sampler2D u_displacement;
varying vec2 v_uv;

float PI = 3.141592653589;

void main() {
  vec2 uv = v_uv;

  vec4 disp = texture2D(u_displacement, uv);
  float theta = disp.r * 2.0 * PI;
  vec2 dir = vec2(sin(theta), cos(theta));
  uv += dir * disp.r * 0.1;

  vec4 color = texture2D(tDiffuse, uv);

  gl_FragColor = color;
}
`
```

<!-- ============================================================== -->
# Distortion Effect（ねじれ効果）
<!-- ============================================================== -->
Distortion Effectは、Ripple Effectよりも単純です。
ですが、周期性のあるカオスを実験をしながら実装する必要があるため、Shaderの処理について詳しくはふれません。

### DistortionPass.tsx
`DistortionPass.tsx`は、Distortion EffectのPost-processingを作成するコンポーネントです。

```.tsx:components/postprocessing/DistortionPass.tsx
import { useMemo, useRef, VFC } from 'react';
import { ShaderPass } from 'three-stdlib';
import { extend, useFrame } from '@react-three/fiber';

extend({ ShaderPass })

type DistortionPassType = {
	enabled?: boolean
	progress?: number
	scale?: number
}

export const DistortionPass: VFC<DistortionPassType> = props => {
	const { enabled = true, progress = 0, scale = 1 } = props

	const distortionRef = useRef<ShaderPass>(null)

	const shader: THREE.Shader = useMemo(() => {
		return {
			uniforms: {
				tDiffuse: { value: null },
				u_time: { value: 0 },
				u_progress: { value: 0 },
				u_scale: { value: 1 }
			},
			vertexShader: vertexShader,
			fragmentShader: fragmentShader
		}
	}, [])

	useFrame(() => {
		distortionRef.current!.uniforms.u_time.value += 0.01
	})

	return (
		<shaderPass
			ref={distortionRef}
			attachArray="passes"
			args={[shader]}
			enabled={enabled}
			uniforms-u_progress-value={progress}
			uniforms-u_scale-value={scale}
		/>
	)
}

// --------------------------------------------------------
const vertexShader = `
varying vec2 v_uv;

void main() {
  v_uv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`

const fragmentShader = `
uniform sampler2D tDiffuse;
uniform float u_time;
uniform float u_progress;
uniform float u_scale;
varying vec2 v_uv;

void main() {
	vec2 uv = v_uv;

	vec2 p = 2.0 * v_uv - 1.0; // -1 ~ 1
	p += 0.1 * cos(u_scale * 3.7 * p.yx + 1.4 * u_time + vec2(2.2, 3.4));
	p += 0.1 * cos(u_scale * 3.0 * p.yx + 1.0 * u_time + vec2(1.2, 3.4));
	p += 0.3 * cos(u_scale * 5.0 * p.yx + 2.6 * u_time + vec2(4.2, 1.4));
	p += 0.3 * cos(u_scale * 7.5 * p.yx + 3.6 * u_time + vec2(12.2, 3.4));

	uv.x = mix(v_uv.x, length(p), u_progress);
	uv.y = mix(v_uv.y, 0.5 * length(p) + 0.15, u_progress);
  
	vec4 color = texture2D(tDiffuse, uv);
  
	gl_FragColor = color;
	// gl_FragColor = vec4(vec3(length(p)), 1.0);
}
`
```
`fragmentShader`では、length関数を使用して中心からの距離に応じたuv座標を指定します。これによって、シーンのピクセル（tDiffuse）を円形に取得しています。
また、mix関数を使用することで、u_progressに従って、`オリジナルのuv座標`と`変換したuv座標`をモーフするようにしています。

```.glsl
uv.x = mix(v_uv.x, length(p), u_progress);
uv.y = mix(v_uv.y, 0.5 * length(p) + 0.15, u_progress);
```
円形に取得したシーンのピクセルを、動的で周期性のあるカオスな形状にするために、三角関数を重ねて使用してます。
各係数は、実験的に得られたものです。

```.glsl
p += 0.1 * cos(u_scale * 3.7 * p.yx + 1.4 * u_time + vec2(2.2, 3.4));
p += 0.1 * cos(u_scale * 3.0 * p.yx + 1.0 * u_time + vec2(1.2, 3.4));
p += 0.3 * cos(u_scale * 5.0 * p.yx + 2.6 * u_time + vec2(4.2, 1.4));
p += 0.3 * cos(u_scale * 7.5 * p.yx + 3.6 * u_time + vec2(12.2, 3.4));
```
uv座標がどんなサンプリングをするか確認をする場合、`gl_FragColor`を以下のように指定するとわかりやすいです。

```.glsl
gl_FragColor = vec4(vec3(length(p)), 1.0);
```
白い領域がサンプリングされる範囲です。また、`u_scale`を操作することで、複雑性が増していることがわかります。
![output(video-cutter-js.com) (5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/dc77898b-7b81-8da4-9efb-53a79c13bb50.gif)

<!-- ============================================================== -->
# リポジトリ
<!-- ============================================================== -->

https://github.com/nemutas/r3f-homunculus

<!-- ============================================================== -->
# まとめ
<!-- ============================================================== -->
かなりかい摘んだ説明になってしまいました。
Three.jsのShaderまで踏み込むと、前提知識がない状態を想定した説明はどうしても難しいです...:disappointed:

<!-- ============================================================== -->
# おまけ
<!-- ============================================================== -->
この記事を書いているのが、2021.12.31です。
今年も終わりということで、2021年の成果物を載せました。

### 投稿記事の一覧
2021年からWebフロントエンドの学習を始めて、記事を書くことが多くなりました。
それを一覧として項目別に時系列にまとめています。

https://qiita.com/nemutas/private/e8dbb3d5359c588f1d3f

### CodeSandbox
普段の実験的な実装は、ローカル環境（VSCode）で行っています。
そこでしっかりまとまったモノは、**CodeSandbox**にアップロードしています。さらにアプリケーション化したいモノ、コード量が多いモノは**Github**にアップロードしています。

CodeSandboxにアップロードしているモノは、規模が小さくそれに関する記事も基本的に書いていないので、ここで紹介します。

https://codesandbox.io/s/wave-plane-with-displacement-map-x4o3q

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

https://codesandbox.io/s/grow-line-using-meshsurfacesampler-with-r3f-gjw59

https://codesandbox.io/s/morph-particle-using-meshsurfacesampler-with-r3f-4lrpk

https://codesandbox.io/s/snoise-sphere-using-vertexshader-with-r3f-yelco

https://codesandbox.io/s/pnoise-sphere-using-vertexshader-with-r3f-z8nqz

https://codesandbox.io/s/twist-cube-using-vertexshader-with-r3f-ktix4

https://codesandbox.io/s/twisted-colorful-sphere-using-vertexshader-with-r3f-vhjr1

https://codesandbox.io/s/glass-effect-using-physicalmaterial-with-r3f-0gt2k

https://codesandbox.io/s/cube-in-cube-using-rendertarget-with-r3f-x1l0j

https://codesandbox.io/s/gsap-animation-d12l2