如何将游戏移植到PSVita可以改善整体性能

图片

在这个级别上可以有成千上万的敌人。

防御者的任务:被遗忘的DX之谷一直存在速度方面的长期问题,我终于设法解决了这些问题。 大幅提高速度的主要动力是我们在PlayStation Vita上的端口 。 该游戏已经在PC上发布,并且在带有PS4Xbox One上运行得很好,甚至不能完美运行。 但是如果不对游戏进行重大改进,我们将永远无法在Vita上启动它。

当游戏变慢时,Internet上的评论员通常会指责一种编程语言或引擎。 的确,诸如C#和Java之类的语言比C和C ++更昂贵,而诸如Unity之类的工具则具有无法解决的问题,例如垃圾回收。 实际上,人们之所以提出这样的解释,是因为语言和引擎是软件最明显的特性。 但是真正的性能杀手可能是与架构无关的愚蠢的微小细节。

0.分析工具


只有一种真正的方法可以使游戏更快-执行性能分析。 找出计算机花了太多时间在计算机上的时间,使它花在计算机上的时间减少了,甚至更好,使计算机根本不浪费时间。

最简单的配置工具是标准Windows系统监视器(性能监视器):


实际上,这是一个相当灵活的工具,使用起来非常容易。 只需按Ctrl + Alt + Delete,打开“任务管理器”,然后单击“性能”选项卡。 但是,不要运行太多其他程序。 如果仔细观察,您可以轻松检测到CPU使用率的峰值,甚至内存泄漏。 这是一种无用的方法,但它可能是寻找慢速位置的第一步。

Defender的Quest用高级Haxe语言编写,并已编译成其他语言(我的主要目标是C ++)。 这意味着任何能够分析C ++的工具都可以分析我的Haxe生成的C ++代码。 因此,当我想了解问题的原因时,我从Visual Studio启动了Performance Explorer:


此外,不同的控制台都有自己的配置工具,这非常方便,但是由于NDA的原因,我无法告诉您任何有关它们的信息。 但是,如果您可以访问它们,请务必使用它们!

我没有写有关如何使用性能管理器之类的性能分析工具的可怕教程,而是留下了官方文档的链接并转到了主要主题-令人惊奇的事情,这些事情大大提高了生产率,以及如何设法找到它们!

1.问题检测


游戏的性能不仅在于速度本身,还在于其感知能力。 Defender's Quest是一款塔防游戏,以60 FPS渲染,但可变的游戏速度在1 / 4x至16x范围内。 无论游戏速度如何,该模拟都使用固定的时间戳 ,该时间戳具有1倍模拟时间的每秒60次更新。 也就是说,如果您以16倍的速度运行游戏,则更新逻辑实际上将以960 FPS的频率工作。 老实说,这些对游戏的要求太高了! 但是正是我创造了这种模式,如果事实证明它很慢,那么玩家肯定会注意到它。

在游戏中有一个这样的水平:


这是最终的奖金战“无尽2”,也是“我的个人噩梦”。 屏幕截图是在“新游戏+”模式下拍摄的,其中敌人不仅强大得多,而且还具有恢复健康的功能。 玩家最喜欢的策略是将巨龙抽到最高咆哮等级(使敌人昏迷的AOE攻击),并在其后方放一些骑士,并向其抽出最大数量的“击退”,以推动通过巨龙的每个人回到其行动区域。 累积的影响是,一大群怪物会无休止地停留在一个地方,这比玩家杀死他们要生存的时间长得多。 由于玩家需要等待波动而不是消灭波动才能获得奖励和成就,因此这种策略绝对有效且出色-这正是我所激发的玩家的行为。

不幸的是,事实证明,这也是性能下降的原因, 尤其是当玩家想要以16倍或8倍的速度玩游戏时。 当然,只有最顽固的玩家会尝试在New Game +中获得“无尽2级”的“百潮”成就,但是他们只是那些说游戏声音最大的人,所以我希望他们感到高兴。

这只是带有一堆精灵的2D游戏,这可能有什么问题呢?

的确如此。 让我们做对。

2.冲突解决


