在Unity游戏中使用LINQ技术
关于代码的文章我们还没有发表。这篇关于LINQ语法的材料,是我们与程序员德米特里·斯塔罗科热夫的试金石,他曾参与《战争机器人》、《永明》和《法贝尔之魂》等游戏的开发。顺便提一下,他将于9月25日开设“深入Unity”的课程。
德米特里·斯塔罗科热夫
引言
在多年的游戏开发工作中,尤其是在Unity游戏开发中,我多次遇到开发者对LINQ的偏见。这是有原因的。LINQ会生成过多的内存分配。在可以不分配内存的地方,却分配了内存。
这个库在服务器开发中经常被使用,并且极大地简化了代码编写的过程。如果不深入挖掘,这不过是对集合进行的许多语法糖。当我们需要计算列表中所有元素的参数总和时,首先想要的就是从IDE的提示中选择Sum方法,然后继续进行。问题在于,游戏开发对垃圾回收器的工作非常敏感。我们尚未影响其生命周期,因此,与其冒险影响到主要游戏玩法中的帧数,不如消除所有多余的内存分配。对于服务器开发来说,这一点并不那么敏感,因为首先,服务器代码是在技术团队选择的硬件上执行的;其次,即使性能不足,团队也可以通过增加内存或更换CPU来提升性能。
让我们将此与游戏开发进行比较。可以立即猜到,服务器上的集合与客户端上的集合并不太可比。服务器代码处理的是包含游戏所有用户信息的数据库。而客户端只对自己的事务负责,即环顾自己的背包或20万个背包。区别很大,是吗?似乎LINQ对客户端来说过于“臃肿”了。第二个要素是硬件。开发者无法影响玩家所使用的设备。除了通过系统要求来切断一部分受众,但这样做会受到制作人的严厉批评,这并不是一个好主意。还有第三点,在这篇文章的背景下最重要的——FPS,即每秒帧数。
游戏本身是一个无限执行的循环,能执行所有代码的速度越快越好。这就是我们的性能。如果所有代码在16毫秒内处理完毕,我们自豪地宣布得到了60 FPS。16(十六)毫秒,老兄!你所有的百万行代码都必须在16毫秒内运行,然后再运行一次,然后再来一次。然后,在某一帧上,垃圾收集器会清除所有分配的但已不再使用的内存。这也会占用处理器时间,意味着它实际上从你的16毫秒中偷走了一点。顺便提一下,这些毫秒被称为每帧的预算。我们希望合理地花费这个预算,根据自己的意愿,而不必害怕GC的抢劫。
C#和其他一些语言中的垃圾收集器负责内存管理,将这项责任从开发者身上剥离。因此,它看似是我们的朋友。这确实加速了开发过程,并减少了最终构建中的错误和内存泄漏。关于它的工作,我建议阅读官方文档,以免在此停留于细节。
实验
我会向我的学生详细讲解以上所说的一切,但这次我决定亲自验证LINQ在游戏开发中是否真会造成伤害。毕竟时间在继续,游戏引擎和编程语言也在不断发展,而这个问题从来都不是个天大的秘密,因此可以推测,在我多年的实践中,情况可能已经有所变化。
为了测量性能,我安装了最新版本的Unity LTS 2022.3.40f1。在其中使用的是C# 9.0版本。因此,我将主要在这个设置下检查性能。所有测试都在Unity编辑器中进行,并不针对设备进行打包。CMD+P,CMD+7,开始吧。
_list[0].Do(); _list.First().Do(); // LINQ
首先让我们看最简单也不太实用的LINQ方法——First。我称它为不太实用,是因为按索引0访问集合的元素并不难,不需要封装成专门的方法。这两种访问方式的区别在于,LINQ为此使用了迭代器。在这里,我们显然没有发现任何差异。
var find = _list.FindAll(x => x.Number < 1000000); var where = _list.Where(x => x.Number < 1000000); // LINQ
现在我们进入列表过滤部分。List有一个FindAll方法,它使用传入参数的谓词来返回一个新列表。看来,在这里我们也遭遇了内存分配。但情况并不完全明确。LINQ在此并不因为过多的分配而受到指责,但它消耗的处理器时间更多。因此,在实际项目中,我会比较这两种实现,而不是依赖于合成测试。此外,修改代码行中的一个单词并不算什么大事。
_list.Sort((x, y) => x.Number.CompareTo(y.Number)); var order = _list.OrderBy(x => x.Number);
排序集合是服务器上最常见的任务之一,但对客户端来说并不那么必要。此外,开发者通常会将这一任务交给服务器,由服务器发送已排序列表给客户端。不过,我仍然不能忽视排序。如前所述,Sort使用快速排序算法,效率最高。同时,它对处理器的需求很高,但并不进行内存分配。它在现有列表中重排元素,而LINQ则创造一个新列表。如果玩游戏的时候只有一个帧,我会选择LINQ,但在这里我更倾向于Sort。
_list.CopyTo(new Incrementer[_list.Count], 0); var linqArray = _list.ToArray(); // LINQ
现在我尝试将List转换为简单数组。在这两种情况下,仍然可以用一行代码来完成。使用LINQ的那行代码看起来更优雅,眼睛看起来愉悦,但在其他方面的结果几乎是相同的。在这种情况下,可以放心地使用LINQ。
var cSum = 0; foreach (var item in _list) { cSum += item.Number; } var linqSum = _list.Sum(x => x.Number); // LINQ
当然,我想结束在我开始时提到的内容。Sum方法是非常有用且诱人的,因为无法以一行替代它,而任务看起来又是如此简单。这里的结果毫无疑问。LINQ在所有参数中都落后。老式的手写循环效率更高、更可预测。这里,总是可以依靠扩展方法,我们可以快速写出并忘记它,但关于这个值得单独写一篇文章,如果有人愿意阅读。
总结
因此,通过比较几个简单的集合操作案例,可以得出结论,2024年版本的C# LINQ已经不像之前那么可怕了。是的,这个库确实仍然在不必要时分配内存。但开发者并不总能在长期内从拒绝使用LINQ中受益。现在这仅仅是一个工作工具,需要在每个特定案例中进行测试,以便最大限度地提升游戏性能。
就这些,谢谢您读到最后。我将乐于接受任何健康的批评。如果您喜欢,可以提出下次我该写什么文章。
P.S. 代码可以通过链接访问。