游戏引擎学习第230天

回顾并为今天的内容定下基调

今天是我们进行“排序”工作的第二天。昨天我们在渲染器中实现了排序功能。这其实是从一开始就知道必须做的事情,只是一直没有合适的时机。而昨天终于迎来了这个时机,不知道为什么,可能就是突然有了冲动和想法,所以我们就动手去做了。

昨天我们实现的是一个非常简单的排序方式。虽然它可能存在一些性能上的问题,比如在某些情况下运行时间可能不太理想,但目前还不容易下定论,我们之后还会继续探讨这个问题。

实现这个简单排序的时候,我们意识到,不管用什么样的排序方式,我们都必须有一个叫做“排序键”的东西。这个排序键是用来决定在渲染时,哪些元素应该出现在其他元素之前的关键。我们确实添加了排序键,但当时并没有太深入思考,只是使用了一个标准版本,目的只是为了先把排序逻辑跑通。

现在排序的功能已经能正常工作了,是时候仔细看看这个排序键了。我们需要构建一个更合适的版本,让它更好地服务于我们实际的渲染需求,或者说能够支持我们想实现的各种功能。不管是从功能性还是设计合理性角度,我们都需要进一步改进这个排序键。

黑板:用于渲染的排序键(Sort Key)

我们来仔细讨论一下渲染中的排序键问题,也就是 sort key 的设计思路。

目前我们面临的一个核心问题是:我们在渲染的时候,其实是需要以两种不同的方式对物体进行排序的。这种需求来源于我们想要在屏幕上正确地呈现“深度感”,尤其是在一个类似 2.5D 的渲染环境中,物体的绘制顺序会直接影响视觉上的层次关系。

第一种排序需求是最直观的:我们希望画面中的物体,按照它们距离摄像机的远近来排序,也就是从“后”往“前”绘制。比如画面中有几棵树,站着的角色走在这些树中间,我们当然希望先画最远的那棵树,然后再画离我们近一点的,最后画最靠近摄像机的那一棵。这样画出来的图像才符合我们的视觉逻辑。

这个顺序在目前的实现中是通过物体在屏幕上的 Y 坐标来判断的。因为在俯视角度下,一个物体的 Y 越低,它越靠近我们;Y 越高,它越远离我们。所以基于屏幕空间的 Y 值来排序,是实现这种“伪深度感”的一种方式。

但事情并没有那么简单。如果场景中存在“高度差”,比如我们设计了一个有上下两层的建筑物,或者楼梯、洞口这类结构,使得玩家能看到上下两层同时存在的内容,那就会出现新的问题。

举个例子:如果我们在上层放了一个箱子,在下层也放了一个箱子。从纯粹的屏幕空间 Y 坐标来看,下层的箱子可能更靠近屏幕底部,也就是更靠近摄像机,所以会被认为应该最后绘制。但实际上,它处于下层,应该被上层的箱子遮住,换句话说,应该先绘制它,然后绘制上层的那个箱子。

这种情况下,单纯依赖屏幕空间 Y 值的排序方式就失效了,因为它不能表达“高度上的遮挡关系”。所以我们必须引入 Z 值(即场景中的高度)作为排序的第二个维度。只有同时参考 Y 值和 Z 值,我们才能更准确地确定一个物体的绘制优先级。

此外,还有一个在单层平面上也会遇到的问题:地面图块(ground chunk)和站在上面的角色。即使角色在地块的最远处,从逻辑上我们也希望先绘制地块,再绘制角色,角色永远不会被自己脚下的地块遮住。但是如果我们只用 Z 值排序,而地块和角色的 Z 值是一样的,那排序结果可能就会错误地让地块覆盖了角色。

所以,从根本上说,我们需要一个更复杂、更精细的排序键。它必须能综合表达出屏幕空间的 Y 值和世界空间中的 Z 值,还要处理各种重叠与遮挡逻辑。为此,我们可能需要改变我们目前对 Z 的理解方式——不仅仅把 Z 当作一个几何意义上的高度值,还要赋予它更多“语义上的权重”。让 Z 成为我们排序中更具代表性和决定性的因素之一,而不仅仅是一个数字坐标。

综上所述,排序键的设计在渲染系统中至关重要,简单的做法在面对复杂场景时已经不够用了。我们需要一个更加智能的排序策略,它能够兼顾平面上的深度感和层级上的遮挡逻辑,真正为我们的 2.5D 渲染需求服务。

黑板:将 Z 轴纯粹作为语义处理

我们需要做的一件重要的事情,是让 z 值完全变成“语义化”的,而不是一个连续的数值。之所以这样说,是因为无论我们如何微调 z 的具体数值,都无法确保在所有情况下都能正确地渲染出我们期望的前后关系。唯有一种方式是稳定可靠的,那就是明确规定:“地面图块永远在地面上的实体之前绘制”。这是一条强硬的逻辑规则,我们必须接受它。

所以我们得更严肃地考虑 z 值的设计。我们可以引入一种语义上的 z 分层机制。例如,把地面图块的 z 定义为 0,站在地面上的物体定义为 z = 1,然后如果有其他东西叠在这些物体上,比如楼上物体,可以定义为 z = 2。

不过即使是飞行的物体,只要它们仍然在这个地面层(这个“plane”)上,它们的 z 也应该还是 1。只有当它们明确地属于更上一层的空间结构,比如楼上,才能算作 z = 2。而更高的楼层继续往上叠加:z = 3、z = 4……以此类推。

同时,如果我们存在地下结构,那么地下一层可以是 z = -1,再下一层是 z = -2,以此向下扩展。也就是说,z 值在这个设计中完全不代表真实的高度,而是一种明确的“层级关系”,完全是语义驱动的,不与物理坐标或其他连续数值挂钩。

这样做的好处是明确而清晰:z 值只用于表达谁应该在谁上面,谁先被渲染。这种逻辑不会被真实位置的细微偏差打乱,也不会受到实体之间轻微高度重合的影响。

更重要的是,这其实也是我们更倾向于的方向。我们曾尝试将整个系统设计成真正的三维,但我们一次又一次地发现,这种全 3D 的处理方式,在本质上并不适合我们的游戏结构。我们遇到的很多渲染或遮挡问题,都是源于尝试用一个完全 3D 的系统去处理一个根本上是 2.5D 的游戏。

所以我们最终需要某种“有节制的回退”:我们不再坚持一个真实的三维坐标系统,而是把 x 和 y 当作地面的平面坐标,而 z 则只是用来表示“处于哪个层级”。如果一个角色浮在空中,它只是 z = 1 上的悬浮状态,它不会产生任何透视变形,不会因为靠近或远离摄像机而缩放。

而真正影响视差、遮挡关系的是 plane 值。这个“平面值”才是决定透视效果的关键——比如如果我们有一个楼上的层级,它的 plane 会比地面层高,这个 plane 值才是让物体有视差的主要参数。

我们之所以一开始不采用这样的设计,是因为我们希望角色能够“连续地”上下移动,比如在楼梯上平滑移动,不是突然跳到另一层。但是我们已经实现了这种效果,也验证了它在技术上是可行的。所以现在我们可以放心地切换到这种“语义化层级 + 平面透视”的模式。

总之,我们将 z 从一个真实数值转化为“语义层级”,配合 plane 进行透视和渲染处理。这是一个更合理、更符合实际渲染需求的方向,可以带来更清晰的绘制逻辑,也避免了我们在之前尝试全 3D 模型中遇到的各种复杂问题。

game_world_mode.cpp:禁用 GroundBuffer,运行游戏并演示实体在楼梯上的渲染情况

我们目前已经成功实现了在不同楼层之间的连续移动,而且现在由于引入了排序逻辑,渲染上也运作得很好。可以清楚看到上层的房间正确地显示在下层房间之上,各种视觉上的遮挡和层次关系都正确展现出来。这一切看起来都没有问题。

但问题随之而来。一旦引入了“可以平滑过渡楼层”的机制,就不可避免地带来一系列新的挑战。比如,当一个角色正好站在两个楼层的中间位置时,我们没有一个特别明确的机制去判断这时候应该显示什么、隐藏什么、优先绘制哪一层等。没有一个确定的规则或系统来应对这种“夹层状态”。

虽然对玩家角色来说我们可以用特判的方式处理,因为只涉及一个对象,相对简单。但如果我们要支持所有实体,比如怪物也能在楼梯上飞上飞下、穿越楼层,这样的系统就会变得极为复杂。

如果我们真的让所有实体都能自由上下穿梭,那么渲染系统几乎就不得不始终维持在完全的 3D 模式下。可问题是,我们的引擎并不真正拥有完整的 3D 模型。渲染系统需要在缺乏准确三维信息的前提下,不断地“猜测”和“推理”当前应该怎么渲染,从而导致维护难度大大增加。

所以我们陷入了一个两难的局面。我们目前的方向,确实是越来越接近需要做出明确决策的阶段:要么进一步开发“真实 3D”逻辑系统来支持这种上下穿越的复杂情况,要么就限制游戏机制,让角色只能在语义层级内跳跃,不再追求那种连续的上下移动效果。

与此同时,排序键的问题也已经暴露了这一现象的重要性。我们已经处于一个临界点,必须思考如何将渲染系统与游戏逻辑融合起来,解决由此引发的所有边界状况。

