React+TypeScript+R3Fのtutorial応用編5(glTFで3Dアニメーション(AnimationMixer))

2024/01/05に公開

Abstract

今回の参考はここ(Animation Controller)。このソースをTypeScriptで実装しなおす。
アニメーションデータは複数読込むことが出来て、それを管理してくれるのが、AnimationMixer。ちょっと便利。

ポイント

  • *.glbの3Dモデル読込みと表示
  • *.glbのアニメーションデータ読込みと実行
  • アニメーションをキーボード操作で切替え

結論

今回の成果物はココ↓
https://github.com/aaaa1597/react-r3f-advanced005

前提

手順

1.プロジェクト生成 -> VSCodeで開く

めんどいから、このスケルトンコードから始める。react-r3f-base-onebox
で、下記コマンドでフォルダ名とか整備する。

フォルダリネームとか
$ D:
$ cd .\Products\React.js\            # ご自身の適当なフォルダで。
$ rd /q /s D:\Products\React.js\react-r3f-advanced005
$ git clone https://github.com/aaaa1597/react-r3f-base-onebox.git
$ rd /q /s react-r3f-base-onebox/.git
$ ren react-r3f-base-onebox react-r3f-advanced005
$ cd react-r3f-advanced005

準備

コマンドプロンプト
$ npm install --save three
$ npm install --save @types/three
$ npm install --save @react-three/fiber
$ npm install --save @react-three/drei

準備2

  • ここからDLしたモデル一式をプロジェクトの"react-r3f-advanced005/public/assets"配下にコピー。

.eslintrc.jsを修正

エラーになるので、ignoreに追加

.eslintrc.js
     "rules": {
-        "react/no-unknown-property": ['error', { ignore: ['css', "args", 'wireframe', 'rotation-x', 'rotation'] }],
+        "react/no-unknown-property": ['error', { ignore: ['css', "args", 'position', 'angle', 'penumbra', 'castShadow',
+                        "shadow-mapSize-width", "shadow-mapSize-height", 'intensity', 'dispose', 'rotation', 'object',
+                        'frustumCulled', 'geometry', 'material', 'skeleton'] }],     }

App.tsx

まず全体。

App.tsx
-import React, {useRef} from 'react';
+import React, { Suspense} from 'react';
import './App.css';
-import { Canvas, useFrame, MeshProps  } from '@react-three/fiber'
-import * as THREE from 'three'
-import { OrbitControls, Environment } from '@react-three/drei'
+import { Canvas } from '@react-three/fiber'
+import { Stats, OrbitControls, Environment } from '@react-three/drei'
+import Eve from './Eve'

-const Box = (props: MeshProps) => {
-  const ref = useRef<THREE.Mesh>(null!)
-
-  useFrame((_, delta) => {
-    if( !ref.current) return
-    ref.current.rotation.x += 1 * delta
-    ref.current.rotation.y += 0.5 * delta
-  })
-
-  return (
-    <mesh {...props} ref={ref}>
-      <boxGeometry />
-      <meshNormalMaterial />
-    </mesh>
-  )
-}
 
+const Loader = () => {
+  return <div className="loader"></div>
+}

const App = () => {
  return (
-    <div style={{ width: "100vw", height: "75vh" }}>
-      <Canvas camera={{ position: [3, 1, 2] }}>
-        <Box position={[1, 1, 1]} name="A" />
-        <Environment preset="forest" background />
-        <OrbitControls />
-        <axesHelper args={[5]} />
-        <gridHelper />
-      </Canvas>
-    </div>
+    <>
+      <Suspense fallback={<Loader />}>
+        <div style={{ width: "100vw", height: "75vh" }}>
+          <Canvas camera={{ position: [0, 1, 2] }}>
+            <spotLight position={[2.5, 5, 5]} angle={Math.PI / 3} penumbra={0.5} castShadow shadow-mapSize-height={2048} shadow-mapSize-width={2048} intensity={Math.PI * 50} />
+            <spotLight position={[-2.5, 5, 5]} angle={Math.PI / 3} penumbra={0.5} castShadow shadow-mapSize-height={2048} shadow-mapSize-width={2048} intensity={Math.PI * 50} />
+            <Environment preset="forest" background />
+            <Eve />
+            <OrbitControls />
+            <axesHelper args={[5]} />
+            <gridHelper />
+            <Stats />
+          </Canvas>
+        </div>
+      </Suspense>
+      <div id="instructions">                              // ①後勝ちになる。
+        <kbd>W</kbd> to walk<br />             // ①後勝ちになる。
+        <kbd>W</kbd> & <kbd>⇧ Shift</kbd> to run.<br />    // ①後勝ちになる。
+        <kbd>Space</kbd> to jump<br />                     // ①後勝ちになる。
+        <kbd>Q</kbd> to fancy pose<br /><br />             // ①後勝ちになる。
+          Model from{" "}                                  // ①後勝ちになる。
+          <a href="https://www.mixamo.com" target="_blank" rel="nofollow noreferrer">// ①後勝ちになる。
+            Mixamo
+          </a>                                             // ①後勝ちになる。
q+      </div>                                              // ①後勝ちになる。
+    </>
   );
}

export default App;

