16
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【React Three Fiber】Post-processing:Ripple Effect(波紋効果)・Distortion Effect(ねじれ効果)の実装

Last updated at Posted at 2021-12-31

概要

React Three Fiberを使用して、**Ripple Effect(波紋効果)Distortion Effect(ねじれ効果)**をPost-processingとして実装する考え方をまとめました。

https://nemutas.github.io/r3f-homunculus/
output(video-cutter-js.com) (2).gif

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

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

【リポジトリ】は、この動画での学習結果をReact Three Fiber + TypeScriptでまとめています。

この記事では、実装に関して、すべてを解説しているわけではないです。
より詳しい実装手順を知りたい方は、是非動画を見てください。

Ripple Effect(波紋効果)

Ripple Effectの実装は、そこそこ複雑です。なので、最初に実装のイメージを掴むのが重要です。

ざっくりいうと、

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

例えば、スクリーンの中心に波紋のテクスチャーを貼ったMeshが下図のようにあります。これは専用のRenderTargetに描画されます。

このRenderTargetを、下図のシーンにTextureとして渡します。

Post-processingのRenderTargetでは、下図の波紋のところだけ画像が歪むように処理させます。

実際には、波紋のテクスチャーを貼ったMeshは複数(100枚)あり、経過時間で回転、拡大、透過をします。
output(video-cutter-js.com) (3).gif

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

ripple.ts

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

  • 画面いっぱいのRenderTargetを作成する
  • 波紋のテクスチャーを貼ったMesh(Plane)を作成する
  • マウス移動に追従するように、Meshの位置を変える
  • 配置されたMeshを時間経過で、回転、拡大、透過する
  • Meshを配置したSceneをRenderTargetに描画し、それをPost-processingのRenderTargetで使用するuniformにTextureとして渡す
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を作成するコンポーネントです。

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としてtDiffuseu_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を作成するコンポーネントです。

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

リポジトリ

まとめ

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

おまけ

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

投稿記事の一覧

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

CodeSandbox

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

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

16
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?