游戏引擎学习第84天
仓库:https://gitee.com/mrxiao_com/2d_game_2
我们正在试图弄清楚如何完成我们的世界构建
上周做了一些偏离计划的工作,开发了一个小型的背景位图合成工具,这个工具做得还不错,虽然是临时拼凑的,但验证了背景构建的思路。这个过程虽然是一个小插曲,但确实为背景构建提供了一个思路。
另外,不要问为什么屏幕上有这么多浮动的头像,有时候就会出现这些头像。为了避免混淆,现在先把这些头像关闭,恢复到一个浮动的头像,这样更容易处理。
目前的主要任务是完成世界构建,特别是地面部分,以确保我们能够继续推进,标志着 Z 轴的处理已经完成。现在,系统在单一层级上运行良好,但我们已经开始着手实现多层次的功能,这样就可以支持角色走上想象中的楼梯(目前只有矩形表示)。接下来的步骤是实现地面并确保能够正确显示两个层级之间的关系,尤其是在一个层级遮挡另一个层级时能够正确呈现。
我们还没有修改碰撞检测系统来确保角色能够在地面上行走,目前系统只是默认地面在零高度,并且虽然已经标记了地面位置,但并未真正利用这些标记。因此,下一步需要处理的工作就是确保既能绘制地面,又能准确地识别地面的位置,这是当前的关键任务。
思考如何处理地面
目前已经完成了一个能够合成背景中地面的系统,但问题在于,这个地面还没有真正被引入到世界中。它只是在主循环中被调用,所以它只停留在右上角,并没有实际影响游戏世界。因此,下一步的目标是将这个地面纹理实际应用到游戏世界中,让它随着玩家的移动而移动。
为了实现这个目标,可能需要在系统中创建一个实体,这个实体将使用该地面纹理。当这个实体进入视野时,它就会被绘制出来,显示为地面。虽然具体如何实现这一点还没有定下来,但可以先回顾一下当前地面元素的规格,看看是否可以找出一种合理的方法将这个功能整合进现有系统,以避免它成为一个浮动的、独立的部分。
看看我们当前如何指定地面
目前已知系统中有一个“space实体”的概念,它可以描述世界中的space。space实体可以放置在世界中,用来指示玩家能够走动的区域。任何存在space的地方,玩家都可以行走;而space下方没有更多space的地方,则定义为地面。
接下来的问题是如何绘制这些地面区域。是否应当将地面绘制与space实体直接关联,还是将其作为独立的绘制元素来处理,这一点还不确定,考虑起来有些复杂。因此,决定使用黑板来讨论这一想法,以便进一步理清思路。
黑板:我们定义地面的系统
目前系统具有灵活的地面定义方式,使用“空间实体”来定义可行走区域。空间实体并不一定是具体的房间,它们可以是室外区域,表示这是一个可以行走的地方,且底部被视为地面,除非有其他的边界体积重叠。
一种可能的地面定义方式是将地面图像绘制在底部的“平面”上,但这存在问题,尤其是当需要创建更复杂的形状(如挖掘坑洞)时,地面图像的绘制会出现重叠或错误的问题。因此,考虑避免直接存储大规模的地面位图,而是存储重叠的“地面涂抹”信息。通过这种方法,可以从涂抹信息中轻松地重新生成地面纹理,避免存储巨大图像数据集。
此外,考虑到可能存在相邻区域的情况,比如两个房间之间有一个门,或是完全开放的连接区域,平滑滚动时,不希望在边界处出现硬性的接缝。需要确保这些区域的地面涂抹能够无缝连接,形成连续的纹理。
黑板:引擎架构设计的潜在计划和指导原则
目前的设计考虑到如何处理地面涂抹(splat)问题。涂抹不一定需要作为实体存在,因为它们只是用来生成地面纹理,作为实体管理会引入不必要的复杂性。例如,不需要对涂抹进行销毁操作,也不必像实体一样修改它们的属性。涂抹可能只是通过位图覆盖来展示效果,比如显示焦痕,而不会像实体那样进行位置调整。
在架构设计时,需要做出合理的初步判断,并且始终准备好面对可能的错误。在引擎设计中,最重要的是依据游戏实际需求来驱动架构,而不是选择最简单的方案或忽视问题。目标是尽早设想如何使游戏功能有效运行,并通过合理的设置来支持这些需求,确保引擎能够为未来的生产做好准备。
黑板:弄清楚地面的形状
存储涂抹列表的方式可以很简单,基本上可以通过定义一个涂抹区域和涂抹的种类来进行。具体来说,不需要存储所有具体的涂抹内容(如草丛、石头等),而是存储生成这些内容所需的参数。通过这些参数可以重新生成涂抹内容,从而避免存储大量冗余的数据。
更复杂的部分在于确定地面的位置。地面的位置涉及到空间实体,这些空白空间实体定义了可行走的区域,并且需要根据这些空间实体的设置来决定地面的形状。在渲染时,需要知道哪些空间实体与屏幕区域重叠,并根据这些空间实体生成地面。通过将世界划分为多个Z轴层,可以更高效地处理不同层次的地面,并为每个Z轴层单独缓存区域数据。当玩家移动时,可以根据需要重新生成对应的地面区域,而不是一直存储完整的地面数据。
黑板:考虑多个 Z 级别/深度
当处理不同高度的区域时,需要考虑不同的层级和视图。例如,当玩家站在一个平台上,而平台下方有一个悬崖时,地面会被分为不同的Z层,分别表示玩家站立的区域和下方的区域。如果这些区域有不同的Z层高度,那么就需要使用两个独立的缓冲区来处理,而不能将它们合并在同一个缓冲区中。原因在于,不同Z层的区域在渲染时会以不同的速度移动,如果想要实现视差效果,玩家移动时上层区域会比下层区域移动得更快。因此,为了避免合并不同高度的区域,需要为每个层次定义单独的缓存。
为了简化这种处理,可以将空间划分为有限数量的深度层,每个深度层对应一个具体的区域,类似于Z层,但实际上更多是表示层级的深度。例如,深度0层代表玩家站立的位置,深度1层可能表示玩家上方的区域,深度2层可能表示更低的区域。这样,每个层级在渲染时可以独立处理,避免在不同的深度层之间产生混乱。
在进行地面生成时,如果存在空洞或悬崖等结构,则需要特别处理。例如,当区域内有一个坑洞时,需要确保这个空洞部分不会被地面覆盖,而是应该保持透明(Alpha 0),以便显示出下面的部分。生成地面时,可以先填充整个区域的地面,然后再根据空洞的位置将其清除,从而实现挖空区域的效果。
这种方式的关键在于通过填充整个区域并识别空洞,来处理地面的生成。生成的地面可以覆盖整个区域,而空洞部分则通过透明处理去除。这样,地面生成的主要工作就是“雕刻”空洞区域,而不是重新填充整个区域。
黑板:空地物体将会切割出类似的区域
在处理地面生成时,考虑到空洞和悬崖的存在,可以采用一个合理的思路:先将空地区域划分为多个深度层(比如地面、下方区域等),然后根据不同的层次进行划分和处理。对于每个深度层,空地实体(即空洞区域)会根据其最小值的位置被划分到相应的层级中。这意味着,当存在重叠的深度层时,可以根据层次划分来“切割”区域,确保地面和空洞能够正确地显示。
在处理深度剥离(depth peeling)时,当前的做法是通过将实体与相应的深度层进行匹配来决定它们的位置,而不是为每个实体单独生成复杂的几何结构。这避免了使用计算复杂度较高的构造性固体几何(CSG)技术,从而节省了大量不必要的计算工作。这样,虽然实现上有些临时性和简化,但在这种情况下,考虑到操作主要是在几个分层平面上进行的,这种方法仍然是可行的。
因此,考虑到实际需要的复杂度和工作量,将地面区域和深度层进行固定划分并采用简单的处理方式似乎是一个合理的选择,这种方法能够满足大多数场景的需求,并避免了过于复杂的计算和渲染处理。
黑板:该区域的 splats 是什么?
在生成地面区域时,可以采用一种简化的方法,即基于世界坐标生成随机数来决定每个区域的分布。具体来说,可以通过设置一个基于当前位置的随机种子来为每个区域生成“涂抹”效果,而不是存储每个涂抹的具体数据。
例如,在填充一个区域时,可以先定义一个基础分辨率,然后对于每个区域,使用该区域的中心点作为随机种子来生成涂抹效果。接下来,类似于瓦片块系统的工作方式,检查所有可能重叠的区域,生成需要的涂抹效果。这样,涂抹效果只是根据空间位置实时生成,而不需要事先存储所有涂抹的具体信息,这样的方式相对安全且高效。
尝试使地面平面在所有方向上无限滚动
接下来的目标是让地面在所有方向上无限滚动。也就是说,当角色移动时,地面会持续生成,并且始终保持新鲜且不重复的状态。这意味着地面会实时生成,而不是重复使用预先存储的纹理。首先,将通过修改现有的地面生成机制来实现这一目标,并尝试使地面在角色移动时不断生成,以确保无论角色走到哪里,都能有新的地面。
扩大 GroundBuffer,介绍 MaximumZScale 和 GroundOverscan 变量
为了实现地面在所有方向上的无限滚动,首先需要调整现有的地面缓冲区,使其足够大,以便在角色走动时,地面能够扩展并覆盖新的区域。当角色沿着楼梯上升时,地面需要逐渐退到远处,但仍然能够覆盖当前视野区域。因此,地面缓冲区需要有足够的空间,以便地面在深度层次变化时仍能覆盖整个屏幕。
具体实现时,首先通过平台层获得屏幕的宽度和高度,并以此为基础计算缓冲区的大小。接着,考虑到Z轴缩放,当地面随着角色的移动逐渐消失在远处时,地面缓冲区的宽度和高度需要相应增大,以保证能够填充整个屏幕。虽然目前还不确定具体的Z轴缩放因子,但可以通过预设一个缩放比例来为未来的实现做好准备。
此外,为了提高性能,决定引入“过扫描”技术,即在缓冲区的大小上额外增加一些空间,从而减少每一帧更新的次数。这样做有助于在地面滚动时减少计算量,尤其是在后台线程处理中,可以避免每帧都进行完全更新。
最终,地面缓冲区的宽度和高度将根据屏幕尺寸和过扫描比例进行调整,从而确保有足够的空间用于生成新地面并支持角色的移动。
将 GroundBuffer 分区,以便我们可以在其中滚动
在这个过程中,首先需要为地面区域提供足够的空间,以便能够实现滚动。为了达到这一目标,可以通过增加额外的空间来使地面区域能够有效地滚动。接下来,需要将这一区域划分为一个可以更方便使用的结构,以便可以按需进行滚动。此时,必须确保地面区域能够随摄像机一起移动,这是非常重要的功能。
为此,第一步是让地面区域能够随摄像机的运动进行移动。为了实现这一点,摄像机需要以一种合理的方式进行移动。这样可以确保当视角改变时,地面区域的相对位置保持一致,创造出一个连贯的视觉效果。
黑板:当前如何定位 GroundBuffer
在这个过程中,当摄像机移动时,屏幕和缓冲区之间的关系需要特别处理。屏幕展示的是当前视野中的内容,而缓冲区则存储了显示的图像数据。当玩家在某个方向上继续移动时,可能会遇到缓冲区的边界,这时,屏幕上显示的内容就无法继续向该方向滚动。
为了应对这一情况,需要将缓冲区重新居中。具体来说,当玩家继续向某个方向移动并到达缓冲区的边缘时,缓冲区会将已经显示的内容复制到它的起始位置,模拟一个“回弹”的效果。这意味着,当前屏幕上显示的区域将被“传送”到缓冲区的开始位置,同时,缓冲区中的其他内容也会相应地向后移动。这样,就为接下来的地面数据填充腾出了空间,玩家可以继续朝着原来方向前进。
这种操作虽然有效,但在实现时会出现一定的延迟,因为当前的处理过程没有多线程优化,每次重新居中和填充缓冲区时,都会出现短暂的卡顿。这种问题暂时不是优先解决的问题,关键是先让核心功能正常运作,至于如何加快这一过程并通过多线程来优化,计划在后续进行处理。在此之前,尽管会有一定的性能问题,但这是可以接受的,并且会随着开发的推进逐步解决。
确定 GroundBufferP 相对于 CameraP 的位置
为了让地面缓冲区随着摄像机移动,首先需要知道地面缓冲区相对于摄像机的位置。因此,地面缓冲区的初始位置将与摄像机位置重合,然后允许摄像机移动。当摄像机移动到一定距离后,需要将地面缓冲区重新定位,使其与摄像机保持同步。
具体实现时,可以通过屏幕的中心坐标来确定地面缓冲区的位置,结合地面缓冲区的大小进行偏移处理,使得地面缓冲区从屏幕的左上角开始绘制。接着,需要计算摄像机与地面缓冲区的相对位置差异(以某种方式表达为向量),并根据这个差值来调整地面缓冲区的位置。
在此基础上,还需要处理坐标系的转换问题。屏幕坐标是以像素为单位,而地面缓冲区的坐标是以米为单位。因此,必须将米转换为像素,以便正确渲染。
此外,在计算时还会遇到坐标方向上的问题。例如,Y轴的方向在世界坐标系中是向上的,但在屏幕坐标系中可能是相反的。这种问题需要在渲染时进行适当调整。
在实现过程中,可以先将地面缓冲区的位置初始化为摄像机的位置。之后,当摄像机移动时,地面缓冲区的位置会根据摄像机的变化而更新,从而实现地面缓冲区随摄像机的运动同步。
计划在 GroundBuffer 用完像素覆盖时,将其朝摄像机的方向快速移动
当地面缓冲区即将无法覆盖像素时,需要做的是将其位置调整到靠近摄像机的方向。具体来说,当地面缓冲区即将超出屏幕范围时,需要将其位置“吸附”到摄像机的方向,确保它总是能够有效地显示。为此,需要执行一个复制操作,改变地面缓冲区的位置,以便保持其与摄像机视角的对齐。
关闭树木
首先,目标是摆脱当前被树木所限制的情况。因此,暂时关闭生成树木的功能,以便进行测试。接下来,需要清除当前的树木和其他相关物体,以便可以更自由地进行移动和测试。此时,地面测试区域的空间可能不足,因此需要增加空旷区域的大小,确保测试能够顺利进行。可以通过调整“空旷空间添加器”来实现这个目标。为了确保足够的空间,可以将空旷区域做得更大,进一步确保测试的有效性。
关闭 Z 门
通过回到旧的方法,限制生成的内容,确保生成的区域不会出现Z型门。这样在随机选择时,通过指定只生成平坦区域,避免了不必要的复杂性。这使得可以实现之前设想的行走系统,确保了功能的实现,大家都能满意。
另外,之前有一个功能只会生成一个房间,经过调整,停止了这种限制,重新启用了实际的房间生成。这样,房间的生成不再被局限于一个单一房间,而是恢复了更灵活的生成方式。
打开树木
现在可以恢复之前的功能,让生成更多的房间,并确保房间中有更多内容。这样,重新生成的房间变得更加丰富,玩家可以顺利地穿行其中。
接下来,目标是确保这些房间能够填充地面。在进入房间时,必须确保地面始终存在并且被填充。这意味着每当走进一个房间时,都需要确保该区域有稳定的地面支持,保持持续的地面存在。
使地面不断地生成
为了生成填充地面的效果,首先需要智能地生成屏幕内容。在生成这些内容时,不需要复杂的瓦片化处理,操作相对直接。通过使用 DrawTestGround
方法,可以确保填充整个缓冲区。在执行 DrawTestGround
时,关键是要避免使用半径方法,而是要生成一种随机性,能够扩展到缓冲区的边缘。
为了确保生成的地面足够大,并且能够覆盖整个区域,必须在瓦片边界上正确地重新生成种子。这样就能确保每次生成的内容都符合预期,不会出现空白或不连贯的地面区域。
黑板:平铺考虑
在生成地面时,遇到的挑战是当玩家移动到屏幕一侧时,需要复制缓冲区的一部分并将其填充到新的区域。然而,由于不是使用简单的重复瓦片,而是随机生成地面内容,这个过程变得复杂。如果地面内容是重复的瓦片,直接填充就能解决问题,但随机生成的内容涉及更多因素,比如区域内可能有不同的地形特征,生成过程也需要考虑这些因素。
为了保证每次填充相同的区域时能生成一致的内容,必须避免受随机因素的影响。一种解决方案是将地面分割成多个“块”,每个块可以根据固定的参数来生成内容,而不是依赖于动态的随机因素。这样,生成的内容在每次填充时都能保持一致,确保整个地面填充过程的连贯性和稳定性。
将 DrawTestGround 改为 DrawGroundChunk
为了生成地面块,需要通过一个“DrawGroundChunk”绘制方法,将其与缓冲区进行交互。在绘制时,首先需要知道每个块的位置,并确保生成的内容基于相同的初始位置,以确保随机生成的内容每次一致。通过使用块的位置坐标(如块的X、Y、Z值),可以生成一个随机种子,这个种子决定了该区域的内容。
接下来,生成的地面块需要填充指定区域,而不是依赖固定的半径或像素大小。在绘制时,使用随机因素来确定偏移量,确保地面内容覆盖整个区域。通过这种方式,可以摆脱传统的固定尺寸和半径计算,使用整个位图的尺寸作为偏移量来生成地面。最终,生成的地面块会精确覆盖区域,并使用随机值来调整偏移量,以便每次填充的结果都能保持一致。
这些步骤确保了地面生成的灵活性和一致性,但目前的实现还相对简化,代码未完全优化,仍然有改进空间。
问:为什么不填满整个游戏窗口,而是留那么多黑色空间?
在填充整个缓冲区时,尽管目前已经填充了大部分区域,但细节层面尚未达到预期。因此,需要提高填充的细节度,可能需要增加参数值以获得更精细的覆盖。然而,这个问题可以在后续阶段进一步解决。
至于在开发过程中不使用全屏显示,这是为了避免出现绘制错误,因为全屏可能会让问题隐藏在屏幕边缘。开发时,通常会以窗口模式显示游戏内容,而不是全屏,这样更容易检测到潜在的绘制问题。在未来的调试过程中,可能会采用一种居中显示的方式,并且会标出安全区域,确保开发人员能够看到更多的游戏区域,尤其是屏幕之外的部分。
这种显示方式帮助开发者更好地定位和修复问题,而不仅仅是展示玩家视角。全屏显示主要用于最终测试,以确保游戏在实际玩家体验中正常运行。
问:处理多个层级显示时的一些问题,是否源于没有一个 Z 坐标来让摄像机直接进行线性插值?
在处理多层显示时,并不主要由于缺少相机的Z坐标进行插值(lerp)引起问题,因为相机的Z坐标是有的,问题更多是由于没有完整的3D几何体。如果游戏完全采用3D构建,这些问题就不会存在。然而,使用2D游戏时,艺术创作会变得相对简单,单个艺术家就能完成整个2D游戏的制作,而3D游戏则需要更多的人员和资源。
2D游戏虽然在艺术质量上可能有所提升,但程序复杂性相对较低,因为不需要处理复杂的资产管道、预处理和着色器等3D开发中的问题。尽管2D游戏在一些方面简化了开发,但如果要做一些有趣的、复杂的功能,开发仍然会变得更加复杂。
如果想避免解决复杂的编程问题,也可以选择使用单一Z轴水平或者不做平滑的楼梯上升,这也是可以接受的做法。但如果追求更多复杂的编程挑战,采取这些方式就会限制开发的灵活性。通过这个编程流,目的是展示如何超越简单的工具,推动代码开发的边界,做一些在现成工具中无法轻易实现的事情。例如,程序化生成的地面就是一个典型的例子,这种功能可能无法通过简单的现成工具实现,因此展示如何进行这类创新是重点。
问:如果没有人问相关问题,能否大致估算一下添加字体和文本渲染系统何时会变得有用或必要?
添加字体和文本渲染系统的时间框架已经在开发计划中,预计不会太久。虽然目前的工作重点是解决一些更为复杂的问题,这些问题并不直接需要字体显示,但在处理诸如资产流式传输之类的任务时,字体系统将变得非常重要,尤其是在调试时,需要显示调试信息,这时就需要字体系统来支持显示调试文本。此时,可以使用临时的调试字体,而不需要最终的字体设计。
讨论学习 C++
关于学习C++或OpenGL等编程问题,推荐的学习资料并不多,且目前市场上充斥着许多质量不高的教程。尽管如此,还是可以找到一些有用的教程。例如,OpenGL方面,之前有一个名为Arc Synthesis的教程系列,虽然它被认为是一个不错的学习资源,但目前似乎已经无法找到这些教程了。如果这些教程确实消失了,就需要另寻其他合适的学习资源。目前,考虑到开发进度,可能会在将来的直播中涉及一些图形API的内容,若幸运的话,未来也许会用到Vulkan图形API。因此,尽管目前没有明确推荐的OpenGL教程,但Arc Synthesis教程曾经是一个不错的选择。
问:你提到将地面绘制工作转移到单独的线程以防止卡顿。是否有可能将绘制工作分配到多个帧中,并且这比任何一种方法有益吗?
计划将地面绘制的工作分配到单独的线程上,以防止主线程出现卡顿。这种做法的目的是为了利用CPU上的空闲资源,尤其是在2D游戏中,一旦开始使用GPU进行渲染,通常CPU上会有多个核心处于空闲状态。因此,希望将一些生成性工作完全异步地从主线程中分离出来,比如生成或加载数据等。可能会有一个专门用于后台加载和解压的CPU线程,以及另一个用于生成工作的线程,这些线程之间不需要直接关联,而是通过队列进行通信。这种方法将能有效提高性能,尽管目前无法保证最终一定会这样实现,但这是一种可能的优化方案。
问:你的游戏目前使用了多少内存占用?当然它不会很大,但我更想了解有多少不同的容器?
游戏的内存占用目前不会很大,但问题是关于使用了多少种不同的容器。如果能更具体地说明“容器”指的是什么,可以更好地回答这个问题。
问:2D 图形的程序生成:是否有一些主流的数学方法?
程序生成2D图形并没有明确的主流数学公式。虽然其中有一些数学元素,比如噪声模式等,但更多的是一种黑魔法。程序生成2D图形是一个需要不断学习和实践的过程。例如,将会进行更好的地面生成,这将是一个探索这类技术的实际案例。
问:目前已经使用了多少不同的类?
在编程中,并不使用传统的类,而是采用了系统化的方式。例如,使用“模拟区域”(Sim Region)来处理游戏区域的模拟,使用“世界表示”(World Representation)来将世界划分为多个块并允许快速访问。这些“系统”通过结构体来定义数据布局,而不是传统的面向对象的类。系统之间的结构较为宽泛,通常更侧重于整体的系统设计,而非按类划分。
问:有没有一种简单的方式来解释 C++ 中的指针和引用?
在C++中,指针和引用之间的差异实际上并不复杂,它们只是语法上的不同。指针使用*
符号来表示一个指向某个变量的地址,而引用则是某个变量的别名。
-
指针:通过声明一个指针(如
int* x
),我们实际上是在声明一个指向整数的内存地址。这意味着,指针存储的是另一个变量的地址,通过解引用(*x
)可以访问该地址存储的值。指针允许访问和修改它所指向的内存位置。 -
引用:引用则是某个变量的别名。例如,
int& x = y;
表示x
是y
的引用,意味着x
和y
实际上指向相同的内存位置。引用无需解引用操作符(*
),直接当作原变量来使用。引用在语法上看起来更像是直接访问变量,但底层仍然是通过指针来实现的。
在实际使用中,指针和引用在大多数情况下有相同的效果。在函数调用中,无论是通过指针还是引用传递参数,最终都能够修改原始数据。差异在于引用更简洁,语法上更易于理解,而指针则提供了更灵活的操作,例如指针可以为空,而引用则不能。
实际上,指针和引用的实现方式相同,都是指向内存地址,区别在于它们的语法和用法。除非有特定的C++特性要求使用引用,否则指针可以提供更多灵活性,引用则更多用于简化代码。