« April 2024 | Main

May 06, 2024

重新启程

今天,是我在广州阿里中心办公的最后一天。虽然 Last day 设定在了 5 月 20 日,但后面全部是假期,应该不会再回来这里。这些年的年假我都没有用过,总是到年底自动作废,今年算是休全(一小半)了。

回顾我的职业生涯,2001 年之前在北京经历了创业,而后又短暂的工作了数月。之后便在网易工作了十年直到 2011 年离开 。2011 年底,我们创办了简悦,原本以为会把这家公司一直开下去,但在各种机缘下,于 2017 年底被阿里巴巴收购。之后,我便退出公司的管理,专心开发游戏引擎

每段经历,印证着不同的心境。幸运的是,每次开始和结束,都是我的自主选择。感谢那些容忍着我的任性的伙伴,而我执着于自己想法的同时,也回报了周遭的人。

毕业的开始缘于大学时代交的诸多热爱游戏的朋友,年轻气盛的孩子们梦想做出自己的游戏。当我觉得自己能力还不足时,不愿意只是为了工资而把精力消耗在不太喜欢的事情上,所以我选择离开,独自提高自己。

进入网易,因为我从大学期间就在开发的风魂引擎。阴差阳错,我又挽救了一个失败的游戏项目(大话西游),然后一干十年。而离开,是为了重新开始。当我发现我无法在网易做出更多想做的东西时,能自己去主导做事,是莫大的诱惑。

创办简悦轻松自然,虽说是创业,但几乎没有感觉到压力。这应该是整个团队经历过成功带来的自信吧。虽然前两年并不成功,接连两个项目都做失败了,但又奇迹般搭上了手机游戏的快车活了过来。

我觉得这次创业经历给我最大的启发是:团队合作创业,一定要先确定好股权及利益分配的方案,再全权交给可以信任的人去执行,大家就能没有后顾之忧的做事。直到和阿里谈公司收购时,我们的创始人团队依然遵循着最初的约定,让几乎所有坚持到最后的伙伴都获得了还不错的经济回报。而阿里也因为我们的加入,在短短一年内就收获 了数倍的投资回报,双赢的结局。

2018 年开始,我决定安心做一点想做而擅长的事。人生短暂,学习如何管理很多人做事并非我期望的发展方向。尤其当我逐步融入开源社区后,我发现,这个世界上许多软件基础设施往往都是由一两个人支撑。早在 2011 年时,我就怀疑过,软件项目需要很多人一起完成可能是一个骗局 ,那么,当处于一个稳定的环境而自己又有能力时,这种机遇并不多见,就应该尝试做点什么。

游戏引擎是我多年来一直想做的事情。早在二十多年前的校园里,我开始写自己的游戏时,就发现必须先有一些完善的底层设施支持。我的兴趣点一直都是挑战一个个具体问题。游戏引擎就由一系列具体问题构成,每一点做好都不容易。这件事需要一步步来,一步步试错,一个个问题解决。

我们在简悦时,游戏服务器部分的底层设施 skynet 从一开始就是开源形式开发的 。它几乎是由我一个人维护了许多年,同时又从开发社区吸收了大量的贡献,让它成长为一个成功的项目,甚至应用在游戏之外的大量领域。

而游戏客户端部分,一直未能建立自己的基础设施。虽然在 2013 年,我们在转型开发手机游戏时,我写了 ejoy2d ,但那只是一个为了支撑当时陌陌争霸这个游戏快速定制的小玩意。待到现在用于三国志战略版的几年,结构设计上已经不堪重负,欠了很多技术债。

虽然自己创办的公司已经完全卖掉,但我对这份事业还颇有感情。我认为,如果一个游戏公司想长远发展,那么拥有自己的底层设施颇具战略意义。自研开发是个缓慢的过程,初期一定看不到回报,很可能失败,但一旦成功,收益巨大。但这件事必须由一个不求短期回报,有强烈自驱力的人去做。除了我自己,还能有谁?


今天我结束在阿里的工作,是因为公司关停了 Ant Engine 这个项目。我感觉公司不再想发展自己的游戏客户端引擎,至少是不赞同我对于 Ant Engine 的发展规划。当然,这些出于个人推断,仅代表我的个人观点。实际上,阿里游戏目前以三国志战略版为首的诸多游戏底层依然基于自己研发的代码,也没有迁移到 Unity 等商业引擎上的计划。它依然有一支不小的技术团队持续维护中。希望他们接下来能做得更好。

