« March 2017 | Main | May 2017 »

April 21, 2017

MMORPG 客户端的网络消息框架

昨天和人闲扯,谈到了 MMORPG 客户端的网络消息应该基于怎样的模型。依稀记得很早写过我的观点,但是 blog 上却找不到。那么今天补上这么一篇吧。

我认为,MMO 类游戏,服务器扮演的角色是虚拟的世界,一切的状态变化都是在游戏服务器仲裁和演化的。而客户端的角色本质上是一个状态呈现器,把玩家视角看到的虚拟世界的状态,通过网络消息呈现出来。所以、在设计客户端的网络消息分发框架时,应围绕这个职责来设计。

客户端发起的请求分两种:一种是通知服务器,我扮演的角色状态发生了改变,请求服务器仲裁;另一种是,我期望获取服务器对象的最新状态。后一种有时以服务器主动推送来解决,也可以用主动请求,两者主要的区别在于流量控制。

其实、对于客户端作为状态呈现这个角色而言,请求和回应之间的关系并没有什么紧密联系,并不适合使用基于 RPC 的网络模型。这个无关乎 RPC 的实现是用 callback 还是用 coroutine 。

这是因为,当服务器的状态改变时,客户端关心的应该是这类事情发生时,我应该如何呈现;而不是具体到某个请求发出后,收到回应我应该怎么处理。RPC 把请求和回应紧密耦合在一起,让回应的处理流程强依赖请求时的上下文,这样容易引起不必要的状态管理。

比如,我看到我们公司的一个项目中曾经出过这样的 bug :UI 界面上点击一个按钮,用 callback 的形式发起了一次 RPC 调用;在回应的 callback 函数中对 UI 界面上的元素做了一系列的修改。可是在回应的网络消息收到时, UI 界面已经关闭了,由于处于节约内存的考虑,还触发了一些对象销毁流程,结果因为操控不存在的对象造成了 bug 。

你可以从别的角度来看待这个 bug 。比如说应该建立一个更稳固的 UI 框架,做更严谨的生命期管理。但我认为,根源问题就是没有把请求和回应解耦造成的。

我们应该这样看待按钮点击事件:它只是触发了一个事件在服务器上运行,这个事件导致了某个服务器上的对象状态改变了,而回应包只是通知了状态改变的结果。客户端真正要做的是怎样正确呈现这个结果。

如果我们把客户端处理网络包的流程都归纳成类似的模型,统一成一个简单的框架就很容易了。

客户端根据收到的网络包进行相应的处理,无论这个网络包是服务器的推送、还是对之前客户端发起请求的回应。在这个框架下,只关心来了一个网络包后的类别,根据这个类别来决定要做哪类事情。处理流程是关联在消息类别上的,而不是关联在单个请求上的。

对于请求回应的处理,其实和推送的处理并没有本质的不同。只是实现上略有差别。对应回应包的处理,处理流程得到的参数不仅仅是网络包,还需要有对应的请求数据(也就是客户端当初自己发起请求的参数)和这个事件绑定的客户端本地对象。

通常我们会用一个 session 号来关联回应包和请求包,在发起请求时,不仅把请求内容打包发给服务器,同时还把它记录在本地,用 session 关联起来;这样在收到请求时,可以根据 session 找回当初请求的参数,以及请求的类型,这样就可以不必让服务器推送完整的状态值,客户端可以自己找到匹配的内容。

在大部分情况下,我们还需要在发起请求时,给这个 session 绑定一个本地的对象。虽然请求本身肯定有这个信息(否则服务器就不知道该请求到底操作呢什么东西),但额外的一个本地对象使用起来更方便,可以用来携带少量本地状态信息。在回应抵达时,直接操作这个绑定对象写起来更方便。

如果用 lua 来实现,看起来大致是这样的:

send_request(obj, req_type, req_data) -- 发起一个请求,请求类型为 req type ,参数是 req data ,绑定对象为 obj 。

function net.req_type(obj, req_data, resp_data) -- 定义一个函数来处理 req_type 这种类型的消息,可以获得发起请求时的 obj 和 req data 以及服务器的回应 resp data 。

April 10, 2017

为什么美术和策划在使用 git 时会遇到更多麻烦

