游戏引擎学习第131天
仓库:https://gitee.com/mrxiao_com/2d_game_3
运行游戏并识别我们的小问题
今天的工作重点是对游戏引擎进行架构优化,特别是针对渲染和多线程的部分。目前,我们的目标是让地面块在独立线程上进行渲染,以提高性能。在此过程中,我们注意到虽然渲染速度已经变得非常快,但在生成新的地面块时,仍然会出现轻微的帧率波动。
这个问题虽然不算严重,但为了进一步提升性能,我们决定改进地面块生成的方式。目前,我们生成地面块的方法过于简单,涉及过多的图形工作,这显然可以通过更高效的方式来实现。为了更好地处理这一问题,我们考虑将地面块的生成和渲染过程移到后台线程中,这样就可以在后台完成这些任务,不影响主线程的渲染工作。
这样做不仅有助于解决当前的性能瓶颈,而且为未来更复杂的游戏任务和引擎开发打下基础。通过实现多线程处理,我们希望能够在游戏开发中实现更高效的资源加载和渲染,避免由于主线程负载过重而导致的卡顿或延迟。
我们目前正在做一些基础的架构工作,确保在将来能够顺利地进行游戏开发。这个过程虽然看似简单,但实际上涉及到多个模块的协作,包括渲染系统和后台任务调度。通过合理的线程管理和任务调度,可以显著提高游戏的流畅度和响应速度。
总的来说,今天的工作不仅是为了优化当前的性能问题,还在为整个引擎架构的进一步完善做准备,确保我们在开发过程中能够应对更多的挑战和需求。

为什么我们不能在单独的线程上生成地面块
目前的目标是将地面块渲染工作移到单独的线程上运行,但在此之前,我们需要进行一些清理工作,因为目前的代码存在一些未处理的问题,幸运的是,代码在没有处理这些问题的情况下,居然能够正常运行。
我们发现,问题的根源在于内存对齐。当代码编译时,如果路径没有正确配置,系统会触发断言错误,因为内存没有对齐,导致渲染代码无法正常工作。事实上,代码之所以能运行是因为内存恰好对齐了。我们之前确保了帧缓冲区是对齐的,因此没有问题,但当我们尝试渲染那些地面块时,内存并没有正确对齐。
为了避免这个问题,我们需要做的是确保内存对齐。如果没有正确的对齐模式,我们会触发断言错误,甚至崩溃。因此,我们需要进行一些处理,确保内存在渲染过程中得到正确的对齐。
如果我们取消了对齐检查,虽然代码可能能够继续执行,但在后续操作中肯定会崩溃,因为没有对齐的加载操作会导致错误。例如,如果内存加载指令没有对齐,系统就会崩溃。同样的情况也会出现在存储操作中。为了避免这些问题,我们必须确保所有加载和存储操作都使用对齐的指令,而这也是我们选择使用对齐加载和存储的原因。
总的来说,当前的工作是通过确保内存对齐来避免潜在的崩溃和错误,确保渲染过程能够顺利进行。这为将来实现多线程渲染和其他优化奠定了基础。



game.h: 允许 PushSize_ 接受对齐方式
首先,需要解决的问题是如何确保地面块的内存对齐。当前,在创建这些地面块时,我们已经有了一个暂时的内存区域(transient_state),但是我们并没有办法确保这些内存区域是对齐的。
为了确保对齐,我们需要在内存分配过程中指定对齐要求。以 make_empty_bitmap 为例,当我们创建地面块时,可以在这里确保内存对齐。具体来说,我们可以修改内存分配函数(比如 PushSize_ 函数),添加一个新的参数,来指定内存的对齐方式。这个参数就是对齐要求,表示希望分配的内存需要按照什么样的对齐方式进行对齐。
为了实现这一点,我们需要确保对齐要求是2的幂次方,例如常见的对齐方式是4字节对齐。我们可以设定一个默认值,比如 4 字节对齐,这样大多数情况下都能满足需求。如果需要其他的对齐方式,则需要显式地指定对齐要求。
通过这种方法,能够在内存分配时确保内存区域按需要的对齐方式进行分配,从而避免因为内存不对齐导致的崩溃和性能问题。这是解决内存对齐问题的一种方案,为地面块的正确渲染和多线程处理提供了基础。

引入 AlignmentMask
为了确保内存对齐,首先需要检查当前内存地址是否符合所需的对齐要求。具体来说,当分配内存时,可能需要对内存地址进行调整,确保它满足对齐的条件。对齐是指内存地址能够按特定的字节数(如4字节、8字节等)对齐,以提高效率和避免崩溃。
已经有一些经验可以借鉴,尤其是在之前处理图形裁剪时,就涉及到了类似的对齐检查。在图形渲染中,曾经使用过类似的对齐代码,比如检查一个地址是否是4字节对齐。如果地址不是4字节对齐,就需要进行调整,通常会将地址向上调整到最近的4字节对齐边界,因为我们不能将地址向下调整(那样可能会覆盖已分配的内存)。所以,只能向上调整到下一个对齐边界。
具体的实现方式包括使用一个对齐掩码(alignment mask)。这个掩码用来检查内存地址是否符合对齐要求。如果内存地址与掩码按位与操作后不为0,表示当前地址不对齐,需要进行调整。
调整的过程大概是这样的:
- 计算当前内存地址与对齐要求的掩码的“与”操作,得到不对齐部分。
- 如果不对齐,就需要将内存地址向上调整,确保它对齐到下一个对齐边界。
这种方式帮助确保内存始终按照正确的边界对齐,避免了因内存不对齐而引发的性能问题或崩溃问题。