一种潜在的解决方法,是我们可以让实体拥有完整的三维位置(包括连续的 z 坐标),但在计算排序键时,把这个 z 值“吸附”或“映射”到最近的语义楼层上,从而简化排序逻辑。例如:当角色刚开始踏上楼梯时,我们可以立即将其排序键提升到上层楼层所对应的 z 层级,从而确保他能遮挡或被遮挡的关系是正确的。

当然,简单的“吸附”并不完美,有些情况下可能无法产生理想效果。一个改进方式是使用“偏向上层”的吸附逻辑,例如使用向上取整(ceiling)方式,并稍微做一点偏移调整。这样做的好处在于,一旦角色稍微踏入楼梯的上升方向,就能立即获得上层的遮挡优先级,从而避免出现渲染穿插的问题。

但这仍然只是一个尝试性的解决方向,我们并不确定这种方案在所有情况下是否有效。更进一步,还需要根据不同的场景、对象交互方式,以及渲染需求进行细致调整。

总结来看:

  • 实现连续上下楼层移动是可行的;
  • 渲染排序在当前逻辑下表现良好;
  • 最大的问题来自于中间状态的判定;
  • 只能靠“语义层级 + 偏向吸附”来处理;
  • 如果要全体实体支持这个系统,就必须解决排序与渲染中的复杂遮挡关系;
  • 我们可能需要开始从引擎开发转向具体游戏机制设计,从而逼近解决这个系统性问题的答案。

当前这个排序系统就是我们进入下一个阶段的前哨。未来的逻辑、设计、机制,都要在此基础上融合展开。
在这里插入图片描述

黑板:关于如何渲染在楼梯上的实体

我们当前面对的问题并不是在无的放矢,而是一个确实非常棘手的挑战——尤其是在处理伪 3D 空间、楼层过渡、排序渲染等交织在一起的系统时,这类问题是结构性的。尤其在角色位于楼梯或楼层边界等“灰色地带”时,渲染和遮挡逻辑变得异常复杂,没有一个绝对清晰的规则可以覆盖所有场景。

举个具体例子,比如有一个楼梯从上层延伸到下层。角色从下层靠近楼梯时,角色肯定会被上层结构所遮挡;但当他走上楼梯,头部露出地面时,情况就变得模糊了——这时他的头可能应该遮挡某些上层的物体。这说明排序逻辑不能只看地面位置,还得综合考虑角色高度、方向以及他处于哪个语义层级。

我们设想了一种可能的处理方式:只要角色踏上楼梯,就将其排序平面“提升”到上层,也就是说在逻辑上他就算处于上层了。这样一来他在渲染上就能开始遮挡上层物体。这个规则并非完美,但看上去是一个可行的近似方案。

同时也考虑过另一个方案,即采用倾斜式的渲染卡片技巧。其原理是将角色的精灵图在深度上做一个角度偏移(例如从侧视角看像是斜着的),这样角色在移动时会随着位置“向后退”或“向上爬”,从而在渲染顺序上更加自然地与场景融合。这是一种常见于 2.5D 游戏的渲染技巧,可以在某些复杂遮挡情况下自动做出合理排序。不过我们目前倾向于不走这条路线,因为它需要更复杂的渲染处理,偏离了我们当前的架构设计。

回到我们更倾向的处理方式,我们希望继续保留“连续移动”的游戏体验,比如楼梯可以流畅走上去,但在排序和渲染时,仍然需要对角色的视觉状态进行某种“离散化”——比如对 z 排序的计算采用固定层级的方式,而不是基于真实高度连续变化。也就是说,在逻辑上角色的位置可以是连续的,但渲染时我们把它“对齐”到某个语义层面,比如“地面”、“楼梯上”、“楼层顶”等。

同样的处理方式也适用于地面区块(ground chunks)。这些区块应该明确归属于某个语义平面,而不是与场景中竖直站立的物体混为一谈。也就是说,像地板这样的东西必须是“躺平的”,而不是竖起来站立的,这样排序系统才能准确地把它渲染在角色之下。

整体来看,为了解决当前这个伪 3D 的渲染问题,我们需要做出如下调整:

  1. 采用语义化分层的排序平面:不再使用真实 z 值,而是将物体的排序层级固定在一些“已知的”、“人为设定的”平面上(例如 z=0 表示底层,z=1 表示上层)。
  2. 角色一旦进入过渡区域(如楼梯)就提升其排序层级:例如刚踏上楼梯,就视为已到达上层,从而优先渲染在上层物体前。
  3. 考虑特殊物体的排布方向:例如地板是平铺的,而柱子或障碍物是竖立的,这样即使在同一个 z 层上,也可以通过朝向和形态差异来决定渲染优先级。
  4. 不采用复杂的倾斜卡片渲染方案,但保留这个方向作为参考:未来如有需要,也可考虑进一步精细化视觉深度处理。

此外,我们渲染系统中的排序逻辑也需要进一步增强,比如每个物体除了位置,还要标记其类型(例如“平铺地面”、“直立障碍”、“浮空单位”等),这样在排序时就能有更多语义上的参考信息,从而减少判断错误。

我们现在需要的,是从底层渲染逻辑、游戏对象分类、排序权重设定等方面,逐步建立起一套语义化的、可控的排序和显示系统,为之后的内容实现打好基础。

意外触发了 InvalidCodePath

我们之前其实也提到过这个问题,不过现在确实碰到了——我们用尽了 push buffer 的空间。问题出现在位图的 push 操作上,调试信息中显示我们在向 push buffer 推入位图时空间不足,导致渲染出现异常。

这非常奇怪,因为按照正常逻辑不应该出现这种情况。我们不是很确定是什么触发了这一点,所以开始从调试输出中查找线索。

我们查看了 debug text 的相关逻辑,发现问题是在渲染调试文字时发生的。这些文字被转换为位图并推入 push buffer 中。而当前的情况表明,似乎在某一帧中我们尝试渲染了过多的调试文字,超出了 buffer 的容量。

这令人感到意外,因为我们之前并没有在一帧内生成这么大量的调试文本。我们进一步检查了 push bitmap 的调用位置,尤其关注了 debug text 的输出情况,发现确实在一些特定情况下,会有非常大量的文字被打印出来,比如调试信息堆积、或者某些调试路径没有及时清理。

我们计划进一步审查以下几方面:

  1. 推送位图操作的计数机制:是否某处逻辑出错导致重复或多余推送。
  2. 调试信息是否被多次渲染:比如是否同一段文字被不同层次渲染了两遍。
  3. push buffer 空间管理是否存在漏洞:有没有可能之前某些空间没有被正确释放或重置,导致逐帧累积。

总结来看,这次的异常情况提供了一个重要线索:push buffer 的容量虽然设计上足够大,但在极端场景下还是有可能被用满,尤其是和调试输出相关联的路径。这提示我们在系统设计时,调试逻辑也必须具备稳健性,防止它在开发过程中反过来影响正常渲染流程。我们接下来会逐步定位并优化这个流程,确保不会因调试文本而引发资源溢出的问题。

的确过一段时间就触发了
在这里插入图片描述

在这里插入图片描述

调试器:发现我们从未重置 SortEntryAt

我们在 push buffer 的调试中注意到一个异常情况:在尝试推送元素编号为 97 的内容时,没有看到对应的 sword entry(排序信息),而这本不应该发生。于是我们开始调查每个传输条目的大小,以及 push buffer 的最大容量是多少。

通过检查后我们发现一个非常不合理的数值,看起来像是我们对某些缓冲区的初始化值完全搞错了。这个数根本就不可能是合理值,完全偏离了预期范围,因此初步判断是某处初始化逻辑存在严重问题。

进一步分析:

  1. sword entry 丢失的情况:表明我们的排序系统可能在某个环节没有正确记录或者生成排序信息,尤其是在元素较多(如 97 个)的场景下出现丢失,暗示可能是超出了分配或初始化容量的范围。

  2. 传输条目的大小与最大容量不匹配:我们原本设置的最大容量应足够覆盖当前帧中的所有渲染需求,但当前的实际使用数据表明,容量根本不足。这说明一开始我们对所需容量的估算就存在根本性错误,或者在计算单位大小、条目数量时使用了错误的参数。

  3. 初始化值错误的可能性:结合输出结果分析,明显有一个数值被初始化成了不合理的值。这种情况可能是由于在内存分配、结构体默认值设定、或者配置参数设置时出现了低级错误,导致后续所有关于 buffer 的行为都偏离预期。

接下来的工作重点包括:

  • 检查 push buffer 初始化的所有路径,尤其是默认容量、条目大小、排序结构等关键参数。
  • 验证是否有部分路径跳过了排序信息的注册或创建,导致 sword entry 缺失。
  • 检查是否有逻辑在计算条目大小时使用了错误的单位或溢出(例如将字节当成字数等)。

整体来看,这是一个比较严重的系统性 bug,说明底层的缓冲机制在当前使用条件下还不够健壮。我们需要彻查初始化流程,确保所有参数在创建时都被合理设定,同时在调试阶段加入更多断言和日志,以提前捕捉这种异常情况的苗头。
在这里插入图片描述

game_render_group:引入 ClearRenderValues 函数

