🐍

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

38 min read

はじめに

この記事ではこんな表現の実装方法を解説します。
GIF

CodePen (0.5x を押すと見やすいです)

2021/7/26 追記 CodePen( 慣性スクロールありバージョン)

海外のデザインアワードを受賞するようなリッチなサイトで良く見る演出(個人的主観)です。WebGLの汎用的な表現な気がしますが、その実装方法を解説する記事は(少なくとも日本語では)ほとんど見つけられなかったので、自分の勉強も兼ねて書くことにしました。ちなみに技術記事を書くのは初めてに近いので暖かい目と心で読んでいただけます幸いです。実装はThree.jsを用いています。

注意事項

想定する読者

Three.jsを触ってみたが、サイト制作に活かす術が分からない、同様の表現を良く見るけどどうやって実装しているか分からない、といった方を対象としています。従って本記事では、Three.jsの基本的な描画手順などの解説は割愛させていただきますのでご了承ください。
また、実装では少しだけシェーダーを書きますので、シェーダーとはなんぞ、といったような知識が多少はあることが望ましいです。
Three.jsの基本、及びThree.jsで扱うシェーダーの基本については、この方の記事が分かりやすいのでご紹介させていただきます。Three.jsやシェーダーを触ったことがないという方は先にこちらで概要を掴んでいただくことをおすすめします

https://qiita.com/watabo_shi/items/bf9bcd4569b6d480c608

実装レベルについて

自分自身まだまだ初学者であり、実務でこの表現を使用した経験は無いので、本記事の実装がベストプラクティスである自信はあまりないです。あくまで実装方法の一つとして参考にしていただければと思います。もっとこうした方が良い、こういう実装方法の方が望ましい、などありましたらぜひご教示いただきたいです。

実装する環境について

開発の環境についてお話しします。実装だけ見たいという方は飛ばしちゃって構いません。

本記事の解説は最初にお見せしたCodePenの内容を完成形とし、またThree.jsのインストールについてはCDNを利用しています。お手元の環境に合わせてnpmyarnで始めていただいて大丈夫ですが、ソースコード内にimport文などの記載がありませんのでご注意ください。

npmまたはyarnでのインストールはこちら。

npm i three

または

yarn add three

環境とか良く分からん、もしくは手っ取り早く触ってみたいという方におすすめしたいのが、CDN + VSCodeLive Serverです。Three.jsでの開発にはローカルサーバーを起動する必要がありますが、Live Serverはワンクリックでローカルサーバーを立てられるので超手軽です。
CDNインストールはこちらをHTMLのhead内にコピペしてください。

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>

Three.jsのバージョンはr128を使用しています

実装の概要

コードの解説に移る前に、今回紹介する表現がどのような手段で実装されているかをざっくり説明します。
なんとなく想像がつくかもしれませんが、このぐにゃぐにゃしている画像はDOMではなく、canvasに描画されたWebGLの3Dオブジェクトです。ページ内の画像要素の位置にぐにゃぐにゃする3Dオブジェクトを配置し、元々あった画像要素を透明にすることで、あたかも画像がぐにゃぐにゃしているかのように見せることができます。また、元々あった画像要素は透明にするだけでクリック判定を残しておけば、画像リンクとして機能させることができます。以降詳しく解説しますが、以上のことを念頭に置いた上で読んでいただくと、理解がスムーズかもしれません。

実装の解説

最初にお見せしたCodePenを完成目標とし、そこまでを4つの段階に分けて徐々に近づけていくチュートリアル的な方式で解説します。

【STEP 01】
画像を貼り付けたメッシュを生成する

3Dオブジェクトの描画までは一気に進めちゃいます。

html
<div class="webgl-canvas">
  <canvas id="webgl-canvas" class="webgl-canvas__body"></canvas>
</div>
css
/* -- リセット系 -- */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

ul,
li {
  list-style: none;
}

a {
  text-decoration: none;
}

img {
  width: 100%;
}

/* -- ここまで -- */

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

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

js
const canvasEl = document.getElementById('webgl-canvas');
const canvasSize = {
  w: window.innerWidth,
  h: window.innerHeight,
};

const renderer = new THREE.WebGLRenderer({ canvas: canvasEl });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvasSize.w, canvasSize.h);

// ウィンドウとwebGLの座標を一致させるため、描画がウィンドウぴったりになるようカメラを調整
const fov = 60; // 視野角
const fovRad = (fov / 2) * (Math.PI / 180);
const dist = canvasSize.h / 2 / Math.tan(fovRad);
const camera = new THREE.PerspectiveCamera(
  fov,
  canvasSize.w / canvasSize.h,
  0.1,
  1000
);
camera.position.z = dist;

const scene = new THREE.Scene();

const loader = new THREE.TextureLoader();
const texture = loader.load('https://source.unsplash.com/whOkVvf0_hU/');