我们公司有两个项目的客户端在使用 git 做项目管理,三个项目使用 svn 管理。程序员比较爱 git ,但是为什么 svn 还存在?主要是在做客户端开发时,策划和美术人员始终迈不那道坎。即使已经在用 git 的项目,策划们还是反应用起来比 svn 跟容易犯错误,遇到自己无法解决的问题也更多。

我是非常想在公司全部推广使用 git 做项目管理的。所以需要仔细考察到底是什么东西阻止了策划(及美术)们掌握这个工具。

思考一番后,我认为一套完整的培训机制还是得建立起来。完全靠自学和口口相传是不靠谱的。尤其是在有 svn 基础的时候,非程序开发人员接受 git 其实比程序开发人员要困难的多。如果你硬要把 svn 的概念全部适配到 git 上,其实就是在把 git 当 svn 在用,不仅获得不了好处,反而增加了很多困扰。

而实用主义者,在没有外力的情况下,只会看到表面。不可能系统的从原理上系统理解 git 到底解决了什么问题、每步操作背后到底做了什么,如果出现了问题,问题是怎么引起的。我们知道,在用 git 的时候,由于分支和提交都比 svn 方便,分布式的结构也会更容易导致版本演化图变得异常复杂。当它乱成一团乱麻的时候,任何新的合并操作都会比之前遇到更多麻烦。如果使用者心里有清晰的概念,时刻保持演化关系简单,他遇到的问题自然会少。而你遇到问题乱解决一通,只满足于把现在的问题搞定,那么下次就会面临更大的灾难。

过去在网易,我们建立了一套培训机制。入职开发人员(至少是所有校招人员),无论是做策划还是程序还是美术,都必须经过 svn 的培训。只有建立其版本管理的概念,日后工作中才会少犯错误。

而从基本概念上讲,git 其实并不比 svn 复杂多少,缺少的正是这套流程。让使用者知其然也知其所以然。


今天我自己做了一份材料,晚上尝试对部分同事做了两个小时的讲座。材料其实不重要,网上可以找到比我做的详细的多,好的多的教程。重要的是互动教学的部分。听的人可以讲出自己的疑惑,然后立刻得到解答。

我最想教授的就是 git 根本的原理,而操作这些反而是次要的。因为你不记得的时候总可以在网上搜索到。


首先,我觉得最关键、也是最容易被学习者遗漏的是,git 是有工作区、暂存区、历史记录(仓库)三个区域的。而 svn 只有工作区和中心服务器的仓库两个,没有暂存区。如果只记几个操作,完全不用知道这些,硬去背操作就好了。但是出了问题就容易懵。

我们平常工作的文件,都是在工作区修改。这和 svn 并没有什么不同,也不需要特别讲解。但是,一旦工作做到一个段落,需要提交到大家共享的仓库,情况就变得不同了。

暂存区实际可以勉强看成 svn (图形化界面)的那个提交面板上的多选框。svn 和它的前辈 cvs 最大的不同就是文件是做成一个集合同时提交的,提交是原子性的,要么一起成功,要么一起失败。而 cvs 是基于单个文件的。所以我们需要把提交声明成一个集合。

svn 采用的方法是在提交时勾选,而 git 采用的是设立一个中间容器,可以用 git add , git rm ,git mv 逐步把想修改的东西复制进去,然后在生成提交集的时候原子性搞定。由于提交总是针对本地仓库,就总是成功;而 svn 针对是远程仓库,有可能失败。

git 允许大家协作时共享的数据保存在不同的地方,只要数据严格相同,就认为是同一份数据。这个设计允许每个创作人员有最大的自主性,因为你可以在这份数据上任意修改(包括开设分支),只在最后要和别人同步的时候再同步。但是坏处就是,事实上每个人的那份共享数据又各不相同,这是因为大家的同步时机不可能相同。

我们要获得创作自主性这个好处的同时,就必须承担手动同步共享数据这个责任。也就是 fetch merge (pull) push 这些操作。这些操作在用 svn 时是不存在的,用 git 时,如果你不了解上面的细节,自然也容易忘记。

