• Three.js做了一个网页版的我的世界


    在这里插入图片描述

    前言

    笔者在前一阵子接触到 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
      

      : 包含了与地形相关的各种内容:

      • 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>(<
  • 相关阅读:
    SpringBoot自定义starter
    一套完善的设备管理系统能给企业带来什么?
    .NET餐厅管理系统菜品添加页面前端
    CentOS 7.9 安装 MySQL 8 配置模板
    使用OpenTelemetry进行监控
    v-for 中 key的作用和原理
    剑指offer.翻转字符串
    【前端基础小案例】HTML+CSS打造精美选项卡菜单效果
    Java Stream Map的使用
    分享一下怎么做小程序营销活动
  • 原文地址:https://blog.csdn.net/mmmmm44444/article/details/139652630