React Nativeで3Dキャラクターを動かしてみよう

<h1>（１）はじめに</h1>

<p>普段はソーシャルワーカーとして障がいをお持ちの方の支援をしています。ReactやReact Nativeでアプリ作成を楽しんでおり、最近は3D表示に関心がありThree.jsなどを使っています。前回は<a href="https://qiita.com/syeha/items/29458e911a0c9214b306">React Nativeで3D表示してみようの続き
</a>
で三人称視点のコードを紹介しました。Advent Calendarに合わせてバージョンアップさせたのでまとめてみました。</p>



<h1>（２）3Dキャラクターを動かしてみよう</h1>
![20211215_010511.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635004/e479b1d4-44e6-000e-2007-3e4aaa3401d7.gif)
<p>前回までは四角いオブジェクトを表示していましたが、今回はキャラクターを表示してみます。また、バーチャルスティックを円形に改良しましたので、そちらもコードを書いてみたいと思います。</p>

<p>参考にしたサイト</a><br>
<a href="https://threejs.org/">Three.js公式</a><br>
<a href="https://docs.expo.dev/versions/latest/sdk/gl-view/">Expo公式 GLView</a><br>
<a href="https://www.mixamo.com/">Mixamo公式</a><br><br>

@nemutasさんの記事<br>
<a href="https://qiita.com/nemutas/items/6202b3f8458376ab79b6">【React】react-three-fibarで3D表現をする（Mixamoを使ったアニメーションモデル）
</a><br>



<h1>（３）使った技術</h1>

<p>
・react-native<br>
・expo 42.0.1<br>
・expo-gl 10.4.2<br>
・expo-three 6.0.1<br>
・gsap 3.6.0<br>
・three 0.132.0　←　今回のコードはバージョンによってはエラーになります<br> 
・base-64 1.0.0<br>
・expo-asset 8.3.3

<h1>（４）バーチャルスティック</h1>
<p>まずはバーチャルスティックについて、前回は四角形バックが四角形だったので可動範囲の座標が決まっていたのですが、今回は円形のため可動範囲は計算が必要になります。</p>

```ts
//Position.js
import React, { useState } from "react";
import { View, Text } from "react-native";

export default function Position(props) {
// onMoveは画面にタッチして指を動かした時に発火するイベント App.jsに渡す
// onEndは画面から指を話した時に発火するイベント App.jsに渡す
  const { onMove, onEnd } = props; 

  const largeRadius = 90; // 大きい円の半径
  const smallRadius = largeRadius / 3; // 小さい円の半径
  //円形バーチャルスティックの中心座標を設定
  const [x, setX] = useState(largeRadius - smallRadius); // 大きい円の半径 - 小さい円の半径の差 x
  const [y, setY] = useState(largeRadius - smallRadius); // 大きい円の半径 - 小さい円の半径の差 y

  const handleTouchMove = (e) => {
    const touchX = e.nativeEvent.locationX; // 画面をタッチしたときのx座標
    const touchY = e.nativeEvent.locationY; // 画面をタッチしたときのy座標
    // タッチした座標から小さい円の半径を差し引いた円の中心の座標を設定
    let coordinates = {
      x: touchX - smallRadius,
      y: touchY - smallRadius,
    };
    // atan2でラジアンを算出する
    const radian = Math.atan2(y, x);
    // 大きい円と小さい円の中心が接している座標を算出
    // ラジアンからコサインを算出し、大きい円の半径と乗算し座標を算出する
    let limitX = largeRadius - smallRadius + largeRadius * Math.cos(radian);
    let limitY = largeRadius - smallRadius + largeRadius * Math.sin(radian);
    // タッチした座標と大きい円の座標の小さい値をx座標、y座標にセットする
    setX(Math.min(coordinates.x, limitX));
    setY(Math.min(coordinates.y, limitY));
    onMove({ x: x, y: y });
  };
  // 指を話した時に実行される関数、座標を中心（初期座標）に戻しonEndイベントを渡す
  const handleTouchEnd = () => {
    setX(largeRadius - smallRadius);
    setY(largeRadius - smallRadius);
    onEnd();
  };

  return (
    <>
      <View>
        <Text>
          x:{Math.trunc(x - 60)}, y:{Math.trunc(y - 60)}
        </Text>
        <View
          onTouchMove={handleTouchMove}
          onTouchEnd={handleTouchEnd}
          style={{
            width: 2 * largeRadius, // 半径 * 2
            height: 2 * largeRadius,
            borderRadius: largeRadius, // 円形にする
            backgroundColor: "black",
          }}
        >
          <View
            pointerEvents="none"
            style={{
              height: 2 * smallRadius, // 半径 * 2
              width: 2 * smallRadius,
              borderRadius: smallRadius, // 円形にする
              backgroundColor: "blue",
              position: "absolute",
              transform: [{ translateX: x }, { translateY: y }],
            }}
          />
        </View>
      </View>
    </>
  );
}
```
![560px-Atan2definition.svg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635004/23463f3a-abcb-9068-90e4-6ec078543bfd.png)

