エフェクト
slider-3d

Slider-3d


デモ

ソースコード

import frag from "./fragment.glsl";
import vert from "./vertex.glsl";
import {
  Group,
  Mesh,
  Vector3,
  MeshStandardMaterial,
  MathUtils,
  Euler,
} from "three";

import gsap from "gsap";
import { viewport, INode, utils, world, config } from "negl";
import { MultiMeshSlider } from "#/parts/helper/slider/MultiMeshSlider";

/**
 * slider-3d
 *
 * xyz軸方向に移動するスライダです。A slider that moves in the direction of the xyz axes.
 *
 * data-gap=".4"         スライドの間隔
 *                       The interval between slides
 * data-axis="0,0,0"     スライダの回転(x,y,z).
 *                       Rotation of the slider(x,y,z)
 * data-direction="z"    スライダが切り替わる方向 "x"、"y"、または"z" で指定
 *                       The direction in which the slider switches, specified as "x", "y", or "z"
 * data-speed="0.06"     スライダの切り替わるスピード
 *                       The speed at which the slider switches
 * data-fade-level="3"   スライダのフェードイン、フェードアウトの間隔(0: 全てのスライダを表示、数値が上がるほど表示されるスライダが絞られます。)
 *                       The interval of fade-in and fade-out of the slider (0: all sliders are displayed, the higher the number, the more the displayed sliders are narrowed down.)
 * data-z="-200"         スライダの z 座標. The z-coordinate of the slider
 */
export default class extends MultiMeshSlider {
  beforeCreateMesh() {
    super.beforeCreateMesh();
    this.gap = this.rect.width * (this.dataAttrs.gap ?? 1.5);

    // スライドの向きを定義
    // Define the orientation of the slide
    const axisDegree = (this.dataAttrs.axis ?? "0,0,0")
      .split(",")
      .map((v) => +v); // + 演算子で文字列から数値に変換
    // Convert string to number with + operator
    // ラジアンで軸を決定
    // Determine the axis in radians
    this.axis = new Vector3(
      MathUtils.degToRad(axisDegree[0]),
      MathUtils.degToRad(axisDegree[1]),
      MathUtils.degToRad(axisDegree[2])
    );

    // スライドの進行方向を保持
    // Maintain the direction of slide progression
    this.direction = this.dataAttrs.direction ?? "x"; // "x" or "y" or "z"

    // スライドの速度
    // The speed of the slide
    this.slideSpeed = this.dataAttrs.speed ? +this.dataAttrs.speed : 0.07;

    this.fadeLevel = this.dataAttrs.fadeLevel ? +this.dataAttrs.fadeLevel : 0;
  }

  setupFragment() {
    return frag;
  }

  setupVertex() {
    return vert;
  }

  playVideo() {
    return;
  }

  setupMaterial() {
    return new MeshStandardMaterial({
      transparent: true,
    });
  }

  setupUniforms() {
    const uniforms = super.setupUniforms();
    return uniforms;
  }

