Subsections

层级细节水平和隐式状态

游戏主机和显卡通常不会在管线的多边形渲染阶段遇到瓶颈。通常会是带宽有限。如果有大量 alpha 混合,还可能是填充率问题。多数情况下,图形芯片会在读取纹理上花大量时间,纹理带宽往往就成为瓶颈。正因如此,用多个多边形数量递减的网格来做 LoD[*] 的老办法,怎么也比不上考虑到每个渲染对象 LoD 的实际数据的新技术。渲染时,驱动端的处理能涵盖绝大部分停顿,或者说是,相对实际渲染的内容,处理得太多。HLoD [*] 可以解决图元数量过大,远超必要的驱动调用问题。

有一个基本优化技巧,通过将许多低 LoD 的网格,组织、合并成一个低 LoD 网格。这样就能减少设置渲染调用的时间,比较适用于驱动调用成本较高的情况。典型的超大规模的环境中,使用 HLoD 能够将游戏引擎的工作量降一个量级,场景中需要处理、渲染的实体数量显著下降了。

即便对比之下,渲染的多边形数量可能完全相同,甚至更高。实际引擎也只同时处理了数量相当的实体,稳定性增加了,也可以对美术和代码层面做更精确的针对性优化了。

从零到无限

既然实体可以根据其属性隐式存在,我们倒可以利用 HLoD 技术做些代码优化。传统 LoD 中,相机离物体(或实体)越远,细节和准确度就越差。比如减少多边形数量、纹理大小,甚至是驱动蒙皮网格的骨架数量。游戏逻辑也会退化。远离实体时,它可能会切到更粗粒度的时间步长。AI 行为从 $50Hz$ 的更新频率变到 $1Hz$ 也不是没有过。在 HLoD 实现中,实体越近,又或是对玩家越明显,才是它开始存在的时候。

假设有个射击游戏,玩家正在抵御敌袭,保卫基地。玩家守在一座防空炮塔里,敌方声势浩大,包含上万架敌机联队涌来,每个中队里有上百架。玩家必须击落所有敌机,否则就会被炮火淹没,整个基地也夷为平地。

如果每次每架敌机上都执行完整 AI,上万架敌机成群移动,躲避慢吞吞的火炮,开销大概会很大。但实际上不需要这样。大多数 AI 程序员都会做一个基本假设:AI 只在其攻击范围内才会执行。的确如此,而且与相比笨办法,直截了当就提升了速度。HLoD 则打开了思路,根据玩家对实体的感知方式,改变实体的数量。用更合适的术语,总体 LoD [*]就不错,它能更好地描述背后发生的事情。就算有时没有层级,也仍旧可以变动不同 LoD 之间元素的引用规则。总体 LoD 一词的灵感来自于总体这一术语。一个鸦群[*]为一个计算单元,每只乌鸦都是总体中低 LoD 的子元素。

& 鸦群 [dl] [d] [dr]
乌鸦 & 乌鸦 & 乌鸦

这个游戏的总体 LoD 版本中,雷达上几支飞行中队投射出的光点。但他们接近前不会有自己的实体。一旦有中队进入射程,代表当前中队地光点被移除,并生成新的中队实体。新实体会在雷达上显示出每架飞机的光点。这些飞机还不存在,而是隐含在中队中,就如同中队隐含在更大的联队中一样。只要有中队进入射程,就移除它,直到联队内部计数降为零,就可以删掉自己,因为它已经不代表任何实体了。如果有中队进入更近的范围,它就会把其中的飞机移除,创建独立的飞机实体,最终删除自身。随着飞机越来越近,传统的 LoD 技术开始发挥作用,可渲染对象也能切到更高分辨率,AI 也能以更高规格运行。

& 光点 [dl] [d] [dr]
飞行中队 & 飞行中队 [dl] [d] [dr] & 飞行中队
飞机 & 飞机 [dl] [d] [dr] & 飞机
出仓的飞行员 & 机身 & 机翼

