【Threejs×Nextjs】DOM座標同期したWebGLをページ遷移でシームレスに動かす
はじめに
この記事ではThreejs(react-three-fiberではない)とNextjsを用いて、DOM座標と同期したThreejsのPlaneをページ遷移でシームレスに動かす方法を解説します。
具体的な動きは、実装のお手伝いをさせていただいたサイト 株式会社Numéro.8 のPCで閲覧したときのWebGL画像のものです。
※ThreejsのPlaneをDOM座標と同期させる方法は 【Three.js】スクロールでぐにゃぐにゃする画像を実装する がとても参考になります
バージョン情報
パッケージ | バージョン |
---|---|
typescript | 4.3.5 |
react | 17.0.2 |
next | 12.0.3 |
recoil | 0.4.1 |
framer-motion | 5.3.1 |
three | 0.136.0 |
概要
大まかな実装の仕組みを紹介します。
-
framer-motion
を用いてDOMの透明度を変更したページ遷移を実装する -
recoil
を用いて遷移時
と遷移先
のURLを記録する -
recoil
を用いて遷移先のDOMがマウントされた時
のURLを記録する - 記録したURLによってPlaneの
遷移前の頂点
と遷移後の頂点
を補間して動かす
解説
実装の仕組みを4段階に分けて解説します。
1. DOMの透明度を変更したページ遷移
まず、WebGLを使用する前にDOMの透明度変更によるフェードイン/アウトのページ遷移を実装します。実装には framer-motion を用います。framer-motionを使うと任意の値(今回はURL)の変動によってマウント時とアンマウント前にアニメーションをさせることができます。フェードイン/アウトするコンテンツをラップするコンポーネントを作成すると便利ですので、下記に例を記載します。
import { ReactNode, useEffect } from 'react'
import { useRouter } from 'next/dist/client/router'
import { AnimatePresence, motion } from 'framer-motion'
export const TransitionWrap: ({
children,
}: {
children: ReactNode
}) => JSX.Element = ({ children }: { children: ReactNode }) => {
const router = useRouter()
return (
<AnimatePresence exitBeforeEnter>
<motion.div
// 初期状態
initial={{ opacity: 0 }}
// マウントされた後に実行される
animate={{
opacity: 1,
transition: {
duration: duration.pageTransition,
},
}}
// アンマウントされる前に実行される
// exitが終わり次第、旧childrenを破棄、新childrenをマウントし、animateを発火する
exit={{
opacity: 0,
transition: {
duration: duration.pageTransition,
// duration: 1.8,
},
}}
// key が変わるたびに exit のアニメーションが実行される
key={router.asPath}
onAnimationComplete={() => {
// ここにアニメーション(animate, exit)完了時に発火するcallbackを指定する
}}
>
{children}
</motion.div>
</AnimatePresence>
)
}
2. 遷移時と遷移先のURLを記録する
Planeを管理するHooks等の内部にあるuseEffectにURLの変更を検知させるために、状態管理ライブラリの recoil を用います。ページの遷移先によってPlaneの動きが変わることを想定して、遷移時と遷移先のURLを記録します。
import { useEffect } from 'react'
import { useRouter } from 'next/dist/client/router'
import { atom, useRecoilValue, useSetRecoilState } from 'recoil'
const oldPathState = atom<string | null>({
key: 'pathHistory/oldPath',
default: null,
})
const currentPathState = atom<string | null>({
key: 'pathHistory/currentPath',
default: null,
})
export const useGlobalPathHistory = {
get(): { oldPath: string | null; currentPath: string | null } {
const oldPath = useRecoilValue(oldPathState)
const currentPath = useRecoilValue(currentPathState)
return { oldPath, currentPath }
},
update(): void {
const setOldPath = useSetRecoilState(oldPathState)
const setCurrentPath = useSetRecoilState(currentPathState)
const router = useRouter()
const oldPath = useRecoilValue(oldPathState)
useEffect(() => {
setCurrentPath(router.asPath)
}, [])
useEffect(() => {
const handleRouteChange = (url: string, { shallow }) => {
setOldPath(router.asPath) // 遷移前のURLを格納する
setCurrentPath(url) // 遷移後のURLを格納する
}
// beforeHistoryChange はURLが変化する前に発火する
router.events.on('beforeHistoryChange', handleRouteChange)
return () => {
router.events.off('beforeHistoryChange', handleRouteChange)
}
}, [router.asPath])
},
}
3. 遷移先のDOMがマウントされた時のURLを記録する
こちらも2と同様にuseEffectに遷移後のDOMがマウントされた時を検知させるために、recoilを用います。また、マウントをrecoilに伝えるHooksも作成してPlaneと同期させるDOMを内包したコンポーネント内で発火されるようにします。
import { atom, useRecoilValue, useSetRecoilState } from 'recoil'
const mountPathState = atom<string | null>({
key: 'Path/mountPath',
default: null,
})
export const useGlobalMountPath = {
get() {
const getState = useRecoilValue(mountPathState)
return getState
},
set() {
const setState = useSetRecoilState(mountPathState)
const setMountPath = (path: string | null) => {
setState(path)
}
return { setMountPath }
},
}
import { useCallback, useEffect, useRef, MutableRefObject } from 'react'
import { useGlobalPathHistory } from 'store/pathHistory'
import { useGlobalMountPath } from 'store/mountPath'
const useIsMount = (
option_ref_promise?: MutableRefObject<
(value: void | PromiseLike<void>) => void
>,
option_ref_reject?: MutableRefObject<(reason?: any) => void>,
option_callback?: Function,
) => {
const isMounted = useRef(false)
const { currentPath, oldPath } = useGlobalPathHistory.get()
const { setMountPath } = useGlobalMountPath.set()
const onMount = () => {
setMountPath(currentPath)
isMounted.current = true
}
useEffect(() => {
if (option_ref_promise) {
new Promise<void>((resolve, reject) => {
option_ref_promise.current = resolve
option_ref_reject.current = reject
})
.then(() => {
onMount()
if (option_callback) {
option_callback()
}
})
} else {
onMount()
}
return () => {
if (option_ref_reject) {
option_ref_reject.current()
}
isMounted.current = false
}
}, [])
return useCallback(() => isMounted.current, [])
}
export { useIsMount }
4. Planeの遷移前の頂点と遷移後の頂点を補間して動かす
前述の2と3にあるrecoilで管理している値を、Planeを管理するHooks等のuseEffectの第二引数に指定することでURLが変更された時
と遷移先のDOMがマウントされた時
の二つを検知することができます。そのuseEffect内にそれぞれPlaneに関する任意の処理をすることでシームレスに動かす実装が実現します。
useEffect(() => {
// URLが変更された時の処理
}, [currentPath])
useEffect(() => {
// 遷移先のDOMがマウントされた時の処理
}, [mountPath])
ここで、リンクを押してページ遷移する時の流れを確認します。
- 遷移前と遷移後のURLを記録した後にURLが遷移後のものになる。
- 遷移前のDOMがフェードアウトする。Planeは遷移前のDOMとの同期を維持している。
- 遷移前のDOMがアンマウントする。Planeは遷移前のDOMとの同期を解除する。
- 遷移後のDOMがマウントする。Planeは遷移前のDOMの座標で停止している。
- 遷移後のDOMがフェードインする。Planeは遷移後のDOMの座標に向かって動く。
- 遷移後のDOMのフェードインが完了する。Planeは遷移後のDOMとの同期をする。
4のタイミングで遷移後のDOMの座標を取得して、遷移後の頂点情報に反映させます。
遷移前の頂点と遷移後の頂点を補間して動かすことについては単純で、GLSLでmix関数を用います。バーテックスシェーダーで遷移前の頂点と遷移後の頂点を任意の進捗率で補間します。
vec4 mixPosition = mix(currentPosition, targetPosition, progress);
gl_Position = projectionMatrix * viewMatrix * mixPosition;
ここで注意することは、NextLink
を使用している場合はリンクを押した時にページのトップに自動でスクロールされてしまい、Planeの位置によっては遷移前の座標が画面外になってしまうことがあります。そのため、NextLinkのスクロールオプションをfalse
に設定しておく必要があります。
<NextLink scroll={false}>
ただし、ブラウザの履歴を操作(戻る/進む)した時にはスクロールオプションがfalseの場合でも自動でスクロールされることがあります。その対応策として_app
ディレクトリにあるコンポーネントに、ページ遷移する前のスクロールオプションを指定します。
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual'
}
router.beforePopState((state) => {
state.options.scroll = false
return true
})
}, [router])
(こちらはイシヤマさんに教えていただきました…ありがとうございます!)
おわりに
実際の案件では様々な仕様に対応する必要があると思うので、基礎となる流用できる範囲でのコードのみを記載いたしました。今回初めてReactを触ったということもあり、もっと良い実装方法があるかもしれません。誤認識や改善点等ありましたらご教示いただけますと幸いです。
参考文献
Discussion