const uniforms = {
  uTexture: { value: texture },
  uImageAspect: { value: 1920 / 1280 }, // 画像のアスペクト
  uPlaneAspect: { value: 800 / 500 }, // プレーンのアスペクト
};
const geo = new THREE.PlaneBufferGeometry(800, 500, 100, 100);
const mat = new THREE.ShaderMaterial({
  uniforms,
  vertexShader: document.getElementById('v-shader').textContent,
  fragmentShader: document.getElementById('f-shader').textContent,
});

const mesh = new THREE.Mesh(geo, mat);

scene.add(mesh);

// 毎フレーム呼び出す
const loop = () => {
  renderer.render(scene, camera);

  requestAnimationFrame(loop);
};

const main = () => {
  window.addEventListener('load', () => {
    loop();
  });
};

main();

vertex-shader
varying vec2 vUv;

void main(){
  vUv = uv;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
fragment-shader
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)
  );

  // 計算結果を用いて補正後のuv値を生成
  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);
}

以下、注意点です。

今回の実装ではシェーダーをHTMLファイル内に書いていますが、シンタックスハイライトが効かず可読性に欠けます。なので、以降のセクションではシェーダーソースを単独のコードブロックで記載します。実装する際にはHTMLファイルのscriptタグ内のコードを置き換えてください。

したがって、実際のHTMLファイルの内容は以下のようになっています。

html
<div class="webgl-canvas">
  <canvas id="webgl-canvas" class="webgl-canvas__body"></canvas>
</div>
<!-- シェーダーをscriptタグ内に書いてJSで読み込む -->
<script id="v-shader" type="x-shader/x-vertex">
varying vec2 vUv;

void main(){
  vUv = uv;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
</script>
<script id="f-shader" type="x-shader/x-fragment">
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)
  );

  // 計算結果を用いて補正後のuv値を生成
  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);
}
</script>

Three.jsではシェーダーのソースをただの文字列としてShaderMaterialに渡すので、以下のようにscriptタグ内の文字列を参照し、渡しています。

js
const mat = new THREE.ShaderMaterial({
  uniforms,
  vertexShader: document.getElementById('v-shader').textContent,
  fragmentShader: document.getElementById('f-shader').textContent,
});

以下、ポイントになる箇所のみ抜粋して紹介します。

カメラ設定

この後のステップで、canvas内のオブジェクトをDOMと連動させる必要があります。そのため、windowの座標とWebGLの座標を一致させる必要があり、カメラがwindowサイズにぴったり収まるようなカメラ距離を計算しています。

js
const fov = 60;
const fovRad = (fov / 2) * (Math.PI / 180);
const dist = canvasSize.h / 2 / Math.tan(fovRad);
const camera = new THREE.PerspectiveCamera(
  fov,
  canvasSize.w / canvasSize.h,
  0.1,
  1000
);
camera.position.z = dist;

fovはカメラの視野角で、視野の範囲を決定しています。45〜60くらいにされていることが多い印象です。カメラ距離の計算についてはこちらの記事を参考にしています。図が使われていて分かりやすいです。

https://qiita.com/watabo_shi/items/0811d03390c18e46be86

テクスチャ

テクスチャ画像はunsplashからお借りしています。

js
const loader = new THREE.TextureLoader();
const texture = loader.load('https://source.unsplash.com/whOkVvf0_hU/');

const uniforms = {
  uTexture: { value: texture },
  uImageAspect: { value: 1920 / 1280 }, // 画像のアスペクト
  uPlaneAspect: { value: 800 / 500 }, // プレーンのアスペクト
};

uniform変数として、テクスチャの他に画像とプレーンそれぞれのアスペクト比を渡しています。これらを使って、画像の見え方がcssで言うobject-fit: coverのような状態になるよう調整します。

fragment-shader
// 画像のアスペクトとプレーンのアスペクトを比較し、短い方に合わせる
vec2 ratio = vec2(
  min(uPlaneAspect / uImageAspect, 1.0),
  min((1.0 / uPlaneAspect) / (1.0 / uImageAspect), 1.0)
);

// 計算結果を用いて補正後のuv値を生成
vec2 fixedUv = vec2(
  (vUv.x - 0.5) * ratio.x + 0.5,
  (vUv.y - 0.5) * ratio.y + 0.5
);

min関数はGLSLに用意された関数で、2つの引数のうち小さい方を返します。コメントの通りですが、頂点シェーダーから受け取ったuv値をそのまま使わず、画像とプレーンそれぞれのアスペクト比を用いてどちらの辺を余らせるかを判断し、新しい比率(ratio)を計算します。それを使ってテクスチャ配置し直すイメージです。0.5を足したり引いたりしているのは、uvの値がxyどちらも0.0〜1.0で正規化された値であるため、中央にずらす→反映する→戻す みたいなことをしているイメージです。(僕もふんわりとした理解です、すみません)

ここまでの実装結果はこんな感じです。

