游戏引擎学习第228天

发布于:2025-04-16 ⋅ 阅读:(19) ⋅ 点赞:(0)

对上次的内容进行回顾,并为今天的开发环节做铺垫。

目前大部分功能我们已经完成了,唯一剩下的是一个我们知道存在但目前不会实际触发的 bug。这个 bug 的本质是在某些线程仍然访问一个已经被销毁的游戏模式(mode)之后的状态,因此它属于一种“潜在的隐患”,不是立即暴露的问题。除了这个问题外,整体系统现在运行得还不错。

现在的流程是这样的:我们设置了一个简单的占位符作为标题画面,目前是红色背景的清屏效果,暂时还没有真正的标题画面美术资源。之后我们进入过场动画(cutscene),这一部分也能正常播放,然后我们可以顺利地进入游戏。

在游戏内我们可以自由地移动和进行操作,体验完整的游戏逻辑。现在也可以顺利退出游戏,回到过场动画,而且这个过程能够正确地重新初始化,这是之前我们想要达到的效果。不过,我们觉得可能应该退出后回到标题画面而不是过场动画,但这只是一个一行代码的小改动,根据我们想要的流程随时可以调整。

总的来说,这一整套流程都感觉良好,但还有一些细节我们需要进一步完善。其中最重要的是我们之前提到的线程 bug:如果某个模式在运行时开启了一些线程,那么当该模式结束时,我们需要确保这些线程都被正确清理,以免出现资源未释放或非法访问的情况。必须要有一个机制来确保模式在结束前能确认相关线程都已退出。

另一个需要考虑的点是是否需要设计“独立运行的模式”,例如用于预加载(prefetch)或预处理的模式。就像我们之前对过场动画内容进行预取一样,未来也许需要在进入某些模式前就开始异步加载资源。这可能在某些场景下很有用,比如提前加载过场动画、场景或角色资源等等。不过这个功能是否立即实现还不确定,也许可以暂时搁置。

即便我们最终不使用地形块(ground chunk)的合成逻辑,也不代表我们不会在别的地方需要多线程的机制,因此这方面的线程清理逻辑仍然必须完善。确保即使后面换了用途,我们的基础结构依然是安全可靠的。

当这些核心问题解决后,我们接下来想要回到调试系统(debug code)的开发上,把之前的一些调试工具完善好,确保游戏逻辑相关的部分有良好的开发支持环境。大部分游戏逻辑已经就绪,感觉我们已经可以开始真正的玩法开发了。

希望新的一年我们能把更多时间放在实际的玩法内容制作上,而不是基础架构的搭建。所以我们当前的目标是:

  1. 彻底解决线程相关的 bug;
  2. 让调试系统稳定、易用;
  3. 给渲染系统加入排序逻辑,并进行一些光照方面的小优化。

当这些事情都搞定之后,我们就可以放心地进入下一阶段的开发。

描述了一个任务系统中的线程问题:多个任务访问并依赖游戏模式状态(Game Mode),这些任务在模式切换后依然持续执行,可能引发状态不一致或错误。

我们现在系统里有一个“游戏模式”(game mode)的概念,比如标题画面、过场动画、世界模式等,游戏状态已经不再是一个巨大的整体结构,而是被拆分成各个模式下的不同部分,各自拥有独立的数据结构和处理逻辑。

但在引入游戏模式之后,我们也遇到了一个具体的问题:任务系统(tasks with memory)中的一些任务可能仍在运行,特别是某个模式下启动的任务,在该模式已经退出后却没有及时结束,可能继续访问已经无效的内存,这会引发潜在的 bug。

我们现在的任务分为几类,比如有些任务是和资源系统相关的,用于动态从磁盘中加载资源,这是用来实现无缝加载、避免加载画面的关键机制。这些任务运行期间会不断从磁盘读取数据,但它们与具体的游戏模式完全解耦。它们只关心“资源”本身,不关心这些资源是为哪个模式服务的,比如是为标题画面加载,还是为世界模式加载都无所谓。因此,这类任务即使跨模式运行也不会出问题。

相比之下,问题主要出在那些直接依赖于某个特定模式数据的任务,比如说“地面块加载任务”(ground chunk compositing tasks),这些任务的工作内容和某一个具体模式中的数据结构紧密耦合,当这个模式被销毁后,如果这些任务仍然在运行,就会访问无效的内存,从而出现 bug。

除此之外,还有一种任务是渲染相关的任务(render tasks),但这些任务不会成为问题的来源。因为渲染任务是在每一帧内部生成并销毁的,它们从不会跨越帧边界,也不会在模式切换时存活下来,因此模式切换时不需要考虑它们的影响。

所以最终我们的问题非常明确:需要一个机制确保在模式结束时,清理掉那些在该模式下启动的、依赖该模式数据的任务,防止它们越界运行或访问已释放的数据。而那些与模式无关或生命周期控制明确的任务则可以忽略,不在考虑范围之内。

为了解决这个问题,我们可以考虑两个方向:

  1. 完全不允许任务和模式直接耦合,所有任务都要像资源系统那样独立。这种方式理想但实现难度大,需要重构任务逻辑,避免任务访问模式内的数据。
  2. 或者,在模式被销毁之前,确保所有依赖该模式的任务都已经安全退出,做到模式与其内部的任务共同生命周期管理。这种方式更实际,只要在切换模式前调用某种“等待任务完成”的机制就可以。

目前我们倾向于采用第二种方式,也就是在模式退出时强制等待其任务退出。这种方式能够最直接地解决 bug,同时不需要大规模重构已有系统。

列出了几种可能的解决方案,思考如何应对此类依赖模式状态的任务问题。

我们现在真正需要关心的任务,其实只有一种:那些访问游戏模式状态并且会跨越多帧运行的任务。目前系统里唯一符合这个条件的是用于生成地形块的任务(ground chunks),其他任务要么是跟资源加载相关(与模式解耦),要么是渲染任务(只在单帧内运行)。

对于这些跨帧运行且依赖游戏模式状态的任务,我们面临两个选择:

  1. 完全不允许这类任务存在,也就是说,不让任何任务在模式中长时间存在并访问模式内部数据。这种方式最为简单和稳妥,但也限制了灵活性。
  2. 为这类任务提供机制以支持安全管理和清理,即在切换游戏模式时,能够识别并妥善终止或等待这些任务完成,避免访问已经销毁的数据。

我们暂时不确定哪种方式更好,因为还不确定最终完成的游戏是否会真正需要这类跨帧任务。从直觉上来说,当前的地形块生成逻辑可能最终不会使用——因为设计上动态创建大块地形的需求并不强烈,而且实施起来复杂、回报不高,因此倾向于将其移除。

然而,即便移除了地形块生成逻辑,我们仍可能遇到类似的需求,最典型的例子就是世界生成。假设游戏开始时,玩家需要立即进入游戏,而整个世界需要十几秒甚至几十秒来生成。如果我们不希望让玩家看到加载画面(loading screen),那么一个更合理的做法就是:

  • 让玩家直接进入游戏并开始在初始区域活动
  • 后台同时运行世界生成任务,逐步生成剩余世界内容

这种方式在一些游戏中很常见,比如《Don’t Starve》等游戏在启动新游戏时会显示“生成世界”的提示,如果我们不想要这种停顿,就必须依赖后台任务来异步完成世界生成工作。
在这里插入图片描述

因此,这就意味着:我们需要支持某些任务在模式内部跨帧执行,且需要为它们提供生命周期管理机制。这样即使这些任务不是为当前模式服务了,我们也可以在切换模式前安全地终止它们,或者等它们执行完。

总结来说,我们决定支持这类任务,即便移除了当前的地形块生成系统,也仍然有其他场景需要它,比如异步世界生成。因此,我们会保留并完善这类任务的管理机制,为将来避免加载等待和提升用户体验做好准备。

game.h 中为 task_with_memory 添加了一个布尔字段:b32 DependsOnGameMode,用于标记该任务是否依赖当前游戏模式。

我们目前使用的任务系统中,存在“带内存的任务”(task with memory)的概念,并且这些任务现在被保存在 transient 状态中。在当前实现中,一共有四个此类任务在运行。

为了解决切换游戏模式时仍有任务访问已销毁数据的问题,我们决定给这些任务打标签,标识它们是否依赖于当前的游戏模式。也就是说,我们需要判断一个任务是否与当前激活的游戏模式绑定。只要我们知道这一点,在切换模式时,就可以决定是否需要清理这些任务。

我们的做法是:

  1. 在带内存的任务结构中添加一个标记字段,类似于 depends_on_game_mode,用于标记该任务是否依赖当前的游戏模式。

  2. 在创建任务时,通过 begin_task_with_memory 函数,我们将依赖标志作为参数传入,并将其保存到任务结构中。

  3. 在切换游戏模式的逻辑中,我们可以扫描所有活跃任务,只清除那些 depends_on_game_mode 为 true 的任务。

