【Pixi連載#1】Pixi.jsでcanvas製オリジナルスライダーを作る

Pixi.jsは慣れれば簡単に2Dアニメーションを表現できて最近とても重宝しているライブラリの一つです。
今回はそんなPixi.jsを使って、一風変わったオリジナルのスライダーを作っていきます。
また、Pixi.jsのフィルターやアニメーション部分にはTweenMaxも使用していきます。

## Pixi.jsでcanvas製オリジナルスライダーを作る

### 今回作るもの（完成版）
![slider.mov.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/101111/d0c2f623-f000-6910-6a17-3d277fc8522b.gif)

今回作るスライダーの完成版です。
スライダーの構造は至って普通のスライダーと変わりはありません。
その上にPixi FilterのDisplacementフィルターをかけて少し画像がグニっとした見た目になるようにしています。また、クリックした時に波紋が広がるような表現になるように、クリックイベント時にShockwaveフィルターをかけています。

- 完成版ソースは以下  
[https://github.com/horizun-horizon/pixi-slider/blob/master/index.html](https://github.com/horizun-horizon/pixi-slider/blob/master/index.html)

### 0. ライブラリ・プラグインの読み込み
まずは以下の3つのライブラリ・プラグインを読み込みます。
※それぞれの説明はコメントの通りです。

```html
<!-- Pixi.js本体 -->
<script src="//cdnjs.cloudflare.com/ajax/libs/pixi.js/5.0.3/pixi.min.js"></script>
<!-- Pixi Filter（これ一つで全てのフィルターが使える） -->
<script src="//cdn.jsdelivr.net/npm/pixi-filters@latest/dist/pixi-filters.js"></script>
<!-- アニメーション部分に使うTweenMax -->
<script src="//cdnjs.cloudflare.com/ajax/libs/gsap/2.1.3/TweenMax.min.js"></script>
```

### 1. canvas追加と画像読み込み
次に、canvasを指定の要素に追加するために`new PIXI.Application`を宣言します。
また、画像読み込みに`PIXI.loader`を使います。

```javascript
  const mySlide = {
    currentSlide: 0, // 現在のスライド番号
    init: function(options) {
      this.options = options;
      this.slideWrap = document.getElementById(this.options.targetId);
      this.slideLength = this.options.slides.length;

      // Applicationの準備
      this.app = new PIXI.Application({
        width: this.options.width,
        height: this.options.height,
        autoStart: true,
        autoResize: true,
        transparent: true,
        resolution: devicePixelRatio,
      });
      // Containerの準備
      this.container = new PIXI.Container();
      // マウスイベントを有効化
      this.container.interactive = true;
      // フィルターの準備
      this.container.filterArea = this.app.screen;
      this.container.filters = [];

      // canvasの追加
      this.slideWrap.appendChild(this.app.view);
      this.app.stage.addChild(this.container);

      const images = [];
      this.options.slides.map((image, i) => {
        images.push({ name: `slide${i}`, url: image });
      });
      // スライド画像読み込み
      PIXI.loader
        .add(images)
        .add('displacement', 'displacement-map.jpg')
        .load(() => {
          // 読み込み完了後の処理をここに記述
        });
    }
  };

  mySlide.init({
    targetId: 'canvas', // スライダーを追加するid
    width: 900, // スライダーの幅
    height: 540, // スライダーの高さ
    autoplaySpeed: 5000, // 何秒でスライドを切り替えるか
    speed: 0.5, // スライドを切り替えるスピード
    slides: [ // スライド画像のパス
      'img-01.jpg',
      'img-02.jpg',
      'img-03.jpg',
    ]
  });
```

### 2. スライド画像をコンテナに追加
`PIXI.loader.load()`内にスライド画像を追加する処理の宣言を追加します。
そして、配列にSprite化させた画像をそれぞれ追加した後、x位置を設定したSpriteをそれぞれコンテナに追加していきます。

```javascript
  const mySlide = {
    init: function() {
      // 〜省略
      PIXI.loader
        .add(images)
        .add('displacement', 'displacement-map.jpg')
        .load(() => {
          // 読み込み完了後の処理をここに記述
          this.setSlide();
        });
    },
    setSlide: function() {
      this.slides = [];
      for (let res of Object.keys(PIXI.loader.resources)) {
        if (res.search(/slide/) !== -1) {
          // 配列にSprite化させた画像を追加
          this.slides.push(new PIXI.Sprite(PIXI.loader.resources[res].texture));
        }
      }
      this.slides.map((slide, i) => {
        slide.x = this.options.width * i; // x位置の設定
        this.container.addChild(slide); // コンテナに追加
      });
    }
  };
```

### 3. Displacementフィルターを追加
スライドが切り替わる瞬間に、画像がグニっとした見た目になるようにDisplacementフィルターを掛けていきます。

```javascript
  const mySlide = {
    init: function() {
      // 〜省略
      PIXI.loader
        .add(images)
        .add('displacement', 'displacement-map.jpg')
        .load(() => {
          // 読み込み完了後の処理をここに記述
          this.setSlide();
          this.setDisplacement();
        });
    },
    setSlide: function() {
      // 〜省略
    },
    setDisplacement: function() {
      // Displacementフィルターに使うフィルター画像をSprite化
      this.displacementSprite = new PIXI.Sprite(PIXI.loader.resources.displacement.texture);
      this.displacementSprite.texture.baseTexture.wrapMode = PIXI.WRAP_MODES.REPEAT;
      // フィルターの適用
      this.displacementFilter = new PIXI.filters.DisplacementFilter(this.displacementSprite);
      // フィルターのオプション
      this.displacementSprite.skew.x = 1;
      this.displacementSprite.skew.y = -1;
      this.displacementSprite.position.y = 0;
      this.displacementSprite.scale.y = 1.8;
      this.displacementSprite.scale.x = 1.8;
      this.displacementSprite.anchor.set(0.5);

      // フィルターの追加
      this.container.filters.push(this.displacementFilter);
      // フィルター画像の追加
      this.container.addChild(this.displacementSprite);
    },
  };
```

### 4. ページネーションを追加
注意点として、そのまま大元のコンテナにページネーションに追加してしまうと上で追加したDisplacementフィルターがページネーションにも掛かってしまってグニャっとしてしまうので、新たなページネーション用のコンテナを追加しています。

```javascript
  const mySlide = {
    init: function() {
      // 〜省略
      PIXI.loader
        .add(images)
        .add('displacement', 'displacement-map.jpg')
        .load(() => {
          // 読み込み完了後の処理をここに記述
          this.setSlide();
          this.setDisplacement();
          this.setPagenation();
        });
    },
    setSlide: function() {
      // 〜省略
    },
    setDisplacement: function() {
      // 〜省略
    },
    setPagenation: function() {
      this.pagenations = [];
      // ページネーション用にコンテナ追加
      const gContainer = new PIXI.Container();
      for (let i = 0; i < this.slideLength; i++) {
        this.pagenations.push(new PIXI.Graphics());
        this.pagenations[i].beginFill('0xffffff');
        this.pagenations[i].drawCircle(20 * (i + 1), this.options.height - 15, 3);
        this.pagenations[i].endFill();
        this.pagenations[i].slideNumber = i;
        gContainer.addChild(this.pagenations[i]);
      }
      this.app.stage.addChild(gContainer);
    },
  };
```

### 5. クリックイベントをとスライド切り替え処理
スライド切り替えの作りは至って普通のスライダーと変わりはありません。
スライド切り替えのアニメーション部分にはTweenMaxを使っています。
ネクストバックは画面の左右どちらをクリックしたかで判定してスライドを切り替えています。
また、注意点としてページネーションをクリックした際にスライド全体に掛けているクリックイベントに伝播してしまうため、以下の記述をページネーションのクリックイベント部分に追加しています。
`e.stopped = true;`

```javascript
  const mySlide = {
    init: function() {
      // 〜省略
      PIXI.loader
        .add(images)
        .add('displacement', 'displacement-map.jpg')
        .load(() => {
          // 読み込み完了後の処理をここに記述
          this.setSlide();
          this.setDisplacement();
          this.setPagenation();
          // スライド切り替え用のクリックイベント
          this.container.on('click', e => this.clickEvent(e));
        });
    },
    setSlide: function() {
      // 〜省略
    },
    setDisplacement: function() {
      // 〜省略
    },
    setPagenation: function() {
      // 〜省略
      for (let i = 0; i < this.slideLength; i++) {
        // 〜省略

        // ページネーションのマウスイベントを有効化
        this.pagenations[i].interactive = true;
        // ページネーションをクリックした時の処理
        this.pagenations[i].on('click', (e) => {
          this.pagenationEvent(e.currentTarget.slideNumber);
          // これを追加しないとスライド全体に掛けているクリックイベントに伝播してしまう
          e.stopped = true;
        });
      }
      this.navigationCheck();
    },
    pagenationEvent: function(targetNumber) {
      this.stop(); // スライド停止
      this.currentSlide = targetNumber; // 次のスライド番号を設定
      this.animateSlide();
      this.navigationCheck();
      this.start(); // スライド開始
    },
    clickEvent: function(e) {
      const posX = e.data.global.x;
      // スライドが0番目の時に左側、スライドが最後の時に右側をクリックしたら処理停止
      if (
        posX < this.options.width / 2 && this.currentSlide === 0 ||
        posX > this.options.width / 2 && this.currentSlide === this.slideLength - 1
      ) {
        return;
      }
      this.stop(); // スライド停止
      // クリック時のShockwaveフィルター
      this.onShockWave(e.data.global.x, e.data.global.y, 1);
      // 次のスライド番号を設定
      this.currentSlide = posX < this.options.width / 2 ?
        this.currentSlide - 1 : this.currentSlide + 1;
      this.animateSlide();
      this.navigationCheck();
      this.start(); // スライド開始
    },
    navigationCheck: function() {
      // ページネーションの切り替え
      this.pagenations.map((pagenation, i) => {
        pagenation.alpha = i !== this.currentSlide ? 0.5 : 1;
      });
    },
    start: function() {
      this.timer = setInterval(() => {
        // 次のスライド番号を設定
        this.currentSlide = this.currentSlide < this.slideLength - 1 ? this.currentSlide + 1 : 0;
        this.animateSlide();
        this.navigationCheck();
      }, this.options.autoplaySpeed);
    },
    stop: function() {
      clearInterval(this.timer);
    },
    animateSlide: function() {
      // スライド切り替え時のアニメーション
      this.slides.map((slide, i) => {
        TweenMax.to(slide, this.options.speed, { x: (i - this.currentSlide) * this.options.width, ease: 'Power2.easeInOut' });
      });
    },
  };
```

### 6. クリック時のShockwaveフィルターを追加
最後にスライド全体をクリックした時のShockwaveフィルターを追加します。

```javascript
  const mySlide = {
    // 〜省略
    onShockWave: function(x, y) {
      const shock = new PIXI.filters.ShockwaveFilter([x, y], {
        time: 0,
        amplitude: 44,
        wavelength: 200,
        brightness: 1.2,
        radius: 500
      });
      // フィルターを追加
      this.container.filters.push(shock);
      TweenMax.to(shock, 2, {
        time: 1,
        ease: Expo.easeOut,
        onComplete: () => {
          // 完了後にShockwaveフィルターを削除
          this.container.filters.unshift();
        }
      });
    }
  };
```

## 終わりに
ざっと説明しましたが、APIを見ながらでしたが2〜3時間程で一連の処理を追加できました。
見た目的にももう少しカスタマイズすれば、実案件でも使えるレベルのものにはなったんじゃないでしょうか。

Pixi.jsはまだまだ他の見せ方もたくさんできるライブラリなので、今後も引き続きPixi周りの連載をしていければと思います。
では、また〜

- 完成版ソースは以下  
[https://github.com/horizun-horizon/pixi-slider/blob/master/index.html](https://github.com/horizun-horizon/pixi-slider/blob/master/index.html)