Eve.tsx

次はモデル描画部分。全部新規追加。

Eve.tsx
import React, { useRef, useMemo, useState, useEffect } from 'react';
import { useFrame } from '@react-three/fiber'
import { useGLTF } from '@react-three/drei'
import { AnimationAction, AnimationMixer, SkinnedMesh } from 'three'

type jsonkeymap = { [key: string]: boolean } // ②json形式の定義方法
const useKeyboard = () => {
    const keyMap = useRef<jsonkeymap>({})    // ③json形式の初期化は{}
  
    useEffect(() => {
      const onDocumentKey = (e: KeyboardEvent) => {
        keyMap.current[e.code] = e.type === 'keydown'
      }
      document.addEventListener('keydown', onDocumentKey)
      document.addEventListener('keyup', onDocumentKey)
      return () => {
        document.removeEventListener('keydown', onDocumentKey)
        document.removeEventListener('keyup', onDocumentKey)
      }
    })
  
    return keyMap.current
}
  
type jsoactions = { [key: string]: AnimationAction }
const Eve = () => {
    const ref = useRef<THREE.Group>(null!)
    const { nodes, materials } = useGLTF('./assets/eve.glb')
    const idleAnimation   = useGLTF('./assets/eve@idle.glb').animations
    const walkAnimation   = useGLTF('./assets/eve@walking.glb').animations
    const runningAnimation= useGLTF('./assets/eve@running.glb').animations
    const jumpAnimation   = useGLTF('./assets/eve@jump.glb').animations
    const poseAnimation   = useGLTF('./assets/eve@pose.glb').animations
    const actions = useRef<jsoactions>({})           // ④useMemoだと実装が分からん
    const mixer:AnimationMixer = useMemo<AnimationMixer>(() => new AnimationMixer(null!), [])
    const keyboard = useKeyboard()
    const [actionName, setActionName] = useState<string>(null!)// ⑤useState<AnimationAction>だとsetAction()呼び出してもundefinedにしかならない。
    const [wait, setWait] = useState<boolean>(false)
    let actionAssigned = false
  
    useEffect(() => {
        actions.current['idle']   = mixer.clipAction(idleAnimation   [0], ref.current)
        actions.current['walk']   = mixer.clipAction(walkAnimation   [0], ref.current)
        actions.current['running']= mixer.clipAction(runningAnimation[0], ref.current)
        actions.current['jump']   = mixer.clipAction(jumpAnimation   [0], ref.current)
        actions.current['pose']   = mixer.clipAction(poseAnimation   [0], ref.current)
    
        actions.current['idle'].play()
        setActionName('idle')
      }, [])

      useEffect(() => {
        const act: AnimationAction = actions.current[actionName]
        act?.reset().fadeIn(0.1).play()
        return () => {
          act?.fadeOut(0.1)
        }
      }, [actionName])
    
      useFrame((_, delta) => {
        if (!wait) {
          actionAssigned = false
    
          if (keyboard['KeyW'] && keyboard['ShiftLeft']) {
            setActionName('running')
            actionAssigned = true
          }
          else if (keyboard['KeyW']) {
            setActionName('walk')
            actionAssigned = true
          }
          
          if (keyboard['Space']) {
            setActionName('jump')
            actionAssigned = true
            setWait(true) // wait for jump to finish
            setTimeout(() => setWait(false), 1000)
          }
          
          if (keyboard['KeyQ']) {
            setActionName('pose')
            actionAssigned = true
          }
    
          !actionAssigned && setActionName('idle')
        }
    
        mixer.update(delta)
      })

      return (
        <group ref={ref} dispose={null}>
          <group name="Scene">
            <group name="Armature" rotation={[Math.PI / 2, 0, 0]} scale={0.01}>
              <primitive object={nodes.mixamorigHips} />
              <skinnedMesh castShadow name="Mesh" frustumCulled={false}
                           geometry={(nodes.Mesh as SkinnedMesh).geometry}
                           material={materials.SpacePirate_M} skeleton={(nodes.Mesh as SkinnedMesh).skeleton} />
            </group>
          </group>
        </group>
      )
}
export default Eve;

で、実行。


出来た!!

ハマったところ

①先に定義した<div/>タグ子要素の文字列群が表示されなかった。

HTMLは、後勝ちらしくって、後ろに定義したら表示されるようになった。

②json形式の定義方法

json形式の型定義が分からんくって、地味に時間食った。

③json形式の初期化は{}

TypeScriptでずっとエラーが取れず、解決策が分からなかった。

④useMemoだと実装が分からん

参考元のコードだとuseMemo使ってたんだけど、関数でビルドエラーが取れんくって、仕方なくuseRef()に変更した。const actions: jsoactions = {}ってやると、値が保持してくれなかった。

⑤ useState<AnimationAction>だとsetAction()呼び出してもundefinedにしかならない。

なぜか、AnimationAction型のuseStateを定義してもundefinedにしかならず、不明。仕方なくstringに変更した。


React+TypeScript+R3Fのtutorial応用編4(glTFで3Dアニメーション(モデルとモーション別々ファイル読込み))


React+TypeScript+R3Fのtutorial応用編6(FBXモデル表示)

Discussion