スクロールでグニャらせる画像エフェクト

はじめに

今回は、WebGLを使ってスクロールに応じて画像が揺れるエフェクトを実装する方法を解説します。

ユーザーがページをスクロールするたびに、画像がゆらゆらと揺れるエフェクトを実装します。

CanvasとThree.jsのセットアップ

初期セットアップとして、以下のようにCanvas要素を取得し、WebGLレンダラーとカメラをセットアップします。

// Canvas要素を取得し、WebGLレンダラーをセットアップ
this.canvasEl = document.getElementById(canvasId) as HTMLCanvasElement;
this.renderer = new THREE.WebGLRenderer({ canvas: this.canvasEl });
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setSize(window.innerWidth, window.innerHeight);

// カメラを設定し、視野角(FOV)を調整
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
this.camera.position.z = this.calculateCameraDistance();

ここでは、画像をテクスチャとしてロードし、それをシェーダーで使うためのuniforms変数として設定しています。

uniforms変数は、シェーダー内で画像の描画に使用されるパラメータです。

これにより、画像がエフェクト付きで描画されます

const texture = loader.load(img.src);
const uniforms = {
  uTexture: { value: texture },
  uImageAspect: { value: img.naturalWidth / img.naturalHeight },
  uPlaneAspect: { value: img.clientWidth / img.clientHeight },
  uTime: { value: 0 },
};

const mat = new THREE.ShaderMaterial({
  uniforms,
  vertexShader,
  fragmentShader,
});

return new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 100, 100), mat);

スクロールに連動したエフェクトの実装

スクロール位置を取得し、その位置に基づいてシェーダー内の時間を更新します。

スクロール位置を取得し、線形補間を使ってなめらかにスクロールオフセットを計算しています。

このオフセットがシェーダー内の時間として使われ、スクロールに応じた揺れのエフェクトが生まれます。

※線形補間とは、2つの値を補間する方法で、値をなめらかに変化させるために使われるみたいです。

this.targetScrollY = document.documentElement.scrollTop;
this.currentScrollY = this.lerp(this.currentScrollY, this.targetScrollY, 0.1);
this.scrollOffset = this.targetScrollY - this.currentScrollY;

this.imagePlaneArray.forEach((plane) => {
  plane.update(this.scrollOffset);
});

Vertex Shader

今回のエフェクトでは、頂点を波打つように動かしています。

x座標をy座標に応じてサイン波の形で動かしています。

uTimeはスクロールに応じて変化する値で、これが波の振動数に影響します。

float amp = 0.03; // 波の振幅
float freq = 0.01 * uTime; // 波の振動数

pos.x = pos.x + sin(pos.y * PI * freq) * amp;

Fragment Shader

ここでは画像のアスペクト比を調整して、きれいに表示する処理を行っています。

画像のアスペクト比と平面のアスペクト比を比較し、画像の位置など歪まないように調整しています。

この計算によって、テクスチャが中央にきれいに配置されます。


vec2 ratio = vec2(min(uPlaneAspect / uImageAspect, 1.0), min((1.0 / uPlaneAspect) / (1.0 / uImageAspect), 1.0));
vec2 fixedUv = vec2((vUv.x - 0.5) * ratio.x + 0.5, (vUv.y - 0.5) * ratio.y + 0.5);

vec3 texture = texture2D(uTexture, fixedUv).rgb;
gl_FragColor = vec4(texture, 1.0);

まとめ

この資料では、Three.jsとシェーダーを使ってスクロールに連動する画像エフェクトの実装方法を解説しました。

Three.jsを使えば、WebGLの高度な機能を比較的シンプルに利用できます。

参考

【Three.js】スクロールでぐにゃぐにゃする画像を実装する

コード

index.html
  <ul class="image-list">
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img class="w-full" src="/images/sample1.jpg" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img class="w-full" src="/images/sample2.jpg" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img class="w-full" src="/images/sample1.jpg" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img class="w-full" src="/images/sample2.jpg" alt="" />
      </a>
    </li>
  </ul>

<div class="webgl-canvas">
  <canvas id="webgl" class="webgl-canvas__body"></canvas>
</div>
<style>
  .image-list{
    padding-left: 0;
  }
  .webgl-canvas {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: -1;
  }

  .webgl-canvas__body {
    width: 100%;
    height: 100%;
  }

  .webgl-canvas {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: -1;
  }

  .webgl-canvas__body {
    width: 100%;
    height: 100%;
  }

  .image-item {
    width: 100%;
  }

  .image-item:not(:first-of-type) {
    margin-top: 180px;
  }

  .image-wrapper {
    display: block;
    width: 100%;
  }

  .image-wrapper > img {
    object-fit: cover;
    opacity: 0;
  }
</style>

