visit
In modern web development, the boundaries between classic and web applications are blurring every day. Today we can create not only interactive websites, but also full-fledged games right in the browser. One of the tools that makes this possible is the library - a powerful tool for creating 3D graphics based on using React technology.
React Three Fiber is a wrapper over Three.js that uses the structure and principles of React to create 3D graphics on the web. This stack allows developers to combine the power of Three.js with the convenience and flexibility of React, making the process of creating an application more intuitive and organised.
At the heart of React Three Fiber is the idea that everything you create in a scene is a React component. This allows developers to apply familiar patterns and methodologies.
One of the main advantages of React Three Fiber is its ease of integration with the React ecosystem. Any other React tools can still be easily integrated when using this library.
Web-GameDev has undergone major changes in recent years, evolving from simple 2D games to complex 3D projects comparable to desktop applications. This growth in popularity and capabilities makes Web-GameDev an area that cannot be ignored.
Modern browsers have come a long way, evolving from fairly simple web browsing tools to powerful platforms for running complex applications and games. Major browsers such as Chrome, Firefox, Edge and others are constantly being optimised and developed to ensure high performance, making them an ideal platform for developing complex applications.
One of the key tools that has fuelled the development of browser-based gaming is . This standard allowed developers to use hardware graphics acceleration, which significantly improved the performance of 3D games. Together with other webAPIs, WebGL opens up new possibilities for creating impressive web applications directly in the browser.
First of all, we will need a React project template. So let's start by installing it.
npm create vite@latest
npm install three @react-three/fiber @react-three/drei @react three/rapier zustand @tweenjs/tween.js
In the main.jsx file, add a div element that will be displayed on the page as a scope. Insert a Canvas component and set the field of view of the camera. Inside the Canvas component place the App component.
Let's add styles to index.css to stretch the UI elements to the full height of the screen and display the scope as a circle in the centre of the screen.
In the App component we add a Sky component, which will be displayed as the background in our game scene in the form of a sky.
Let's create a Ground component and place it in the App component.
In Ground, create a flat surface element. On the Y axis move it downwards so that this plane is in the field of view of the camera. And also flip the plane on the X axis to make it horizontal.
By default, there is no lighting in the scene, so let's add a light source ambientLight, which illuminates the object from all sides and does not have a directed beam. As a parameter set the intensity of the glow.
In the assets folder add a PNG image with a texture.
To load a texture on the scene, let's use the useTexture hook from the @react-three/drei package. And as a parameter for the hook we will pass the texture image imported into the file. Set the repetition of the image in the horizontal axes.
Using the PointerLockControls component from the @react-three/drei package, fix the cursor on the screen so that it does not move when you move the mouse, but changes the position of the camera on the scene.
Let's make a small edit for the Ground component.
<mesh position={[0, 3, -5]}>
<boxGeometry />
</mesh>
Use the Physics component from the @react-three/rapier package to add "physics" to the scene. As a parameter, configure the gravity field, where we set the gravitational forces along the axes.
<Physics gravity={[0, -20, 0]}>
<Ground />
<mesh position={[0, 3, -5]}>
<boxGeometry />
</mesh>
</Physics>
However, our cube is inside the physics component, but nothing happens to it. To make the cube behave like a real physical object, we need to wrap it in the RigidBody component from the @react-three/rapier package.
Let's go back to the Ground component and add a RigidBody component as a wrapper over the floor surface.
Let's create a Player component that will control the character on the scene.
The character is the same physical object as the added cube, so it must interact with the floor surface as well as the cube on the scene. That's why we add the RigidBody component. And let's make the character in the form of a capsule.
Place the Player component inside the Physics component.
The character will be controlled using the WASD keys, and jump using the Spacebar.
With our own react-hook, we implement the logic of moving the character.
Let's create a hooks.js file and add a new usePersonControls function there.
After implementing the usePersonControls hook, it should be used when controlling the character. In the Player component we will add motion state tracking and update the vector of the character's movement direction.
To update the character's position, let's useFrame provided by the @react-three/fiber package. This hook works similarly to requestAnimationFrame and executes the body of the function about 60 times per second.
Code Explanation:
1. const playerRef = useRef(); Create a link for the player object. This link will allow direct interaction with the player object on the scene.
2. const { forward, backward, left, right, jump } = usePersonControls(); When a hook is used, an object with boolean values indicating which control buttons are currently pressed by the player is returned.
3. useFrame((state) => { ... }); The hook is called on each frame of the animation. Inside this hook, the player's position and linear velocity are updated.
4. if (!playerRef.current) return; Checks for the presence of a player object. If there is no player object, the function will stop execution to avoid errors.
5. const velocity = playerRef.current.linvel(); Get the current linear velocity of the player.
6. frontVector.set(0, 0, backward - forward); Set the forward/backward motion vector based on the pressed buttons.
7. sideVector.set(left - right, 0, 0); Set the left/right movement vector.
8. direction.subVectors(frontVector, sideVector).normalize().multiplyScalar(MOVE_SPEED); Calculate the final vector of player movement by subtracting the movement vectors, normalising the result (so that the vector length is 1) and multiplying by the movement speed constant.
9. playerRef.current.wakeUp(); "Wakes up" the player object to make sure it reacts to changes. If you don't use this method, after some time the object will "sleep" and will not react to position changes.
10. playerRef.current.setLinvel({ x: direction.x, y: velocity.y, z: direction.z }); Set the player's new linear velocity based on the calculated direction of movement and keep the current vertical velocity (so as not to affect jumps or falls).
As a result, when pressing the WASD keys, the character started moving around the scene. He can also interact with the cube, because they are both physical objects.
In order to implement the jump, let's use the functionality from the @dimforge/rapier3d-compat and @react-three/rapier packages. In this example, let's check that the character is on the ground and the jump key has been pressed. In this case, we set the character's direction and acceleration force on the Y-axis.
For Player we will add mass and block rotation on all axes, so that he will not fall over in different directions when colliding with other objects on the scene.
Code Explanation:
- const world = rapier.world; Gaining access to the Rapier physics engine scene. It contains all physical objects and manages their interaction.
- const ray = world.castRay(new RAPIER.Ray(playerRef.current.translation(), { x: 0, y: -1, z: 0 })); This is where "raycasting" (raycasting) takes place. A ray is created that starts at the player's current position and points down the y-axis. This ray is "cast" into the scene to determine if it intersects with any object in the scene.
- const grounded = ray && ray.collider && Math.abs(ray.toi) <= 1.5; The condition is checked if the player is on the ground:
- ray - whether the ray was created;
- ray.collider - whether the ray collided with any object on the scene;
- Math.abs(ray.toi) - the "exposure time" of the ray. If this value is less than or equal to the given value, it may indicate that the player is close enough to the surface to be considered "on the ground".
You also need to modify the Ground component so that the raytraced algorithm for determining the "landing" status works correctly, by adding a physical object that will interact with other objects in the scene.
To move the camera, we will get the current position of the player and change the position of the camera every time the frame is refreshed. And for the character to move exactly along the trajectory, where the camera is directed, we need to add applyEuler.
Code Explanation:
The applyEuler method applies rotation to a vector based on specified Euler angles. In this case, the camera rotation is applied to the direction vector. This is used to match the motion relative to the camera orientation, so that the player moves in the direction the camera is rotated.
Let's slightly adjust the size of Player and make it taller relative to the cube, increasing the size of CapsuleCollider and fixing the "jump" logic.
To make the scene not feel completely empty, let's add cube generation. In the json file, list the coordinates of each of the cubes and then display them on the scene. To do this, create a file cubes.json, in which we will list an array of coordinates.
[
[0, 0, -7],
[2, 0, -7],
[4, 0, -7],
[6, 0, -7],
[8, 0, -7],
[10, 0, -7]
]
In the Cube.jsx file, create a Cubes component, which will generate cubes in a loop. And Cube component will be directly generated object.
import {RigidBody} from "@react-three/rapier";
import cubes from "./cubes.json";
export const Cubes = () => {
return cubes.map((coords, index) => <Cube key={index} position={coords} />);
}
const Cube = (props) => {
return (
<RigidBody {...props}>
<mesh castShadow receiveShadow>
<meshStandardMaterial color="white" />
<boxGeometry />
</mesh>
</RigidBody>
);
}
Let's add the created Cubes component to the App component by deleting the previous single cube.
In order to get the format we need to import the model into the scene, we will need to install the gltf-pipeline add-on package.
npm i -D gltf-pipeline
Using the gltf-pipeline package, reconvert the model from the GLTF format to the GLB format, since in this format all model data are placed in one file. As an output directory for the generated file we specify the public folder.
gltf-pipeline -i weapon/scene.gltf -o public/weapon.glb
Then we need to generate a react component that will contain the markup of this model to add it to the scene. Let's use the from the @react-three/fiber developers.
Going to the converter will require you to load the converted weapon.glb file.
In the converter we will see the generated react-component, the code of which we will transfer to our project in a new file WeaponModel.jsx, changing the name of the component to the same name as the file.
Now let's import the created model to the scene. In App.jsx file add WeaponModel component.
To enable shadows on the scene you need to add the shadows attribute to the Canvas component.
Next, we need to add a new light source. Despite the fact that we already have ambientLight on the scene, it cannot create shadows for objects, because it does not have a directional light beam. So let's add a new light source called directionalLight and configure it. The attribute to enable the "cast" shadow mode is castShadow. It is the addition of this parameter that indicates that this object can cast a shadow on other objects.
After that, let's add another attribute receiveShadow to the Ground component, which means that the component in the scene can receive and display shadows on itself.
Similar attributes should be added to other objects on the scene: cubes and player. For the cubes we will add castShadow and receiveShadow, because they can both cast and receive shadows, and for the player we will add only castShadow.
Let's add castShadow for Player.
Add castShadow and receiveShadow for Cube.
The reason for this is that by default the camera captures only a small area of the displayed shadows from directionalLight. We can for the directionalLight component by adding additional attributes shadow-camera-(top, bottom, left, right) to expand this area of visibility. After adding these attributes, the shadow will become slightly blurred. To improve the quality, we will add the shadow-mapSize attribute.
Now let's add first-person weapon display. Create a new Weapon component, which will contain the weapon behaviour logic and the 3D model itself.
import {WeaponModel} from "./WeaponModel.jsx";
export const Weapon = (props) => {
return (
<group {...props}>
<WeaponModel />
</group>
);
}
Let's place this component on the same level as the RigidBody of the character and in the useFrame hook we will set the position and rotation angle based on the position of the values from the camera.
To make the character's gait more natural, we will add a slight wiggle of the weapon while moving. To create the animation we will use the installed tween.js library.
The Weapon component will be wrapped in a group tag so that you can add a reference to it via the useRef hook.
Let's add some useState to save the animation.
Code Explanation:
- const twSwayingAnimation = new TWEEN.Tween(currentPosition) ... Creating an animation of an object "swinging" from its current position to a new position.
- const twSwayingBackAnimation = new TWEEN.Tween(currentPosition) ... Creating an animation of the object returning back to its starting position after the first animation has completed.
- twSwayingAnimation.chain(twSwayingBackAnimation); Connecting two animations so that when the first animation completes, the second animation automatically starts.
In useEffect we call the animation initialisation function.
Code Explanation:
- const isMoving = direction.length() > 0; Here the object's movement state is checked. If the direction vector has a length greater than 0, it means that the object has a direction of movement.
- if (isMoving && isSwayingAnimationFinished) { ... } This state is executed if the object is moving and the "swinging" animation has finished.
In the App component, let's add a useFrame where we will update the tween animation.
TWEEN.update() updates all active animations in the TWEEN.js library. This method is called on each animation frame to ensure that all animations run smoothly.
We need to define the moment when a shot is fired - that is, when the mouse button is pressed. Let's add useState to store this state, useRef to store a reference to the weapon object, and two event handlers for pressing and releasing the mouse button.
Let's implement a recoil animation when clicking the mouse button. We will use tween.js library for this purpose.
Let's make functions to get a random vector of recoil animation - generateRecoilOffset and generateNewPositionOfRecoil.
Create a function to initialise the recoil animation. We will also add useEffect, in which we will specify the "shot" state as a dependency, so that at each shot the animation is initialised again and new end coordinates are generated.
And in useFrame, let's add a check for "holding" the mouse key for firing, so that the firing animation doesn't stop until the key is released.
To do this, let's add some new states via useState.