物理エンジンライブラリ「matter.js」を使って要素が動くリッチなアニメーションを作ってみた

さいと

2023.03.15

565

こんにちは。斎音です。


YOKUのHPを制作するときに、matter.jsを使って要素が重力を持った物体のように動き、またスクロールに応じて動くようなアニメーションを制作しました。今回はそれの実装方法を紹介します。Next.jsで実装したのでそのままのソースコードを載せます。


今はディレクションの都合で非表示にしてあります。


こちらが動画です。


matter.jsの公式はこちらです。

https://brm.io/matter-js/

全体のソースコード

MatterArea.tsx

import {
  Engine,
  Body,
  Render,
  Bodies,
  MouseConstraint,
  Mouse,
  Runner,
  Composite,
} from 'matter-js'
import { memo, useLayoutEffect } from 'react'
import styles from '@/styles/components/MatterArea.module.scss'

export const MatterArea = memo(() => {
  useLayoutEffect(() => {
    // イベントリスナー クリーンアップ関数で呼ぶため先に定義
    let scrollTimer: NodeJS.Timeout

    function initMatter(matterHolder: HTMLElement) {
      const engine = Engine.create()
      const width = matterHolder?.clientWidth
      const height = matterHolder?.clientHeight

      const render = Render.create({
        element: matterHolder,
        engine,
        options: {
          width: width,
          height: height,
          background: 'transparent',
          wireframes: false,
          pixelRatio: 5,
        },
      })

      // 壁の追加
      Composite.add(engine.world, [
        Bodies.rectangle(width / 2, height + 250, width, 500, {
          isStatic: true,
          label: '_noMap',
          render: {
            fillStyle: 'transparent',
          },
        }),
        Bodies.rectangle(-50, height / 2, 100, height * 20, {
          isStatic: true,
          label: '_noMap',
          render: {
            fillStyle: 'transparent',
          },
        }),
        Bodies.rectangle(width + 50, height / 2, 100, height * 20, {
          isStatic: true,
          label: '_noMap',
          render: {
            fillStyle: 'transparent',
          },
        }),
      ])

      matterHolder
        .querySelectorAll('[data-object]')
        .forEach((object: HTMLImageElement) => {
          if (object.getAttribute('data-circle')) {
            addObjectCircle(object)
          } else {
            addObject(object)
          }
        })

      function addObjectCircle(object: HTMLImageElement) {
        const objWidth = object.clientWidth
        const objHeight = object.clientHeight
        let rect: Body

        rect = Bodies.circle(
          Math.random() * (width - 200) + 100,
          -300,
          objWidth / 2,
          {
            label: object.getAttribute('data-object'),
            // density:
            //   object.getAttribute('data-object') === 'obj2' ? 0.01 : 0.02, // 質量
            // frictionAir: 0.01, // 空気抵抗
            restitution: 0.75, // 跳ね返り
            friction: 0.01, // 摩擦
            render: {
              sprite: {
                texture: `${object.getAttribute('src')}`,
                xScale: objWidth / object.naturalWidth,
                yScale: objWidth / object.naturalWidth,
              },
            },
          }
        )

        Composite.add(engine.world, rect)
      }

      function addObject(object: HTMLImageElement) {
        const objWidth = object.clientWidth
        const objHeight = object.clientHeight
        let rect: Body

        rect = Bodies.rectangle(
          Math.random() * (width - 200) + 100,
          -300,
          objWidth,
          objHeight,
          {
            label: object.getAttribute('data-object'),
            // density: 2, // 質量
            // frictionAir: 0.01, // 空気抵抗
            restitution: 0.75, // 跳ね返り
            friction: 0.01, // 摩擦
            render: {
              sprite: {
                texture: `${object.getAttribute('src')}`,
                xScale: objWidth / object.naturalWidth,
                yScale: objHeight / object.naturalHeight,
              },
            },
          }
        )

        Composite.add(engine.world, rect)
      }

      if (window.innerWidth >= 768) {
        const mouse = Mouse.create(render.canvas)
        const mouseConstraint = MouseConstraint.create(engine, {
          mouse: mouse,
          constraint: {
            render: {
              visible: false,
            },
          },
        })
        render.mouse = mouse
        Composite.add(engine.world, mouseConstraint)
      }

      Render.run(render)
      const runner = Runner.create()
      Runner.run(runner, engine)

      matterHolder.classList.add('is-active')

      // スクロールで物体を動かす
      scrollTimer = setTimeout(() => {
        const allBodies = Composite.allBodies(engine.world)
        const scrollY = window.scrollY

        const scrollEvent = () => {
          const scrollDelta = window.scrollY - scrollY
          // console.log(scrollDelta)

          allBodies.forEach((body: Matter.Body) => {
            if (body.label === '_noMap') {
              return
            }

            if (Math.floor(scrollDelta) % 5 === 0) {
              Body.applyForce(
                body,
                { x: body.position.x, y: body.position.y },
                {
                  x:
                    (Math.floor(Math.random() * 2) === 0 ? -1 : 1) *
                    Math.random() *
                    0.01,
                  y: -0.047,
                }
              )
            }
          })
        }

        if (typeof window === 'object') {
          // 画面内だけでスクロールイベント発生
          if (window.IntersectionObserver) {
            const observer = new IntersectionObserver((entries) => {
              for (const e of entries) {
                if (e.isIntersecting) {
                  window.addEventListener('scroll', scrollEvent)
                } else {
                  window.removeEventListener('scroll', scrollEvent)
                }
              }
            })
            const observerEl = document.querySelector('#observer')
            observer?.observe(observerEl)
          }
        }
      }, 2000)

      // リサイズが終了して1秒後に削除
      let timeoutId: NodeJS.Timeout
      let windowW = window.innerWidth
      window.addEventListener('resize', () => {
        if (
          window.innerWidth >= 768 ||
          (window.innerWidth <= 767 &&
            Math.abs(windowW - window.innerWidth) > 5)
        ) {
          clearTimeout(timeoutId)

          timeoutId = setTimeout(function () {
            const allBodies = Composite.allBodies(engine.world)
            allBodies.forEach((body) => {
              Composite.remove(engine.world, body)
            })
            Engine.clear(engine)
            Runner.stop(runner)
          }, 1000)
        }
      })
    }

    const matterHolder: HTMLElement =
      document.querySelector('[data-html-matter]')

    if (typeof window === 'object') {
      // 画面内で物理エンジンを実行
      if (window.IntersectionObserver) {
        const observer = new IntersectionObserver((entries) => {
          for (const e of entries) {
            if (e.isIntersecting) {
              if (!matterHolder.classList.contains('is-active')) {
                initMatter(matterHolder)
              }
            } else {
              // console.log('out');
            }
          }
        })

        const observerEl = document.querySelector('#observer')
        observer.observe(observerEl)
      }
    }

    // リサイズが終了して1秒後に再生成
    // safariは過剰にresizeイベントが発火されるのでそれ対策
    let timeoutId: NodeJS.Timeout
    let windowW = window.innerWidth
    const initMatterResize = () => {
      if (
        window.innerWidth >= 768 ||
        (window.innerWidth <= 767 && Math.abs(windowW - window.innerWidth) > 5)
      ) {
        clearTimeout(timeoutId)

        timeoutId = setTimeout(function () {
          initMatter(matterHolder)
        }, 1000)
      }
    }

    window.addEventListener('resize', initMatterResize)

    return () => {
      Engine.clear
      Runner.stop
      window.removeEventListener('resize', initMatterResize)
      clearTimeout(scrollTimer)
    }
  }, [])

  return (
    <>
      <div className={`${styles.matter}`} data-html-matter>
        <div className={`${styles.observer}`} id='observer'></div>
        <img
          className={`${styles.logo}`}
          src='/images/top/matter/logo.svg'
          data-object='obj1'
          data-circle
          alt=''
        />
        <img
          className={`${styles.team}`}
          src='/images/top/matter/team.svg'
          data-object='obj2'
          data-circle
          alt=''
        />
        <img
          className={`${styles.yoku}`}
          src='/images/top/matter/yoku.svg'
          data-object='obj3'
          alt=''
        />
        <img
          className={`${styles.build}`}
          src='/images/top/matter/build.svg'
          data-object='obj4'
          alt=''
        />
        <img
          className={`${styles.member}`}
          src='/images/top/matter/member.svg'
          data-object='obj5'
          alt=''
        />
        <img
          className={`${styles.product}`}
          src='/images/top/matter/product.svg'
          data-object='obj6'
          alt=''
        />
        <img
          className={`${styles.service}`}
          src='/images/top/matter/service.svg'
          data-object='obj7'
          alt=''
        />
      </div>
    </>
  )
})

