发布于 2026-01-05 1 阅读
0

我用 JavaScript 构建了整个宇宙,遵循多元宇宙许可协议。

我用 JavaScript 构建了整个宇宙。

穿越多元宇宙

执照

这不是标题党。我真的做到了。而且这不仅仅是一个网页体验。不,这是一个真正的游戏,可以自由探索,3D效果,就在你的浏览器里!你不信?

TLDR

哦,我知道您的时间很宝贵。这是精简版。

我用 JavaScript 开发了一款免费的 3D 浏览器游戏,玩家可以自由探索无限个宇宙。游戏的目标是什么?从一个宇宙到另一个宇宙,探索万物的起源。

这是一个分为四章的故事,结尾有一个惊天大揭秘。

在继续阅读本文之前,请停止一切​​操作,将游戏画面全屏显示,准备一些爆米花,然后玩《穿越多元宇宙》!

https://across-multiverse.com/

没时间玩?

你更喜欢在电脑上而不是手机上观看?

或者你只是想在玩游戏之前多了解一些信息?

我接到你了。

我在YouTube上发布了一个宣传片!只有3分钟长,内容很丰富。

但请注意,它非常壮观!

我投入了大量的时间、心血和技能来创作这款免费的互联网游戏。

如果你能抽出五秒钟时间分享一下,那就太好了。

与此同时,是时候谈谈房间里那头显而易见却又无人提及的大象了。

我到底是怎么做到的?

空谈无益,拿出代码来。

我知道你们很多人都喜欢直接看代码,甚至在我给出精彩解释之前就先看代码。我完全理解这一点。

对于最没有耐心的玩家,这里提供了游戏的完整源代码。

GitHub 标志 jesuisundev / acrossthemultiverse

一款可在浏览器中自由探索的3D游戏,带你穿越程序生成的无限宇宙。穿梭于各个宇宙,揭开万物起源的秘密。游戏包含四个章节,结尾将有惊天揭秘。

穿越多元宇宙

穿越多元宇宙

js标准样式

一款可在浏览器中自由探索的 3D 游戏,穿越程序生成的无限宇宙。从一个宇宙到另一个宇宙,发现万物的起源。一个四章的故事,结尾有史诗般的揭秘。

https://across-multiverse.com/

一篇解释所有功能运作方式的博文请点击这里查看!

执照

源代码

根据BSD 3-Clause 许可

音乐

所有音乐版权均归艺术家所有(见下方列表),并根据署名-非商业性使用-相同方式共享 3.0 未本地化版本 (CC BY-NC-SA 3.0)许可协议在此使用。

您不得将这些音乐用于商业用途,且必须注明以下艺术家。

  • 超凡脱俗——乔尔·尼尔森
  • 我与鬼魂同行 - 斯科特·巴克利
  • 发现——斯科特·巴克利
  • 天体 - 斯科特·巴克利
  • 欧米茄 - 斯科特·巴克利
  • 夜幕降临 - Melodysheep

画面

根据署名 4.0 国际许可协议 (CC BY 4.0)

你可以将游戏视频用于任何用途,但必须注明本项目并附上以下链接:https://across-multiverse.com/

当然,它是根据 MIT 许可证开源的(代码部分)。

我仍然建议大家通过这篇文章了解该项目逐步创建的历程。这样能提供更多背景信息,更重要的是,也能让一切更容易理解。

如何构建宇宙?

在开始之前,你应该知道我使用了 Three.js JavaScript 库。这个库允许你通过 JavaScript 使用 WebGL API 在浏览器中进行 3D 渲染。

您可能不理解前面的句子,或者您不了解 Three.js。
幸运的是,我考虑到了所有人。

我写了一篇专门介绍 Three.js 和 JavaScript 中的 3D 渲染的文章。

本文将使您在短短 5 分钟内立即了解该项目的基本原理。

总之,回到正题。

如何构建宇宙?

显然,这个问题太大了。

我无法正面解决这个问题。作为一名开发人员,你不能那样做。面对巨大而复杂的问题,只有一种本能反应。

降低复杂性

构建宇宙?太复杂了。我甚至不知道从何入手。好吧,那我们缩小范围。

构建银河系?还是太复杂了,我们来简化一下。

