3D model animation in react-three-fiber

I wanted to add an animated propeller to my submarine and thought it would be a good time to go through a basic model animation and also add some interactivity based on user input. Animating the propeller when a user presses the arrow up key is what I was going for.

I started with updating the submarine glTF file rendered without the propeller and added a new file with just the propeller. I just opened submarine.glTF in Blender and organized the layers into two collections: one for the propeller and one for the submarine without the propeller. Once these two files were saved and added to the public folder, I updated the Submarine.js component to reference the new file.

... 
  const { scene, nodes, materials } = useGLTF('./models/submarine-without-propeller.gltf');
... 

Next, I added a new component to render the propeller.

 import React, { useLayoutEffect }from 'react'; 
 import { useGLTF } from '@react-three/drei'; 
  
 export const Propeller = ({currentColor, currentTexture, colorMap, normalMap, roughnessMap, metalnessMap}) => { 
   const { scene, nodes, materials } = useGLTF('./models/propeller.gltf'); 
  
   useLayoutEffect(() => { 
     Object.assign(materials.Material, {  
       metalnessMap: metalnessMap, 
       normalMap: normalMap, 
       roughnessMap: roughnessMap, 
       map: colorMap, 
       color: currentColor}) 
   }, [scene, nodes, materials, currentColor, currentTexture, colorMap, normalMap, roughnessMap, metalnessMap]); 
  
   return <primitive object={scene} /> 
 }; 

Basic propeller animation

To animate the propeller, I needed to import the handy useFrame hook from Fiber to extract the clock parameter and use its .getElapsedTime() method. Then using React’s useRef hook, I get a reference to the propeller mesh. Once I had a property to animate and elapsed time on every frame, I animated the propeller on the z axis.

...
   const propellerMesh = useRef(); 
   useFrame(({ clock }) => { 
     propellerMesh.current.rotation.z = clock.getElapsedTime(); 
   }) 
...

Here’s the entire updated Scene.js component.

 import { useRef } from 'react'; 
 import { useFrame } from '@react-three/fiber'; 
 import { Environment, Stage, useTexture } from '@react-three/drei' 
 import { Submarine } from '../components/Submarine'; 
 import { Propeller } from '../components/Propeller'; 
  
 export const Scene = ({currentColor, currentTexture}) => { 
   const [colorMap, normalMap, roughnessMap, metalnessMap] = useTexture(currentTexture); 
   const propellerMesh = useRef(); 
   useFrame(({ clock }) => { 
     propellerMesh.current.rotation.z = clock.getElapsedTime(); 
   }) 
   return ( 
     <Stage intensity={1} > 
       <Environment 
           background={false} 
           files={'UW_1.hdr'} 
           path={'/'} 
         /> 
       <mesh> 
         <Submarine  
           map={colorMap} 
           normalMap={normalMap} 
           roughnessMap={roughnessMap} 
           metalnessMap={metalnessMap}  
           currentColor={currentColor} 
           currentTexture={currentTexture} /> 
       </mesh> 
       <mesh ref={propellerMesh}> 
         <Propeller  
           map={colorMap} 
           normalMap={normalMap} 
           roughnessMap={roughnessMap} 
           metalnessMap={metalnessMap}  
           currentColor={currentColor} 
           currentTexture={currentTexture} /> 
       </mesh> 
     </Stage> 
   ) 
 } 

Propeller animation on ArrowUp keypress

For keyboard interactions, I created a new Keyboard.js component and set up a new custom useKeys hook for my up and down handlers.

 import { useEffect } from 'react'; 
  
 const useKeys = (setUpKeyPressed) => { 
   useEffect(() => { 
     const downHandler = ({ key }) => { 
       if (key === 'ArrowUp') { 
         setUpKeyPressed(true); 
       } 
     } 
  
     const upHandler = ({ key }) => { 
       if (key === 'ArrowUp') { 
         setUpKeyPressed(false); 
       } 
     } 
  
     window.addEventListener('keydown', downHandler, { passive: true }) 
     window.addEventListener('keyup', upHandler, { passive: true }) 
  
     return () => { 
       window.removeEventListener('keydown', downHandler) 
       window.removeEventListener('keyup', upHandler) 
     } 
   }, [setUpKeyPressed]) 
 } 
  
  
 export const Keyboard = ({setUpKeyPressed}) => { 
   useKeys(setUpKeyPressed); 
   return null; 
 } 

I created a setUpKeyPressed boolean state to keep track of user interaction.

...
const [upKeyPressed, setUpKeyPressed] = useState(false);
...

In App.js, I updated to use the <Keyboard /> component and set the setUpKeyPressed property. Then added the upKeyPressed value prop to the <Scene /> component.

...
 return ( 
   <div> 
     <Menu  
       handleColorChange={handleColorChange}  
       handleTextureChange={handleTextureChange} /> 
     <Canvas dpr={[1, 2]} camera={{ fov: 50 }}> 
       <color attach="background" args={['#253B56']} /> 
       <Suspense fallback={null}> 
         <Scene  
           currentTexture={currentTexture}  
           currentColor={currentColor}  
           upKeyPressed={upKeyPressed} /> 
       </Suspense> 
       <OrbitControls enableZoom={true} enablePan={true} /> 
     </Canvas> 
     <Keyboard setUpKeyPressed={setUpKeyPressed} /> 
   </div> 
 ) 
...

The last thing to do was update the useFrame hook with an if statement testing the upKeyPressed boolean value. If the arrow up key is pressed the propeller will animate.

...
   useFrame(({ clock }) => { 
     if (upKeyPressed) { 
       propellerMesh.current.rotation.z = clock.getElapsedTime() * 3; 
     } 
   }) 
...

Animations using the useRef and useFrame hooks is pretty straight forward and easy to use. As always, I have a branch on GitHub with this code and here’s the propeller in action when pressing the arrow up key.