可愛いお花の画像が一枚表示されました。板のオブジェクトにテクスチャを一枚貼っただけですが、この後の工程のためにカメラや画像の表示を調整したのでちょっと長くなってしまいました。

【STEP 02】
シェーダーを書いてメッシュを変形する

STEP 01で設置したメッシュを、とりあえずその場でゆらゆらさせてみましょう。
シェーダーを動かしたいので、時間経過の値をuniform変数に追加します。

js
 const uniforms = {
   uTexture: { value: texture },
   uImageAspect: { value: 1920 / 1280 }, // 画像のアスペクト
   uPlaneAspect: { value: 800 / 500 }, // プレーンのアスペクト
+  uTime: { value: 0 }, // 時間経過
 };

loop関数の中で、uTimeを毎フレーム加算することで時間経過をシェーダーに渡せます。

js
 const loop = () => {
+  uniforms.uTime.value++;
   renderer.render(scene, camera);

   requestAnimationFrame(loop);
 };

受け取ったuTimeの値を使い、頂点シェーダーで三角関数でのアニメーションをしてみます。元々の記述がかなり少なかったので、差分ではなく全文を載せます。

vertex-shader
varying vec2 vUv;
uniform float uTime;

float PI = 3.1415926535897932384626433832795;

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

  float offset = 0.01; // y座標に比例して値をずらすが、そのままだとかなり値が大きいので調整するための係数
  float freq = 0.05; // 振動数(の役割)。大きくすると波が細かくなる
  float amp = 10.0; // 振幅(の役割)。大きくすると波が大きくなる
  pos.x = pos.x + sin(pos.y * offset + uTime * freq * PI ) * amp;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}

それぞれの変数の役割はコメントの通りです。三角関数を使ってx方向に往復するような動きを持たせます。その際に、y座標に比例するずれを持たせることで波のような動きになります。それぞれの値をいじったり、xyを入れ替えたりして遊んでみるとイメージが湧きやすいかもです。

ここまでの実行結果はこんな感じになると思います。

こんな感じで、画像をぐにゃぐにゃさせること自体は比較的簡単にできます(ただの三角関数なので動きはとても単調ですが)。次のステップからDOMに連動させていきます。

【STEP 03】
ページ内にimg要素で画像を配置し、それをSTEP02までで作成したメッシュで置き換える

HTML/CSSの追記

HTMLCSSに画像のリストを表示するための記述を追記します。

html
 <!-- bodyタグ内に追加 -->
<div class="container">
  <ul class="image-list">
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
  </ul>
</div>
css
/* 画像リスト用のスタイル */
.container {
  width: 80vw;
  max-width: 1000px;
  margin: 0 auto;
}

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

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

.image-list {
  width: 800px;
  margin: 0 auto;
  padding: 180px 0;
}

.image-item {
  width: 100%;
}

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

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

.image-wrapper > img {
  height: 100%;
  object-fit: cover;
}

以上2つをコピペして追加していただくと、先程のお花の画像(DOM)がリストになって表示されると思います。スタイルについては最低限しか書いていないませんので、お好みで調整してください。
背景でゆらゆらしているメッシュは中央にいるままで動きませんので、こいつをDOMに追従させます。

メッシュの作成フローを関数にする

画像DOMの数だけメッシュを作成しなくてはいけないので、コードを関数にまとめます。

js
-  const texture = loader.load('https://source.unsplash.com/whOkVvf0_hU/');

-  const uniforms = {
-    uTexture: { value: texture },
-    uImageAspect: { value: 1920 / 1280 }, // 画像のアスペクト
-    uPlaneAspect: { value: 800 / 500 }, // プレーンのアスペクト
-    uTime: { value: 0 },
-  };
-  const geo = new THREE.PlaneBufferGeometry(800, 500, 100, 100);
-  const mat = new THREE.ShaderMaterial({
-    uniforms,
-    vertexShader: document.getElementById('v-shader').textContent,
-    fragmentShader: document.getElementById('f-shader').textContent,
-  });

-  const mesh = new THREE.Mesh(geo, mat);

-  scene.add(mesh);

+  // Planeを作る関数
+  const createMesh = (img) => {
+    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 geo = new THREE.PlaneBufferGeometry(1, 1, 100, 100); // 後から画像のサイズにscaleするので1にしておく
+   const mat = new THREE.ShaderMaterial({
+     uniforms,
+     vertexShader: document.getElementById('v-shader').textContent,
+     fragmentShader: document.getElementById('f-shader').textContent,
+   });
+
+   const mesh = new THREE.Mesh(geo, mat);
+
+   return mesh;
+ };

画像要素を引数に取り、作成したmeshを返します。ここのポイントは、これまで手動で入力していたプレーンのサイズやアスペクト比、画像そのもののアスペクト比を画像要素から参照している点です。こうすることで、img要素のsrcにセットした画像をそのままテクスチャとして持ってくることができます。ジオメトリのサイズは後から画像サイズに合わせるので、1×1の正方形にしておきます。

