回顾并计划今天的工作
我们从头开始编写一款完整的游戏,完全不依赖游戏引擎和库。我们会从最基本的渲染代码开始,一直到高层的AI代码,涵盖其中的一切。
目前,我们正在做一些比较轻松有趣的事情,可以说是比较随意的内容,那就是自己实现一个通用的内存分配器。昨天我们已经做了一个,但是因为时间不够,我们并没有真正运行它,也没有进行充分的测试,实际上也没有验证它是否工作正常。并且,它的效率也肯定不高,所以今天我们将回头看一下这个通用分配器,进行调试,把它调整到一个可用的状态。
接下来,我们可能会进一步思考如何优化这个分配器的效率,毕竟,它的实现方式可能存在一些不太理想的地方。今天的工作重点就是调试并使其正常运行,之后再看看有没有地方可以改进。
我们需要调试上次的更改
昨天简要运行了一下通用分配器,它基本上是能工作的,但表现出来的一些情况让我觉得它还有问题。虽然没有直接崩溃,但出现了一些奇怪的闪烁现象,这让我怀疑它在释放内存时可能存在问题。这个闪烁可能是因为内存没有被正确释放,或者在某些情况下无法及时清理掉已经不需要的内存。
虽然这个问题并不致命,也没有造成程序崩溃,但我感觉它表明系统内部有一些不正常的地方。我的直觉是,可能存在某些内存管理的逻辑问题。虽然我们可以通过增加允许使用的内存量来暂时消除闪烁,但这可能只是掩盖了问题本身。因此,我决定趁这个机会深入检查,找出问题所在,并修复它。修复后,闪烁现象应该会明显减少,但我也知道这种情况可能只是我的假设,实际情况可能有所不同。
至于之前的内存管理策略,像“逐出资产”和“目标内存与已用内存对比”的逻辑,现在看来已经不再需要了。因为现在的内存管理方式已经完全基于内存块。当内存块不够时,我们知道需要释放某些内存,所以之前的这些计算就变得没有必要了。接下来,我会从代码中移除这些不再需要的部分,简化内存管理逻辑,专注于解决实际问题。
简化昨天的一些代码
首先,决定简化一些代码,去除那些不再需要的部分。目标是让代码更简洁,避免不必要的复杂性导致混淆。首先,去掉了“目标内存使用”和“已用内存”相关的代码,因为现在内存管理是基于内存块的,不再需要这些计算。接着,将“逐出资产”的逻辑也去掉了,因为现在不再需要这个功能,所有内存释放和管理工作都可以直接通过内存块来处理。
然后,开始专注于核心的内存管理功能,特别是释放资产内存的部分。发现当前代码存在一个问题:没有重新合并内存块。这意味着我们没有正确地将已释放的内存块重新合并成大块,这可能是导致内存无法有效利用的原因。因此,怀疑这可能是导致闪烁现象的原因,而不是其他更严重的错误。实际上,可能只是没有完全实现内存块合并的功能,导致内存块碎片化,最终无法加载较大的资产。
接下来,将“逐出资产”的逻辑直接内联到代码中,因为这个操作只有在一个地方才会执行,所以没有必要单独作为一个函数调用。通过这种方式,代码会更加紧凑,易于理解。还决定将释放资产内存的操作也内联到代码中,使得整个内存管理部分变得更加简单和集中。
在测试时,闪烁现象依然存在。尽管如此,认为可能是由于内存块没有被合并,导致内存碎片化,从而影响了大型资产的加载。但在没有彻底实现内存块合并功能之前,很难准确调试出问题所在。因此,认为在继续调试之前,必须先完整实现内存合并功能,因为只有在功能完全实现的情况下,调试才有意义。
完成释放块的合并
首先,当我们释放资产块时,不再需要像之前那样去寻找合适的内存块。现在,只需要检查当前块是否已经被使用。如果当前块能够释放,那么我们直接操作这个块,而不再去找其他块。
一旦释放了当前块,就需要检查它两侧的相邻块,看它们是否能够与当前块合并。我们希望将这些相邻的块合并成一个更大的块。如果可以合并,就将其合并。具体来说,首先检查当前块前面的块,如果能合并,就执行合并操作。然后,再检查当前块后面的块,执行同样的操作。
为此,设计了一个“合并如果可能”的函数。这个函数会检查当前块前后是否有相邻的空闲块,如果有,则尝试合并它们。合并完成后,返回合并后的块,或者返回原块(如果没有成功合并)。
合并操作的具体流程是:首先尝试将当前块与前面的块合并,如果合并成功,返回合并后的块。如果合并失败,则返回当前块。然后,再尝试将当前块与后面的块合并。如果合并成功,则更新当前块,使其包含合并后的内存区域。
无论合并是否成功,最终都以当前块作为操作的结果。合并后,如果块变大,当前块就会变得更大;如果没有合并成功,则当前块保持原状。这个过程的目的是尽量合并内存块,从而更有效地利用内存空间,减少碎片化问题。
实现 MergeIfPossible
首先,要实现“如果可能,合并”操作。为了完成这项任务,需要检查两个相邻的内存块是否可以合并。首先定义这两个块为“第一个资产内存块”和“第二个资产内存块”,并且假设它们应该是连续的,即第一个块的结束位置紧接着第二个块的开始位置。
合并的条件是:
- 确保这两个块都不是虚拟的或“哨兵”块(即这些块是实际的内存块而非占位符)。
- 检查这两个块的状态,确保它们都是“未使用”的。只有当这两个块都为空闲状态时,才有可能合并它们。
接下来,我们需要验证这两个块是否在内存中是连续的。假设系统允许使用多个不连续的内存区域(特别是在32位系统中),因此不能直接假设两个块在物理内存中的位置是紧挨着的。为了检查它们是否连续,我们需要计算出第二个块的位置,并确保它恰好接在第一个块的后面。
具体来说,如果第一个块的结束位置加上资产内存块的头部大小(用于存储元数据)和实际数据的大小后,正好等于第二个块的开始位置,那么它们就是连续的。
一旦确认了块是可以合并的,接下来的操作是:
- 从内存链表中移除第二个块。由于没有现成的函数来移除内存块,因此需要手动操作,将前一个块的“下一个”指针指向第三个块,将后一个块的“前一个”指针指向第一个块,从而将第二个块从链表中移除。
- 更新第一个块的大小,将它的大小扩展到包括第二个块的内存。
- 完成合并操作并返回结果。
最后,如果所有操作成功,合并标志为真,表示两个块已成功合并。如果任何步骤失败,返回假,表示无法合并这两个块。
修改 AcquireAssetMemory
,检查我们加载的资产是否适合新释放的空间
在合并内存块的过程中,还有一个重要的检查需要进行。在当前逻辑下,假设在合并后,得到的内存块应该能够满足需要存储的数据大小,但这个假设并不一定成立。因此,在合并之后,需要重新检查当前的内存块是否真的足够大,能够容纳目标数据。
具体来说:
- 不能再简单地假设合并后就一定可以使用,而是需要一个额外的检查。
- 在合并之后,应该检查当前的内存块是否存在,并且是否能够存储目标数据。
- 之前可能将这一点当作一个断言(assertion),但现在应该改为显式的条件检查。
- 由于刚释放的内存块可能依然不足以容纳新的数据,因此即便完成合并,仍然需要额外的逻辑来决定是否继续释放或申请其他块。
最终,合并后的内存块仍然需要进行额外的条件判断,确保它真的可以存放需要存储的内容,而不能直接假设合并一定能解决问题。
我们完成了!
目前,我们的通用内存分配器已经基本能够正常工作,并且在有限的内存条件下完成了资产的动态调入。当前的系统可以在不额外申请内存的情况下,只使用固定的内存块进行资产管理和分页加载。虽然现在仍然存在闪烁现象,但这主要是由于内存过于受限,导致资源不断被换入换出,而非系统本身的问题。
主要的优化成果:
内存使用受控
- 资产系统的内存使用量是固定的,不会超过预先设定的大小。例如,当前设定的上限是 4MB,系统始终在这个限制内管理资产,无论有多少资产被加载或换出。
- 这意味着整个游戏现在可以在严格的内存预算下运行,而不会无限制地申请新内存。
正常的资产换入换出
- 由于系统现在严格遵守内存限制,资产会在需要时被换入,并在内存不足时进行替换。
- 目前的闪烁问题主要是由于内存不足,导致资产频繁被移除和重新加载,而不是系统本身的错误。
现存的问题与后续优化方向:
资源加载策略
- 目前资产换入换出的机制虽然可以在有限内存下正常工作,但由于内存过小,可能导致一些关键资源频繁被替换,影响性能和视觉稳定性。
- 可能需要进一步优化策略,例如:
- 提前加载关键资源并锁定它们,避免高频率的换出。
- 采用更智能的资源调度算法,优先保留使用频率高的资产。
闪烁现象的优化
- 由于资产被频繁替换,可能会导致帧率波动或视觉上的闪烁现象。
- 可能需要调整内存管理策略,例如:
- 增加缓冲区,减少资产被换出的频率。
- 优化内存分配算法,使得合并后的内存块更容易容纳较大的资源。
系统测试和稳定性检查
- 目前虽然系统表现良好,但仍需要更长时间的测试,以确保所有边界情况都能被正确处理。
- 需要检查是否有潜在的内存泄漏或分配失败的情况,特别是在长时间运行或极端情况下。
总的来说,当前的内存管理系统已经达到了预期的效果,并且可以很好地控制内存使用量,使得游戏能够在固定的内存条件下运行。但仍然存在优化空间,主要集中在资源加载策略和减少闪烁现象方面。
这个通用分配器可能的问题:每次分配时,链表可能太长,导致遍历的效率低
当前内存分配方案总结及潜在问题分析
目前的内存分配方案采用了一种链表管理方式,其中每个内存块都通过双向链表进行链接。这种方式在合并内存块时非常高效,因为合并操作可以通过简单的指针调整瞬间完成。然而,这种方案在搜索可用内存块时可能会遇到效率问题,特别是在资产数量增加的情况下。
当前内存分配机制
基本结构
- 资产的内存块以双向链表的形式组织,所有空闲块和已使用的块都链接在一起。
- 每次加载新资产时,都会遍历链表,寻找合适的空闲块进行分配。
- 当释放内存时,相邻的空闲块会被合并,以减少碎片化。
合并操作的优势
- 由于采用双向链表,合并操作是即时完成的,无需复杂计算。
- 释放一个块后,只需检查前后两个块,若它们都是空闲的,则直接合并。
- 这个部分的效率不会成为瓶颈,无论资产数量多少。
可能的性能问题:查找合适块的速度
- 目前的查找策略是线性搜索,即每次加载新资产时,都需要遍历链表,找到能容纳新资产的合适块。
- 假设游戏中有 4000 个资产,每个资产占据一个独立的内存块,则链表长度可能会达到 8000 个节点(包括已分配和空闲的块)。
- 这意味着每次加载新资产时,可能需要遍历数千个节点,导致内存分配时间随着资产数量增加而显著上升。
潜在的优化方向
由于当前游戏的资产数量较少(大约 30 个),遍历几十个节点的开销可以忽略不计,因此短期内不会遇到性能问题。然而,在未来,当游戏的资产数量增长到成千上万时,可能会遇到以下问题:
内存分配操作变慢:
- 例如,每帧可能需要加载多个新资产,而每次加载都需要在链表中遍历数千个节点,这将导致 CPU 开销显著增加。
- 在 4GB 以上的内存预算下,可能会存储大量资产,届时分配速度将成为瓶颈。
优化方案:加速查找
- 哈希表/树结构:可以在链表基础上引入额外的索引结构,如 哈希表 或 平衡树(如 AVL 树、红黑树),加速空闲块的查找。
- 分层链表:将不同大小的空闲块放入不同的链表,避免遍历整个列表。
- 自由块映射(Free List Buckets):按照块大小划分多个队列,例如 4KB、8KB、16KB 的块分别存储到不同的列表中,减少搜索范围。
当前决策:延后优化,等待性能瓶颈出现
尽管优化内存分配查找是有意义的,但目前优化可能是**“无数据支持的过早优化”**,存在以下问题:
- 现在资产数量较少,遍历链表开销很低,没有实际性能问题。
- 过早优化可能会导致不必要的代码复杂度,并优化错误的部分,浪费时间。
- 最佳策略是等到游戏内存使用量增加、性能问题真正显现后,再进行优化。
因此,当前的计划是:
- 先保持现状,观察链表查找是否真的成为瓶颈。
- 等到游戏资产增加到几千个时,再进行性能分析,确认是否需要加速查找。
- 如果查找成为性能热点,再决定最佳优化策略,例如哈希表索引或分层链表。
总结
- 当前的内存分配方式主要问题是查找速度,而非内存碎片化。
- 合并操作已优化,不会影响性能。
- 短期内不会遇到性能问题,但当资产数量增加时,链表遍历可能变慢。
- 未来可能采用索引结构(如哈希表、树、分层链表)来加速查找,但暂时不进行优化。
- 等待实际性能瓶颈出现后,再进行针对性优化,避免“无数据支持的优化”导致资源浪费。
当前的内存管理系统已经基本成型,接下来可以将注意力转向调试工具或其他模块,而不是过早优化内存查找逻辑。
修正 OpenNextFile
中的错误:如果平台层无法分配文件句柄,该函数不会报告错误
目前,我们发现了一个代码实现上的问题,这个问题源于我们最初的假设与实际运行方式不符。因此,我们需要对其进行调整,以确保错误处理逻辑能够正确运行。
当前的错误处理系统整体上是比较完善的,能够使用 PlatformNoFileErrors
这一机制来处理文件句柄的问题。但我们发现 platform_open_next_file
可能会完全失败,这是因为它依赖于内存分配,而平台层无法保证一定能返回一个有效的文件句柄。
具体来说,platform_open_next_file
需要调用 VirtualAlloc
来获取文件句柄,而这个过程可能会失败。这导致 platform_no_file_errors
机制无法正常工作,因为返回的句柄可能是 NULL
,从而导致后续的代码无法正常运行。因此,我们需要调整 platform_open_next_file
的行为,使其符合我们最初的设计目标。
为了解决这个问题,我们决定改用一种新的数据结构。新的结构会存储一个指向平台特定部分的 void
指针,这样可以确保关键数据(如错误码和文件计数)始终可用,而不会因为 NULL
句柄而导致异常情况。同时,这样的设计也能够避免分配失败导致的错误扩散。
具体的调整方式如下:
调整文件句柄的存储方式
- 之前的实现中,我们在代码中多次使用了指针来引用文件句柄。
- 现在,我们改为直接存储文件句柄,而不是使用指针引用。这样可以确保文件句柄的基本信息始终有效。
修改
platform_open_next_file
的返回方式- 之前
platform_open_next_file
直接返回指针,现在改为返回一个包含platform-specific
部分的struct
,这样即使NULL
发生,也不会影响外部代码的运行。 - 这使得错误检查逻辑更加清晰,并且避免了外部代码反复检查
NULL
句柄的问题。
- 之前
统一错误检查方式
- 之前在多个地方都需要检查
NULL
句柄,这种做法既繁琐又容易出错。 - 现在,我们改为在
file_handle
结构体内部存储错误状态,这样外部代码可以通过一个统一的错误码来判断状态,而不需要手动检查NULL
。
- 之前在多个地方都需要检查
修改调用方式,避免额外的指针操作
- 之前的代码需要不断地对指针进行操作,而新的实现可以直接使用结构体变量来存储必要的信息。
- 这样可以减少不必要的指针操作,提高代码的可读性和安全性。
修正
platform_file_error
的实现方式- 目前
platform_file_error
被实现为一个函数,但我们发现它完全可以被改为一个宏,就像platform_no_file_errors
一样。 - 这样可以减少函数调用的开销,并且更加直观。
- 目前
代码调整与清理
- 在实现过程中,我们修改了多个函数的参数,使其符合新的数据结构。
- 主要的变化包括:
get_all_files_of_type
仍然使用指针,以保持一致性。platform_open_next_file
现在返回完整的file_handle
结构体,而不是指针。- 相关函数的调用方式相应调整,改用
&file_handle
传递参数,而不是file_handle*
。
确保所有平台特定部分正确引用
platform_file_handle
现在被作为file_handle
的一部分存储,而不再单独作为指针管理。- 这样可以简化内存管理,同时确保
file_handle
结构体始终是完整的,不会因为NULL
句柄而导致崩溃。
修正
read_data_from_file
的错误- 之前的代码在
read_data_from_file
中错误地使用了->
操作符,而现在改为.
,确保访问的是正确的数据结构成员。
- 之前的代码在
经过这些修改,我们成功实现了更可靠的错误处理机制,使代码更加健壮,同时也减少了外部代码对 NULL
句柄的反复检查。这样,错误处理逻辑更加集中,避免了潜在的异常情况,并提升了代码的可维护性和可读性。
处理包含 Unicode 字符的资产文件
虽然我们并不关心这个问题,但如果有人关心,我们仍然愿意演示如何处理。人们担心的问题并不是代码本身存在问题,因为这个代码不会有任何问题,而是如果在 Unicode 环境下使用类似的代码,可能会出现问题。Unicode 相关问题常常让人焦虑,但这并不是我们关注的重点。
在 Windows 系统中,我们使用的是带有 “A” 版本的 API 调用,即 ANSI 版本的 API,因此我们传递的是 ANSI 字符串。这里的代码仅用于枚举当前目录中的文件,而这些文件是我们自己创建的资源文件,因此不会包含 Unicode 文件名,因为我们不会在资源文件中使用 Unicode 文件名。
然而,如果需要加载包含 Unicode 文件名的资源文件,这种方法是无法工作的。原因在于,当调用 FindFirstFile 这样的 API 并使用 “A” 版本时,它无法返回 Unicode 文件名,因为 WIN32_FIND_DATAA 结构体不支持 Unicode 文件名。因此,如果想要加载 Unicode 文件名的数据,就需要切换到使用 “W” 版本的 API,例如 FindFirstFileW。我们接下来就会进行相应的修改。
关于不暴露文件名概念给游戏代码的重要性
此外,这个问题还会向下级联,因此不仅仅是 FindFirstFile
需要使用 W
版本,CreateFile
和 FindNextFile
也必须使用 W
版本,而不能继续使用 A
版本。因此,这部分代码同样需要进行调整,切换到使用 W
版本的 API。
这里有一个值得强调的点,这个设计是有意而为之的,虽然之前没有特别提及。整个系统的架构中,文件名从未暴露给游戏代码,这是一个非常有意识的决定。原因就在于文件系统的底层实现千差万别,我们无法预知代码最终会运行在哪个操作系统上,也无法确定它使用的是何种字符编码,例如 UTF-8、UTF-16,甚至是某些特殊的本地编码格式。此外,我们也无法确定不同系统的路径分隔符是什么,甚至不清楚是否可以在文件名中直接使用路径信息。换句话说,底层文件系统的复杂性是不可控的,任何情况都有可能发生。
然而,很多人选择让这些文件名信息直接传递到游戏层,这是一种糟糕的设计。这样做的最大问题是,它会将底层文件系统的复杂性暴露给游戏代码,而实际上游戏代码并不关心这些问题。游戏代码只需要知道资源文件或存档文件的存在,而不需要了解它们的具体名称。因此,我们的架构设计是让 API 对文件名保持“盲目”状态,API 只知道有一定数量的文件返回,而在遍历这些文件时,它们仅通过索引访问。例如,可以请求第一个文件,或者通过索引获取下一个文件,而完全不关心文件的具体名称。
这一设计是合理的,并且使得我们的代码能够在不影响上层逻辑的情况下轻松地切换到 Unicode 处理方式。正因为文件名信息没有泄露到游戏层,我们可以在底层自由地进行修改,而不需要改动上游代码。这不仅优化了架构设计,也能减少开发过程中因编码问题带来的麻烦,让系统更加稳定和易于维护。
使代码能够正确处理 Unicode 文件名
首先,我们需要将所有相关的函数和变量切换到使用 W
版本的 API,即 Unicode 版本。这样做的目的是让编译器开始提示我们,哪些地方存在问题,哪些地方需要调整,因为它们不再兼容之前的 ANSI 字符串处理。
修改过程
将所有函数切换到
W
版本:- 我们的目标是将所有涉及文件操作的函数都替换为它们的 Unicode 版本。例如,
FindFirstFileA
被替换为FindFirstFileW
,CreateFileA
被替换为CreateFileW
等。所有函数名中的A
都需要替换为W
。
- 我们的目标是将所有涉及文件操作的函数都替换为它们的 Unicode 版本。例如,
检查并处理字符串类型:
- 很多地方的字符串参数之前是
char
类型的,而现在需要改为wchar_t
类型,因为 Unicode 字符串使用的是宽字符。比如,FindFirstFileW
和CreateFileW
都要求传入的是宽字符(LPCWSTR
),而我们现在传入的可能是普通的char
类型字符串。这时,编译器会提示错误,指出字符串类型不匹配。
- 很多地方的字符串参数之前是
修改传入的字符串:
- 比如,在
FindFirstFileW
函数中,原来的wildcard
参数是一个char
字符串,而现在它需要是一个宽字符(wchar_t
)字符串。为了避免这种类型不匹配的错误,我们需要将原来的char*
字符串转换为wchar_t*
字符串。可以通过MultiByteToWideChar
等函数来进行转换。
- 比如,在
解决类型不匹配问题:
- 比如在
CreateFileW
函数调用中,传入的文件名参数原本是char*
类型的,现在需要改成wchar_t*
类型。编译器会报错,提示文件名参数类型不匹配,这时候就需要进行字符串类型转换。
- 比如在
处理文件句柄和其他相关对象:
- 在处理文件句柄时,也需要注意
WIN32_FIND_DATAA
和WIN32_FIND_DATAW
之间的区别。原来使用的WIN32_FIND_DATAA
结构体需要改成WIN32_FIND_DATAW
,否则也会导致类型不匹配的错误。
- 在处理文件句柄时,也需要注意
使用 Unicode 字符串的步骤
确保所有字符串常量都是宽字符字符串:
- 在 Windows 中,宽字符字符串以
L
前缀开头,比如L"filename"
。确保所有需要作为 Unicode 字符串传递的地方都使用了宽字符常量。
- 在 Windows 中,宽字符字符串以
使用合适的 API 函数:
- Windows 提供了许多支持 Unicode 的 API 版本,确保我们调用的是以
W
结尾的版本,而非以A
结尾的版本。比如,FindFirstFileW
和CreateFileW
都是处理宽字符字符串的函数。
- Windows 提供了许多支持 Unicode 的 API 版本,确保我们调用的是以
使用
MultiByteToWideChar
转换字符串:- 如果我们从其他地方获取了普通的
char
字符串,而需要将其转换为宽字符字符串(wchar_t
),可以使用MultiByteToWideChar
函数进行转换。这是 Windows 中提供的标准方法,可以将多字节字符(如 ANSI)转换为宽字符(Unicode)。
- 如果我们从其他地方获取了普通的
确保 Unicode 字符串在整个代码中一致:
- 所有涉及文件路径、文件名等操作的地方都需要确保使用 Unicode 字符串,以保证兼容性和稳定性。
总结
这次的目标是将代码完全切换为 Unicode 版本。首先,我们将所有相关函数从 A
版本切换为 W
版本,然后解决由字符串类型不匹配引起的编译错误。通过这种方式,我们能够确保代码支持 Unicode 字符串,并且能够处理不同的操作系统和字符编码。通过这些修改,代码变得更加健壮,能够在不同环境下正确运行。
wchar_t
在这段过程中,首先要讨论的是如何从使用 ANSI 字符串切换到使用宽字符(Unicode)字符串,特别是在处理文件路径和文件名时。这里的核心问题是如何正确地处理字符类型,使其符合 Unicode 的要求,而不影响代码的其他部分。
步骤解析:
转换为宽字符(
wchar_t
):- 在 Windows 中,
wchar_t
用来表示 Unicode 字符。一个常见的方式是将字符从char
(ANSI 字符)转换为wchar_t
(宽字符),这就是所谓的 UTF-16 字符类型。我们需要确保使用的所有字符变量都遵循这个规范。
- 在 Windows 中,
初始化宽字符字符串:
- 在初始化宽字符字符串时,使用
L"*."
这样的方式将普通的字符常量转变为宽字符常量。每个字符在 Unicode 中占 2 字节(16 位),这不同于常规的char
类型,后者通常只占 1 字节。这样,字符 “*” 和 “.” 会变成两个字节而不是一个字节。为了让 Windows 系统处理这些字符字符串,需要将这些常量显式地转换为宽字符字符串。
- 在初始化宽字符字符串时,使用
为什么可以直接使用 ASCII 字符:
- 令人惊讶的是,虽然我们在代码中使用的是
wchar_t
类型的字符,但对于大多数普通的 ASCII 字符(例如英文字母、数字和一些常见的标点符号),它们在wchar_t
中的表示方式实际上与原来的 ASCII 字符相同。具体来说,ASCII 字符的前 8 位是它们本身的值,而后 8 位则是零。所以,ASCII 字符可以直接作为wchar_t
字符使用,而无需特别转换。
- 令人惊讶的是,虽然我们在代码中使用的是
如何解决文件名处理:
- 在 Windows 中,
WIN32_FIND_DATA
结构体包含了文件信息,其中有一个字段用于存储文件名。这个字段是用TCHAR
类型表示的,而TCHAR
是一种根据编译时设置来决定是char
还是wchar_t
的类型。通过使用wchar_t
类型,可以确保文件名是以 Unicode 格式处理的,从而解决不同系统上可能存在的字符编码问题。
- 在 Windows 中,
调整结构体和文件操作:
- 在文件操作时,尤其是像
FindFirstFile
和CreateFile
这类函数,必须使用宽字符版本(如FindFirstFileW
和CreateFileW
),这样才能正确处理 Unicode 字符串。如果继续使用 ANSI 版本的函数(例如FindFirstFileA
和CreateFileA
),就会导致字符编码不匹配的问题。
- 在文件操作时,尤其是像
进一步优化和清理代码:
- 为了使代码更加清晰和健壮,可以通过使用一些宏或者工具函数来处理字符串的转换,而不是手动转换字符。通过简化这些转换操作,可以使代码更加可读和可维护。
兼容性和影响:
- 一个很好的方面是,这种修改不会影响上游的代码,也就是说,调用这些文件操作函数的其他部分代码不需要改变。因为只有文件名的处理发生了变化,其他部分的接口和功能保持不变。
将游戏代码与文件扩展名的概念隔离
在这段过程中,讨论了进一步改进文件处理机制的想法,特别是在平台相关的文件类型管理和通用文件处理方面。
步骤分析与概念阐述:
扩展文件类型管理:
- 当前的文件处理方式假设文件扩展名在所有平台上都能通用,但这种假设可能并不完全准确。不同操作系统和平台可能会有不同的文件命名约定和扩展名规则。
- 为了进一步改进,可以定义一个平台特定的文件类型集,而不是依赖操作系统的默认扩展名。这样做的目的是将操作系统特定的文件扩展规则与代码逻辑解耦,使得代码更加灵活、可移植。
使用枚举类型:
- 这种方案的实施方法是,在平台层定义一组文件类型,并通过枚举类型来标识不同的文件类型。例如,可以定义
AssetFile
、SaveGameFile
等枚举值,然后在代码中使用这些枚举值来处理不同类型的文件,而不是直接传递字符串或文件扩展名。 - 每个枚举值会关联一个特定的文件名通配符(例如
*
或*.*
)。这将避免直接与文件名字符串打交道,使得代码更具可维护性,并且避免操作系统相关的细节暴露给上层代码。
- 这种方案的实施方法是,在平台层定义一组文件类型,并通过枚举类型来标识不同的文件类型。例如,可以定义
改进通配符处理:
- 在代码实现中,针对每个文件类型,可以使用
switch
语句来处理文件类型和相应的通配符。例如,对于AssetFile
类型,可以使用对应的通配符(例如*.hha
),而对于SaveGameFile
类型,可以使用另一种通配符(例如*.hhs
)。 - 这种方式使得代码的逻辑更加清晰和模块化,每个文件类型的处理都是独立的,并且可以灵活地扩展新的文件类型而不影响现有代码。
- 在代码实现中,针对每个文件类型,可以使用
简化文件名处理:
- 通过上述方案,文件名和扩展名的细节不会直接暴露给上层代码。上层代码只需要处理通配符和文件类型的枚举值,而不需要关心具体的文件名和扩展名,从而减少了操作系统细节的依赖,增强了代码的可移植性。
内存优化与性能提升:
- 在处理资源时,为了减少频繁的资源加载和卸载导致的闪烁现象,可以增加更多的内存来缓存资源。具体而言,建议将内存容量增加到例如 16MB,这样可以减少资源频繁加载和卸载带来的性能问题,尤其是在处理需要频繁切换的资源(如英雄的图片)时。
未来优化:
- 目前的解决方案已经能够减少闪烁问题,但未来还可以进一步优化,例如通过预先加载资源、减少不必要的资源切换,或者在设计上采用更先进的资源管理策略,确保无论资产是否已经加载,都不会影响游戏的流畅性。
总结:
这段内容主要讨论了如何通过定义文件类型枚举和使用通配符来改进文件处理机制,从而使代码更加灵活和与平台无关。同时,还讨论了如何通过增加内存来优化性能,减少资源加载时的闪烁现象。通过这种方法,代码可以进一步解耦操作系统细节,支持更多不同平台和场景,提升了整体的可维护性和性能。
俄罗斯莫斯科阳光的第一缕光线向你问好
我昨天问过一个类似的问题,但我猜没有解释清楚。你有想过资产系统如何与第三方模组一起工作吗?比如说有人想做一个模组,增加新的图形、音效或游戏逻辑。实现模组加载器、Steam 工作坊支持等会有多困难?
关于之前提问的类似问题,主要讨论了经典化(Classicism)如何与第三方MOD结合,尤其是当有人希望在游戏中加入新的图形、声音或逻辑时,是否能通过MOD加载器、Steam Workshop等平台来实现。
对此,首先明确指出,Steam本身并不太感兴趣,也不打算投入太多时间去研究如何实现Steam Workshop相关的内容。不过,提到MOD加载器的实现并不困难,事实上,可以说是相对简单的。因为游戏的资源文件会合并,添加新的内容就非常容易。比如说,如果想要为所有英雄绘制更多的定义角度,这完全是可行的。
但目前没有支持的功能是“移除旧的资源”。换句话说,当前的系统并没有提供一种方式,允许玩家完全删除旧的资源并替换为新的资源。例如,如果想要让某个英雄不再使用原本的皮肤,而是使用自定义的新的皮肤,目前系统并不支持这种操作。这个功能被提到是可能未来会考虑实现的,虽然不一定会立即执行。
最后,如果有强烈的需求,可能会在下周开始着手处理,以确保所有细节都得到妥善处理。
是否应该有一种方式,当加载了其中一个英雄资产时,所有相关的资产都应该被加载,而不是被清除?
讨论中提到了一种可能的方法,即如果某个英雄的资源被加载了,那么所有相关的资源都应该一同加载,而不是被驱逐出去。然后,进一步解释了他们不太倾向于做一个“非驱逐”机制,因为最近使用的资源已经在一定程度上做了类似的管理。
更倾向的做法是实现预缓存(pre-caching)功能,这种方法会比较频繁地广播一个通知,告知游戏系统:如果玩家使用了某个角色的某个方向,通常也会使用该角色的其他方向。因此,系统可以提前加载这些资源。这种做法不特定于英雄,而是一个更通用的机制,适用于所有资源。比如,如果玩家使用了怪物的某个方向,系统会自动预加载怪物的其他方向。
他们认为,预缓存功能的实现将会在后期进行,预计这个过程中,英雄的部分会得到“免费”处理,因为英雄的资源管理相对简单,可以通过锁定资产来解决。而他们认为,更具挑战性的任务是处理像怪物这样的资源,因为这些资源比英雄的资源更加复杂。因此,英雄的资源管理会自然地从这些更复杂的情况中得以解决。
可能有点离题,但你为什么在代码中使用了 typedef
的函数?
讨论中提到的内容涉及代码中的函数指针及其使用方式。首先,提到可以回去看一些早期的章节,那时已经详细解释了相关概念。这些函数指针实际上是插入到一个表格中的,这个表格会从Windows 32库或平台层传递给游戏的DLL(动态链接库),然后游戏通过这些函数指针来调用相应的功能。
接着,解释了在代码中存在一个全局变量“platform”,这是用来存储这些函数指针的集合。通过这个集合,游戏能够访问和调用各种功能。为了让这些函数能够正确地插入到这个表格中,需要对函数进行类型定义(typedef)。这就是为什么需要为这些函数指针进行类型定义的原因。
在代码中,这个“platform”变量的结构是一个函数指针集合,类似于一个调度表(dispatch table)。如果没有进行类型定义,代码中将会出现大量的冗余输入,因此必须进行这种类型定义,避免无意义的重复代码。
你看过 Nostalgia Critic 的《Wicker Man》评论吗?(尼古拉斯·凯奇穿着熊装)
ah
提到了一部名为《士兵批评:编织人》或者类似的电影/剧集,可能是通过某个链接在Twitter上看到的,虽然已经注意到这个链接,但由于时间原因,还没有机会观看。计划在这个周末抽时间去看一下。
我们能不能在调试时,看到加载/卸载的资产内存块的可视化展示?
提到了希望能获得一个视觉化的显示,用于展示已加载和未加载的资源块,尤其是在进行调试时。这项功能被认为是非常重要的,并且明确表示这是一个非常想要实现的目标。因此,相关的搜索和实现工作应该会进行。
Windows 10 会改变编码方式吗(如果你知道的话)?
提到关于Windows操作系统的更新,询问是否会改变代码的写法。回答表示,从Windows XP开始,操作系统本身并没有改变代码的基本运行方式,依然可以运行相同的代码。不过,Windows确实引入了一些新的API(应用程序接口),如果需要的话,可以使用这些新的API。但通常来说,旧的API依然能够正常工作,因此即使操作系统更新了,旧的代码通常仍然兼容。
在 MergeIfPossible
中,当你通过检查并进行合并时,你增加了头部大小加上大小,但在那之前,你检查了(大小 + 头部),但大小应该已经包含了头部。你能解释一下为什么吗?
首先,解释了一个惯例:块的大小不包括头部。具体来说,insert block
函数会处理一个块的大小,它计算的总大小是块的大小减去头部的大小。一个块的总内存量由头部大小和块的实际数据大小组成。因此,当需要定位第二个块的位置时,首先会通过指针加上头部的大小,接着再加上第一个块的大小,最后查看第二个块是否存在。
如果第二个块存在,就可以进行合并。在合并时,首先需要将第二个块从链表中移除,然后调整第一个块的大小,使其包含第二个块的全部内容。合并后的块将包括原先第一个块的头部,以及第二个块的所有内存。
接下来,解释了合并时头部的变化。由于合并后第一个块将包括第二个块,第二个块的头部将不再存在,它“消失”并成为第一个块的一部分。因此,在计算合并后的总大小时,第二个块的头部不再计算,而是将第二个块的实际大小和第一个块的头部大小合并计算。
通过这种方式,计算的块大小会随着合并而发生变化,最终的块大小会包括所有内容,但原本第二个块的头部不再单独计算,因为它已被吸收入第一个块的总内存中。
渲染时的深度错误是怎么引起的?
在渲染过程中,出现深度错误的原因是没有对渲染顺序进行排序。当前,资产和纹理是按它们进入渲染过程的顺序进行渲染的,这导致了渲染的顺序是随机的。因此,渲染时可能会出现深度冲突,特别是当物体的位置和深度关系不一致时,这种问题尤为明显。
为了避免这种问题,应该在渲染之前先对物体进行排序。通过正确地排序渲染顺序,可以确保物体的深度关系被正确处理,从而解决渲染时的深度错误。这意味着在渲染之前,需要按从远到近的顺序处理物体,确保被遮挡的物体不会干扰到前景物体的正确渲染。
总之,解决这个问题的关键是对渲染过程中的资产进行排序,使得渲染顺序符合物体的深度关系,避免因为随机渲染顺序导致的深度错误。
我怀疑对于完全的新手来说,现在构建游戏的方式可能让人很难理解。试想一下,你知道如何制作一个游戏,所以你会按部就班地逐一完成你已经熟悉的组件(大多数)。但对于初学者来说,可能更容易理解的是先做一个小功能游戏(比如标题画面、游戏循环、游戏结束、重新开始等等),然后在这个基础上逐步完善。这样更具视觉性,而且有一个小游戏每一天逐步演变,可以保持开发者的动力,而不是像专家一样已经知道最终的游戏应该是什么样子。
在构建游戏时,面对初学者,当前的制作过程可能会让人感到难以理解。很多人可能会觉得,如果不熟悉制作游戏的流程,整个过程就像是在处理一个庞大的清单,涉及大量自己已经掌握的组件和技术。尤其是当所有工具和技术都到位时,制作游戏可能看起来理所当然,但对新手来说,这些信息可能很难消化。
对于初学者来说,一种更容易理解的方式是从一个简单、功能性的游戏开始。例如,制作一个简单的游戏循环——游戏开始、游戏结束、重新开始,然后不断地对这个游戏进行迭代和完善。通过这种方式,每天都有一点进展,保持动力,同时让学习者能够更直观地理解游戏制作的过程。相比于专业的开发者,那些已经知道最终游戏效果的人来说,这种方法对新手而言更加友好。
在考虑这一点时,做出的决定是希望这个系列能够有一个“断点”,大约在制作的第二百到二百五十天之间。当引擎部分完成,并且进入实际的游戏制作阶段时,所有希望跳过引擎部分的观众就可以直接从这一阶段开始观看。这类似于从Unity入手学习开发,大家可以直接进入到如何使用现有引擎来制作游戏。并且,引擎的性能也会更好,所以从这一点来看,新手可以直接学习如何利用已完成的引擎进行开发,而无需深入了解引擎的实现。
如果没有了解如何渲染位图,就无法将精灵图像显示在屏幕上。直接提供现有代码会让很多人错过学习这些基础概念的机会。因此,决定从零开始,逐步讲解技术细节和游戏制作过程。这样做的目的是确保学习者能够从基础开始,逐步建立自己的知识体系。
另外,提供源代码的目的是给那些不想深入了解引擎实现的学习者一个直接的起点,他们可以从已经完成的代码开始,而不必自己去学习每个细节。这样,学习者可以专注于使用代码来实现更复杂的功能,比如渲染精灵和播放声音,而不必从零开始。
总结来说,制作游戏的过程可以选择不同的路径,对于新手来说,从一个简单的游戏开始,逐步积累经验,是更有动力且易于理解的方式。而不直接提供现有代码的原因在于希望学习者能够理解每个技术环节,而不是跳过基础知识直接进入开发阶段。
你玩过《Freelancer》吗?
提到《Freelancer》这款游戏时,表示自己并没有玩过这款游戏,也没有玩过其中的私有者版本(Privateer)。虽然尝试过玩《Privateer》,但是觉得游戏体验不太好。不过,记得自己曾经玩过《Privateer 1》,那个时候还很年轻,基本上什么游戏都玩。所以,虽然不太喜欢《Privateer》,但也并不排斥尝试各种类型的游戏。总的来说,虽然没有玩过《Freelancer》,但是当时自己玩的游戏种类很多,基本没有太多偏好,什么游戏都能接受。
你看过《Kung Pow》吗?
提到《功夫熊猫》时,表示自己没有看过这部电影。自己并没有看过这部电影。
你在工作中使用的是哪个版本的 Visual Studio?
在工作中使用的是Visual Studio 2012版本。之前购买过商业版的Visual Studio 2012,并且一直在使用这个版本。
字体渲染会很快实现吗?
字体渲染即将开始,预计可能会在下周开始着手进行。当前调试代码时,如果没有字体的支持,进展会非常有限。虽然在没有字体的情况下可以做一些非常基础的调试可视化,但实际上,还是需要字体才能更有效地进行调试和开发。因此,开始处理字体渲染的工作变得非常必要。
你是如何渲染的,而不实例化一个画刷对象?
在渲染过程中,之所以不实例化画刷(brush)对象,是因为渲染并不是通过GDI(图形设备接口)来完成的。实际上,唯一需要Windows做的事情,就是将我们自己制作的位图显示到屏幕上。其他的渲染工作都由我们自己完成,因此不需要使用GDI的画刷对象。GDI并不为我们进行绘制,它唯一的作用就是将位图传送到Windows的显示组合中,显示在屏幕上。
因此,不需要实例化任何GDI画刷,除了那些基本的、必要的功能,像是将位图传递给Windows的操作。这部分内容在教程的前三天或更早的开始阶段就有讲解。这个过程是我们在渲染中唯一需要依赖Windows做的事情,其他所有的绘制工作都由我们自己控制。
你会选择使用简单的位图字体,还是实现 TTF(TrueType Font)字体?
在字体渲染的选择上,决定不会使用TrueType字体。主要的原因是,游戏的艺术资源本身就是位图,因此如果选择使用可任意缩放的矢量字体,显得非常不合理。毕竟,游戏中的大多数视觉内容,99%的时间都是位图,而不是矢量图。如果需要考虑更高质量的线条艺术,首先应该关注的是游戏中的资源,而不是字体,因为字体在游戏中的作用并不那么重要。
因此,最终决定直接使用位图字体,而不是引入TrueType字体。位图字体在游戏中并没有明显的优势,特别是在像这种游戏中,文字并不是核心内容,使用位图字体更为直接且高效。
你会实现距离场字体(distance field font)吗?
关于是否使用距离场字体(distance field fonts),决定并不会采用这种技术,因为距离场字体对于字体的效果非常差,尤其是不适合用于渲染文本。使用单一的距离场来渲染字体会导致字体非常模糊,边角部分会变得圆滑,看起来非常难看,根本不适合显示在屏幕上。
距离场字体之所以效果差,是因为它只能在每个像素上记录与边缘的距离,这导致字体的角部无法准确渲染。例如,像字母“T”这样的字形,在距离场渲染下,其角落部分会被平滑化,变得圆润,看起来非常不清晰。要实现更清晰的字体效果,如果选择使用距离场编码,实际上需要使用多种不同的距离场或者其他函数的编码方式,然后通过像素着色器来修正这些信息,从而生成更准确的边缘效果。
然而,这种方法非常复杂,而且性能成本也较高。所以,在面对这种技术时,必须要权衡是否值得投入那么多资源。相比之下,直接使用高分辨率的位图字体可能会更加高效且简单,不需要处理复杂的编码和着色器计算。因此,最终决定还是继续使用位图字体,而不是采用距离场技术。
是否值得尝试仅使用纯函数来制作游戏,也就是说没有副作用?
使用纯函数(没有副作用)来制作游戏是不可能的,原因在于游戏需要处理时间的流逝、输入和状态的变化,这些都无法通过纯函数来实现。
首先,考虑时间的流逝。即使能够定义一个函数 f ( t ) f(t) f(t),它能根据时间 t t t 生成游戏的当前帧,但如何跟踪时间的流逝呢?纯函数没有副作用,无法记录和更新时间的增加。游戏需要知道时间是如何前进的,但纯函数无法“记住”这些信息,因此无法单独用纯函数来驱动时间的流动。
其次,输入也是一个问题。如果游戏是纯函数的,那么每个函数调用都必须依赖于输入的所有历史数据,也就是从开始到当前时刻的所有输入。为了生成当前帧,函数需要从头开始重新模拟游戏的所有过程,这意味着必须保存所有历史输入数据,而这显然是不切实际的。纯函数无法保存任何状态,而如果能够保存状态,就会产生副作用,这违背了纯函数的原则。
此外,还需要考虑如何传递这些信息。例如,谁负责维护和更新所有输入数据的缓冲区?时间的变化又是谁在控制的?这些问题都无法用纯函数来解决。
因此,我认为用纯函数来制作游戏是不现实的,这实际上是一个不可能完成的任务。
在这里提到的“纯函数”是指没有副作用的函数,并且其输出仅仅依赖于输入参数,给定相同的输入,纯函数每次都返回相同的输出。纯函数的特点包括:
无副作用:纯函数在执行过程中不会修改外部状态,也不会产生任何副作用,例如修改全局变量、写入文件或改变输入参数的值等。
可预测性:纯函数的输出仅依赖于输入值,因此,给定相同的输入,纯函数总是返回相同的输出。
没有可变状态:纯函数不会改变它所操作的数据,所有的数据变更都必须是通过函数的返回值来体现,而不是直接修改外部的状态。
举个例子:
假设有一个纯函数 add(a, b)
,它接受两个参数并返回它们的和:
def add(a, b):
return a + b
这个函数就是纯函数,因为它的输出完全依赖于输入 a
和 b
,并且它没有副作用——它不会修改任何外部变量或状态。
在游戏开发中的应用:
在游戏开发中,时间、输入和状态的变化往往需要对外部状态进行修改,这些修改会产生副作用。例如,游戏的每一帧都需要更新角色的位置,检测用户的输入并作出响应,这些操作会改变游戏状态。而纯函数无法直接处理这些动态变化,因为它们不能“记住”之前的状态,也不能进行实时更新。
因此,虽然可以在一些特定的计算任务中使用纯函数,但对于复杂的游戏开发而言,使用纯函数是非常困难的,因为游戏涉及到很多外部状态的管理与副作用。
你会覆盖非等宽字体和字体字距调整(kerning)吗?
实现非等宽字体和等宽字体的差异实际上非常小,几乎不会增加代码的复杂性。两者之间的区别主要在于字符之间的间距计算方式。
对于等宽字体,每个字符的宽度是固定的,所以绘制每个字符时,只需按固定的距离移动光标。例如,在绘制字符 “a” 后,光标的位置就会增加一个常量值,以便绘制下一个字符。
而对于比例字体,字符的宽度是可变的,因此在绘制每个字符时,需要根据前一个字符的宽度来决定光标的移动距离。这通常通过查找一个包含所有字符宽度信息的查找表来完成。具体实现上,就是通过查找该表,得出当前字符与前一个字符之间的距离,从而决定光标位置。
总结来说,等宽字体和比例字体的实现差异仅仅是多了一个查找表,用来决定字符之间的间距。这个差异非常简单,几乎不会影响代码的复杂度。因此,实现比例字体的工作量相对较小,代码实现也非常直接。
纯函数游戏:状态是一个参数,一个新的状态会被返回
在这种功能性游戏的模型中,游戏的状态是一个参数,新的状态是通过返回得到的。因此,游戏的每一帧都会基于当前的游戏状态(例如“世界”和“时间”)以及输入,生成一个新的游戏状态(例如“新世界”)。这个过程是纯粹的函数式编程,通过传递和返回新的状态,而不是修改原来的状态。
然而,问题在于,如何将这个新的状态显示到屏幕上而没有副作用。屏幕本身是一个缓冲区,而函数式编程的一个核心原则是避免副作用——也就是说,不允许直接修改外部的状态(例如屏幕缓冲区)。因此,尽管游戏的状态(例如“世界”)可以在每一帧计算出来,并且可以返回一个新的状态,但如何将这个新状态传递到显示层面,仍然是一个问题。
如果我们采用一个传统的循环结构来处理这个过程,我们可以通过在每个时间步计算并返回新的状态,但仍然面临如何“显示”这个新的世界的问题。在一个纯粹的函数式模型中,没有直接的机制来修改图形缓冲区或进行绘制操作,因为这需要副作用的操作。虽然可以通过某种方式在一个函数式框架下进行处理,但这并不容易实现,因为函数式编程的特性并不适合直接与硬件交互(如屏幕显示)。
总之,尽管在功能性游戏中可以通过纯函数来管理游戏状态,但将这个新的状态渲染到屏幕上,依然涉及副作用的问题,这在纯粹的函数式编程模型下很难实现。
你目前写的代码中,有多少可以很容易地移植到 3D 游戏中?
到目前为止,所写的代码大部分已经是三维的,实际上可以很容易地迁移到三维环境中。唯一需要调整的部分是纹理的透视校正问题。由于当前的代码并没有进行纹理的透视校正,若直接使用现有的位图填充方法来绘制三维形状时,会出现纹理拉伸的现象。因此,需要引入透视除法来处理位图填充问题,虽然这并不复杂。
除此之外,游戏中的大部分内容已经是三维的,不太使用任何二维的元素。换句话说,除了处理透视校正的纹理问题,现有的代码已经可以支持三维场景的渲染。
至少在 Haskell 中,“纯函数”部分通过一个叫做“Monad”的数学概念隐藏在 IO 之外
所讨论的内容中,提到了一种将纯函数式编程与现实世界的游戏开发相结合的方法。在这种方法下,游戏的显示部分并不完全是函数式的,而是通过将函数式编程隐藏在游戏的底层逻辑中,使用一个数学概念“monad”(单子)来处理纯函数式部分的复杂性。也就是说,想要将游戏的显示部分和输入输出处理部分与纯函数式编程分离。
然而,提到的问题是,虽然底层逻辑可以保持函数式,但是否有必要让整个游戏都保持纯函数式。对于这个问题,讨论指出,完全使用纯函数式编程可能会带来额外的代价,特别是在内存带宽和性能上。因为纯函数式编程要求每次都重写整个数据结构,这会导致大量内存的浪费,并且增加了计算和资源开销。这种做法虽然从理论上能提供某些优势,但实际中可能带来不必要的复杂性和低效性。
因此,作者认为在游戏开发中完全采用纯函数式编程可能并没有太多的实际好处,尤其是考虑到它带来的性能损失和内存开销。所以,保持函数式编程只在某些特定的低层逻辑部分,而不完全在游戏的每个方面应用,是一个更加现实的选择。
你怎么看待故意模拟 Mode 7 的图形系统?
Mode 7技术允许在一个平面图像上进行倾斜或扭曲,从而实现类似3D效果的表现。尽管这是过去的一项技术,在如今已经拥有完全的3D图形技术时,继续使用这种方式似乎没有太大意义,尤其是如果它只能局限于一种简单的3D效果时,可能显得有些过时。
关于网络功能的实现,明确表示不会在当前的开发计划中加入网络功能。