Disclaimer: The graphic and model materials involved in this article are only for personal study, research and appreciation, please do not re-modify, illegally spread, reprint, publish, commercialize, or conduct other profit-making activities.
background
In the Kepler 1028 galaxy 2545 light -years away, there is a colorful and habitable planet🌑
, and interstellar immigrants👨🚀
must wear radiation protection suits issued by the base to survive. Ali🦊
driving the interstellar vehicle🚀
comes to this place, help him find the base within a limited time by moving the roulette wheel to get the radiation protection suit!
This article uses the Three.js + React + CANNON
technology stack to control the model by sliding the screen to move in the 3D
world Low Poly
low-poly style game. This article mainly related to the knowledge points include: Three.js
shadow type, create particle systems, cannon.js
basic usage, use cannon.js
height field Heightfield
Create terrain, control model animation with wheel movement, and more.
Effect
- How to play : Click the start game button, move Ahri by operating the roulette at the bottom of the screen, and find the base within the time limit of the countdown.
- Main quest : Find the shelter within a limited time.
- Side quests : Freely explore the open world.
Online preview :
-
👀
Address 1: https://3d-eosin.vercel.app/#/metaverse -
👀
Address 2: https://dragonir.github.io/3d/#/metaverse
Adapted:
-
💻
PC
end -
📱
Mobile
🚩
Tips: The higher you stand, the farther you can see. It is vaguely heard that the base is located in the west of the initial position. You should move forward to the left at the beginning.
design
The game flow is shown in the figure below: After the page is loaded, the player 👨🚀
clicks the start button, and then controls the roulette at the bottom of the page 🕹
to move the model within a limited time to find the location of the target base. . If the search succeeds or fails, the result page will be displayed 🏆
. There are two buttons on the result, try again and explore freely . Clicking try again will reset the time, and then return to the starting point to start the countdown. Clicking on free exploration will stop the timing. Players can operate the model to explore freely in the 3D
open world. At the same time, the in-game page also provides a time rewind button. Its function is that players 👨🚀
can manually reset the countdown before failure ⏳
, and start the game again.
accomplish
load resource
Load the prerequisite resources needed for development: GLTFLoader
for loading the fox 🦊
and base 🏠
for creating the model, CANNON
3D
creating 3D
Physics engine for the world; CannonHelper
Yes CANNON
Some wrappers for using mouse move position or touch; JoyStick
Touch the displacement generated by the screen to control the roulette wheel that moves the model 🕹
.
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import CANNON from 'cannon';
import CannonHelper from './scripts/CannonHelper';
import JoyStick from './scripts/JoyStick';
page structure
The page structure is relatively simple, .webgl
for rendering WEBGL
; .tool
is the in-game toolbar, used to reset the game and display some prompts; .loading
is the game loading page, which is used to display the game loading progress, introduce the game rules, and display the game start button; .result
is the game result page, which is used to display the game success or failure result, and provide replay Try once and explore both buttons freely 🔘
.
(<div id="metaverse">
<canvas className='webgl'></canvas>
<div className='tool'>
<div className='countdown'>{ this.state.countdown }</div>
<button className='reset_button' onClick={this.resetGame}>时光倒流</button>
<p className='hint'>站得越高看得越远</p>
</div>
{ this.state.showLoading ? (<div className='loading'>
<div className='box'>
<p className='progress'>{this.state.loadingProcess} %</p>
<p className='description'>游戏描述</p>
<button className='start_button' style={{'visibility': this.state.loadingProcess === 100 ? 'visible' : 'hidden'}} onClick={this.startGame}>开始游戏</button>
</div>
</div>) : '' }
{ this.state.showResult ? (<div className='result'>
<div className='box'>
<p className='text'>{ this.state.resultText }</p>
<button className='button' onClick={this.resetGame}>再试一次</button>
<button className='button' onClick={this.discover}>自由探索</button>
</div>
</div>) : '' }
</div>)
Data initialization
Data variables include the loading progress, whether to display the loading page, whether to display the result page, the copy of the result page, the countdown, whether to enable free exploration, etc.
state = {
loadingProcess: 0,
showLoading: true,
showResult: false,
resultText: '失败',
countdown: 60,
freeDiscover: false
}
scene initialization
Initialize scene 🏔
, camera 📷
, light source 💡
.
const renderer = new THREE.WebGLRenderer({
canvas: document.querySelector('canvas.webgl'),
antialias: true,
alpha: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
// 添加主相机
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, .01, 100000);
camera.position.set(1, 1, -1);
camera.lookAt(scene.position);
// 添加环境光
const ambientLight = new THREE.AmbientLight(0xffffff, .4);
scene.add(ambientLight)
// 添加平行光
var light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(1, 1, 1).normalize();
scene.add(light);
💡
Three.js shadow type
This article uses THREE.PCFSoftShadowMap
to enable softer shadows, Three.js
to provide the following shadow types:
-
THREE.BasicShadowMap
: Provides unfiltered shadow maps, fastest performance, but lowest quality. -
THREE.PCFShadowMap
: Use thePercentage-Closer Filtering (PCF)
algorithm to filter the shadow map, which is the default type. -
THREE.PCFSoftShadowMap
: Softer shadow maps filtered using thePCF
algorithm, especially when using low resolution shadow maps. -
THREE.VSMShadowMap
: Shadow map using varianceVSM
algorithmically filtered shadow map. When usingVSMShadowMap
all shadow receivers also cast shadows.
Create the world
Use Cannon.js
initialize the physical world 🌏
.
// 初始化物理世界
const world = new CANNON.World();
// 在多个步骤的任意轴上测试刚体的碰撞
world.broadphase = new CANNON.SAPBroadphase(world);
// 设置物理世界的重力为沿y轴向上-10米每二次方秒
world.gravity.set(0, -10, 0);
// 创建默认联系材质
world.defaultContactMaterial.friction = 0;
const groundMaterial = new CANNON.Material("groundMaterial");
const wheelMaterial = new CANNON.Material("wheelMaterial");
const wheelGroundContactMaterial = new CANNON.ContactMaterial(wheelMaterial, groundMaterial, {
// 摩擦系数
friction: 0,
// 恢复系数
restitution: 0,
// 接触刚度
contactEquationStiffness: 1000
});
world.addContactMaterial(wheelGroundContactMaterial);
💡
Cannon.js
Cannon.js
is a physics engine library implemented with JavaScript
, which can be used with any rendering or game engine that supports browsers, and can be used to simulate rigid bodies, to achieve 3D
🌏
More realistic physical form of movement and interaction. More Cannon.js
related API
documents and examples can refer to the link at the end of the article.
Create a starry sky
Create 1000
particles for the model starry sky ✨
and add them to the scene. In this example, the particles are created in the form of shaders, which is more conducive to the rendering efficiency of GPU
.
const textureLoader = new THREE.TextureLoader();
const shaderPoint = THREE.ShaderLib.points;
const uniforms = THREE.UniformsUtils.clone(shaderPoint.uniforms);
uniforms.map.value = textureLoader.load(snowflakeTexture);
for (let i = 0; i < 1000; i++) {
sparkGeometry.vertices.push(new THREE.Vector3());
}
const sparks = new THREE.Points(new THREE.Geometry(), new THREE.PointsMaterial({
size: 2,
color: new THREE.Color(0xffffff),
map: uniforms.map.value,
blending: THREE.AdditiveBlending,
depthWrite: false,
transparent: true,
opacity: 0.75
}));
sparks.scale.set(1, 1, 1);
sparks.geometry.vertices.map(spark => {
spark.y = randnum(30, 40);
spark.x = randnum(-500, 500);
spark.z = randnum(-500, 500);
return true;
});
scene.add(sparks);
Create terrain
Created by CANNON.Heightfield
128 x 128 x 60
to visualize gradient terrain. The unevenness of the terrain is realized through the following height map HeightMap
, which is a black and white picture 🖼
, which records the height information through the color depth of the pixels, and creates it according to the height map data information. Terrain grid. A random heightmap can be generated online via the link provided at the end of the article. Terrain generation is complete and it is added to the world 🌏
, then the animate
method is called when the page is redrawn in the check
method to detect and update the model in location on the terrain.
const cannonHelper = new CannonHelper(scene);
var sizeX = 128, sizeY = 128, minHeight = 0, maxHeight = 60, check = null;
Promise.all([
// 加载高度图
img2matrix.fromUrl(heightMapImage, sizeX, sizeY, minHeight, maxHeight)(),
]).then(function (data) {
var matrix = data[0];
// 地形体
const terrainBody = new CANNON.Body({ mass: 0 });
// 地形形状
const terrainShape = new CANNON.Heightfield(matrix, { elementSize: 10 });
terrainBody.addShape(terrainShape);
// 地形位置
terrainBody.position.set(-sizeX * terrainShape.elementSize / 2, -10, sizeY * terrainShape.elementSize / 2);
// 设置从轴角度
terrainBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
world.add(terrainBody);
// 将生成的地形刚体可视化
cannonHelper.addVisual(terrainBody, 'landscape');
var raycastHelperGeometry = new THREE.CylinderGeometry(0, 1, 5, 1.5);
raycastHelperGeometry.translate(0, 0, 0);
raycastHelperGeometry.rotateX(Math.PI / 2);
var raycastHelperMesh = new THREE.Mesh(raycastHelperGeometry, new THREE.MeshNormalMaterial());
scene.add(raycastHelperMesh);
// 使用 Raycaster检测并更新模型在地形上的位置
check = () => {
var raycaster = new THREE.Raycaster(target.position, new THREE.Vector3(0, -1, 0));
var intersects = raycaster.intersectObject(terrainBody.threemesh.children[0]);
if (intersects.length > 0) {
raycastHelperMesh.position.set(0, 0, 0);
raycastHelperMesh.lookAt(intersects[0].face.normal);
raycastHelperMesh.position.copy(intersects[0].point);
}
target.position.y = intersects && intersects[0] ? intersects[0].point.y + 0.1 : 30;
var raycaster2 = new THREE.Raycaster(shelterLocation.position, new THREE.Vector3(0, -1, 0));
var intersects2 = raycaster2.intersectObject(terrainBody.threemesh.children[0]);
shelterLocation.position.y = intersects2 && intersects2[0] ? intersects2[0].point.y + .5 : 30;
shelterLight.position.y = shelterLocation.position.y + 50;
shelterLight.position.x = shelterLocation.position.x + 5
shelterLight.position.z = shelterLocation.position.z;
}
});
💡
CANNON.Heightfield
The bumpy terrain in this example is achieved by CANNON.Heightfield
which is Cannon.js
the height field of the physics engine. In physics, the distribution of a physical quantity in a region in space is called a field , and a height field is a field related to height. The height of Heightfield
is a function of two variables, which can be expressed as HEIGHT(i,j)
.
Heightfield(data, options)
-
data
is an array ofy值
that will be used to build the terrain. options
is a configuration item with three configurable parameters:-
minValue
is the minimum value of a data point in the data array. If not given, it will be calculated automatically. -
maxValue
Maximum value. -
elementSize
is the world spacing between data points in thex轴
direction.
-
Loading progress management
Use LoadingManager
manage the loading progress. When the page model is loaded, the loading progress page displays the start game menu .
const loadingManager = new THREE.LoadingManager();
loadingManager.onProgress = async (url, loaded, total) => {
this.setState({ loadingProcess: Math.floor(loaded / total * 100) });
};
Create a base model
Before loading the base model 🏠
create a shelterLocation
mesh to place the base model, and the mesh object is also used for subsequent terrain detection. Then use GLTFLoader
to load the base model and add it to the shelterLocation
mesh. Finally add a PointLight
💡
to the base model to add a colored point light source, add a DirectionalLight
💡
for shadow generation.
const shelterGeometry = new THREE.BoxBufferGeometry(0.15, 2, 0.15);
const shelterLocation = new THREE.Mesh(shelterGeometry, new THREE.MeshNormalMaterial({
transparent: true,
opacity: 0
}));
shelterLocation.position.set(this.shelterPosition.x, this.shelterPosition.y, this.shelterPosition.z);
shelterLocation.rotateY(Math.PI);
scene.add(shelterLocation);
// 加载模型
gltfLoader.load(Shelter, mesh => {
mesh.scene.traverse(child => {
child.castShadow = true;
});
mesh.scene.scale.set(5, 5, 5);
mesh.scene.position.y = -.5;
shelterLocation.add(mesh.scene)
});
// 添加光源
const shelterPointLight = new THREE.PointLight(0x1089ff, 2);
shelterPointLight.position.set(0, 0, 0);
shelterLocation.add(shelterPointLight);
const shelterLight = new THREE.DirectionalLight(0xffffff, 0);
shelterLight.position.set(0, 0, 0);
shelterLight.castShadow = true;
shelterLight.target = shelterLocation;
scene.add(shelterLight);
Create Ali model
The loading of the fox 🦊
model is also similar. You need to create a target grid first, which will be used for terrain detection, and then add the fox 🦊
model to the target grid. Fox 🦊
after completion of loading the model, it needs to be saved clip1
, clip1
two kinds of animation, the wheel is determined by the subsequent need 🕹
to determine which animation to play. Finally add a DirectionalLight
💡
light source to create shadows.
var geometry = new THREE.BoxBufferGeometry(.5, 1, .5);
geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, .5, 0));
const target = new THREE.Mesh(geometry, new THREE.MeshNormalMaterial({
transparent: true,
opacity: 0
}));
scene.add(target);
var mixers = [], clip1, clip2;
const gltfLoader = new GLTFLoader(loadingManager);
gltfLoader.load(foxModel, mesh => {
mesh.scene.traverse(child => {
if (child.isMesh) {
child.castShadow = true;
child.material.side = THREE.DoubleSide;
}
});
var player = mesh.scene;
player.position.set(this.playPosition.x, this.playPosition.y, this.playPosition.z);
player.scale.set(.008, .008, .008);
target.add(player);
var mixer = new THREE.AnimationMixer(player);
clip1 = mixer.clipAction(mesh.animations[0]);
clip2 = mixer.clipAction(mesh.animations[1]);
clip2.timeScale = 1.6;
mixers.push(mixer);
});
const directionalLight = new THREE.DirectionalLight(new THREE.Color(0xffffff), .5);
directionalLight.position.set(0, 1, 0);
directionalLight.castShadow = true;
directionalLight.target = target;
target.add(directionalLight);
Control Ali's movement
When using the roulette controller to move 🦊
model, the direction of the model is updated in real time. If the roulette wheel is displaced, the displacement of the model is updated and the running animation is played. Otherwise, the animation is prohibited from playing. At the same time, according to the position of the model target, the position of the camera 📷
is updated in real time to generate a third-person perspective. The roulette movement control model movement function is realized by introducing the JoyStick
class. Its main realization principle is to monitor the mouse or touch position, and then map it to the model position change through calculation.
var setup = { forward: 0, turn: 0 };
new JoyStick({ onMove: (forward, turn) => {
setup.forward = forward;
setup.turn = -turn;
}});
const updateDrive = (forward = setup.forward, turn = setup.turn) => {
let maxSteerVal = 0.05;
let maxForce = .15;
let force = maxForce * forward;
let steer = maxSteerVal * turn;
if (forward !== 0) {
target.translateZ(force);
clip2 && clip2.play();
clip1 && clip1.stop();
} else {
clip2 && clip2.stop();
clip1 && clip1.play();
}
target.rotateY(steer);
}
// 生成第三人称视角
const followCamera = new THREE.Object3D();
followCamera.position.copy(camera.position);
scene.add(followCamera);
followCamera.parent = target;
const updateCamera = () => {
if (followCamera) {
camera.position.lerp(followCamera.getWorldPosition(new THREE.Vector3()), 0.1);
camera.lookAt(target.position.x, target.position.y + .5, target.position.z);
}
}
🚩
Roulette controllerJoyStick
the specific implementation of the class, please refer to the end of the articleCodepen
link [5].
animation update
During page repaint animation, update camera, model state, Cannon
world, scene rendering, etc.
var clock = new THREE.Clock();
var lastTime;
var fixedTimeStep = 1.0 / 60.0;
const animate = () => {
updateCamera();
updateDrive();
let delta = clock.getDelta();
mixers.map(x => x.update(delta));
let now = Date.now();
lastTime === undefined && (lastTime = now);
let dt = (Date.now() - lastTime) / 1000.0;
lastTime = now;
world.step(fixedTimeStep, dt);
cannonHelper.updateBodies(world);
check && check();
renderer.render(scene, camera);
requestAnimationFrame(animate);
};
page scaling
When the page is zoomed, update the rendered scene 🏔
and the camera 📷
.
window.addEventListener('resize', () => {
var width = window.innerWidth, height = window.innerHeight;
renderer.setSize(width, height);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}, false);
So far, the 3D world of the game 🌏
has been fully realized.
Add game logic
According to the previous game process design, now add game logic, reset the data and start the game when starting the game 60s
Countdown ⏳
🦊
The position, direction and camera position are set to the initial state; the free exploration state is turned on during free exploration, and the countdown is cleared. ⏳
startGame = () => {
this.setState({
showLoading : false,
showResult: false,
countdown: 60,
resultText: '失败',
freeDiscover: false
},() => {
this.interval = setInterval(() => {
if (this.state.countdown > 0) {
this.setState({
countdown: --this.state.countdown
});
} else {
clearInterval(this.interval)
this.setState({
showResult: true
});
}
}, 1000);
});
}
resetGame = () => {
this.player.position.set(this.playPosition.x, this.playPosition.y, this.playPosition.z);
this.target.rotation.set(0, 0, 0);
this.target.position.set(0, 0, 0);
this.camera.position.set(1, 1, -1);
this.startGame();
}
discover = () => {
this.setState({
freeDiscover: true,
showResult: false,
countdown: 60
}, () => {
clearInterval(this.interval);
});
}
frosted glass effect
Loading
The page, the result page and the back to the past button all use the frosted glass effect style 💧
, through the following lines of style code, you can achieve amazing frosted glass.
background rgba(0, 67, 170, .5)
backdrop-filter blur(10px)
filter drop-shadow(0px 1px 1px rgba(0, 0, 0, .25))
Summarize
The new knowledge points involved in this article mainly include:
-
Three.js
Shadow Type - Create a particle system
-
cannon.js
Basic usage - Create terrain using
cannon.js
Heightfield
- Control model animation with wheel movement
To learn about scene initialization, lighting, shadows, basic geometry, meshes, materials, and other Three.js related knowledge, you can read my previous articles. Please indicate the original address and author when reprinting . If you think the article is helpful to you, don't forget to click three links 👍 .
appendix
- [1]. Three.js flame effect to realize the dynamic logo of Ayrden's Ring
- [2]. Three.js realizes the magical 3D text suspension effect
- [3]. Three.js implements 2D images with 3D effects
- [4]. Three.js implements the 2022 Winter Olympics theme 3D fun page, Bingdundun🐼
- [5]. Three.js to create an exclusive 3D medal
- [6]. Three.js realizes the 3D creative page for the Spring Festival of the Tiger Year
- [7]. Three.js implements Facebook metaverse 3D dynamic logo
- [8]. Three.js implements a 3D panoramic detective game
- [9]. Three.js implements cool acid style 3D pages
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。