我们发现了 push buffer 异常增长的根本原因:在渲染过程中并没有清理所有应当被重置的状态,特别是排序相关的数据结构。虽然在 EndRender() 时我们清空了 push buffer,但没有清除 tile entries(瓦片条目)等其他相关状态,导致排序信息不断累积,最终造成了 push buffer 空间耗尽的问题。

这个问题也解释了为什么随着帧数增长,排序条目的数量不断变大,并可能导致帧率下降,因为系统不得不处理越来越多不必要的排序信息。

为了解决这个问题,我们决定引入一个统一的清理机制,比如 ClearRenderValues() 或类似的函数,专门用于在每帧开始或结束时,清空所有需要重置的渲染状态,防止忘记手动清理某个字段。

主要修改和思路如下:


问题识别与定位:

  • SortEntryCount 不断增长,说明排序信息未被重置。
  • AllocateRenderGroup() 虽然会设置初始值,但 EndRender() 中没有清理相关状态。
  • 结果导致某些变量“记住”了上一帧的状态,持续堆积。

拟定的解决方案:

  1. 引入清理函数

    • 创建如 ClearRenderValues() 的方法,在每帧渲染开始前调用,用于统一清除所有应重置的变量。
    • 这样避免散落在多个地方设置变量,降低出错概率。
  2. 统一清除状态变量

    • 包括但不限于:
      • SortEntryCount
      • MissingResourceCount
      • FirstFreeBitmapID
      • 所有临时渲染相关的缓存或标志位
  3. 确保结束时也清理

    • EndRender() 末尾再次调用 ClearRenderValues(),确保渲染状态始终保持干净,便于调试和下次渲染。
  4. 考虑是否清理 Transform 状态

    • RenderTransform 中的平移偏移(OffsetP)和缩放(Scale)可以考虑清理;
    • 但用户设置的变换参数可能希望跨帧保持,所以暂时保留其值;
    • 留下 TODO 标记后续再视情况决定是否需要加入清理流程。

效果预期:

  • 避免 push buffer 重复积压旧排序信息;
  • 渲染逻辑更清晰、健壮;
  • 帧率不再随着帧数上升而变慢;
  • 未来更容易维护,新增渲染状态时也只需加到统一清理函数中。

这类问题虽然看似细节,但实际会对渲染系统的稳定性与性能产生重大影响,因此尽早发现和系统化处理是非常重要的。后续还可以进一步审查是否还有其他持久化状态需要纳入清理逻辑中,以构建一个更健壮的渲染管线。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

运行游戏并观察上一帧的耗时

我们发现并修复了一个显而易见但潜在影响巨大的渲染系统问题。之前由于渲染过程中没有清空关键状态信息,尤其是 push buffer 的排序数据,导致每一帧都不断往其中添加内容,排序条目数量持续增长,最终引起渲染性能下降和不可预期的行为。

我们确认这个问题正是之前性能下降的根源之一。虽然我们一开始认为帧率下降可能是其他因素,比如 debug 系统带来的开销,但最终发现是因为每一帧没有清空排序相关的数据,导致渲染器持续累积无用信息。

修复后的效果如下:


修复方式:

  • 把所有需要清理的渲染状态集中管理,例如:
    • 排序条目数量
    • 缺失资源的计数
    • 位图 ID 的索引等
  • 在每一帧开始和结束时调用统一的清理函数,确保渲染状态干净整洁。

修复带来的好处:

  1. 避免性能隐患

    • 防止 push buffer 随着帧数不断膨胀;
    • 避免渲染器处理越来越多的冗余数据。
  2. 提高稳定性

    • 把容易出错的手动清理行为集中管理,减少忘记初始化的可能。
  3. 易于扩展和维护

    • 未来若新增其他需要清理的状态,直接加到统一的清理函数即可。

关于性能表现:

虽然之前观察到的帧率降低部分可能是 debug 输出的影响,但实际上我们也确认了,当渲染内容较少时帧率确实更高——比如视野里渲染物体变少,性能就更好。这也再次说明了渲染开销与场景复杂度直接相关。

不过现在最重要的是,我们已经消除了一个非常隐蔽但危险的 bug。这个 bug 不只是单一的问题,而是一个可以影响整个渲染系统所有状态值的通用问题。如果不处理,未来在扩展渲染功能时将反复遇到类似的错误。而现在通过集中清理机制,这类错误的可能性大大减少了。

因此,我们认为这是一个非常值得做的改进,能显著提高系统的健壮性和开发效率。

在这里插入图片描述

game_render_group.cpp:思考如何生成排序键最合理

在排序处理的情况下,当前的排序关键字生成方式存在一些问题。虽然我们已经能够生成排序键(sort key),但在生成过程中,Z轴的部分(即位置的高度)并没有按照我们预期的方式处理。具体来说,P.z的部分并不是我们所需要的值。因此,需要找到一种更精细的方式来处理这些值。

问题描述:

  1. 排序关键字中的P.z部分不符合预期

    • 当前的Z轴(P.z)并没有正确地反映我们所希望的值,这使得物体排序时会出现问题。
  2. 没有足够的信息来决定排序

    • 即使我们知道世界中每一层的高度(比如每一层楼的高度),但在渲染实体的过程中,当我们得到排序信息时,实际上并没有足够的上下文来判断某个物体是“站在地面上的”还是“位于某一层的楼层中”。
    • 这意味着在生成排序关键字时,我们无法准确地知道某些物体是应该更早渲染的(例如,站在地面上的物体)还是应该更晚渲染的(例如,在楼上或某个更高位置的物体)。

需要解决的问题:

  • 如何在生成排序关键字时,更加准确地处理Z轴的位置,使得物体的渲染顺序能够正确地反映它们的层级关系。
  • 由于在渲染时,系统缺乏关于物体是否位于地面或其他层的具体信息,需要找到一种方法,将这些信息传递到渲染过程中。

可能的解决思路:

  • 对Z轴值进行更精细的处理

    • 通过将Z轴的值“截断”或“四舍五入”,使其更加符合实际的层次结构。比如,可以考虑根据物体所在的楼层高度来调整Z轴的位置,从而确定它在渲染中的排序。
  • 引入更多的上下文信息

    • 在生成排序关键字时,不仅要考虑物体的当前高度,还需要根据物体所在的环境(如它是地面物体还是某一层的物体)来决定其排序顺序。
    • 例如,可以引入“楼层信息”或者“物体在空间中的相对位置”,使得渲染系统能够根据这些信息来更准确地处理排序问题。

总结:

我们需要一种方法,能够在排序时处理更复杂的情况,确保物体按照预期的顺序渲染。这可能涉及到改进Z轴值的处理方式,同时在渲染过程中引入更多的上下文信息来帮助决定排序。
在这里插入图片描述

game_render_group.h:在 render_transform 中添加 b32 Upright 字段

在处理变换(transform)和渲染变换(render transform)时,当前的渲染变换缺乏关键信息,特别是没有明确指示物体是“直立”还是“倾斜”的信息。虽然渲染变换中包含了所有与摄像机相关的内容(比如正交投影等),但这些数据并没有涉及到物体本身的具体状态,尤其是它是否处于“直立”或“倾斜”的状态。

问题:

  1. 缺乏判断物体状态的信息

    • 渲染变换中没有明确指示物体是否是“直立”状态。
    • 当前的变换系统主要关注摄像机视角和其他与渲染相关的信息,但没有考虑到物体本身的朝向或状态,尤其是在涉及复杂物体时(如立着的物体或倾斜的物体)。
  2. 变换与渲染之间的信息分离

    • 渲染变换处理的是物体如何在世界空间中进行变换和显示,然而,物体本身的状态(比如是否直立)并没有在其中得到体现。渲染变换中所有信息都是针对摄像机的,而不是物体的具体属性。

需要改进的地方:

  • 渲染变换需要更多的信息

    • 渲染变换不仅应该包含与摄像机相关的数据,还应该包括物体的状态信息。例如,需要增加一个标志,指示物体是否是“直立”的状态。这样可以在渲染时更加准确地判断物体的行为。
  • 信息融合

    • 当前,变换与物体状态信息是分开的,这导致在渲染时无法充分利用物体的状态来优化渲染排序或处理不同状态的物体。需要将这些信息结合起来,以便渲染时可以做出更合适的决策。

解决方案:

  • 增加状态信息

    • 在渲染变换中,除了摄像机相关的内容,加入一个额外的字段,标明物体是否直立。这能帮助渲染系统在排序和渲染过程中更好地处理不同物体的状态。
  • 物体与摄像机的变换信息分开处理

    • 可以将与摄像机相关的变换信息与物体本身的状态信息分开存储和处理。这样可以避免信息混淆,同时保证物体的状态能被正确地传递给渲染系统。

总结:

需要对渲染变换系统进行改进,以便将物体的状态(如直立或倾斜)纳入考量。这可以通过在渲染变换中增加一个表示物体状态的字段来实现,以便渲染系统可以根据物体的真实状态做出适当的渲染决策。
在这里插入图片描述

“把这个移动出去,做成一个独立的东西”α