MatterArea.displayName = 'MatterArea'


MatterArea.module.scss

@use '../variable' as v;
@use '../mixin.scss' as r;

.observer {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.matter {
  width: 676rem;
  height: 500rem;
  background-color: #fff;
  border-radius: 20px;
  overflow: hidden;
  position: relative;

  canvas {
    position: absolute;
    z-index: 1;
    left: 50%;
    bottom: 0;
    width: 100%;
    height: 100%;
    transform: translateX(-50%);
    background-color: transparent;
  }

  [data-object] {
    position: absolute;
    border-radius: 5px;
    background: rgba(0, 0, 0, 0.05);
    pointer-events: none;
    opacity: 0;

    * {
      pointer-events: all;
    }
  }

  [data-object='obj1'],
  [data-object='obj2'] {
    border-radius: 50%;
  }

  @include r.mq(mdless) {
    border-radius: 0;
    width: 390rem;
    height: 500rem;
    margin-left: -28rem;
    margin-top: -156rem;
  }
}


少し長いですが、全体像はこんな感じです。cssはCSS Modulesであててます。

読めば大体わかるはずなので、肝心なところを取り上げて説明します。

Reactで動かすためにすること

useEffect内ではなくuseLayoutEffect内で実行する

Matter.jsは、ブラウザのDOMのレンダリングサイクルのタイミングに合わせて、物理エンジンのアニメーションを実行する必要があります。


Reactでは、useEffectはレンダリング後に実行されるため、物理エンジンのアニメーションが描画される前に実行されてしまう可能性があります。これにより、描画される前に計算された位置に物理オブジェクトが表示されたり、描画が一時停止したりするなどの問題が発生する可能性があります。


一方、useLayoutEffectは、ReactがDOMのレンダリング前に呼び出す関数であり、物理エンジンのアニメーションを描画する前に実行されます。これにより、物理エンジンが正確な位置でアニメーションされることが保証されます。

クリーンアップ関数を書く

return () => {
  Engine.clear
  Runner.stop
  window.removeEventListener('resize', initMatterResize)
  clearTimeout(scrollTimer)
}


上記のクリーンアップ関数内で物理エンジンをクリアしたり、イベントリスナー、タイマーを除去してます。

アンマウントされる際にリソースの解放を行う必要があるのでこれは大事な処理です。

画面内でのみ物理エンジンを実行する

パフォーマンス改善のために、画面内でのみ物理エンジンを実行するようにしました。

if (typeof window === 'object') {
  // 画面内で物理エンジンを実行
  if (window.IntersectionObserver) {
    const observer = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (e.isIntersecting) {
          if (!matterHolder.classList.contains('is-active')) {
            initMatter(matterHolder)
          }
        } else {
          // console.log('out');
        }
      }
    })

    const observerEl = document.querySelector('#observer')
    observer.observe(observerEl)
  }
}


