笔者在前一阵子接触到 Three.js
后, 发现了它能为前端 3D 可视化 / 动画 / 游戏方向带来的无限可能, 正好最近在与朋友重温我的世界, 便有了用 Three.js
来仿制 MineCraft 的想法, 正好也可以通过一个有趣的项目来学习一下前端 3D 领域
相信大家对我的世界应该都不太陌生, 他是一款 3D 像素风的生存类游戏, 本项目是模仿我的世界的来进行实现的, 目前大致支持了以下的功能:
除此之外, 笔者目前也在尝试着为项目添加一些别的特性:
玩法介绍也可以在 游戏体验地址 中的操作介绍下找到
因为项目的初衷便是探索一下 Three.js
, 所以除了 Three.js
之外并没有别的第三方库依赖, 因此最后的打包大小其实是十分轻量级的, gzipped 后仅有 140kb 左右.
在此之上笔者还添加了 TypeScript
来进行类型检测, 并且使用了 Vite
进行的开发
上图是整体项目的源码架构, 开发主要基于了 Class
写法, 主要有五大类以及一些子类, 分别为:
Core
: 包含了 Three.js
中的一些核心内容, 并且进行一些初始化设定
Player
: 包含了玩家的一些基础属性以及当前模式(行走, 奔跑, 作弊等)
Audio
: 主要进行声音的导入, 并且暴露了一些用于播放音乐的 api
Terrain
: 包含了与地形相关的各种内容:
Noise
: 通过柏林噪音实现了地形, 树木, 方块类型的随机生成算法
GenerateWorker
: 通过 web worker
的方式实现了地形动态生成的算法
Mesh
: 场景中基础的网格体(方块)
Blocks
: 自定义方块类Materials
: 各种方块的材质加载Highlight
: 实现了实时高亮准心位置方块的算法
Control
: 包含了各种与操作相关的算法, 比如移动, 镜头转动, 碰撞检测等
CollideWorker
: 在 web worker
中实现碰撞检测以提高运行效率ui:包含了 ui 界面以及其功能:
Bag
: 放置不同方块的背包FPS
: 实时展示当前 fps
笔者会在这一部分深入的分析一下项目的核心技术点以及一些遇到的难点, 如果大家只是来试玩看看 / 图一乐儿的话, 这一部分就可以跳过啦~ 不过要是有同学对底层的实现或者 Three.js
感兴趣的话, 也可以看看这一部分
对于文中大部分的涉及代码部分, 笔者尽可能的删去了不相关的内容, 让代码更加的易懂, 有兴趣的小伙伴也可以直接去 GitHub 上查看源码
地形生成采用了柏林噪音来进行实现的, Three.js
中自带了噪音的底层算法实现, 所以笔者只对其进行了一下简单的封装:
ts复制代码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
的数组中:
ts复制代码const blocks: THREE.InstancedMesh[] = []
blocks[i].instanceMatrix = new THREE.InstancedBufferAttribute(
new Float32Array(maxCount * blocksFactor[i] * 16),
16
)
然后在具体的循环中首先依据前面的种子来判断地形的高度, 然后依据具体方块类型的种子来判断具体该渲染什么样的方块类型, 最后将每一个方块的位移量写入对应的 InstancedMesh
中:
ts复制代码
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
) 的回调函数中判断玩家是否移动到了新的区块, 如果区块发生了变化, 则会触发一次渲染:
ts复制代码 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
中的类都无法直接进行传输, 最后不得不封装了一些自定义的数据结构来进行数据的沟通:
ts复制代码 // 将数据传入 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>(<