スクロールでグニャらせる画像エフェクト
はじめに
今回は、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);
}