Blackboard: 将 2 的幂转化为掩码
为了确保内存地址的对齐,可以使用位运算来判断一个值是否符合特定的对齐要求,特别是当对齐要求是2的幂时。这里以4字节对齐为例,详细过程如下:
-
理解二进制与对齐要求:
- 每个数字在二进制中由多个位组成,比如8位、16位、32位、64位等。对于一个4字节对齐的需求,我们只关心最低的两位(即二进制的最后两位),因为4字节对齐意味着这些最低的两位应该是0。
- 比如,如果一个数字是4,二进制表示为
0000 0100,其中只有第四位是1,其他位是0。
-
使用掩码来判断对齐:
- 为了检查一个值是否满足4字节对齐,可以使用掩码。这意味着我们需要检查该值的最低两位是否是0。如果是0,则表示这个地址已经是4字节对齐的。
-
通过减去1来生成掩码:
- 一种简便的方式是,通过将对齐要求的值减去1来生成掩码。例如,如果要求4字节对齐,4减去1就得到3,二进制表示为
0000 0011,这个掩码可以用来检查是否对齐。 - 具体方法是:减去1后,较低的位会发生变化,形成一个掩码。如果原值是4(即二进制
0000 0100),减去1后得到3(二进制0000 0011)。通过与原值进行位运算,检查是否满足对齐要求。
- 一种简便的方式是,通过将对齐要求的值减去1来生成掩码。例如,如果要求4字节对齐,4减去1就得到3,二进制表示为
-
检查对齐:
- 为了判断一个值是否对齐,可以使用“与”运算(
&)。将原值与掩码进行“与”运算,如果结果是0,说明该值满足对齐要求;如果结果不为0,则说明该值没有对齐。
- 为了判断一个值是否对齐,可以使用“与”运算(
-
总结:
- 通过这种方法,我们可以非常高效地检查内存地址是否满足特定的对齐要求,尤其是在对齐要求是2的幂时。只需要减去1生成掩码,然后进行位运算即可判断对齐情况。
这种技巧在内存管理和优化中非常有用,因为对齐不当会导致性能下降或者在某些平台上引发崩溃。
根据 AlignmentMask 设置 AlignmentOffset
为了确保内存对齐,特别是在内存池中处理内存分配时,我们需要进行一些对齐操作。这些操作的目的是确保返回的指针是按照指定的对齐要求进行调整的。以下是整个过程的详细说明:
-
初始化内存和对齐偏移量:
- 设定了一个内存池(称为arena)并使用一个基地址指针
arena base来表示它的起始位置。此外,arena used指示了已经使用的字节数。 - 对齐要求是通过
alignment mask来表示,它包含了不能被设置的位。通过与内存地址进行位运算,我们可以检查是否已经满足对齐要求。
- 设定了一个内存池(称为arena)并使用一个基地址指针
-
检查对齐情况:
- 在分配内存之前,我们需要检查当前的基地址是否已经符合对齐要求。如果地址不对齐,接下来就需要进行调整。
- 如果地址不对齐,我们需要计算出实际需要的对齐偏移量。比如,如果当前地址已经偏离对齐要求,那么就需要通过增加一个合适的偏移量来确保地址对齐。
-
调整对齐:
- 计算对齐的方式是:首先判断当前地址和对齐要求的关系。如果当前地址的低位没有符合对齐要求(例如,要求对齐到4字节,但当前地址不是4的倍数),就需要调整地址。
- 通过计算出当前地址与对齐要求的差异,决定需要加多少字节来满足对齐要求。通常,通过减去当前地址的低位部分,得到所需的偏移量。
-
调整内存块大小:
- 除了调整地址,还需要调整内存块的大小。由于内存的对齐,所需的内存大小会被“扩展”一定的字节数。这样做是为了确保分配的内存块能够满足对齐要求。
- 内存块大小需要根据对齐要求来进行增加,这样内存的使用就能够按要求对齐,从而避免访问时出现错误。
-
检查内存是否溢出:
- 在调整内存大小后,必须确认内存池中是否还有足够的空间来容纳新的内存块。因为在当前的实现中,并不会动态分配新的内存,而是直接使用现有的内存池。如果内存池已满,分配会失败,导致错误。
-
最终指针计算:
- 最终,计算出对齐后的内存块的指针。这个指针是基地址加上调整后的对齐偏移量。将得到的指针转换为
void*类型,确保它可以被正确使用。
- 最终,计算出对齐后的内存块的指针。这个指针是基地址加上调整后的对齐偏移量。将得到的指针转换为
通过这些步骤,我们确保了分配的内存块是按照要求对齐的,从而避免了因为内存不对齐而导致的崩溃或性能问题。

声明地方添加默认值好像定义的地方不能添加默认值


再次讨论 PushSize_
在处理内存对齐时,目标是确保内存地址能够符合指定的对齐要求,以便更高效地使用内存并避免潜在的错误。为了完成这一目标,以下是具体的步骤和过程:
-
检查是否需要对齐:
- 首先,需要确定当前内存指针是否符合对齐要求。如果当前指针不符合要求,则需要计算出调整的偏移量,使其对齐到指定的字节边界。
- 计算的方法是:检查当前地址和对齐要求的关系,判断是否存在“残余”部分。如果存在,那么就需要增加相应的字节数,直到指针对齐。
-
调整内存大小:
- 在对齐时,除了调整内存指针,还需要增加内存块的大小。增加的大小是为了确保对齐后的地址能够完全满足要求,这个增量是基于对齐要求来决定的。
- 例如,如果地址的低位部分不符合对齐要求,那么就需要增加相应的字节数,使得指针对齐。
-
内存池检查:
- 调整内存大小后,需要进行断言,确保调整后的内存块能够在内存池中成功分配。如果调整后的内存大小超出了当前内存池的可用空间,那么分配将失败,并会触发错误。
-
更新内存池的已用空间:
- 一旦内存分配成功,需要更新内存池中已用空间的大小。这个值需要包含调整后的内存块大小,以确保后续的分配能够正常进行。
-
返回对齐后的指针:
- 最终,返回调整后的内存指针,这个指针已经根据指定的对齐要求进行了调整,确保后续的内存访问不会出现错误。
通过这些步骤,内存分配过程中的对齐问题得以解决,同时保证了内存的高效使用。虽然目前的实现可能并非最为高效,但它能够确保在当前的需求下正确地执行对齐操作。如果将来需要更高效的实现,可以进一步优化这部分代码。
调试器: 步进执行 PushSize_
在处理内存对齐时,我们通过以下步骤确保内存地址正确对齐:
-
初始化内存区域和指针:
- 我们从内存池的基础指针(
base pointer)开始,并检查当前的内存区域的使用情况。在这个步骤中,内存池的大小尚未被使用,因此可以看到空闲区域的大小。
- 我们从内存池的基础指针(
-
计算对齐掩码:
- 接下来,我们根据所需的对齐要求,构建一个对齐掩码。这个掩码是用来判断当前内存是否已经符合对齐要求。对于四字节对齐,掩码通常会检查低两位是否为零。如果这些低位不为零,说明当前内存指针未对齐。
-
检查是否需要调整对齐:
- 如果内存指针已经符合要求(即低位为零),则不需要做任何修改。如果当前的内存指针已经对齐到四字节边界,则不需要增加内存的大小,继续进行正常的内存分配。
-
调整内存大小:
- 如果发现内存指针没有对齐,我们会计算出需要增加的字节数,以便将内存指针对齐到所需的边界。调整后的内存块大小会相应地增加。
-
最终内存指针:
- 最终,我们将返回调整后的内存指针(
result pointer),它会指向正确的对齐地址。此时,该内存地址符合所需的对齐要求,且内存区域的大小已经调整好。
- 最终,我们将返回调整后的内存指针(
通过这些步骤,我们确保了内存的对齐,并且避免了因对齐问题导致的潜在错误。如果内存已经对齐,那么无需做额外的调整,分配过程可以顺利进行。

__VA__ARGS
在处理内存对齐的问题时,首先需要确保能够指定内存对齐方式,以便更好地处理不同类型的数据对齐需求。具体步骤如下:
-
当前对齐方式:
- 目前我们将所有内存默认对齐到4字节边界,这对于大多数情况下是足够的。然而,在处理某些类型的地面瓦片时,需要将其对齐到16字节边界,因此需要进一步调整。
-
调整
MakeEmptyBitmap函数:- 在
MakeEmptyBitmap函数中,需要修改内存分配的方式,以确保地面瓦片的内存是按照16字节边界对齐的。为此,计划在调用precise函数时,允许指定一个对齐参数,而不仅仅是默认的4字节对齐。
- 在
-
使用宏和变参机制:
- 通过修改宏定义,允许在调用时传递额外的对齐参数。具体来说,可以在宏的末尾添加指定对齐的参数,这样可以灵活地控制对齐方式。这种方法需要特别处理一些编译器的差异,例如MSVC和GCC之间的差异。
-
处理平台差异:
- 不同的编译器和平台可能对宏和变参的处理方式不同。某些编译器可能需要使用特殊的语法来处理宏参数,因此在实现时要特别注意这一点,确保在不同的平台上都能正常工作。为了兼容各种平台,使用了一种被广泛认可的语法,虽然在一些平台上可能需要调整,但当前这种实现方式应该可以在大多数情况下工作。
-
后续验证:
- 在完成这些调整后,需要依赖不同平台上的编译者来验证是否能够正常工作。特别是在编译器处理变参宏时,可能会出现一些特殊情况,因此需要广泛测试来确保代码的跨平台兼容性。
通过这种方式,可以在内存分配时灵活控制对齐方式,以确保不同类型的数据都能正确对齐,从而避免由于对齐错误带来的潜在问题。

在 C++ 中,##__VA_ARGS__ 是一种与可变参数宏(variadic macro)相关的语法,通常用于处理宏定义中的参数拼接。它的核心作用是将可变参数(__VA_ARGS__)与前面的标记(token)通过 ## 运算符进行拼接,从而生成合法的代码。要理解它的用法,我们需要逐步拆解它的组成和场景。
背景知识
-
可变参数宏:
- C++(从 C99 开始引入,C++11 正式支持)允许定义带有可变参数的宏,使用
...表示可变参数,__VA_ARGS__表示这些参数的实际内容。 - 示例:
调用#define LOG(...) printf(__VA_ARGS__)LOG("Hello, %d\n", 42)会展开为printf("Hello, %d\n", 42)。
- C++(从 C99 开始引入,C++11 正式支持)允许定义带有可变参数的宏,使用
-
##运算符:##是宏定义中的“token-pasting”(标记拼接)运算符,用于将两个标记连接成一个新的标记。- 示例:
调用#define CONCAT(a, b) a ## bCONCAT(foo, bar)会展开为foobar。
-
##__VA_ARGS__:- 当
##与__VA_ARGS__结合时,它将前一个标记与可变参数的内容拼接起来,形成一个新的标记。
- 当
##__VA_ARGS__ 的用法
##__VA_ARGS__ 通常用于需要动态拼接可变参数的场景,尤其是在模板元编程、调试工具或生成复杂代码时。它的关键点在于:
__VA_ARGS__代表所有传入的可变参数(可以是多个,用逗号分隔)。##会尝试将前面的标记与__VA_ARGS__的内容连接起来。
基本语法
#define MACRO(pre, ...) pre ## __VA_ARGS__
pre是前缀标记。__VA_ARGS__是可变参数。##将pre与__VA_ARGS__的内容拼接。
示例 1:简单拼接
#include <iostream>#define GLUE(x, ...) x ## __VA_ARGS__int main() {int GLUE(var, 123) = 42; // 展开为 int var123 = 42;std::cout << var123 << std::endl; // 输出 42return 0;
}
- 调用
GLUE(var, 123)时,x是var,__VA_ARGS__是123,##将它们拼接为var123。 - 结果是一个变量声明
int var123 = 42;。
注意事项
-
__VA_ARGS__不能为空(早期实现):- 在 C99 和早期 C++ 实现中,如果
__VA_ARGS__为空(即没有参数传入),##__VA_ARGS__会导致语法错误。 - 示例:
#define GLUE(x, ...) x ## __VA_ARGS__ GLUE(foo, ) // 错误:__VA_ARGS__ 为空 - 解决方法:现代编译器(C++11 及以上)支持空
__VA_ARGS__,但需要小心处理。
- 在 C99 和早期 C++ 实现中,如果
-
C++11 改进:空参数支持:
- C++11 允许
__VA_ARGS__为空,##前面的逗号会被忽略。例如:#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__) LOG("Hello\n"); // 展开为 printf("Hello\n") LOG("Value: %d\n", 42); // 展开为 printf("Value: %d\n", 42) - 这里
##确保即使没有额外参数也能正确展开。
- C++11 允许
-
多参数时的行为:
- 如果
__VA_ARGS__包含多个参数(用逗号分隔),##会将前缀与整个__VA_ARGS__的内容拼接,这可能导致意外结果。 - 示例:
#define GLUE(x, ...) x ## __VA_ARGS__ GLUE(var, 1, 2, 3) // 错误:var1, 2, 3 不是合法标记 - 这种情况下,
##只能拼接单个标记,多参数需要其他方式处理。
- 如果
实用场景
示例 2:调试宏
#include <stdio.h>#define TRACE(name, ...) printf("Tracing " #name " with args: " #__VA_ARGS__ "\n")
#define TRACE_VAR(name, ...) name ## __VA_ARGS__int main() {int TRACE_VAR(x, 1) = 10; // 展开为 int x1 = 10;TRACE(x1, 1, 2, 3); // 输出 Tracing x1 with args: 1, 2, 3printf("x1 = %d\n", x1); // 输出 x1 = 10return 0;
}
TRACE_VAR用##__VA_ARGS__生成变量名。TRACE用普通__VA_ARGS__输出参数。
示例 3:生成函数名
#include <iostream>#define FUNC(name, ...) name ## __VA_ARGS__void FUNC(print, _int)(int x) {std::cout << x << std::endl;
}int main() {FUNC(print, _int)(42); // 调用 print_int(42)return 0;
}
FUNC(print, _int)展开为print_int,定义并调用一个函数print_int。
高级用法:结合模板
在模板元编程中,##__VA_ARGS__ 可以用来生成复杂的标识符。例如:
#include <iostream>#define MAKE_TYPE(T, ...) T ## __VA_ARGS__template<typename T>
struct Wrapper {T value;
};int main() {MAKE_TYPE(Wrap, per)<int> w{42}; // 展开为 Wrapper<int> w{42};std::cout << w.value << std::endl; // 输出 42return 0;
}
MAKE_TYPE(Wrap, per)拼接为Wrapper,生成模板类型Wrapper<int>。
常见问题与解决
-
问题:多参数拼接失败
- 如果
__VA_ARGS__有多个参数,直接用##会出错。 - 解决:使用辅助宏逐个处理参数,或者避免拼接多参数。
#define FIRST_ARG(x, ...) x #define GLUE(x, ...) FIRST_ARG(__VA_ARGS__) ## x GLUE(foo, bar, baz) // 展开为 barfoo
- 如果
-
问题:空参数处理
- 如果需要支持空
__VA_ARGS__,可以用条件宏:
(需要 Boost.Preprocessor 库支持)#define GLUE(x, ...) x BOOST_PP_IF(BOOST_PP_IS_EMPTY(__VA_ARGS__), , ##__VA_ARGS__)
- 如果需要支持空
总结
##__VA_ARGS__的作用:将前缀与可变参数拼接为单一标记。- 用法:常用于生成变量名、函数名或其他标识符。
- 限制:适用于单一标记拼接,多参数或空参数需额外处理。
- 场景:调试、信息生成、模板元编程等。
如果你有具体的需求或代码示例,可以告诉我,我帮你进一步优化或解释!
game.cpp: 传递 16 给 PushSize 调用并运行游戏
现在,我们已经成功地把地面覆盖物(ground covers)对齐到16字节,并且解决了之前崩溃的问题。经过调试后,代码看起来运行正常,虽然渲染这些地面覆盖物需要较长时间,但这其实是一个有利的情况。因为这为我们接下来的多线程处理提供了机会。通过利用渲染时间较长的特点,我们可以尝试将这些任务并行化,来提高性能,优化渲染过程。
具体来说,尽管当前的代码运行不算非常高效,但考虑到这个过程不是非常频繁地被调用,暂时保持代码简单明了是一个不错的选择。我们不需要过多的优化,反而能够通过这种简洁的方式清晰地看到每一个步骤的执行情况。
总的来说,虽然我们可以进一步优化,使得处理过程更加高效,但目前的实现已经解决了问题,并且能顺利进行调试和渲染。这也为后续的多线程改进打下了基础。因此,现在的目标是尽量利用这些时间差,通过并行处理来加速工作流程。
这种方式虽然简单,但已经足够满足当前的需求。