看一下此屏幕截图:


看到护林员附近的这个百吉饼吗? 这是她的影响范围-请注意,还有一个死区,在死区中无法击中目标。 每个级别都有自己的攻击区域,每个后卫都有不同的规模区域,具体取决于提升级别和个人参数。 从理论上讲,每位防御者都可以针对其触及范围内的任何敌人。 某些类型的敌人也是如此。 地图上最多可以有36个防御者(不包括主要角色Azru),但是敌人的数量没有上限。 每个防御者和敌人都有一个可能的目标列表,这些目标是根据在每个更新步骤检查该区域的调用而创建的(减去当前无法攻击的对象的逻辑边界,等等)。

如今,视频处理器的速度非常快-如果您不对它们进行过多处理,则它们可以处理几乎任何数量的多边形。 但是,即使最快的CPU也很容易在简单的过程中出现“瓶颈”,尤其是那些指数增长的过程。 这就是为什么2D游戏比更漂亮的3D游戏要慢的原因-不是因为程序员无法应付(也许至少在我的情况下也是如此),而是原则上因为逻辑有时会更昂贵,比绘图! 问题不是屏幕上有多少对象,而是它们做什么

让我们探索并加速碰撞识别。 为了进行比较,我要说的是,在进行优化之前,冲突识别在主战周期中占用了大约50%的CPU时间。 优化后,不到5%。

全部与象限树有关


解决慢速碰撞识别问题的主要方法是分割空间 -从一开始我们就使用了象限树的高质量实现。 本质上,它有效地分隔了空间,因此可以跳过许多可选的碰撞检查。

在每个帧中,我们更新整个象限树(QuadTree)以跟踪每个对象的位置,并且当敌人或防御者想要瞄准某人时,他会向QuadTree请求附近对象的列表。 但是探查器告诉我们,这两个操作都比它们应该的慢得多。

怎么了

事实证明-很多。

字符串输入


由于我将敌人和防御者都留在一棵象限的树中,因此我必须指出我要寻找的东西,这样做是这样的:

var things:Array<XY> = _qtree.queryRange(zone.bounds, "e"); //"e" - "enemy"

用程序员的术语来说,这称为字符串键入代码,除其他原因外,这很不好,因为字符串比较总是比整数比较慢。

我迅速选择了整数常量,并用以下代码替换了代码:

var things:Array<XY> = _qtree.queryRange(zone.bounds, QuadTree.ENEMY);

(是的,使用Enum Abstract来实现最大的类型安全可能是值得的,但是我很着急,我需要先完成工作。)

单单此更改就做出了巨大的贡献,因为每次有人需要新的目标列表时,都会连续不断地调用此函数。

数组与向量


看一下这个:

var things:Array<XY>

Haxe数组与ActionScript和JS数组非常相似,因为它们是可调整大小的对象的集合,但是在Haxe中,它们是强类型的。

但是,还有另一种数据结构对诸如cpp之类的静态目标语言(即haxe.ds.Vector )更为有效。 Haxe向量在本质上与数组相同,除了创建它们时它们具有固定的大小。

由于我的象限树已经有固定的体积,因此我用向量替换了数组以实现明显的速度提高。

只索取您需要的东西


以前,我的queryRange函数返回对象列表( XY实例)。 它们包含所引用游戏对象的x / y坐标及其唯一的整数标识符(主数组中的搜索索引)。 执行请求的游戏对象收到了这些XY,提取了一个整数标识符以获取其目标,然后将其余的遗忘了。

那么,为什么要为每个QuadTree节点递归地将所有这些引用传递给XY对象,甚至每帧960次? 返回一个整数标识符列表就足够了。

专业提示:整数的传输要快于几乎所有其他数据类型!

与其他更正相比,这很简单,但是性能提升仍然很明显,因为非常积极地使用了此内部循环。

尾递归优化


有一个优雅的东西叫做尾叫优化 。 很难解释,因此我将向您更好地举例说明。

那是:

nw.queryRange(Range, -1, result);
ne.queryRange(Range, -1, result);
sw.queryRange(Range, -1, result);
se.queryRange(Range, -1, result);
return result;


