Hover Image Ripple with GLSL

Visit Site
References

Vertex Shader

// シェーダーに渡される一時変数
uniform float iTime; // 現在の時間
uniform vec2 iResolution; // 画面の解像度
uniform vec2 iMouse; // マウスの位置

// ジオメトリのUVマッピング座標をフラグメントシェーダーに渡す
varying vec2 vUv;

// JavaScriptから渡される変数
uniform vec2 uMeshSize; // メッシュ(画像)のサイズ
uniform vec2 uMediaSize; // メディア(実際の画像)のサイズ
uniform vec2 uOffset; // オフセット量(マウス移動による)
uniform float uOpacity; // 不透明度
uniform float uMouseEnter; // マウスがエンターしているかどうかのフラグ
uniform float uMouseEnterMask; // マウスエンター時のマスク効果

// ダミー変数(デバッグ用)
varying vec2 vDummy;

const float PI = 3.14159265359;

// 画像のスケールを変更する関数
vec2 scale(in vec2 st, in vec2 s, in vec2 center) {
    return (st - center) * s + center;
}

// 指定した値を0.0から1.0の間に制限する関数
float saturate(float a) {
    return clamp(a, 0., 1.);
}

// 画像の変形効果を計算する関数
vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) {
    position.x = position.x + (sin(uv.y * PI) * offset.x);
    position.y = position.y + (sin(uv.x * PI) * offset.y);
    return position;
}

// UVスケールを計算する関数
vec2 getUVScale() {
    float d = length(uMeshSize);
    float longEdge = max(uMeshSize.x, uMeshSize.y);
    float dRatio = d / longEdge;
    float mRatio = uMeshSize.x / uMeshSize.y;
    return vec2(mRatio / dRatio);
}

// アニメーションの進行度を計算する関数
float getProgress(float activation, float latestStart, float progress, float progressLimit) {
    float startAt = activation * latestStart;
    float pr = smoothstep(startAt, 1., progress);
    float p = min(saturate(pr / progressLimit), saturate((1. - pr) / (1. - progressLimit)));
    return p;
}

// 頂点の変形を計算する関数
vec3 distort(vec3 p) {
    vec2 uvDistortion = uv;
    vec2 uvScale = getUVScale();
    uvDistortion = scale(uvDistortion, uvScale, vec2(.5));
    uvDistortion = (uvDistortion - .5) * 2.;
    float d = length(uvDistortion);
    float pr = getProgress(d, .8, uMouseEnter, .75) * .08;
    p.xy *= (1. + pr);
    return p;
}

// メイン関数
void main() {
    vec3 p = position; // 頂点の初期位置
    p = deformationCurve(p, uv, uOffset); // 変形効果を適用
    p = distort(p); // 更に変形
    gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.); // 最終的な頂点の位置
    vUv = uv; // UV座標をフラグメントシェーダーに渡す
}

Fragment Shader

// シェーダーに渡される一時変数
uniform float iTime; // 現在の時間
uniform vec2 iResolution; // 画面の解像度
uniform vec2 iMouse; // マウスの位置

// 頂点シェーダーから受け取るUV座標
varying vec2 vUv;

// テクスチャ情報
uniform sampler2D iChannel0; // 画像のテクスチャ

// JavaScriptから渡される変数
uniform vec2 uMeshSize; // メッシュ(画像)のサイズ
uniform vec2 uMediaSize; // メディア(実際の画像)のサイズ
uniform vec2 uOffset; // オフセット量(マウス移動による)
uniform float uOpacity; // 不透明度
uniform float uMouseEnter; // マウスがエンターしているかどうかのフラグ
uniform float uMouseEnterMask; // マウスエンター時のマスク効果

// ダミー変数(デバッグ用)
varying vec2 vDummy;

