概要
React Three Fiberを使用して、**Ripple Effect(波紋効果)とDistortion Effect(ねじれ効果)**をPost-processingとして実装する考え方をまとめました。
https://nemutas.github.io/r3f-homunculus/
このアプリケーションには元ネタとなるサイトが存在します。
このサイトで使われている(であろう)実装技術を解説してくださっている動画があります。
【リポジトリ】は、この動画での学習結果を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枚)あり、経過時間で回転、拡大、透過をします。
※ **「専用のRenderTargetに描画するとは?」**と思った方は、以下のSandboxがヒントになると思います。
ripple.ts
ripple.ts
では、RippleRendererクラスを提供しています。
深く掘り下げませんが、このクラスでは以下の処理を行っています。
- 画面いっぱいのRenderTargetを作成する
- 波紋のテクスチャーを貼ったMesh(Plane)を作成する
- マウス移動に追従するように、Meshの位置を変える
- 配置されたMeshを時間経過で、回転、拡大、透過する
- Meshを配置したSceneをRenderTargetに描画し、それをPost-processingのRenderTargetで使用するuniformにTextureとして渡す
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として渡しています。
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を作成するコンポーネントです。
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で囲う必要があるからです。
export const RipplePass: VFC<RipplePassType> = props => {
const { enabled = true } = props
return (
<Suspense fallback={null}>
<Ripple enabled={enabled} />
</Suspense>
)
}
RippleRenderer
は、波紋テクスチャーを引数にインスタンスを作成することができます。
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に渡します。
const shader: THREE.Shader = useMemo(() => {
return {
uniforms: {
tDiffuse: { value: null },
u_displacement: { value: null }
},
vertexShader: vertexShader,
fragmentShader: fragmentShader
}
}, [])
useFrame
では、フレームごとに波紋を描画したRenderTargetを取得し、その結果をu_displacement
に格納しています。
useFrame(({ gl }) => {
effect.update(gl, shaderRef.current!.uniforms.u_displacement)
})
fragmentShader
では、受け取ったu_displacement
の明度を元に、uv座標をコントロールして、取得するスクリーン画像のピクセルを歪ませ波紋を作っています。
このように、fragmentShaderでピクセルの位置をコントロールしたい場合は、uv座標を操作します。
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を作成するコンポーネントです。
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座標
をモーフするようにしています。
uv.x = mix(v_uv.x, length(p), u_progress);
uv.y = mix(v_uv.y, 0.5 * length(p) + 0.15, u_progress);
円形に取得したシーンのピクセルを、動的で周期性のあるカオスな形状にするために、三角関数を重ねて使用してます。
各係数は、実験的に得られたものです。
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
を以下のように指定するとわかりやすいです。
gl_FragColor = vec4(vec3(length(p)), 1.0);
白い領域がサンプリングされる範囲です。また、u_scale
を操作することで、複雑性が増していることがわかります。
リポジトリ
まとめ
かなりかい摘んだ説明になってしまいました。
Three.jsのShaderまで踏み込むと、前提知識がない状態を想定した説明はどうしても難しいです...
おまけ
この記事を書いているのが、2021.12.31です。
今年も終わりということで、2021年の成果物を載せました。
投稿記事の一覧
2021年からWebフロントエンドの学習を始めて、記事を書くことが多くなりました。
それを一覧として項目別に時系列にまとめています。
CodeSandbox
普段の実験的な実装は、ローカル環境(VSCode)で行っています。
そこでしっかりまとまったモノは、CodeSandboxにアップロードしています。さらにアプリケーション化したいモノ、コード量が多いモノはGithubにアップロードしています。
CodeSandboxにアップロードしているモノは、規模が小さくそれに関する記事も基本的に書いていないので、ここで紹介します。