在处理渲染变换时,有一个问题需要解决,就是是否需要在排序完成后,将一些操作提取出来,变得更加明确和可控。具体来说,这个操作应该放在排序完成后执行,这样可以避免一些重复的工作并且让代码结构更清晰。当前在处理这些问题时,代码中经常出现难以跟踪的情况,因此需要更加明确地将这些操作分离出来,并且在调用代码中更加明确地传递相应的参数,比如创建一个变换对象并传递给渲染系统。

解决方案:

  1. 将操作提取到独立的功能模块

    • 排序完成后,可以考虑将渲染变换的相关逻辑提取出来,放到一个独立的地方来处理。这样不仅可以让渲染过程更加清晰,还能避免操作过于杂乱,增强可维护性。
  2. 在渲染变换中添加“是否直立”的标志

    • 在渲染变换(render transform)中,加入一个判断物体是否直立的标志。这可以帮助渲染系统决定是否需要对物体的Z轴坐标进行偏移。例如,如果物体是直立的,就可以对Z轴进行适当的偏移,以便正确渲染。
  3. 明确标记与传递信息

    • 在调用时,创建渲染变换对象并将其传递给渲染系统时,明确标记物体的状态(如是否直立)。这样做有助于渲染系统根据物体的状态进行合适的渲染处理,比如调整Z轴坐标或其他相关参数。

总结:

为了提高代码的清晰度和可维护性,应该在排序完成后,将渲染变换相关的操作提取到独立的功能模块中。并且,在渲染变换中加入是否“直立”的标志,这样可以在渲染时根据物体的状态进行更精确的Z轴偏移。通过这种方式,可以避免重复工作并增强代码的结构化。

game_render_group.cpp:在排序键计算中使用 Upright

在渲染过程中,可以通过设置一个标志来标识物体是否直立,来控制物体的排序和渲染。具体的做法是:

  1. 使用clear render values

    • 在渲染前,可以通过清除渲染值来重置一些重要的渲染参数。特别是在进行变换操作时,确保“是否直立”这一状态被正确设置。例如,大部分物体(如卡片)默认是直立的,渲染地面时则将其设置为“不直立”。
  2. 修改排序键(sort key)

    • 在排序键中,可以添加一个判断物体是否直立的标志。如果物体是直立的,可以通过调整Z值来偏移物体的渲染位置,确保直立物体在渲染时正确地排布。
  3. 增加偏移量

    • 根据物体的直立状态,可以通过在Z值上增加一个偏移量来影响排序。比如,当物体是直立的时,给它一个较高的Z值偏移,使得它的渲染优先级更高。具体来说,可以根据物体的变换值来设置这一偏移量,从而实现更精确的排序。
  4. 更新排序

    • 最后,通过这种方式,直立物体会自动在排序中被调整,使其在渲染时占据正确的位置,而其他不直立的物体则根据其状态和位置进行调整。

通过这种方法,可以灵活地控制物体的排序和渲染,确保在不同情况下,物体能够正确地显示在预期的位置。
在这里插入图片描述

在这里插入图片描述

game_world_mode.cpp:设置 GroupBuffer 不为 Upright

当进行“偏置”操作时,目的是通过调整Z值来改变物体的渲染顺序。在这种情况下,物体被设置为“平放”,并且需要确保它在渲染时排在其他物体之后。这是通过设置偏移量来实现的。

  1. 设置平放状态

    • 在这种操作中,物体被设置为“平放”状态,这意味着它不再是直立的,而是水平放置。这种状态通常适用于地面或类似的物体,它们应该渲染在其他物体的下方。
  2. 调整渲染顺序

    • 为了确保这些“平放”的物体排在其他物体后面,调整它们的Z值偏移。通过设置偏移量,可以确保这些物体的渲染优先级较低,从而实现正确的渲染顺序。
  3. 偏置的应用

    • 这种偏置的应用让渲染系统能够灵活地处理不同物体的渲染顺序。特别是对于像地面或建筑块这样的物体,它们通常需要在场景中的其他物体后面显示。

总结来说,通过偏置操作,可以控制物体在渲染时的位置,使其根据状态正确地显示在其他物体之前或之后。
在这里插入图片描述

运行游戏,发现我们没有为楼梯留出空位

现在可以看到,地面的 Z 值不再被改变,但由于我们对其进行了偏移,使其变为平坦的地面,它现在与其他所有物体正确排序了。并且,我仍然可以继续进行地面排序,甚至对于上层楼层的排序也没有问题。

这也引出了一个有趣的点,现在有了排序功能,我们可以实现一直以来想做的事情,就是让地面块之间可以顺畅过渡,特别是在楼梯的位置。实际上,这可能是我们应该尽早做的事情,因为这会是一个挺有趣的功能。所以也许在排序之后就可以着手做这个功能。

不过,目前我不确定为什么会出现闪烁问题,我觉得可能是因为目前只处理了一层地面块的情况。我们需要做的是添加更多的地面块,并且要考虑不同的平面,但我也忘了之前没有做到这一点,因为没有排序功能。现在有了排序,我们可以完全实现三维堆叠的效果。

所以,是时候做这个了,我们可以平滑地过渡,所有的东西都开始正常工作,尽管我们的排序键还不是最理想的,但正如我之前说的,这个问题我们可能需要继续优化,可能还得花些时间,接下来可能要解释渲染部分。

在这里插入图片描述

之前把上楼梯去就不见梯子了
在这里插入图片描述

尝试让楼梯渲染稍微正确一点

现在打算尝试实现楼梯井的功能,首先是让地面块连续工作。我们决定先着手处理地面块的连续性问题。

目前的问题是我们只处理了一层地面块。可以看到在处理地面缓冲区时,有一段是对 z 值进行操作的,并且在更新地面缓冲时使用了某种 z 相关机制。但现在不确定当时是怎么实现的,已经有些久远了。

在当前地面缓冲区的更新中,能够看到确实是在处理一个最小和最大 z 值的范围。理论上来说,应该会渲染出多层地面块。但从实际表现来看,某些地面块似乎会消失,所以怀疑并没有真正渲染出多个地面层。

接下来检查摄像机的包围盒范围。camera_bounds_in_meters 应该会定义摄像机能看到的最小和最大范围。从代码中看,它应该是按楼层的高度减去三层并加上一层,也就是说理论上应该有多个 z 层会被处理。按照设定,它应该是能让多个地面块被包含进去的。

那么问题来了,既然摄像机范围设定正确,为什么多层地面块在视觉上会消失?这个地方就是目前要解决的核心问题。

所以现在的重点是搞清楚:

  1. 摄像机的包围盒实际是否生效
  2. 我们是否真的在每一层都渲染了地面块
  3. 地面块是否被正确排序和绘制,是否被遮挡或跳过

接下来应该深入调试这些地面块的渲染逻辑,确认是否确实对每个 z 层执行了更新和绘制操作。如果没有,那就需要补上这些逻辑。这个问题需要尽快解决,因为是实现楼梯井和多层环境平滑过渡的前提。

运行游戏,注意到一些渲染问题

当前观察到的问题是,角色在移动过程中地面块会突然完全消失,尤其是当从地面楼层开始移动时,这种现象非常明显。原本在地面时可以看到地面块,但一旦开始向上走,它们就完全不见了。

这个现象可能与多个因素有关。

首先是排序键(sort key)。当前的排序键似乎没有正确处理这一类地面块的情况。理论上排序键应该能确保所有渲染对象以正确的顺序显示,但目前来看,地面块可能并没有被包含进排序逻辑中,或者被错误地处理了。

其次,地面似乎没有随着摄像机的移动而正确缩放。从画面观察来看,地面块在镜头中大小保持不变,不能正常地随着摄像机后退而变小,这说明它们没有正确地和摄像机的变换保持同步。这是一个明显的问题,因为在一个正交投影或透视渲染系统中,摄像机远离地面时,地面应当显得更小。

这可能意味着地面块没有应用正确的视图矩阵或者变换矩阵,或者它们的 Z 值或其它空间定位信息没有被正确更新,导致它们被渲染在了一个错误的深度层级上,从而导致不该显示时被绘制,或该显示时不被绘制。

总结目前观察到的问题:

  1. 地面块在角色移动后突然消失,尤其是从地面走向高处时;
  2. 排序键没有正确处理地面块的可视状态
  3. 地面块未能正确与摄像机变换保持同步,缺乏缩放感
  4. 可能存在 Z 值错误或视图变换未应用问题,导致渲染表现异常。

接下来需要深入排查以下几点:

  • 排序键生成逻辑中是否正确考虑了地面块的空间状态;
  • 地面块是否使用了正确的渲染变换(render transform);
  • 摄像机的视图矩阵是否正确应用到了地面块;
  • 是否存在地面块未被包含在渲染列表中的情况(例如 Z 范围判断错误);

这些问题修复后,应当可以让地面块在摄像机移动过程中保持正确的缩放和可见性,支持后续楼层之间的平滑过渡以及楼梯井的完整渲染效果。

game_world_mode.cpp:给 GroundBuffer 添加 Z 值

当前地块渲染中的问题,主要是由于没有正确处理 Z 轴偏移造成的。

在进行地块(ground chunk)渲染时,虽然已经有了负责 Z 轴的渲染变换(transform),但实际上并没有使用它。这也就导致了当前的问题:地块并没有被正确地偏移,无法跟随摄像机或者楼层的变化正确地显示在屏幕上。