main.ts


import * as THREE from "three";
import fragmentShader from "./shaders/fragmentShader.glsl";
import vertexShader from "./shaders/vertexShader.glsl";

// WebGLの設定と初期化を行うクラス
class WebGLRendererSetup {
private canvasEl: HTMLCanvasElement; // Canvas要素
private renderer: THREE.WebGLRenderer; // WebGLレンダラー
private camera: THREE.PerspectiveCamera; // カメラ
private scene: THREE.Scene; // シーン
private fov: number; // カメラの視野角(Field of View)
private fovRad: number; // 視野角をラジアンに変換したもの
private canvasSize: { w: number; h: number }; // Canvasのサイズ
private imagePlaneArray: ImagePlane[] = []; // 画像平面の配列
private targetScrollY: number = 0; // 目標のスクロール位置
private currentScrollY: number = 0; // 現在のスクロール位置(補間後)
private scrollOffset: number = 0; // スクロールオフセット

// コンストラクタ(初期化)
constructor(canvasId: string) {
  // Canvas要素を取得
  this.canvasEl = document.getElementById(canvasId) as HTMLCanvasElement;

  if (!this.canvasEl) {
    throw new Error("Canvas element not found"); // Canvasが見つからない場合はエラーを投げる
  }

  // Canvasのサイズをウィンドウサイズに設定
  this.canvasSize = { w: window.innerWidth, h: window.innerHeight };
  this.renderer = new THREE.WebGLRenderer({ canvas: this.canvasEl }); // WebGLレンダラーの作成
  this.renderer.setPixelRatio(window.devicePixelRatio); // ピクセル比を設定
  this.renderer.setSize(this.canvasSize.w, this.canvasSize.h); // Canvasのサイズを設定

  // カメラの設定
  this.fov = 60; // 視野角を60度に設定
  this.fovRad = (this.fov / 2) * (Math.PI / 180); // 視野角をラジアンに変換
  const dist = this.calculateCameraDistance(); // カメラの距離を計算
  this.camera = new THREE.PerspectiveCamera(
    this.fov,
    this.canvasSize.w / this.canvasSize.h,
    0.1,
    2000,
  ); // カメラの作成
  this.camera.position.z = dist; // カメラの位置を設定

  // シーンの作成と背景色の設定
  this.scene = new THREE.Scene();
  this.scene.background = new THREE.Color(0xffffff); // 背景色を白に設定

  this.addEventListeners();
}

// カメラの距離を計算するメソッド
private calculateCameraDistance(): number {
  return this.canvasSize.h / 2 / Math.tan(this.fovRad);
}

// イベントリスナーを追加するメソッド
private addEventListeners(): void {
  // ウィンドウのリサイズイベントに対して、処理を遅延させてリサイズを実行
  window.addEventListener("resize", this.debouncedResize(200).bind(this));
  // ウィンドウのロードイベント時に初期化処理を実行
  window.addEventListener("load", this.init.bind(this));
}

// リサイズ処理を遅延させるメソッド
private debouncedResize(delay: number) {
  let timeoutId: NodeJS.Timeout | null = null;

  return () => {
    if (timeoutId) clearTimeout(timeoutId); // 前回のタイムアウトをクリア
    timeoutId = setTimeout(() => this.resize(), delay); // 新たにリサイズ処理を遅延実行
  };
}

// リサイズ時の処理
private resize(): void {
  // Canvasのサイズを再設定
  this.canvasSize.w = window.innerWidth;
  this.canvasSize.h = window.innerHeight;

  // レンダラーのサイズを更新
  this.renderer.setPixelRatio(window.devicePixelRatio);
  this.renderer.setSize(this.canvasSize.w, this.canvasSize.h);

  // カメラのアスペクト比と距離を更新
  this.camera.aspect = this.canvasSize.w / this.canvasSize.h;
  this.camera.updateProjectionMatrix();
  this.camera.position.z = this.calculateCameraDistance();
}

// 初期化処理
private init(): void {
  // ページ内のすべての画像要素を取得
  const imageArray = Array.from(document.querySelectorAll("img"));
  imageArray.forEach((img) => {
    // 画像からメッシュを作成し、シーンに追加
    const mesh = this.createMesh(img);
    this.scene.add(mesh);

    // 画像平面を作成し、パラメータを設定して配列に追加
    const imagePlane = new ImagePlane(mesh, img, this.canvasSize);
    imagePlane.setParams();
    this.imagePlaneArray.push(imagePlane);
  });

  this.loop(); // アニメーションループを開始
}

// 画像要素からメッシュを作成するメソッド
private createMesh(
  img: HTMLImageElement,
): THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial> {
  const loader = new THREE.TextureLoader(); // テクスチャローダーを作成
  const texture = loader.load(img.src, undefined, undefined, (err) => {
    console.error("Failed to load texture:", err); // テクスチャの読み込みに失敗した場合のエラー処理
  });

  // シェーダーで使用するユニフォーム変数を設定
  const uniforms = {
    uTexture: { value: texture },
    uImageAspect: { value: img.naturalWidth / img.naturalHeight },
    uPlaneAspect: { value: img.clientWidth / img.clientHeight },
    uTime: { value: 0 },
  };

  // 平面ジオメトリとシェーダーマテリアルを作成
  const geo = new THREE.PlaneGeometry(1, 1, 100, 100);
  const mat = new THREE.ShaderMaterial({
    uniforms,
    vertexShader,
    fragmentShader,
  });

  return new THREE.Mesh(geo, mat); // メッシュを返す
}

// 線形補間(lerp)を計算するメソッド
private lerp(start: number, end: number, multiplier: number): number {
  return (1 - multiplier) * start + multiplier * end;
}

// スクロールの更新処理
private updateScroll(): void {
  this.targetScrollY = document.documentElement.scrollTop; // 現在のスクロール位置を取得
  this.currentScrollY = this.lerp(
    this.currentScrollY,
    this.targetScrollY,
    0.1,
  ); // 線形補間でスクロールをスムーズに更新
  this.scrollOffset = this.targetScrollY - this.currentScrollY; // スクロールオフセットを計算
}

// アニメーションループ
private loop(): void {
  this.updateScroll(); // スクロール位置の更新

  // すべての画像平面を更新
  this.imagePlaneArray.forEach((plane) => {
    plane.update(this.scrollOffset);
  });

  // シーンをレンダリング
  this.renderer.render(this.scene, this.camera);
  requestAnimationFrame(this.loop.bind(this)); // 次のフレームで再びループを呼び出す
}
}