建造我们自己的太阳系?不,这太复杂了。我们来简化一下。

建造一个充满繁星的空旷空间?

哈!听起来可行!在漆黑的太空中营造一个简单的星空。

仔细想想,我觉得这个问题其实很简单。这意味着我已经把问题的复杂度降低到足够低的程度了。

是时候开始了。

如何构建一个简单的星空?

从那时起,我总是会使用参考图像。一张照片,或者一张我想要重现的物体的真实照片。一张用来参考的图像,以便尽可能地接近逼真的渲染效果。

至于星空,我决定用哈勃卫星拍摄一张照片。

我想我只需要在黑色背景上显示一些随机的白色圆点即可。

非常简单。

我们先创建一个空旷的黑色空间,然后在里面放置一台摄像机。

const scene = new THREE.Scene()
const renderer = new THREE.WebGLRenderer({ powerPreference: "high-performance", antialias: false, stencil: false, depth: false })
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setClearColor(0xffffff, 0)
renderer.domElement.id = 'starfield'
document.body.appendChild(renderer.domElement)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.set(0, 10, 0)
camera.lookAt(0, 0, 0)
function animate(time) {
    renderer.render(scene, camera)
    requestAnimationFrame(animate)
}
animate()
Enter fullscreen mode Exit fullscreen mode

好吧。真是太棒了。搞了这么多,结果就显示一个黑屏?!

是的。让我们耐心点。总得有个开始。

现在,难度更大了。

让我们在这个黑色区域随机显示一些白点。你能做到吗?我不知道怎么做,所以我查阅了文档

我找到了!

有一个专门的类:积分

好的,我们按照文档做,把这些记下来。

function getStarsGeometry() {
    const geometry = new THREE.BufferGeometry()
    geometry.setAttribute('position', new THREE.Float32BufferAttribute(getStarsRandomVertices(), 3))
    return geometry
}
function getStarsRandomVertices(verticesNumber = 10000) {
    const vertices = []
    for (let i = 0; i < verticesNumber; i++) {
        const x = 2000 * Math.random() - 1000;
        const y = 2000 * Math.random() - 1000;
        const z = 2000 * Math.random() - 1000;
        vertices.push(x, y, z);
    }
    return vertices
}
function getStarsMaterial() {
    const starSprite = new THREE.TextureLoader().load('../images/star.png');
    const starMaterial = new THREE.PointsMaterial({ size: 5, sizeAttenuation: true, map: starSprite, alphaTest: 0.5, transparent: true });
    return starMaterial
}
function getStars() {
    const stars = new THREE.Points(getStarsGeometry(), getStarsMaterial())
    return stars
}
scene.add(getStars())
Enter fullscreen mode Exit fullscreen mode

在这段代码中,真正发挥作用的是getStarsRandomVertices函数。

我们的星空(这里用新的 THREE.Points表示)需要两样东西。

1:要显示的每个点的坐标;
2:每个点的材质。也就是说,我们将(简化地)显示每个点的具体内容。

坐标由 getStarsRandomVertices 管理。

我们的相机位于坐标 (0,0,0) 处。我们希望周围都是星星。因此,我们的点应该位于坐标 (-1000) 到 (1000) 之间。也就是我们周围的所有星星。

为此,我们将进行简单的计算。

2000 * Math.random() – 1000

这个非常简单的数学运算会给我们一个介于 -1000 和 1000 之间的随机值(math.random 实际上并不是真正的随机值,但我们姑且承认这一点)。我们将这个计算结果分别应用到每个轴(x、y、z)上,就完成了!

该素材由getStarsMaterial管理。

没什么好说的。我们直接用一张白色圆圈的图片作为纹理,然后应用上去。目前我们不需要太多东西。

我们将这两个函数合并到getStars函数中,问题就解决了。

目前,它只能显示一个二维的静态白点图像。

这有点逊色。我们可以做得更好。

是时候对这个结果进行迭代了。

通过迭代改进

让我们先从赋予事物生命开始吧。

目前的想法是让摄像机的移动与鼠标的移动保持一致。这样做的时候,我只是想确保自己意识到我现在是在做3D建模。

我们把它写下来。