这是目前最简单有效的解决方案,虽然在某些情况下会造成轻微的阻塞(同步清理任务),但对于第一步实现来说是可接受的。后续我们也可以进一步优化,比如引入一些内存管理机制,避免同步阻塞或资源浪费。

接下来是具体实施步骤:

  • 修改 task_with_memory 的数据结构,加入 depends_on_game_mode 字段;
  • 在调用 begin_task_with_memory 时,传入是否依赖游戏模式;
  • 在任务创建时,将该值记录到任务本体;
  • 审查当前已有任务,标记哪些是依赖游戏模式的,哪些不是。经过检查后发现,音频、字体、纹理贴图等任务并不依赖游戏模式,它们的生命周期与模式无关,属于独立系统;
  • 唯一一个依赖游戏模式的是地形块任务(ground chunk),它会访问特定的模式状态,因此需要标记为依赖;
  • 有了这些信息后,当我们切换游戏模式时,就可以清理那些依赖模式的任务,确保不会有访问悬空数据的风险。

至此,我们完成了第一步的机制搭建,这种方式虽然基础,但已经解决了任务依赖问题,为后续模式切换过程中的资源清理提供了良好的起点。后续我们可以再进一步引入更灵活的异步管理与内存回收策略。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game.cpp 中修改 SetGameMode,添加 Platform.CompleteAllWorkNeedToWait 的机制,确保在模式切换前等待所有相关任务完成。

我们现在已经可以在清除游戏模式(game mode)时,采取最简单直接的方式来确保所有相关任务能够被正确关闭。虽然这种方式可能不是最优的性能方案,但对于当前的需求已经足够。

我们的方法是:


等待相关任务完成再清除模式

当我们调用 set_game_mode 并准备清空之前的模式数据(即清空 mode arena)时,我们会在此之前插入一个等待操作,确保所有“依赖于游戏模式”的任务都已经完成。这个等待机制的具体实现方法是使用我们已有的任务系统接口,比如:

PlatformCompleteAllWork(low_priority_queue);

这个接口会阻塞,直到所有提交到低优先级队列的任务完成执行。


如何判断是否需要等待

为了避免不必要的阻塞,我们不会总是等待所有任务完成。我们引入了一个标志判断逻辑:

  1. 遍历所有活跃的带内存任务;
  2. 检查每个任务的 depends_on_game_mode 标志;
  3. 如果存在任何一个任务标记为依赖当前游戏模式,则触发等待;
  4. 否则,不需要等待,直接切换游戏模式。

示意逻辑如下:

for (int i = 0; i < transient.task_count; ++i) {
    if (transient.tasks[i].depends_on_game_mode) {
        need_to_wait = true;
        break;
    }
}

if (need_to_wait) {
    PlatformCompleteAllWork(low_priority_queue);
}

修改调用链支持 transient_state

为使 set_game_mode 能够正确判断任务依赖,我们需要将 transient_state 传入其中。尽管这种参数传递方式有些烦琐,也许将来可以通过更高级的结构合并优化,但目前我们先按机械方式处理。

各个调用者中,比如:

  • play_title_screen
  • play_intro_cutscene
  • play_world

都需要补充 transient_state 参数,以便在切换游戏模式时能够访问当前任务状态并做出判断。


初始化逻辑调整

在游戏初始化时,我们需要保证 transient_state 已正确初始化。在主流程中加入判断:

if (game_state.game_mode == GAME_MODE_NONE) {
    play_intro_cutscene();
}

通过设置初始的 game_modeGAME_MODE_NONE,可以确保 play_intro_cutscene 的调用时机在一切准备就绪之后,避免初始化未完成时访问未定义状态。


小结

这套机制虽然基础,但足以解决在切换游戏模式时残留任务访问已销毁数据的问题。我们做到了:

  • 明确哪些任务依赖于当前游戏模式;
  • 在切换模式前安全地等待这些任务完成;
  • 最小化不必要的阻塞;
  • 合理调整初始化流程,保证状态一致性;
  • 建立一条清晰的调用链。

后续可以进一步优化内存管理结构,让这个机制更加灵活高效。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏进行验证,确认改动效果正确。

现在理论上我们已经一切就绪,整个流程应该可以顺利运行。当前的实现基本达成了预期的目标,虽然还有其他需要处理的小细节,但先验证核心逻辑是没有问题的。


当前验证流程概况

我们已经成功完成了从进入游戏、退出游戏、再重新进入游戏的流程测试。从目前的表现来看:

  • 状态在游戏模式切换过程中得到了正确管理;
  • 依赖当前游戏模式的任务在模式切换前被正确清理或等待完成;
  • 游戏流程能够稳定运行,无明显异常或资源泄露;
  • 再次进入游戏后能顺利加载内容,说明资源状态也被正确重建。

接下来准备处理的细节问题

虽然当前逻辑基本没问题,但还有一处额外的功能希望补充完善:

在某个特定的用户行为发生时,可能还希望触发一些附加的逻辑处理。

这个“行为”具体在当前内容中未展开,但从上下文推测,很可能是指在用户退出、切换、或再次进入游戏时,希望执行一些额外的视觉或逻辑效果,比如:

  • 渐变动画
  • 资源预热加载
  • 状态清空提示
  • UI 层级更新或过渡

这部分虽然不是核心逻辑,但确实会影响整体的用户体验和流程流畅度,因此后续会加入考虑和处理。


总结

目前整个系统已具备以下能力:

  • 能识别哪些任务依赖游戏模式;
  • 能在模式切换前等待或清理这些任务;
  • 状态切换过程中逻辑清晰,资源处理得当;
  • 再次进入游戏时状态能够正确重建;
  • 已准备好继续添加附加处理逻辑。

整体来看,基础结构已经搭好,接下来可以更安心地投入到优化细节和扩展功能中。

game_world_mode.cpp 中添加了 PlayTitleScreen 调用逻辑,确保从游戏世界退出时能正确返回标题画面。

当前逻辑运行已经大致完成,但还有一个我们希望进一步优化的细节:在退出游戏时,如果玩家没有任何角色存活,那么我们不应该进入中间剧情(inter cutscene),而是应该直接跳转回标题画面(title screen),这才更符合逻辑和用户预期。


目标行为说明

当满足以下条件时:

  • 所有玩家控制的角色(heroes)全部死亡;
  • 游戏模式切换流程开始(例如退出当前游戏或重新加载);

我们希望的行为是:

  • 不再进入中间过渡剧情(inter cutscene);
  • 直接回到标题界面(title screen)。

当前代码中逻辑分布位置分析

这一判断逻辑应该被放置在与“游戏模式结束”相关的节点处,比如在 world 模式处理的逻辑中,也就是:

  • 在 world 模式检测到没有英雄角色时触发;
  • 判断角色数量是否为零;
  • 如果是零,直接切换到 title screen;
  • 否则,仍然按照原本流程进入中间剧情。

也就是说,“是否跳过中间剧情”这一决策,需要与角色存活状态强相关,优先判断角色是否全部死亡,再决定下一个游戏模式。


具体实现思路

  1. 在 world 模式逻辑中添加判断:
    • 遍历所有角色;
    • 判断是否全部死亡或角色列表为空;
  2. 如果是,则:
    • 调用 SetGameMode 或相关方法切换到 title screen;
  3. 如果不是,则:
    • 继续执行现有流程(播放 inter cutscene)。

总结

我们通过逻辑判断是否有存活角色来决定退出游戏后的流程走向。如果没有任何角色存活,则直接回到标题画面,这更合理也更具用户引导性。同时该逻辑嵌入位置明确、影响范围集中,适合局部调整而不会引发系统性副作用。

这样做能够有效提升玩家体验,避免在失败状态下仍被迫进入剧情流程,从而保持流程的自然性与逻辑性。
在这里插入图片描述

在调试器中发现现在我们可能在清零一大块内存,运行游戏并使用 Debug -> Break All 功能验证该行为。

我们之前做了一些修改,其中之一是启用了在内存分配时自动清零(clear-to-zero-on-push),这个操作表面上是无感的,但现在注意到一个新的问题:在从标题画面进入游戏后,按下 F5 进行重载时,黑屏淡出动画的启动变慢了。这个行为与过去相比是新出现的,因此我们开始怀疑这个问题与清零有关。


问题分析

目前的内存分配策略中,当我们申请一个新的内存块时,系统会自动将其内容全部置为零。这原本是为了避免使用未初始化内存带来的不确定性,但有些时候这反而会带来性能负担,尤其是在处理大规模内存块的时候。

比如现在加载游戏过程中的某个操作正在分配一大块内存用于创建 ground buffer(地形缓存),由于启用了自动清零,这些 buffer 全部被强行清零,而这在很多情况下其实是不必要的。因为这些内存稍后很可能会被立即覆盖或者由图像数据填充,不需要先将其归零。


调试验证

