Three.js 仿制网页版MineCraft(我的世界)
P.S. 目前还不支持手机端, 不过手机端的小伙伴可以先看看视频 :<
前言
笔者在前一阵子接触到 Three.js 后, 发现了它能为前端 3D 可视化 / 动画 / 游戏方向带来的无限可能, 正好最近在与朋友重温我的世界, 便有了用 Three.js 来仿制 MineCraft 的想法, 正好也可以通过一个有趣的项目来学习一下前端 3D 领域
介绍
游戏介绍
相信大家对我的世界应该都不太陌生, 他是一款 3D 像素风的生存类游戏, 本项目是模仿我的世界的来进行实现的, 目前大致支持了以下的功能:
- 方块的放置 / 破坏 
- 选择不同的方块类型 
- 移动和碰撞检测 
- 随机的地形和树木生成 
- 无限的世界 
- 保存 / 读取游戏 
- 音效和背景音乐 
- 可调节的渲染距离和视野范围 
- 基本的 UI 
除此之外, 笔者目前也在尝试着为项目添加一些别的特性:
- 生成水 
- 更多的保存栏位 
- 手机支持 
玩法介绍
技术栈介绍
因为项目的初衷便是探索一下 Three.js, 所以除了 Three.js 之外并没有别的第三方库依赖, 因此最后的打包大小其实是十分轻量级的, gzipped 后仅有 140kb 左右.
在此之上笔者还添加了 TypeScript 来进行类型检测, 并且使用了 Vite 进行的开发
源码介绍
上图是整体项目的源码架构, 开发主要基于了 Class 写法, 主要有五大类以及一些子类, 分别为:
- Core: 包含了- Three.js中的一些核心内容, 并且进行一些初始化设定
- Player: 包含了玩家的一些基础属性以及当前模式(行走, 奔跑, 作弊等)
- Audio: 主要进行声音的导入, 并且暴露了一些用于播放音乐的 api
- Terrain: 包含了与地形相关的各种内容:
- Blocks: 自定义方块类
- Materials: 各种方块的材质加载
- Noise: 通过柏林噪音实现了地形, 树木, 方块类型的随机生成算法
- GenerateWorker: 通过- web worker的方式实现了地形动态生成的算法
- Mesh: 场景中基础的网格体(方块)
- Highlight: 实现了实时高亮准心位置方块的算法
- Control: 包含了各种与操作相关的算法, 比如移动, 镜头转动, 碰撞检测等
- CollideWorker: 在- web worker中实现碰撞检测以提高运行效率
- ui:包含了 ui 界面以及其功能: 
- Bag: 放置不同方块的背包
- FPS: 实时展示当前- fps
核心技术点
笔者会在这一部分深入的分析一下项目的核心技术点以及一些遇到的难点, 如果大家只是来试玩看看 / 图一乐儿的话, 这一部分就可以跳过啦~ 不过要是有同学对底层的实现或者 Three.js 感兴趣的话, 也可以看看这一部分
随机的地形生成
地形生成采用了柏林噪音来进行实现的, Three.js 中自带了噪音的底层算法实现, 所以笔者只对其进行了一下简单的封装:
import { ImprovedNoise } from 'three/examples/jsm/math/ImprovedNoise'
export default class Noise {
  noise = new ImprovedNoise()
  seed = Math.random()
  stoneSeed = this.seed * 0.4
  coalSeed = this.seed * 0.5
  treeSeed = this.seed * 0.7
  leafSeed = this.seed * 0.8
  