它变成了:

 return se.queryRange(Range, filter, sw.queryRange(Range, filter, ne.queryRange(Range, filter, nw.queryRange(Range, filter, result)))); 

该代码返回相同的逻辑结果,但是根据分析器,至少在转换为cpp时,第二个选项更快。 这两个示例都执行完全相同的逻辑-更改“结果”数据结构,然后将其传递给下一个函数,然后再返回。 当我们递归执行此操作时,我们可以避免编译器生成临时引用,因为它可以立即简单地返回上一个函数的结果,而不必执行额外的步骤。 或类似的东西。 我不完全了解它的工作原理,因此请阅读上面链接中的文章。

(据我所知,当前版本的Haxe编译器没有尾部递归优化功能,也就是说,它可能是C ++编译器的工作-因此,如果在不使用cpp转换Haxe代码时此技巧不起作用,请不要感到惊讶。)

对象池


如果我需要准确的结果,则必须在每次更新调用时再次销毁并重建QuadTree。 创建新的QuadTree实例是一项相当常见的任务,但是由于拥有大量新的AABB和XY对象,依赖于它们的QuadTree导致严重的内存过载。 由于这些对象非常简单,因此事先分配大量此类对象并不断重复使用它们是合乎逻辑的。 这称为对象池

我曾经做过这样的事情:

