likes
comments
collection
share

【Threejs】怎么在React里用Threejs写代码?

作者站长头像
站长
· 阅读数 11

前言

在之前我们已经尝试使用原生的方式创建一个场景,添加物体,设置相机等,每一步都需要自己考虑,很繁琐,在这个工具满天飞的时代,怎么能没有threejs配合的工具呢,这次我们就用 react-three/drei + react-three/fiber + leva 来尝试一下构建项目。下面的代码为片段式,如果有哪个地方不是很清晰,可以留言或者私信,我看到会尽快补充上。

前期技术准备

在开始之前,假设你已经有了对threejs基本api的了解,包含但不限于 场景、灯光、相机、控制器、几何体、材质和纹理等。 另外就是:react 的基础,当前项目是以react 框架为依托来完成的,如果前面的两项都还没有准备好,那么可能后面的内容看起来有些吃力(大佬除外 🐶)。

当前项目用到的技术:

  • react
  • threejs
  • react-router-demo
  • react-three/drei
  • react-three/fiber
  • leva

我们上一节也通过一些方式拿到了一些模型,在本次文章中,我们就尝试拿这些模型来创建一下,看看使用工具是否能真的减少我们的工作量,以及有哪些好玩的方式。下面就先介绍一下项目里用到的几个工具:


Three.js扩展库: react-three/drei

react-three/drei 是一个用于在 React 应用中使用 Three.js 库的扩展库Three.js 是一个用于在 Web 上创建和展示 3D 图形的 JavaScript 库,它提供了各种功能,包括创建 3D 场景、渲染器、灯光、相机、物体和材质等。

react-three/drei 提供了一系列用于简化在 React 中使用 Three.js 的组件和工具。它包含了很多实用的组件,如模型加载器、相机控制器、动画组件、环境贴图和阴影组件等,使开发者可以更轻松地在 React 项目中创建和管理 Three.js 3D 场景。

下面几个是比较常用的方法:

useFBX 获取FBX格式模型里的详细细节,包括名称、骨骼、定位、旋转角度等

const skinfbx = useFBX(`animations/${skin}.fbx`);

useGLTF 获取GLB或者GLTF类型的模型

const { nodes, materials } = useGLTF(`models/${skin}.glb`);

useAnimations 获取到模型自带的动画信息

const { actions } = useAnimations([...skinfbx.animations], group);
...
// 执行动画
actions['name'].reset().play();

// 停止动画
actions['name'].reset().stop();

Threejs 快速创建场景:react-three/fiber:

它是一个由 React 团队为了在 React 应用中使用 Three.js 而开发的实验性项目。它通过将 Three.js 的场景、摄像机和渲染器与 React 的组件模型结合起来,提供了一种声明式、可组合的方式来创建和控制 Three.js 的场景和对象。可以将 Three.js 的组件视为 React 组件,并使用 React 的生命周期、状态和更新机制来控制场景中的对象和动画。文档地址

快速创建一个场景:

 <Canvas shadows camera={{ position: [0, 2, 5], fov: 40 }}></Canvas>

当前我对于这个项目的探索的还不够深入,用到的方法也比较少,后面如果有机会,再深入研究。


GUI 交互式的控制面板:leva

leva 是一个用于创建交互式调试界面的库,它可以帮助开发者在开发过程中更轻松地调试和控制应用程序的各种参数和选项。它提供了一种简单且可视化的方式来管理和调整您的应用程序的各种参数,而无需手动地修改代码并重新加载应用程序

文档地址

Git 项目地址

使用方式:

  import { useControls } from 'leva';

  const {light, model, skin, turnLeft } = useControls({
    light: true,
    model: {
        value: 'swimmer',
        options: ['animeGirl', 'worker', 'swimmer']
    },
    skin: {
      value: 'girl',
      options:['girl','girl1','girl2']
    },
    turnLeft: true,
  });

创建后就会有右侧的面板展示,方便我们调试参数:

【Threejs】怎么在React里用Threejs写代码?


下面是项目中的一些文件:

packagejson:

{
  "name": "r3f-vite-starter",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@react-three/drei": "^9.80.1",
    "@react-three/fiber": "^8.13.0",
    "leva": "^0.9.35",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "three": "^0.145.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.27",
    "@types/react-dom": "^18.0.10",
    "@vitejs/plugin-react": "^3.1.0",
    "vite": "^4.1.0"
  }
}