メッシュとDOM情報を持つクラスを作成

メッシュと画像DOMの情報が紐づいたインスタンスがあれば管理が楽なので、クラスにします。
JSファイルに以下を追記してください。

js
// 画像をテクスチャにしたplaneを扱うクラス
class ImagePlane {
  constructor(mesh, img) {
    this.refImage = img; // 参照するimg要素
    this.mesh = mesh;
  }

  setParams() {
    // 参照するimg要素から大きさ、位置を取得してセットする
    const rect = this.refImage.getBoundingClientRect();

    this.mesh.scale.x = rect.width;
    this.mesh.scale.y = rect.height;
    
    // window座標をWebGL座標に変換
    const x = rect.left - canvasSize.w / 2 + rect.width / 2;
    const y = -rect.top + canvasSize.h / 2 - rect.height / 2;
    this.mesh.position.set(x, y, this.mesh.position.z);
  }

  update() {
    this.setParams();

    this.mesh.material.uniforms.uTime.value++;
  }
}

先程のcreateMesh関数で作成したmeshと画像要素imgを引数に渡し、画像オブジェクトをインスタンス化します。setParams関数では、この画像オブジェクトに紐づくimg要素からgetBoundingClientRectで大きさと座標を取得し、canvas内のメッシュに反映します。updateはマイフレーム呼ばれる想定の関数で、setParamsの実行と、時間経過の処理を行います。

注意点は、windowとWebGLでは座標系が異なる(windowは画面左上が原点で右下に行くほど大きく、Web GLは画面中央が原点で右上に行くほど大きくなる)ので、座標はそのまま使わず変換する必要がある点です。以下の部分ですね。

// window座標をWebGL座標に変換
const x = rect.left - canvasSize.w / 2 + rect.width / 2;
const y = -rect.top + canvasSize.h / 2 - rect.height / 2;

原点をずらしてy方向の向きを反転するイメージです。

作成した関数、クラスをメインの処理で呼ぶ

createMesh関数、ImagePlaneクラスの呼び出しを追記します。

js
+ const imagePlaneArray = []; // 画像オブジェクトの配列

  // 毎フレーム呼び出す
  const loop = () => {
-  uniforms.uTime.value++;
+  for (const plane of imagePlaneArray) {
+    plane.update();
+  } 
   renderer.render(scene, camera);

   requestAnimationFrame(loop);
 };

 const main = () => {
   window.addEventListener('load', () => {
+  const imageArray = [...document.querySelectorAll('img')];
+  for (const img of imageArray) {
+     const mesh = createMesh(img);
+     scene.add(mesh);
+
+     const imagePlane = new ImagePlane(mesh, img);
+     imagePlane.setParams();
+
+     imagePlaneArray.push(imagePlane);
+   }

     loop();
   });
 };

 main();
  1. querySelectorAllimg要素を取得
  2. createMesh関数に渡してmeshを作成
  3. img要素とmeshを渡して画像オブジェクトを作成
  4. 配列に格納

という流れですね。loop関数では画像オブジェクトのupdateを呼び出しています。

最後に、img要素を透明にします。

css
 .image-wrapper > img {
   height: 100%;
   object-fit: cover;
+  opacity: 0;
 }

これでimg要素の位置にThree.jsの画像オブジェクトが表示され、あたかもぐにゃぐにゃしている画像をスクロールしているかのように見せることができます。

ここまで実装するとこんな感じになると思います。

ぐにゃぐにゃした画像が4枚表示され、スクロールで動かせるようになりました。結構近づいてきましたね。

一旦ここまでのコードを全文載せておきます。

HTML
html
<div class="container">
  <ul class="image-list">
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
        <img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
  </ul>
</div>
<div class="webgl-canvas">
  <canvas id="webgl-canvas" class="webgl-canvas__body"></canvas>
</div>
CSS
css
/* -- リセット系 -- */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

ul,
li {
  list-style: none;
}

a {
  text-decoration: none;
}

img {
  width: 100%;
}

/* -- ここまで -- */

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

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

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

ul,
li {
  list-style: none;
}

a {
  text-decoration: none;
}

img {
  width: 100%;
}

/* 画像リスト用のスタイル */
.container {
  width: 80vw;
  max-width: 1000px;
  margin: 0 auto;
}

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

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

.image-list {
  width: 800px;
  margin: 0 auto;
  padding: 180px 0;
}

.image-item {
  width: 100%;
}

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

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

.image-wrapper > img {
  height: 100%;
  object-fit: cover;
  opacity: 0;
}
JS
js
const canvasEl = document.getElementById('webgl-canvas');
const canvasSize = {
  w: window.innerWidth,
  h: window.innerHeight,
};