nw = new QuadTree( new AABB( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( new AABB( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( new AABB( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( new AABB( cx + hs2x, cy + hs2y, hs2x, hs2y) );


但是后来我用以下代码替换了代码:

nw = new QuadTree( AABB.get( cx - hs2x, cy - hs2y, hs2x, hs2y) );
ne = new QuadTree( AABB.get( cx + hs2x, cy - hs2y, hs2x, hs2y) );
sw = new QuadTree( AABB.get( cx - hs2x, cy + hs2y, hs2x, hs2y) );
se = new QuadTree( AABB.get( cx + hs2x, cy + hs2y, hs2x, hs2y) );


我们使用开源的HaxeFlixel框架,因此我们使用FlxPool HaxeFlixel类实现了这一点。 在进行如此高度专业化的优化的情况下,我经常用自己的实现(例如与QuadTrees一样)替换一些基本的Flixel元素(例如,冲突识别),但是FlxPool比我自己编写的所有内容都要好,并且它确实可以满足其需要。

必要时进行专业化


XY对象是具有属性xyint_id的简单类。 由于它是在特别活跃使用的内部循环中使用的,因此我可以通过将所有这些数据移到提供与Vector<XY>相同功能的特殊数据结构中,从而节省大量内存分配命令和操作。 我调用了这个新的XYVector类,结果可以在这里看到。 这是一个非常专业的应用程序,但同时又不灵活,但是它为我们提供了一些速度上的改进。

内建功能


现在,在完成碰撞识别的广泛阶段之后,我们需要进行大量检查以找出实际碰撞的对象。 在可能的情况下,我尝试比较点和图,而不是图和图,但是有时我必须做后者。 无论如何,所有这一切都需要进行自己的特殊检查:

 private static function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; } 


只需使用一个inline即可改善所有这些功能:

 private static inline function _collide_circleCircle(a:Zone, b:Zone):Bool { var dx:Float = a.centerX - b.centerX; var dy:Float = a.centerY - b.centerY; var d2:Float = (dx * dx) + (dy * dy); var r2:Float = (a.radius2) + (b.radius2); return d2 < r2; } 


当我们向函数添加内联时,我们告诉编译器复制并粘贴此代码,并在使用时粘贴变量,而不是对单独的函数进行外部调用,这会导致不必要的成本。 嵌入并非总是适用(例如,它会使代码数量增加),但是对于反复调用小函数的情况而言,它是理想的选择。

我们想到冲突


这里真正的教训是,在现实世界中,优化并不总是相同的类型。 这些修补程序是先进技术,廉价黑客,应用逻辑建议以及消除愚蠢错误的混合体。 总的来说,所有这些都为我们带来了性能提升。

但是仍然- 测量七次,砍一遍!

尽管代码丑陋和愚蠢,但每两个帧调用一次耗时0.001 ms的长达2个小时的功能优化是不值得的。

3.对所有内容进行排序


实际上,这是我最后的改进之一,但是事实证明它是如此的有利以至于它应该拥有自己的头衔。 此外,它是最简单的,并且反复证明了自己。 探查器向我展示了一个我根本无法改进的过程-主draw()循环,这花费了太多时间。 原因是该函数在渲染之前对所有屏幕元素进行了排序 -即, 所有sprite进行排序所花的时间比绘制它们还要长!

如果您查看游戏中的屏幕截图,您会看到所有敌人和防御者都先按y排序,然后按x排序,以便当我们从屏幕的左上角移动到右下角时,这些元素从后到前,从左到右彼此重叠。

胜过排序的一种方法是简单地将渲染排序通过框架。 对于某些昂贵的功能,这是一个有用的技巧,但它立即导致非常明显的视觉错误,因此不适合我们。

最后,该决定来自HaxeFlixel维护者Jens Fisher之一 。 他问:“您确定您使用的排序算法对几乎排序的数组快速吗?”

不行 原来没有。 我使用了Haxe标准库中的数组排序(我认为这是合并排序 -在一般情况下是个不错的选择。但是我有一个非常特殊的情况。在每个帧中排序时,即使有很多精灵,排序位置也只会改变非常少量的精灵。因此,我用inserts进行排序替换了旧的排序调用,然后开始运行 -速度迅速提高。

4.其他技术问题


冲突识别和排序是update()draw()逻辑的重大胜利,但在积极使用的内部循环中隐藏了更多陷阱。

标准()和强制转换


在不同的“热门”内部循环中,我有一个类似的代码:

 if(Std.is(something,Type)) { var typed:Type = cast(something,Type); } 


在Haxe语言中, Std.is()告诉我们对象是属于特定类型(Type)还是类(Class),并且Std.is()在程序执行期间将其Std.is()转换为特定类型。

有安全的和不受保护的强制转换版本-安全的强制转换会降低性能,但不受保护的强制转换则不会。

安全: cast(something, Type);

未受保护: var typed:Type = cast something;

当不安全的转换尝试失败时,我们得到null,而安全的转换抛出异常。 但是,如果我们不打算捕捉异常,那么进行安全转换有什么意义呢? 没有捕获,该操作仍然会失败,但是运行速度会更慢。

此外, Std.is()安全转换之前先进行Std.is()检查是没有意义的。 使用安全类型转换的唯一原因是可以保证的异常,但是如果我们在类型转换之前检查类型,我们已经保证了转换不会失败!

在检查Std.is()之后,我可以使用Std.is()加快处理速度。 但是,如果我根本不需要检查类的类型,为什么我们需要重写相同的东西?

假设我有一个CreatureSprite ,它可以是DefenderSpriteEnemySprite的子类的实例。 无需调用Std.is(this,DefenderSprite)我们可以在CreatureSprite创建一个整数字段,并使用诸如CreatureType.DEFENDERCreatureType.ENEMY类的值进行检查,以更快地进行检查。

我重复一遍,只有在明显记录有明显放缓的地方才值得修复。

顺便说一句,您可以在Haxe手册中阅读更多有关安全 无保护的演员表的信息。

宇宙的序列化/反序列化


在代码中找到这样的地方很烦人:

 function copy():SomeClass { return SomeClass.fromXML(this.toXML()); } 

是的 要复制对象,我们将其序列化为XML ,然后解析所有这些XML ,然后立即丢弃该XML并返回一个新对象。 这可能是复制对象的最慢方法,此外,它会使内存过载。 最初,我编写了XML调用来从磁盘保存和加载,我认为我懒得编写正确的复制过程。

如果很少使用此功能,则可能一切正常,但这些调用出现在游戏过程中的不适当位置。 所以我坐下来,开始编写和测试正确的复印功能。

对空说不


相等检查是否为null经常被使用,但是当将Haxe转换为cpp时,允许不定值的对象会导致不必要的开销,如果编译器可以假定该对象永远不会为null,则不会产生不必要的开销。 这对于像Int这样的基本类型尤其如此-Haxe通过其“打包”在静态目标系统中为其实现了未定义值的有效性,这种情况不仅发生在明确声明为null的变量( var myVar:Null<Int> )中,而且用于帮助程序选项( ?myParam:Int )之类的东西。 此外,空检查本身也会造成不必要的浪费。

仅通过查看代码并考虑替代方案,我就能解决其中的一些问题-我可以做一个更简单的检查,当对象为null时,情况总是如此吗? 我可以在函数调用链中更早地捕获null并向子调用传递简单的整数或布尔标志吗? 我是否可以对所有内容进行结构化处理,以使该值永远不能保证为空? 依此类推。 我们无法完全消除空值检查和可空值,但是将它们带出函数对我有很大帮助。

5.下载时间


在PSVita上,某些场景的加载时间存在特殊的严重问题。 分析时发现,原因主要归结为文本光栅化,不必要的软件渲染,昂贵的按钮渲染等。

文字内容


HaxeFlixel基于OpenFL ,它具有出色而可靠的TextField。 但是我以不完美的方式使用了FlxText对象-FlxText对象具有一个内部OpenFL文本字段,该文本字段已栅格化。 但是,事实证明,我不需要大多数这些复杂的文本函数,但是由于设置UI系统的愚蠢方式,必须在定位所有其他对象之前呈现文本字段。 例如,在加载弹出窗口时,这会导致较小但明显的跳跃。

在这里,我做了三处更正-首先,我用光栅字体替换了尽可能多的文本。 Flixel内置了对各种光栅字体格式的支持,包括AngelCode的BMFont ,这使得使用Unicode,样式和字距调整变得容易,但是光栅文本API与纯文本API略有不同,因此我不得不编写一个小型包装类来简化过渡。 (我给它起了一个合适的名字FlxUITextHack )。

这稍微改善了工作-位图字体的渲染速度非常快-但稍微增加了复杂度:我不得不专门准备单独的字符集并根据语言环境添加切换逻辑,而不仅仅是设置一个可以完成所有工作的文本框。

第二个解决方法是创建一个新的UI对象,该对象是文本的简单占位符 ,但具有与文本相同的公共属性。 我将其称为“文本区域”,并在UI库中为其创建了一个新类,以便我的UI系统可以使用与实际文本字段相同的方式来使用这些文本区域,但是在计算出所有其他区域的大小和位置之前,它不会呈现任何内容。 然后,当准备好场景时,我开始用真实的文本字段(或位图字体的文本字段)替换这些文本区域的过程。

第三个更正涉及感知。 如果输入和反应之间存在暂停(即使是半秒),则玩家会认为这是制动。 因此,我尝试查找所有输入直到下一次转换都存在延迟的场景,并添加了带有单词“ Loading ...”的半透明层或仅添加了没有文本的层。 这种简单的校正极大地改善了游戏响应能力,因为即使在显示菜单上花费一些时间,在玩家触摸控件后也会立即发生某些事情。

软件渲染


大多数菜单结合使用软件缩放和9片段合成。 发生这种情况的原因是,在PC版本中,存在与分辨率无关的UI,该UI可以按4:3和16:9的宽高比进行相应缩放。 但是在PSVita上,我们已经知道分辨率,也就是说,我们不需要所有这些额外的高分辨率资源和实时缩放算法。 我们可以简单地将资源预渲染为确切的分辨率,然后将其放置在屏幕上。

首先,我进入了Vita条件的UI标记,该条件将游戏切换为使用一组并行资源。 然后,我需要创建为一个许可准备的这些资源。 事实证明, HaxeFlixel调试器在这里非常有用-我在其中添加了脚本,以便它仅将栅格缓存刷新到磁盘。 然后,我为Windows创建了一个特殊的构建配置,以模拟对Vita的许可,依次打开所有游戏菜单,切换到调试器,并启动了按比例缩放版本的资源的导出命令,即现成的PNG。 然后,我将它们重命名,并将其用作Vita的资源。

按钮渲染


我的UI系统在使用按钮时遇到了一个真正的问题-创建按钮时,按钮呈现了默认的资源集,过了一会儿,它们被调整了大小(并再次呈现)UI引导代码,有时甚至是第三次,在加载整个UI之前。 我通过添加将按钮的渲染延迟到最后阶段的选项来解决了这个问题。

可选的文字扫描


该杂志的加载特别慢。 起初,我认为问题出在文本字段,但是没有。 杂志文本可能包含指向其他页面的链接,这些链接由原始文本本身中嵌入的特殊字符指示。 这些字符后来被切出并用于计算链接的位置。

原来如此。 我扫描了每个文本字段,以查找并用格式正确的链接替换了这些字符,甚至没有先检查此文本字段中是否有任何特殊字符! 更糟糕的是,根据设计,链接在内容页面上使用,但我在每个页面的每个文本框中都选中了它们。

我设法使用形式为“此文本框是否完全使用链接”的if构造来解决所有这些检查。 这个问题的答案通常是“否”。 最后,加载时间最长的页面就是索引页面。 由于它在日记菜单中从未更改,因此为什么不缓存它?

6.内存分析


速度不仅仅是CPU。内存也可能是个问题,尤其是在Vita等较弱的平台上。即使您设法摆脱了最后一次内存泄漏,您在垃圾回收环境中使用锯齿内存的问题仍然可能存在问题。

锯齿内存使用情况是什么?垃圾收集器的工作方式如下:不使用的数据和对象随时间累积,并定期清除。但是您对发生这种情况的时间并没有明确的控制,因此内存使用情况图看起来像锯:



取出垃圾


由于清洁不是立即进行的,因此您使用的RAM总量通常会超过实际需要。但是,如果超出系统RAM 总量,则可能会发生以下两种情况之一-在PC上,您可能仅使用页面文件,即,将部分硬盘空间临时转换为虚拟RAM。在有限的内存环境(例如控制台)中,另一种选择是使应用程序崩溃,即使没有足够多的可悲的字节对。即使您不使用这些字节,也将发生这种情况,并且很快将对它们进行垃圾回收!

Haxe的优点在于它是完全开源的,也就是说,您不会像Unity那样被锁定在无法修复的黑匣子中。而且hxcpp后端直接从API提供了广泛的垃圾收集管理!

为了保持在给定的限制内,我们使用它们在大容量存储后立即清除内存:

cpp.vm.Gc.run(false); // (true/false - / )

如果您不知道自己在做什么,则不要非自愿地使用它,但是在需要这些工具时很方便。

7.通过设计的解决方法


所有这些性能改进都不足以优化PC上的游戏,但我们还尝试发布PSVita的版本,并且我们对Nintendo Switch制定了长期计划,因此我们不得不压缩从代码到发布的所有内容。

但是,当您只关注技术性技巧而忘记了简单的设计更改可以大大改善这种情况时,通常会出现“隧道视野”

高速加速效果


在16倍速下,许多特效发生得如此之快,以至于玩家甚至看不到它们。我们已经使用了一个技巧-随着游戏速度的提高,Azra的闪电变得更容易,并且用于AOE攻击的粒子数量更低。我们通过禁用高速伤害数字和其他类似技巧来补充此技术。

我们还意识到,在某些情况下,屏幕上有太多对象时,16倍速实际上可能比8倍速,因此,当敌人数量增加到一定限制时,我们会自动将游戏速度降低到8倍或4倍。在实践中,玩家可能只会在《无尽之战2》中看到它。这可以实现流畅的性能和渲染,而不会导致CPU过载。

我们还专门针对平台使用了限制。在Vita上,当Azra触发或加速角色并使用其他类似技巧时,我们将跳过闪电效果。

隐藏身体


而《无尽之战2》右下角的一大堆敌人又如何呢?实际上有成百上千敌人在一个上面互相吸引。我们为什么不跳过那些甚至看不到的渲染呢?

这是一个狡猾的设计技巧,需要狡猾的编程,因为我们需要一个定义隐藏对象的智能算法。

这些游戏大多数都是使用艺术家的算法绘制 -绘制列表中的先前对象被其后的所有内容所阻挡。

通过反转渲染艺术家算法的顺序,您可以生成“封面地图”并找出应该隐藏的内容。我创建了一个具有8个级别的“暗度”(只是二维字节数组)的假“画布”,其分辨率比真实战场要低得多。从渲染列表末尾开始,我们将每个对象的边界框“绘制”在画布上,对于低分辨率边界框所覆盖的每个“像素”,将点的“暗度”增加1。同时,我们将读取要绘制区域的平均“暗度”。实际上,我们可以预测每个对象在一次真正的绘图调用中将经历多少次重绘

如果预计的重击次数足够高,那么我将敌人标记为“被掩埋”,具有两个阈值-完全掩盖,即完全不可见,或部分掩埋,即将他绘制出来,但不绘制任何生命线。

(顺便说一下,这是检查重绘功能。)

为了使此功能正常运行,必须正确配置隐藏贴图的分辨率。如果太大,那么我们将不得不执行一堆简化的绘制调用,如果太小,那么我们将过于主动地隐藏对象并获得视觉错误。如果正确选择了卡片,效果几乎不明显,但是速度的提高非常明显-没有比完全不绘制更快的绘制方法

比刹车更好的预紧力


在战斗中,我注意到频繁制动,我确信这是由于垃圾收集暂停造成的。但是,分析表明事实并非如此。进一步的测试表明,这是在敌人产生一波巨浪的开始时发生的,后来我发现,这仅是在之前从未存在的一波敌人浪中发生的显然,一些敌人的配置代码导致了问题,当然,在进行性能分析时,在图形设置中发现了“热”功能。我开始研究复杂的多线程下载设置,但后来我意识到我可以将所有敌人的图形加载程序放入战斗预加载中。另外,这些下载量很小,即使是在最慢的平台上,也只占不到总战斗加载时间的一秒,但在游戏过程中却避免了非常明显的刹车。

我们为以后保留库存


如果您在内存有限的环境中工作,则可以使用我们这个行业的古老技巧-那样分配大量的内存,然后在项目结束之前将其遗忘。在项目结束时,由于浪费了全部可用的内存预算,因此可以节省您的资金。

我们发现自己处在这种情况下-我们只需要十几个字节就可以将PSVita的程序集保存下来,但是,该死-我们忘记了这个技巧,因此陷入了困境!剩下的唯一选择是数周的绝望和痛苦的代码手术!

但请稍等!我的(失败的)优化之一是加载尽可能多的永久资源将它们存储在内存中,因为我错误地认为较大的加载时间是由程序执行期间读取资源引起的。事实证明并非如此,因此几乎所有这些有关预加载和永久存储的额外调用都可以完全删除,而我仍然有可用的内存!

摆脱我们不使用的东西


在为PSVita进行构建时,我们特别清楚,我们不需要一堆东西。由于分辨率较低,因此无法区分源图形模式和HD图形模式,因此对于所有精灵,我们都使用原始图形。我们还设法借助特殊的像素着色器改进了替换调色板的功能(之前我们使用程序渲染功能)。

另一个例子是战斗地图本身-在PC和家用控制台上,我们将一堆拼贴卡彼此叠放在一起以创建多层地图。但是由于地图永远不会改变,因此在Vita上,我们可以将所有内容烘焙到一张完成的图像中,以便在一次绘制调用中将其调用。

除了额外的资源外,游戏还有许多额外的呼叫,例如,防御者和敌人即使没有再生能力每帧都会发出再生信号如果用户界面已针对此类生物打开​​,则将在每个帧中对其进行重绘

还有其他六种小型算法的示例,这些算法在“热”函数中计算内容,但从不返回结果。通常,这些是在开发的早期阶段创建结构的结果,因此我们将其删除。

钠盐


这个案子很有趣。分析器报告说,计算角度需要很多时间。这是在探查器中生成的Haxe C ++代码:


这是采用类似值-90并将其转换为的函数之一270。有时您会得到诸如之类的值-724,在几个周期内这些会减少为4

由于某种原因,一个值被传递给此函数-2147483648


让我们进行计算。如果在每个周期中将360加到-2147483648,那么它将花费大约5,965,233次迭代,直到变得大于0并完成周期为止。顺便说一下,每次弹丸(或其他物体)改变角度时,每次更新都执行此循环(不是在每个帧中 -在每次更新中!)。

当然,这是我的错,因为我传入了一个值NaN-一个特殊的值,表示“不是数字”(不是数字),通常表示以前在代码中发生过错误。如果不先检查就将其带入整数,则会发生此类奇怪的事情。

作为临时解决方案,我添加了一张支票Math.isNan(),这样的事件(很少发生,但不可避免)会重置角度。同时,我继续寻找错误的根本原因,找到了它,延迟立即消失了。事实证明,如果不执行600万次无意义的迭代,则可以大大提高速度!

(针对此错误的修复程序已插入 HaxeFlixel本身)。

不要自欺欺人


OpenFL和HaxeFlixel均基于资源缓存。这意味着当我们加载资源时,下次接收该资源时,将从缓存中获取该资源,而不从磁盘中重新加载该资源。此行为可以被覆盖,有时是有道理的。

但是,我遇到了一些奇怪的牵强附会的事情:下载了资源,明确告诉系统不要缓存结果,因为我完全确定自己在做什么,并且不想在缓存上“浪费内存”。多年后,这些“智能”调用使我一次又一次地加载相同的资源,从而降低了游戏速度并浪费了宝贵的内存,而我通过放弃缓存来“节省”了这些内存。

8.此外,像《无尽之战2》这样的关卡可能不值得


是的,很高兴我们实施了所有这些小技巧来提高速度。老实说,直到开始将游戏移植到功能较弱的系统上时,我们才注意到其中的大多数问题,而在某种程度上,问题变得完全无法容忍。我很高兴最终能够提高速度,但我认为也应避免进行病理水平设计。 《无尽之战2》给系统带来了太多压力,尤其是与游戏的所有其他级别相比

即使进行了所有这些更改,PSVita版本仍然无法应付原始的Endless 2设计,并且我不想冒险在基本型号XB1和PS4上冒险,所以我更改了Endless 2控制台版本的平衡。我减少了敌人的数量,但增加了其特性因此该级别具有大致相同的难度。此外,在PSVita上,我们将波形数限制为一百,以避免发生内存故障的风险,但并未在PS4和XB1上增加限制。因此,在所有游戏机上仍然很难实现持久性。在PC版本中,Endless Batlte 2级别的设计保持不变。

所有这些对我们来说都是一个教训,在创建Defender的Quest II时会考虑到这一点-我们将非常关注水平,而对屏幕上的敌人数量没有上限!当然,“无尽”任务对塔防迷来说非常吸引人,所以我不会完全摆脱它们,但是玩家在进入下一波之前必须摧毁屏幕上所有物体的关卡等级如何?这不仅将使我们能够限制屏幕上的敌人数量,而且还可以实现在中间级别进行保存而无需大惊小怪地在激烈的战斗中序列化疯狂对象的状态-这足以让我们简单地保存防御者的坐标,提升等级等。

9.总结思想


游戏性能是一个复杂的话题,因为玩家通常不了解它的含义,因此我们不应该期望他们对此有所了解。但是我希望本文能为您澄清一下所有内容,并且您了解了更多有关设计,技术折衷和愚蠢的决策如何减慢游戏速度的信息。

最重要的是,即使在一个有才华的团队开发的具有良好设计的游戏中,也可以在随处可见这种小的“生锈”代码片段。但是实际上,只有一小部分实际上会影响性能。检测和消除它们的能力同样是艺术和科学。

我很高兴我们将在开发Defender的Quest II中利用所有这些优势。老实说,如果我们没有为PSVita移植,那么我可能甚至不会尝试其中一半的优化。即使您不为PSVita购买游戏,也可以感谢这个小小的控制台,它大大提高了Defender Quest的速度。

Source: https://habr.com/ru/post/zh-CN414135/


All Articles