飞机被击中时,会切到受损的类型。未受损前,是满血敌机。如果 AI 对伤害的反应是惧怕,可能就会弹射驾驶员,在世界里增加一个实体。如果机翼被打掉,也会变成一个新实体。一旦坠毁,就可以删除其实体,用硝烟残骸的实体替代,无论是不是模拟的,都要比完整的空气动力学模拟容易处理得多。

如果事态失控,玩家没能阻止进攻,飞机数量剧增,以至于常规的 LoD 系统都无法减轻其影响。此时总体 LoD 仍能带来一点帮助:将飞机收回中队,用集群取代单体进攻。桌游《战锤幻想战役》中,经常有很多小队互相射箭,玩家也常会以小队为进攻单位,不会真的为每个独立的士兵、老鼠、兽人等掷骰子。实际上会根据有多少小队,然后掷出等量骰子,看看有多少攻击通过判定。这就是作为中队攻击的意思。飞机不再攻击,而是计算攻击的成功概率,掷下骰子,攻击命中。LoD 的启发规则(heuristic) 可以调整,所以最近、最靠前的中队一定是最高的 LoD,他们可以有效地独立掷骰子,而在后方的中队则保持非常简单的表示。

这就是游戏开发的魔术[*],是基本的游戏引擎元素。过去,开发者减少了同时攻击的 AI 数量 [*];通过交错排列赛道,减少同屏汽车数量 [*];与其说许多人同屏,但其实所有人都合并成了一个 [*]。这种减少处理的方法很常见。接下来要考虑把它用在所有合适的地方,不不单单是玩家看不到的时候。

快照

减少细节,带来一个老问题。改变游戏逻辑、AI 等方面的 LoD,会丢失掉高细节的历史状态。这样,就需要能够来存储所需,保证玩家有紧密流畅的体验。如果玩家面前的一个高细节中队离开了视线,另一个中队进入,那在前一个中队回到视野时,应当仍旧保留之前的损伤。试想,如果之前把飞机所有玻璃都打碎了,但再次看到时,玻璃却又完好如新。虽然只是外观,但显得特别扎眼,让人出戏。

高细节的实体降到低 LoD 时,应存储一个快照[*],一块小且压缩良好,包含所有必要信息的数据块,能够从低细节实体重建高细节实体。中队离开视野时,存储压缩好的快照,包含损伤的数量、位置,飞机的大致位置等信息。当中队回到视野,读取这些数据,将高细节实体恢复到之前的状态。大多数情况下,有损压缩也没什么问题。哪些窗户?怎么碎的?都不重要,或许只用知道它坏了 2/3 就够了。

高细节 [dr]^-存储 & & 高细节
& 快照 [ur]^-展开

另一个例子,城市漫游类游戏。如果 AI 可以进出车辆,就可以在 AI 进入车辆时从世界中移除它,减少处理时间。如果只是作为乘客,只需保存少量信息就足以重建。如果是司机,可能就需要在他下车前,基于行人的属性创建司机类型,然后再创建快照 [*]

如果车辆远离玩家到一定距离,就可以删除。为了性能,可以改变有快照的车辆的优先级,使其尽量离开玩家视野,这样就能提前删除他们。这类优化在面向对象系统中会很难协调,因为其不鼓励做类型的内部检查。有些游戏,会通过从设计层面重置快照数据,并将其作为游戏元素的一部分,解决掉这个问题。《塞尔达:荒野之息》会在血月期间重置怪物。因此,当玩家回到营地,即便发现所有怪物都跟没打过一样,也不会诧异了。

JIT 快照

如果有辆车,本来只是作为环境元素放进场景,然后忽然得承担更重要的角色,比如卷入一场枪战。那它就得有更多细节,并且得真实可信。因此考虑到玩家对游戏的了解,需要特别注意,生成的新实体不能过于普通或出戏。生成数据的行为可以当作是记录一张快照,以便即时读取。即时快照[*] 能够创建假快照,利用伪随机发生器(或哈希函数),按需创建合适的信息来保证一致性。并且它不需要依赖任何存储的数据,只依赖需求方实体的隐含信息。

