広報ウェブサイトの構成について
使った技術とかいろいろ
2024.10.01
- ・Next.js + TailwindCSS + supabase で構成
- ・DOMアニメーションはframer-motionを使用
- ・3D描写はthree.js、2D描写はpixi.jsを使用
- ・Cloudflare Pagesにデプロイ
- ・名刺代わりのウェブサイトなので実験というかやってみたかったことをやってみた
背景でシャドーボクシングしてる女の子
- ・VRoid Studioで適当に作成した3Dモデル(.vrm)のボーンを削除するなどして軽量化
- ・3DモデルはThreeJSのGLTFLoader、モーションデータ(.bvh)はBVHLoaderでロードして適用
- ・3DモデルとモーションデータはZIP圧縮してsupabaseのストレージに配置し、初回アクセス時のみダウンロード・展開してブラウザのIndexedDBに格納、次回アクセスからはIndexedDBからのロードを試みるようにして転送量を削減
- ・ダウンロード状況や読込状況などのプログレスの状態管理はRecoilを使用
- ・モーションデータはアメリカのカーネギーメロン大学様が無償・無制限で公開しているものを使用させていただきました
- ・vrmにbvhを適用するためかなりの工夫(偉大な先人の知恵)が必要でした
- ・初期状態で手がパーなので、グーになるようにポーズデータを追加
- ・ThreeJSのステージにアスキーエフェクトを適用してそれっぽくした(重い)
流れだけなんとなくわかるようにモデル読込部分の一部コードを載せておきます
"use client"; import { useState, useEffect, useRef, useMemo } from "react"; import { useRecoilValue, useRecoilState } from "recoil"; import { useScroll } from "framer-motion"; import { AnimationAction, AnimationClip, AnimationMixer, MathUtils } from "three"; import { GLTF, GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader"; import { BVH, BVHLoader } from "three/examples/jsm/loaders/BVHLoader"; import { useThree, useFrame, PrimitiveProps } from "@react-three/fiber"; import { VRM, VRMUtils } from "@pixiv/three-vrm"; import axios, { AxiosResponse } from "axios"; import JsZip from "jszip"; import { save as saveCache, load as loadCache} from "@/utils/vroid.db"; import { colormodeAtom } from "@/atoms/colormodeAtom"; import { modelLoadProgressAtom, modelLoadStatusIndexAtom } from "@/atoms/modelAtom"; import { modelLoadStatusSelector } from "@/selectors/modelSelector"; import { createClip } from "@/utils/vroid.helper"; import { fist } from "@/poses/fist"; export default function Model({ ...props }: { isCanvasLoaded: boolean; onModelLoaded: () => void; }) { // Props const { isCanvasLoaded, onModelLoaded, ...others } = props; // RecoilValues const colormode = useRecoilValue(colormodeAtom); const modelLoadStatus = useRecoilValue(modelLoadStatusSelector); // RecoilStates const [, setModelLoadStatusIndex] = useRecoilState(modelLoadStatusIndexAtom); const [, setModelLoadProgress] = useRecoilState(modelLoadProgressAtom); // States const [lastTime, setLastTime] = useState<number>(0); const [animationAction, setAnimationAction] = useState<AnimationAction>(); const [vroidZip, setVroidZip] = useState<Blob>(); const [vrmBlob, setVrmBlob] = useState<Blob>(); const [bvhBlob, setBvhBlob] = useState<Blob>(); const [gltf, setGltf] = useState<GLTF>(); const [vrm, setVrm] = useState<VRM>(); const [bvh, setBvh] = useState<BVH>(); const [clip, setClip] = useState<AnimationClip>(); const [mixer, setMixer] = useState<AnimationMixer>(); // Refs const primitiveRef = useRef<PrimitiveProps>(); // Three const { size, viewport } = useThree(); // ScrollY const { scrollYProgress } = useScroll(); // Memos const aspect = useMemo<number>(() => Math.min(size.width / viewport.width, size.height / viewport.height), [size, viewport]); useMemo<void>(() => { if (gltf) { // 不必要なジョイントを削除 VRMUtils.removeUnnecessaryJoints(gltf.scene); VRM.from(gltf) .then(vrm => { // VRM反映 if (vrm && vrm.humanoid) setVrm(() => vrm); }); } }, [gltf]); const vrmUrl = useMemo<string>(() => vrmBlob ? URL.createObjectURL(vrmBlob) : "", [vrmBlob]); const bvhUrl = useMemo<string>(() => bvhBlob ? URL.createObjectURL(bvhBlob) : "", [bvhBlob]); const updateTime = useMemo(() => new Date().getTime() - lastTime, [lastTime]); // Effects useEffect(() => { if (isCanvasLoaded) { // ダウンロード関数 const download = async () => { // n=0|1 const n = Math.floor(Math.random() * 2); // Axios Client await axios({ url: String(n === 0 ? process.env.NEXT_PUBLIC_VROID_ZIP_0_URL : process.env.NEXT_PUBLIC_VROID_ZIP_1_URL), method: "GET", responseType: "blob", onDownloadProgress: progressEvent => setModelLoadProgress({ value: Math.ceil(Math.round((progressEvent.loaded * 100) / Number(progressEvent.total))) }) }) .then((response: AxiosResponse<Blob>) => setVroidZip(() => response.data)) .catch((e) => console.error("DOWNLOAD FILE EXCEPTION OCCURRED")) }; // キャッシュ確認 (async function () { // [1]init setModelLoadStatusIndex(1); // キャッシュ読込 try { // キャッシュ取得 const cache = await loadCache(); // データ取得 const { vrm, bvh } = cache!; // データ読込 setVrmBlob(() => vrm); setBvhBlob(() => bvh); // キャッシュ読込不可 } catch (e) { // ダウンロード実行 return await download(); } }()); } }, [isCanvasLoaded]); useEffect(() => { if (vroidZip) { // [2]unzip setModelLoadStatusIndex(2); setModelLoadProgress({ value: 0 }); // 解凍処理 (async function () { try { // JSZipで解凍 const { files } = await new JsZip().loadAsync(vroidZip); Object.keys(files).forEach(async (filename) => { try { // Found VRM if (filename.endsWith(".vrm")) { const blob = await files[filename].async("blob"); if (blob) setVrmBlob(() => blob); } // Found BVH if (filename.endsWith(".bvh")) { const blob = await files[filename].async("blob"); if (blob) setBvhBlob(() => blob); } } catch (e) { console.error("LOAD FILE EXCEPTION OCCURRED: ", filename); } }); } catch (e) { console.error("UNZIP FILE EXCEPTION OCCURRED"); } }()); } }, [vroidZip]); useEffect(() => { (async function () { if (vrmBlob && bvhBlob) { try { // キャッシュ保存 await saveCache(vrmBlob, bvhBlob); } catch (e) { console.error("SAVE FILE EXCEPTION OCCURRED"); } } }()); }, [vrmBlob, bvhBlob]); useEffect(() => { if (vrmUrl && modelLoadStatus.index < 5) { // [3]vrm setModelLoadStatusIndex(3); setModelLoadProgress({ value: 0}); // GLTF読込 new GLTFLoader().load(vrmUrl, (gltf) => setGltf(gltf), (event: ProgressEvent<EventTarget>) => setModelLoadProgress({ value: Math.ceil(event.loaded / event.total * 100) }), (error: ErrorEvent) => { console.error("LOAD GLTF EXCEPTION OCCURRED: ", error.message); setModelLoadStatusIndex(-1); setModelLoadProgress({ value: 0 }); alert("モデルの読み込みに失敗しました。再試行してください。"); } ); // Revoke URL.revokeObjectURL(vrmUrl); } return () => URL.revokeObjectURL(vrmUrl); }, [vrmUrl]); useEffect(() => { if (bvhUrl && modelLoadStatus.index < 5) { // [4]bvh setModelLoadStatusIndex(4); setModelLoadProgress({ value: 0 }); // BVH読込 new BVHLoader().load(bvhUrl, (bvh) => setBvh(bvh), (event: ProgressEvent<EventTarget>) => setModelLoadProgress({ value: Math.ceil(event.loaded / event.total * 100) }), (error: ErrorEvent) => { console.error("LOAD BVH EXCEPTION OCCURRED: ", error.message); setModelLoadStatusIndex(-1); setModelLoadProgress({ value: 0 }); alert("BVHデータの読み込みに失敗しました。再試行してください。"); } ); // Revoke URL.revokeObjectURL(bvhUrl); } return () => URL.revokeObjectURL(bvhUrl); }, [bvhUrl]); useEffect(() => { if (vrm && vrm.scene && vrm.humanoid && bvh) { // Position調整 vrm.scene.position.x = -2.02; vrm.scene.position.y = 0.00; vrm.scene.position.z = -0.22; vrm.scene.receiveShadow = false; // 拳反映 vrm.humanoid.setPose(fist); // Clip生成 setClip(createClip(vrm, bvh)); // AnimationMixer生成 setMixer(new AnimationMixer(vrm.scene)); } }, [vrm, bvh]); useEffect(() => { if (mixer && clip) { // ModelLoaded onModelLoaded(); const timeoutId = setTimeout(() => { // AnimationAction setAnimationAction(mixer.clipAction(clip).setEffectiveWeight(1.0)); }, 1000 * 1); // CleanUp return () => clearTimeout(timeoutId); } }, [mixer, clip]); useEffect(() => { if (animationAction && !animationAction.isRunning()) { animationAction.play(); } }, [animationAction]); // Frame useFrame(({ camera }) => { if (animationAction && !animationAction.isRunning()) animationAction.play(); if (vrm && mixer) { // AnimationMixer更新 setLastTime(new Date().getTime()); mixer.update(updateTime); if (primitiveRef.current) { primitiveRef.current.position.x = MathUtils.lerp(primitiveRef.current.position.x, -0.35 + 0 / aspect / 10, 0.025); primitiveRef.current.rotation.y = 3.43 + scrollYProgress.get() * 8; } } }); // Render return ( <mesh> { gltf && clip && mixer && <primitive ref={ primitiveRef } object={ gltf.scene } scale={ 1.65 } { ...others } /> } </mesh> ); }
グリッチエフェクト達磨
色相を分解してずらしてサイバーなかんじにするやつです
- ・2D描画に特化したWebGLフレームワークのpixi.jsを使用
- ・GSAPでタイムライン管理
こちらも雰囲気だけわかるように一部コードを載せておきます
// @/components/shared/pixi.daruma.tsx "use client"; import { useState, useEffect, useRef, useCallback, memo } from "react"; import { Texture, IRendererOptions } from "pixi.js"; import { Stage as PixiStage, Sprite as PixiSprite } from "@pixi/react"; import { GlitchFilter } from "@pixi/filter-glitch"; import { RGBSplitFilter } from "@pixi/filter-rgb-split"; import { gsap } from "gsap"; export interface SpriteOptionsProps { alpha: number; position: { x: number; y: number; }; } const DarumaSprite = memo<{ options?: SpriteOptionsProps; }>(function DarumaSprite({ ...props }) { // Path const path = "/assets/fudeji/Daruma_Transparent_Reverse.webp"; // Props const { options } = props; // States const [filters, setFilters] = useState<[RGBSplitFilter, GlitchFilter]>(() => [ new RGBSplitFilter([10, 0], [10, 0], [0, 10]), new GlitchFilter({ slices: 20, offset: 20 }), ]); // Refs const timelineRef = useRef<gsap.core.Timeline | null>(null); // Callbacks const randomIntFromInterval = useCallback((min: number, max: number): number => { return Math.floor(Math.random() * (max - min + 1)) + min; }, []); const init = useCallback(() => { setFilters(() => [ new RGBSplitFilter([0, 0], [0, 0], [0, 0]), new GlitchFilter({slices: 0, offset: 0 }) ]); }, []); const anim = useCallback(() => { if (!filters.length) return; // Cleanup previous timeline if (timelineRef.current) { timelineRef.current.kill(); } // Filters const [rgbFilter, glitchFilter] = filters; // Create new GSAP timeline const tl = gsap.timeline({ delay: randomIntFromInterval(2, 6), onComplete: () => { init(); anim(); } }); // Red tl.to(rgbFilter.red, { duration: 0.2, x: randomIntFromInterval(-15, 15), y: randomIntFromInterval(-15, 15), onUpdate: () => setFilters([...filters]) }) .to(rgbFilter.red, { duration: 0.01, x: 0, y: 0, onUpdate: () => setFilters([...filters]) }) // Blue .to(rgbFilter.blue, { duration: 0.2, x: randomIntFromInterval(-15, 15), y: 0, onUpdate:() => { if (glitchFilter) { glitchFilter.slices = 20; glitchFilter.direction = randomIntFromInterval(-75, 75); setFilters([...filters]); } } }, "-=0.2") .to(rgbFilter.blue, { duration: 0.1, x: randomIntFromInterval(-15, 15), y: randomIntFromInterval(-5, 5), onUpdate: () => { if (glitchFilter) { glitchFilter.slices = 12; glitchFilter.direction = randomIntFromInterval(-75, 75); setFilters([...filters]); } } }) .to(rgbFilter.blue, { duration: 0.01, x: 0, y: 0, onUpdate: () => { if (glitchFilter) { glitchFilter.slices = 0; glitchFilter.direction = 0; setFilters([...filters]); } } }) // Green .to(rgbFilter.green, { duration: 0.2, x: randomIntFromInterval(-15, 15), y: 0, onUpdate: () => setFilters([...filters]) }, "-=0.2") .to(rgbFilter.green, { duration: 0.1, x: randomIntFromInterval(-20, 20), y: randomIntFromInterval(-15, 15), onUpdate: () => setFilters([...filters]) }) .to(rgbFilter.green, { duration: 0.01, x: 0, y: 0, onUpdate: () => setFilters([...filters]) }) .timeScale(1.2); timelineRef.current = tl; }, [filters, init, randomIntFromInterval]); // Effects useEffect(() => { init(); anim(); // Cleanup on unmount return () => { if (timelineRef.current) { timelineRef.current.kill(); } setFilters(() => [ new RGBSplitFilter([0, 0], [0, 0], [0, 0]), new GlitchFilter({ slices: 0, offset: 0 }), ]); }; }, []); // Render return ( <PixiSprite texture={ Texture.from(path) } filters={ filters! } { ...options } /> ); }); export const Stage = ({ ...props }: { stageOptions: IRendererOptions; spriteOptions?: SpriteOptionsProps; }) => { // Props const { stageOptions, spriteOptions } = props; // Render return ( <PixiStage options={ stageOptions }> <DarumaSprite options={ spriteOptions }/> </PixiStage> ); };
お問い合わせフォーム
- ・フォーム管理にreact-hook-formを使用
- ・バリデーションはzodを使用
- ・そのうちreact-hook-formからConformに載せ替え予定
- ・お問い合わせががあるとSlackに通知がくるようにした
- ・Resendを使用してメールでの自動通知機能を作成したもののCloudflare Pages の無料プランの1MBバンドルサイズ制限にひっかかったため保留中(※毎月500円くらい課金すれば解決する)
まとめ
Next.jsはいいぞ