document.body.addEventListener('pointermove', onPointerMove);
function onPointerMove(event) {
    if (event.isPrimary === false) return
    mouseX = event.clientX - windowHalfX
    mouseY = event.clientY - windowHalfY
}
function animate(time) {
    renderer.render(scene, camera)
    camera.position.x += (mouseX - camera.position.x) * 0.05
    camera.position.y += (-mouseY - camera.position.y) * 0.05
    requestAnimationFrame(animate)
}
Enter fullscreen mode Exit fullscreen mode

好吧,它很酷,它会动,它是3D的,我们很满意。

现在我们来增加一些互动性。我想在里面自由走动,就像玩第一人称射击游戏那样,用鼠标和键盘操作。

当时,我面临两个选择:

  • 要么是我自己重写了整个FPS游戏的导航系统。
  • 我要么直接使用了 Three.js 现成的 FPS 控制类。

我当然选择使用已有的代码。

它经过许多人的测试和使用。我建议你遇到这种情况时也这样做。除非你正处于学习阶段,否则没有必要重新发明轮子。

然而,该模块提供的功能对我来说还不够。

我想要更多。

我想要一个速度系统,让玩家感受到加速和减速的感觉。所以我不得不利用现有的模块,并在我自己的类中扩展这些功能。

好的,我们把它写下来。

import { PointerLockControls } from './PointerLockControls.js'
import * as THREE from 'three'

export default class Controls {
  constructor (camera, parameters) {
    this.parameters = parameters
    this.camera = camera
    this.pointerLockControls = new PointerLockControls(this.camera, document.body)

    this.velocity = new THREE.Vector3()
    this.direction = new THREE.Vector3()

    this.moveForward = false
    this.moveBackward = false
    this.moveLeft = false
    this.moveRight = false
  }

  onKeyDown (event) {
    if (this.pointerLockControls.isLocked) {
      switch (event.code) {
        case 'ArrowUp':
        case 'KeyW':
        case 'KeyZ':
          this.moveForward = true
          break
        case 'ArrowLeft':
        case 'KeyA':
        case 'KeyQ':
          this.moveLeft = true
          break
        case 'ArrowDown':
        case 'KeyS':
          this.moveBackward = true
          break
        case 'ArrowRight':
        case 'KeyD':
          this.moveRight = true
          break
      }
    }
  }

  onKeyUp (event) {
    if (this.pointerLockControls.isLocked) {
      switch (event.code) {
        case 'ArrowUp':
        case 'KeyW':
        case 'KeyZ':
          this.moveForward = false
          break
        case 'ArrowLeft':
        case 'KeyA':
        case 'KeyQ':
          this.moveLeft = false
          break
        case 'ArrowDown':
        case 'KeyS':
          this.moveBackward = false
          break
        case 'ArrowRight':
        case 'KeyD':
          this.moveRight = false
          break
      }
    }
  }


  handleMovements (timePerf, prevTimePerf) {
    const delta = timePerf - prevTimePerf

    this.direction.z = Number(this.moveForward) - Number(this.moveBackward)
    this.direction.x = Number(this.moveRight) - Number(this.moveLeft)

    if (this.moveForward || this.moveBackward) {
      this.velocity.z -= this.direction.z * this.parameters.controls.velocity * delta
    }

    if (this.moveLeft || this.moveRight) {
      this.velocity.x -= this.direction.x * this.parameters.controls.velocity * delta
    }

    this.pointerLockControls.moveRight(-this.velocity.x * delta)
    this.pointerLockControls.moveForward(-this.velocity.z * delta)
  }
}
Enter fullscreen mode Exit fullscreen mode

就这样,我们构建了一个可以像第一人称射击游戏一样探索的星空。

给你,我放一个 codesandbox,你可以在里面实时运行。

你只需点击星星即可切换到第一人称射击模式。

还不错,对吧?

还不错。

但现在是时候进入正题了。

如何模拟无穷大?

目前我们只是在玩家周围放置了一些点。但他只要稍微移动一下就能识破我们的诡计。这招有点儿老套了。

那么我们如何才能实现规模化呢?

如何以一种合理的方式做到这一点?

最重要的是,如何在不占用过多内存和/或不使帧率低于 60FPS 的情况下做到这一点?

现在真正的项目开始了。