const renderer = new THREE.WebGLRenderer({ canvas: canvasEl });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvasSize.w, canvasSize.h);

// ウィンドウとwebGLの座標を一致させるため、描画がウィンドウぴったりになるようカメラを調整
const fov = 60; // 視野角
const fovRad = (fov / 2) * (Math.PI / 180);
const dist = canvasSize.h / 2 / Math.tan(fovRad);
const camera = new THREE.PerspectiveCamera(
  fov,
  canvasSize.w / canvasSize.h,
  0.1,
  1000
);
camera.position.z = dist;

const scene = new THREE.Scene();

const loader = new THREE.TextureLoader();

// 画像をテクスチャにしたplaneを扱うクラス
class ImagePlane {
  constructor(mesh, img) {
    this.refImage = img;
    this.mesh = mesh;
  }

  setParams() {
    // 参照するimg要素から大きさ、位置を取得してセット
    const rect = this.refImage.getBoundingClientRect();

    this.mesh.scale.x = rect.width;
    this.mesh.scale.y = rect.height;

    const x = rect.left - canvasSize.w / 2 + rect.width / 2;
    const y = -rect.top + canvasSize.h / 2 - rect.height / 2;
    this.mesh.position.set(x, y, this.mesh.position.z);
  }

  update() {
    this.setParams();

    this.mesh.material.uniforms.uTime.value++;
  }
}

// Planeメッシュを作る関数
const createMesh = (img) => {
  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 geo = new THREE.PlaneBufferGeometry(1, 1, 100, 100); // 後から画像のサイズにscaleするので1にしておく
  const mat = new THREE.ShaderMaterial({
    uniforms,
    vertexShader: document.getElementById('v-shader').textContent,
    fragmentShader: document.getElementById('f-shader').textContent,
  });

  const mesh = new THREE.Mesh(geo, mat);

  return mesh;
};

const imagePlaneArray = [];

// 毎フレーム呼び出す
const loop = () => {
  for (const plane of imagePlaneArray) {
    plane.update();
  }
  renderer.render(scene, camera);

  requestAnimationFrame(loop);
};

const main = () => {
  window.addEventListener('load', () => {
    const imageArray = [...document.querySelectorAll('img')];
    for (const img of imageArray) {
      const mesh = createMesh(img);
      scene.add(mesh);

      const imagePlane = new ImagePlane(mesh, img);
      imagePlane.setParams();

      imagePlaneArray.push(imagePlane);
    }
    loop();
  });
};

main();

頂点シェーダー
vertex-shader
varying vec2 vUv;
uniform float uTime;

float PI = 3.1415926535897932384626433832795;

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

  float offset = 3.0;
  float freq = 0.05;
  float amp = 0.05;
  pos.x = pos.x + sin(pos.y * offset + uTime * freq * PI ) * amp;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
フラグメントシェーダー
fragment-shader
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);
}

次のステップでは、このぐにゃぐにゃをスクロールしている時だけ発生させるようにします。

【STEP 04】
スクロール量を取得しシェーダーエフェクトに反映する

スクロール量の取得

JSファイルに以下を追記します。

js
// スクロール追従
let targetScrollY = 0; // 本来のスクロール位置
let currentScrollY = 0; // 線形補間を適用した現在のスクロール位置
let scrollOffset = 0; // 上記2つの差分

// 開始と終了をなめらかに補間する関数
const lerp = (start, end, multiplier) => {
  return (1 - multiplier) * start + multiplier * end;
};

const updateScroll = () => {
  // スクロール位置を取得
  targetScrollY = document.documentElement.scrollTop;
  // リープ関数でスクロール位置をなめらかに追従
  currentScrollY = lerp(currentScrollY, targetScrollY, 0.1);

  scrollOffset = targetScrollY - currentScrollY;
};

スクロールしている時だけエフェクトをつけたいので、フレーム前後のスクロール位置の差分を取る必要があります。しかし、ただそのままスクロールの差を取るだけでは値の変化にばらつきがあり、エフェクトがカクついたような動きになってしまいます。そこでリープ関数という数学の線形補間と呼ばれる式を使います(なんか難しそうな単語が出てきましたがそんなに身構えなくて大丈夫です)。
リープ関数は、ざっくり言うと開始点と終了点をなめらかに補間する関数で、これを使って「実際のスクロールに少し遅れてやんわりと動く」みたいな動きを作ります。やんわりと動いた位置をcurrentScrollY、実際のスクロール位置をtargetScrollYとして2つの差分を取ることで、スクロールするとなめらかに増え、やめるとなめらかに減る値(scrollOffset)を計算できます。

以上のスクロール処理を、ループの中で実行します。さらに、計算したをscrollOffset画像オブジェクトのupdate関数に渡してやります。

js
 const loop = () => {
+  updateScroll();
   for (const plane of imagePlaneArray) {
-    plane.update();
+    plane.update(scrollOffset);
   }
   renderer.render(scene, camera);

   requestAnimationFrame(loop);
 };

