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.