// 画像をテクスチャにした平面を扱うクラス
class ImagePlane {
private refImage: HTMLImageElement; // 参照する画像要素
private mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>; // 画像のメッシュ
private canvasSize: { w: number; h: number }; // Canvasのサイズ

// コンストラクタ(初期化)
constructor(
  mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>,
  img: HTMLImageElement,
  canvasSize: { w: number; h: number },
) {
  this.refImage = img;
  this.mesh = mesh;
  this.canvasSize = canvasSize;
}

// 画像のサイズと位置を設定するメソッド
setParams(): void {
  const rect = this.refImage.getBoundingClientRect(); // 画像の位置とサイズを取得
  this.mesh.scale.set(rect.width, rect.height, 1); // 画像のサイズにメッシュをスケーリング

  // 画像の位置をCanvasの中央に調整して設定
  const x = rect.left - this.canvasSize.w / 2 + rect.width / 2;
  const y = -rect.top + this.canvasSize.h / 2 - rect.height / 2;
  this.mesh.position.set(x, y, this.mesh.position.z);
}

// メッシュを更新するメソッド
// メッシュを更新するメソッド
update(offset: number): void {
  this.setParams(); // 画像の位置とサイズを再設定
  this.mesh.material.uniforms.uTime.value = offset; // シェーダーにスクロールオフセットを渡す
}
}

// インスタンス化してWebGLを開始
new WebGLRendererSetup("webgl");

fragmentShader.glsl

varying vec2 vUv;
uniform sampler2D uTexture;
uniform float uImageAspect;
uniform float uPlaneAspect;

void main() {
  // 画像のアスペクトとプレーンオブジェクトのアスペクトを比較し、短い方に合わせる
      vec2 ratio = vec2(min(uPlaneAspect / uImageAspect, 1.0), min((1.0 / uPlaneAspect) / (1.0 / uImageAspect), 1.0));

  // 計算結果を用いてテクスチャを中央に配置
      vec2 fixedUv = vec2((vUv.x - 0.5) * ratio.x + 0.5, (vUv.y - 0.5) * ratio.y + 0.5);

      vec3 texture = texture2D(uTexture, fixedUv).rgb;
      gl_FragColor = vec4(texture, 1.0);
}
vertexShader.glsl

varying vec2 vUv;
uniform float uTime;

float PI = 3.1415926535897932384626433832795;

void main() {
    vUv = uv;
    vec3 pos = position;

    /** 横方向 */
    float amp = 0.03; // 振幅(の役割) 大きくすると波が大きくなる
    float freq = 0.01 * uTime; // 振動数(の役割) 大きくすると波が細かくなる

    /** 縦方向 */
    float tension = -0.001 * uTime; // 上下の張り具合
    pos.x = pos.x + sin(pos.y * PI * freq) * amp;
    pos.y = pos.y + (cos(pos.x * PI) * tension);
    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

デモ