また、今まで時間経過でアニメーションしていたのをスクロール量に連動する形に変更します。

js
class ImagePlane {
// 省略
  update(offset) {
    this.setParams();

    this.mesh.material.uniforms.uTime.value = offset; // offetを受け取り代入する
  }
}

(時間経過を渡すわけではなくなったのでuTimeという名前は相応しくないかもしれませんが、混乱を防ぐためこのままでいきます)

頂点シェーダーを修正

この時点で、先程までのアニメーションがスクロールに連動するようになったのではないでしょうか。ただ、ちょっと不恰好なのでアニメーションのロジックを修正します。

vertex-shader
-  float offset = 3.0;
-  float freq = 0.05;
-  float amp = 0.05;
-  pos.x = pos.x + sin(pos.y * offset + uTime * freq * PI ) * amp;

+  // 横方向
+  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);

三角関数をゴニョゴニョしているという点ではあまり変わっていないかもです。
横方向の波の付け方も若干変えていますが、大きく変わったのは縦方向にも動きを付けた点です。これによって、スクロールで上下に引っ張られている感覚が増したのではないでしょうか。パラメータを変えて遊んでみてください。

フラグメントシェーダーに追記してRGBシフト

形を変えるだけでもそれなりに見えますが、せっかくなので色にも変化を付けましょう。

fragment-shader
  varying vec2 vUv;
  uniform sampler2D uTexture;
  uniform float uImageAspect;
  uniform float uPlaneAspect;
+ uniform float uTime;

  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;

+   vec2 offset = vec2(0.0, uTime * 0.0005);
+   float r = texture2D(uTexture, fixedUv + offset).r;
+   float g = texture2D(uTexture, fixedUv + offset * 0.5).g;
+   float b = texture2D(uTexture, fixedUv).b;
+   vec3 texture = vec3(r, g, b);

    gl_FragColor = vec4(texture, 1.0);
  }

uTimeの値を(そのままでは大きすぎるので小さくして)オフセットとし、テクスチャのR``G``Bそれぞれを上下方向にずらしたりずらさなかったりしています。この辺も好みで調節してみてください。

だいぶリッチな感じになったのではないでしょうか。

windowリサイズ処理

最後に、ウィンドウのリサイズに耐えられるようにします。
以下のコードを追記してください。

js
// リサイズ処理
let timeoutId = 0;
const resize = () => {
  // three.jsのリサイズ
  const width = window.innerWidth;
  const height = window.innerHeight;
  
  canvasSize.w = width;
  canvasSize.h = height;

  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  
  // カメラの距離を計算し直す
  const fov = 60;
  const fovRad = (fov / 2) * (Math.PI / 180);
  const dist = canvasSize.h / 2 / Math.tan(fovRad);
  camera.position.z = dist;
};

キャンバスのリサイズやカメラ距離の再設定などをしています。これらの処理をwindowのリサイズイベントに紐付けます。

js
  const main = () => {
    window.addEventListener('load', () => {
      const imageArray = [...document.querySelectorAll('img')];
      for (const img of imageArray) {
        const mesh = createMesh(img);
        scene.add(mesh);

        const imagePlane = new ImagePlane(mesh, img);
        imagePlane.setParams();

        imagePlaneArray.push(imagePlane);
      }
      loop();
    });

+   // リサイズ(負荷軽減のためリサイズが完了してから発火する)
+   window.addEventListener('resize', () => {
+     if (timeoutId) clearTimeout(timeoutId);

+     timeoutId = setTimeout(resize, 200);
+   });
};

setTimeoutを使って、windowのリサイズが完了してから処理を呼び出すようにしています。

2021/7/26 追記:慣性スクロールによる同期ずれ対策

DOMの同期ズレについてご指摘をいただき、対応策として慣性スクロールの実装をしましたので追記します。

実は今回のようにWebGLのオブジェクトをDOMに追従させる場合、両者の位置同期にはズレが起きてしまいます。今回は画像しか並べていないのでほとんど気になりませんが、試しにHTMLで画像キャプションなどのテキストなどを追加すると、位置ズレが起きているのが分かると思います(ちょっとエフェクトが派手なので切った方が分かりやすいです)。
これは、WebGLのアニメーションを行っているrequestAnimationFrameスクロールイベントのタイミングのずれから生じる問題で、対応策として慣性スクロールという手法が取られることが多いです。慣性スクロールの実装では、DOMをスクロールイベントではなくWebGLと同じrequestAnimationFrametransformを使って動かすので、同期ズレを軽減することができます。

まず、HTMLの構造を変えます。外側に2重のdivを挟みます。

html
+ <div class="wrapper">
+   <div class="scrollable">
      <div class="container">
        <ul class="image-list">
        省略    
        </ul>
      </div>
+   </div>
+ </div>