为验证这个假设,我们使用了一个调试技巧:在运行游戏时使用 Debug Break All(中断所有线程),观察当前代码在执行什么。当中断点命中后,我们可以很明显地看到,在创建空 bitmap 的过程中,程序遍历了所有 ground buffer,并对它们执行了清零操作。这个操作是在申请这些内存之后立即进行的,并不是因为需要清零,而是因为清零变成了默认行为。

从栈调用路径来看,这一切是由我们引入的“自动清零内存”策略触发的。这也解释了为什么 F5 后延迟增加——系统正在无意义地清零一大片数据。


优化建议

为了修复这个问题,我们需要:

  1. 识别不应自动清零的内存分配: 比如 bitmap buffer 或者 ground buffer 这类分配后立刻会被覆盖写入的内存块。
  2. 在内存申请接口中提供标志位,用于显式控制是否执行清零操作。
  3. 在分配这类内存时传入不清零的参数,从而跳过这一冗余操作。
  4. 保留默认清零行为,但只对真正需要初始化为零的情况使用。

这样,我们既保留了清零机制带来的安全性,又避免了性能浪费。


总结

当前的问题是由于我们统一启用了内存自动清零机制,导致某些本不需要清零的大块内存也被强行初始化,从而带来性能问题。我们通过调试确认了这一点,下一步需要在内存分配系统中加入更细粒度的控制逻辑,确保只在真正需要时才进行清零,从而提升加载效率,避免不必要的延迟。
在这里插入图片描述

game.h 中引入新结构 arena_push_params,允许我们在调用 Push 系列函数时指定是否清零内存。

我们为了在圣诞假期前彻底解决这个内存清零导致性能下降的问题,决定修改内存分配(Push)逻辑,让它更加灵活、可控。目前内存系统默认分配时会自动清零,这在大多数情况下是安全的,但在处理大块数据(例如图像缓冲)时则可能浪费大量计算资源。因此现在要对 Push 操作进行重构。


优化目标

目标是将内存对齐方式(alignment)和是否清零(clear to zero)这两个设置项从硬编码中解耦出来,改为通过结构体传参,以实现更细粒度、更具可扩展性的控制。


方案设计

引入一个新的结构体,比如叫 ArenaPushParams(暂称为内存分配参数),它包含:

  • flags:例如 ArenaFlag_ClearToZero,表示是否需要清零;
  • alignment:表示内存对齐的方式,用于优化内存访问性能。

然后为这类结构体定义一个默认生成器函数 DefaultArenaPushParams(),该函数返回默认清零且默认对齐的参数。后续每次执行内存 Push 操作时都将使用 ArenaPushParams 作为参数,代替之前独立传入的 alignment 和 flag。


实现逻辑

新的 Push 实现流程如下:

  1. 每个支持 Push 的内存操作函数都接受一个 ArenaPushParams 结构体;
  2. 默认行为使用 DefaultArenaPushParams()
  3. 在执行 Push 操作时,读取参数:
    • 如果参数中包含 ArenaFlag_ClearToZero,则执行内存清零;
    • 否则跳过清零;
  4. 使用 params.alignment 进行内存对齐操作;
  5. 整个过程逻辑更清晰且支持扩展。

例如:

if (params.flags & ArenaFlag_ClearToZero) {
    ZeroMemory(...);
}

优化效果

这样设计有几个好处:

  • 灵活性提升:可以根据不同场景决定是否清零,避免非必要的大规模 memset;
  • 默认行为简洁:使用默认参数即可获得最安全的做法;
  • 接口统一:所有 Push 接口传入结构体,逻辑清晰,方便扩展;
  • 性能优化:对性能敏感的部分可选择不清零,节省大量 CPU 时间。

后续应用

现在已经重构完成,可以在如 GroundBuffer 等大型内存分配场景中使用 ArenaPushParams 显式指定不清零。比如:

ArenaPushParams params = { .flags = 0, .alignment = 16 };
PushMemory(arena, size, params);

这样分配时就会跳过清零过程。


总结

我们通过引入 ArenaPushParams 结构体,统一管理内存分配时的参数设置,成功将内存对齐与是否清零的控制权从底层逻辑中提取出来。这不仅提升了代码可读性和扩展性,还为后续进行更精细的性能调优提供了基础。最终目标是在不牺牲安全性的前提下,提高整体运行效率,尤其在进入游戏场景等涉及大量数据初始化的关键路径上。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game.h 中进一步引入 AlignNoClearAlign,用于更灵活地控制内存对齐与清零行为。

我们现在可以创建一套自定义的内存分配参数,使内存分配操作既可读又直观,清楚地表达每一次分配操作的具体意图。这套机制围绕 ArenaPushParams(内存分配参数结构)进行扩展,结合对齐方式和是否清零的设置,极大提升了代码的灵活性和清晰度。


自定义分配参数的创建

在默认分配参数 DefaultArenaPushParams 的基础上,我们扩展了新的自定义函数,比如:

  • AlignNoClear:指定对齐方式但不清零;
  • AlignClear:指定对齐方式并进行清零。

这些函数根据实际需求灵活构建参数结构,开发过程中调用者只需选择合适的配置函数,即可完成清晰明确的内存分配。


实现思路

每个自定义函数内部会创建一个 ArenaPushParams 实例,设置所需的对齐方式及是否启用清零标志。例如:

ArenaPushParams AlignNoClear(u32 alignment) {
    ArenaPushParams params;
    params.flags = 0;
    params.alignment = alignment;
    return params;
}

通过这种方式,我们可以快速构建出符合语义的内存分配逻辑。


可读性与灵活性提升

通过这种设计,调用 PushMemory 时不再是模糊的数值参数,而是直观的表达式,比如:

PushMemory(arena, size, AlignNoClear(16));
PushMemory(arena, size, AlignClear(16));

这样的调用方式在阅读和维护代码时,能立刻明白内存块将会如何被处理(是否清零、是否对齐)。


清理冗余逻辑

由于内存是否清零现在可以通过分配参数控制,因此可以移除之前专门处理清零的逻辑函数,比如 ClearBitmap() 等。过去调用时需要手动清空图像缓冲区,现在只需调用 PushMemory 并传入相应参数即可自动完成,无需额外清理函数。

例如:

bitmap.data = PushMemory(arena, size, AlignClear(16));

意味着自动对齐并清空该图像数据。


灵活配置选项

我们还考虑支持通过布尔值方式进一步简化选择逻辑,例如:

ArenaPushParams ChooseAlignedClear(bool shouldClear) {
    return shouldClear ? AlignClear(16) : AlignNoClear(16);
}

这样在运行时可以根据条件选择是否清空内存,大大增强了代码灵活性。


总结

我们成功实现了一种结构化、清晰、可读性强的内存分配参数机制,使内存管理更具语义性和可扩展性。通过引入 ArenaPushParams 结构以及一系列配置函数,我们摆脱了原本硬编码和重复清理逻辑,实现了对内存分配行为的完全控制。这一机制将广泛应用于如图像数据、音频缓冲等高频率内存操作中,为后续优化提供了坚实基础。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

将这些新功能应用到所有需要它们的 Push 调用中。

现在的任务是检查整个代码中所有使用内存分配(push)的地方,并逐一判断是否应该使用新加入的参数配置(比如是否清零、对齐方式等),确保这些内存分配操作充分利用我们新引入的功能,达到性能优化和逻辑明确的目的。以下是具体梳理和处理结果:


总体思路

逐一查找所有使用 PushMemory 或类似 push 操作的地方,根据上下文判断是否应清零或指定对齐,并决定使用 AlignClearAlignNoClear 或其他组合函数,提升效率同时保持逻辑安全性。


分析与分类