除了用全局随机发生器生成新角色,还可以用生成目标的细节作为种子。比如,玩家正靠近一辆车,现在要生成司机和乘客,渲染车内的人。通过查表创建随机角色固然可以。但如果车辆远离了渲染范围,再折返,车内的人看起来可能就不同了,因为他们必须重新生成。但如果使用其他独特属性(如车牌)做种子生成司机与乘客,既不影响生成的快照,也无需内存开销,同时也不用担心对象的生命周期。总是能从无到有,重新生成。

& 载具 [dl]_Seed
乘客存根 [dr]_Seed
@=>[rr] & & 角色
& 快照 [ur]_extract

这种技术常用于地形景观生成器,其中,地形通常用坐标 $(x, y)$ 做种子。那用来生成游戏中第 107 天的天气是否可行呢?生成 Perlin 噪声时,许多算法都会基于某个噪声函数。为了确保地形能够复现,噪声函数必须可重复,所以它需要每次都能产生同样的结果。如果要生成地形,噪声函数最好也具备连贯性。即,输入函数的微小差异,对应输出中的微小变化。不过生成 JIT 快照时,不需要这种特性:微小的输入差异都能让输出大相径庭的哈希函数足矣。

举例来说,在给定地形上生成一栋建筑。先用任意常规的随机生成器,用建筑物坐标作种子。根据建筑所在及其约束,从建筑模板中选择、生成随机的建筑,如同通过从磁盘加载配置文件生成一样。建筑多大?随机选择大、中、小。根据大小,有多少房间?小的 2、3 个,大的 (int)(7+rand*10) 个。重点是,只要随即发生器接受输入的种子,每次相同的过程都有相同的结果。每次 ${223.17,-100.5}$ 的建筑,或许都是一样的四面墙,一样的油漆,破损的窗户,后花园里还有完美的田园小青蛙池塘。

基于 JIT 快照,生成有质感的环境。通过风格表(或风格指南)指导在虚拟空间中生成快照的感觉偏向。假设,有一份城市的风格指南有规定汽车乘员。其中描述道,商人可以与人共乘,但倾向于独乘;家庭会有孩子在后座,由年长的成人驾驶;年轻人通常结伴驾车出行。风格指南能帮助生成的数据变得更可信。加入局部变化,如,将汽车类型与司机类型联系起来。敞篷车由穿着考究的人(或年轻人)驾驶;低趴 [*] 在刻板印象里几乎和他们车主形象绑定;进口车、改装车则由年轻人驾驶。就像太空游戏中,货船里蓬头垢面的船员,指挥豪华游艇的舰长,无畏舰里粗野或精干的佣兵。于是,有了氛围,进一步增加点惊喜,整个游戏便栩栩如生。

JIT 快照还能用来保持多样性。依赖风格指南,每个人都没能给玩家留下印象,所以每个人都一样。显现这些偏向,又不严格遵守,就能建立更富有质感的环境。如果环境中一直有大量完全不同的人,就没什么好坚持的,无法识别出什么模式。大脑在没有模式时,倾向于看到噪声,千篇一律。即便最多样的虚拟世界,如果有太多的内容扎堆在一起,也会显得很平淡。沿着街道走,是否能发现有完全一样的地砖?或许可以,但也能看到的些许损坏、腐烂、灰尘、出错、瑕疵。为了让环境真实可信,必须让它看起来像是有人花了大力气,力图让全部细节符合预期。

替代维度

同所有事物一样,去掉一些假设,就能发现工具的其他用途。熟悉 LoD 系统的话,就不难发现,其显式的约束条件,通常是一些空间中的距离函数。是时候抛弃这个假设,并分析其背后时如何运作的了。

首先,去掉距离的假设,条件就可以推断为某种线性度量。这个值通常由函数生成,该函数能获取目标物体与相机的距离。舍弃距离假设,还要对接下来要做的事情有个更基本的认识。现在,我们正在用单一的运行时变量,控制游戏中实体的呈现。虽然现在已经有很多游戏状态由运行时变量控制,但当前,监控变量(或轴)反映出的表现是被动的。这种表现通常是一些图形、逻辑的 LoD,但也可能是对实体很重要的东西,例如它本身是否存在。