考虑将所有 FillGroundChunk 放入队列
接下来需要解决的问题是,我们在创建暂时状态(transient state)时,并没有为用户提供临时缓冲区的概念。这个问题如果不解决,会很快暴露出潜在的风险。举个例子,假设我们要处理某个名为FillGroundChunk的内容,我们希望能够调用一个函数,在队列上执行工作。具体来说,我们希望这个函数能处理整个流程,将整个过程作为队列上的任务来执行。
为了实现这一目标,实际上我们需要将这段处理逻辑放入队列中,完成之后再处理。如果没有适当的临时缓冲区支持,任务的队列可能会因为缺乏合适的内存分配而变得无法正常工作。因此,考虑到这一点,我们需要设计一个合适的机制来支持这个队列任务,并且保证在执行过程中每个临时缓冲区的生命周期不会影响系统的其他部分。
现在,暂时我打算将这段处理代码拆分为多个部分进行处理,因为不确定我们是否需要将整个操作都放入队列。我想在一开始对这部分做一些分隔,看看在队列中执行时是否有问题。主要考虑的是,实际上将任务分配到队列并不会有太大问题,因为每个队列中的任务会在系统空闲时被逐一执行,不会造成资源冲突或阻塞。
但这也提出了一个新的问题:如何保证任务分配的正确性以及每个临时缓冲区的生命周期与其他内存区域不冲突。这需要进一步的设计和实现,以确保临时缓冲区能够与其他部分的内存管理系统无缝协作。
关闭 TiledRenderGroupToOutput
如果我们只是将所有的 pushback 映射排入队列,然后根本不去渲染它们,不做任何处理,理论上我们就不会遇到任何暂停现象。这是我的假设。为了确认这一点,我可以先将当前的状态恢复一下,这样可以更确保我们不会遇到任何暂停或类似的问题。


game_render_group.cpp: 切换到玩家的相机并四处走动
我们恢复了渲染实体的基础部分,并继续处理。经过测试后,感觉没有出现什么问题,编译也顺利进行。在运行时,我们没有遇到任何长时间的停顿或卡顿,所以一切都挺好的,感觉目前的情况已经可以接受。


离开时,我们的一部分操作是同步的
这意味着,我们可以将部分操作保持异步,这对于某些情况来说可能是有益的。例如,在处理模拟时,我们可能不希望锁定某些区域,或者其他可能会干扰的操作。当前的做法是可以完全异步地进行的。但如果未来涉及到实体位置、绘制等操作,这可能会导致锁定我们不想锁定的资源,或者出现其他不可预见的问题。
因此,我们希望能够让操作在同步模式下运行,完成需要生成的内容并进行生成。具体来说,就是将这一部分操作推送到单独的线程中执行,这样就能避免阻塞主线程。
然而,问题在于,我们当前为渲染组创建了临时内存来存储命令,并在渲染输出后立即结束了这个临时内存块。如果这些操作是重叠执行的,且持续一段时间,那就不能在任务运行时释放内存,因为内存一旦被释放,仍在运行的任务就会读取被覆盖的数据,导致崩溃或读取垃圾数据。因此,需要小心处理内存的释放时机。
提供临时内存“临时空间”,以便后台任务在不被覆盖的情况下工作
为了解决这个问题,我们需要一个临时存储区域,可以将数据存放在这里,以便后台任务能够进行处理,而不会因为内存被覆盖而出错。为了实现这一点,可能并不会非常复杂,我们可以在现有的内存结构中进行一些调整。
在暂态状态(transient_state)中,我们已经有了内存区域(memory arena)。接下来,我打算引入子区域(sub-arenas)的概念,也就是在一个大内存区域内再划分出多个小的内存区域。这样,多个需要并行工作的任务可以在各自的内存块中进行操作,而不会相互干扰。每个任务都会得到一个独立的内存区域,且我知道它们不会产生冲突。
通过这种方式,不仅可以避免使用锁(locking),减少同步上的潜在问题,还能避免性能损失。只要事先估计好每个任务所需要的内存空间,就可以将大内存区域预先划分成若干块,任务各自占用一块,且无论多少任务并发进行,都不会影响其他任务的运行。这种方法不仅能够提高效率,还能够避免因同步问题带来的潜在 bug。