wrapperを画面に固定してしまい、中のscrollabletransfromで動かすのでそのためのCSSを追記します。

css
body {
  overscroll-behavior: none;
}

.wrapper {
  width: 100%;
  height: 100vh;
  position: fixed;
  top: 0;
  left: 0;
}

.scrollable {
  position: absolute;
  width: 100%;
  top: 0;
  left: 0;
}

overscroll-behaviorは、画面の端でスクロールがびよ〜んってなるアレの制御ができます。慣性スクロールは本来のスクロールとタイミングが異なるので、アレが特に気になってしまいます。なので切ります。ちなみにIEとSafariは対応していません。(2021/7/26現在)

最後にJSを追記しますが、実は慣性スクロールを実装する仕組みはすでにほとんどできていてます。エフェクトに使ったlerp関数でスクロールの差分をなめらかにしていましたが、まさにアレを使うのです。

まずは要素を取得します。スクロールエリアはposition: absoluteしているのでbodyに高さが無くなってしまいスクロールできなくなるので、JSで高さを入れてあげます。

js
// 慣性スクロール
const scrollArea = document.querySelector('.scrollable');
// ボディの高さがなくなるのでコンテンツ分指定する
document.body.style.height = `${scrollArea.getBoundingClientRect().height}px`;

そして、ループ関数の中でスクロール位置を更新したあと、DOMをtransformで動かします。

js
  const loop = () => {
    updateScroll();

+   scrollArea.style.transform = `translate3d(0,${-currentScrollY}px,0)`;
    for (const plane of imagePlaneArray) {
      plane.update(scrollOffset);
    }
    renderer.render(scene, camera);

    requestAnimationFrame(loop);
  };

もうできちゃいました。リープ関数って便利ですね。
これで同期ずれはかなり軽減されたのではないでしょうか。ただ、慣性スクロールはモバイル端末などデバイスによってはすでにブラウザに実装されているため、その場合は挙動が重なってもっさりとした動きになってしまいます。なので慣性スクロールを実装すれば解決!というわけにはいかないみたいです。難しいですね。
とはいえ実際に対応策として取られるメジャーな手法であることは確かなので、覚えておいて損はないと思います。僕も今回勉強になりました。

-- 2021/7/26 追記ここまで --

これで完成になります。お付き合いいただきありがとうございました!
ここまで実装してしまえば、HTML側で好きな画像を指定したりCSSで画像のレイアウトを変えてもThree.jsのオブジェクトに反映されます。またaタグにリンクを設定してクリックすることもでき、シェーダーを使ったリッチなホバーエフェクトをつけることもできます(逆にCSSの単純なホバーアニメーションはできなくなってしまいますが)。

以下、コード全文です。

HTML
html
<div class="wrapper">
<div class="scrollable">
<div class="container">
  <ul class="image-list">
    <li class="image-item">
      <a href="" class="image-wrapper">
	<img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
	<img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
	<img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
    <li class="image-item">
      <a href="" class="image-wrapper">
	<img src="https://source.unsplash.com/whOkVvf0_hU/" alt="" />
      </a>
    </li>
  </ul>
</div>
</div>
</div>
CSS
css
/* -- リセット系 -- */
* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

ul,
li {
  list-style: none;
}

a {
  text-decoration: none;
}

img {
  width: 100%;
}

/* -- ここまで -- */

body {
  overscroll-behavior: none;
}

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

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

.wrapper {
  width: 100%;
  height: 100vh;
  position: fixed;
  top: 0;
  left: 0;
}

.scrollable {
  position: absolute;
  width: 100%;
  top: 0;
  left: 0;
}

.container {
  width: 80vw;
  max-width: 1000px;
  margin: 0 auto;
}

.image-list {
  width: 800px;
  margin: 0 auto;
  padding: 180px 0;
}

.image-item {
  width: 100%;
}

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

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

.image-wrapper > img {
  height: 100%;
  object-fit: cover;
  opacity: 0;
}


JS
js
const canvasEl = document.getElementById('webgl-canvas');
const canvasSize = {
  w: window.innerWidth,
  h: window.innerHeight,
};

const renderer = new THREE.WebGLRenderer({ canvas: canvasEl });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(canvasSize.w, canvasSize.h);

// ウィンドウとwebGLの座標を一致させるため、描画がウィンドウぴったりになるようカメラを調整
const fov = 60; // 視野角
const fovRad = (fov / 2) * (Math.PI / 180);
const dist = canvasSize.h / 2 / Math.tan(fovRad);
const camera = new THREE.PerspectiveCamera(
  fov,
  canvasSize.w / canvasSize.h,
  0.1,
  1000
);
camera.position.z = dist;

const scene = new THREE.Scene();

const loader = new THREE.TextureLoader();

// 画像をテクスチャにしたplaneを扱うクラス
class ImagePlane {
  constructor(mesh, img) {
    this.refImage = img;
    this.mesh = mesh;
  }

