LoginSignup
7
4

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-22

概要

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

https://uqy1l.csb.app/
output(video-cutter-js.com) (3).gif

React Three Fiber(Three.jsのReact用ラッパーパッケージ)を使用しています。

実装

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

イメージを作る

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

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

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

これをイメージにしたものが、以下になります。

Mesh(Plane)上にマウスがあります。例えば、このときの "" と、 "" の頂点の動きを考えます。


どちらの頂点も、マウス座標から頂点座標のベクトル方向(- - -)に移動します。
また、移動量は、マウスに近いほど大きくなります。

コード

/* 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にあたっているときの座標はとても簡単に取得できます。

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

このコードは、デバッグ用に書いているコードですが、Meshをクリックしたときに、クリックした位置の座標と法線ベクトルを取得しています。
同様に、Mesh上をマウスが移動するとき(handlePointerMove)でも座標を取得できます。

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);
}
`

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

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

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を使用して、マウスが離れても移動した頂点が元の位置に一瞬で戻らないように、アニメーションさせています。

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

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

Cube
スクリーンショット 2021-12-22 181536.png

Textureを貼ってみる

PlaneにTextureを貼ると、本当に魚眼レンズのようになります。

https://jimpy.csb.app/
output(video-cutter-js.com).gif

CodeSandbox

まとめ

VertexShaderを扱うには慣れが必要ですが、慣れれば面白い表現が色々できそうです:slight_smile:

7
4
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
7
4