这个问题的根源,可以追溯到旧的逻辑。当时还没有实现排序系统,因此采用了一些临时方案来控制渲染层级。比如使用了 Delta.z >= -1.0f) && (Delta.z <= 1.0f)? 这样的条件判断,用于限制哪些地块可以被渲染。现在看来,这些判断是为了在没有排序的情况下手动过滤远离当前楼层的地块。但在当前已经支持排序的情况下,这类判断显然已经过时,反而可能引发地块不显示的问题。

此外,还有一个关键错误在于变量 delta 的使用。在旧代码中,delta 被用于内部控制 Z 偏移的判断逻辑,而没有将其真正应用于地块的位置变换中。正确的做法应该是,将 delta 作为偏移值传递给地块的位置偏移操作,从而在渲染时体现出来。

因此需要做出以下修正:

  1. 移除旧的 delta_z 判断逻辑,避免错误地限制地块的渲染;
  2. 将 Z 偏移值(delta)正确传递给地块的渲染偏移操作,而不是单纯用于条件判断;
  3. 统一渲染流程,使用新的排序机制来决定渲染顺序,不再依赖临时条件过滤;
  4. 清理旧代码中多余的处理逻辑,例如多重分号、无意义的 delta 比较等;

通过这些调整,地块将能依据实际的渲染变换进行正确偏移,同时借助排序系统来保证可视顺序,彻底解决当前摄像机视角变化时地块渲染异常的问题。
在这里插入图片描述

运行游戏,发现最上层的 GroundChunk 如预期遮挡了其他内容

现在的表现基本符合预期,地块渲染顺序已经正确,最上层的地块能正确地渲染在其他物体的前面,这本身说明排序机制已经起效。但也因此带来了另一个需要解决的问题:最上层的地块始终完全不透明,没有淡出(fade out)效果,视觉上会遮挡下方内容,影响体验。

我们需要让这些最上层的地块在适当的时候逐渐淡出,比如当角色靠近或视角即将穿透该层时。之前已经实现了 alpha 淡出处理机制,但已经不太记得具体的实现方式了。

经过回顾,发现之前的 alpha 处理是手动完成的,即不是由系统自动控制,而是在代码中显式设置透明度值来实现淡出效果。也就是说,透明度的计算逻辑是写在地块渲染的代码路径中,可能是根据摄像机高度、角色所在层级、或者相对偏移来决定一个 alpha 值,然后将其应用到对应地块的渲染数据上。

接下来要解决的问题是:

  1. 重新整理并启用之前的手动 alpha 淡出机制,确保在视角靠近最上层地块时地块可以逐渐变透明;
  2. 确认当前的渲染路径是否还正确保留 alpha 通道的使用
  3. 优化 alpha 的插值或过渡逻辑,让淡出效果更加自然,不影响地块的正常识别;
  4. 确保淡出机制与排序逻辑协同运作,即透明度变化不能影响排序正确性,但必须在正确的时间段内影响视觉表现。

完成这些调整后,当前多楼层地块渲染系统将更加完整,不仅支持排序,还具备了视觉上更具层次感和过渡感的显示方式。下一步可以着重测试不同角度和移动状态下的表现,进一步微调淡出曲线或临界点逻辑。
在这里插入图片描述

game_world_mode.cpp:让 FadeTopStart 再次生效

我们目前的地块透明处理方式并不理想,因为之前的透明度计算是手动写在某一处的,现在如果想让地块(ground chunks)正常工作,也必须在那里重新处理透明度逻辑。

虽然我们可以通过复制粘贴那段代码来临时解决问题,但更理想的做法是对整个渲染流程进行整理和清理,尤其是在我们已经开始更加正式地处理排序的情况下。现在既然排序系统已经基本到位,完全可以回过头来把这些零散的、不规范的逻辑重构一遍,整理成一个更统一、结构清晰的方式。

下一步的计划是:

  1. 先把排序系统完成,确保其功能完整;
  2. 排序完成后,回到这里重新整理与渲染、透明度相关的代码;
  3. 目标是把这段目前“手动处理透明度”的逻辑,真正变成一个渲染系统的一部分;
  4. 引入统一的结构,比如一个包含顶层 Z 值的常量或渲染结构成员变量;
  5. 为未来可能需要更复杂的透明计算或层级判断留下拓展空间。

我们打算在渲染部分增加一个 FadeTopStartZ(顶层起始 Z 值),当前可以作为一个简单常量使用,但将来有可能需要把它正式纳入渲染系统的上下文中,以便动态调整或为不同的渲染对象提供不同的淡出控制。

总之,当前透明处理方式只是临时方案,计划在排序系统稳定后统一重构,使地块渲染、排序、透明度控制三者融合为一个整洁、可维护的整体架构。
在这里插入图片描述

outline 好像有问题
在这里插入图片描述

运行游戏,发现渲染一切看起来都正常了

我们现在的渲染情况明显比之前正确得多了。画面显示效果也更加合理,一切看起来都已经按预期渲染了,这让我们感到很满意。

不过,接下来我们还需要解决一个问题:我们目前仍然无法看到楼梯井的位置。虽然大概还记得楼梯井的位置,但在游戏中并不能直观地看到它,必须靠记忆或猜测。

为了真正让玩家能够使用楼梯井,我们必须让其在视野中可见,否则玩家根本不会意识到它的存在。实现这一目标的方式是,在生成地面区块(ground chunks)的时候,地面逻辑需要知道哪些地方存在楼梯井,并避免在这些位置放置地面区块。也就是说,需要为楼梯井保留一块“可视窗口”以便玩家从高层向下看到它。

目前的工作重点已经从“地块正确渲染和排序”转移到了“空间可视性处理”。接下来将计划:

  1. 修改地块生成逻辑,使其在有楼梯井的位置不绘制地块;
  2. 这样可以在视觉上“留洞”,玩家能透过高层直接看到下方的楼梯区域;
  3. 这需要地块系统能够访问楼层结构信息,比如知道楼梯井的坐标;
  4. 完成后,不仅排序正常,交互路径也会变得更加清晰和直观。

总之,现在大部分渲染问题都解决了,剩下的是一些与空间可视性和交互体验相关的细节优化,特别是确保玩家可以真正看见并使用楼梯连接不同层级。

注意到我们实际上绘制了整个屏幕多次

我们目前还有一个显而易见的问题,这也是之前我们曾经提到“也许该采用从后往前(back-to-front)渲染”的原因之一。

虽然在现代的三维加速环境下,性能一般足够强大,不一定需要担心这个问题,但如果我们想让软件渲染(software rendering)保持高效,那这个问题就必须重视。因为现在的渲染方式中,每一层地面区块都会完整地绘制一次整个屏幕。这意味着:

  • 屏幕被重复绘制了很多次;
  • 每一层都会重复输出所有像素;
  • 大部分时候其实这些层是被遮挡的,根本不可见;
  • 但它们还是被完整绘制了,这造成了大量不必要的计算浪费。

现实中,玩家在一个特定楼层时,只会看到当前层的地面区块,只有在有“地面空洞”(比如楼梯井)时才会看到下方的地面区块。所以如果没有空洞,底层的绘制都是完全被遮住的。

如果我们真想提升软件渲染的效率,可以考虑换一种策略:从最远的物体开始往前渲染,并在渲染过程中记录每个屏幕区域是否已经完全被遮挡(occluded)。也就是说:

  1. 从后往前处理每个物体;
  2. 每渲染一层,更新一个遮挡信息结构;
  3. 如果后续物体要绘制的区域已经完全被遮住了,就跳过那部分;
  4. 这样可以避免很多重复绘制操作,尤其是那些在视觉上最终会被盖住的层。

不过这个优化是有成本的:

  • 实现起来比较复杂;
  • 需要一个遮挡缓存或遮挡检测系统;
  • 工作量不小;
  • 不一定在所有情况下都带来显著收益。

因此现在是否值得投入精力去做,还不确定。如果我们已有足够强大的软件渲染能力,也许就不必急着优化。但如果以后希望更高效,特别是在低性能设备上运行得更好,那这一点可以作为进一步优化的方向。

win32_game.cpp:将分辨率降到 960x540

目前的帧率表现其实不算理想,尤其是在1080p 分辨率下运行时,帧率偏低。不过值得一提的是,我们完全可以选择不在这么高的分辨率下运行软件渲染。之所以一直使用1080p,只是因为它“足够快”,所以顺理成章地就采用了。但如果我们想要更高的帧率,其实可以像早期软件渲染那样,降低分辨率运行。这样一来,计算量下降,性能自然也就提升了。

举个例子,我们可以将分辨率降低到更传统、接近老式软件渲染时代的水平,性能表现就会立刻改善。例如,在较低分辨率下运行时,帧率可以提升到每秒十六帧左右,这已经是可以接受的水准,虽然还达不到六十帧,但也足够流畅和可用,特别是在非动作密集型场景下。

不过,虽然这么做是可行的,但还是会忍不住去考虑进一步优化。因为我们很清楚,目前的软件渲染其实还有很多潜力没有被完全挖掘出来。尽管在当前硬件条件下看似够用了,但作为技术实现者,看到性能上的提升空间总是会觉得——是不是可以再推进一下?毕竟这是“游戏编程”而不是“游戏设计”,技术上的挑战本身就是乐趣所在。

