13
头图
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 :

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 the Percentage-Closer Filtering (PCF) algorithm to filter the shadow map, which is the default type.
  • THREE.PCFSoftShadowMap : Softer shadow maps filtered using the PCF algorithm, especially when using low resolution shadow maps.
  • THREE.VSMShadowMap : Shadow map using variance VSM algorithmically filtered shadow map. When using VSMShadowMap 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 of y值 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 the x轴 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 controller JoyStick the specific implementation of the class, please refer to the end of the article Codepen 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

References


dragonir
1.8k 声望3.9k 粉丝

Accepted ✔