同样,reset checkout revert merge 等等都是在对三个区之间挪动数据的不同操作。如果不去理解操作指针、不去理解工作区和暂存区的关系,应去记 git reset --hard 可以回退版本,远程仓库的回退要 revert ,碰到冲突时就无法理解问题是怎么产生的。

而概念其实不那么理解,需要的只是认真去了解。


我个人认为, git 的本质就在于管理好版本演化的图。图的每个节点,也就是 commit 意味着一组对文件的修改。commit 和 commit 的关系就是谁在谁基础上做的修改,构成了节点间的连线。git 的复杂点在于,每个 commit 都可能基于多个 commit 生成的,让这些节点不再是一条直线连接。

这个复杂性还是为了解决更灵活的,从任何一个位置衍生创作这个需求。表现在 git 里就是无限制的开分支。

分支对协作开发非常的重要,因为开发人员总需要自己试验点什么而不需要和别人达成默契(不需要把修改推送到中央服务器);总有开发团体中的部分人间的协作,而不想影响到另一部分人;长期维护的项目总有各种不同的版本需要同时维护……

灵活的分支是 git 和 svn 根本性的不同,它也带来了开发流程上的革新,所谓支持分布式开发只是一点点副作用而已。


所以每个使用者也都有责任维护好整个项目的 commits 构成的图不要失控,乱成一团。

为什么策划和美术更容易遇到麻烦?

我想,所谓麻烦,都是和 merge 有关,也就是要重新连接图上面无关的两个(多个)节点时需要做的事情。merge 就是把图上两个节点用一个新节点连起来,然后后面的人就可以从一个统一点开始i继续演化。这里面就包括了把节点对应的数据内容合理的融合在一起,而不光是连个线。

融合的算法基础叫做三路合并。也就是,当你想让 A 和 B 合成一个时,版本管理器会去追寻 A 和 B 之前是在哪里分开的。大多数情况下可以找到一个公共的节点 C 。我们就可以认为 A 路线和 B 路线在走到 C 之前是完全一致的,我们不用理会,要做的是让 C 到 A 与 C 到 B 殊途同归。

由于每个修改集是有更零碎的针对单个文件的修改,我们检查 A 和 B 中,如果有和 C 中相同的文件;

  1. 那么如果三个版本完全一致,这个文件就保持原样就够了,因为 A 和 B 都没有修改过。
  2. 如果 A 和 B 中的文件版本都和 C 不一致,但是 A 和 B 的版本之间又是相同的,那么它们的修改是一样的,就取改过的就好了。
  3. 如果只有 A 和 C 不一致,或只有 B 和 C 不一致;那么就认为只有一方修改,那么取修改的一方即可。
  4. 冲突发生在这里,A B C 分别是完全不同的三个版本,这就要留给人来决策。

当文件是纯文本的时候,内容其实是按行分成更小的单位的。我们可以理解成是更细碎的小块。所以即使发生了 4 ,大部分情况下,分得更细的块之间依然可以自动处理好分歧。

但是策划一般用 excel word 这种二进制格式,美术更是以二进制图片模型数据为主。享受不到拆分更细的自动消除分歧过程,需要手工处理的东西更多。

不过通常,这也不是大问题。策划可以简单的用使用我的版本或使用他的版本,在 A B 中二择,帮助系统完成三路合并。


那么困扰策划们的问题出在哪里呢?我认为是 “交叉合并” 的情况。也就是需要合并的 A 和 B 并没有简单的一个分差点 C ,而是多个。git 在处理这种情况有多套方案,默认的是递归处理。把合并问题转换为一个个三路合并的子问题。在解决子问题的过程中,很可能就需要多次的寻求人的帮助确认。简单的单次用我的版本或用你的版本很容易犯错。

我一下找不到合适的实际例子,随便编一个,可能不太合理,姑且看看:

如果一开始的一处 commit 修改了 A B C ,我们把它的版本叫做 A0B0C0 。然后,版本推进到了下一个版本,修改了 AB 没有动 C ,版本变成了 A1B1C0 。

这个时候,两个人分别在 A1B1C0 的基础上做跟进,甲改了 B1 成 B2 ,乙改了 C0 成 C1 。我们看到的版本演化大致是这样的:

A0B0C0 ------- A1B1C0 +------- A1B2C0(甲)
                      +------- A1B1C1(乙)

这时候,在 A0B0C0 上发现了一个 bug ,有人修复了。修复涉及到 B 和 C 的修改,这样就产生了 B3 和 C2 。我们暂把 A0B3C2 这个版本成为 bugfix 分支。

A0B0C0 +------ A1B1C0 +------- A1B2C0(甲)
       |              +------- A1B1C1(乙)
       +------ A0B3C2(bugfix)

这时,甲和乙都知道这个 bug 修改了,他们都把这个 bugfix 合到了自己的分支上。三路合并的结果是 A 保留了版本 A1 ,甲发现 B 被两者都改了,他在 B2 和 B3 的基础上创作了新的 B4 ;乙除了要在 B1 和 B3 的基础上创作出 B5,还发现发现 C 也都改了,就在 C1 和 C2 的基础上创作了 C3 。

现在版本图就成了这样:

A0B0C0 +------ A1B1C0 +------- A1B2C0(甲) ---------------A1B4C2
       |              +------- A1B1C1(乙)------A1B5C3       |
       |                                       /            |
       +------ A0B3C2(bugfix)-----------------/-------------+

甲和乙的版本,也就是 A1B4C2 和 A1B5C3 要合并的那一刻,问题就不再是简单的三路合并了。因为这两个版本有两个共同的根:A1B1C0 和 A0B3C2 。

如果是甲来处理合并,他会非常疑惑。因为再次之前,甲并没有修改过 C ,他手头上的 C2 是在之前和 bugfix 分支三路合并自动合过来的。他当时并没有在意。但是现在他要处理 A1B4C2 和 A1B5C3 两个版本汇总的 C2 (bugfix 带过来) 和 C3 的冲突了。而 C 文件似乎并没有经过他的修改。


在这个例子里,烦恼的核心在于 bugfix 被分别合并到了两个分支上,这个合并过程,甲和乙做起来都不难,顺手就搞定了。但是在最后甲和乙做大合并时,埋下的炸弹才爆炸。

如果在开发过程中,不断的这种随意合并,一开始冲突比较好解决;但是积累多了以后,就变成了选择难题。

程序员比较少碰到这种选择难题,我想是因为:

  1. 程序员即使是对待开发路径上比较顺利的 merge 过程,也会比较谨慎的审核,心里对变更跟有底。而文本合并更容易看到差异全貌,也方便了做这种审核。所以问题比较少累积。

  2. 即使是并非自己经手的数据文件,最终的合并行为也可以单独的被正确处理,而不是只有使用我们的,使用他们的一条路。

  3. 文本文件可以被拆分的更细,往往系统就把差异处理好了,不必抛给人来审查。

April 06, 2017

Unity3D 的大场景内存优化

我们公司的一个 MMORPG 项目最近在内存方面碰到了红线,昨天开会讨论了一下。我提出了一个改进方案,写篇 blog 记录一下。

问题是这样的。在当下的手机及平板硬件设备条件下,操作系统留给应用的可用内存并不多,大约只有 500M 左右。

和 PC 环境不同,手机上是交换分区的机制来对应一些临时突发性内存需求的。而手机必须保证一些系统服务(某些高优先级后台业务)的运行,所以在接电话、收取推送等等意外任务发生时,有可能多占用一些内存,导致操作系统杀掉前台任务让出资源。

根据实际测试,游戏想跑在当前主流高端手机上必须把自己的内存占用峰值控制在 400M 内存以下,350 M 会是一个合理的值,而这个值是远远低于 10 年前的 PC 游戏标准的。而我们的项目却是一个写实类型的 拥有大场景的 MMORPG 。

Unity3D 是在智能手机普及以前设计的,远没有料到会被广泛用于手机游戏的制作。它在设计之初并没有为低内存环境考虑。内存使用太粗犷,是在使用 Unity 开发 MMORPG 项目时最被吐槽的一点。