  /**
   * スライドの総枚数における、idx (スライダの番号)がどの位置にあるのかを 0 ~ 1 で表します。
   * Represents the position of idx (the number of the slider) in the total number of slides as a value between 0 and 1.
   *
   * MultiMeshSlider ではスライダを無限ループでアニメーション出来るように各スライダの位置を円の上に配置するように周期的に配置しています。
   * In MultiMeshSlider, the position of each slider is periodically placed on a circle to animate the sliders in an infinite loop.
   *
   * getPhase の返す数値の意味は次の通りです。
   * The meaning of the value returned by getPhase is as follows:
   * 0    : 初期位置(スライダの中央の位置となります。すなわち、エフェクトの元になった DOM 要素の位置となります。)
   *        Initial position (the central position of the slider, i.e., the position of the DOM element that originated the effect.)
   * 0.25 : 振幅が最大の位置(スライダが初期位置から最も離れた位置となります。0 -> 0.25 で初期位置から離れ、0.25 -> 0.5 で初期位置に戻ります。)
   *        The position of maximum amplitude (the position furthest from the initial position of the slider. It moves away from the initial position from 0 to 0.25 and returns to the initial position from 0.25 to 0.5.)
   * 0.5  : 初期位置(0の地点と同じ位置です。ただ、getVisibleメソッドによって mesh は非表示の状態となります。)
   *        Initial position (the same position as point 0. However, the mesh becomes invisible due to the getVisible method.)
   * 0.75 : 0.25とは反対の振幅が最大の位置。なお、振幅の値は getAmp の戻り値です。または、1 に近づくにつれて、初期位置に近づきます。
   *        The position of maximum amplitude opposite to 0.25. The value of the amplitude is the return value of getAmp. As it approaches 1, it gets closer to the initial position.
   * 1    : 初期位置(0の地点と同じ位置です。)
   *        Initial position (the same position as point 0.)
   *
   * @param {number} idx
   * @returns 0 ~ 1 の値を返します。
   * @returns Returns a value between 0 and 1.
   */
  getPhase(idx) {
    return this.getModIdx(idx) / this.slideTotal;
  }

  /**
   * HTMLの表示位置を中央とした時に各スライドのメッシュをどの程度移動させれば良いかを移動量として返却します。
   * Returns the amount of movement required to move each slide's mesh when the display position of the HTML is centered.
   * @param {*} idx
   * @returns 移動量を返します。
   * @returns Returns the amount of movement.
   */
  getAmp(idx) {
    /**
     * フェーズ(0~1)に 2PI を掛けて、ラジアンに変換します。
     * Multiply the phase (0~1) by 2PI to convert it to radians.
     */
    const rad = this.getPhase(idx) * 2 * Math.PI;

    /**
     * のこぎり波を返す関数を定義します。
     * Defines a function that returns a sawtooth wave.
     *
     * 以下のコードを [GLSL Grapher](https://fordhurley.com/glsl-grapher/) に張り付けると波形が確認できます。
     * You can check the waveform by pasting the following code into [GLSL Grapher](https://fordhurley.com/glsl-grapher/).
     *
     * ```glsl
     * float y(float x) {
     *   return asin(sin(x)) / 3.141592 * 2.;
     * }
     * ```
     */
    const triangle = (x) => (Math.asin(Math.sin(x)) / Math.PI) * 2;

    /**
     * rad ごとの のこぎり波 の値を取得します。
     * Gets the value of the sawtooth wave for each rad.
     */
    const phase = -triangle(rad);

    /**
     * スライダ中央から一番遠くに移動する地点を振幅(amp)として取得します。
     * Obtains the point furthest from the center of the slider as the amplitude (amp).
     *
     * 補足)
     * Supplementary)
     * -/+両方にスライダを動かすため、1/2を行っています。
     * To move the slider in both -/+ directions, it is divided by 2.
     */
    const amp = this.gap * (this.texes.size / 2);

    /**
     * 振幅(amp)とのこぎり波の値(phase)を掛け合わせることで、スライド番号(idx)ごとにスライドが配置されます。
     * By multiplying the amplitude (amp) with the value of the sawtooth wave (phase), slides are positioned for each slide number (idx).
     */
    return amp * phase;
  }

  setupMesh() {
    const groupMesh = (this.groupMesh = new Group());
    groupMesh.position.z = this.dataAttrs.z ?? 0;
    this.changeRotateAxis();

    let i = 0;
    this.texes.forEach((tex, key) => {
      const mate = this.material.clone();

      mate.map = tex;
      mate.transparent = true;

      const _mesh = new Mesh(this.geometry, mate);

      groupMesh.add(_mesh);

      i++;
    });

    this.slides = [...groupMesh.children];

    this.slideTotal = this.slides.length;

    this.setSlidePos(this.activeSlideIdx);

    const scrollMesh = new Group();
    scrollMesh.add(groupMesh);
    return scrollMesh;
  }