// テクスチャをメッシュにフィットさせるための計算
vec2 cover(vec2 s, vec2 i, vec2 uv) {
    float rs = s.x / s.y;
    float ri = i.x / i.y;
    vec2 new = rs < ri ? vec2(i.x * s.y / i.y, s.y) : vec2(s.x, i.y * s.x / i.x);
    vec2 offset = (rs < ri ? vec2((new.x - s.x) / 2., 0.) : vec2(0., (new.y - s.y) / 2.)) / new;
    uv = uv * s / new + offset;
    return uv;
}

// UV座標をスケールする関数
vec2 scale(in vec2 st, in vec2 s, in vec2 center) {
    return (st - center) * s + center;
}

// アスペクト比に基づいてUV座標を調整する関数
vec2 ratio2(in vec2 v, in vec2 s) {
    return mix(vec2(v.x, v.y * (s.y / s.x)),
               vec2((v.x * s.x / s.y), v.y),
               step(s.x, s.y));
}

// UV座標に歪みを加える関数
vec2 distort(vec2 uv) {
    uv -= .5;
    float mRatio = uMeshSize.x / uMeshSize.y;
    float pUvX = pow(uv.x * mRatio, 2.);
    float pUvY = pow(uv.y, 2.);
    float pSum = pUvX + pUvY;
    float multiplier = 10. * (1. - uMouseEnter);
    float strength = 1. - multiplier * pSum;
    uv *= strength;
    uv += .5;
    return uv;
}

// メイン関数
void main() {
    vec2 uv = vUv; // UV座標
    uv = cover(uMeshSize, uMediaSize.xy, uv); // メッシュにフィットさせる
    float d = getMaskDist(uv); // マスクの距離を計算
    float mask = 1. - step(uMouseEnterMask, d); // マスクの適用
    uv = scale(uv, vec2(1. / (1. + (1. - uMouseEnter) * .25)), vec2(.5)); // スケール調整
    uv = distort(uv); // 歪みを適用
    vec4 tex = texture(iChannel0, uv); // テクスチャを取得
    vec3 color = tex.rgb; // 色情報
    float alpha = mask * uOpacity; // 不透明度を適用
    gl_FragColor = vec4(color, alpha); // 最終的なピクセルの色
}

JavaScript

// 必要なライブラリをインポートしています。
import * as kokomi from "https://esm.sh/kokomi.js";
import * as THREE from "https://esm.sh/three";
import gsap from "https://esm.sh/gsap";

// 頂点シェーダーとフラグメントシェーダーのコードは省略します。
const vertexShader = /* glsl */ `...`;
const fragmentShader = /* glsl */ `...`;