为手机游戏定制游戏引擎,最特别的,和 PC 游戏不同的两点就是内存必须严格控制、能耗必须严格控制。比如在我们开发的 ejoy2d 中,会为资源数据中的项目引用定制短指针,即资源内部的相互引用使用 32bit 偏移量来代替 64bit 指针,每个指针节省出 4 字节内存;变换矩阵使用 6 个 32bit 的定点数,资源中相同矩阵共享一份数据;资源数据尽量连续存放,避免小数据块太多造成内存碎片浪费内存等等。这些显然是 Unity 没花精力去做的。在 PC 上,省下几M 几十M 内存微不足道,但在手机上很可能就是生死之间。

ps. 能耗问题是另一个有趣点。在 PC 上你可以通过多线程并行,压榨出高 fps ,可以不管 CPU 多烫;但是到了手机上,即使 CPU 8 核心已经是标配,还是尽量不要这么做。因为在总任务相同的情况下,单线程能做完的工作只要拆分到多线程上完成,就一定意味着总工作量增加(至少增加了线程间协调的工作)。增加了总能耗。玩家是不想玩一个插着充电线也会玩关机,手机滚烫的游戏的。这个问题有机会我另写一篇 blog 展开,今天是想谈谈内存。


在我们最近的测试中,在较坏情况下,我们的游戏会占到 360M 内存左右,已经接近了内存红线,所以要考虑进一步的优化。其中,较大的一块是游戏场景,占了 120M 内存。

有趣的是,这 120M 内存中只有大约 50M 是用于场景上物件的贴图、模型等等资源数据(注:我们项目没有使用静态批次合并,那样更消耗内存。);也就是说,有 70M 内存用于构建场景本身的结构。所以,并非让美术人员尽量复用同样模型的花花草草就可以省下内存的。换句让美术人员更容易明白的说法,我们的场景中摆的东西太多了,不是减少贴图用量,把同一块石头到处摆可以解决的。这和过去制作 PC 游戏的常识不同。

所以,大部分现有的手机 MMORPG 的画风偏卡通幻想风格不是没有道理。因为那样,可以用有悖现实的物件比例,场景物件个头大,就可以用更少的数量去充斥场景。而写实风讲将就细节丰富,用诸多细节去填满视野。在过去 PC 上,这不是问题,只要少做点独特的模型,少用贴图就能把内存降下来,手机上不行了。


我们并非刚刚意思到这点。一开始,开发人员就针对大场景制定了技术解决方案。

我们在场景上,认为设定了若干包围盒,勾画出一块块小区域。一旦玩家离开包围盒太远,程序就会把包围盒里面的物件卸载出内存。然后在美术设计上,不让玩家有可以从远处观望的角度。我们的美术风格会尽量保证场景细而精致、不追求空旷宏大的场面。

但这还做的不够,我提出了一个改进方案。


  1. 保留包围盒方案。但是包围盒略微扩大,允许包围盒重叠,并可以用多个包围盒来定义一个区域。同一个场景物件只可以属于一个区域,即使它的位置在多个区域内。(区域可以重叠)

  2. 所有物件都标记分类出外观物件和细节物件。比如一个城市的城墙就是外观物件,而城内的所有东西都是细节物件;一片树林的大颗植物是外观物件,地面的花花草草是细节物件。一般情况下,大部分物件都默认是细节物件,只有少数需要远观的才标记成外观。这点,其实原本就做了视距分层,只不过是为了在渲染时做显示剔除用的,并没有用于控制内存。而这次,需要对外观物件和细节物件单独打包分类,便于分开卸载。

  3. 当玩家处于一个区域内部时,必须保证这个区域的外观物件和细节物件都加载到内存。如果之前并不在内存,也需要开启异步加载的流程。当一个玩家距离另一个区域比较近时,只需要确保该区域的外观物件在内存即可,可以卸载任何不在区域的细节物件。


在以上改进方案里,把包围盒扩大以及允许多个包围盒一起构成区域,是为了改进数据加载的时机。单一用距离判断会有瑕疵。比如在城墙外,即使隔的很近也不需要加载城内的细节。而我们完全可以在城门外加一个缓冲的小区域并入城市区域,只要玩家一踩到城门口,城内的细节加载流程就开始启动了。

而允许区域重叠,并让物件唯一归属于单一区域则可以解决城门外的细节物体提前加载时机。城外的这块缓冲区上的物件就不必归属到城市板块,它们早在玩家在城外活动时就加载完毕了。