保持清零的情况(默认 AlignClear*

这些内存通常用于数据结构初始化,为避免脏数据残留,保留清零逻辑:

  • 地面缓冲区数组(Ground Buffers):体积不大,继续清零安全稳妥。
  • 游戏资源(Game Assets):初始状态明确,必须清零。
  • 场景模拟区(Sim Region):涉及大量状态变量,保留清零以防遗漏。
  • 调试系统结构体(Debug Info):不涉及性能敏感,清零更保险。
  • 世界创建(Create World)、实体块(Entity Blocks)、碰撞规则表(Collision Rules):这些结构可能包含很多未初始化字段,清零更稳妥。
  • 渲染组结构体(Render Group Structs):初始状态必须明确,保留清零。
禁用清零的情况(使用 AlignNoClear

这些内存块分配后立即被全部写入,清零是多余的开销:

  • 精准数据块(Precise Where):分配后立即完全覆盖,不需要清零。
  • 加载任务结构体(LoadAssetWork):所有字段都在后续赋值。
  • 整个资源存储(Asset Store):大体积结构体,每次重新分配前都会完全重写,清零开销极大。
  • 文件读取缓冲区(Asset File Arrays):数据读取后会立即填充整个结构,清零没有必要。
  • 渲染系统的缓冲区(Flush Buffers):每帧刷新,内容会被覆盖。
  • 最大缓冲区设置(Max Buffer Size):作为容量值传递,无需清零。
    在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

视情况处理的情况(默认保留清零)

部分结构虽然可能会被覆盖,但为防止遗漏某些字段或防止未初始化访问,选择保守处理:

  • 标签数量(Tag Counts)、资源计数(Asset Counts):结构较小且初始化过程可能不完整,保留清零避免风险。
  • 创建字体、位图(PushFont、PushBitmap 等):虽然不是性能瓶颈,保留清零更安全。
  • 播放剪辑数据(Cutscene 等):无性能敏感需求,清零无碍。
  • 简单碰撞信息结构体(Collision Layers Struct):结构明确但初始化分布不一,清零更稳妥。

遗留和排查中断点

  • 所有非真正意义上的内存分配(如函数名中含 push,但不涉及内存)的部分全部排除。
  • 部分调用者逻辑复杂(如从文件读取失败的情况下是否影响内存内容),默认采取不清零处理并观察后续效果。

整体评估与优化方向

  1. 性能提升:最大收益来源于跳过对大结构体的清零(如资源存储区、渲染缓冲等),避免了大量无意义的内存操作。
  2. 逻辑安全:对于不确定是否会完全初始化的结构体,仍然保留清零逻辑,保持代码健壮性。
  3. 代码可维护性:通过使用 AlignClear()AlignNoClear() 这类函数让分配意图一目了然,极大提高了可读性与未来可维护性。

下一步计划

继续向下审查代码中的 push 使用位置,确保全部更新为新参数方式,并根据实际运行中的性能数据进一步调优,可能还需加入运行时检查机制来标记未完全初始化的数据使用风险点,从而形成更完整的内存管理系统。

最终定义 NoClear 等辅助函数,完成这批内存操作优化。

现在我们已经大致理清了哪些内存需要清零、哪些不需要清零,接下来的目标是完善并补全缺失的配置函数,使得内存分配调用时可以清晰地选择是否清零、是否对齐等行为。其中一个尚未定义的配置是“不清零”(NoClear),现在要对它进行定义和实现,同时优化其它配置函数的行为,使它们更加通用和一致。


主要改动和设计意图

定义 NoClear 配置
  • NoClear 是一个简单的配置,表示内存分配时不进行清零操作。
  • 实现方式是基于默认的参数配置(DefaultArenaPushParams),然后移除清零标志(ArenaFlag_ClearToZero)。
  • 这种方式保证默认配置若在未来增加其他 flags,这些自定义配置仍然可以继承新特性,具有更好的前向兼容性。
ArenaPushParams NoClear()
{
    ArenaPushParams result = DefaultArenaPushParams();
    result.Flags &= ~ArenaFlag_ClearToZero;
    return result;
}

对其它配置函数的统一化处理

  • 比如 AlignNoClear(alignment)AlignClear(alignment),都可以通过调用 DefaultArenaPushParams() 并覆盖对齐和 flags 实现。
  • 所有配置函数都统一先从 DefaultArenaPushParams() 拷贝,然后只修改自己关注的部分(如清零与否、对齐值)。
  • 好处是:如果默认配置更新,比如加入新的 flags 或行为变更,这些配置函数无需改动,依然自动获得新的默认行为。

例如:

ArenaPushParams AlignClear(u32 alignment)
{
    ArenaPushParams result = DefaultArenaPushParams();
    result.Alignment = alignment;
    result.Flags |= ArenaFlag_ClearToZero;
    return result;
}
ArenaPushParams AlignNoClear(u32 alignment)
{
    ArenaPushParams result = DefaultArenaPushParams();
    result.Alignment = alignment;
    result.Flags &= ~ArenaFlag_ClearToZero;
    return result;
}

小修正

  • 在插入一些逻辑块的地方,有写法需要加括号以保证优先级正确,比如 InsertBlock(...) 这一处被指出存在括号缺失,已经修正。

优化策略说明

  • 此方案核心理念是将所有内存分配的“参数集合”统一封装在 ArenaPushParams 中,不再使用繁杂的参数传递或特例代码。
  • 使用函数式构造器(如 AlignClear, NoClear 等)来声明意图清晰的内存行为,提升代码可读性和可维护性。
  • 通过继承默认配置的方式来应对未来代码的扩展或变动,使所有相关配置保持一致性和鲁棒性。

结论

这一步补全并规范了配置函数体系,形成了一个统一的内存分配行为接口。今后在任何地方进行内存分配时,只需选择合适的配置函数,就能清晰控制是否清零、是否对齐等行为,同时也便于调试和维护。代码结构也更加稳健,具备了更强的扩展能力。

再次运行游戏,发现现在游戏运行明显更快、更流畅。

现在整体系统状态已经得到了明显改善。

通过这次对内存分配系统的整理,我们对 Arena 内存推送操作做了统一封装,并增加了更细粒度的控制,例如是否清零、是否对齐等参数。这种设计使得内存管理的表达更清晰、更易维护,代码逻辑也更易于阅读。

我们逐个检查了所有使用 push 操作的位置,根据具体用途决定是否需要清零,成功避免了一些不必要的大规模内存清零操作,特别是某些占用大量内存但会立即被覆盖的数据结构,不再执行多余的初始化,从而提升了整体运行效率。

完成这些修改后,我们对程序进行了测试,运行效果更加流畅,反应明显更快,性能得到了切实改善。这个优化本身计划已久,如今终于落实完成,达到了预期目标。

现在整个系统的内存分配逻辑更健壮、更可控,同时也为后续的优化和扩展打下了坚实基础。整体感觉状态非常好。

探讨了几个后续优化方向,包括将内存分配直接传递到底层操作系统、让调试系统使用独立的动态内存分配系统,以及优化渲染器的排序逻辑。

目前我们还有大约二十分钟的时间,正在考虑在这段时间里进行什么样的工作。

一个备选方案是让内存 Arena 的设计深入到底层操作系统的内存管理机制,实现一种可以动态增长的 Arena,即在 Arena 空间不足时,操作系统可以继续分配更多内存来扩展它。这种设计的动机有几个方面:

  • 支持大世界或大内存需求的运行场景:比如在 PC 上运行的大型游戏世界,借助虚拟内存,我们希望允许系统尽可能使用机器上的可用内存。
  • 适应平台差异:比如在内存固定的嵌入式平台(如 Raspberry Pi)上,仍然使用固定大小的 Arena;而在 PC 上可以使用可增长的 Arena。
  • 更好地分离调试系统的内存需求:调试系统不应该与游戏主内存竞争资源。它可以使用操作系统分配的独立内存池,不受限制地扩张,因为调试工具不会出现在最终用户的版本中。

不过,考虑到剩下的时间可能不足以完成该功能的实现,因此决定暂时不进行这项工作。

接着开始思考可以在当前时间内完成的其他小任务。一个候选项是修复渲染排序相关的问题。

在渲染系统中,存在一些与排序有关的问题,目前可能还没有真正实现排序机制。计划是先实现一个非常简单的排序算法(比如冒泡排序)作为占位,等到假期结束回来之后,再用更加高效的排序算法替换它。

这个任务的难点并不在排序算法本身,而在于需要重构 push buffer 的结构,以支持高效排序。这就需要考虑如何组织 buffer 中的数据,使其可以按需提取、排序、重新写入或处理。这部分结构设计可能比排序本身更复杂,所以也许并不适合在这段较短的时间内处理。

因此,目前处于一个评估阶段:在剩余时间里是否能完成一些简单但有意义的工作,还是应该等待更完整的时间段再着手较大的系统重构。还在权衡和思考中。

查看当前渲染系统的具体实现逻辑,分析其工作方式。

目前没有更好的想法要做什么,所以我们决定先看看当前的渲染流程,进行一些探索和分析。

目前渲染的整体结构是这样的:我们采用**瓦片化渲染(Tiled Render)**的方式,每帧渲染时会调用 TiledRenderGroupToOutput,这个函数负责把渲染任务分发到多个线程中。平台层则会负责实际调度这些线程并执行任务。每个线程会调用具体的渲染执行函数。

具体的渲染工作函数是 DoTiledRenderWork。这个函数是多线程渲染的核心,它会被各个线程调用以并行完成一帧的渲染输出。

在这个函数中有一个有趣的现象:我们在执行渲染时仍然使用了偶数行与奇数行交替的处理方式(即线程之间以交错的 scanline 分配渲染任务),这个逻辑最早是为了支持超线程(Hyper-Threading)优化设计的,但实际上一直没有真正落地和调优。

回顾来看,虽然渲染部分并没有进行特别深入的系统级优化,但通过一些简单的优化策略,我们已经能够在 1080p 分辨率下稳定运行在 60 帧每秒,这说明现代机器性能的确非常强劲,因此很多复杂的优化暂时并不是瓶颈。

不过,这种 scanline 交错的方式如今可能已经成为负担,有可能降低了性能,因为线程调度和缓存使用可能不再符合现代 CPU 的访问模式。因此考虑是否有必要保留这个逻辑,或者尝试做个实验——直接去掉交错处理,看看是否会带来更高的帧率。

在这里插入图片描述

在这里插入图片描述

考虑移除「扫描线(scanlines)」的概念以简化渲染逻辑。

当前渲染流程中存在一个令人发笑的遗留设计:我们保留了“奇偶行交错处理”的概念,即将渲染任务分配成奇数行和偶数行分别由不同线程处理。然而这个机制从来没有被真正使用过,也不太可能会被使用。

其原因主要在于多线程调度的不确定性——在任务队列中并不清楚哪个超线程(Hyper-Thread)会在何时取走一个任务,因此无法实现理想中的交错调度逻辑。此外,如果没有明确的同步机制,也很难保证渲染效率反而不会因为人为干预变差。

因此,这段逻辑看起来就显得很“愚蠢”,毫无实际意义,还可能带来不必要的复杂性。于是我们决定彻底移除这套奇偶行渲染交错逻辑,简化系统,避免对性能造成影响。

在做这些调整之后,我们计划测量当前的帧率表现,了解真实的运行性能。下一步准备在 Henley 环境中重新构建项目(build),以确保系统能够正确运行并观察性能指标。这个过程也是为了确认:在去除多余逻辑后,渲染系统是否变得更高效、响应更快。整体来看,简化渲染路径、移除无用机制,有助于进一步提升系统稳定性与可维护性。

启用 GAME_INTERNAL 宏以便启用内部调试逻辑。

我们现在打算按顺序构建并启动系统,同时开启内部调试模式(internal),因为据我们记得之前的调试快捷键(比如 F5)依然可以正常使用。当前的限制主要是因为我们还没完成字符串处理相关的热重载(Hot Code Reloading)功能,暂时不支持完整热加载。

接下来我们尝试构建项目时出现了一个问题:get_arena_size_remaining 这个函数被调用时没有传入合适的构造参数,提示“没有合适的构造函数”。根据上下文,这个函数需要一个对齐参数(alignment),而当前传入的是默认值 1

为了解决这个问题,我们打算使用一个带对齐设置的结构,比如用 no_clear(1)align_no_clear(1),也就是调用对应的内存分配器构造,传入所需的对齐参数。这种方式可以显式指定内存分配时的对齐方式,而不需要依赖默认行为。

总的来说,我们通过以下几点推进系统构建与调试:

  1. 确认内存分配器的使用方式:特别是传入对齐值时要使用合适的接口,避免出错。
  2. 临时绕开热重载相关功能:由于字符串系统尚未完成,热加载暂时不可用,但这不影响正常调试功能的运行。
  3. 保持调试环境正常运行:通过旧的 F 系列快捷键(例如 F5)继续支持调试工作流程。

这一部分的目标是保证构建过程可以顺利进行,为后续更复杂的系统更新奠定稳定的基础。

运行游戏,观察最后一帧的渲染时间。

我们现在完成了渲染流程的基本启动,从显示结果来看,在播放过场动画时渲染速度明显变慢,这是意料之中的情况,因为在该阶段我们存在大量的过度绘制操作(overdraw),占用了相当多的资源。

接着,我们决定临时移除掉用于帧率检测的“锁定查找”(lock lookup)部分,以便更准确地观察当前帧渲染表现。在代码中,我们定位到 handmade 文件夹下的相关逻辑,找到了帧率显示(frame timing)的实现部分。这一部分通过启用 real VBlank 支持来计时。我们之前是关闭了它的,所以现在手动启用了帧率显示功能。

当前测试显示的帧率大致在 33-36 FPS 之间,这个帧率只是大致参考,因为我们当前并没有进行实际的优化,也没有进行系统性的性能分析。因此这些数据不能作为最终性能评估的依据,只是用于验证更改后不会带来灾难性的性能下降。

我们设置帧率显示的主要目的是在修改逻辑(例如去除偶数/奇数扫描线交替渲染)后,快速验证其是否对性能造成了明显影响。因为那段逻辑(even/odd alternating render lines)目前并没有真正发挥作用,同时还可能带来额外开销,所以我们决定将其移除。

但在移除之前,必须确认这一更改不会误伤性能,因此帧率显示的作用就在于——在我们尝试更改后,能够看到帧率是否下降显著,确保我们不是因为理解错误或者未曾察觉的问题,导致帧率严重下滑。

总结如下:

  1. 当前渲染正常运行,过场动画期间帧率下降属于正常情况
  2. 启用帧率显示,当前帧率维持在约 33-36 FPS,用于参考
  3. 移除偶数/奇数交替渲染逻辑,因其未实际启用、可能造成性能浪费
  4. 在修改渲染逻辑前确保帧率稳定,防止误操作导致性能劣化
  5. 目前还未开始正式优化,只是为了验证现有结构的基础表现

接下来可以继续推进性能结构优化或进一步清理冗余渲染逻辑。

game_render_group.cpp 中移除了「奇数/偶数帧」的渲染逻辑,简化代码。

我们准备彻底移除之前实现的“偶数/奇数行交替渲染”逻辑,这部分原本是为了在多线程或超线程渲染时尝试减少线程之间的缓存行争用(cache line contention),但由于最终并未真正利用该机制,现在看来它反而可能带来了不必要的复杂性与性能开销,因此决定将其完全去除。

首先,我们定位到 DrawRectangleQuickly 函数,该函数是当前优化过的绘制矩形的方法,它接受一个参数 even,而我们现在要做的就是清理掉相关的 even 逻辑。

我们检查 even 的作用,发现它只是简单地控制绘制起始行的位置,如果处于“奇数”模式,它就会把起始 Y 坐标向下偏移一行。这种方式本质上是跳过偶数或奇数行的一种变通处理。但由于我们已经不再需要这种交替扫描处理方式,因此这些逻辑可以直接删除。

修改点如下:

  1. 移除对 even 参数的依赖:将所有传入 DrawRectangleQuicklyeven 参数删除;
  2. 处理循环中的行步进:原先由于跳行处理,需要以 2 * BufferPitch 的步长来推进绘制,现在只需要用 BufferPitch 作为步进;
  3. 删除与 even 有关的条件判断与偏移:包括任何 if (Even)if (!Even) 的判断逻辑;
  4. 检查函数定义和调用处的一致性:移除函数声明中的参数,同时更新所有调用该函数的地方,确保不再传入 even

调整后,循环可以逐行完整地绘制,不再跳行,步进统一,结构更加简洁。

此外,我们也验证了是否预定义了这个版本的 DrawRectangleQuickly,确认它是否为预编译优化版本,并确保函数签名在修改后仍然保持一致性。

总结如下:

  • 删除了不再使用的 even 逻辑;
  • 将渲染步进从 2 * BufferPitch 改为 BufferPitch
  • 简化了 DrawRectangleQuickly 函数的定义与调用;
  • 清理了相关冗余逻辑,统一绘制流程;
  • 为后续性能评估和渲染模块优化打下了基础。

这一更改能让渲染过程更加高效、结构更清晰,并消除了潜在的性能损耗点。下一步可以观察帧率表现,确认改动未带来负面影响。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏,确认移除后依旧渲染正常、效果良好。

我们完成了修改,现在来看看运行效果如何。

可以看到,渲染运行得非常顺畅,表现非常出色,整体效果非常漂亮。画面平滑,没有出现任何明显的问题或性能下降的迹象。这说明我们所做的对渲染流程的优化和重构是成功的。

特别是我们刚才移除了关于“偶数/奇数行渲染”的冗余逻辑,并统一了行步进的处理方式,使得绘制过程变得更加直接和高效。这一改变不仅没有引发任何渲染错误,反而让整个系统看起来更加整洁和可靠。

整体效果自然、无缝,可以说整个优化过程是成功的,达到了预期的目标。系统在功能和性能上都没有受到负面影响,反而显得更加强健,这次的修改非常值得肯定。

game_optimized.cpp 中将 Y 方向上的递增从 2 改为 1,提高精度或渲染密度。

我们注意到当前的渲染效果并不理想,说明之前的修改还存在问题。虽然最初看起来很顺利,但细节上还是出现了遗漏。

为了查找原因,我们决定放慢节奏,逐步排查问题。首先确认剪裁区域(clip rect)设置是正确的,因为偶数和奇数行的逻辑是单独处理的,与当前的区域无关,所以裁剪逻辑依旧保持正确。

渲染流程中,只涉及两个主要函数:DrawRectangleDrawRectangleQuickly。这两个函数理论上在去掉“偶数/奇数”逻辑后,应该只进行一次简单的 Y 方向递增,逻辑是直截了当的,没什么复杂的地方。

但我们意识到一个关键的错误:我们在其中一个函数里忘记修改 Y 坐标的递增方式,仍然保持了原来“每次增加 2”的做法。由于先前的偶数/奇数行分离渲染依赖这个步长为2的方式,现在已经取消了这种模式,步长理应回到每次增加1(即处理每一行),否则会导致跳行渲染,从而表现出错误或不完整的图像。

这个遗漏正是当前渲染异常的根源所在,修复这个逻辑后,渲染应该会恢复正常。我们接下来将修正这一点,确保 Y 坐标按照正确的步幅进行递增。

再次运行游戏,发现性能似乎又略有提升。

我们完成了对偶数/奇数行渲染逻辑的移除,并再次进行了测试。从直观效果来看,渲染表现并没有明显变化,整体显示结果与之前基本一致,看起来没有任何问题。

虽然我们没有进行精确的时间测量,但感觉帧率确实有轻微提升,说明去掉该逻辑至少在性能上没有造成负面影响。也就是说,即便没有明确的数据支持,从目前的观察来看,这个更改是安全的,可以保留。

我们认为继续使用原先的“交错渲染”方式(偶数/奇数分开渲染)并不值得,逻辑复杂而且无法带来实际性能收益,反而可能引起维护上的麻烦。去掉这部分内容之后,整体代码变得更清晰,也更直观。

当然,我们也不排除未来某些情况下这个交错渲染思路可能重新变得有价值,但目前而言,这种实现看起来只是个多余的负担。

总的来说,现在的判断是:去掉偶数/奇数逻辑是一个合理、稳妥的优化步骤,既简化了代码,又没有实质性性能损失。我们接下来将保持这一简化状态,继续向后推进渲染系统的其他优化部分。

思考如何更清晰地表示当前渲染系统到底在排序哪些元素。

目前,我们的渲染流程中所有图元的绘制顺序是按照它们被压入 push buffer 的顺序来进行的,也就是说,哪个先进入缓冲区,就先绘制哪个。但为了实现正确的图层顺序(例如前景遮挡背景),我们必须对这些图元进行排序,而不是盲目地按照顺序直接绘制。

为了解决这个问题,我们需要一种能够对图元进行整体访问并比较它们之间关系的方法。也就是说,我们必须拥有一个支持“随机访问”的图元列表,从而在渲染前对其进行排序,比如按照深度(z 值)或其他优先级信息排列。

现在 push buffer 中的结构是“紧凑打包”的,也就是说不同类型的图元大小可能不同,所以我们通过“基址 + 偏移”的方式向前推进。如果我们想对其排序,这种结构会带来不便,因为没法直接用统一接口比较它们。

所以我们在考虑两个可能的优化方向:


1. 统一结构体大小

如果所有的图元类型最终都可以转化为一个“固定大小”的结构体,那我们就可以把整个 push buffer 视为一个“数组”,这样可以原地排序。这种方式在实现排序逻辑时会更直接、方便,比如可以直接使用冒泡排序或其他快速排序方法。


2. 双阶段处理流程(更推荐)

  • 第一阶段:遍历整个 push buffer,判断每个图元落在哪个 tile(屏幕分块)中,并将其放入该 tile 对应的渲染队列(或数组)中。
  • 第二阶段:每个线程独立处理自己的 tile,先对 tile 内的图元列表进行排序(只排序这个 tile 所需的部分),然后再依次绘制。

这种方式更有吸引力,原因包括:

  • 每个 tile 的图元数量相对较少,排序成本低。
  • 各 tile 渲染互不影响,天然适配多线程架构。
  • 不需要改变 push buffer 的结构,不会影响已有渲染逻辑。
  • 可以在不影响主流程的前提下分步逐渐引入排序逻辑。

因此,最优方案可能是实现一种 tile 级别的预处理机制。我们在渲染前先“收集”出每个 tile 实际需要绘制的图元,构成一个待渲染队列,并对这些队列进行排序,再执行渲染。

这将让我们的渲染流程更具灵活性,并为后续进一步的渲染优化(比如图元合批、透明度处理等)打下良好基础。我们可以从构建 tile 层图元队列开始,逐步构建排序与绘制系统。这看起来是当前最具可行性的路线。

game_render_group.h 中为 render_group 添加 PushBufferElementCount 字段,用于追踪被压入渲染缓冲区的元素数量。

我们在考虑对渲染元素进行排序,为了实现这一点,首先需要一个地方来存储这些渲染元素的相关信息。幸运的是,我们已经知道最多会有多少个渲染元素,因此完全可以分配出一块固定大小的内存来保存这些数据。

在构造 push_buffer 的过程中,可以添加一个额外的计数机制,例如 push_buffer_element_count,每次有一个新的渲染元素被压入缓冲区时,该计数器就加一。这样一来,我们就能始终准确地知道当前缓冲区中到底有多少个渲染元素。

具体的实现方式如下:

  • 在调用 allocate_render_group 分配渲染组内存的时候,除了设置缓冲区的大小外,还可以将 push_buffer_element_count 初始化为 0。
  • 每当执行一次元素压入(push)的操作时,对 push_buffer_element_count 进行自增。
  • 这个计数器在每次渲染结束调用 end_render_group 的时候也会被清零,确保下次渲染时是全新的状态。

如此一来,在执行 detailed_render_work 的时候,我们就可以通过这个计数值提前知道本次渲染中有多少个渲染元素。接下来在构建工作结构(例如每个线程处理的 tile 对应的渲染数据)时,就可以分配出一块临时内存空间来保存这些数据,用于后续的排序操作。

这也就意味着,我们可以在每个 tile 所代表的渲染工作结构中,预先为排序结果留出内存区域,每个线程在工作前先把需要绘制的图元筛选出来存进这个区域中,再进行排序并最终执行绘制。

这个逻辑实现起来清晰、可靠,同时不会影响原有 push buffer 的使用方式,非常适合在现有系统基础上引入排序机制,为更复杂的图层管理和渲染流程打下良好基础。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

game_render_group.cpp 中引入新结构 tile_sort_entry,并应用于 RenderGroupToOutput 进行排序处理。

我们现在的重点是构建一个排序机制,用于在渲染流程中更好地控制渲染元素的顺序。具体来说,需要实现一套排序的中间数据结构和分配机制,使得每个线程在执行 tile 渲染时,都能临时性地拥有一块排序缓存空间用于排序工作。整体思路清晰,并且针对不同线程和渲染路径也做了初步的内存策略考虑。以下是详细整理和总结:


总体目标

  • 实现渲染元素的排序功能,用于按指定顺序进行输出。
  • 渲染时,不再按照元素写入顺序绘制,而是基于 sort key 来重新排序再绘制。

数据结构设计

  • 为每个渲染元素设置一个排序结构体(如:TileSortEntry),结构包含:
    • 对应元素的索引或指针
    • sort key
    • 在 push buffer 中的偏移位置等信息
  • 对每个 tile 或整体渲染调用,创建一块排序临时缓存区域,用于存储这些结构体。

内存管理设计

  • 利用临时内存(transient arena)进行排序缓存空间分配。
  • 因为已知最多会有多少个渲染元素,所以可以一次性分配足够大的空间:
    • PushArray() 之类的宏或函数,直接申请一段可排序的数组。
  • 结构体中新增一个字段:PushBufferElementCount,用于记录实际元素数量,便于分配空间。

渲染流程整合

  • 无论是普通还是 tile 渲染流程,都需要支持排序。
  • 所以在 RenderGroupToOutput()TiledRenderGroupToOutput() 函数中都必须执行类似的准备逻辑:
    • 将临时 arena 传入,用于后续分配排序缓冲区。
    • 进行 BeginTemporaryMemory()EndTemporaryMemory() 操作来清理使用的临时空间。

多线程 tile 渲染注意事项

  • 在多线程 tile 渲染中,每个线程应从自己的上下文或任务结构中分配排序所需临时内存,避免冲突。
  • 排序和绘制仅作用于当前 tile 中的元素,所以只需要局部排序而非全局排序。

调试与备用逻辑

  • 在 debug 模式下也调用了 tile 渲染流程,因此需要确认调试路径中也合理使用了临时内存。
  • debug 模式下可以灵活使用 debug arena 或 transient arena,因为这些只存在于当前渲染周期。

结论

  • 整体上,实现渲染元素排序的机制是可行且思路清晰的。
  • 已明确元素数量可知,因此可预分配排序缓冲区。
  • 各路径都需传入临时内存,以便安全分配排序空间。
  • 多线程场景下,每个任务各自处理排序内存,不会发生冲突。
  • 该方案既兼容现有架构,又具备扩展性。

如果需要进一步实现这套逻辑,可以继续拆分出:

  • TileSortEntry 的定义
  • PushRenderElement 的结构调整
  • 排序过程(如快速排序或稳定排序)的实现接口
  • 渲染时对排序结果的遍历与调用

需要我帮你具体写一下这块的伪代码或结构设计吗?
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏进行测试,验证这些结构和排序逻辑都正常运行。

目前为止的渲染系统改造已经完成了一个关键步骤:为每个 tile 的详细渲染工作分配了专门的排序缓冲区,这为后续实现排序渲染打下了坚实基础。下面是这段工作的详细总结:


当前阶段完成情况

  • 排序空间已就绪:每个 tile 在进行实际渲染前,都拥有一块独立的排序内存区域,可以存放需要排序的渲染项。
  • 架构合理:并未直接进入排序逻辑的实现,但当前的内存与数据结构设计已经为之后的排序打通了路径。
  • 不会中断流程:这个阶段的结束点自然且平稳,没有留下一些未解的问题,也没有逻辑断点,适合作为阶段性收尾。

后续渲染流程的准备

  • 可以在接下来的逻辑中,将当前的渲染项数据转换为排序项(如:填充 TileSortEntry)。
  • 排序项结构中会包含渲染项在 PushBuffer 中的偏移位置和对应的排序 key。
  • 一旦排序项数组填充完成,就可以执行排序操作,然后按序进行实际绘制。

整体渲染流程的演变

  1. 原始流程:渲染元素按写入顺序直接输出。
  2. 当前状态:准备好排序阶段的内存和结构,但未执行排序。
  3. 未来目标:对元素排序后再输出,实现更加合理或优化的渲染顺序(例如按层深度、透明度等排序)。

后续任务方向(待完成)

  • 构造排序项填充逻辑。
  • 设计排序算法(可能是快排或稳定排序)。
  • 替换原有输出流程,改为按排序项输出。
  • 视情况对 debug 渲染路径做适配。

总结

我们已经成功为渲染排序功能搭建好了基本框架和必要的内存结构,接下来的工作就是将渲染项转换为可排序结构,执行排序后再进行输出渲染。整体架构清晰可控,后续实现将水到渠成。虽然还需一定的实现时间,但方向明确,逻辑上已非常稳固。

需要我现在帮你规划或编写排序部分的实现吗?比如伪代码或者函数接口设计?

在这里插入图片描述

在这里插入图片描述

大小不够吗
在这里插入图片描述

“扩展型 arena 的实现大致上会是怎样的?”

这是对如何实现一个可扩展内存分配器(expanding arena)的详细总结:


实现可扩展 Arena 的基本思路

核心思想非常简单:在尝试分配内存失败(即当前 Arena 容量不够)时,不再报错或断言(assert),而是动态向操作系统申请新的内存区域。

基础实现逻辑:
  1. 判断容量是否足够
    • 原本的做法是在容量不足时直接断言失败;
    • 现在改为:如果容量不足,进入一个分支逻辑。
  2. 向操作系统申请更多内存
    • 使用平台相关接口(如 VirtualAllocmmapmalloc 等)来获取额外内存;
    • 将新分配的内存连接到当前 Arena;
    • 更新 Arena 的容量和指针信息。

这样一来,Arena 就可以自动根据需要扩展,避免容量限制带来的错误或人为干预。


临时内存系统(Temporary Memory)的复杂性

相比之下,实现临时内存(Temporary Memory)系统时就需要更多处理,因为它需要**可回退(rewind)**的能力。

临时内存的特点:
  • 会在某个点开始分配临时空间;
  • 使用完之后可以一键回滚,释放所有中间的分配;
  • 因此需要保存一个快照(如 base pointer 或 offset)以便复原;
  • 在可扩展 Arena 上实现这种“回滚”行为就涉及更多结构和数据维护。

实现简洁性

  • 主体逻辑非常简单,是一种极易实现的内存分配策略;
  • 扩展性强,特别适用于不需要频繁释放的线性分配模式;
  • 唯一需要更细致处理的就是与临时分配的配合机制。

总结

可扩展 Arena 实现极其直观:判断容量不足就申请新内存。唯一带来一些逻辑复杂度的是“临时内存支持”,因为需要保存和恢复状态。但整体而言,整个系统设计上是非常直接、高效并易于控制的。

需要我现在给出具体的代码框架或伪代码吗?可以直接用 C 或 C++ 写出一份简单可扩展 Arena 的例子。

“你提到的底层内存 arena 是不是其实就类似 malloc / VirtualAlloc 的机制?”

这是关于底层内存管理中 Arena 和系统默认分配器(如 mallocVirtualAlloc)之间差异的详细总结:


内存 Arena 的本质

内存 Arena 是一种最简单、最直接的内存分配模型,通常用于性能敏感、可控生命周期的内存分配场景。其特点如下:

  • 线性分配:从一块连续的内存区域中按顺序分配;
  • 不可自由释放:通常不支持任意顺序的释放操作,只支持一次性全部释放或按栈结构回滚;
  • 极高效率:由于无需处理碎片、合并、重用等复杂逻辑,性能非常高;
  • 非常适合游戏或渲染等临时性数据构建场景

mallocVirtualAlloc 的区别

相比之下,像 mallocVirtualAlloc 则是系统层面的更复杂内存分配机制,它们具有以下特性:

  • 允许任意顺序的申请与释放
  • 支持碎片合并(memory coalescing),保持内存利用率;
  • 内部维护分配表与元信息,以便跟踪每一块内存;
  • 相对慢很多,因为需要处理通用性和复杂情况;
  • malloc 属于 C 运行库的一部分,底层可能调用 VirtualAlloc 或其他平台 API;
  • VirtualAlloc 是 Windows 系统调用,直接控制虚拟内存页的分配。

核心对比总结

特性 内存 Arena malloc / VirtualAlloc
分配顺序 线性顺序 任意顺序
释放方式 一次性全部释放或回滚 任意释放
内部逻辑 极简、无碎片管理 有元数据管理、合并、重用逻辑
性能 极高 相对较慢
控制性 程序员全权控制 由系统/运行库管理
应用场景 临时构建、渲染、加载阶段 通用应用分配需求

总结观点

内存 Arena 并不是 mallocVirtualAlloc 的简单封装,而是一种更原始、更轻量、更高效的分配策略。它牺牲了灵活性和碎片管理能力,换取极致的分配速度与可预测性,非常适合底层性能优化、临时工作空间等场景。

需要我给你画一张对比示意图,或写一段 Arena 实现代码吗?

“我刚买了 K&R 这本书来学 C,还有推荐的资源吗?我对网上的资源有些保留。”

我们提到了 Simon Denson、Cane、Butler 这几个相关的资源,但除了这些之外,没有找到其他特别有用的学习资料。我们对网上的信息向来保持谨慎态度,不会轻信网络上的内容。因此,对于这类问题,觉得自己可能不是最合适的人选来提供判断或推荐。

比较好的选择是直接去请教正在直播的那群人,那边可能会有更具权威性或者更有经验的人来给予建议。总体而言,我们更倾向于基于实际经验或可靠来源来判断,而不是随便采纳网络上的说法。

“Interlaced(交错)和 Interleaved(交织)是一个意思吗?”

我们讨论了 “gasifier is interlaced” 中的术语差异,明确了 interlacedinterleaved 虽然听起来相似,但含义上存在显著区别。

interleaved(交错) 是一个更通用的术语,表示两种或多种事物在操作或结构中以交替方式排列或执行,比如内存访问、数据排布等领域都可能用到。只要是交替进行的,就可以称作 interleaved,应用非常广泛,不局限于特定领域。

interlaced(隔行扫描) 则是一个非常特定的术语,主要用于显示技术领域。它描述了一种图像扫描方式:在一个帧周期内,先绘制所有偶数行(或奇数行),然后再回到顶部绘制剩下的奇数行(或偶数行),以此完成完整图像的显示。这种方式主要用于早期的电视和显示器技术,用于降低带宽需求。

因此,虽然这两个词都带有“交错”之意,但 interlaced 是 interleaved 的一种特定形式,专门用于描述显示设备如何扫描图像;而 interleaved 是更抽象和广义的概念,可以用于任何需要交替处理的情境。

“在 game Hero 暂停期间我应该做些什么?”

我们在 Hammy 休息期间,有了一段思考接下来该做什么的讨论。当前已经为我们安排了一个具体的任务目标:实现排序功能。相关的准备工作都已经完成,所需的结构和支持也都已经就绪。

接下来的大致计划是这样的:

  • 利用这段空档时间专注完成排序功能的实现,任务已明确,目标清晰。
  • 预计花费时间大约两周左右,可能会稍短一些,但两周是一个比较稳妥的估计。
  • 在这段时间里,由于暂时不能在那台机器上继续编程,我们就可以集中精力推进项目,不受干扰。
  • 目标是在回来并恢复编程之前,让游戏的基础功能能够运行起来,具体而言,是能够完成排序流程并整合进当前渲染系统中。

这是一个十分适合独立攻坚的阶段,时间充足,任务聚焦,具备良好的实现条件。只要按部就班推进排序逻辑的开发和调试,我们完全有能力在回来之前完成这项关键功能,为后续开发打下坚实基础。

“你计划用哪种排序算法?”

我们打算从最基础的排序算法开始实现。首先会选择冒泡排序,这是最简单、最基础的一种排序方式,几乎可以说是“最笨的那种”。不过这正是它的优点——结构清晰、实现直观,适合作为排序流程搭建的起点。

接下来,会尝试实现一种更复杂的排序算法,比如归并排序(Merge Sort)。这是经典的高效排序方法,尤其适用于需要稳定排序、或者数据量较大的场景。

这么做的原因在于:排序在性能上的表现并不是绝对的。有时候,看起来简单、低效的排序算法(比如冒泡排序),在特定的上下文中反而比“聪明”的排序方法更快。这可能跟数据的分布特性、缓存命中、排序规模等各种因素有关。

因此,我们会用实验的方式去比较不同排序算法的性能,而不是先入为主地选择看起来更先进的方案。通过这种方式,不仅能够确保选择最适合当前场景的排序方法,也能够为后续的系统优化积累更多真实的性能数据。

可以预见,这个过程可能会有些“疼”(性能测试和调优通常伴随着反复试验),但这是必要的一步。我们已经准备好接受这个挑战。

“为什么用 Windows 应用程序(WinMain)而不用标准的 main 函数?还是得手动注册窗口类并创建窗口?”

我们选择以 Windows 应用程序(使用 WinMain 作为入口点)而不是控制台程序来运行,是出于界面专业性和用户体验的考虑。

如果将应用构建成控制台程序,每次运行时,系统都会自动弹出一个控制台窗口。这个控制台窗口会始终存在,无法避免地出现在用户眼前,这在发布游戏或正式应用时看起来非常不专业。

虽然有一些方法可以隐藏这个控制台窗口,比如运行时查找并手动隐藏它,但那是一种多此一举的做法,尤其是在我们根本不需要控制台窗口的情况下。既然我们本身不打算用它来输出调试信息或接收输入,那就完全没有必要使用控制台程序入口

因此,从一开始就直接将程序构建为标准的 Windows 应用程序是最合理的选择。这样,用户打开应用时只会看到我们的主窗口界面,不会出现多余的黑色控制台窗口,整体效果更干净、专业,也更符合最终发布的需求。

“当初为什么会加入奇偶帧交替渲染这种机制?”

之所以当初加入了交错渲染(interleaving),是因为我们当时在考虑是否可以利用超线程来提升性能。具体设想是:让每个超线程渲染交替的扫描线(比如一条线程渲染偶数行,另一条渲染奇数行),这样它们在同一个核心上运行时,就能共享缓存(cache),提升数据命中率,进而提高效率。

理论上这个想法听起来是合理的,因为两个线程在交替渲染时可能会访问相邻的数据,而这些数据如果都能保留在缓存中,就能减少内存访问延迟,提高整体性能。

然而,实际操作中发现存在一个关键问题——无法强制控制线程在特定的方式下调度运行。即便设计好了交错访问的逻辑,也无法保证两个线程会按照预期在同一个物理核心上交错运行。线程的调度是由操作系统决定的,我们无法精确干预,从而导致这个优化无法真正发挥预期作用。

所以最终来看,这种交错渲染的做法并没有带来实质性的性能收益,反而可能让渲染逻辑更复杂,变成了一种不必要的尝试。简而言之,这个优化点在缺乏调度控制的情况下是没有实际意义的冗余设计

“每一帧都重新生成渲染线程是不是开销过大?”

每帧都重新创建渲染线程会带来较大的开销,尤其是在扫描内存时,因为线程的创建和销毁本身就需要一定的时间和资源。如果每帧都重新生成渲染线程,不仅会导致更高的资源消耗,还会增加线程调度的负担,影响整体性能。因此,为了避免这种额外的开销,我们选择保持渲染线程的存在,而不是每帧都重新生成。这样可以减少不必要的开销,提高性能。

“你做游戏时是不是总是从软件渲染开始?”

在游戏开发中,通常不会从软件渲染开始。之所以选择使用软件渲染,是为了让观众了解图形渲染的工作原理。通过手动编写渲染器,可以帮助人们更清楚地理解图形如何实际运作。如果仅仅依赖现代的图形API(如图形库),而没有深入了解其底层实现,就很难真正理解图形是如何处理和显示的。过去,只有那些早期从事图形编程的人,才有机会深入了解这些底层细节。因此,通过这种方式,可以让更多人获得对图形渲染过程的深入理解。

“用 GDI 做动画时,如果不用 vsync,还能做到非常平滑吗?”

目前,关于在没有V-Sync的情况下实现平滑动画并不确定。随着Windows操作系统的不断发展,和过去的不同,现在对图形处理的了解和技术手段也有了很大的变化。对屏幕刷新和合成器(例如箭头合成器)并没有深入探索过,因此对于这种特定情况的处理方法并不清楚。

对于游戏开发来说,通常来说,这并不是非常重要,因为游戏最终仍然需要通过GPU来处理图形,即使是使用软件渲染,仍然需要通过OpenGL等图形API来确保能够进行垂直同步(V-Sync)。即使没有硬件加速的渲染过程,依然需要一些代码来通过OpenGL实现垂直同步。因此,目前对如果不采用这种方式会发生什么,并没有太多了解。

“如何在纯 C 中 forward declare 一个 struct?‘typedef struct foo {…} foo;’ 要怎么提前声明?”

关于如何在C或C++中前向声明结构体,首先需要明确一点:在C语言中,结构体的前向声明是很常见的做法,但如果要为结构体使用 typedef,通常需要一定的步骤。

要前向声明一个结构体类型并使用 typedef,可以按照以下方式进行:

typedef struct MyStruct MyStruct;

这种方式告诉编译器有一个结构体类型 MyStruct,但它的实际定义会在稍后的代码中出现。这样可以让你在函数声明和指针操作中使用 MyStruct,但还不需要定义它的具体内容。

完整的定义会在稍后的代码中出现:

typedef struct MyStruct {
    int a;
    float b;
} MyStruct;

如果尝试直接用 typedef struct 来前向声明和定义类型(例如 typedef struct MyStruct { ... } MyStruct;),它通常会导致错误,因为编译器需要结构体的完整定义来完成 typedef

“有没有优化方案可以避免混合透明像素?”

目前在渲染过程中,并没有对透明像素进行任何优化。当前的情况是,计算机性能非常强大,因此即使在没有优化的情况下,渲染速度仍然能够达到每秒 30 帧。实际上,渲染时,背景中的每一部分都被绘制了多次,可能多达十层重叠,这意味着每个像素可能会被多次重复计算和渲染。

如果需要提高效率,实际上可以通过优化显著提高渲染速度。通过消除不必要的重复绘制和优化透明像素的处理,渲染速度可能可以提升四到五倍。然而,即便没有这些优化,现代计算机的速度仍然足够快,能够确保即使是复杂的场景和动画也能顺利运行,而不需要做额外的工作,这种性能的提升令人惊讶。

“一旦实现了排序功能,你是否打算让渲染器从前往后绘制?”

在排序实现之后,是否会改变渲染顺序,改为前到后的绘制方式,是一个值得考虑的问题。的确,这个方法并不坏,之前也讨论过这个想法,并且在某些情况下,可能会进行一些组织和调整。虽然目前没有明确的决定,但这个问题值得深入思考。

“我写一些后台程序不想出现窗口,所以用 WinMain 再把窗口隐藏。如果用 main 启动,有没有办法让控制台也隐藏,像任务管理器里都看不到?”

询问的是是否有办法在Windows应用程序中使用控制台并将其隐藏,尤其是隐藏在任务管理器等地方。对于这个问题,询问者似乎不确定具体想要隐藏的是什么内容。

“可以简单介绍下你们当前的内存管理方式吗?比如 PushArena / BeginTempArena 等。”

目前内存管理的方式非常简单,使用的是推送内存区(push arena)和临时内存(temporary memory)等机制。内存管理本身并不复杂,虽然手动管理内存需要编写一些代码,特别是在流媒体(stream)中,可能需要较多的工作,但一旦实现了这些基础,管理内存就变得相对容易了。大部分时候,内存管理几乎可以忽略不计,只有在特定的几个地方才需要特别关注内存的分配与管理。

黑板讲解:「内存 Arena 管理系统」

目前的内存管理方式非常简单。我们首先分配一块内存,假设是1GB,用于游戏的内存需求。然后,这块内存会被划分成不同的区域,比如一部分用于资产(assets),一部分用于游戏本身等。

我们不直接将整个1GB的内存分配给游戏,而是在启动时将其划分成不同的块,这些块称为“永久内存区”和“临时内存区”。接下来,当内存被分配时,我们就开始往这些块中“堆积”数据。具体来说,内存就像一个大的缓冲区,从空开始,我们向其中不断添加数据。

内存管理的关键在于“堆栈”式的操作。每次添加新的内存块时,它会直接“压入”栈中。当处理临时内存时,我们会记住当前栈的位置,使用这部分内存后,回到之前的栈位置,仿佛这部分内存从未被使用过。这样新的内存请求会覆盖之前的内容。

总体来说,内存管理非常简单,只是不断推送和回退栈指针而已。没有更复杂的操作,只有这个最基础的过程。


网站公告

今日签到

点亮在社区的每一天
去签到