整体而言,对现阶段的渲染效果还是比较满意的,当前这个 sort key 实现方式虽然可能还有改进空间,但就我们目前的需求来说,已经能满足功能了。因此,至少暂时可以接受目前的状态。后续如果有更多优化需求,再针对性调整也不迟。

game_render_group.h:将 transform 数据拆分为 object_transformcamera_transform

现在还剩大约十分钟的时间,计划进行一些结构上的整理和重构。虽然当前的帧率不高,但暂时不打算管它,主要精力放在优化渲染结构上。

当前的问题在于渲染分组结构存在很多“粘性加载”的现象,导致逻辑处理非常混乱、代码也显得不干净。比如在绘制时,需要不断地设置和取消状态,显得繁琐又容易出错,而且这么做也没有实际意义。完全可以通过更清晰的结构来简化这些问题。

接下来的思路是,将渲染中涉及的位置和变换逻辑提取出来,采用一种更整洁的做法:使用两个独立的变换结构体——一个是对象变换(object transform),另一个是相机变换(camera transform)。这种方式能让每一部分的职责更加明确。

具体来说:

  • 对象变换(object transform)用于记录物体本身的位置、是否竖直(upright)、偏移量(offset)、缩放(scale)等信息;
  • 相机变换(camera transform)则记录是否为正交投影、每米对应的像素数(meters_to_pixels)、焦距(focal_length)等与视图相关的信息。

此外,还注意到一个叫 distance_above_target 的字段,它目前设计得并不合理。这个值其实就是一个加在 OffsetP 上的常量,完全可以包含在对象的变换中,而不必单独作为相机的一部分。虽然暂时先保留它,但实际上是没有必要存在的。

从当前的结构来看,相机变换部分是存在的,但对象变换还没有实现,因此添加后会引发大量编译错误。但这些错误多数是重复性、机械性的修复,并不复杂,只需要根据新结构调整相关调用即可。

总之,这种结构上的整理会让整体渲染逻辑变得更清晰、职责更分明,也会为后续的扩展和维护打下良好的基础。即使短时间内不能全部完成,也值得尽快启动这个清理过程。
在这里插入图片描述

game_render_group.h:引入 DefaultUprightTransformDefaultFlatTransform

我们决定现在就开始进行结构优化。接下来的目标是引入默认的对象变换(default object transform)机制,让代码更清晰、更方便使用。

这个结构的基本设想是这样:我们提供一个默认的变换结构体,名字可能叫做 default_transform 或类似名称。使用时只需要调用这个默认变换,然后根据需要对其进行修改,最后使用它完成变换的传入,整个过程无需繁琐地手动设置每个字段,避免一堆重复代码。

这套默认变换方案分为两类:

  1. 普通默认变换(例如用于平地的那种)
  2. 带“upright”属性的变换(例如人物、物品等竖直显示的对象)

这些变换不需要显式初始化,因为针对“扁平变换(flat transforms)”的情况已经有了预设值。只需要针对对象的偏移、缩放或姿态进行必要修改即可。这样的话,这个“默认变换系统”主要管理的就是对象的位置偏移(offset)、缩放(scale)和是否竖直显示(upright)这几个核心属性。

一旦这套默认对象变换结构建立起来,我们代码中以前那种到处“设状态再清除”的写法就可以彻底废除。不再需要冗长地在多个地方重复处理同一组状态,也不需要担心状态交叉影响。

接下来需要做的,就是在代码中系统地替换掉那些老旧的状态管理逻辑,改为直接使用这个新的对象变换结构。虽然这会带来一大波编译错误,但本质上这些错误都很简单,只需要把原先的状态替换成我们新引入的结构使用即可。

最终目标是:整个渲染逻辑统一使用这套清晰的、解耦的变换结构。每个渲染对象只关心自身的变换信息,而视图系统只关心相机的视角变换。二者分离,逻辑更清晰,扩展也更容易。这样一来,不仅当前项目变得更干净,未来新增功能或优化性能时也更得心应手。
在这里插入图片描述

在这里插入图片描述

game_render_group.cppgame_debug.cpp:传播上述更改

我们对渲染系统进行了进一步的重构和优化,核心目标是彻底去除之前依赖的全局状态,将对象变换(Object Transform)与相机变换(Camera Transform)分离并通过参数传递的方式显式地使用,从而使渲染流程更加清晰、可控、无副作用。

我们开始将所有原本默认依赖渲染组中全局变换状态的部分,改为通过局部变量明确传递。所有的 Transform 现在都不再通过指针,而是通过值传递,这样能避免潜在的内存别名问题和副作用问题。逻辑上更安全,维护上也更方便。

在绘制函数(如 PushBitmap)中,我们将 ObjectTransform 作为必要参数进行传递。对于不需要特殊变换的地方,例如某些 UI 元素或背景图块,我们统一使用一个默认的 FlatTransform,也就是一个单位变换,不做位置或缩放上的调整。这样可以大幅减少代码中的状态设定和清理逻辑。

我们也逐步梳理了旧代码中变换的使用,发现某些字段比如 Scale 从未真正使用,这说明系统中存在一些冗余设计。虽然暂时保留了这些字段,但后续可以考虑删除或完善实际的使用逻辑,例如真正实现缩放。

在一些原本混乱或不明确的渲染调用中,比如 DrawHitPoints 或 UI 按钮绘制,我们也统一使用了明确的 ObjectTransform,这样就不需要依赖外部状态或在调用前设置状态了。变换信息清晰、透明,易于跟踪和调试。

同时我们也对一些不再需要变换的部分(如加载界面、静态图标等)直接使用了一个内联创建的 DefaultFlatTransform,避免多余的传参或初始化。

整体来看,这次重构的核心成果包括:

  • 明确传递 CameraTransformObjectTransform,不再依赖全局状态;
  • 所有变换均通过局部值传递,逻辑更安全;
  • 简化绘制接口调用,减少了复杂性和代码重复;
  • 引入统一的默认变换对象,避免手动初始化;
  • 清理掉无用或未使用的字段和逻辑。

最后我们运行了游戏,观察到视觉上没有变化,鼠标交互正常,渲染流程也照常工作,说明这次结构重构非常成功。虽然发现实体排序的 picking 还未完善,但这本来就不是目标的一部分,当前状态已经令人满意。整个渲染架构更加稳定、清晰、现代化,也为后续功能扩展或性能优化打下了良好基础。
在这里插入图片描述

在这里插入图片描述

修复一下不能选中实体的问题

在这里插入图片描述

速度加大一点每次感觉很费劲

在这里插入图片描述

在这里插入图片描述

你最喜欢的排序方法是什么?

我们并没有偏好的排序算法。实际上,在我们所处理的项目中,排序从来都不是一个值得花太多时间去深究的问题。就算使用冒泡排序,也完全可以满足当前的需求,真的没必要特别优化排序算法。

当然,世界上确实存在一些对排序性能要求极高的问题,那时候确实需要精细设计排序流程。但我们几乎从未真正遇到过这种情况。

目前的项目中,我们根本不需要处理成千上万个实体的排序操作,也就没有必要去追求什么“最优”的排序方法。即便是之后某个场景涉及到排序,任何一种排序算法在性能上都不会成为瓶颈。

而且在游戏开发的环境下,性能预算本就非常紧张。如果真的到了要对大量元素排序的程度,通常也不会选择每一帧都重新排序,而是让数据结构本身维持一个近似有序的状态。比如会使用二叉树或者其他自维护结构,这些结构能在插入或删除元素时保持顺序,这样就不需要每帧进行全量排序。

从我们的经验来看,排序并不是重点。并不是因为排序不重要,而是因为我们的工作内容并不会涉及到需要复杂排序逻辑的情境。或许其他游戏或项目中有人遇到过需要高效排序的场景,但我们目前并没有这种需求。

总之,我们的态度是务实的——能解决问题的就是好方案,至于排序方式本身,用什么都行,没有必要太纠结。

我目前正在做一个 OpenGL 框架的游戏项目。我可以使用你在第 023 天(Live Code Editing)之前写的代码来开发商业游戏吗?我也在直播开发过程,代码可能在直播中被看到。

我们目前正在开发一个用于游戏的 OpenGL 框架,计划未来用于商业游戏项目。同时我们在直播这个开发过程,所以代码可能在直播中会被观众看到。

关于是否可以使用从第 1 天到第 23 天的代码,目前的确认是完全可以使用这些代码,即使是用于商业项目也没问题。也就是说,这部分代码可以作为项目的基础使用,无需担心授权或许可方面的限制。

总之,既可以将这些代码用于商业目的,也可以在开发和直播过程中公开展示,无需额外许可,非常适合当前项目的需要。

我知道你喜欢用 discriminated unions,但你是如何管理那些会导致巨大 switch 的情况?OOP 通常通过对象分发来解决这问题。

我们在使用判别联合(discriminated unions)时,确实会引入一个潜在问题:随着类型的扩展,可能导致 switch 分支数量庞大,看起来臃肿或难以维护。而传统面向对象编程中通常是通过虚函数调用或对象自身的方法(比如 Cube 模型那样的委托)来“分发行为”,从而避免大量的 switch。