好在叮当年初离开阿里离开前做了最后一件事,帮助我这些年开发的 Ant Engine 开源。四月以来,在叮当离开后,我一直在公司内努力促进引擎的发展。因为我真心觉得这六年来,我和我的小团队在 Ant 引擎开发这件事上一步一个脚印走得很踏实。我可以给引擎打上 80 分,开发过程中固然犯了很多错误,但最终,我想克服的技术问题,都在我的能力范围内解决的很好。自研引擎做起来虽然不容易,但能够花上六年专心开发更是难得的机缘,今天本应是开始让它开花结果的时候了。

整个四月,我和公司各个项目组的人长谈,项目制作人、程序、策划、美术…… 。感觉几周时间说的话超过了过去一年。我把我的信念传达到了。通过我们制作的游戏,也展示了引擎的质量:基于 Lua 快速开发的同时保证了在手机平台上的高性能,当前世代的图形管线表现出的还算精致的画面。以及,我认为最重要一点:没有欠下技术债。

我想我说服了不少人,但可惜的是,当下的公司决策人最终放弃它。或许阿里游戏现在有更急迫的事情要做,无法再等到三五年后它带来的收益。经过这些沟通,离开也让我了无遗憾。我已经做完了我能做的所有事,而过去几年的工作也颇有所获,可说是问心无愧。


之后,虽然开发团队已无法专职开发引擎,但大家对它还是颇有感情。好在主体功能均已完成,引擎本身的设计就倾向于方便扩展,我的想法本身就是像 skynet 那样,只专注于维护最核心的部分。引擎已经开源,是不是专职开发也就不那么重要了,主要开发者都会在业余时间尽力维护。

而我,也将开启下一段旅程。初步的想法是制作一款 Windows 平台的独立游戏。虽然我们在过去的一年已经在做一款类似异星工厂的游戏,但那主要是为了展示引擎在手机平台上的质量,它的 gameplay 方面还需大量工作。我并不认为手机是个好的独立游戏平台,需要把重心放在 PC 上。引擎对 Windows 以及 Steam 的支持,会是我的短期工作。这些会随着我自己动手编写游戏代码,慢慢完善。

具体做怎样的独立游戏,还在酝酿中。引擎开发团队中唯一的美术也和我一起离开阿里,他是一个多面手,和我一样对独立游戏开发有极大的兴趣,在接下来的几个月,我们会一起构想。

May 03, 2024

大批量动画模型的优化

最近和公司一个开发团队探讨了一下他们正在开发的游戏中遇到的性能问题,看看应该如何优化。这个游戏的战斗场景想模仿亿万僵尸(They are billions)的场景。在亿万僵尸中,场景中描绘了上万的僵尸潮,但我们这个游戏,超过 500 个僵尸就遇到了性能问题。固然,手机的硬件性能比不上 PC ,但 500 这个数量级还是略低于预期。

对于游戏中大量类似的动画物体,肯定有方法可以优化。我们来看看渲染这些动画可行的优化方向:

常见的方式是把僵尸先预渲染成图片,而动画自然就是多个图片帧。对于亿万僵尸这个游戏来说,它本身就是基于 2D 渲染引擎的,这么做无可厚非。

如果引擎本身基于 3d 渲染管线,也可以以预渲染图片的方式去渲染它们,但图片是否比基于三角形的模型渲染有更好的性能,这个需要根据具体场景去分析。

当我们在运行时,将模型的三维顶点信息传递给 GPU ,让 GPU 做光栅化,通常可以获得两个方面的好处:

一,最终渲染出来的像素有更准确的光照信息,它是根据该像素在场景中的空间状态计算出来。而预渲染图片上的像素则是根据预渲染时的固定空间状态计算,所以不准确。

二,可以省掉顶点着色器的计算过程,对像素着色过程也简化为一个简单的复制(而不必计算光照)。

但是,渲染图片也未必一定有绝对的性能优势。这是因为,预渲染图片本质上是将渲染过程预处理,烘焙到贴图上。约等于用空间换时间的优化。图片本身相比顶点数据会消耗更大的带宽。当模型本身顶点不多,用到的贴图也不大时,这个区别会更加明显。通常模型顶点占用的带宽会比图片需要的带宽小一个数量级。当使用帧动画时,需要为每个动画帧渲染独立的图片,这个差别就更加明显。


再来看动画。通常,我们会用骨骼来描述动画。这其实也是一种数据压缩方式。一个模型的顶点可能有成千上万甚至更多。但我们只需要用几十个关键点来描述动画即可。同时,只需要建立唯一的蒙皮数据,把数千个顶点映射到几十个关键点(被称为骨骼)上,每个动画帧就不再需要重复几千顶点的空间状态。