// Sketchクラスを定義します。このクラスは、kokomi.jsライブラリを使って、
// 3Dエフェクトの画像ギャラリーをウェブページ上に作成するための処理をまとめています。
class Sketch extends kokomi.Base {
  async create() {
    // 画像エフェクトに関する設定を定義します。
    const config = {
      offsetAmount: 1 // マウス移動に応じたエフェクトの強さ
    };

    // ギャラリーの要素と各画像項目を取得します。
    const galleryEl = document.querySelector(".gallery");
    const galleryItems = [...document.querySelectorAll(".gallery-item")];
    const hoverImg = document.querySelector(".hover-img");

    // ギャラリーの画像に関する情報をリストとして準備します。
    const resourceList = galleryItems.map((item, i) => ({
      name: item.dataset["glImgName"], // 画像の名前
      type: "texture", // リソースのタイプ(テクスチャ)
      path: item.dataset["glImg"] // 画像のパス
    }));

    // アセットマネージャーを作成し、画像リソースをプリロードします。
    const am = new kokomi.AssetManager(this, resourceList);

    // すべてのリソースが読み込まれた後に実行される処理を定義します。
    am.on("ready", async () => {
      // ローダースクリーンを非表示にします。
      document.querySelector(".loader-screen")?.classList.add("hollow");

      // 画面全体をカバーするカメラを設定します。
      const screenCamera = new kokomi.ScreenCamera(this);
      screenCamera.addExisting();

      // 画像を表示するためのメッシュ(平面)を作成します。
      const geometry = new THREE.PlaneGeometry(1, 1, 64, 64);
      const uj = new kokomi.UniformInjector(this);
      const material = new THREE.ShaderMaterial({
        vertexShader,
        fragmentShader,
        transparent: true, // 透明度を有効にします。
        uniforms: {
          ...uj.shadertoyUniforms,
          iChannel0: {
            value: null // テクスチャは後で設定されます。
          },
          // その他のuniform変数も設定しますが、初期値はここで設定されます。
        }
      });
      const mesh = new THREE.Mesh(geometry, material);
      mesh.scale.set(hoverImg.clientWidth, hoverImg.clientHeight, 1);
      this.scene.add(mesh);

      // マウスの位置に基づいて、エフェクトのオフセットを計算します。
      let targetX = 0;
      let targetY = 0;
      let offsetX = 0;
      let offsetY = 0;

      // レンダリングループで繰り返し実行される処理を定義します。
      this.update(() => {
        // 現在のマウスの位置を取得し、オフセットを計算します。
        targetX = this.interactionManager.mouse.x;
        targetY = this.interactionManager.mouse.y;
        offsetX = THREE.MathUtils.lerp(offsetX, targetX, 0.1);
        offsetY = THREE.MathUtils.lerp(offsetY, targetY, 0.1);

        // オフセットに基づいてシェーダーのuniform変数を更新します。
        material.uniforms.uOffset.value = new THREE.Vector2(
          (targetX - offsetX) * config.offsetAmount,
          (targetY - offsetY) * config.offsetAmount
        );

        // マウスの位置に合わせてメッシュとホバー画像を動かします。
        gsap.to(mesh.position, {
          x: (this.interactionManager.mouse.x * window.innerWidth) / 2,
          y: (this.interactionManager.mouse.y * window.innerHeight) / 2
        });
        gsap.to(hoverImg, {
          x: this.iMouse.mouseDOM.x - hoverImg.clientWidth / 2,
          y: this.iMouse.mouseDOM.y - hoverImg.clientHeight / 2
        });
      });

      // 画像項目にマウスを乗せたときの処理を定義します。
      const doTransition = (item) => {
        if (item) {
          hoverImg.src = item.dataset["glImg"];
          const tex = am.items[item.dataset["glImgName"]];
          material.uniforms.iChannel0.value = tex;
          // シェーダーに必要な情報を更新します。
          material.uniforms.uMeshSize.value = new THREE.Vector2(
            mesh.scale.x,
            mesh.scale.y
          );
          material.uniforms.uMediaSize.value = new THREE.Vector2(
            tex.image.width,
            tex.image.height
          );
          // 不透明度をアニメーションで変更します。
          gsap.to(material.uniforms.uOpacity, {
            value: 1,
            duration: 0.3
          });
          // マウスエンター効果のアニメーションを設定します。
          gsap.fromTo(
            material.uniforms.uMouseEnter,
            {
              value: 0
            },
            {
              value: 1,
              duration: 1.2,
              ease: "power2.out"
            }
          );
          gsap.fromTo(
            material.uniforms.uMouseEnterMask,
            {
              value: 0
            },
            {
              value: 1,
              duration: 0.7,
              ease: "power2.out"
            }
          );
        } else {
          // マウスが離れた場合は不透明度を0に戻します。
          gsap.to(material.uniforms.uOpacity, {
            value: 0,
            duration: 0.3
          });
        }
      };

      // ギャラリー項目にイベントリスナーを追加します。
      let currentItem = null;
      galleryItems.forEach((item) => {
        item.addEventListener("mouseenter", () => {
          currentItem = item;
          doTransition(currentItem);
        });
      });
      galleryEl.addEventListener("mouseleave", () => {
        currentItem = null;
        doTransition(currentItem);
      });
    });
  }
}

// Sketchクラスのインスタンスを作成し、ギャラリーを表示します。
const sketch = new Sketch("#sketch");
sketch.create();