  setParams() {
    // 参照するimg要素から大きさ、位置を取得してセット
    const rect = this.refImage.getBoundingClientRect();

    this.mesh.scale.x = rect.width;
    this.mesh.scale.y = rect.height;

    const x = rect.left - canvasSize.w / 2 + rect.width / 2;
    const y = -rect.top + canvasSize.h / 2 - rect.height / 2;
    this.mesh.position.set(x, y, this.mesh.position.z);
  }

  update(offset) {
    this.setParams();

    this.mesh.material.uniforms.uTime.value = offset;
  }
}

// Planeメッシュを作る関数
const createMesh = (img) => {
  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 geo = new THREE.PlaneBufferGeometry(1, 1, 100, 100); // 後から画像のサイズにscaleするので1にしておく
  const mat = new THREE.ShaderMaterial({
    uniforms,
    vertexShader: document.getElementById('v-shader').textContent,
    fragmentShader: document.getElementById('f-shader').textContent,
  });

  const mesh = new THREE.Mesh(geo, mat);

  return mesh;
};

// スクロール追従
let targetScrollY = 0; // スクロール位置
let currentScrollY = 0; // 線形補間を適用した現在のスクロール位置
let scrollOffset = 0; // 上記2つの差分

// 開始と終了をなめらかに補間する関数
const lerp = (start, end, multiplier) => {
  return (1 - multiplier) * start + multiplier * end;
};

const updateScroll = () => {
  // スクロール位置を取得
  targetScrollY = document.documentElement.scrollTop;
  // リープ関数でスクロール位置をなめらかに追従
  currentScrollY = lerp(currentScrollY, targetScrollY, 0.1);

  scrollOffset = targetScrollY - currentScrollY;
};

// 慣性スクロール
const scrollArea = document.querySelector('.scrollable');
document.body.style.height = `${scrollArea.getBoundingClientRect().height}px`;

const imagePlaneArray = [];

// 毎フレーム呼び出す
const loop = () => {
  updateScroll();

  scrollArea.style.transform = `translate3d(0,${-currentScrollY}px,0)`;
  for (const plane of imagePlaneArray) {
    plane.update(scrollOffset);
  }
  renderer.render(scene, camera);

  requestAnimationFrame(loop);
};

// リサイズ処理
let timeoutId = 0;
const resize = () => {
  // three.jsのリサイズ
  const width = window.innerWidth;
  const height = window.innerHeight;

  canvasSize.w = width;
  canvasSize.h = height;

  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);

  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  // カメラの距離を計算し直す
  const fov = 60;
  const fovRad = (fov / 2) * (Math.PI / 180);
  const dist = canvasSize.h / 2 / Math.tan(fovRad);
  camera.position.z = dist;
};

const main = () => {
  window.addEventListener('load', () => {
    const imageArray = [...document.querySelectorAll('img')];
    for (const img of imageArray) {
      const mesh = createMesh(img);
      scene.add(mesh);

      const imagePlane = new ImagePlane(mesh, img);
      imagePlane.setParams();

      imagePlaneArray.push(imagePlane);
    }
    loop();
  });

  // リサイズ(負荷軽減のためリサイズが完了してから発火する)
  window.addEventListener('resize', () => {
    if (timeoutId) clearTimeout(timeoutId);

    timeoutId = setTimeout(resize, 200);
  });
};

main();

頂点シェーダー
vertex-shader
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);
}
フラグメントシェーダー
fragment-shader
varying vec2 vUv;
uniform sampler2D uTexture;
uniform float uImageAspect;
uniform float uPlaneAspect;
uniform float uTime;

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

  // RGBシフト
  vec2 offset = vec2(0.0, uTime * 0.0005);
  float r = texture2D(uTexture, fixedUv + offset).r;
  float g = texture2D(uTexture, fixedUv + offset * 0.5).g;
  float b = texture2D(uTexture, fixedUv).b;
  vec3 texture = vec3(r, g, b);

  gl_FragColor = vec4(texture, 1.0);
}

終わりに

最後まで読んでいただきありがとうございました。至らない点が多いかと思いますが、どなたかの助けになれば嬉しいです。
冒頭にも書きましたが、僕自身まだまだ勉強中の身ですので、ソースコード内のおかしな点や技術的な認識の間違いなどありましたらご指摘いただけますと幸いです。

▼ 2021/7/26 追記

  • スクロール取得のコードと説明を修正しました。
  • 慣性スクロールを実装し、追記しました。

参考

https://qiita.com/watabo_shi/items/bf9bcd4569b6d480c608
https://qiita.com/ykob/items/4ede3cb11684c8a403f8
https://www.notion.so/Si-Vertical-Smooth-Scroll-JS-7bc9ca180f5c4f98a98254144f746186
https://twitter.com/_unshift/status/1392403406883495937?s=20

Discussion

ログインするとコメントできます