Three.jsとGLSLで画像エフェクトを実装する方法

はじめに

Three.jsとGLSLを使用して、ウェブページ上の画像にエフェクトを与える技術について解説します!

この記事では、スクロールによってトリガーされるノイズエフェクトを追加する方法を紹介します!

必要なパッケージ

各必要なパッケージをインストールします。

詳細はnpm docs hogehogeで確認してください。

  "three": "^0.165.0",

詳細解説

init 関数について

init関数では、各画像コンテナごとにThree.jsのシーン、カメラ、レンダラーを設定し、シェーダーを使用してエフェクトを適用するためのメッシュを作成します!

また、ウィンドウのリサイズやスクロールイベントをリスニングするイベントリスナーを設定します!

function init() {
  imageContainers.forEach((container, index) => {
    const canvas = container.querySelector("canvas");
    if (!canvas) return;
    const image = container.querySelector(".js-original-image") as HTMLImageElement | null;
    if (!image) return;

    const scene = new THREE.Scene();
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1);
    const renderer = new THREE.WebGLRenderer({ canvas });
    renderer.setSize(container.clientWidth, container.clientHeight);

    const geometry = new THREE.PlaneGeometry(2, 2);
    const loader = new THREE.TextureLoader();
    const texture = loader.load(image.src);

    const uniform = {
      u_time: { value: 0.0 },
      u_resolution: { value: new THREE.Vector2() },
      u_texture: { value: texture },
      u_noiseStrength: { value: 0.0 },
    };

    const material = new THREE.ShaderMaterial({
      uniforms: uniform,
      vertexShader: vertexShaderSource(),
      fragmentShader: fragmentShaderSource(),
    });

    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    scenes.push(scene);
    cameras.push(camera);
    renderers.push(renderer);
    uniforms.push(uniform);
    animations.push({ active: false, startTime: 0 });

    updateSize(index);
  });

  window.addEventListener("resize", onWindowResize);
  window.addEventListener("scroll", onScroll);
}

リサイズ処理

onWindowResize関数では、ウィンドウのサイズ変更に応じて各コンテナのサイズを更新します!

updateSize関数では、各コンテナのサイズをレンダラーとユニフォームに適用します!

function onWindowResize() {
  imageContainers.forEach((_, index) => updateSize(index));
}

function updateSize(index: number) {
  const container = imageContainers[index];
  const renderer = renderers[index];
  const uniform = uniforms[index];
  renderer.setSize(container.clientWidth, container.clientHeight);
  uniform.u_resolution.value.set(renderer.domElement.width, renderer.domElement.height);
}

スクロールイベント

onScroll関数では、各コンテナがビューポート内に表示されているかどうかをチェックし、表示されている場合にアニメーションを開始します!

function onScroll() {
  imageContainers.forEach((container, index) => {
    const rect = container.getBoundingClientRect();
    const isVisible = rect.top < window.innerHeight && rect.bottom >= 0;
    if (isVisible && !animations[index].active) {
      animations[index].active = true;
      animations[index].startTime = Date.now();
    } else if (!isVisible && animations[index].active) {
      animations[index].active = false;
      uniforms[index].u_noiseStrength.value = 0.0;
    }
  });
}

アニメーションループ

animate関数では、requestAnimationFrameを使用して連続的にrender関数を呼び出し、シーンをレンダリングします!

function render() {
  const currentTime = Date.now();
  scenes.forEach((scene, index) => {
    if (animations[index].active) {
      const elapsedTime = (currentTime - animations[index].startTime) / 1000;
      uniforms[index].u_time.value = elapsedTime;
      uniforms[index].u_noiseStrength.value = Math.min(0.5, elapsedTime * 0.1);
    }
    renderers[index].render(scene, cameras[index]);
  });
}

シェーダー

function vertexShaderSource() {
  return `
    varying vec2 vUv;
    void main() {
        vUv = uv;
        gl_Position = vec4(position, 1.0);
    }
  `;
}