  get = (x: number, y: number, z: number) => {
    return this.noise.noise(x, y, z)
  }
}
复制代码然后在地形生成的模块下, 首先为不同的方块类型创建了对应数量的 InstancedMesh, 然后将其存放到名为 blocks 的数组中:
const blocks: THREE.InstancedMesh[] = []
blocks[i].instanceMatrix = new THREE.InstancedBufferAttribute(
  new Float32Array(maxCount * blocksFactor[i] * 16),
  16
)
复制代码然后在具体的循环中首先依据前面的种子来判断地形的高度, 然后依据具体方块类型的种子来判断具体该渲染什么样的方块类型, 最后将每一个方块的位移量写入对应的 InstancedMesh 中:
  for (
    let x = -chunkSize * distance + chunkSize * chunk.x;
    x < chunkSize * distance + chunkSize + chunkSize * chunk.x;
    x++
  ) {
    for (
      let z = -chunkSize * distance + chunkSize * chunk.y;
      z < chunkSize * distance + chunkSize + chunkSize * chunk.y;
      z++
    ) 
      const yOffset = Math.floor(
        noise.get(x / noise.gap, z / noise.gap, noise.seed) * noise.amp
      )
      matrix.setPosition(x, y + yOffset, z)
      // 如果为草方块
      blocks[BlockType.grass].setMatrixAt(
        blocksCount[BlockType.grass]++,
        matrix
      )
      // 如果为其他方块
      ...
    }
  }
复制代码除了地形外, 对于树和树叶的生成也是大同小异, 这里就不展开了.
无限动态地形生成
除去最基本的地形外, 笔者还添加了无限动态地形生成的算法从而实现的一个无限大小的世界, 这样就不至于说会走到地形的边界然后掉出世界了 XD
具体的实现的话是通过在 requestAnimationFrame (以下简称 raf) 的回调函数中判断玩家是否移动到了新的区块, 如果区块发生了变化, 则会触发一次渲染:
  update = () => {
    this.chunk.set(
      Math.floor(this.camera.position.x / this.chunkSize),
      Math.floor(this.camera.position.z / this.chunkSize)
    )
    // 当进入新的区块时, 触发一次渲染
    if (
      this.chunk.x !== this.previousChunk.x ||
      this.chunk.y !== this.previousChunk.y
    ) {
      this.generate()
    }
    this.previousChunk.copy(this.chunk)
  }
复制代码对于具体的 generate 部分, 因为对于地形的位置的计算是一个比较耗时的过程, 所以如果直接在主线程中进行运算的话则会带来卡顿的感觉, 所以具体计算的部分则是移动到了 web worker 中去实现的, 然后只在主线程中行进最后的渲染.
笔者也是在这个项目中发现了 web worker 不允许传输函数, 所以像各种 Three.js 中的类都无法直接进行传输, 最后不得不封装了一些自定义的数据结构来进行数据的沟通:
  // 将数据传入 web worker
  generate = () => {
    this.blocksCount = new Array(this.blocks.length).fill(0)
    this.generateWorker.postMessage({
      distance: this.distance,
      chunk: this.chunk,
      noiseSeed: this.noise.seed,
      treeSeed: this.noise.treeSeed,
      stoneSeed: this.noise.stoneSeed,
      coalSeed: this.noise.coalSeed,
      idMap: new Map<string, number>(),
      blocksFactor: this.blocksFactor,
      blocksCount: this.blocksCount,
      customBlocks: this.customBlocks,
      chunkSize: this.chunkSize
    })
  }
// 处理返回的数据
this.generateWorker.onmessage = (
  msg: MessageEvent<{
    idMap: Map<string, number>
    arrays: ArrayLike<number>[]
    blocksCount: number[]
  }>
) => {
  this.resetBlocks()
  this.idMap = msg.data.idMap
  this.blocksCount = msg.data.blocksCount
  for (let i = 0; i < msg.data.arrays.length; i++) {
    this.blocks[i].instanceMatrix = new THREE.InstancedBufferAttribute(
      (this.blocks[i].instanceMatrix.array = msg.data.arrays[i]),
      16
    )
  }
  for (const block of this.blocks) {
    block.instanceMatrix.needsUpdate = true
  }
}
复制代码无限动态地形生成(垂直方向)
上面的这么多渲染其实只对玩家可见的部分进行了渲染, 而对于玩家不可见的部分 (地表下面) ,一开始也是不去进行渲染的, 直到需要被展示的时候才去动态生成.
具体的逻辑是在玩家破坏一个方块后, 会对那个方块周围的方块进行判断, 检测周围的方块是否应该动态的进行生成:
  generateAdjacentBlocks = (position: THREE.Vector3) => {
    const { x, y, z } = position
    const noise = this.noise
    const yOffset = Math.floor(
      noise.get(x / noise.gap, z / noise.gap, noise.seed) * noise.amp
    )
    if (y > 30 + yOffset) {
      return
    }
    
    // 生成周围的方块
    this.buildBlock(new THREE.Vector3(x, y - 1, z), type)
    this.buildBlock(new THREE.Vector3(x, y + 1, z), type)
    this.buildBlock(new THREE.Vector3(x - 1, y, z), type)
    this.buildBlock(new THREE.Vector3(x + 1, y, z), type)
    this.buildBlock(new THREE.Vector3(x, y, z - 1), type)
    this.buildBlock(new THREE.Vector3(x, y, z + 1), type)
    this.blocks[type].instanceMatrix.needsUpdate = true
  }