game.h: 引入 GroundChunkArenas
想要引入的概念是使用多个内存区域(memory arena),这些内存区域主要用于地面块的填充(ground chunk filling)。这些内存区域可以称作“地面块内存区域”(GroundChunkArenas。目标是为地面块分配一些内存区域,并且能够循环使用这些区域。
具体来说,希望通过引入一个“滚动计数器”来管理这些内存区域的使用情况。这个计数器将允许按顺序使用这些内存区域,确保当一个内存区域的任务完成后,能够标记它并重新使用其他区域。这种方式可以确保不同的内存区域不会发生冲突,同时又能高效地分配内存给不同的任务。
目前,对于如何实现这一点,想法还不完全明确。例如,可能会考虑让每个内存区域从它的开始位置开始使用,或者采用其他方式来管理这些区域的分配和回收。不过,已经决定将继续推进这个方向。

引入 task_with_memory
想要做的事情是引入一种新的概念,称为“task_with_memory”(task with memory)。每个任务会有一个专用的内存区域(arena),用于执行任务时的内存分配。此外,每个任务还会有一个“使用状态”(being used),用于表示该任务是否正在使用内存。
具体来说,计划定义一个结构体来表示这个“task_with_memory”。这个结构体包含一个内存区域,并有一个标识当前任务是否正在使用的字段。通过这种方式,能够管理多个任务的内存使用情况。
想要实现一个任务池,其中有几个“task_with_memory”实例。每当有任务需要执行时,能够从池中选取一个空闲的任务并分配相应的内存区域。如果某个任务完成并且内存区域不再使用,就可以将其标记为可用,从而重新加入到池中,以便后续使用。
这种方式使得可以灵活地管理内存资源,并且可以根据需要创建更多的任务实例,以适应不同的任务需求。

game.cpp: 创建一堆任务,每个任务都创建一个 SubArena
在此过程中,首先创建了一个新的任务结构(task_with_memory)。每个任务都需要关联一个内存区域,并且设置一个标志来表示该任务是否正在使用内存。
具体做法是,首先进行初始化工作。之后,计划为每个任务分配一个内存区域,并创建一个task with memory类型的结构。每个任务都包含一个标志being used来表示该任务是否正在使用内存区域,同时每个任务将关联一个内存区域(arena)。
然后,任务会使用一块内存区域,并且可以根据需要在内存区域内分配一个子区域。比如,每个任务可以从一个更大的内存池中划分出一定大小的内存区域,假设任务需要1MB的栈空间,那么就可以从这个内存池中切割出1MB的区域来供任务使用。这种子区域的分配通过对原本的内存区域进行子分配来实现。
这种方法的目的是在不锁定整个内存区域的情况下,让多个任务能够并行使用各自的内存区域,而不相互冲突,同时也能有效地利用内存。

查看 FillGroundChunk 是如何工作的
当调用填充地面块(FillGroundChunk)时,当前的这个例程实际上是一个占位符,并不包含具体的功能实现。因此,在这一阶段并不会对任务如何启动进行深入探讨。后续可以考虑如何更好地启动这些任务。
一旦调用了FillGroundChunk,我们要做的第一件事是获取一个可用的任务(task)。获取到任务后,我们希望将临时内存的开始和相关操作绑定到任务的执行过程中。具体来说,我们希望这个临时内存的管理和任务的完成状态相互关联,即任务完成时也会结束相应的内存操作。这样可以确保任务在执行过程中能够正确地分配和释放内存,从而避免潜在的内存泄漏或未正确回收的问题。
game.h: 在 task_with_memory 中隐式表示内存是临时的
目标是使任务的内存管理变得更加隐式且自动化。具体来说,每当任务开始时,所有分配的内存都将被视为临时内存,因为当任务结束时,这些内存将会被自动清除或刷新。
在任务结构中,临时内存将被包含为任务的一部分,并且这个内存的状态会被标记为“已刷新”或类似的标识。这样,通过任务的生命周期,内存会被自动管理,不需要额外的手动操作,确保任务结束时相关的临时内存被正确释放。

game.cpp: 实现 BeginTaskWithMemory 和 EndTaskWithMemory
目标是通过任务管理来处理内存的分配和释放。在任务开始时,会通过 begin task 来启动任务,并初始化相关的临时内存。任务结束时,内存将会自动刷新和释放。
具体来说,begin task 将会在合适的地方调用,可能是在程序的某个模块里,而临时内存的初始化(即 begin temporary memory)也将被移到任务开始时处理。这样,内存的分配和释放操作都与任务生命周期紧密绑定,确保内存管理的简洁和高效。
在处理过程中,执行实际渲染工作的部分会依赖于这些已管理好的内存区域,在执行渲染时,调用任务内存结构来处理临时内存,确保内存不被错误覆盖或泄漏。

如果 BeginTaskWithMemory 为 true,则执行 FillGroundChunk
在填充地面数据时,首先使用 BeginTaskWithMemory 方法来检查是否可以开始一个新任务。通过使用条件判断,如果返回 true,才会执行实际的地面填充工作。这种做法的目的是控制任务的数量,防止启动过多任务导致系统过载。这样,任务的启动受到限制,只在资源允许的情况下才会分配新的任务。
这种方法的好处是可以避免任务队列过度膨胀,从而导致性能问题。通过这种方式,可以更精细地控制后台任务的处理,确保系统在负载过重时不会出现问题。
总体来说,这种处理方式能帮助更好地管理任务队列,避免无控制地增加任务负担,保持任务的数量和执行的有效性。

编写 BeginTaskWithMemory
在这里,系统将会遍历任务池中的任务,检查是否有可用的任务。如果某个任务没有被使用,则表示该任务可以被分配并使用。一旦找到可用任务,就会将其标记为当前任务,并为其分配内存。
具体操作包括:
- 遍历任务列表,检查每个任务的使用状态。
- 如果任务未被使用,则选择该任务,并将其设为当前任务。
- 为当前任务分配临时内存空间,并开始使用该任务的内存。
- 完成任务处理后,刷新任务的内存,释放资源。
如果没有找到可用任务,函数会返回零,表示没有可用的任务可供分配。这样就确保了任务的合理调度,并在任务完成后清理内存,以防止内存泄漏。
最终,通过这种方式,任务的分配和内存的管理能够高效地进行,并确保在没有足够任务时不会浪费资源。


将 TiledRenderGroupToOutput 放到后台线程
在进行“TiledRenderGroupToOutput”操作时,目标是将这个过程移到后台线程中执行。为了支持多线程渲染,系统将提供一个选项来决定是否启用多线程。如果选择启用多线程,渲染操作将被分配到多个线程上执行,从而提高效率;如果不启用多线程,渲染仍然可以正常进行。
为了实现这一点,考虑到是否需要多线程渲染,将会有一个标志位,来决定是否开启多线程。这样,渲染过程可以根据需要选择不同的执行方式,既支持多线程,也支持单线程操作。
同时,为了简化实现过程,可能会创建一个单独的例程来处理这些不同的渲染方式。通过这种方式,系统能够灵活地在多线程和单线程模式下运行渲染任务,确保根据实际需要调整渲染策略。


game_render_group.cpp: 引入 RenderGroupToOutput 作为非瓦片版本
在实现“RenderGroupToOutput”的过程中,如果不启用tile渲染(即单线程渲染),系统将依旧执行相同的操作,但是不会进行分块渲染。具体来说,系统将跳过与tile相关的操作,而是直接在整个区域上进行渲染。
为了实现这一点,首先需要确保在渲染前对内存区域进行验证,例如验证上层内存的正确性和尺寸是否合适。此外,还需要去掉与tile渲染相关的部分,保留一些必要的代码来确保渲染区域正确。例如,设置一个剪辑矩形(ClipRect),这个剪辑矩形覆盖整个渲染区域,也就是整个地面缓冲区。
最终,在此路径中,系统将按照单线程渲染的方式工作,假装进行tile渲染的操作,但实际上并不会进行tile分块,而是直接对整个区域进行渲染。

game.cpp: 调用 RenderGroupToOutput
在实现渲染时,系统可以选择不使用渲染队列,并且渲染队列可以被传入为零,这样就不再需要使用渲染队列。通过这样的设置,代码变得更加简洁且明确,能够确保不依赖于渲染队列来执行渲染操作。
此外,需要为这个任务添加适当的回调机制,确保任务能够在完成时进行必要的操作,包括内存刷新、标记任务完成,以及设置相关标志,如将任务的“正在使用”标记设置为“false”。为了确保任务执行的顺利完成,还可以添加完成任务的操作,确保数据的一致性。为了确保任务操作的正确性,可能还需要加上一些同步机制,比如读屏障,以确保在任务执行过程中数据的顺序性和一致性。
如果将任务转换为内联执行(因为它的执行代码较小),这将进一步提高效率。虽然添加读屏障在某些情况下可能并不必要,但考虑到多线程环境下的数据一致性问题,加入读屏障仍然是一个有效的优化措施,确保内存访问的顺序性。
另外,在实现平台的相关功能时,可以在平台的代码中定义一些基本的操作和数据结构,确保任务的执行与平台的操作系统和硬件环境之间的一致性。


game_intrinsics.h: #define CompletePreviousWritesBeforeFutureWrites
在实现手写平台时,首先可以使用编译器来定义一些平台特定的细节,例如使用 MSCV 编译器,或者对于不清楚的情况使用 else 分支进行错误处理,但为了继续编译,可以暂时保留不清楚的部分,并在稍后再做处理。
接着,为了确保数据一致性,需要定义类似 GCC 的内存屏障指令,确保先前的写操作在进行后续写操作前已经完成。这种屏障有助于确保数据访问的顺序性,防止读取不一致的数据。
具体而言,这里涉及的屏障操作是“写屏障”(Write Barrier),它确保在写入数据时,所有前面的写操作已经完成并且不会被重排序,从而保证数据的一致性。这对于多线程或并发执行的环境特别重要,可以有效避免数据竞争和不一致的问题。
接下来,还需要确保代码能够在不同平台上进行编译,尤其是那些可能使用不同编译器的平台,如 GCC 或其他编译器。同时,在开发过程中需要时刻保持对代码的跟进和优化,尽量避免过多的工作量,以确保实现的高效性和稳定性。

.Clangd用来给编辑器显示高亮

game.cpp: 清理
目前,正在进行渲染工作,需要处理“分块渲染工作”。此时,无法将参数转换为写入地址,因为已经有相应的函数体,因此需要确保定义正确。当前的工作实际上是在处理“填充块任务”,并且任务的内存需要正确管理和清理。
基本上,现在的任务是在准备开始执行这些任务。这意味着开始执行任务是下一步的关键目标。



从 &Task->Arena 中分配 AllocateRenderGroup
在执行任务时,当需要分配渲染组时,之前是使用临时内存区域来分配内存。但现在,打算改为使用任务内存区域(task arena)。因为目前没有其他的分配需求,渲染组将会完全从任务内存区域中分配内存。
因此,渲染组的内存分配将直接使用任务内存区域剩余的空间,而不会再需要额外的内存。为了做到这一点,任务内存区域将被用来完全分配给渲染组,这样渲染组就能在这块内存中完成所有的渲染工作,不需要额外的内存空间。

引入 fill_ground_chunk_work
在任务内部,需要执行具体的工作,因此必须定义一个“填充地面”工作结构(fill_ground_chunk_work)。该结构将包含所有任务执行所需的内容。已经事先列出了这些内容,因此现在只需将它们正确地组织和插入结构中。
具体来说,需要处理的内容包括渲染组(render group)、输出缓冲区(output buffer)等元素。渲染组的输出缓冲区可能是一个加载的位图缓冲区(loaded bitmap buffer),但实际上它就是一个缓冲区(buffer),用于存储渲染数据。这些元素将会作为结构的一部分来执行任务的工作。

将 fill_ground_chunk_work 放入 PLATFORM_WORK_QUEUE_CALLBACK
这些就是所需要的实际内容。如果将任务传递给“填充地面”工作时,它将接收一个包含所有必要内容的结构体。这个结构体包含了任务需要的所有信息,任务执行时会从中提取数据并进行处理。处理完毕后,它会在最后进行清理。
需要注意的是,这个清理过程必须是任务的最后一步,因为它会销毁相关的资源,意味着一旦清理完毕,无法恢复。所以,清理工作是任务执行的最终步骤,确保所有资源都被妥善释放并处理完毕。

将 fill_ground_chunk_work 放入 FillGroundChunk 并在最后填充它
接下来,我们要做的是从任务内存池中分配空间。首先,从任务内存池(task arena)分配内存,为每个“fill_ground_chunk_work”(ground chunk work)结构体创建一个实例。接着,可以将需要的数据填充到这个结构体中。
当处理即将结束时,将所有的数据添加到结构体中。具体来说,这些数据将包括缓冲区、渲染组和任务等相关信息,这样就能确保在任务执行过程中,所有必要的资源都已正确地关联并准备好。


调用 PlatformAddEntry
接下来,应该可以像在渲染组中一样进行操作,启动任务。具体来说,我们需要在平台上进行入口操作,将任务传递到适当的队列中。这个队列用于处理那些在后台执行的任务。
在任务中,包含了要填充的地面工作(ground chunk work),以及执行该任务所需的工作顺序(work order)。这些步骤将确保任务能够正确地获取地址并开始执行。
通过这种方式,我们可以确保后台任务得到正确的安排和管理,确保系统能够高效地处理并行任务。

game.h: 实现 GetArenaSizeRemaining
现在需要实现之前提到的、还没有实现的其他函数,实际上它们相对简单。首先,来看一下内存池(arena)的结构。我们有size和used两个字段。
为了实现“获取剩余内存”的功能,并没有特别复杂的操作。我们可以直接通过计算得到剩余内存。具体来说,remaining就等于总内存大小减去已经使用的内存量。
但有一点需要注意的是,计算剩余内存时,实际上还需要考虑对齐(alignment)的问题。因为内存对齐可能会导致一些内存空间的浪费,所以需要在计算剩余内存时考虑对齐的影响。如果内存块需要特定的对齐,必须在计算剩余内存时调整大小。
因此,最终的代码应该在计算内存剩余时,同时考虑对齐的因素,并根据对齐要求调整空间的计算。这是为了确保内存分配时不会因为对齐问题导致错误或者浪费内存。

引入 GetAlignmentOffset
首先,需要创建一个类似于“GetAlignmentOffset”这样的函数,这个函数会帮助完成对齐计算的工作。它会在计算过程中返回正确的对齐偏移量。该函数的实现会包含对内存的对齐调整,从而确保我们在进行内存操作时正确处理了对齐的需求。
在实现时,函数会获取当前内存块的对齐偏移量,并将其加到当前内存的大小中,这样就能确保内存的分配和访问都符合对齐要求。这个过程实际上只是一个数学运算,用来处理内存块的对齐问题。
通过这种方式,我们可以保证每次进行内存操作时,都会考虑到对齐问题,避免因对齐不当导致的内存错误或浪费。接下来,调用这个计算函数时,就可以确保每次都能返回正确的对齐偏移量,从而在分配内存时做出相应的调整。
最终,计算完成后,内存对齐偏移量会被返回,这样我们就可以在需要时使用它来调整内存大小,确保内存分配与访问操作都符合预期的对齐规则。


计算 GetArenaSizeRemaining 中的结果并清理
首先,在进行内存分配时,需要确保对齐的正确处理。为了计算剩余的内存大小,首先需要从总的内存大小中减去已使用的内存量。接着,需要加上任何为对齐所需的额外空间,以确保内存的对齐要求得以满足。
在实现时,应该初始化对齐偏移量为零,之后根据实际的对齐要求,调整计算出来的剩余空间。这样可以确保内存分配的对齐性。
接下来的步骤是处理数据类型转换,特别是从 size_t 转换到 uint32_t 类型,因为在某些情况下这可能会涉及到数据丢失的警告。在这里,编译器会警告潜在的数据丢失问题,因此需要特别注意。
这部分代码的关键在于正确地计算剩余内存空间,并确保所有的内存操作都遵循正确的对齐规范,同时解决可能发生的数据类型转换问题。

game.cpp: 在 FillGroundChunk 中将 GetArenaSizeRemaining 强制转换为 uint32
在处理内存分配时,需要注意数据类型转换问题,尤其是将较大范围的值从 uint64_t 转换到 uint32_t。由于 uint32_t 的最大值为 4GB,当值超出这个范围时,会发生数据丢失或溢出。
为了解决这个问题,可以使用一个安全的转换方法,在进行 uint64_t 到 uint32_t 的转换时,首先检查值是否超出 uint32_t 的最大值。如果超出,就需要采取适当的措施来避免溢出。可以使用一种安全的转换机制,例如 safe_cast,来保证转换过程中不会丢失数据。
在这种情况下,选择使用 uint32_t 或 uint64_t 作为内存大小的表示类型需要谨慎。如果最大缓冲区大小较大,超出了 uint32_t 的表示范围,则可能需要使用 uint64_t 来避免潜在的溢出问题。


game.h: 实现 SubArena
在处理内存分配时,创建子区域(SubArena)相对直接。首先,需要一个内存区域(memory arena)来作为基础,然后从中创建子区域。为此,传递内存区域和结果大小信息就足够了。子区域的大小由传入的参数决定,表示为某个特定的大小值。
此外,内存分配还需要考虑对齐(alignment)。虽然有可能强制将所有子区域的对齐方式设置为特定值,确保对齐正确,但对于一些特殊情况,还需考虑具体需求。如果对齐是固定的,可以忽略不做特别处理,否则需要根据实际情况来选择适当的对齐方式。
创建子区域时,除了大小和对齐外,还需要初始化其他必要的变量,例如基础地址、内存索引、临时计数等。这些变量用于确保内存分配的正确性和完整性。子区域的创建过程可以视为一个子分配(sub-allocation)过程,从内存区域中分配一块特定的空间。
创建完子区域后,可能还需要进行一些额外的调整,比如将某些数据转换为适合的格式,确保内存指针指向正确的位置。例如,确保内存指针是 uint8_t 指针类型,表示字节级的地址。通过这种方式,可以确保内存管理的灵活性和高效性。
总结起来,创建子区域的过程涉及传递相关大小、内存区域和对齐信息,同时也需要根据实际情况初始化必要的变量,以确保分配的内存区域正确且高效地利用。


演示我们新增的功能
目前,虽然我们接近完成一些工作,但仍有许多内容没有完全覆盖,特别是在如何整理和完成任务处理部分(如任务队列和调度)。但是,从整体上看,我们已经在逐步接近可以进行一般性工作的阶段,而不仅仅是针对某些特定的渲染工作负载。
具体来说,在填充工作时,我们正在使用一个专门的内存区域来处理当前任务。这个内存区域仅限于当前要执行的任务,将所有必要的内容都放入该区域中,任务完成后会被释放。完成后的任务会通过标记“任务不再使用”来通知系统,从而让系统可以清理该任务资源。
目前的实现虽然没有特别复杂,但它确实为后续的调试和执行提供了一个基础,帮助我们将任务分配到多个队列,并允许它们并行运行,直到任务完成。通过这种方式,系统可以有效地管理多个任务,并确保它们按需被执行。
这些步骤为我们提供了一个框架,使得可以通过任务调度和内存管理来执行一般性工作,而不仅限于某些特定的工作负载。接下来需要进一步完善和调试,以确保这个系统能够按预期工作。

game.h:添加+=Size 将 += Size 放到 PushSize_ 中正确的位置
遇到的问题是,内存分配时,应该保证所请求的内存大小大于或等于所需的大小。通过断言检查,可以确保至少分配了请求的内存量。在调试时,发现错误的根源是内存位置的推进被放置在了错误的位置。这导致了分配的问题,尽管内存空间实际上是充足的,但由于推进位置不正确,内存没有按预期分配。
这个问题通过调整推进位置来解决,确保了内存能够正确分配。这个错误的发现是通过断言验证来确认的。

在调试过程中,发现需要考虑到结构体的对齐问题。在分配内存时,除了需要处理最大内存大小外,还需要确保考虑到结构体的对齐要求。因此,决定在进行内存分配时加入结构体对齐的处理。这样可以确保内存的分配不仅满足最大内存大小要求,还能正确对齐结构体,避免潜在的内存问题。


game_render_group.cpp: 在 AllocateRenderGroup 中有条件地设置 MaxPushBufferSize
在处理 allocate render group 的时候,遇到了需要考虑最大推送缓冲区大小(MaxPushBufferSize)的问题。当前的做法是,如果最大推送缓冲区大小为零,则会使用剩余的空间来分配内存。这种做法并不是最理想的,但在目前的情况下,它是一个可行的解决方案。虽然不太喜欢这种方式,但没有看到其他更好的选择,因此暂时采用了这种做法,即在 MaxPushBufferSize 为零时,使用剩余空间进行内存分配。



成功运行游戏
现在在后台和渲染过程中有了一些进展,虽然我们没有给它足够的提前时间去始终命中,但似乎已经解决了长时间停滞的问题,这是好的。目前,渲染的地块已经在后台运行,这是我们想要的效果。
game_render_group.cpp: 重新启用调试相机
现在后台任务已经开始顺利运行,我们可以在此基础上进一步改进。接下来可以调整一些细节,比如将渲染基础设置为调试模式,并扩大查询范围,确保后台任务正常工作。
目前,虽然已经解决了卡顿问题,确保了平稳的帧率,但仍有一些细节没有处理好。例如,当前我们在渲染位图的同时,可能存在未完成的任务问题。为了避免这种情况,应该加入一个机制,确保任务完成后再开始渲染,否则会导致错误。因此,需要在任务完成之前加个检查,确保任务已完成才进行渲染。
总体来说,已经解决了很多问题,保持了良好的帧率,整体运行良好。


如果一个 task_with_memory 调用先于前面的几个 task_with_memory 调用执行 EndTaskWithMemory,前面的任务会出问题吗?还是这种情况不可能发生?我感觉我没完全理解某些东西
如果有一个任务使用了内存,并且它前面有多个任务在使用内存,当它调用并测试第一个任务时,前面的任务会不会被弄乱?这种情况是不可能发生的,因为每个任务使用的是各自独立的内存区域,而不是共享同一个内存区域。
具体来说,每个任务都会使用自己专用的内存区域(arena),因此它们之间不会相互影响。即使一个任务开始执行,它前面的任务的内存内容也不会被破坏,因为每个任务的内存分配和操作是独立的。这样可以避免任务之间的相互干扰,确保它们各自按计划进行,不会互相影响。
在 Arena 中存放什么以及它们是如何工作的
在启动时分配了一个临时内存区域(transient arena),这个区域的大小是根据机器内存来决定的,基本上是整个系统可用的内存量。这个内存区域一开始是一个大的内存块,随后会被划分成多个小区域来使用。
首先,这个内存区域会被用来存储一些数据,比如说图像或其他一些内容。接着,内存的不同部分会被用来存放不同的任务或数据。例如,用来存储地面数据、位图数据等。然后,根据需求,将内存区域分割成几个独立的子区域,分别用于不同的任务处理。
每个任务都会获得自己专用的内存区域(称为子内存区域或arena)。这些子区域是完全独立的,因此多个任务可以异步执行,互不干扰。每个任务只会在自己的内存区域内操作,不需要和其他任务的内存进行同步或通信。这种设计确保了任务之间互不干扰,各自可以独立进行计算。
当需要启动一个任务时,任务的相关数据(例如渲染组数据)会被放入相应任务的内存区域。一旦任务开始执行,就会一直运行到完成。当任务完成时,它会通过设置一个标志位来表明自己已经完成。每个内存区域都有一个“正在使用”的标志位,这样系统可以通过检查这些标志位来确定哪些任务还在进行,哪些任务已经完成。一旦一个任务完成,标志位会被设置为0,表示该内存区域可以被其他任务使用。
这样,每个任务的内存区域都是隔离的,这意味着任务可以独立运行,互不干扰,系统不需要担心任务的执行顺序或同步问题。
我们已经为 GroundBuffers 预留了内存。为什么不能直接使用这些内存来处理线程任务?
我们已经为地面缓冲区预留了内存,但是不能将这些内存直接用于多线程任务处理。原因是,地面缓冲区和渲染组使用的是不同的内存区域,且它们的用途不同。地面缓冲区内存需要用来存放位图,而渲染组则涉及到与渲染相关的数据,因此不能将这两者的内存混用。
此外,我们需要处理大量的地面数据,例如有256个地面区域和128个地面块,而这些地面块的内存会被用来存储位图数据。位图本身的大小是256x256的,因此需要相当大的内存空间来存储这些位图。如果直接将渲染组数据放入地面缓冲区内存区域中,渲染组数据可能会覆盖掉位图数据,从而导致渲染错误。
另外,如果我们将渲染组数据存放在地面缓冲区内存的开头部分,虽然可以避免覆盖位图数据,但这样会导致所有128个地面块的内存被扩大。即便我们只会同时使用其中的四个地面块,其他不需要的内存也会被浪费掉。这显然是不合适的,因为这样会浪费大量内存空间。
为了解决这个问题,我们为每个任务单独分配了内存,这样只有在任务生命周期内需要的内存才会被使用。任务处理完成后,这些内存会被释放,不会占用持久存储,从而避免了浪费大量内存。通过这种方式,可以有效地管理内存使用,同时确保每个任务有足够的内存进行处理。
屏幕上的那些线条是什么?
屏幕上的这些线条是调试模式下显示的,用来帮助查看不同的区域。具体来说,这些线条表示以下几个区域:
- 相机视野范围:表示相机能够看到的区域。
- 模拟区域:表示模拟的工作区域,也就是游戏或应用的核心计算和处理区域。
- 碰撞考虑的外边界:表示碰撞检测的最大边界,用来判断物体之间是否发生碰撞。
这些线条是调试时用于帮助开发人员了解各个区域的关系,用户在实际使用时是看不到这些线条的。用户只会看到应用或游戏的正常界面,而这些调试信息只是用来辅助开发和调试工作。例如,黄色线条表示相机的边界,它显示了相机能够捕捉到的区域范围。


win32_game.cpp: 在 Win32DisplayBufferInWindow 中暂时将 OffsetX 和 OffsetY 设置为 0
在调试过程中,遇到了一个对齐问题。看起来调用路径可能有误,导致出现了不希望出现的行为。具体来说,发现窗口宽度和缓冲区宽度的倍数关系不对,导致了某种错误的路径被执行。检查后发现,这个问题与窗口大小的拉伸(stretch)有关,而实际上不应该进行拉伸操作。
经过分析,发现这个问题是由于代码的某个路径被错误地调用,导致了不正确的行为。为了暂时解决问题,决定注释掉一些代码行,直到能够做出更智能的调整。

所以你基本上为地面块构建了一个任务池来在另一个线程上运行。你能否使用这个机制处理其他类型的任务?
这个机制不仅仅是为了处理地面块的任务,它的设计是可以用于其他类型的任务的。通过这种方式,任何需要的临时存储和任务处理都可以通过这种机制来实现。对于渲染任务,虽然它也通过相同的任务系统进行处理,但它不需要临时存储,因为它是一个即时的任务,所以不需要额外的存储空间。
为什么不把临时 Arena 顶部的东西(比如地面块位图)存放到永久存储中?
将暂时存储区顶部的内容放入永久存储区并不可行,因为这些数据并不是永久性的,它们可以随时重新创建。例如,当操作系统进入低内存状态时,这些数据可以被丢弃,然后在需要时重新生成。因此,使用暂时存储区是合理的,因为这些数据可以根据需要被丢弃并重新创建。
为什么有时会闪烁 magenta?是因为我们在工作没有完成时就开始绘制了吗?
闪烁成品红色(品红色)是因为我们在没有完成工作的情况下就开始绘制了。正如之前提到的,应该在绘制之前先进行锁定。虽然从技术上讲,我们可以选择不进行锁定,因为我们的目标是始终在这些区域被看到之前就将它们排队,这样就不会进入这种情况。但是,目前的问题是我们没有提前请求这些区域。
在 Windows 和 Linux 系统中,堆(Heap)和栈(Stack)的内存地址增长方向是由操作系统和硬件架构共同决定的。以下是对两者在常见情况下的详细分析,特别是在主流的 x86 和 x64 架构上。我们将分别讨论堆和栈的内存地址增长方向,并解释其背后的机制。
栈(Stack)的内存地址增长方向
栈是用于存储函数调用上下文(如局部变量、返回地址等)的一种数据结构,其内存地址的增长方向通常由 CPU 架构决定。
Windows 和 Linux 的共同点
- 在 x86(32 位)和 x64(64 位)架构上,栈的地址增长方向是向下(从高地址向低地址增长)。
- 原因:
- 这是 Intel x86/x64 架构的硬件设计决定的。栈指针(
ESP或RSP)在每次压栈(push)操作时减少,指向更低的内存地址。 - 例如,调用函数时,栈指针会减小以分配空间给局部变量,返回时通过加回来释放(
pop)。
- 这是 Intel x86/x64 架构的硬件设计决定的。栈指针(
具体行为
- 初始栈顶:
- 操作系统会为每个线程分配一个栈空间(默认大小 Windows 上通常是 1MB,Linux 上通过
ulimit -s查看,常用 8MB)。 - 栈顶从高地址开始,例如
0x7FFFFFFF(32 位)或0x7FFFFFFFFFFF(64 位,具体地址因系统而异)。
- 操作系统会为每个线程分配一个栈空间(默认大小 Windows 上通常是 1MB,Linux 上通过
- 增长方向:
- 每次压栈(
push)或分配局部变量,栈指针减小,例如从0x7FFFFFFF到0x7FFFFF00。 - 示例:
void func() {int a = 1; // 栈指针减少 4 字节(32 位)或 8 字节(64 位)int b = 2; // 再次减少 }- 假设初始栈指针是
0x7FFFFFFF,分配a后可能变为0x7FFFFFFB,再分配b后变为0x7FFFFFF7(32 位系统,未考虑对齐)。
- 假设初始栈指针是
- 每次压栈(
Windows 和 Linux 的细微差异
- 栈大小:
- Windows:默认 1MB,可通过链接器选项(
/STACK)调整。 - Linux:默认 8MB,可通过
ulimit -s修改。
- Windows:默认 1MB,可通过链接器选项(
- 地址空间:
- 两者的虚拟地址范围不同,但增长方向一致(向下)。
- Windows 64 位:用户态栈地址通常在
0x7FFFFFFF0000附近。 - Linux 64 位:栈地址通常在
0x7FFFxxxxxxxx范围内(由内核随机化影响)。
堆(Heap)的内存地址增长方向
堆是动态分配内存的区域(如通过 malloc、new 等),其地址增长方向由操作系统的内存管理器决定,而非硬件直接控制。
Windows 和 Linux 的共同点
- 堆的地址增长方向通常是向上(从低地址向高地址增长)。
- 原因:
- 堆管理器从较低的地址开始分配内存块,随着分配请求增加,逐渐向更高的地址扩展。
- 这样设计避免与栈(从高地址向下增长)发生冲突。
具体行为
- 初始堆地址:
- 堆通常位于进程地址空间的较低部分(高于代码段和数据段)。
- 示例:
- Windows 64 位:可能从
0x00000000xxxxxxx开始。 - Linux 64 位:可能从
0x00000000xxxxxxx开始(受 ASLR 影响)。
- Windows 64 位:可能从
- 增长方向:
- 每次分配内存(如
malloc),堆管理器从当前堆顶向高地址扩展。 - 示例:
int* p1 = new int; // 分配 4 字节,假设地址 0x1000 int* p2 = new int; // 下一个地址 0x1004(忽略对齐和元数据)
- 每次分配内存(如
Windows 的堆管理
- 实现:Windows 使用
HeapAlloc(基于 NT 堆管理器)。 - 增长:
- 默认堆从低地址开始,调用
HeapAlloc时向高地址扩展。 - 如果默认堆不够,系统会创建额外的堆(地址可能不连续)。
- 默认堆从低地址开始,调用
- 地址示例:
- 初始堆可能在
0x0000000140000000附近,后续分配递增。
- 初始堆可能在
Linux 的堆管理
- 实现:Linux 使用
glibc的malloc,底层通过brk或mmap分配。 - 增长:
- 小块内存:通过
brk扩展程序的数据段(sbrk),地址向上增长。 - 大块内存:通过
mmap分配,可能不连续,但总体趋势是从低到高。
- 小块内存:通过
- 地址示例:
- 初始堆可能在
0x0000555555600000附近,后续递增。
- 初始堆可能在
差异
- ASLR(地址空间布局随机化):
- Linux 默认启用 ASLR,堆地址随机化程度更高,每次运行可能不同。
- Windows 也支持 ASLR,但默认堆地址相对稳定(除非明确启用随机化)。
- 扩展方式:
- Linux 的
mmap可能导致堆地址跳跃,而 Windows 的堆更倾向于连续增长。
- Linux 的
内存地址空间布局
为了直观理解,以下是典型 64 位进程的地址空间布局(简化版):
+-------------------+ 高地址 (0x7FFFFFFFFFFF)
| 栈 (向下增长) |
| |
| ... |
| |
| 堆 (向上增长) |
| ... |
| 数据段 (.data) |
| 代码段 (.text) |
+-------------------+ 低地址 (0x000000000000)
- 栈:从高地址向下(如
0x7FFFFFFFxxxx到0x7FFFFFxxxx)。 - 堆:从低地址向上(如
0x000055555xxxx到0x000055556xxxx)。
验证方法
示例代码
#include <iostream>
int main() {int a = 1; // 栈上int* p = new int(2); // 堆上std::cout << "Stack addr: " << &a << std::endl;std::cout << "Heap addr: " << p << std::endl;delete p;return 0;
}
- Windows 输出(示例):
Stack addr: 0x000000F4A6EFFC4C (高地址) Heap addr: 0x000001F4A7206640 (低地址) - Linux 输出(示例):
Stack addr: 0x7FFF5E8B6CFC (高地址) Heap addr: 0x55555556B6B0 (低地址)
观察增长
- 栈:连续定义多个局部变量,地址递减。
- 堆:多次
new,地址递增。
总结
- 栈:
- Windows 和 Linux:向下增长(高地址 -> 低地址)。
- 由 x86/x64 硬件设计决定。
- 堆:
- Windows 和 Linux:向上增长(低地址 -> 高地址)。
- 由操作系统堆管理器实现,Linux 受 ASLR 和
mmap影响更大。
- 差异:
- 默认栈大小和地址随机化策略不同,但增长方向一致。
如果需要更具体的地址示例或调试方法,可以告诉我你的系统和架构,我可以进一步帮你验证!
相关文章:
游戏引擎学习第131天
仓库:https://gitee.com/mrxiao_com/2d_game_3 运行游戏并识别我们的小问题 今天的工作重点是对游戏引擎进行架构优化,特别是针对渲染和多线程的部分。目前,我们的目标是让地面块在独立线程上进行渲染,以提高性能。在此过程中,我…...
Visual Studio Code集成MarsCode AI
Visual Studio Code集成MarsCode AI 1、搜索MarsCode AI 安装包 2、点击install安装即可 小编这里已经安装过了 3、登录自己的账号 点击链接,注册账号 https://www.marscode.cn/events/s/i5DRGqqo/ 4、登录后可以自己切换模型...
partner‘127.0.0.1:3200‘ not reached
在SAP虚拟机中,如果LRPSAP 0显示黄色,通常表示服务启动异常或存在配置问题。以下是一些可能的处理方法: 检查主机文件配置 确保主机文件(hosts)中已正确配置SAP服务的域名解析。例如,添加以下内容到hosts文…...
蓝桥备赛(六)- C/C++输入输出
一、OJ题目输入情况汇总 OJ(online judge) 接下来会有例题 , 根据一下题目 , 对这些情况进行分析 1.1 单组测试用例 单在 --> 程序运行一次 , 就处理一组 练习一:计算 (ab)/c 的值 B2009 计算 (ab)/c …...
Flume
Flume安装配置 使用的三台主机名称分别为bigdata1,bigdata2,bigdata3。所使用的安装包名称按自己的修改,安装包可去各大官网上下载 1.解压 将Master节点Flume安装包解压到/opt/module目录下 tar -zxvf /opt/software/apache-flume-1.9.0-bi…...
Java 大视界 -- Java 大数据中的时间序列数据异常检测算法对比与实践(103)
💖亲爱的朋友们,热烈欢迎来到 青云交的博客!能与诸位在此相逢,我倍感荣幸。在这飞速更迭的时代,我们都渴望一方心灵净土,而 我的博客 正是这样温暖的所在。这里为你呈上趣味与实用兼具的知识,也…...
三次握手内部实现原理
socket()创建一个新的套接字 int socket(int domain, int type, int protocol); 参数: domain:地址族,如 AF_INET(IPv4),AF_INET6(IPv6) type:套接字类型&…...
ES from size聚合查询10000聚合查询,是每个分片先聚合,再统计。还是所有节点查询1万条后,再聚合
在 Elasticsearch 中,聚合查询 的执行过程是 分布式 的,Elasticsearch 会先在每个分片(shard)上执行本地聚合,然后再在协调节点(coordinating node)上对所有分片的聚合结果进行 全局汇总。具体过…...
JAVA实战开源项目:安康旅游网站(Vue+SpringBoot) 附源码
本文项目编号 T 098 ,文末自助获取源码 \color{red}{T098,文末自助获取源码} T098,文末自助获取源码 目录 一、系统介绍二、数据库设计三、配套教程3.1 启动教程3.2 讲解视频3.3 二次开发教程 四、功能截图五、文案资料5.1 选题背景5.2 国内…...
Redis详解(实战 + 面试)
目录 Redis 是单线程的!为什么 Redis-Key(操作redis的key命令) String 扩展字符串操作命令 数字增长命令 字符串范围range命令 设置过期时间命令 批量设置值 string设置对象,但最好使用hash来存储对象 组合命令getset,先get然后在set Hash hash命令: h…...
宝塔webhooks与码云实现自动部署
1. 宝塔面板配置Webhook 登录宝塔面板,进入「软件商店」→ 搜索「Webhook」并安装。添加Webhook: 名称:自定义(如 Gitee自动部署)脚本:编写部署脚本,示例如下:#!/bin/bash# 项目路径…...
什么是Agentic AI?(Doubao-1.5-pro-32k 大模型开启联网回答)
Agentic AI即代理式人工智能,也称为智能体AI、代理式AI、能动AI或自主AI(Autonomous AI),是人工智能领域的新兴概念。它是指被设计用来通过理解目标、导航复杂环境,并在最少的人工干预下执行任务的系统,能够…...
LSTM预测模型复现笔记和问题记录
LSTM复现笔记和问题记录 1 LSTM复现记录1.1 复现环境配置1.2 LSTM_Fly文件夹1.2.1 LSTM回归网络(1→1).py1.2.1.1 加载数据1.2.1.2 数据处理1.2.1.3 输入模型维度 1.2.2 移动窗口型回归(3→1).py1.2.2.1 数据处理1.2.2.2 输入模型维度 1.2.3 时间步长型回归(3→1).py1.2.3.1 数…...
开篇词 | Go 项目开发极速入门课介绍
欢迎加入我的训练营:云原生 AI 实战营,一个助力 Go 开发者在 AI 时代建立技术竞争力的实战营。实战营中包含大量 Go、云原生、AI Infra 相关的优质实战课程和项目。欢迎关注我的公众号:令飞编程,持续分享 Go、云原生、AI Infra 技…...
《论软件测试中缺陷管理及其应用》审题技巧 - 系统架构设计师
论软件测试中缺陷管理及其应用写作框架 一、考点概述 本论题“论软件测试中缺陷管理及其应用”主要考查的是软件测试领域中的缺陷管理相关知识与实践应用。论题涵盖了以下几个核心内容: 首先,需要理解软件缺陷的基本概念,即软件中存在的破坏正常运行能力的问题、错误或隐…...
虚拟机快照与linux的目录结构
虚拟机快照是对虚拟机某一时刻状态的完整捕获,包括内存、磁盘、配置及虚拟硬件状态等,保存为独立文件。 其作用主要有数据备份恢复、方便系统测试实验、用于灾难恢复以及数据对比分析。具有快速创建和恢复、占用空间小、可多个快照并存的特点。在管理维…...
FPGA时许约束与分析 1
1、时钟的基本概念 1.1 时钟定义: 同步设计:电路的状态变化总是由某个周期信号的变化进行控制的,在这个信号的 posedge 或者是 negedge 都可以作为电路状态的触发条件。 时钟:在同步设计中,这个信号 叫做时钟。 理…...
【STM32F103ZET6——库函数】6.PWM
目录 配置PWM输出引脚 使能引脚时钟 配置PWM 使能PWM 配置定时器 使能定时器时钟 使能定时器 例程 例程说明 main.h main.c PWM.h PWM.c led.h led.c DSQ.h DSQ.c 配置PWM输出引脚 PWM的输出引脚必须配置为复用功能。 注意:需要使用哪个引脚&…...
基于SpringBoot + Vue的商城购物系统实战
一:简介 使用springboot框架编写后端服务,并使用若依框架搭建管理端界面。在原有基础功能基础上有加入了人工客服、收货地址、智能助手(接入通义千问,暂时关闭)、抽奖功能、支付宝沙箱支付、优惠卷等功能。 目前已部…...
Perl 调用 DeepSeek API 脚本
向 chat.deepseek.com 提问:请将这个 python 脚本翻译为 perl 语言脚本 参阅:Python 调用 DeepSeek API 完整指南 将 Python 脚本翻译为 Perl 语言脚本时,需要注意两种语言之间的语法差异。以下是将给定的 Python 脚本翻译为 Perl 的版本&a…...
华为云AI开发平台ModelArts
华为云ModelArts:重塑AI开发流程的“智能引擎”与“创新加速器”! 在人工智能浪潮席卷全球的2025年,企业拥抱AI的意愿空前高涨,但技术门槛高、流程复杂、资源投入巨大的现实,却让许多创新构想止步于实验室。数据科学家…...
在rocky linux 9.5上在线安装 docker
前面是指南,后面是日志 sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo sudo dnf install docker-ce docker-ce-cli containerd.io -y docker version sudo systemctl start docker sudo systemctl status docker …...
【git】把本地更改提交远程新分支feature_g
创建并切换新分支 git checkout -b feature_g 添加并提交更改 git add . git commit -m “实现图片上传功能” 推送到远程 git push -u origin feature_g...
大模型多显卡多服务器并行计算方法与实践指南
一、分布式训练概述 大规模语言模型的训练通常需要分布式计算技术,以解决单机资源不足的问题。分布式训练主要分为两种模式: 数据并行:将数据分片到不同设备,每个设备拥有完整的模型副本 模型并行:将模型分割到不同设备,每个设备处理部分模型计算 现代大模型训练通常结合…...
C# SqlSugar:依赖注入与仓储模式实践
C# SqlSugar:依赖注入与仓储模式实践 在 C# 的应用开发中,数据库操作是必不可少的环节。为了让数据访问层更加简洁、高效且易于维护,许多开发者会选择成熟的 ORM(对象关系映射)框架,SqlSugar 就是其中备受…...
Maven 概述、安装、配置、仓库、私服详解
目录 1、Maven 概述 1.1 Maven 的定义 1.2 Maven 解决的问题 1.3 Maven 的核心特性与优势 2、Maven 安装 2.1 下载 Maven 2.2 安装配置 Maven 2.3 测试安装 2.4 修改 Maven 本地仓库的默认路径 3、Maven 配置 3.1 配置本地仓库 3.2 配置 JDK 3.3 IDEA 配置本地 Ma…...
html-<abbr> 缩写或首字母缩略词
定义与作用 <abbr> 标签用于表示缩写或首字母缩略词,它可以帮助用户更好地理解缩写的含义,尤其是对于那些不熟悉该缩写的用户。 title 属性的内容提供了缩写的详细说明。当用户将鼠标悬停在缩写上时,会显示一个提示框。 示例&#x…...
高效线程安全的单例模式:Python 中的懒加载与自定义初始化参数
高效线程安全的单例模式:Python 中的懒加载与自定义初始化参数 在软件开发中,单例模式(Singleton Pattern)是一种常见的设计模式,确保一个类仅有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式时需要注意线程安全问题,以防止多个线程同时创建实例,导致…...
搭建DNS域名解析服务器(正向解析资源文件)
正向解析资源文件 1)准备工作 服务端及客户端都关闭安全软件 [rootlocalhost ~]# systemctl stop firewalld [rootlocalhost ~]# setenforce 0 2)服务端安装软件:bind 1.配置yum源 [rootlocalhost ~]# cat /etc/yum.repos.d/base.repo [Base…...
接口自动化测试:HttpRunner基础
相关文档 HttpRunner V3.x中文文档 HttpRunner 用户指南 使用HttpRunner 3.x实现接口自动化测试 HttpRunner介绍 HttpRunner 是一个开源的 API 测试工具,支持 HTTP(S)/HTTP2/WebSocket/RPC 等网络协议,涵盖接口测试、性能测试、数字体验监测等测试类型…...

