Credit I followed one of the Wawa Sensei's tutorial series, "How to Create a 3D game with React Three Fiber" to create this app, although I made a tiny changes to the codes. Thanks for the amazing lessons.
Error
It happened in the Kicker
component when tried to call the if
statement before useFrame
function.
What causd this kind of error
if
statements called before a hook- A hook is invoked inside the body of an
if
,else
,for
orwhile
statement
const config = useControls({
meshPhysicalMaterial: false,
transmissionSampler: false,
backside: false,
samples: { value: 10, min: 1, max: 32, step: 1 },
resolution: { value: 1024, min: 256, max: 2048, step: 256 },
transmission: { value: 1, min: 0, max: 1 },
roughness: { value: 0.0, min: 0, max: 1, step: 0.01 },
thickness: { value: 0, min: 0, max: 10, step: 0.01 },
ior: { value: 1, min: 1, max: 5, step: 0.01 },
chromaticAberration: { value: 0, min: 0, max: 1 },
anisotropy: { value: 0, min: 0, max: 1, step: 0.01 },
distortion: { value: 0.0, min: 0, max: 1, step: 0.01 },
distortionScale: { value: 0, min: 0.01, max: 1, step: 0.01 },
temporalDistortion: { value: 0, min: 0, max: 1, step: 0.01 },
clearcoat: { value: 1, min: 0, max: 1 },
attenuationDistance: { value: 1, min: 0, max: 10, step: 0.01 },
attenuationColor: "#ffffff",
color: "#efbeff",
bg: "#ffffff",
});
....
<Sphere>
<meshPhysicalMaterial {...config} />
</Sphere>
....
You can find free 3D models at pmndrs market. Once you find one, click "Download Model" to download the model. The file format is automatically set to gltf
, which is convenient for the next step.
You can automatically convert the downloaded gltf model to the React component with gltfjsx. Run the following command. (Assume that the model is located inside models
> female-cyborg
folder.)
npx gltfjsx public/models/female-cyborg/model.gltf
And then copy all the code inside the auto generated Model.jsx
file, create the Character.jsx
component and paste the code inside of it. Also the link to the gltf model should be fixed properly.
import React, { useRef } from "react";
import { useGLTF } from "@react-three/drei";
export default function Character(props) {
const group = useRef();
const { nodes, materials } = useGLTF("./models/female-cyborg/model.gltf");
return (
<group ref={group} {...props} dispose={null}>
<group scale={0.64}>
<primitive object={nodes.LeftFootCtrl} />
<primitive object={nodes.RightFootCtrl} />
<primitive object={nodes.HipsCtrl} />
<skinnedMesh
geometry={nodes.characterMedium.geometry}
material={materials["skin.001"]}
skeleton={nodes.characterMedium.skeleton}
castShadow
/>
</group>
</group>
);
}
useGLTF.preload("./models/female-cyborg/model.gltf");
An alternative way to create the model component from the model is using online gltf conveter offered by pmndrs. Just drop the gltf file and it automatically generates the jsx code for you.
In order to make the character move with the keyboard, use KeyboardControls
from @react-three/drei
. It's recommended to implement it outside <Canvas>
so that non-three.js components can access keyboard inputs.
App.js
export const Controls = {
forward: "forward",
backward: "backward",
leftward: "leftward",
rightward: "rightward",
jump: "jump",
};
function App() {
/**
* PARAMS FOR KEYBOARD CONTROL
*/
const map = useMemo(
() => [
{ name: Controls.forward, keys: ["ArrowUp", "KeyW"] },
{ name: Controls.backward, keys: ["ArrowDown", "KeyS"] },
{ name: Controls.leftward, keys: ["ArrowLeft", "KeyA"] },
{ name: Controls.rightward, keys: ["ArrowRight", "KeyD"] },
{ name: Controls.jump, keys: ["Space"] },
],
[]
);
return (
<>
<KeyboardControls map={map}>
<Canvas .... >
<Suspense>
<Physics debug>
<Experience />
</Physics>
</Suspense>
</Canvas>
....
</KeyboardControls>
</>
);
}
export default App;
First you need to create the CharacterController
component and wrap Character
component with RigidBody
.
<RigidBody>
<Character />
</RigidBody>
In order to make the character move with keyboard inputs, first you need to set up useKeyboardControls
from @react-three/drei
.
const [subscribeKeys, getKeys] = useKeyboardControls();
Making the character move involves the physics, thus you need to reference the character via <RigidBody>
.
const body = useRef();
....
<RigidBody ref={body} .... >
<Character />
</RigidBody>
Using "getter" of useKeyboardControls
to fetch keyboard input states, then apply forces to the model with applyImpulse
. It's important to use and tweak "one vector" to apply forces otherwise unwanted forces could be applied to the character leading to unpredictable character movements. Applying forces needs to be done inside useFrame
. For provide the same user experience regardless of device, the amount of applied force should be optimized through delta
. The second boolean parameter of applyImpulse
is to wake up the character (react three rapier system automatically sets objects sleep after several seconds).
useFrame((state, delta) => {
// Get input key states
const { forward, backward, leftward, rightward } = getKeys();
// One vector for handling all applied forces
const impluse = { x: 0, y: 0, z: 0 };
// Move forward, backward, leftward, rightward
if (forward) {
impluse.z -= 3 * delta;
}
if (backward) {
impluse.z += 3 * delta;
}
if (leftward ) {
impluse.x -= 3 * delta;
}
if (rightward) {
impluse.x += 3 * delta;
}
// Apply forces to the rigid body
body.current.applyImpulse(impluse, true);
});
For better and optimized physicall simulations, implement CapsuleCollider
.
<RigidBody .... colliders={false} >
<Character />
<CapsuleCollider .... />
</RigidBody>
When the force is applied to the character, it will fall on the ground. To prevent it, tweak the enabledRotations
attribute of RigidBody
.
<RigidBody .... enabledRotations={[false, false, false]} >
<Character />
<CapsuleCollider .... />
</RigidBody>
In order to create natural character movements, you need to limit its speed by accessing its movement speed via linvel()
method. And also set linearDamping
of RigidBody
to automatically diminish applied forces.
useFrame((state, delta) => {
// Get input key states
const { forward, backward, leftward, rightward } = getKeys();
// One vector for handling all applied forces
const impluse = { x: 0, y: 0, z: 0 };
// Access the character linear velocity
const linvel = body.current.linvel();
// Move forward, backward, leftward, rightward
if (forward && linvel.z > -3) {
impluse.z -= 3 * delta;
}
if (backward && linvel.z < 3) {
impluse.z += 3 * delta;
}
if (leftward && linvel.x > -3) {
impluse.x -= 3 * delta;
}
if (rightward && linvel.x < 3) {
impluse.x += 3 * delta;
}
// Apply forces to the rigid body
body.current.applyImpulse(impluse, true);
});
....
<RigidBody .... linearDamping={0.5} >
<Character />
<CapsuleCollider .... />
</RigidBody>
It looks more natural if the character face the direction in which it moves. It can be done through accessing the character mesh
and tweaking its rotation (since it doesn't have to be related to the physic simulation in this game).
const character = useRef();
useFrame((state, delta) => {
// Get input key states
const { forward, backward, leftward, rightward } = getKeys();
// One vector for handling all applied forces
const impluse = { x: 0, y: 0, z: 0 };
// Access the character linear velocity
const linvel = body.current.linvel();
// Control the character mesh rotation
let changeRotation = false;
// Move forward, backward, leftward, rightward
if (forward && linvel.z > -3) {
impluse.z -= 3 * delta;
changeRotation = true;
}
if (backward && linvel.z < 3) {
impluse.z += 3 * delta;
changeRotation = true;
}
if (leftward && linvel.x > -3) {
impluse.x -= 3 * delta;
changeRotation = true;
}
if (rightward && linvel.x < 3) {
impluse.x += 3 * delta;
changeRotation = true;
}
// Rotate the character according to move directions
if (changeRotation) {
const angle = Math.atan2(linvel.x, linvel.z);
character.current.rotation.y = angle;
}
// Apply forces to the rigid body
body.current.applyImpulse(impluse, true);
});
....
<RigidBody .... >
<group ref={character}>
<Character />
</group>
<CapsuleCollider .... />
</RigidBody>
Jump logic should be triggered based on the keyboard input "state changes", not
"state" (otherwise the jump forces keep applied and the character fly away). In order to listen "state changes", you need to "subscribe" the keyboard input.
const jump = () => {
body.current.applyImpulse({ x: 0, y: JUMP_FORCE, z: 0 }, true);
};
// Listen to the "jump state" change for trigger jump Fn
useEffect(() => {
const unsubscribeKeys = subscribeKeys(
(state) => state.jump,
(value) => value && jump()
);
return () => {
unsubscribeKeys();
};
}, []);
Without limitations, the character jumps endlessly (and could fly to the moon) and that's not ideal user experience. So set the jump height limit with rapier.Ray
. To do so, useRapier
should be properly imported from @react-three/rapier
.
const { rapier, world } = useRapier();
const jump = () => {
const origin = body.current.translation();
origin.y -= 1.25 / 2; // Move origin to the touch ground
const direction = { x: 0, y: -1, z: 0 };
const ray = new rapier.Ray(origin, direction);
const hit = world.castRay(ray, 20, true);
if (hit.toi < 3) {
body.current.applyImpulse({ x: 0, y: JUMP_FORCE, z: 0 }, true);
}
};
// Listen to the "jump state" change for trigger jump Fn
useEffect(() => {
const unsubscribeKeys = subscribeKeys(
(state) => state.jump,
(value) => value && jump()
);
return () => {
unsubscribeKeys();
};
}, []);
- Add the
sensor
attribute to the specificCollider
- Create the reset function of the character position & velocity
A Collider can be set to be "a sensor", which means that it will not generate any contact points, and will not be affected by forces. This is useful for detecting when a collider enters or leaves another collider, without affecting the other collider.
To detect when a collider enters or leaves another collider, you can use the onIntersectionEnter
and onIntersectionExit
events on the collider.
In this game, there's the floor outside the stage, so set the sensor
attribute to that collider.
<RigidBody colliders={false} type="fixed" name="void">
<mesh .... />
<CuboidCollider .... sensor />
</RigidBody>
onIntersectionEnter
callbacks when this collider, or another collider starts intersecting, and at least one of them is a sensor
. So create the reset function and set to the CharacterController
component through the onIntersectionEnter
attribute.
const resetPosition = () => {
body.current.setTranslation({ x: 0, y: 0, z: 0 });
body.current.setLinvel({ x: 0, y: 0, z: 0 });
body.current.setAngvel({ x: 0, y: 0, z: 0 });
};
....
<RigidBody
....
onIntersectionEnter={({ other }) => {
if (other.rigidBodyObject.name === "void") {
resetPosition();
}
}}
>
<group ref={character}>
<Character />
</group>
<CapsuleCollider .... />
</RigidBody>
To let the camera follow the character movements, access its position through getWorldPosition
of the character mesh
(in this case, it's done with its mesh
ref, character
) and synchronize the both movements with adjusting the camera position & target (where the camera looks at).
- Camera position: synchronize xz-axis, but y-axis is fixed
- Camera target: synchronize xyz-axis
useFrame((state, delta) => {
// Get the character position
const characterWorldPosition = character.current.getWorldPosition(
new THREE.Vector3()
);
// Set the camera position
state.camera.position.x = characterWorldPosition.x;
state.camera.position.z = characterWorldPosition.z + 10;
// Set the camera target
const cameraTarget = new THREE.Vector3();
cameraTarget.copy(characterWorldPosition);
state.camera.lookAt(cameraTarget);
});
- Prepare the model as the format of "fbx"
- Upload to Mixamo
- Find an animation and download it
- Attach the imported animation to the model in blender
- Export as "gltf" file
Don't forget to check "In Place".
Run gltfjsx again to generate the code, and switch the model with the animation one.
npx gltfjsx public/models/female-cyborg/model.gltf
export const useGameStore = create(
subscribeWithSelector((set, get) => ({
....
characterState: "Idle",
setCharacterState: (characterState) => {
set({ characterState: characterState });
},
}))
);
useFrame((state, delta) => {
....
if (Math.abs(linvel.x) > RUN_VEL || Math.abs(linvel.z) > RUN_VEL) {
if (characterState === "Idle" || characterState !== "JumpAnimation") {
setCharacterState("RunAnimation");
}
} else if (
(Math.abs(linvel.x) === 0 || Math.abs(linvel.z) === 0) &&
Math.abs(linvel.y) === 0
) {
if (characterState !== "Idle") {
setCharacterState("Idle");
}
}
});
....
const jump = () => {
....
if (hit.toi < JUMP_ACTIVATE_HIGHT) {
body.current.applyImpulse({ x: 0, y: JUMP_FORCE, z: 0 }, true);
if (characterState !== "JumpAnimation") {
setCharacterState("JumpAnimation");
}
}
};
const characterState = useGameStore((state) => state.characterState);
useEffect(() => {
actions[characterState].reset().fadeIn(0.01).play();
return () => {
actions[characterState].fadeOut(0.1);
}
}, [characterState])
Once you add the armature to the scene, scale it up to roughly fit the character.
Go to "metarig > metarig > Viewport Display > Show" and check "In Front", then the rig is always visible and not get hidden inside the character mesh.
Select the rig, go to "Edit" mode and adjust bones to roughly fit inside the character mesh.
Select the rig and apply "all transform".
Go to "metarig > metarig > Rigify" and press "Generate Rig" button.
- Select a character mesh & the auto generated rig
- Press Ctr + p to select "Set Parent To > Armature Deform > With Automatic Weights"
- Wait a bit
- The rig and the mesh are connected
Go to "Pose Mode" and check whether the rig is properly functioned.