真正的指标

虽然通常会用距离来确定某个东西的 LoD。但他其实并不是真正的指标,只算是密切相关。并且其实是反过来的。真正衡量 LoD 的标准,应该是实体在我们的感知中的占比。一个大而远的实体占据的感知,应当和小而近的一样多。我们一直讨论的 HLoD,用房间里的大象描述这种情况再贴切不过。雷达上有远方敌机投射的光点。它们占用的感知与一个中队相当,而中队在射击范围内占用的感知空间和一架敌机相当。

理解这个概念:LoD 应该由玩家在它所处的范围内如何感知事物来定义。如果能够内化这一点,就能选对 LoD 间的界限。

超越空间

再来考虑,还可以计算哪些变量?哪些变量,能够去除冗余的细节?任何能省去不必要处理的机会都不能放过。如果游戏中某些元素不是玩家当前关心的,或者很容易忘掉的,就可以让其消解。把玩家关心事物的概率当作尺度,玩家的记忆和注意力就能被量化,就可以用来决定最终表现。

已知有个玩家关注的实体,但是实际看不到,却又玩家的感知中占很大比重。这种利害关系,使其在细节方面有着更高优先级,远高于本来应有的水平。例如,玩家在暗杀游戏中追踪的人物,或许在任务开始时只被发现了一次。但其在整个任务中必须保持高度一致,因为它是玩家最关心的对象,仅次于生存等原始需求。即使角色躲进人群,很久之后才被发现,也必须和玩家上次看到的样子一致。

试想,玩家多久会忘记相对重要的事情?这些信息有助于尽可能减少内存占用。如果读者玩过《侠盗猎车手4》,可能有注意到:只要不看车,车就会消失。尝试转身几次之后,也许会发现,每次看向车的时候,它们似乎都不一样。这就是 TLoD [*] 的绝佳案例。玩家撞过、开过、停过的汽车仍停在原地,本质上,是玩家把它们放在那里。玩家与它们互动过,因而玩家很可能记得它们的位置。然而周围的车辆,无论警车还是民用车,都没那么重要。而且通常没有任何特殊状态,所以在玩家移开视线时就会消失。

另一种极端,有些游戏会记住玩家所做的一切。玩家在游戏的前几分钟杀敌,掠夺,物品散落一地;一百个小时后回来,物品仍在离开时的地方。这种的游戏存储了大量微小的细节,它们需要仔细存储,否则会引发持续的让人崩溃的性能问题。其中一个方法是使用空间映射的快照,能将这种级别的,对玩家与游戏互动的关注度合理化。

除了前面提到的时间,有些元素的细节则由其他变量决定:如玩家的进度,拥有某物的数量,或某些行为的次数。比方说,典型的交易动画可能会被剪得越来越短,因为游戏会以玩家最近交易的次数为轴,缩减由该事件导致的非交互部分的时长。这类功能很容易实现,且从玩家那边收效巨大。例如只在一定量的单项交易后才可以开启多项交易。实际上,可以通过这些抽象 LoD 风格的维度去设置游戏元素,响应状态,触发教程,提示玩家,扩展游戏选项等。还能以玩家的熟练度为轴,去处理游玩法深度和复杂度的 LoD。

这样操作游戏当前状态更安全,能够避免过渡错误。从一个状态到另一状态过渡时,某些属性可能已经设置为 true,但反向过渡时,没能将其设置回 false,就可能引发这类错误。但其实可以将状态视作维度上的隐含信息。状态,很容易在错误的时间,错误的方式被修改。如果状态与其他变量相关联,即,状态是其他状态的函数,就更难出现不一致问题了。

