さいと
2023.03.15
4617
こんにちは。斎音です。
YOKUのHPを制作するときに、matter.jsを使って要素が重力を持った物体のように動き、またスクロールに応じて動くようなアニメーションを制作しました。今回はそれの実装方法を紹介します。Next.jsで実装したのでそのままのソースコードを載せます。
今はディレクションの都合で非表示にしてあります。
こちらが動画です。
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であててます。
読めば大体わかるはずなので、肝心なところを取り上げて説明します。
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などで何なりと聞いてください。
995
タム
2024.04.15
16
金子
2024.01.04