网格

**那时,我停止了触碰键盘。**

要编写解决这类问题的代码,你不能“凭感觉摸索”。不,你必须先在纸上从概念上解决这个问题,然后再动手。

否则你就是在浪费时间。

我讨厌浪费时间。

该画画了。

我最先想到的办法之一就是用网格来表示空间。

具体来说,这个空间将是一个无限延伸的网格。玩家需要逐个方格移动,查看每个方格内的内容。而每个方格中,玩家都可以放置自己想要的东西。

恒星、星云、星系、太阳、黑洞,你想看什么都行!

为了让您更好地理解我的意思,我画一幅小图给您看。

解决问题

我们先来描述一下现有的代码。现在,玩家位于初始坐标 (0,0,0) 处,周围环绕着星星。如果他稍微移动一下,就会完全陷入黑暗中。

从概念上讲,它看起来是这样的。

  • 这个小家伙代表我们的玩家
  • 蓝色的星星代表已经随机放置在他周围的点
  • 红色数字表示空间每个正方形的坐标。

目前为止,没什么复杂的。

这就是目标!我要不惜一切代价保持简单。但保持简单本身就很复杂。让我们努力让网格更新算法保持简单吧。

我们需要两个主要功能。

初始化函数。

该函数将在原始方格以及玩家周围的所有方格中创建材质。

初始化阶段的优势在于你可以承担较高的行动成本。只要玩家尚未进入游戏阶段,你就可以自由行动。

从概念上讲,它看起来是这样的。

  • 绿色星号代表由初始化函数动态创建的点。

更新功能。

该函数仅在玩家越过两个方格之间的边界时才会更新网格。

我们希望通过这个函数实现两个目标:

  • 删除距离玩家过远的方格内容。
  • 创建玩家最有可能前往的方格的内容
    。从概念上讲,它看起来会像这样。

  • 蓝色星号代表已放置的点。

  • 绿色星号代表我们动态创建的点。

  • 红色星号代表被删除的分数。

就这样,我们成功进行了无限模拟。

玩家可以随心所欲地去任何地方,想去哪里就去哪里,想怎么走就怎么走。他不会识破任何诡计。无论他走到哪里,都会有美妙的景色可供欣赏。

我喜欢这个方案,因为它有几个优点。

  • 它相对来说效率很高。

方格内容是动态生成的,而且更重要的是,它们会同时被删除,这大大节省了内存。此外,每次只创建必要的最小数量方格。

  • 我们无需控制球员的方向。

无论玩家朝哪个方向移动,算法都相同。实际上,我们不需要知道玩家前方有哪些方格,我们只需要知道玩家周围哪些方格是空的!这样玩家就可以朝他想去的方向移动,我们的算法会执行完全相同的操作。

  • 它很容易管理。

根本不需要什么像图或者八叉树这种地狱般的数据结构。不,不。去他妈的,别烦我。一个数组,两个哈希表就够了。省事儿。简单点儿。

好,我们把它写下来。

编写解决方案

我们将创建这个著名的类来管理网格。为了简洁起见,我不会解释所有内容,也不会展示所有内容。

如果你想查看全部内容,可以获得完整的源代码。

今天我们只关注重点部分。

import MultiverseFactory from '../procedural/MultiverseFactory'

export default class Grid {
  constructor (camera, parameters, scene, library) {
    this.camera = camera
    this.parameters = parameters
    this.scene = scene
    this.library = library
    this.activeClusters = new Map()
    this.queueClusters = new Map()

    this.multiverseFactory = new MultiverseFactory(this.scene, this.library, this.parameters)
  }

  getCurrentClusterPosition () {
    const currentCameraPosition = this.getCurrentCameraPosition()
    const xCoordinate = Math.trunc(currentCameraPosition.x / this.parameters.grid.clusterSize)
    const yCoordinate = Math.trunc(currentCameraPosition.y / this.parameters.grid.clusterSize)
    const zCoordinate = Math.trunc(currentCameraPosition.z / this.parameters.grid.clusterSize)
    const currentClusterPosition = `${xCoordinate},${yCoordinate},${zCoordinate}`

    return currentClusterPosition
  }

  getCurrentCameraPosition () {
    this.camera.updateMatrixWorld()

    return this.camera.position
  }