App.jsx:

import { Canvas } from '@react-three/fiber';
import Experience from './components/Experience';

function App() {
  return (
    <Canvas shadows camera={{ position: [0, 2, 5], fov: 40 }}>
      <color attach="background" args={['#ececec']} />
      <Experience />
    </Canvas>
  );
}

export default App;

main.jsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

文件目录如下:

【Threejs】怎么在React里用Threejs写代码?


需求详解

现有多个模型,有多个动作,需支持模型切换,动作的切换,多个模型共用相同的动作,切换模型和切换动作,展示正常。

第一步:生成模型

第二步:将你的glb 格式文件转成jsx 文件,方便后续给模型添加动作:

命令行执行如下代码:(具体文件名和位置根据你当前位置修改)

 npx gltfjsx public/models/animeGirl.glb   

执行成功后,在当前目录就会得到一个如下一样的文件,如下:

/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
Command: npx gltfjsx@6.2.10 public/models/animeGirl.glb 
*/

import React, { useRef } from 'react'
import { useGLTF } from '@react-three/drei'

export function Model(props) {
  const { nodes, materials } = useGLTF('/animeGirl.glb')
  return (
    <group {...props} dispose={null}>
      <primitive object={nodes.Hips} />
      <skinnedMesh name="Hair001_(merged)baked001" geometry={nodes['Hair001_(merged)baked001'].geometry} material={materials['N00_000_Hair_00_HAIR (Instance)']} skeleton={nodes['Hair001_(merged)baked001'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_1" geometry={nodes['Hair001_(merged)baked001_1'].geometry} material={materials['N00_000_00_FaceMouth_00_FACE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_1'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_1'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_1'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_2" geometry={nodes['Hair001_(merged)baked001_2'].geometry} material={materials['N00_000_00_EyeIris_00_EYE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_2'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_2'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_2'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_3" geometry={nodes['Hair001_(merged)baked001_3'].geometry} material={materials['N00_000_00_EyeHighlight_00_EYE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_3'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_3'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_3'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_4" geometry={nodes['Hair001_(merged)baked001_4'].geometry} material={materials['N00_000_00_Face_00_SKIN (Instance)']} skeleton={nodes['Hair001_(merged)baked001_4'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_4'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_4'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_5" geometry={nodes['Hair001_(merged)baked001_5'].geometry} material={materials['N00_000_00_EyeWhite_00_EYE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_5'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_5'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_5'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_6" geometry={nodes['Hair001_(merged)baked001_6'].geometry} material={materials['N00_000_00_FaceBrow_00_FACE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_6'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_6'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_6'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_7" geometry={nodes['Hair001_(merged)baked001_7'].geometry} material={materials['N00_000_00_FaceEyelash_00_FACE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_7'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_7'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_7'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_8" geometry={nodes['Hair001_(merged)baked001_8'].geometry} material={materials['N00_000_00_FaceEyeline_00_FACE (Instance)']} skeleton={nodes['Hair001_(merged)baked001_8'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_8'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_8'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_9" geometry={nodes['Hair001_(merged)baked001_9'].geometry} material={materials['N00_000_00_Body_00_SKIN (Instance)']} skeleton={nodes['Hair001_(merged)baked001_9'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_9'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_9'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_10" geometry={nodes['Hair001_(merged)baked001_10'].geometry} material={materials['N00_002_03_Tops_01_CLOTH_01 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_10'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_10'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_10'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_11" geometry={nodes['Hair001_(merged)baked001_11'].geometry} material={materials['N00_002_03_Tops_01_CLOTH_02 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_11'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_11'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_11'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_12" geometry={nodes['Hair001_(merged)baked001_12'].geometry} material={materials['N00_010_01_Onepiece_00_CLOTH_01 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_12'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_12'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_12'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_13" geometry={nodes['Hair001_(merged)baked001_13'].geometry} material={materials['N00_002_03_Tops_01_CLOTH_03 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_13'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_13'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_13'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_14" geometry={nodes['Hair001_(merged)baked001_14'].geometry} material={materials['N00_002_03_Tops_01_CLOTH_04 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_14'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_14'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_14'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_15" geometry={nodes['Hair001_(merged)baked001_15'].geometry} material={materials['N00_008_01_Shoes_01_CLOTH (Instance)']} skeleton={nodes['Hair001_(merged)baked001_15'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_15'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_15'].morphTargetInfluences} />
      <skinnedMesh name="Hair001_(merged)baked001_16" geometry={nodes['Hair001_(merged)baked001_16'].geometry} material={materials['N00_010_01_Onepiece_00_CLOTH_02 (Instance)']} skeleton={nodes['Hair001_(merged)baked001_16'].skeleton} morphTargetDictionary={nodes['Hair001_(merged)baked001_16'].morphTargetDictionary} morphTargetInfluences={nodes['Hair001_(merged)baked001_16'].morphTargetInfluences} />
    </group>
  )
}

useGLTF.preload('/animeGirl.glb')

第三步:添加动作

第四步:给模型绑定动作

现在假设我们已经有多个模型和多个动作,现在将两者合并,让我们的模型动起来(下面先举例:当只有一个模型和一个动作的时候如何绑定

Experience:

....
import  {Fragment, useEffect, useRef } from 'react';
import { useAnimations, useFBX, useGLTF } from '@react-three/drei';

const group = useRef();

// 引入对应模型
const { nodes, materials } = useGLTF(`models/girl.glb`);
useGLTF.preload(`models/girl.glb`);

// 引入动作
const { animations } = useFBX(`animations/Walking.fbx`);
animations[0].name = 'Walking';

// 将模型和动作绑定
const { actions } = useAnimations(
   animations[0],
   group
);

// 进入页面后让动作执行
useEffect(() => {
    actions[animations]?.reset()?.play();
    return () => {
      actions[animation]?.reset()?.stop();
    };
}, []);
....

return (
<Fragment>
      <OrbitControls />
      <Sky />

      <group position-y={-1}>
        <ContactShadows
          opacity={1}
          scale={10}
          blur={1}
          far={10}
          resolution={256}
        />
       <group {...props} ref={group} dispose={null}>...内容就同上面用命令生成的内容,太多了就不写了</group>
        <mesh
          receiveShadow
          scale={5}
          rotation-x={-Math.PI * 0.5}
          position-y={0.01}
        >
          <planeGeometry width={2} height={2} />
          <meshStandardMaterial color="white" />
        </mesh>
      </group>

      <ambientLight intensity={1} />
  
<Fragment/>);

此时,第一个场景和第一个模型以及第一个动作已经成功添加:

【Threejs】怎么在React里用Threejs写代码?


第五步:现在我们来添加多个模型和多个动作:

现在有一个问题:既然模型应有的骨骼是相同的,那么我们是否能尝试用同一份骨骼信息???也就是上面生成的那个group文件

??????????

通过多次尝试,尝试用其他模型来生成jsx,得到的内容除了引入的glb文件地址,其他信息都是相同的,太棒了,那么下面我们就尝试把这个文件抽出来,通过传入参数的形式,来动态更换模型。

子模型组件ModelElement

import { useEffect } from 'react';
const ModelElement = (props) => {
  const { group, nodes, materials } = props;
 
  return (
    <group {...props} ref={group} dispose={null}>
      <group>
        <primitive object={nodes.Hips} />
        <skinnedMesh
          geometry={nodes.Wolf3D_Body.geometry}
          material={materials.Wolf3D_Body}
          skeleton={nodes.Wolf3D_Body.skeleton}
        />
        <skinnedMesh
          geometry={nodes.Wolf3D_Hair.geometry}
          material={materials.Wolf3D_Hair}
          skeleton={nodes.Wolf3D_Hair.skeleton}
        />
       .... 就是上面生成的那个文件,内容太多,就省略了
      </group>
    </group>
  );
};
export default ModelElement;

父组件:

import React, { useEffect, useRef, useState } from 'react';
import * as THREE from 'three';
import { useAnimations, useFBX, useGLTF } from '@react-three/drei';
import { useControls } from 'leva';
import ModelElement from './ModelSkeleton/ModelElement';

import { animationlList, worker } from './config';

const RealModel = (props) => {
  const group = useRef(null);
  const animationArr = [];
  
  // 引入控制面板,快速操作更换模型和动作
  const { skin, animation } =
    useControls({
      skin: {
        value: 'girl',
        options: worker
      },
      animation: {
        value: 'Standing',
        options: animationlList
      }
    });

  // 引入对应模型
  const { nodes, materials } = useGLTF(`models/${skin}.glb`);
  useGLTF.preload(`models/${skin}.glb`);

  // 批量引入动作
  animationlList.forEach((item) => {
    const { animations } = useFBX(`animations/${item}.fbx`);
    animations[0].name = item;
    animationArr.push(animations);
  });

  // 批量给节点绑定动作
  const { actions } = useAnimations(
    animationArr.map((animation) => animation[0]),
    group
  );

  // 执行动作
  useEffect(() => {
    actions[animation]?.reset()?.play();
    return () => {
      actions[animation]?.reset()?.stop();
    };
  }, [animation, actions, group]);
    
  return (
    <ModelElement
      {...props}
      key={skin}
      group={group}
      nodes={nodes}
      materials={materials}
    />
  );
};

export default RealModel;

此时发现一个问题:每次切换模型后,模型还会记录着上一个执行的工作,所以经常会有动作不对的情况,如下:

【Threejs】怎么在React里用Threejs写代码?

正确的应该是下面这个展示:

【Threejs】怎么在React里用Threejs写代码?

第六步:修正问题

所以考虑是模型切换没有完全停止到上一个动作,所以把执行动作的useEffect(下面的内容)放到子组件:

  useEffect(() => {
    actions[animation]?.reset()?.play();
    return () => {
      actions[animation]?.reset()?.stop();
    };
  }, [animation, actions, group]);

完成:下面就是一个完整的页面了,支持模型切换,动作的切换,你也快动手试试吧!

【Threejs】怎么在React里用Threejs写代码?


进阶一:添加键盘控制

此时我们的模型和动作已经可以正常切换,我当前有一个walking的动作,下面我们尝试通过键盘控制让模型动起来:

useFrame每一帧运行时执行的钩子

在使用Three.jsReact构建交互式3D场景时,我们通常需要在每一帧进行一些操作,例如更新相机位置、控制对象的动画、响应用户输入等。而useFrame钩子函数则为了方便地在React组件中执行这些操作而存在。

...
import { useFrame } from '@react-three/fiber';

const [cubeRotation, setCubeRotaion] = useState(0);
const [mouseDown, setMouseDown] = useState(false);
const [keystroke, setKeystroke] = useState(null);
  
  const handleKey = (key) => {
    if (
      ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key) &&
      animation !== 'Walking1'
    ) {
      setAnimationType('Walking1');
    }
    switch (key) {
      case 'ArrowUp':
        setCubeRotaion(Math.PI);
        group.current.position.z -= 0.01;
        break;
      case 'ArrowDown':
        group.current.position.z += 0.01;
        setCubeRotaion(0);
        break;
      case 'ArrowLeft':
        setCubeRotaion(-Math.PI * 0.5);
        group.current.position.x -= 0.01;
        break;
      case 'ArrowRight':
        setCubeRotaion(Math.PI * 0.5);
        group.current.position.x += 0.01;
        break;
    }
  };
  
  // 处理桢的逻辑
  useFrame((state) => {
    if (mouseDown) {
      handleKey(keystroke);
    }
  });
  
  // 键盘按下
   window.addEventListener('keydown', (e) => {
    setMouseDown(true);
    setKeystroke(e.key);
  });

  // 键盘抬起
  window.addEventListener('keyup', (e) => {
    setMouseDown(false);
  });
  

  .....
   <ModelElement
        {...props}
        key={skin}
        animation={animationType}
        group={group}
        nodes={nodes}
        materials={materials}
        actions={actions}
        rotation-z={cubeRotation}
      />

现在我们的模型就可以前后左右移动了:

【Threejs】怎么在React里用Threejs写代码?

【Threejs】怎么在React里用Threejs写代码?

【Threejs】怎么在React里用Threejs写代码?

【Threejs】怎么在React里用Threejs写代码?

进阶二:创建更多场景

如果手里还有一些模型,可以尝试添加进来来完成一些有意思的场景:

  • 动作:Typing

【Threejs】怎么在React里用Threejs写代码?

  • 动作:Dancing

【Threejs】怎么在React里用Threejs写代码?

  • 动作:Applauding

【Threejs】怎么在React里用Threejs写代码?

  • 动作:Waving

【Threejs】怎么在React里用Threejs写代码?

  • 动作:Breaststroke

【Threejs】怎么在React里用Threejs写代码?

  • 动作:SwimmingToEdge

【Threejs】怎么在React里用Threejs写代码?

更多的动作和模型要靠大家的想象力喽!大家可以自己尝试去下载一下。


相关链接:


如果喜欢我的文章,关注一下吧!