我用 JavaScript 构建了整个宇宙。
穿越多元宇宙
执照
这不是标题党。我真的做到了。而且这不仅仅是一个网页体验。不,这是一个真正的游戏,可以自由探索,3D效果,就在你的浏览器里!你不信?
TLDR
哦,我知道您的时间很宝贵。这是精简版。
我用 JavaScript 开发了一款免费的 3D 浏览器游戏,玩家可以自由探索无限个宇宙。游戏的目标是什么? 从一个宇宙到另一个宇宙,探索万物的起源。
这是一个分为四章的故事,结尾有一个惊天大揭秘。
在继续阅读本文之前,请停止一切操作,将游戏画面全屏显示,准备一些爆米花,然后玩 《穿越多元宇宙 》!
没时间玩?
你更喜欢在电脑上而不是手机上观看?
或者你只是想在玩游戏之前多了解一些信息?
我接到你了。
我在YouTube上发布了一个宣传片!只有3分钟长,内容很丰富。
但请注意,它非常壮观!
VIDEO
我投入了大量的时间、心血和技能来创作这款免费的互联网游戏。
如果你能抽出五秒钟时间分享一下,那就太好了。
与此同时,是时候谈谈房间里那头显而易见却又无人提及的大象了。
我到底是怎么做到的?
空谈无益,拿出代码来。
我知道你们很多人都喜欢直接看代码,甚至在我给出精彩解释之前就先看代码。我完全理解这一点。
对于最没有耐心的玩家,这里提供了游戏的完整源代码。
一款可在浏览器中自由探索的3D游戏,带你穿越程序生成的无限宇宙。穿梭于各个宇宙,揭开万物起源的秘密。游戏包含四个章节,结尾将有惊天揭秘。
当然,它是根据 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