Building Your First Browser Game with Three.js and React: Part 3 - Adding Interactivity and Physics
Introduction
Welcome back to our series on creating a 3D basketball game using Three.js and React in your browser! So far, we've set up our project and incorporated essential 3D models into our scene. Now, it's time to animate our game with interactivity and physics. This installment will focus on integrating physics to simulate realistic ball movements and interactions, making our game genuinely interactive and enjoyable.
Step 1: Setting Up Physics with @react-three/rapier
When it comes to integrating physics into React Three Fiber, several libraries are available. @react-three/rapier stands out as one of the most recent updated (created in 2019 and written in Rust), making it a great choice for adding physics in 3D web environments.
While Rapier is known for its ease of use and good performance, other libraries might also be excellent choices depending on specific use cases, like Cannon for example. (The fork by pmndrs, that is awesome too)
First, ensure that @react-three/rapier
is installed in your project:
npm install @react-three/rapier
Initialize the physics engine in your Experience file where all our scene is set up.
To accomplish this, we add the Physics
component, provided by rapier, around our Table and Ball. We don't need to include our lights, controls, or environment, as they don't require physics.
We are adding a debug
parameter to the Physics component. This will allow us to view all the colliders of our object.
import React from 'react';
import { Box, Center, Environment, OrbitControls } from '@react-three/drei';
import Table from './Components/Table';
import Ball from './Components/Ball';
import { Physics } from '@react-three/rapier';
const Experience = () => {
return (
<>
<color attach="background" args={["#ddc28d"]} />
<ambientLight />
<directionalLight position={[0, 1, 2]} intensity={1.5} />
<Environment preset="city" />
<OrbitControls makeDefault />
<Physics debug>
<Center>
<Table position={[0, 0, 0]} />
<Ball position={[0.25, 1.5, 0]} />
</Center>
</Physics>
</>
);
}
export default Experience;
This wrapper will manage all physics-related updates automatically.
Step 2: Adding Physics to the Ball
Let's add physics to our ball. We'll adjust the Ball
component to include the RigidBody
component from rapier.
import { RigidBody } from '@react-three/rapier';
const Ball = ({ position }) => {
return (
<group position={position} scale={0.7}>
<RigidBody>
<mesh
castShadow
receiveShadow
geometry={nodes.Sphere.geometry}
material={materials["Material.001"]}
/>
</RigidBody>
</group>
);
};
We also include a group in our mesh to set the position and scale for the ball, along with our rigid body. To slightly decrease the ball's size, we incorporate a scale parameter of 0.7, which we'll use later to fit the hoop ring.
This setup adds physics to our ball. You should see the ball falling into the depths of our scene. The ball falls because we have not integrated physics into our table yet.
Before the ball falls, you should see a square around it, as shown in the screenshot below. This square represents the object's colliders. By default, all colliders are square-shaped and adapt to the size of the object to which we add a RigidBody
. However, we can adjust the shape using the colliders
parameter of RigidBody.
Let's try another collider, such as ball
:
import { RigidBody } from '@react-three/rapier';
const Ball = ({ position }) => {
const { nodes, materials } = useGLTF("/models/basketball.glb");
return (
<group position={position} scale={0.7}>
<RigidBody colliders="ball">
<mesh
castShadow
receiveShadow
geometry={nodes.Sphere.geometry}
material={materials["Material.001"]}
/>
</RigidBody>
</group>
);
};
You should now see the collider in a "round shape" (like a ball). This is exactly what we want!
It exist some colliders provided by rapier:
cuboid
, the default one, for square shape.ball
, the one we use, for round shape.hull
, a collider that fits a shape very closely but ignores holes within it. It's useful for complex objects without holes.trimesh
is similar tohull
, but it doesn't ignore holes. While this collider is powerful, it can complicate collision detection and potentially introduce bugs. It's best to avoid it if possible, but for smaller projects with less collision, it’s acceptable. (Am I saying this because I use it in this project? Perhaps..)
Step 3: Adding physics to the Table
Now, let's modify the Table
component to respond to the ball. This way, the ball will be "trapped" inside the table. First, let’s add RigidBody
for the Table mesh:
import { RigidBody, useRigidBody } from '@react-three/rapier';
const Table = (props) => {
return (
<group {...props}>
<RigidBody type='fixed'>
<mesh
castShadow
receiveShadow
geometry={nodes.Table.geometry}
material={materials.Wood}
position={[0, 0.068, 0]}
/>
</RigidBody>
{/* ... */}
</group>
);
};
Now, we should observe the ball falling and coming to rest on the table. We're adding a 'fixed' type to the rigid body because the table shouldn't move; it simply acts as a "floor" for our ball. Take note of the table's colliders as illustrated below:
Our table has a shape more complex than a basic cuboid, so let's use trimesh instead (yes, I know, emergency collider only..).
import { RigidBody, useRigidBody } from '@react-three/rapier';
const Table = (props) => {
return (
<group {...props}>
<RigidBody type='fixed' colliders='trimesh'>
<mesh
castShadow
receiveShadow
geometry={nodes.Table.geometry}
material={materials.Wood}
position={[0, 0.068, 0]}
/>
</RigidBody>
{/* ... */}
</group>
);
};
Now, you can observe that our collider perfectly matches the shape of our table.
Now that our base table is set up, we need to add physics to our glass as well. This should be the final mesh of the Table component (if you followed the code given in this serie):
import { RigidBody, useRigidBody } from '@react-three/rapier';
const Table = (props) => {
return (
<group {...props}>
{/* ... */}
<RigidBody type='fixed' colliders='trimesh'>
<mesh
castShadow
receiveShadow
geometry={nodes.Glass.geometry}
position={[0.497, 1.54, 0.005]}
>
<MeshTransmissionMaterial anisotropy={0.1} chromaticAberration={0.04} distortionScale={0} temporalDistortion={0} />
</mesh>
</RigidBody>
</group>
);
};
The last physics component to add is our basketball hoop. It comprises four meshes: Base, Cylinder, Panel, and Ring. Like the others, enclose these four meshes in a RigidBody with a fixed type and trimesh colliders.
import { RigidBody, useRigidBody } from '@react-three/rapier';
const Table = (props) => {
return (
<group {...props}>
{/* ... */}
<RigidBody type='fixed' colliders='trimesh'>
<mesh
castShadow
receiveShadow
geometry={nodes.Base.geometry}
material={materials.Wood}
position={[-2.235, 0.565, 0]}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Cylinder.geometry}
material={materials.Red}
position={[-2.235, 1.177, 0]}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Panel.geometry}
material={materials.Wood}
position={[-2.234, 1.814, 0]}
/>
<mesh
castShadow
receiveShadow
geometry={nodes.Ring.geometry}
material={materials.Red}
position={[-1.686, 1.46, 0]}
/>
</RigidBody>
{/* ... */}
</group>
);
};
We have now integrated physics into all of our models (except for a few used for controls)! You should be able to see all the colliders in your scene like this:
To observe our physics in action, let's adjust the ball's position slightly to the left and watch it roll across the table:
const Experience = () => {
return (
<>
{/* ... */}
<Physics debug>
<Center>
<Table position={[0, 0, 0]} />
{/* Let's change the third parameter of position to 0.2 */}
<Ball position={[0.25, 1.5, 0.2]} />
</Center>
</Physics>
</>
);
}
export default Experience;
Step 4: Tweak the Restitution and the Friction
Let's assign some default values to our existing physics to improve the ball's reaction to the floor, among other things.
Return to our Ball component and add restitution and friction to the RigidBody:
import { RigidBody } from '@react-three/rapier';
const Ball = ({ position }) => {
const { nodes, materials } = useGLTF("/models/basketball.glb");
return (
<group position={position} scale={0.7}>
<RigidBody colliders="ball" restitution={1} friction={0.2}>
<mesh
castShadow
receiveShadow
geometry={nodes.Sphere.geometry}
material={materials["Material.001"]}
/>
</RigidBody>
</group>
);
};
Restitution refers to the "bounciness" of an object. The default value is 0, meaning the object does not bounce. We set it to 1 to give our basketball a lot of bounce!
Friction refers to the degree to which surfaces rub against each other. The default value is 0.7. The higher the friction, the quicker the object will stop. To let our ball slide more on the table, we’ll reduce the friction to 0.2.
You should see the ball jumping more. Feel free to tweak these parameters to observe the changes!
Now, let's return to our table. We will add both restitution and friction to the table and the glass.
import { RigidBody, useRigidBody } from '@react-three/rapier';
const Table = (props) => {
return (
<group {...props}>
<RigidBody type='fixed' colliders='trimesh' restitution={0.6} friction={0}>
<mesh
castShadow
receiveShadow
geometry={nodes.Table.geometry}
material={materials.Wood}
position={[0, 0.068, 0]}
/>
</RigidBody>
{/* ... */}
<RigidBody type='fixed' colliders='trimesh' restitution={0.2} friction={0}>
<mesh
castShadow
receiveShadow
geometry={nodes.Glass.geometry}
position={[0.497, 1.54, 0.005]}
>
<MeshTransmissionMaterial anisotropy={0.1} chromaticAberration={0.04} distortionScale={0} temporalDistortion={0} />
</mesh>
</RigidBody>
</group>
);
};
You should see the ball jumping even more! We have now the perfect setup for our game. We can now add the controls.
Step 5: Integrating Controls
To make our game interactive, we need to give players control over the ball. We'll implement click controllers to trigger thrusters that propel the ball towards the basket ring.
First, we are going to add the “onclick” control on our Control_A
and Control_B
mesh.
Adding Control on Mesh
To identify our controls, we need to use useRef()
, a hook from React that allows us to reference a value. This value can be anything we want, such as a boolean, a number, a string, or an object. After assigning this reference to our mesh, we can retrieve it later.
Let's incorporate it into our Table component:
import { useRef } from 'react'
const Table = (props) => {
const controlA = useRef(null);
const controlB = useRef(null);
return (
<group {...props}>
{/* ... */}
<mesh
ref={controlA}
castShadow
receiveShadow
geometry={nodes.Control_A.geometry}
material={materials.Red}
position={[4.184, 0.129, 0.744]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Control_A_Text.geometry}
material={materials.White}
position={[0.237, 0.046, 0.21]}
rotation={[Math.PI / 2, 1.179, -Math.PI / 2]}
/>
</mesh>
<mesh
ref={controlB}
castShadow
receiveShadow
geometry={nodes.Control_B.geometry}
material={materials.Green}
position={[4.183, 0.128, -0.754]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Control_B_Text.geometry}
material={materials.White}
position={[0.25, 0.043, 0.207]}
rotation={[Math.PI / 2, 1.184, -Math.PI / 2]}
/>
</mesh>
{/* ... */}
</group>
);
};
Now that we have our references to Control A and Control B, we need to activate the "click" control. For this, the <mesh>
component from Three.js offers two functions that we can use: onPointerUp
and onPointerDown
.
We can use these functions to trigger a function we've created when a player clicks on ControlA or ControlB. Let's create these two functions and assign them to the controls:
import { useRef } from 'react'
const Table = (props) => {
const controlA = useRef(null);
const controlB = useRef(null);
const clickUp = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128
}
}
const clickDown = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128 - 0.1
}
}
return (
<group {...props}>
{/* ... */}
<mesh
ref={controlA}
castShadow
receiveShadow
geometry={nodes.Control_A.geometry}
material={materials.Red}
position={[4.184, 0.129, 0.744]}
onPointerUp={() => clickUp(controlA)}
onPointerDown={() => clickDown(controlA)}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Control_A_Text.geometry}
material={materials.White}
position={[0.237, 0.046, 0.21]}
rotation={[Math.PI / 2, 1.179, -Math.PI / 2]}
/>
</mesh>
<mesh
ref={controlB}
castShadow
receiveShadow
geometry={nodes.Control_B.geometry}
material={materials.Green}
position={[4.183, 0.128, -0.754]}
onPointerUp={() => clickUp(controlB)}
onPointerDown={() => clickDown(controlB)}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Control_B_Text.geometry}
material={materials.White}
position={[0.25, 0.043, 0.207]}
rotation={[Math.PI / 2, 1.184, -Math.PI / 2]}
/>
</mesh>
{/* ... */}
</group>
);
};
In this function, we "simulate" a click by slightly lowering the position of our control, similar to pressing a button. We trigger the clickDown
action when the user clicks the button, and clickUp
action when they release the click.
You can test it on your scene, you should see the button going down when clicking on it.
We can now proceed to the next part: adding physics to the thruster.
Adding Physics on Thrusters
In this section, we need to identify our thrusters using useRef
, add a special rigid body to them, and activate them when we use the controls. Let's get started:
const Table = (props) => {
// Add useRef on thruster to identify them
const thrusterA = useRef(null);
const thrusterB = useRef(null);
const clickUp = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128
}
}
const clickDown = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128 - 0.1
}
}
return (
<group {...props}>
{/* ... */}
<RigidBody
ref={thrusterA}
type="kinematicPosition"
colliders="hull"
lockRotations={true}
enabledTranslations={[false, true, false]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Thruster_A.geometry}
material={materials.Black}
position={[2.259, -0.189, 0.765]}
/>
</RigidBody>
<RigidBody
ref={thrusterB}
type="kinematicPosition"
colliders="hull"
lockRotations={true}
enabledTranslations={[false, true, false]}
>
<mesh
castShadow
receiveShadow
geometry={nodes.Thruster_B.geometry}
material={materials.Black}
position={[2.259, -0.189, -0.764]}
/>
</RigidBody>
{/* ... */}
</group>
);
};
We assign a specific type of RigidBody, kinematicPosition, to the thrusters. This type allows our object to be moved using special functions, such as translate/rotate, through code. The rigid body doesn't move until we instruct it to, and can't be moved by other rigid bodies, for example.
We also restrict rotations on this mesh, as we only want it to move up and down.
Now, we simply need to move it up or down when we press or release our controls. Let's do it:
const Table = (props) => {
const thrusterA = useRef(null);
const thrusterB = useRef(null);
const clickUp = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128
if(controlRef === controlA) {
const position = vec3(thrusterA.current.translation())
thrusterA.current.setNextKinematicTranslation({
x: position.x,
y: position.y - 0.5,
z: position.z
})
} else {
const position = vec3(thrusterB.current.translation())
thrusterB.current.setNextKinematicTranslation({
x: position.x,
y: position.y - 0.5,
z: position.z
})
}
}
}
const clickDown = (controlRef) => {
if (controlRef.current) {
controlRef.current.position.y = 0.128 - 0.1
if(controlRef === controlA) {
const position = vec3(thrusterA.current.translation())
thrusterA.current.setNextKinematicTranslation({
x: position.x,
y: position.y + 0.5,
z: position.z
})
} else {
const position = vec3(thrusterB.current.translation())
thrusterB.current.setNextKinematicTranslation({
x: position.x,
y: position.y + 0.5,
z: position.z
})
}
}
}
return (
{/* ... */}
);
};
Now, when we activate the clickUp
and clickDown
functions by pressing our controls, thruster A for control A and thruster B for control B will propel the ball across our table.
To accomplish this, we simply use the setNextKinematicTranslation
function to apply a translation to our object. We just need to add a number, such as 0.5, to the y-axis to move it upwards.
When the user clicks, the thruster elevates by 0.5. When the user releases the click, the thruster reverts back to -0.5.
Small Tweak to Improve Game
To enhance our game, I propose adding a parameter to the rigid body of our Ball component: gravityScale. This parameter enables us to influence how gravity impacts our object. By default, this value simulates Earth's gravity (-9.81 approximately). However, I want the ball to be more energetic and faster, so let's introduce a new value:
const Ball = ({ position }) => {
return (
<group position={position} scale={0.7}>
<RigidBody colliders="ball" restitution={1} friction={0.2} gravityScale={3.5}>
<mesh
castShadow
receiveShadow
geometry={nodes.Sphere.geometry}
material={materials["Material.001"]}
/>
</RigidBody>
</group>
);
};
Step 6: Testing and Debugging
With physics and controls established, thoroughly testing the game is crucial to observe their interactions. Based on this testing, adjust the physics parameters and controls as needed. Observe how different settings alter the gameplay.
If you encounter any issues, please don't hesitate to contact me!
Conclusion
Congratulations! You have successfully added interactivity and physics to your browser-based basketball game. The ball now moves realistically, and players can control actions within the game. It's time to start scoring some baskets!
In the next installment, we will introduce game mechanics to make the game fully playable. The last step until our final render!
Stay tuned and happy coding! 🏀