const radian = Math.atan2(y, x);
ラジアン（角度の単位）はMath関数で算出することができます。

![472px-TrigFunctionDiagram.svg.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635004/14bb955f-b8ef-ebe3-5c50-ee394c557bc4.png)

let limitX = largeRadius - smallRadius + largeRadius * Math.cos(radian)
半径にコサインを乗算（largeRadius * Math.cos(radian)）し
原点（largeRadius - smallRadius）を加算することで、そのラジアンにおけるx座標が算出されます。

setX(Math.min(coordinates.x, limitX));
Math.minで小さい値を返します。y座標も同様に算出することで円内の可動範囲を制限することができます。

小さい円の座標を飛ぶときがありますが、おおむね期待通り動いてくれます。

<h1>（５）3Dモデル表示</h1>
<p>Mixamoからデータを作成しfbxファイルをBlenderに読み込ませてglbファイルで書き出すところは、@nemutasさんの記事を参照いただければと思います。metro.config.jsを作成し、"glb", "gltf" を読み込めるように追記してください。</p>
```ts
//metro.config.js
module.exports = {
  resolver: {
    sourceExts: ["js", "jsx", "json", "ts", "tsx", "cjs"],
    assetExts: ["db", "mp3", "ttf", "obj", "png", "jpg", "glb", "gltf"],
  },
};
```
React Nativeでglbファイルを表示して動かせるコードになります。できる限りコメントしました。