function fragmentShaderSource() {
  return `
    uniform float u_time;
    uniform vec2 u_resolution;
    uniform sampler2D u_texture;
    uniform float u_noiseStrength;
    varying vec2 vUv;

    float random(float p) {
        return fract(sin(p) * 10000.0);
    }

    float random2(vec2 p) {
        return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
    }

    float noise(vec2 p) {
        vec2 ip = floor(p);
        vec2 u = fract(p);
        u = u * u * (3.0 - 2.0 * u);

        float res = mix(
            mix(random2(ip), random2(ip + vec2(1.0, 0.0)), u.x),
            mix(random2(ip + vec2(0.0, 1.0)), random2(ip + vec2(1.0, 1.0)), u.x), u.y);
        return res * res;
    }

    void main() {
        vec2 st = vUv;
        float aspect = u_resolution.x / u_resolution.y;

        float noiseVal = noise(vec2(st.x * aspect, st.y + u_time * 0.1) * 10.0) * u_noiseStrength;
        st.y += noiseVal * 0.1;

        float lineNoise = step(0.98, random(floor(st.y * 50.0 + u_time)));
        st.x += lineNoise * 0.02 * sin(u_time * 10.0 + st.y * 100.0);

        vec4 color = texture2D(u_texture, st);

        color.rgb += noiseVal * 0.1;
        color.rgb = mix(color.rgb, vec3(1.0), lineNoise * u_noiseStrength);

        gl_FragColor = color;
    }
  `;
}

まとめ

  1. 初期化: 画像コンテナごとにThree.jsのシーン、カメラ、レンダラーを設定し、シェーダーを使用してエフェクトを適用するためのメッシュを作成。
  2. リサイズイベント: ウィンドウのサイズ変更に応じて各コンテナのサイズを更新。
  3. スクロールイベント: 各コンテナがビューポート内に表示されているかどうかをチェックし、表示されている場合にアニメーションを開始。
  4. アニメーション: requestAnimationFrameを使用して連続的にrender関数を呼び出し、シーンをレンダリング。
  5. シェーダー: ノイズエフェクト、ラインエフェクト、ノイズによる色変化を適用して画像を処理。

このスクリプトを使用することで、ウェブページ上の画像に対して動的なエフェクトを簡単に適用できます!

サンプルコード

HTML
<div class="mb-40">
  <div class="js-image-container relative aspect-square w-full overflow-hidden">
      <img class="js-original-image hidden" src={hogehoge} alt="Image 1">
      <canvas class="absolute left-0 top-0 size-full" ></canvas>
  </div>
</div>

<div class="grid grid-cols-3 gap-5">
  <div class="js-image-container relative aspect-square w-full overflow-hidden">
      <img class="js-original-image hidden" src={hogehoge} alt="Image 1">
      <canvas class="absolute left-0 top-0 size-full"></canvas>
  </div>
  <div class="js-image-container relative aspect-square w-full overflow-hidden">
      <img class="js-original-image hidden" src={hogehoge} alt="Image 2">
      <canvas class="absolute left-0 top-0 size-full"></canvas>
  </div>
  <div class="js-image-container relative aspect-square w-full overflow-hidden">
      <img class="js-original-image hidden" src={hogehoge} alt="Image 3">
      <canvas class="absolute left-0 top-0 size-full"></canvas>
  </div>
</div>
TypeScript
import * as THREE from "three";

(async () => {
const imageContainers = document.querySelectorAll(".js-image-container");
const scenes: THREE.Scene[] = [];
const cameras: THREE.OrthographicCamera[] = [];
const renderers: THREE.WebGLRenderer[] = [];
const uniforms: {
  u_time: { value: number };
  u_resolution: { value: THREE.Vector2 };
  u_texture: { value: THREE.Texture };
  u_noiseStrength: { value: number };
}[] = [];
const animations: { active: boolean; startTime: number }[] = [];

// 初期化
init();
// アニメーションループの開始
animate();

// 初期化関数
function init() {
  // 各画像コンテナごとに設定を行う
  imageContainers.forEach((container, index) => {
    const canvas = container.querySelector("canvas");
    if (!canvas) return;
    const image = container.querySelector(".js-original-image") as HTMLImageElement | null;
    if (!image) return;

    // シーンの作成
    const scene = new THREE.Scene();
    // カメラの作成(OrthographicCameraを使用)
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1);
    // レンダラーの作成
    const renderer = new THREE.WebGLRenderer({ canvas });
    renderer.setSize(container.clientWidth, container.clientHeight);

    // 平面ジオメトリの作成
    const geometry = new THREE.PlaneGeometry(2, 2);
    // テクスチャローダーの作成
    const loader = new THREE.TextureLoader();
    const texture = loader.load(image.src);

    // シェーダーのユニフォーム変数の設定
    const uniform = {
      u_time: { value: 0.0 },
      u_resolution: { value: new THREE.Vector2() },
      u_texture: { value: texture },
      u_noiseStrength: { value: 0.0 },
    };

    // シェーダーマテリアルの作成
    const material = new THREE.ShaderMaterial({
      uniforms: uniform,
      vertexShader: vertexShaderSource(),
      fragmentShader: fragmentShaderSource(),
    });

    // メッシュの作成とシーンへの追加
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);

    // 各種オブジェクトを配列に保存
    scenes.push(scene);
    cameras.push(camera);
    renderers.push(renderer);
    uniforms.push(uniform);
    animations.push({ active: false, startTime: 0 });

    // 初期サイズの設定
    updateSize(index);
  });

  // リサイズイベントとスクロールイベントの設定
  window.addEventListener("resize", onWindowResize);
  window.addEventListener("scroll", onScroll);
}