举个例子,菜单系统中,所有变换都是可逆的。有时我们会发现向下两级菜单,再返回一级,会返回到最开始的界面。比方说,选项-选中音量滑块-返回,此时选项菜单完全关闭了。这类错误并不少见,UI 系统中有大量不同的交互层。对比玩法,UI 中获取输入的方式更为隐晦。菜单的常见问题之一,就是关于某帧输入的所有权。例如,如果玩家同时按下前进和后退,状态机实现的 UI 可能会进入先捕获的按键响应。另一种情况,先收到进入事件,然后在下一级菜单收到返回事件。还有概率极小但更差的情况,但也不是没发生过:菜单同时过渡到两个不同菜单。有时,菜单还可能会因为外部力量进入过渡动画,如在不同的执行线程中捕获到玩家输入,游戏状态就会不连续,没有响应。比如在网游大厅,所有玩家都准备好了,但在有人比赛开始前打开了选项界面,忽然房主连接断开。如果以传统状态机处理菜单,一旦退出选项界面,玩家应该回到哪里?通常,大厅会让玩家返回到服务器搜索界面,但现在大厅已经没了,取而代之的是一片虚无。这便是使用简单维度替代状态机的优势。更简单,进而错误更少,响应更快。

总体 LoD (如何减少实例数量)

这个术语不太好,希望哪天有人能想到更好的。但恐怕直到没人用了,它也都不需要命名。本书写作期间,已经有很多应用它的案例了。这里指的不是那类有成百上千飞机交战游戏,交战双方发射漫天的导弹,带来大量的 GPU 粒子特效。这里要讨论的游戏,实际看起来很简单。比方说有个园艺模拟器,不知为何,植物的每片叶子都建模为单个实例;四处授粉的每一只昆虫也都是单个实例;植物扎根的每块土壤也都是单个实例;埋下的每颗种子也都是单个实例。每个实例都有自己的生命周期、组件、动画、内部状态,整个系统的复杂度不断增加。

假设有个虚构的种田游戏,可以在里面种小麦。游戏中有片由 $100 \times 100 $ 的地块组成的麦田。有些游戏中,这些种植小麦的地块是实例,上面生长的小麦也是实例。这样做其实没什么道理,可以把田地的数据量缩减到非常小。实际需要了解田地和小麦的哪些信息?需要知道小麦的位置吗?并不,它就在平铺的网格中。需要知道地块上是否有小麦吗?是的,但不需要用对象实例表示。需要用对象渲染小麦吗?小麦会随风舞动,所以在它需要被吹动时,跟踪位置并维持动量吗?也不用,几乎所有情况,都能通过作弊,以低成本实现足够的可信度。没有每片草的实例,也能正常渲染草地。一片麦田正确的数据格式,可以简化到 10,000 个 unsigned char,0 表示没有小麦,[1, 100] 表示小麦的生长情况。小麦没有坐标,坐标上方有小麦。

如果在游戏《我的世界》里有一组块,但背包的实例不足 64,那就只有一个类型,一个倍数。即是一组(或一叠)。如果在餐厅模拟游戏里有一摞盘子,且实例数目小于 10,就可以换成一摞盘子的对象,用一个 int 表示当前盘子数目。

这方面的基本原则,是确保这个世界中有“插槽”。无论是手动放置,还是用模式生成,持续跟踪其中的内容,取代掉直接将物品放置在世界这种方式。学习用路人称呼事物的方式。如果问起路人房间里会有什么时,大概不会说是沙发、书架、两个扶手椅、茶几、电视柜、书架等等。不,他大概会说,家具。跳出框框,从外部审视游戏。用玩家的方式描述屏幕中的内容。看他们如何描述背包。从玩家的视角,了解玩家的心理模型,并匹配它,就会找到占用玩家感知空间的内容及其关联。

标准化数据时,可以注意观察,行如何与容器对齐。若有任何形式的网格,从一维到四维,都值得去研究和利用。也不要忽视其他网格,如三角网格、六边形网格。尤其是六边形网格,名字绕口,但它可以通过不同的遍历函数,表示为一个正方形网格。也不要因网格在字面上不规则就绕开,一些基于网格的游戏中,单元格的中心会被扰动,以使其看起来更自然。但游戏代码仍可以严格基于网格,从而将问题放进更好的解决域中,也更容易让玩家推导出能做什么,不能做什么。