```ts
//App.js
import React, { useState } from "react";
import { View } from "react-native";
import { GLView } from "expo-gl";
import { Renderer } from "expo-three";
import { TweenMax } from "gsap";
import {
  PointLight,
  GridHelper,
  PerspectiveCamera,
  Scene,
  AnimationMixer, // アニメーションのため追加
  Clock, // アニメーションのため追加
} from "three";
import Positon from "./Position"; // バーチャルスティックのjs
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
import { Asset } from "expo-asset"; // ファイル読み込みのため追加

export default function App() {
  const [cameras, setCameras] = useState(null);
  const [models, setModels] = useState(null); // 3Dモデルをセットする変数
  const [walk, setWalk] = useState(true); // アニメーションをセットする変数

  // TweenMax.to(何が, 何秒で, { z軸に distance 分移動 })
  const move = (props) => {
    walk.paused = false;　// .paused = アニメーションを一時停止解除
    walk.play(); // アニメーションである変数walkを再生
    TweenMax.to(models.position, 0.1, {
      z: models.position.z + props.y,
      x: models.position.x + props.x,
    });
    TweenMax.to(cameras.position, 0.1, {
      z: cameras.position.z + props.y,
      x: cameras.position.x + props.x,
    });
    // y座標を反転させ radian に +1.5 加算し前後左右にいい感じで振り向かせる
    models.rotation.y = Math.atan2(-props.y, props.x) + 1.5;
  };
  // Position.jsから受け取ったonEnd（画面から指を離すことで発火する）で実行
  const end = () => {
    walk.paused = true; // .paused = アニメーションを一時停止解除
  };

  return (
    <>
      <View style={{ flex: 1 }}>
        <GLView
          style={{ flex: 1 }}
          onContextCreate={async (gl) => {
            // 3D空間の準備
            const { drawingBufferWidth: width, drawingBufferHeight: height } =
              gl;
            const renderer = new Renderer({ gl }); // レンダラーの準備
            renderer.setSize(width, height); // 3D空間の幅と高さ
            renderer.setClearColor("white"); // 3D空間の配色
            const scene = new Scene(); // これが3D空間
            scene.add(new GridHelper(100, 100)); //グリッドを表示

            // GLTFをロードする
            const loader = new GLTFLoader();
            // アセットフォルダのglbファイルを読み込み
            const asset = Asset.fromModule(require("./assets/test.glb")); // Mixamoで作成したtest.glb
            await asset.downloadAsync();

            let mixer;
            let clock = new Clock();
            loader.load(
            // glbファイルをロードして3D空間に表示させる
              asset.uri || "",
              (gltf) => {
                const model = gltf.scene;
                model.position.set(0, 0, 0); // 配置される座標 (x,y,z)
                model.rotation.y = Math.PI;
                const animations = gltf.animations;
                //Animation Mixerインスタンスを生成
                mixer = new AnimationMixer(model);
                // glbファイルのアニメーション
                let animation = animations[0];
                // アニメーションを変数walkにセット
                setWalk(mixer.clipAction(animation));
                // test.glbを3D空間に追加
                scene.add(model);
                setModels(model);
              },
              (xhr) => {
                console.log("ロード中");
              },
              (error) => {
                console.error("読み込めませんでした");
              }
            );
            // 3D空間の光！
            const pointLight = new PointLight(0xffffff, 2, 1000, 1); //一点からあらゆる方向への光源(色, 光の強さ, 距離, 光の減衰率)
            pointLight.position.set(0, 200, 200); //配置される座標 (x,y,z)
            scene.add(pointLight); //3D空間に追加
            // カメラが映し出す設定(視野角, アスペクト比, near, far)
            const camera = new PerspectiveCamera(45, width / height, 1, 1000);
            setCameras(camera);
            // カメラの初期座標
            let cameraInitialPositionX = 0;
            let cameraInitialPositionY = 2;
            let cameraInitialPositionZ = 7;
            // カメラの座標
            camera.position.set(
              cameraInitialPositionX,
              cameraInitialPositionY,
              cameraInitialPositionZ
            );
            const render = () => {
              requestAnimationFrame(render); // アニメーション　moveUd関数、moveLr関数でカメラ座標が移動
              renderer.render(scene, camera); // レンダリング
              //Animation Mixerを実行
              if (mixer) {
                mixer.update(clock.getDelta());
              }
              gl.endFrameEXP(); // 現在のフレームを表示する準備ができていることをコンテキストに通知するpresent (Expo公式)
            };
            render();
          }}
        />
      </View>
      <View style={{ flexDirection: "row", alignSelf: "center" }}>
        <Positon
          // Position.jsからonMoveを受け取ってmove関数を実行
          onMove={(data) => {
            move({
              x: (data.x - 60) / 1000,
              y: (data.y - 60) / 1000,
            });
          }}
          // Position.jsからonEndを受け取ってend関数を実行
          onEnd={end}
        />
      </View>
    </>
  );
}

```

<h1>（６）終わりに</h1>

<p>3Dキャラクターになると一気にそれらしくなりますね。React Nativeでの3D表示は手探りで面白いです。Mixamoにvrmファイルを読み込ませるとVroidを表示させることもできるので、新たなアプリの可能性が広がりそうです:thumbsup_tone2:</p>