但我们并不认为 switch 分支本身构成问题。首先要反问的是,为什么 switch 会被视为问题?在很多情况下,switch 结构是清晰、直接、性能可控的,它并没有天然的缺陷或可维护性问题。只要合理组织代码和控制复杂度,switch 是一种非常实用的控制流结构。

更进一步讲,所谓“分发”(dispatch)机制,并没有从根本上解决问题,它只是将原本集中在 switch 中的逻辑分散到了各个对象方法里。逻辑仍然要写,只是换了位置。这种方式可能看起来“干净”一些,但也引入了额外的抽象层和跳转开销,有时候反而增加了代码跟踪和调试的负担。

换句话说,使用判别联合并在某处集中处理逻辑(例如通过 switch)和将逻辑分发给每个对象分别处理,本质上是“半斤八两”(six of one, half a dozen of the other),并没有谁一定更好。关键在于具体场景、可读性、性能需求和团队习惯等多方面的权衡。

所以我们更倾向于实用主义:如果 switch 能简单明了地表达清楚逻辑,我们就用它。并不会刻意回避或者引入复杂的分发系统,只因为 switch 看起来“笨重”。如果某一天这些 case 确实变得难以维护,那我们再考虑提取公共逻辑或模块化处理。而不是一开始就为了“避免 switch”而引入不必要的结构。

  1. Shopping: “Buy this blue shirt or the black one? It’s six of one, half a dozen of the other.”
    (买这件蓝衬衫还是黑的?半斤八两,都差不多。)

  2. Travel: “Fly with Delta or United? Six of one, half a dozen of the other.”
    (选达美航空还是联合航空?半斤八两,服务和价格差不多。)

  3. Dining: “Go to this Italian restaurant or that one? It’s six of one, half a dozen of the other.”
    (去这家意大利餐厅还是那家?半斤八两,菜品和环境差不多。)

  4. Technology: “Get the iPhone 15 or the Samsung Galaxy? Six of one, half a dozen of the other.”
    (买iPhone 15还是三星Galaxy?半斤八两,功能差不多。)

  5. Fitness: “Join this gym or the one downtown? It’s six of one, half a dozen of the other.”
    (加入这家健身房还是市中心的?半斤八两,设备和费用差不多。)

  6. Hobbies: “Take the painting class or the pottery workshop? Six of one, half a dozen of the other.”
    (上绘画课还是陶艺班?半斤八两,体验差不多。)

演示:switch 语句 vs 函数分发

我们认为使用 switch 语句和使用“分派(dispatch)机制”在本质上是完全一样的,二者没有实质区别,只是结构表现不同而已。我们可以把原本的 switch 结构替换成一种面向对象分发的方式,但最后写出来的逻辑实际上是一模一样的,工作量也相同,代码的本质也并未发生变化。

有些人似乎不太能接受 switch 语句将所有逻辑放在一个地方这种做法,而更倾向于把每个逻辑分散到各个文件、各个类中。但在我们看来,这种做法反而是更差的选择,因为:

  • switch 的好处在于:所有跟某个操作相关的代码都集中在一起,我们在修改这个操作的时候可以一眼看清楚所有分支的行为和结果,避免遗漏和理解上的偏差。
  • 而分发(如虚函数、类继承)虽然可能有一个优点是:每种类型的代码都放在一起,便于单独维护类型逻辑。但这是以牺牲操作整体视角为代价的。

在实际开发中,更常修改的是操作逻辑而不是类型结构,而这些操作的不同分支逻辑往往需要一起审视,因此集中写在一个 switch 中会更合适。

此外还有几个具体的问题:

  • 使用 switch 可以方便地共享一些变量、定义、或在整个逻辑前后统一执行一些处理,而分发式写法则要求我们将这些共享逻辑抽象成某种“基类”或辅助机制,这样反而引入了额外复杂度。
  • switch 中,我们可以在作用域顶部声明如“像素到米”的转换比例、预处理参数等,然后在所有分支中自由使用。而在分发机制中,这种共享逻辑就需要额外封装和传递,效率和维护成本更高。
  • 面向对象式的代码拆分方式在这里显得更啰嗦、不自然、也更难统一管理。

总的来说,我们更喜欢 switch 的编程方式,觉得它是更清晰、更直接的做法。分发式设计则在这里体现出不少弊端,所以我们并不认同那是更“先进”或者“合理”的方法。对于我们而言,switch 是一种非常合适的编程方式,反而更贴合实际开发中的需求和工作流。

显然我们在最近几期中丢失了一些性能。你是打算优化回来,还是就这样转向 GPU 渲染?

我们最近这几次的渲染性能确实明显下降了。现在的问题是,我们要不要尝试把性能优化回来,还是就保持现在这个状态,直接转向 GPU 渲染。

我们的想法是,当前可以先把手头的一些其他工作做完,然后以这个性能问题为契机,回过头来把调试视图相关的系统完善一下。因为现在我们还缺乏一些必要的工具来调查性能具体下降的原因。

我们目前大致知道性能下降的一个主要原因是我们现在渲染了太多内容,明显超出了之前的范围,这一块应该是性能消耗的“大头”。但除此之外,我们也还不清楚是不是还有其他因素,比如逻辑变更、资源使用等,所以我们需要更完善的 debug 工具来进行分析。

等调试工具准备好之后,我们会根据分析结果再来决定:
是对当前的软件渲染器做进一步优化,还是干脆跳过优化,直接转向 GPU 渲染流程。

总的来说,接下来的步骤是:

  1. 完成当前其他开发任务;
  2. 推动 debug 视图系统的完善;
  3. 用它深入分析当前性能瓶颈;
  4. 然后再根据结果判断是否优化软件渲染器,或者切换到 GPU 渲染。

这是我们比较倾向的处理方式。

你开发某个东西时通常会先写一个能跑的简单版本,再慢慢打磨成更好的版本。你觉得我们只学习最终的版本就够了吗?还是自己也应该动手写一遍初版?

在开发某个系统或功能时,我们常常会先以最简单、最直接、能够工作的方式写出来一个初版,然后再慢慢打磨、优化,最终变成一个成熟可靠的版本。这种从简单版本出发再逐步完善的过程,是我们非常重视的。

如果是想赶进度、跟上项目进展,有人可能会倾向于只关注已经完成、已经打磨好的版本。但我们并不推荐只去学习最终版本。学习完整过程、从头写一个简单版,再自己一点点改进,是非常有价值的。

我们做这个项目的目的之一,就是为了帮助自己掌握“打磨”的过程 —— 也就是从原型出发,把一个初级实现逐步完善为一个健壮、灵活、高效的系统。这个“打磨”的能力,才是我们真正需要掌握的技能。

如果我们只是研究最终成品,只能照搬它。可能能复现,但没法灵活地应对不同问题、不同需求,也没法做得更好。我们真正希望具备的,是能够理解这个过程、掌握其中的步骤、并根据具体的场景加以发挥和创造的能力。

这样我们将来才有可能做出比原版更适合自己的、更出色的实现,也能面对全新的问题时灵活应对,独立解决。

所以我们认为,学习的重点不在于结果,而在于过程 ——
要动手从最初版本开始写起,经历问题、寻找解决方案、不断改进,直到获得一个理想的版本。这个过程才是真正的价值所在。

我刚发现你的系列,震惊于你完全不用外部库(比如 SDL)开发整个游戏。我现在在做一个 SDL + OpenGL 项目。你有什么建议?

我们最近刚刚发现了一个系列视频,让人惊讶的是,它从头开始构建整个游戏框架,完全没有依赖任何外部库,连标准库 STL 都没有用。这种纯粹从零开始的做法非常有意思。

不过,这个系列已经做了两百多集了,我们想要追进度时,不太知道该从哪里开始着手。因为我们自己已经在做一个使用 STL 的游戏引擎项目,所以我们想结合自身情况来学习,避免重复,聚焦有用的部分。

这个系列的内容安排并不是线性或模块化的。整体是按需求推动的开发方式 —— 哪个部分需要工作就去做哪个部分,比如有时正在做软件渲染器,突然会切去做调试系统或者渲染器结构相关的内容。没有严格按照功能模块划分,因此不是每个功能都集中讲完才进入下一个。

为了能有条理地学习,我们打算依靠播放列表来导航。播放列表对内容进行了相对清晰的分类整理,比如软件渲染器、调试工具、输入系统等,如果我们不想看某一类内容,比如暂时不想深入软件渲染器,那可以通过播放列表来跳过相应部分。

总之,为了有效学习,需要结合播放列表选择对自己当前项目有帮助的部分来学习,而不是完全从第一集线性往后看。根据我们已有的知识和项目背景,有选择地吸收适合的内容会更高效。

不知道你有没有提过这个问题:当你走到楼梯中间并混合树的时候,会看到一块较暗的重叠区域。怎么解决这个?

当我们在进行树木混合渲染时,会发现一个问题:当人走到楼梯中间,正处于两个树层之间时,重叠的区域会变得更暗。这种现象的根本原因是混合叠加带来的不透明度累加效应。

解决这个问题其实没有一个真正完美的方法。目前唯一能解决的方式是使用“双阶段混合”(Two-pass Blending)技术。

这种方法的基本原理是:
第一阶段,先渲染所有透明部分但不进行颜色写入,只写入深度信息。
第二阶段,再基于深度信息进行真实颜色的混合渲染。