  /**
   * スライドの位置を更新します。
   * Updates the position of the slide.
   *
   * @param {number} idx
   */
  setSlidePos(idx) {
    this.slides.forEach((slide, i) => {
      /**
       * メッシュを表示/非表示を設定します。
       * Sets the mesh to be visible/invisible.
       * [mesh.visible](https://threejs.org/docs/#api/en/core/Object3D.visible) が false の場合はメッシュのクリックイベントは発火しません。
       * If [mesh.visible](https://threejs.org/docs/#api/en/core/Object3D.visible) is false, the mesh's click event will not fire.
       */
      slide.visible = this.getVisible(i - idx);
      /**
       * 各スライドの位置を決定します。
       * Determines the position of each slide.
       */
      const amp = this.getAmp(i - idx);
      /**
       * data-direction にセットされたスライダのアニメーション方向に合わせて各スライダを移動します。
       * Moves each slider according to the animation direction of the slider set in data-direction.
       */
      slide.position.set(
        amp * this.directionVec3.x,
        amp * this.directionVec3.y,
        amp * this.directionVec3.z
      );
    });
  }

  /**
   * アニメーションなしでスライドの位置を変更します。
   * Changes the position of the slide without animation.
   *
   * @param {number} idx
   */
  setTo(idx) {
    super.setTo(idx);
    this.setSlidePos(idx);
  }

  /**
   * 画面リサイズ時の制御を行います。
   * Controls during screen resizing.
   *
   * @param {number} duration
   */
  async resize(duration) {
    this.resizing = true;
    const {
      $: { el },
      mesh,
      originalRect,
    } = this;

    const nextRect = INode.getRect(el);
    const { x, y } = this.getWorldPosition(nextRect, viewport);

    // 位置の変更
    // Changing the position
    const p1 = new Promise((onComplete) => {
      gsap.to(mesh.position, {
        x,
        y,
        overwrite: true,
        duration,
        onComplete,
      });
    });

    // 大きさの変更
    // Changing the size
    const p2 = new Promise((onComplete) => {
      gsap.to(this.scale, {
        width: nextRect.width / originalRect.width,
        height: nextRect.height / originalRect.height,
        depth: 1,
        overwrite: true,
        duration,
        onUpdate: () => {
          mesh.scale.set(this.scale.width, this.scale.height, this.scale.depth);
        },
        onComplete,
      });
    });

    // アスペクト比の変更
    // Changing the aspect ratio
    const p3 = new Promise((onComplete) => {
      const resolution = this.getResolution(nextRect);
      gsap.to(this.uniforms.uResolution.value, {
        x: resolution.x,
        y: resolution.y,
        z: resolution.z,
        w: resolution.w,
        overwrite: true,
        duration,
        onUpdate: () => {
          this.setTexAspect(this.uniforms.uResolution);
        },
        onComplete,
      });
    });

    await Promise.all([p1, p2, p3]);

    this.rect = nextRect;

    this.resizing = false;
  }

  /**
   * 画像とメッシュの縦横比からテクスチャのアスペクト比を設定します。
   * Sets the texture aspect ratio from the aspect ratio of the image and mesh.
   *
   * 補足)
   * Supplementary)
   * このエフェクトでは ShaderMaterial を使っていないため、map.repeat や map.offset を変更して、テクスチャの uv 座標を操作しています。
   * Since ShaderMaterial is not used in this effect, map.repeat and map.offset are changed to manipulate the texture's uv coordinates.
   */
  setTexAspect(uResolution) {
    const { z, w } = uResolution.value;
    const offset = { x: (1 - z) / 2, y: (1 - w) / 2 };
    this.slides.forEach((slide) => {
      // repeat - https://threejs.org/docs/?q=Text#api/en/textures/Texture.repeat
      slide.material.map.repeat.set(z, w);
      // offset - https://threejs.org/docs/?q=Text#api/en/textures/Texture.offset
      slide.material.map.offset.set(offset.x, offset.y);
    });
  }