  getClustersStatus (currentCluster) {
    const clustersNeighbour = this.getNeighbourClusters(currentCluster)
    const clustersToPopulate = this._getEmptyClustersToPopulate(clustersNeighbour)
    const clustersToDispose = this._getPopulatedClustersToDispose(clustersNeighbour, currentCluster)

    return {
      clustersNeighbour,
      clustersToPopulate,
      clustersToDispose
    }
  }

  getNeighbourClusters (currentCluster) {
    const neighbourClusters = [currentCluster]
    const currentClusterArray = currentCluster.split(',')
    const x = currentClusterArray[0]
    const y = currentClusterArray[1]
    const z = currentClusterArray[2]

    // forward
    neighbourClusters.push(`${x},${y},${Number(z) - 1}`)

    // backward
    neighbourClusters.push(`${x},${y},${Number(z) + 1}`)

    // right
    neighbourClusters.push(`${Number(x) + 1},${y},${z}`)

    // left
    neighbourClusters.push(`${Number(x) - 1},${y},${z}`)

    // forward right
    neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) - 1}`)

    // forward left
    neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) - 1}`)

    // backward right
    neighbourClusters.push(`${Number(x) + 1},${y},${Number(z) + 1}`)

    // backward left
    neighbourClusters.push(`${Number(x) - 1},${y},${Number(z) + 1}`)

    return neighbourClusters
  }

  disposeClusters (clustersToDispose) {
    for (const clusterToDispose of clustersToDispose) {
      let matter = this.activeClusters.get(clusterToDispose)

      matter.dispose()
      matter = null

      this.activeClusters.delete(clusterToDispose)
    }
  }

  addMattersToClustersQueue (matters, type = 'starfield', subtype = null) {
    for (const clusterToPopulate of Object.keys(matters)) {
      this.queueClusters.set(clusterToPopulate, {
        type: type,
        subtype: subtype,
        data: matters[clusterToPopulate]
      })
    }
  }

  populateNewUniverse () {
    const clusterStatus = this.getClustersStatus('0,0,0')

    this.buildMatters(clusterStatus.clustersToPopulate)
  }


  renderMatters (position, cluster) {
    const matter = this.multiverseFactory.createMatter(cluster.type)

    matter.generate(cluster.data, position, cluster.subtype)
    matter.show()

    this.queueClusters.delete(position)
    this.activeClusters.set(position, matter)
  }

  _getEmptyClustersToPopulate (neighbourClusters) {
    const emptyClustersToPopulate = []

    for (const neighbourCluster of neighbourClusters) {
      if (!this.activeClusters.has(neighbourCluster)) {
        emptyClustersToPopulate.push(neighbourCluster)
      }
    }

    return emptyClustersToPopulate
  }

  _getPopulatedClustersToDispose (neighbourClusters, currentCluster) {
    const populatedClustersToDispose = []

    for (const activeClusterKey of this.activeClusters.keys()) {
      if (currentCluster !== activeClusterKey && !neighbourClusters.includes(activeClusterKey)) {
        populatedClustersToDispose.push(activeClusterKey)
      }
    }

    return populatedClustersToDispose
  }
}
Enter fullscreen mode Exit fullscreen mode

而且真的有效!

当玩家靠近时,箱子里的物品会实时添加。这种错觉几乎完美。我说“几乎”,是因为很遗憾,我们遇到了一个大问题。

我知道视频里看不出来太多。

更新网格时的性能非常糟糕。

画面会卡住,简直太糟糕了,根本没法玩。

因此,现在是时候进行诊断和优化了。

诊断与优化

当应用程序出现性能问题时,第一反应是在采取任何行动之前先进行诊断。

诊断

对于像我们这样的 Web 应用程序,我们将使用 Chrome 开发者工具来完成这项操作。按 F12,选择“性能”选项卡,然后按 Ctrl+E开始录制。之后,我们正常使用该应用程序,然后再停止录制并分析结果。

这样做之后,我很快就明白了发生了什么事。

我们遇到的帧率下降问题很严重,因为我们试图同时做太多事情。

我们用 JavaScript 做了太多事情。JavaScript 是单线程的,容错率很低。对于单个线程来说,需要在极短的时间内完成太多任务。