这种方法的优点是可以避免多个半透明层之间相互叠加产生的过暗问题。但缺点也很明显,那就是性能开销非常大,不太适合在性能要求较高或设备资源有限的场景下频繁使用。

虽然实现起来相对简单,但代价高昂,所以是否使用,需要根据具体项目需求来权衡。如果画面效果非常关键,可以尝试这种方式;如果性能优先,可能就只能接受这种重叠变暗的视觉现象。

黑板:关于需要混合(compositing)才能实现渐变效果

当我们希望将多个透明对象(例如两棵树)进行混合渲染时,如果直接将它们一个个画上去,并对每个对象单独使用 alpha 值进行透明混合,就会在它们重叠的区域出现颜色变暗的现象。这是因为多个半透明像素在叠加时,每次混合都会降低亮度,导致重叠区域看起来比原本更暗。

这在我们想要淡出整个层级时尤其明显。我们并不希望每个元素单独淡出,而是希望它们像一整张“透明的薄片”一样整体淡出,从而避免这种局部叠加变暗的现象。

解决方案是:

  1. 首先将所有要淡出的对象预先合成到一个中间缓冲区,也就是说,先把这一整层需要混合淡出的元素统一渲染到一张临时图像或帧缓冲中。
  2. 然后对这个合成好的整体图像统一应用一次透明度变化(alpha),使它整体一起淡出。

这样做的好处是整个图像会以统一的方式处理透明度变化,从视觉效果上就不会出现树木等对象重叠后变暗的情况。缺点是这种做法增加了渲染步骤和资源占用,尤其是在层级复杂或实时性要求高的情况下,会带来一定的性能开销。

但目前来看,这几乎是唯一能完全避免该问题的方法,适用于需要较高视觉质量的渲染场景。

std::qsort 值得用吗?还是自己写排序更好?

标准库中的排序方法其实并不是特别值得使用,相比之下,自己实现一个排序算法更有价值。比如快速排序,它非常容易实现。所以,是否使用标准库中的排序方法这个问题,其实没什么太大意义,因为快速排序本身就是一种简单易懂的算法。

你完全可以使用C语言的运行时库(C Runtime Library),如果已经在使用它,或者你也可以选择自己实现一个排序算法。实际上,快速排序并不复杂,我们明天会讲解如何实现它,甚至会提到归并排序,并展示如何做,大家可以看到其实并不难。

像其他很多算法一样,你只需要写一次并保存它在一个文件里,以后每次需要用到时就可以直接调用,不用每次都重新编写。所以,不必担心每次都得从头写。

在多个实体碰撞时,你是怎么处理“碰撞解决”(不是检测)的?比如多个移动物体的冲突解决,总感觉方案都挺丑的,尤其涉及移动顺序的时候。

当面对多个实体之间的碰撞时,解决冲突的问题(即如何处理碰撞后的结果)是一个非常复杂的问题。事实上,几乎没有一种完美的解决方案,很多时候我们只能依赖某种逻辑来处理碰撞结果,比如通过优先级来决定哪个实体先被处理。

问题的根源在于计算机本身并不擅长做连续的碰撞解决,它们更适合做离散的操作。这使得持续处理多物体碰撞的问题变得非常困难。即便是宇宙本身也没有尝试一次性解决多个刚体的碰撞问题,宇宙通过分解成分子和更小的物体,逐层处理碰撞。因此,实际上我们面对的是一个抽象的处理模型,而不是现实物理现象的完美再现。

在这种情况下,我们只能通过某种规则来处理这些抽象化的碰撞。一个相对合理且不至于偏离物理原则的方法是使用“质量”作为决策依据。具体来说,当处理实体的移动和碰撞时,可以根据实体的质量来决定它们如何互相避让。通常,质量较大的物体优先于质量较小的物体,意味着较重的物体会占据更多的空间或在碰撞中移动较少。这种方式通过优先处理大质量物体,避免小物体改变较大物体的运动轨迹,从而达到较为合理的冲突解决。

你说缩放值没被用上,但相机还是会对一切进行变换。这两者有什么区别?

当面对多个实体之间的碰撞时,解决冲突的问题(即如何处理碰撞后的结果)是一个非常复杂的问题。事实上,几乎没有一种完美的解决方案,很多时候我们只能依赖某种逻辑来处理碰撞结果,比如通过优先级来决定哪个实体先被处理。

问题的根源在于计算机本身并不擅长做连续的碰撞解决,它们更适合做离散的操作。这使得持续处理多物体碰撞的问题变得非常困难。即便是宇宙本身也没有尝试一次性解决多个刚体的碰撞问题,宇宙通过分解成分子和更小的物体,逐层处理碰撞。因此,实际上我们面对的是一个抽象的处理模型,而不是现实物理现象的完美再现。

在这种情况下,我们只能通过某种规则来处理这些抽象化的碰撞。一个相对合理且不至于偏离物理原则的方法是使用“质量”作为决策依据。具体来说,当处理实体的移动和碰撞时,可以根据实体的质量来决定它们如何互相避让。通常,质量较大的物体优先于质量较小的物体,意味着较重的物体会占据更多的空间或在碰撞中移动较少。这种方式通过优先处理大质量物体,避免小物体改变较大物体的运动轨迹,从而达到较为合理的冲突解决。

前到后的渲染器(front-to-back renderer)是如何工作的?它会在写入像素之前先测试吗?

前向渲染(front-to-back rendering)在实际工作中的实现可以有两种方式。第一种方式是通过将屏幕分成多个块来进行渲染。这些块通过矩形区域来表示,每个矩形对应着一个位图,而这些位图的区域是完全不透明的。你可以为每个矩形分配多个位图,然后将这些矩形合并在一起,形成一个掩码(mask),这个掩码用于指示哪些部分需要渲染。通过这种方式,渲染引擎可以直接跳过那些被后面的物体遮挡住的区域,避免进行不必要的像素写入。

第二种方式是在光栅化(rasterization)过程中通过使用标志位来进行优化。这种方法类似于通过掩码来标记每个块是否已经完全填充像素。例如,假设你有一个8x8的像素块,它的状态可以通过一个位标志来表示,位标志的每一位代表该块中对应像素的状态。这样,光栅化引擎可以快速检查某个块是否完全填充,若已经填充,就直接跳过该块,进行早期退出(early out)。这种方法实际上是现代图形处理单元(GPU)常用的方式,它通过硬件的支持,能够高效地跳过不需要渲染的区域,避免了复杂的语义计算。

总结来说,第一种方式侧重于通过计算和处理矩形区域来跳过不必要的渲染,而第二种方式则通过硬件加速和标志位来实现更高效的早期退出,避免不必要的像素写入。这两种方式都可以有效地提升渲染性能,但后者更符合图形处理单元的工作方式。

多重分发(比如 n 个类型需要 n² 个分支)你经常遇到吗?遇到的话你怎么处理?

在编程中,通常情况下,算法中的n平方(n²)复杂度并不常见。因为n平方的代码和实际的算法表现通常会带来相同的负担。如果一个系统中存在n²种不同的类型,那就意味着如果有30种类型,那么就有900段代码需要测试和编写,工作量会非常大,且很难管理和维护。

因此,当遇到类似n²复杂度的问题时,通常的做法是将其拆分成更基础的类型集合。通过将类型拆分为更少的类别,可以大大减少分支的数量。例如,如果最初有30种类型,可以将其重构为4个基本类别,每个类别再加上一些参数,这样总的组合数量就只有16种,而n²的复杂度也就得到了有效的缓解。

另外,还可以通过重构现有的代码和算法,避免产生n²复杂度,优化设计,从而使得问题不再是n²的复杂度,而是更有效的结构。

虽然n²复杂度在日常编程中并不常见,但当它出现时,确实需要认真考虑解决方案。可以通过不同的策略来应对,具体使用哪种策略,通常要根据实际情况来决定。

你愿意让 switch 语句默认 fall-through,并用显式 break 来结束吗?我觉得这样更清晰,不知道是不是我忽略了什么。

在讨论条件语句的"fall through"(贯穿)时,认为显式的"fall through"语句比隐式的"break"更清晰。对于很多情况,显式的"fall through"语句更加直观和明确。尤其是通常情况下,"break"语句在代码中应该始终是隐式的,而"fall through"则可以显式地指出,这样会更清楚地表达意图。

实际上,大多数情况下,不需要使用"fall through"。如果要使用,通常可以将多个相似的"case"合并成一组来处理,而不必依赖"fall through"语句。例如,直接在多个"case"语句中放入相同的处理逻辑,这样不需要专门的"fall through"部分。

总体而言,大部分情况下,可以通过简洁和直接的方式处理多个"case"语句,避免使用复杂的"fall through"语句,保持代码的简洁性和可读性。

我就怕这样,不过以为你有什么技巧,比如预处理的 alpha 搞点手脚

关于这个问题,原本有些担心是否有某种技巧可以解决,像是使用预计算的 alpha 值来优化。但实际上并没有什么特别的技巧。在讨论这个问题时,询问过了一个被认为是“老派 alpha”专家的意见,他也认为没有什么特别的解决方案。如果他都认为没有,那大概就真的是没有这种技巧了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值