  /**
   * 各スライドの透明度を取得します。
   * Obtains the opacity of each slide.
   *
   * @param {number} idx
   * @returns
   */
  getOpacity(idx) {
    if (this.fadeLevel === 0) return 1;
    // getPhase 0 ~ 1
    const phase = this.getPhase(idx);
    return Math.cos(2 * this.fadeLevel * Math.PI * phase);
  }

  /**
   * 各スライドの 表示/非表示 を取得します。
   * Obtains the visibility/invisibility of each slide.
   * @param {*} idx
   * @returns
   */
  getVisible(idx) {
    const phase = this.getPhase(idx);

    let level = 1;
    // fadeLevelが 0 の時に level を 0 とすると背面に回ったスライダが表示されるため
    // Setting level to 0 when fadeLevel is 0 would cause the sliders that have turned to the back to be displayed
    if (this.fadeLevel !== 0) level = this.fadeLevel;

    // 背面に回ったスライダは表示しない
    // Do not display sliders that have turned to the back
    if (1 / 4 / level <= phase && phase < 1 - 1 / 4 / level) {
      return false;
    }

    // phaseが以下の時、メッシュを表示(0, 1が基点の状態)
    // Display the mesh when the phase is as follows (0, 1 being the base state)
    // 0 <= phase && phase < 1/4
    // 3/4 <= phase && phase <= 1
    return true;
  }

  render(tick) {
    super.render(tick);

    const idx = utils.lerp(
      this.easeSlideIdx,
      this.activeSlideIdx,
      this.slideSpeed
    );

    if (this.easeSlideIdx === this.activeSlideIdx) return;

    this.setSlidePos(idx);

    this.easeSlideIdx = idx;

    this.slides.forEach((slide, i) => {
      slide.material.opacity = this.getOpacity(i - idx);
    });
  }

  afterInit() {
    this.setTo(this.activeSlideIdx);
  }

  changeRotateAxis() {
    this.groupMesh.rotation.set(this.axis.x, this.axis.y, this.axis.z);

    this.directionVec3 = new Vector3(0, 0, 0);
    this.directionVec3[this.direction] = -1;
    const euler = new Euler(this.axis.x, this.axis.y, this.axis.z, "XYZ");
    this.directionVec3.applyEuler(euler);
  }

  setOpacity(opacity) {
    this.slides.forEach((slide) => (slide.material.opacity = opacity));
  }

  debug(folder) {
    super.debug(folder);
    folder.add(this, "gap", this.rect.width, this.rect.width * 3, 10).listen();

    folder
      .add(this.axis, "x", -180, 180, 1)
      .name("rotation.x")
      .listen()
      .onChange(() => this.changeRotateAxis(this.groupMesh.position));
    folder
      .add(this.axis, "y", -180, 180, 1)
      .name("rotation.y")
      .listen()
      .onChange(() => this.changeRotateAxis(this.groupMesh.position));

    folder
      .add(this.axis, "z", -180, 180, 1)
      .name("rotation.z")
      .listen()
      .onChange(() => this.changeRotateAxis(this.groupMesh.position));

    folder
      .add(this, "direction", ["x", "y", "z"])
      .name("direction")
      .onChange(async () => {
        this.directionVec3 = new Vector3(0, 0, 0);
        this.directionVec3[this.direction] = -1;
      });
  }
}

利用方法

⚠️

ダウンロードしたコードをプロジェクトに配置し、以下のコードを記述してください。

index.html

<img
  class="slider"
  data-webgl="slider-3d"
  data-tex-1="/sample1.jpg"
  data-tex-2="/sample2.jpg"
  data-tex-3="/sample3.jpg"
  data-tex-4="/sample4.jpg"
  data-tex-5="/sample5.jpg"
/>

補足

使用する際はライトと一緒に使用してください。

使用画像・動画

  • こちらを参考にダウンロードした各ファイルを配置してください。