Intersection Observer APIを使います。

#observerの要素が画面に入ってきてるかどうかで判定しています。

[data-html-matter]の要素に.is-activeがついてない時に、initMatterを実行してます。

initMatterの関数内で.is-activeが付くので、2回目以降は実行されないようにしています。

スクロールで要素が動くようにする

const allBodies = Composite.allBodies(engine.world)
const scrollY = window.scrollY

const scrollEvent = () => {
  const scrollDelta = window.scrollY - scrollY

  allBodies.forEach((body: Matter.Body) => {
    if (body.label === '_noMap') {
      return
    }

    if (Math.floor(scrollDelta) % 5 === 0) {
      Body.applyForce(
        body,
        { x: body.position.x, y: body.position.y },
        {
          x:
            (Math.floor(Math.random() * 2) === 0 ? -1 : 1) *
            Math.random() *
            0.01,
          y: -0.047,
        }
      )
    }
  })
}


allBodies.forEachでWorld内の要素をループで回します。

scrollDeltaでスクロール幅を計算してます。スクロールのスピードはブラウザで確認しながら調整しました。

Body.applyForceで要素を動かすことができます。y軸は固定ですが、x軸はランダムに動くようにしました。

Math.floor(scrollDelta) % 5 === 0ちなみにこれ←を書かないと要素が無限に上に消えていきます。(ここは調整)


画質が悪い場合にすること

const render = Render.create({
      element: matterHolder,
      engine,
      options: {
        width: width,
        height: height,
        background: 'transparent',
        wireframes: false,
        pixelRatio: 5,
      },
})


画質が悪い場合、pixelRatio: 5を指定すれば治ります。解像度の設定ですね。

高く設定しすぎると端末が対応していなくてmatter.jsの表示自体されなくなるので注意です。

まとめ

時間がなくてかなり駆け出しで説明しましたが、分からないところや聞きたいこと、間違いなどがあればTwitterなどで何なりと聞いてください。

この記事をシェアする