还记得我一开始跟你讲的那个简单的计算方法吗?

2000 * Math.random() – 1000

我们对每颗恒星重复此操作 30 万次。在一帧图像中。

将坐标系的每个轴(x,y)乘以 3 。

再乘以 3,因为每次玩家从一个方格移动到另一个方格时,都会创建三个新的方格。

现在,我们只是在对星场进行简单的数学计算。以后当我们创建星云或星系时,数学计算将会复杂得多。

这很贵。非常贵。贵到我们超过了流畅画面每帧允许的 16 毫秒的限制。我们达到了 33 毫秒。这会阻塞事件循环,导致画面冻结,最终无法播放。

如果我们这样处理,我们的玩家也会在 33 毫秒后退出游戏。

优化

为了避免这种情况,我有两种解决办法。

  • 首先,我们将摆脱 JavaScript 的单线程限制。

我们将使用浏览器的Web Workers来实现。我不会对此进行详细讲解,这方面的内容非常普遍,MDN 文档也写得非常好,很容易理解。

具体来说,我们将把游戏中所有繁重的计算任务都交给 Web Workers 来完成。

这些计算将在后台由浏览器完成。我们的目标是不干扰主线程。主线程必须只负责一件事:以流畅的方式向玩家显示内容。

一旦繁重的计算完成,Web Workers 就会以事件的形式返回结果。我们的主线程只需要将它们显示出来即可!

// in worker.js

self.onmessage = messageEvent => {
  const heavyMath = _getHeavyMath()

  self.postMessage(heavyMath)
}

function _getHeavyMath () {
  const positions = []
  const colors = []

  // heavy math to process positions and colors of particles

  return {
    positions: new Float32Array(positions),
    colors: new Float32Array(colors)
  }
}


// in main.js

worker.onmessage = messageEvent => this.showData(messageEvent.data)
Enter fullscreen mode Exit fullscreen mode

就这样,我们就把工作量除以了十!

但这还不够。为了获得更出色的性能,我们将进一步简化事件循环。

  • 其次,我们将拉开方块的展示时间。

目前,繁重的计算工作都在 Web Worker 中顺利完成。但是,三个方块的显示请求很可能同时发出。为了获得完美流畅的图像,我们希望避免这种情况。

为此,我们将使用一个小技巧。

我们将通过一个布尔值来控制同一时间只显示一个星空图。然后,我们将使用 setTimeout 来控制每个方格的显示时间。这意味着每个方格的显示不会是瞬时的,而是会间隔 50 毫秒依次显示。

50毫秒对于事件循环来说是一个很大的缓解。

而且玩家是察觉不到的。

正是我们需要的。

isRenderingClusterInProgress = true

const clusterTorender = grid.queueClusters.keys().next().value

setTimeout(() => {
  grid.renderMatters(clusterTorender, 
  grid.queueClusters.get(clusterTorender))
  isRenderingClusterInProgress = false
}, parameters.global.clusterRenderTimeOut)
Enter fullscreen mode Exit fullscreen mode

瞧!

在浏览器中畅享无限星空。

生活真美好,不是吗?

其余的呢?

如果你玩过这款游戏和/或看过预告片,你会发现这篇文章缺少了 90% 的内容。

星云、太阳、黑洞、红巨星、宇宙间的虫洞、四个不同的宇宙,以及令人难以置信的结局!

是的,的确如此。但这个项目规模庞大,一篇文章根本写不完。

关于这个主题,接下来会有很多文章(至少四篇)。我们会逐一探讨这些主题。

  • 如何在 JavaScript 中构建星云?
  • 如何使用 JavaScript 构建 Red Giant?
  • 如何在 JavaScript 中构建黑洞?
  • 如何在JavaScript中构建虫洞?

敬请期待!

我才刚刚起步。

结语

这是我做过的最大的个人项目,过程非常精彩。期间经历了起起伏伏,既有挫折也有喜悦。就让你们好好享受游戏吧。我不确定近期内是否还会再做这么大的项目。

请分享给其他人。这对我很重要。

我现在要去睡觉了。

文章来源:https://dev.to/jesuisundev/i-built-the-entire-universe-in-javascript-4i10