复制代码对于具体的 buildBlock 函数, 也就是生成方块的函数中, 会对是否需要进行生成来进行判断, 并且将生成完的方块加到自定义方块中, 这样可以避免方块的重复渲染.
视角移动 / 玩家移动
地形生成完了, 然后就到了最基本的操作的部分了, 这里会大致介绍一下两个部分:
- 视角移动 (移动鼠标) 
- 玩家移动 (WASD 键和空格等) 
视角移动
视角移动的部分比较简单, 主要就是把鼠标的移动量 (offsetX 和 offsetY) 映射到对镜头的旋转上即可, Three.js 对此部分也有类似的实现 (three/examples/jsm/controls/PointerLockControls), 所以这里就不展开了
玩家移动
首先就是最基本的移动, 可以在用户按下 WASD 后更新当前玩家的速度, 然后在 raf 的回调函数中对玩家的位置增加对应的值:
  setMovementHandler = (e: KeyboardEvent) => {
    if (e.repeat) {
      return
    }
    switch (e.key) {
      case 'w':
      case 'W':
        this.velocity.x += this.player.speed
        break
      case 's':
      case 'S':
        this.velocity.x -= this.player.speed
        break
      case 'a':
      case 'A':
        this.velocity.z -= this.player.speed
        break
      case 'd':
      case 'D':
        this.velocity.z += this.player.speed
        break
    }
 }
 
 update = () => {
   // 更新位置, delta 为时间因子
   this.control.moveForward(this.velocity.x * delta)
   this.control.moveRight(this.velocity.z * delta)
 }
复制代码跳跃, 重力, 和地面检测
仅有水平方向上的移动是还不够的, 因为地形已经是高低不平, 但是这样的移动方法只能在同一水平面位移, 这就会造成穿模 / 飘在天上的情况.
所以我们还要为 raf 的回调函数中加上重力系统, 以及对于是否到达地面的判断. 除此之外, 还要增加跳跃功能:
  setMovementHandler = (e: KeyboardEvent) => {
    if (e.repeat) {
      return
    }
    switch (e.key) {
      case ' ':
        if (this.player.mode === Mode.walking) {
          // 如果当前不是跳跃状态, 进行一次跳跃
          // 并暂时取消地面的碰撞检测(否则会跳不起来 =.=)
          if (!this.isJumping) {
            this.velocity.y = 8
            this.isJumping = true
            this.downCollide = false
            this.far = 0
            setTimeout(() => {
              this.far = this.player.body.height
            }, 300)
          }
        }
        break
    }
 }
 update = () => {
  // 重力
  if (Math.abs(this.velocity.y) < this.player.fallingSpeed) {
    this.velocity.y -= 25 * delta
  }
  
  // downCollide = 是否触碰到地面, 具体的实现会在下面介绍
  // 如果碰到地面, 则清零下落速度, 并且更新更新为可跳跃状态
  if (this.downCollide && !this.isJumping) {
    this.velocity.y = 0
  } else if (this.downCollide && this.isJumping) {
    this.isJumping = false
  }
 }