更进一步,如果动画的时间很长,即使记录每一帧骨骼的状态也会多余。我们会记录一些关键帧(key frame)上的骨骼状态,再用插值的方式求得每个渲染帧的骨骼状态。数据就得到了进一步的压缩。

让我们回顾这个流程:

骨骼关键帧 (通过插值得到)每个渲染帧骨骼信息,(通过蒙皮按权重计算出)这帧模型每个顶点(经过光栅化)映射为屏幕上的画面。

这里的每个环节,前面的都有更少的数据,可以通过索引和计算,得到下一个环节更多的数据。因为 GPU 比较适合做简单但巨量的并行计算,所以,我们每个环节都可以考虑把提前预运算,或是实时计算;计算可以考虑放在 CPU 中,或是放在 GPU 中。每个选择必定有取舍,都可以根据实际场景考量。

当需要渲染的物件非常多时,为了提高性能,要么尽可能的把数据预预算好,而不要每帧都反复计算;要么把计算放入 GPU 中,让处理器可以尽可能的并行。

上面列出的动画流程中,中间一步“从蒙皮计算出顶点”是最有弹性的,根据不同的场合会有不同的处理方法。

早年 GPU 无法处理复杂的业务,大多数引擎会选择在 CPU 中处理蒙皮。如今,GPU 有了 compute shader ,把蒙皮放在 GPU 中处理越来越普遍。 但今天谈的这个场合,我任何并非最佳方案。

因为在 GPU 中计算蒙皮,也面临两个选择:其一,渲染每个物件,都从骨骼到顶点再到屏幕像素跑一遍完整的流程,这样带宽开销是最小的,都计算量最大;其二,把蒙皮过程分离出来,GPU 计算出蒙皮结果,然后把蒙皮后的顶点和其它静态模型顶点统一处理,这样做需要临时占用额外的显存来保存蒙皮计算结果。

如果选择方案一,我们会多出相当多的重复计算。首先,3d 管线中,每个模型都不只处理一次渲染。会有 preZ ,阴影等额外的渲染步骤,这个方案会重复计算蒙皮几次。其次,更重要的是,对于僵尸潮来说,僵尸的数量越多,在播放动画时,恰巧处于同一个动画帧的可能性就越大。当渲染数以千计的僵尸时,会做相当数量的重复蒙皮计算。

如果渲染方案二,我们会为每个僵尸对象的蒙皮结果在显存中保存一份临时顶点数据。PC 上或许有足够大的显存,但手机上非常容易显存不够。(我们过去尝试过这个方案,在手机上果真遇到了显存不足的问题)固然我们可以继续优化方案二,加一个间接层合并一些重复的数据,但渲染管线的复杂度会上升很多,暂时不往这个方向考虑。

实际上,把僵尸模型的所有动画的每一个动画帧的蒙皮结果预算计算好,在这个场景是最为简单有效的方案。

游戏中,单个僵尸只有不到 300 个顶点,以每秒 30 帧动画计,每秒动画不到 10k 顶点数据。预计算 100 秒动画数据,手机显存也能存放的下。我们只需要在 CPU 中烘培好这些数据,就可以把这些动画顶点变成静态模型一并处理。然后使用简单的 instancing 方法就能批量处理上千僵尸的渲染。

以上就是最近在 Ant Engine 中增加的特性,实测在 iPhone 12 上可以流畅渲染数千僵尸动画。不过,这个游戏本身不是 Ant Engine 制作的。虽然我和项目制作人讨论过用新引擎重新制作的可能性,因为开发计划的压力未能实施。


另外,我们在 Ant Engine 中实现这个新特性时,接口设计中用到了一个小技巧:

Ant Engine 是基于 Lua 的。Lua 的函数调用有不小的开销,所以我们在使用时应该避免每帧做过于频繁的调用。对于动画系统,底层接口需要设置每个模型每帧渲染使用的是预计算好的顶点组中的哪一段(以此为 instancing 的一个参数)。逻辑上讲,就是需要通知渲染层动画模型当前帧需要渲染的帧号。

当僵尸数以千计时,每一帧都需要修改每个僵尸的动画帧号,总共数千次。这每帧数千次的 Lua 函数调用其实是可用一个简单的技巧避免的。以走路动画为例,引擎内部其实只持有一个预渲染好的走路动画所有帧的对象。我们每帧都固定推进这个对象的 offset ,而使用这个动画的对象,它们实际保存的是一个起始相位值。在 C 层,每次渲染时都把这个相位数和动画对象中的 offset 相加再对总动画帧数取模,才得到真正的帧号。这样,我们只需要每帧去推进单个动画对象的 offset 就够了,而不需要上层通过 Lua 接口修改每个对象的动画帧。