// ウィンドウリサイズ時の処理
function onWindowResize() {
  imageContainers.forEach((_, index) => updateSize(index));
}

// 各コンテナのサイズを更新
function updateSize(index: number) {
  const container = imageContainers[index];
  const renderer = renderers[index];
  const uniform = uniforms[index];
  renderer.setSize(container.clientWidth, container.clientHeight);
  uniform.u_resolution.value.set(renderer.domElement.width, renderer.domElement.height);
}

// スクロールイベント時の処理
function onScroll() {
  imageContainers.forEach((container, index) => {
    const rect = container.getBoundingClientRect();
    const isVisible = rect.top < window.innerHeight && rect.bottom >= 0;
    // コンテナがビューポート内にある場合、アニメーションを開始
    if (isVisible && !animations[index].active) {
      animations[index].active = true;
      animations[index].startTime = Date.now();
    } else if (!isVisible && animations[index].active) {
      animations[index].active = false;
      uniforms[index].u_noiseStrength.value = 0.0;
    }
  });
}

// アニメーションループ関数
function animate() {
  requestAnimationFrame(animate);
  render();
}

// レンダリング関数
function render() {
  const currentTime = Date.now();
  scenes.forEach((scene, index) => {
    if (animations[index].active) {
      const elapsedTime = (currentTime - animations[index].startTime) / 1000;
      uniforms[index].u_time.value = elapsedTime;
      uniforms[index].u_noiseStrength.value = Math.min(0.5, elapsedTime * 0.1);
    }
    renderers[index].render(scene, cameras[index]);
  });
}

// 頂点シェーダーのソースコード
function vertexShaderSource() {
  return `
    varying vec2 vUv;
    void main() {
        vUv = uv;
        gl_Position = vec4(position, 1.0);
    }
  `;
}

// フラグメントシェーダーのソースコード
function fragmentShaderSource() {
  return `
    uniform float u_time;
    uniform vec2 u_resolution;
    uniform sampler2D u_texture;
    uniform float u_noiseStrength;
    varying vec2 vUv;

    // 1Dランダム関数
    float random(float p) {
        return fract(sin(p) * 10000.0);
    }

    // 2Dランダム関数
    float random2(vec2 p) {
        return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
    }

    // 2Dノイズ関数
    float noise(vec2 p) {
        vec2 ip = floor(p);
        vec2 u = fract(p);
        u = u * u * (3.0 - 2.0 * u);

        float res = mix(
            mix(random2(ip), random2(ip + vec2(1.0, 0.0)), u.x),
            mix(random2(ip + vec2(0.0, 1.0)), random2(ip + vec2(1.0, 1.0)), u.x), u.y);
        return res * res;
    }

    void main() {
        vec2 st = vUv;
        float aspect = u_resolution.x / u_resolution.y;

        // ノイズによる歪み
        float noiseVal = noise(vec2(st.x * aspect, st.y + u_time * 0.1) * 10.0) * u_noiseStrength;
        st.y += noiseVal * 0.1;

        // 横線のエフェクト
        float lineNoise = step(0.98, random(floor(st.y * 50.0 + u_time)));
        st.x += lineNoise * 0.02 * sin(u_time * 10.0 + st.y * 100.0);

        vec4 color = texture2D(u_texture, st);

        // ノイズによる色の変化
        color.rgb += noiseVal * 0.1;

        // 横線の色
        color.rgb = mix(color.rgb, vec3(1.0), lineNoise * u_noiseStrength);

        gl_FragColor = color;
    }
  `;
}
})();