复制代码这样我们就成功的添加了重力系统以及跳跃功能, 但是接下来才是最重要的部分, 除去对于地面的检测外, 我们还需要对上下左右前后等多个方向来进行碰撞检测, 不然还是会有穿模的可能.
碰撞检测
具体碰撞检测的部分是通过 Three.js 中的 RayCaster 来实现的, 他可以从三维中的一个点发射出一条射线, 并且判断该射线是否命中其他物体.
所以对于上述的地面碰撞检测, 其背后的原理便是通过从玩家的位置向下发出一条等同于玩家身高的射线, 然后判断是否命中地面即可.
我一开始便是用这种方法实现的, 不过当我尝试着为前后左右上下添加了同样的机制后, 便有了明显的掉帧现象, 经过一阵子的 debug, 笔者发现其原因是因为每一条 RayCaster 射线都会同时对当前场景中的所有方块进行判断, 而场景中可能同时存在着几十万个方块, 这就会带来极大的运算量从而导致卡顿.
那这个问题该怎么解决呢?
方案一: web worker
我想到的第一个解决方案便是将具体的运算传入 web worker 里进行运算, 尝试着实现了一下后发现这样虽然不再卡顿了, 但是会带来一些穿模的问题.
其背后的原因便是: 如果我们把数据传入了 web worker 进行运算, 即使它运算的再快, 比若说即使在 1 毫秒后完成了运算, 但是对于数据的处理和渲染依然会被推迟到下一帧的 raf 回调中, 这样的话如果玩家在一帧内进行了很大的位移 (比如快速的下落), 就会造成穿模的现象.
况且各家浏览器对于 web worker 的实现并不同, 对于Firefox 而言, 主线程只需要 20 毫秒的运算在传入 web worker 后可能会花费接近 100 毫秒的时间, 这样所带来的穿模情况就会更加的严重, 所以最后不得不放弃这个解决方案.
方案二: 减少计算量
因为卡顿的主要原因便是每一条 RayCaster 射线都会同时对当前场景中的所有方块进行判断, 所以会带来大量的计算量, 那么我们有没有办法通过减少计算量来增加效率呢? 答案是有的, 其实对于每一次的碰撞检测来说, 它们并不需要去检测场景中的所有方块, 而仅需要检测是否碰撞到了周围的方块即可, 其实通过测试后发现, 我们甚至只需要检测指定方向上的第一个方块即可以完成十分丝滑的碰撞检测.
所以最后的解决方案便是通过模拟出指定方向上的方块, 然后直接对其进行检测即可. 下面贴上了一个极简版本的实现, 如果对具体代码感兴趣的朋友可以看这儿: Control 类的 449 行
  collideCheck = (
    side: Side,
    position: THREE.Vector3,
    noise: Noise,
    customBlocks: Block[],
    far: number = this.player.body.width
  ) => {
    const matrix = new THREE.Matrix4()
    // 重置模拟方块
    let index = 0
    this.tempMesh.instanceMatrix = new THREE.InstancedBufferAttribute(
      new Float32Array(100 * 16),
      2
    )
  
    // 获取方块位置
    let x = Math.round(position.x)
    let z = Math.round(position.z)
    let y =
      Math.floor(
        noise.get(x / noise.gap, z / noise.gap, noise.seed) * noise.amp
      ) + 30
    
    // 下方的碰撞检测
    switch (side) {
      case Side.down:
        this.raycasterDown.ray.origin = position
        this.raycasterDown.far = far
        break
    }
    // 更新模拟方块
    matrix.setPosition(x, y, z)
    this.tempMesh.setMatrixAt(index++, matrix)
    this.tempMesh.instanceMatrix.needsUpdate = true
    // 更新 downCollide, 即判断否触碰到地面
    const origin = new THREE.Vector3(position.x, position.y - 1, position.z)
    switch (side) {
      case Side.down: {
        const c1 = this.raycasterDown.intersectObject(this.tempMesh).length
        c1 ? (this.downCollide = true) : (this.downCollide = false)
        break
    }
  }
复制代码又到了玩家移动
好了, 现在我们完成了上下左右前后的碰撞检测, 但是我们也仅仅只是完成了碰撞检测, 对于前后左右玩家移动, 他们依然会穿模 / 飞起来, 所以我们还要通过碰撞检测的结果更新具体的移动方法.
回想一下, 在玩各种 FPS 游戏的时候, 如果前面有一堵墙, 然后你面朝着那堵墙按 W 向前移动会发生什么? 没错, 你会动不了. 但是当你略微的把鼠标 / 镜头向做左移动后会发生什么? 你其实会慢慢的开始贴着墙壁向左边滑动起来, 而随着镜头移动的角度增大, 滑动的速度也会越来越快.
而要实现相同的物理效果, 我们光有碰撞检测的结果还是不够的, 我们还需要知道当前镜朝向, 以及镜头和移动方向上的方块的夹角, 这样才能模拟出相同的物理效果, 以下为镜头朝场景向正 x 轴, 运动方向为正前方的情况:
  // 计算出镜头的朝向
  let vector = new THREE.Vector3(0, 0, -1).applyQuaternion(
    this.camera.quaternion
  )
  let direction = Math.atan2(vector.x, vector.z)
  
  // 镜头朝向场景中的正 x 轴方向, 偏移量为 +45 度到 -45 度之间
  if (direction < Math.PI && direction > 0 && this.velocity.x > 0) {
    if (
      // 镜头和方块有夹角时的左右滑动
      (!this.leftCollide && direction > Math.PI / 2) ||
      (!this.rightCollide && direction < Math.PI / 2)
    ) {
      this.moveZ(Math.PI / 2 - direction, delta)
    }
  } else if (
    !this.leftCollide &&
    !this.rightCollide &&
    this.velocity.x > 0
  ) {
    // 若前方无阻当, 则直接前进
    this.control.moveForward(this.velocity.x * delta)
  }
复制代码而同样的方法需要为场景向正 x 轴, 场景向负 x 轴, 场景向正 z 轴, 场景向负 z 轴同时进行判断, 并且对于每一个方向需要判断前后左右四个方向的移动. 这样我们的玩家移动部分也大功告成啦.
方块放置 / 破坏
现在我们有了最基本的场景和操作方法, 接下来就是要实现 MineCraft 中的核心功能: 方块的放置和破坏了
准星高亮
首先我们要实现的便是准星高亮, 这样我们才能知道当前对准的是什么方块. 具体的实现方法便是在 raf 的回调中, 通过从屏幕中心射出 RayCaster 来判断命中的方块, 然后在相同的位置创建一个透明包围方块来实现高亮, 在命中方块改变时, 更新透明包围方块即可.
这一块在性能优化的思路上同样延续了前面模拟方块的思路, 使得对于高亮方块的判断只需要判断周围的一圈方块即可, 以下是极简版本的实现:
  update() {
    // 重置上一次的高亮
    this.scene.remove(this.mesh)
    this.index = 0
    this.instanceMesh.instanceMatrix = new THREE.InstancedBufferAttribute(
      new Float32Array(1000 * 16),
      16
    )
    const position = this.camera.position
    const matrix = new THREE.Matrix4()
    const noise = this.terrain.noise
    let xPos = Math.round(position.x)
    let zPos = Math.round(position.z)
    
    // 模拟周围一圈的方块
    for (let i = -8; i < 8; i++) {
      for (let j = -8; j < 8; j++) {
        let x = xPos + i
        let z = zPos + j
        let y =
          Math.floor(
            noise.get(x / noise.gap, z / noise.gap, noise.seed) * noise.amp
          ) + 30
        matrix.setPosition(x, y, z)
        this.instanceMesh.setMatrixAt(this.index++, matrix)
      }
    }
    // 高亮新的方块
    this.raycaster.setFromCamera({ x: 0, y: 0 }, this.camera)
    this.block = this.raycaster.intersectObject(this.instanceMesh)[0]
    if (
      this.block &&
      this.block.object instanceof THREE.InstancedMesh &&
      typeof this.block.instanceId === 'number'
    ) {
      this.mesh = new THREE.Mesh(this.geometry, this.material)
      let matrix = new THREE.Matrix4()
      this.block.object.getMatrixAt(this.block.instanceId, matrix)
      const position = new THREE.Vector3().setFromMatrixPosition(matrix)
      this.mesh.position.set(position.x, position.y, position.z)
      this.scene.add(this.mesh)
    }
  }
复制代码方块放置 / 破坏
然后就到了方块的放置和破坏环节, 这一部分其实也不难, 主要的逻辑就是判断高亮方块的位置后:
- 如果为破坏, 则删除高亮方块 
- 如果为放置, 则在高亮方块对应的面上放置一个新的方块 
大致的实现如下:
clickHandler = (e: MouseEvent) => {
    e.preventDefault()
    this.raycaster.setFromCamera({ x: 0, y: 0 }, this.camera)
    const block = this.raycaster.intersectObjects(this.terrain.blocks)[0]
    const matrix = new THREE.Matrix4()
    switch (e.button) {
      // 左键破坏
      case 0:
        {
          if (block && block.object instanceof THREE.InstancedMesh) {
            block.object.getMatrixAt(block.instanceId!, matrix)
            const position = new THREE.Vector3().setFromMatrixPosition(matrix)
            // 移除方块
            block.object.setMatrixAt(
              block.instanceId!,
              new THREE.Matrix4().set(...new Array(16).fill(0))
            )
            block.object.instanceMatrix.needsUpdate = true
            // 播放音效
            this.audio.playSound(
              BlockType[block.object.name as any] as unknown as BlockType
            )
            // 生成周围方块 (详见本文前面的垂直方向无限地形部分)
            this.terrain.generateAdjacentBlocks(position)
          }
        }
        break
      // 右键放置
      case 2:
        {
          if (block && block.object instanceof THREE.InstancedMesh) {
            // 计算新方块的位置
            const normal = block.face!.normal
            block.object.getMatrixAt(block.instanceId!, matrix)
            const position = new THREE.Vector3().setFromMatrixPosition(matrix)
            // 若已有重叠方块则直接返回
            if (
              position.x + normal.x === Math.round(this.camera.position.x) &&
              position.z + normal.z === Math.round(this.camera.position.z) &&
              (position.y + normal.y === Math.round(this.camera.position.y) ||
                position.y + normal.y ===
                  Math.round(this.camera.position.y - 1))
            ) {
              return
            }
            // 放置方块
            matrix.setPosition(
              normal.x + position.x,
              normal.y + position.y,
              normal.z + position.z
            )
            this.terrain.blocks[this.holdingBlock].setMatrixAt(
              this.terrain.getCount(this.holdingBlock),
              matrix
            )
            this.terrain.setCount(this.holdingBlock)
            // 播放音效
            this.audio.playSound(this.holdingBlock)
            this.terrain.blocks[this.holdingBlock].instanceMatrix.needsUpdate =
              true
          }
        }
        break
    }
  }
复制代码保存 / 读取功能
笔者还实现了一个十分基础的保存和读取功能. 其实在上面都没有提及到, 项目中还实现了一个自定义方块类, 他记录了所有非初始生成的方块:
export default class Block {
  constructor(
    x: number,
    y: number,
    z: number,
    type: BlockType,
    placed: boolean
  ) {
    this.x = x
    this.y = y
    this.z = z
    this.type = type
    this.placed = placed
  }
  x: number
  y: number
  z: number
  type: BlockType
  placed: boolean
}
复制代码- 在我们放置方块时, 就会新增一个对应 - BlockType的,- placed = true的方块
- 而在我们破坏方块时, 就会新增一个 - placed = false的方块
这样的话, 我们只需要在保存时将当前玩家位置信息, 地图种子, 和储存了自定义方块类的 Blocks 数组存入 local storage 即可; 而在读取时, 我们只需要通过地图种子重新生成地形, 然后通过 Blocks 重新生成自定义方块 (即放置 / 破坏过的方块).
这样即可实现保存 / 读取机制.
基本的 UI
最后笔者还为项目添加了一些基本的 UI 以及设置功能, 由于没有使用 Vue 或者 React 等框架 / 库, 所以这部分就是基本的 HTML + CSS 这里就不展开啦.
最后
这篇文章到这里就结束啦, 感谢可以看到最后的大家. 因为这也是本小白对于 Three.js 的第一次探索, 途中也是走了不少弯路, 所以只是希望和大家聊一聊, 帮大家避避坑, 而整体项目依然是十分的简单, 所以请各位大佬多多包涵.
最后如果大家在试玩中有什么问题的话, 也可以评论留言或者去 GitHub 上提 Issue, 笔者也会努力更新优化的~
源自:https://juejin.cn/post/7088875551704350756
