由于青铜的幻想忙于《冰杖秘闻》的开发,这期的 GMS 教程由我来为大家整理。
教程定位依然是面向初学者,但风格方面可能会略微有所变化,可以步步遵循的内容会适当减少,但会就一个细节问题谈更多。另外,我对 GMS 的使用经验并不能算作非常丰富,今后教程中很多知识点主要会来自官方文档和一些社区指南,部分内容可能需要在学习过程中进行进一步的验证,囿于水平问题,一些错漏可能也难以避免,还望大家不吝指出,帮助这个教程做得更好,有什么内容方向上的建议,也欢迎在教程中指出,有意愿参与本系列教程编撰的同学也欢迎私信我报名。
未来几期我打算介绍一些文本和UI相关的话题,为了让之后计划的教程内容可以顺利展开,本文我们就先来聊聊使用 GameMaker:Studio 中的脚本语言 GML 的编程最佳实践问题,同时借机一窥这款引擎的某些内部机制。本文主要内容参考自 Mark Alexander 撰写的一篇官方技术博客,并在其基础上做了适当的内容增删。
为顺利理解本文中的所有内容,最好先实践下本系列之前的章节,并大致浏览过官方的文档。
尽管本文主要针对 GML 来进行讨论,但是一般原理均有相通之处,希望对其他游戏编程的初学者来说也能提供一些帮助。
两条基本建议
在正式展开本文的主题前,我们先强调两个比较重要的内容:
- 这篇指南绝非意在提供游戏开发的全能百科。文中内容主要从游戏开发的整体组织或细微优化的角度谈起,并强调了一些在游戏编程中应当养成的优秀习惯(尤其是当你逐渐对 GML 得心应手,开始思考如何更好地编写代码的时候,非常适合详细参考本文的一些观点)。
- 另一个重点是:游戏开发切忌画蛇添足。如果你的游戏运行良好,而且没什么让你特别不满意的地方,而优化方案又要求你改动大量的程序和设计,那么,不要优化,不要优化,不要优化!如果只是为了一点点多出来的 fps 或者其他提升,一般并不值得冒这样大的风险。游戏编程需要平衡多个方面:对可读性,灵活性,模块化组织等代码质量的追求需要同开发时间、精力与成本相权衡,对完美的过分追求有时反而会令人难以及时地达到理想目标。一言以蔽之,对告一段落的项目,如果没有到崩坏的地步,就不要投入大量精力去优化它,请带着学到的经验教训全心投入到新作的开发中去。
我们接下来聊聊编写尽可能好的代码需要注意哪些方面:
代码风格
任何写代码的人都会逐渐养成属于自己的风格。一般来说,代码风格包括你放置括号的方式,你如何进行代码缩进,你如何声明变量……等等看似非常细枝末节的问题,总的来说,代码风格和其最终的可读性与清晰性相关。代码风格的标准化和统一化对多人协作极具意义:如果代码风格成熟而良好,会给其他人或者搁置项目一段时候后的自己在阅读时提供很多方便。
代码风格多种多样。通常来说,一门语言的社区会出现一种到几种比较广泛接受的代码风格标准。开源组织或一定规模的软件公司也会形成自己的规范,很多人会坚称只有自己的风格才是最好的,但事实是,只要可读性好而且合乎你的使用习惯,就是适合你的代码风格。当然,如果涉及和他人协作,使用统一的代码风格也是非常必要的一件事。
对新手需要特别提示的一点是:追求源码在视觉上的紧凑没有实际意义。实际上,这些源码在编译成可执行程序的阶段,那些多余的空格换行及注释(有些编程语言中的特殊注释也会影响最终编译的程序,但不包括 GML)都会被自动忽略掉。
使用局部变量
还是延续上面提到的编程风格的问题,很多新人恨不得在一行代码里塞下全部内容,比如他们会写出这样的代码:
draw_sprite(sprite_index, image_index, x + lengthdir_x(100, point_direction(x, y, mouse_x, mouse_y)), y + lengthdir_y(100, point_direction(x, y, mouse_x, mouse_y)));
这样不仅可读性很差,效率不高(point_direction() 被调用了两次),看起来还很丑陋。所以为什么不试试神奇海螺……不对……为什么不试试局部变量呢?
我们可以这样来写:
var p_dir = point_direction(x, y, mouse_x, mouse_y); var local_x = x + lengthdir_x(100, p_dir); var local_y = y + lengthdir_y(100, p_dir); draw_sprite(sprite_index, image_index, local_x, local_y);
创建局部变量的内存和自由开销都很小,但好处却显而易见,令代码变得清晰明了许多。在编写 GML 脚本时,也应当养成这个习惯,在脚本开始处将脚本的参数存储为局部变量,这样就可以使用可读性更好的变量名,不必让整篇代码充满 argumentX
这样不加注释基本看不懂的东西,减少了很多犯错的机会。
另外,如果多次重复使用同一个表达式,那么建议使用局部变量存储它们以减少性能开销。另外,特别需要强调的一点是,频繁引用的全局变量最好也存储到本地变量再进行使用,这样可以避免因为某处错误造成对全局变量的污染。
数组
数组速度很快,相比其他数据结构来说内存开销也很小,但仍然有很多可以优化的空间。创建数组时系统所分配的内存空间是基于数组大小的,因此你最好一开始就按它的最大容量来声明,即便你暂时用不到那么多内存。比如,如果你希望创建一个容纳100个数值的数组,你应该先这样对它进行初始化:
array[99] = 42;
这样声明的内存空间位于同一区块中(并且所有的数组变量均初始化为默认值42),这样性能会最好,否则每当你新添加一个数组成员,就得为其重新分配内存。
特别注意:这种快速声明并初始化的技巧不适用于 HTML5 模块,因此如果需要导出 HTML5 版本,应该按如下方法处理(参考这里):
if (os_browser == browser_not_a_browser) { array[99] = 42; } else { for(var i = 0; i < 100; i++) { array[i] = SET_VALUE_TO_THE ANSWER_TO_LIFE_THE_UNIVERSE_AND_EVERYTHING; } }
另外,数组名本身也能作为参数传递给脚本,但请一定要注意,传入的参数只是数组的副本,你对其进行的任何改动都不会直接影响原数组的值,需要通过返回值的形式来得到结果,否则什么也保存不下来。如果采用这种方式,运行效率和内存占用都不会非常理想,因此,编写脚本时,应谨慎以传参方式来修改数组。不过呢,GML 也特别提供了一个特殊访问符“@”,允许你直接访问原数组的元素,例子如下:
// 调用脚本时将数组作为参数传入 script(array); // 脚本代码: a = argument0; // 将参数保存到局部变量中 a[0] = 100; // 这样写会创建原数组的拷贝 a[@0] = 100; // 这样写会直接修改原数组的元素
内置数据结构
相较老版 GM,GMS 对一些内置数据结构的设计做了很多优化提升。它们依然需要在释放内存前手动进行销毁,也确实比数组这样简单的数据结构慢一些。但 GML 内置了许多方便的函数令其变得十分好用,那一丁点的性能损失相比之下完全不是什么大事。所以,尽情使用它们就好。
ds_maps
是一个需要重点关注的数据结构类型,它为开发者提供了其他语言中 hashmap 那样的 key-value 型的数据结构。ds_maps
拥有很多方便的 API,用于读入写出数据,能够胜任各式各样的任务。
GMS 也提供了一些特殊的访问符来简化代码书写,你可以在官方文档的相关页面中找到这些内容。
碰撞
GMS 提供了许多方法来实现碰撞,但其中多数都会造成大量 CPU 运算开销。常用的 collision_ functions
,place_ functions
和 instance_ functions
等函数都依赖于包围盒检查,而这种算法基本上没有什么优化的空间。此外,如果使用了精确到像素级的碰撞盒,那么就会引入像素级的碰撞检查,导致速度被拖慢。
我并非建议你放弃使用这些函数,它们是非常便利的工具,但在使用它们之前,你必须要了解它们之间的区别以及性能差异。一般来说,place_ functions
比 instance_ functions
更快,而后者又比 collision_functions
和 point_ functions
,最好的做法是使用前仔细阅读手册,根据实际需求来选用合适的函数。
此外,如果希望构建基于瓷砖的碰撞系统,可以使用2维数组或 ds_grid
。它们比基于精灵碰撞盒的算法快得多,能够大大提升你的游戏性能。不过,当你使用了一些无法对齐到网格的不规则地形,墙壁或对象,这种方法可能就没那么适用了。这方面的例子在 GMS 的默认示例中就能找到一个,有兴趣的话可以看看它的具体效果。
纹理交换与顶点批处理
如果开启了 debug overlay,你会注意到屏幕上会出现两项指标:纹理交换数和顶点批处理数。在进行优化的时候,以性能为出发点,咱们的目标是在保证实现游戏功能的前提下尽可能减少其规模。(牢记本文开头的提示:不要在不需要的时候进行任何优化,当然,懂得一些基本的原则,!)
优化纹理交换数主要通过调整精灵和背景图的存储方式,即精灵的属性选项中的纹理组(Texture Groups)选项,你也可以在全局游戏设置(Global Game Settings)的第二个选项卡中找到相关设置,快捷键为
顶点信息是按"批次"传送给GPU进行绘制的,一般情况下,批处理的量越大越好。因此,应当在绘制过程中应当尽可能避免中断顶点批处理,以免顶点批处理数上涨。有许多操作会中断顶点批处理,切勿分散且频繁地使用它们,包括:使用图层混合模式(blend mode),设置绘制颜色,设置绘制透明度,绘制内置图形等操作。
举个栗子,假设你有一堆子弹的实例在绘制时使用了 bm_add
混合模式(查看这里的文档说明),你会为每一个子弹都进行一次顶点批处理,在性能上这是非常草稿的行为!相反,理想的做法是在游戏中加入一个控制对象,用于绘制游戏中的所有子弹,类似下面的写法:
draw_set_blend_mode(bm_add); with (obj_BULLET) { draw_self(); } draw_set_blend_mode(bm_normal);
这样所有子弹的绘制都在一次批处理之中完成。同样,在调整透明度和颜色的时候也应该注意类似问题。
尽管 GMS 的 3D 支持非常支持,但是万一你还是想稍微尝试一番,下面是我认为会有所帮助的一些建议:
另外,禁用精灵/背景选项菜单中的 "Use for 3D" 选项,它基本上没有什么实际用途。每张 3D 贴图都会单独生成独立的纹理页并分别进行批处理,因此,一般情况下,选择使用普通材质更加合理。你可以通过 sprite_get_uvs()
来获取 UV 坐标并赋值给变量以备后用。虽然这样写代码上繁琐了一点,但从性能角度来考虑还是划得来的。
粒子效果
运用得当的粒子系统能够显著提升游戏的表现效果,但也会导致很多优化方面的问题:目前 GMS 内置的粒子效果精灵都独立存储在单独的纹理页中,这意味着如果你使用了大量不同的粒子效果,就会大幅度地增加纹理交换数。比较合理的方案是像载入其他普通的精灵那样载入它们(一般存储在 ~/%appdata%/GameMaker-Studio/Windows8/html5game/particles/ folder 路径下)。
此外,对这些粒子效果使用混合模式也会降低游戏性能。如果你的目标导出平台包括移动端,请不要使用它们。这种情况下,推荐使用普通的精灵动画来自制粒子效果代替内置的系统。
其他建议
- 相比粒子效果,碰撞,字符串这些,三角函数运算的效率很高,不要害怕使用它们;
- draw 事件中不要放任何与绘制无关的代码;
- 考虑使用 alarm event 来编写那些不必每一帧都调用的代码。
- 牢记我们在文章最开头反复强调过的观点:不要做无谓的优化,在游戏规模体量不大,运行良好的时候,优化基本上只是单纯增加工作量而已,把更多的精力放在玩家能够感受到的游戏体验上才是更划算的选择。
彩蛋
为了让教程内容变得更有交互性,同时也为了考察大家学习时的细致程度,我在这次的教程中尝试了一点略微不同的花样,特意在行文中埋下了两部科幻小说的梗。第一位全部找出并在评论下方留言的同学,我会私信发送一枚国产游戏《符石守护者》的 steam key,想要为今后的彩蛋活动贡献 steam key 的同学也可以私信联系我。
文章中隐藏的2本科幻书名如下:
第一本 《为什么不问问神奇海螺[综] 》,作者:月令上弦
在本文中大致位置:3 使用局部变量……看起来还很丑陋。所以为什么不试试神奇海螺……不对……
第二本 《银河系漫游指南》 作者:[英]道格拉斯·亚当斯
本文中的位置:5 数组
SET_VALUE_TO_THE ANSWER_TO_LIFE_THE_UNIVERSE_AND_EVERYTHING;中的
"THE ANSWER_TO_LIFE_THE_UNIVERSE_AND_EVERYTHING"(生命、宇宙以及任何事情的终极答案)来自于英国作家道格拉斯·亚当斯所写的系列科幻小说《银河系漫游指南》,这个数值是42
@GameCrafter001:第一本不对哦。
@craft:哈哈为什么回答者的 id 看起来这么像你的小号
@ayame9joe:嘘……
第一个彩蛋,『另一个重点是:游戏开发切忌画蛇添足。如果你的游戏运行良好,而且没什么让你特别不满意的地方,而优化方案又要求你改动大量的程序和设计,那么,不要优化,不要优化,不要优化!』应该是致敬《三体》,三体人回复的『不要回答,不要回答,不要回答』;
第二个GameCrafter001同学已经说了,《银河系漫游指南》里面宇宙生命以及一切的终极答案42.
@DerekWtf:哇,这么快答案就出现了,我私信给你发 key。
这个系列是挺更了吗?
@Aprilbuble:暂时没有精力兼顾,不过之后会续命的。
这样发key好欢乐