ECS初探

开始之前先说两句题外话。


GAMES202的作业4是白炉测试,但是我到目前为止还没有做.

其主要原因是,关于GGX BRDF我有点迷惑了.

本来按照LearnOpengl和其他参考书里面讲的, 一般光照计算会分为两部分. 一部分为Diffuse, 一部分为Specular.

Diffuse又可以看作是次表面散射的一种简化。这样我们可以用菲涅尔反射, 来计算一束光线有多少反射出去(BRDF项),有多少进入物体内部进行次表面散射(Diffuse项), 然后把两部分加起来就行了。

但是闫神讲课时说:由于已经采用了微表面模型,就不能在与宏观表面模型Diffuse的假设一同采用,同样在物理上也是错误的,能量不能保证守恒,可能会出现发光的BRDF的情况。由于不同角度、不同粗糙度损失的能量是完全不同的,因此直接加一个Diffuse是完全错误的。计算机视觉识别材质采用了这种方法。如果你用了这种做法,别说闫神教过你。

这话一说,一下子就给我整不会了, 以致于我到现在还没弄明白到底怎么是对的,迟迟没办法做白炉测试。

我可能需要GAMES202的同学来讨论一下:D。


GAMES202告一段落之后,就顺便学习了一下Unity的SRP,教程使用的是catlikecoding

老实讲,我在看这个教程的过程中只有一个体会,心累(当然这并不是教程的问题)。

我最开始对Unity的SRP期望是这样的:在C#中有一些库函数,并且在Shader端也有相匹配的库函数。当我需要成熟的功能时,我调一下C#的函数,然后在Shader中再调用相应的Shader库函数。就可以直接使用他的某个功能了。

然而并不是这样,尤其是catlikecoding上来就搞阴影。Unity中的C#是有一些API可以给我们用,Shader也会有一些内置变量,直接被设置好了。但是怎么用这些变量,是需要我们有足够的Unity知识之后才能应用的。它并不像是一个封装良好的库函数。

这让我在学习过程中很疑惑,到底有多少个Shader内置变量,他们分别是被哪些API进行修改的。我并没有发现一个很好的文档,可以让我根据某个C# API来查询,他会修改哪些Shader变量,这些Shader变量都是什么含义。

这就像盲人摸象一样。以至于我很怀疑,如果我们要做一个项目。到底是应该根据SRP写自己的RenderPipeline, 还是应该魔改URP的RenderPiepline。如果Shader的内置变量五花八门,修改他们的API也很多。那势必就会踩很多坑。如果这样,还不如魔改URP来的安全。

不过在看完整个教程后,我发现SRP除了提供一些基础的渲染功能外,主要额外提供的辅助就实时阴影和烘焙相关部分。这些信息量并不算大,所以上面提到的坑问题也就不存在了。


下面开始进入正题。

关于ECS,我大概花了一周时间来学习理论知识。学习时间尚短,大概率我现在的感受都是错误的,不过我认为还是值得记录下来,以备后面反思时使用。

ECS早已有之,但是它真正在国内火起来,应该要从《守望先锋》架构设计和网络同步算起。

在看完《守望先锋》架构设计和网络同步之后, 我接着看了一下Wiki

Wiki给了一个渲染方面的例子: “一个“系统”,它遍历所有具有物理和可见组件的实体,并绘制它们。可见组件通常可以包含一些关于实体外观的信息(例如人类、怪物、四处飞舞的火花、飞箭),并使用物理组件知道在哪里绘制它。另一个系统可能是碰撞检测。它会遍历所有具有物理组件的实体,因为它不关心实体是如何绘制的。”

乍一听,觉得ECS就是完美啊,就跟当年他们教我OO时,给我举例子做UI一样,各种继承,各种多态,简直完美啊。

但是,历史的经验告诉我OO在非UI领域一点也不好用,以致于他们要出各种设计模式来解决OO带来的坑。

不管怎么样,即然大家都在吹ECS,它肯定是有过人之处的。

抱着试试看的态度,我模拟把我们游戏的客户端逻辑使用ECS进行落地。

第一关就给我难住了,Component到底该如何拆分,拆分粒度是多大。上一次这么手足无措,还是在大约12年前, 我在实模DOS下,往0xB800(显存)地址处写入ASCII码,但是屏幕什么都没有显示。同样的没有经验,同样的资料匮乏。

直到我看到A Data-Driven Game Object System中的一个句话“Each component is a self-contained piece of game logic”,我猛然间醒悟了,我们需要根据业务需要,设计System逻辑,然后根据System来拆分Component(也许叫设计Component更好, 之所以叫拆分是因为我在模拟怎么用ECS实现我们客户端的所有功能, 拆分这个词,在一定程度上其实误导了我)。

我回忆了一下,在日常逻辑的开发中,尤其是已经上线的项目。在新增一个系统时,我往往会单独设计他的数据结构,并存储在数据库的不同位置。而所有系统最终是通过UID这个entity_id来关联起来的。

举个例子:假如我们有一个Bag系统和一个Mail系统,我们的代码组织往往会类似下面情况:

//Bag.cpp
namespace bag {
static std::unordered_map<uint32_t, db::bag> bags;

void bag_add(uint32_t uid, int money, int count)
{
    auto &bag = bags[uid];
    add money into bag and save db
}

}

//Mail.cpp
namespace mail {
    std::unordered_map<uint32_t, db::mailbox> mailboxes;

    void mail_fetch(uint32_t uid, uint32_t mailid)
    {
        auto &mb = mailboxes[uid];
        auto &m = get_mail_by_mailid(mb, mailid);
        bag_add(uid, m.attach.money, m.attach.count);
    }
}

对比可以发现,这其实和ECS的模型很像,只是ECS模式约束更严格,System之间不允许相互调用。

上面这个系统本来就是松散耦合,再举个更复杂的例子,我前几年写的回合制战斗系统。

在整个战斗系统中,buff,hurt,heal,skill这些计算逻辑,往往会操作着hero不同部位的数据。这些计算逻辑读取的数据区域可能会相互重叠,比如hurt,heal都需求读取hero的属性值,而hurt往往还会读取部分buff的属性以便做伤害分摊。

如果按照OO的思路,hero类往往会持有buff,hurt,heal,skill等类的实例,但是由于这几个系统往往需要相互读取对方的部分数据,以至于buff,hero,heal,skill中往往还会持有一个hero的指针, 这样到处都是循环引用。不但不能解耦合,还会让问题变的更糟糕。

对于这种强耦合的逻辑,我采用了Lua虚拟机的实现方式,我把所有用到的数据全部定义成结构体,然后把buff,hero,heal,skill全部实现为纯逻辑,这些纯逻辑可以直接访问它们需要的任何数据结构。

这样只要我能定精准定义好每个结构的字段的含义,各种逻辑都根据数据的含义来执行相应的计算就好了,模块之间大幅解耦,我想这也是贴近ECS模型的一种实现。同样它也不是ECS,因为逻辑模块之间有相互调用。

但是我想使用ECS来实现业务逻辑时,和以上两种实现模式的思路或多或少都会有相似之处,尤其是第二种,感觉更相似。

但我有两个疑虑:

1.因为战斗系统是我一个人开发的,我当然可以从全局精心设计出合适的数据结构。但是如果在多人协作情况下,除非像例子1那样,本来就是松散耦合,否则我对能否设计出合适的Component数据结构是存疑的。

2.因为System之间不进行直接交互,所有交互都是通过Component进行的,这会造成全局变量陷阱。回忆一下,我们刚开始写代码时,都被谆谆教导不要使用全局变量,这是有原因的。

不管怎么样,我打算先实现一个Lua版的简易ECS框架,真实体验一把再说。毕竟没有使用就没用发言权。

2020

写年终总结前,照例翻了一下今年以来的blog, 意外的发现2019去年的年终总结同样拖到很晚。不过今年之所以拖这么晚,大部分原因是今年特别冷。坐到电脑前只顾着瑟瑟发抖了,哪还有心情写年终总结。

翻了一下去年的目标:

  1. 《概率论及其应用·卷1》
  2. 《具体数学》
  3. 《计算机程序设计艺术》
  4. 重新温习一下以往学习过的知识,看看能不能再次打破一下这几年形成的思维定式

总的来讲 有部分完成。
《概率论及其应用·卷1》看了一大半,该看正态分布了,不过现在应该差不多忘光了。
《具体数学》似乎没什么进展
《计算机程序设计艺术》看到卷2的线性同余随机算法推导,其直接产物就是我认为游戏宝箱概率垫刀的根本问题就是因为所有人共用的一个线性同余序列,虽然最终所有人的统计概率都相同,但是理论上短期内对每个人进行分析会有概率分布不均的问题
对于第四点,我刷了2个月的leetcode, 动态规划似乎有部分突破,但是依然没能显著打破思维定式


再说说计划外的收获。

性能篇:

将时间拉回到刚毕业时,那时写代码主要是应用软件,主要考虑的是可扩展性可维护性,很少去考虑代码的性能问题,一般不写过于脑残的代码,几乎也不会碰到过性能问题(当然代码量少也是一方面,我记得当年离职前统计整个软件的代码量也才2W+行不到)。

随着后来转行去做游戏服务器开发后,可能是动则过千人的并发量,让我对性能产生了恐惧,也有可能是《计算机程序设计艺术》计算指令周期的方式让我产生了错误的心理暗示。总之,我后面写代码慢慢变得总是性能至上。甚至在写C语言代码时都会心里粗略估算编译器的优化程度,cache的命中率等

事实上这种错误行为让我付出的惨痛的代价,我为了能够获取理论上的最高性能,经常会写出很Trick的代码,而这些代码经常会漏掉一些边界,却又不是所有边界。以致于很多bug逃逸到了线上, 当然我可以说是测试力度的不够。但是从内心来讲,我认为这和我写代码的trade-off有很大关系。

再后来新写的代码时,我都尽量强迫自己在做优化之前,先估算一下可能的数量级再决定优化不优化。

当然想做到这个并不容易,我总是不自觉的想使用复杂的方案以期望获得更好的性能,甚至潜意识就将简洁的方案给否掉了。这真的很难!有时我就在想,我转了一圈其实我要的不过就是更好的模块化设计,这跟我刚毕业时没有什么两样,那我这7年是否是绕了一个大圈,我现在还没有答案。‘看山是山’和‘看山是山’到底有什么区别?也许惟一的区别就是,我知道理论上可以有更高的性能,但是我就是不用。

渲染篇:

这绝对是一个值得一提的大事,我从2015年就在折腾学习渲染,但是一直就不得其门而入,在2018年时总于算是找了一个门缝,然而虽然我经过了训练,但是我发现我连拍着胸口说我了解渲染的资格都没有。

渲染这个事让我在2020年一个很偶然的机会找到了入门的大门(《GAMES101 现在计算机图形学计入门课程》),我现在已经可以挺起胸口自信的说我渲染入门了。对光栅化,光线追踪,PBR,微表面这些名词再也不是望而生畏了。

最后:

2021照例延续2020的作来继续下去,我对这些基础学科很有信心。

《概率论及其应用·卷1》
《具体数学》
《计算机程序设计艺术》
继续深入学习计算机图形学(听说《GAMES202 高质量实时渲染》马上要开了)

再谈性能优化

版本开发终于接近尾声了,最近在做一些扫尾(性能优化)工作。老实说,这是我第一次细致的测量业务逻辑的性能。

我曾一度以为游戏服务器是io密集型程序,cpu其实很轻,至少对于一般的卡牌游戏应当如此。

虽然天天跟策划㗏㗏性能,但是那仅仅只是为了追求更好性能而已。

我们的服务器程序有10w+的echo能力,再加上我写逻辑从来都是按我知道的性能最高的方式写,所以我从不认为有一天会有处理请求过慢的问题,也从来没有思考过这个问题。

这次测试深深的给我上了一课。

在最初的版本中,一个普通五连抽,竟然要花2+ms,也就是说一秒竟然只能抽500次不到。

这也意味着一个服务器,只要不到500个人同时抽一次五连抽,服务器就卡爆了。就这,还没有统计网络处理的开销。

10万和500的巨大差距是我从来都没有想过的。

带着难以置信的心情,我开始使用valgrind进行性能分析,并一个一个的解决热点。

第一次定位到最大的开销竟然是log。

我看了一下源码,发现为了可以方便的定位问题,我们几乎每个函数都有log。有些函数在条件不满足(也就是什么都不执行)的情况下,依然会打一行log代表自己处理过。

我把所有逻辑都修改为不产生副作用时不输出log。毕竟函数是否进入,可以通过caller的逻辑来确定,相信caller会有相关的log。

而一旦可以确认一个函数被调用,如果没有log输出,本身就可以代表一种状态(函数不满足执行条件)。我认为这应该算是一种比较好的折中了。

修改了log之后,紧接着的热点就是货币管理。

在招募过程中会有若干个子过程,分属不同的模块。这些模块又会分别扣钱和加钱,由于逻辑相关性和KISS原则。我们不会在逻辑中将’扣钱’和’加钱进行合并操作’。

而且,为了保证可靠性,每次进行货币操作,都会进行数据库存储操作。

存数据库前,我们一般是先将一个struct序列化成string。然后进行存储,并打一行log。

根据valgrind显示,此次货币管理模块的热点问题就是存储数据库相关的函数造成的。其中log部分的开销占了一大部分。

我采用了之前文章里提到的关键点存库的方式,将货币模块的储存降到了每个客户端请求只存储一次数据库的频率。

优化货币模块本来最简单的应该是,直接删掉存数据库时的log, 毕竟log占了一大部分的开销。

但是本着最高性能的原则我还是修改了货币的存储频率。这样可以节省一部分数据库的开销。

做完以上这些修改之后,重新测了一下。

已经可以在不开O2的情况下达到800+次请求。开了O2之后可以达到1200+次请求。

继续用valgrind分析,发现代码已经开始均匀的变慢,并没有什么明显的特征。

按说这时候想再提高处理速度就只有换CPU了,但是我察觉到相关代码中均匀的分布着C++的unordered_map类。

联系到,上次优化战斗时,我牺牲了3k内存直接将使用最频繁的一个unordered_map换成数组时,性能翻了2到3倍。我觉得如果应该还有优化的余地。

但是,并不是所有的数据都可以使用数组来替换的,这些业务逻辑需要的是一个更好更快的hash_table。如果想要进一步优化,就只能找一个第三方hash table(比如把lua的借过来)来替换掉unordered_map。

但是扫尾工作,我不太想做这么大的的改动。现阶段这个请求处理速度已经勉强可以达到要求了。毕竟不是所有玩家都是土豪^_^。

我以前说过:“底层逻辑的开销会被上层逻辑放大”,这是对框架说的。现在看起来需要修正为:“被依赖模块的开销,会被所有依赖他的模块放大”。

就这次优化经验来看,有时候所有模块都有高性能的实现,不代表整体性能就高。整体框架或机制的设计,会成为制约着整体性能的瓶颈。

这就要求在以后设计框架或机制时,不仅要做到自身效率高,还要考虑在上面实现业务逻辑时,是不是可以高效的实现。好的框架或机制对业务逻辑的性能是有导向意义的。

ps. 以往优化都是局限在某一个系统或模块。而这次优化引导着我从全局来看整个逻辑,这是一种从未有过的体验。

2019

终于还是赶在农历年之前抽时间来写年终总结了。

2019对我而言其实技术上并没有实质性进展,而去年定下的目标,一个也没有完成。

这无疑是一个令人沮丧的事情。虽然前几个月时间在忙其他事情,但根本原因是我的技术进入到了一个瓶颈。

几年前我就已经发现,随着时间的增长,我的技术提高量越来越低。

但像今年这种技术再无一丝实质性进展的情况确实是第一次出现。


仔细反思了一下,技术增长量的降低可能和我自己的学习模式有关。

大部分人学习方向都是向上的,比如学了编程语言,去学设计模式设计模式,再去学各种框架,然后就是各种分布式,云。

而我自己的学习方向是向下的,学了编程语言,就去学linux内核,再去学数字电路,再去学PCB,再去学FPGA,再去学编译原理,再去学数学。这更像是先画了一个大纲,然后一点一点去丰富细节。

这样补充的细节越多,需要的基础知识就越多,时间缺口就越大。而这时,我贫困的基础知识就会有力的拽住我的后腿,以致于我进步的速度越来越慢。

我从不觉得我的学习方向有什么问题,但是摆在我面前的矛盾却越来越凸出。

首当其冲的问题,就是大纲的准确性。由于仅凭首次接触就凭自我感觉画出了一个大纲,这个大纲会有很多不准确之处,毕竟我不可能仅凭薄弱的C语言基础,就能准确的描绘出Linux Kernel的轮廓。

虽然在细化的过程中,可以不停的修正整个大纲的轮廓。

但是,我有个极大的缺点,第一印象出来的东西,总是会慢慢变成直觉,这也就是说即使我后面把大纲修正的准确无比,但是我的第一反应仍然是初次接触的那个感觉。我必须要花很大的功夫,才能强化后来的修正。

而很多情况下,我的惯性思维和第一印象会将我禁锢其中,而跳脱不出来。当然这种禁锢的根本原因就是因为我没有看到本质,所以不能充分使用。

尤其是我最近经常能意识到禁锢的存在。但这对我来讲应该算是好事,毕竟意识到了,才能打破。

我打破禁锢惟一的手段就是,遇到问题仔细思考一下,看有没有办法换一个思路解决。

当然并不是每次都能成功,大部分情况都是很久以后回忆起来才发现,当时是被惯性思维禁锢了。

因此我越来越喜欢与人交流问题,即使这个问题很简单,他也同样会有无数个视角。

另外一个问题,就是我的学习过程类似page-fault的过程(学知识A的过程中,如果用到知识B,就会暂停学习A,直到B学习完成。)。

在这个嵌套的过程中,每嵌套一层,我上层知识就会丢失一点(还没有学懂的知识,总是容易遗忘的)。这就导致我每回退一层就需要从头学习这一层的知识。

就拿我今年的例子来讲,我之所以一整年都没有实质性进展,就是耗在内容丢失上。

本来在看《计算机程序设计艺术》,看了几章发现需要数学。于是就去学习《具体数学》,在看具体数学的过程中,又发现很多基础知识不懂。又去看《什么是数学》。等我看完《什么是数学》,《具体数学》已经忘的差不多了。等我看完《具体数学》又会发现《计算机程序设计艺术》忘的差不多了。

当然其实到目前为止,《具体数学》我看了7个月,依然还没有看完。但是我现在需要去看《概率论》的东西才能继续看,等我看完概率论,不知道《具体数学》在我脑子里还剩多少。


核心技术没有实质性进展,并不代表我真的一点收获都没有。

至少,我亲身体会了Intel CPU的cache惩罚的效果。

至少,我学习了Unity的AssetBundle的机制,并写了一个管理组件。

至少,我终于明白原来Lua的最底层逻辑是建立在函数式编程上的,并由此明白在不同编程范式中GC的运行规则是有区别的,顺带对编程范式有了新的理解。

最大收获就是,我终于开始学习并试着用数学思维解读一切,虽然效果并不明显,但我自己对这种变化有明显的感知。并且在看数学书的过程中,我明显发现了思维禁锢的存在。有时我甚至连a=b< =>b=a都反不过来。

最后,照例给2020布置一些作业。

  1. 《概率论及其应用·卷1》
  2. 《具体数学》
  3. 《计算机程序设计艺术》
  4. 重新温习一下以往学习过的知识,看看能不能再次打破一下这几年形成的思维定式

ps.写完发现,有点跑题了,不管怎样,姑且认为这是一篇2019年思维过程和学习方式的总结吧。

历史之2018

2018年已经成为历史,当我想总结一下过去一年的所得时,却发现什么都想不起来。借着过去一年的blog和github,总算可以粗略回溯一下历史。

去年的今天,我定下三个目标:

1. 阅读lua源码,并实现虚拟机
2. 阅读《计算机程序设计艺术》
3. 实现一个软件光栅器

到今天为止,lua源码只完整阅读了GC部分,计算机程序设计艺术几乎等于没看,只有软件光栅器做了个七七八八(但其实连光照都没做完)。

下面来说一说流水帐。

过完农历年后,我先挑了“软件光栅器”来做。

一方面是因为《计算机程序艺术》和阅读lua源码都需要比较大的时间片,而软件光栅器相对来讲可能更像是快程课。同时在三个目标中,我的渲染知识最弱,对于一直致力于全栈的我来讲,肯定也要先补齐短板。

从另一方面来讲,前两个更属于内功,软件光栅器更偏向招式,容易被人看出来,早点学可以防身。

在此过程中,主要的叁考书籍就是《3D游戏编程大师技巧》,这本书介绍了如何利用《3D数学基础》中的知识,将图形渲染出来。

但是这本书实在太老了,老到作者为了程序可以流畅跑起来,做了很多优化,比如这个坑其实最后的修正版,就是一个lerp而已。但是作者没有提示出这一关键,导致我最开始渲染出的图形一直有问题。甚至都搞了半个多月。

之后断断续续踩了几个坑,不带光照版的光栅器总算将就能看了。

然而,我这个人有个大毛病,一直想克服,但是至今没有成功。当我认为我会了,我就懒得进行下一步了。

刚好这时lua5.4 work1发布了。增加了分代GC支持,我一看这是个高级特性了,得跟上潮流,下载代码一看,其实lua5.4的分代GC是在5.3的增量三色GC算法上改进而来的,加上年初定的目标,就咬咬牙花了大概一周的时间,把lua5.3的GC源码大致看了一遍。

但是由于每次我看lua源码时,闭包上值的实现,总是让我有意无意的略过去了,因此其实算不上真正把lua GC看懂,应该说是基本看了个七七八八。

刚看完luaGC没两天,差不多五月份左右,我们项目中一个战斗系统刚好也算正式定下怎么做了。

这个系统有一个极特殊的地方,就是同屏人数可能上百,这就意味在数据同步量会非常大,那么如何尽可能的降低数据同步量,成了整个系统设计的重中之重。

这时我开始困难的回忆我所知道的所有知识,比如帧同步,状态同步。每一种都有自己的适应场景,但是都不太适合我们现在这种游戏模式。当然这里所说的帧同步,其实已经退化成行为同步了。

最终,我终于打破了自己的思维局限。

谁说一定只能用一种模式,以我们游戏的模式,其实是可以两者之间进行折中,每走到一片土地后,我们可以先用状态模式拉一下所有状态,然后只要视野不变,就采用行为同步方式进行同步。这样需要广播的数据量大大减少。

其实现在想一想,我以前做过的FPS游戏,中途加入游戏,其实跟我们切视野的思路差不多,这再次印证了太阳底下没有新鲜事。

解决完这个问题,我的周末终于又是我的了。

这时我自认能写软件光栅器了,就想去解决一个我心中的痛投影贴花,这个效果我在做FPS时,别人给我说过之后,我就开始研究了。然而一都没有办法去实现,即使找到文章都看不懂为何能达到这样的效果。幸运的是这次竟然真的看懂了。

解决了,又不知道要干啥了,刚好看到想到最近微服务这么火,那就研究微服务吧。即然都说docker好用,那就把我VPS上的进程全部Docker化吧。

在docker化时,发现了rssreader的一些bug。

在修改bug过程中,发现了一个奇怪的现象,在某些情况下杀死silly会进入死锁状态。其实之前也出现过几次,只是当时没有在意。这次打算认真查一下到底是什么原因。

最后发现,其实是因为lua中的__gc函数使用不当,造成了GC竞争, 这种情况只会以程序退出进行清理资源时才会出现。

具体情况是,由于GC竞争,导致在不同的GC函数中double free了同一个指针,而在进程退出时,jemalloc由于这个double free死锁了。

在做上面这些事的同时,我还有一条支线任务,就是阅读《计算机程序设计艺术》,由于这本书很多地方都会计算指令级开销,让我有种过早优化的倾向。

所以周末翻了翻软件工程神作《Unix编程艺术》,没想到这次解决了一个我当时工作中碰到了一个很棘手的问题。

这时,已经八月份了,时间已经溜走了将近三分之二了。也就是从八月份起,我需要处理很多生活中的问题。

因此后三分之一的时间,我其实并没有太多的精力去探索了。在这期间只是把以前的想法落实到代码上去。

比如我做了一个客户端差量更新工具,这个工具其实我早在三年前,就有打算做了,只是当时一来没需求,二来兴趣也不是很大。这次之所以动手做,还是因为我对他们引入的第三方框架的更新工具,很不满意。

再比如,我封装了一个lua框架forUnity,同样是因为我对现有的框架很不满意。

总结到现在,我发现一个规律,不管有没有生活上的其他事,我每年在10月分之后,基本代码和灵感都呈现断崖式下跌。

是时候定一下,2019年的目标了:

1. 断续阅读lua源码
2. 继续阅读《计算机程序设计艺术》
3. 为软件光栅器加上实时光照和阴影
4. 转向Unreal阵营,这次一定要先下手为强
5. 如果有精力的化,尽量涉及一下深度学习,以打码工具做为项目驱动

再见! 2018。

再见2017

2017年过的格外的快,似乎2016年元旦刚过去没多久,2017年的元旦又来了。

大致回溯了一下2017,好像什么也想不起来了。只好重新扒了一下Blog,才慢慢回想起这一年到底干了些什么。

仿佛是为了印证“计划赶不上变化”这句话。2016年定下的目标,依然没有全部完成。这几乎是一个惯例,每年初定下的目标,到了第二年去看时,一定没有100%完成。而我也已经习惯了这个规律。

2016年目标进度如下:

luaVM的源码并没有读完,只是其中断断续续的读了一部分。到目前为止,已读代码包括:string的实现,table的实现(不包括metatable table的实现), gc的实现,寄存器的分配及部分语法解析和OPCODE生成。

设计一个虚拟机更是一点都没有启动。

计算机程序设计艺术,仅仅读到了卷1的2.2.3节。

blog系统也只做了基本的主题展示和数据存储,还缺一个庞大的后台系统。

spark和zookeeper分布式源码更是一个也没有看,只研究了一下paxos协议。


在看lua语法解析部分期间,而刚好有同学跟我讨论起C语言为什么没有import机制。当时脑子短路,竟然花了大半天时间,炮制了个预处理器就这样把import给支持了,虽然很简陋,但至少可以证明可行性。我个人觉得这个思路非常值得一提。

之后很长一段时间都在研究如何让服务器具备高可伸缩性。并最终实现了一套与韦蝠王在文中提到的“类型4”服务器架构相似的架构。

在实现场景服务器时,接触到了Arena Of Interest的概念,并自己试着实现了一份

这个可伸缩服务器架构是基于一个demo做的,毕竟有需求,才会知道如何去设计。不过最终在框架成形后,demo由于缺少资源等原因没能继续下去。

后来微信小程序开始预热,加上我缺一个移动端好用的rss阅读器。就在小程序的基础上做了一个RssReader,虽然可能没有想象中的那么好用,但是对我自己来讲基本上够用了。之所以做个,除了我缺一个rss阅读器以外,也是因为当时有种陷入瓶颈的感觉。我需要跳出舒适区,来换个领域换个思路。

去年就说过,我的短板是数据库。在实际项目中虽然也使用数据库,但是并没有大幅度使sql数据库。因此接下来花了近一个月的时间学习了一下mysql的使用和sql语句的优化。同样为了跳出舒适区,最初计划学习完mysql之后,把rssreader的后台数据库从redis换成mysql。但犹豫过于懒惰,未能成行。

学习完mysql的过程中,就接触到了,一些分布式存储和一致的概念,比如二段式提交,Paxos协议。其中二段式提交还比较好理解,Paxos虽然描述都很简单,但是我琢磨了将近一个月才大致理解,为什么他会保持一致性。后根据理解实现了一个不考虑效率的Paxos协议的demo

回过头来看,2017年其实并没有干太多事,主要精力都花在了分布式上。如果为2017年取一个总结词,那应该就是"分布式"了。


在做游戏demo时,我顺便学习了Unity和Shader。虽然也能写,但是总觉得有种“浮沙筑高台”的感觉。

因此2018年,我的主要精力应该在图形引擎上,目标如下:

  1. 继续阅读lua源码,并实现虚拟机
  2. 继续阅读《计算机程序设计艺术》
  3. 实现一个软件光栅器(要支持实时光照/阴影大部分图形引擎的功能)

回首2016

16年初我又辞职了,即使在满足我的薪资要求的情况下。主要原因就在于,我的技术路线图受阻。

原本按我的打算,在有了少许高并发和socket经验之后,我需要进一下积累数据库使用实战经验。

然而在上家公司中,数据库部分操作是单独剥离出来,由java实现的。
而我所维护的C++部分仅仅处理高并发和正常的战斗逻辑并不涉及任何数据库部分。

因此,为了顺利继续我的技术路线图,我违反了自己的约定。

如果要找一个词来概括我2016年的大部分内容,那就是lua。

是的,正是由于这门语言,让我接触到了不一样的世界,函数式编程、面向原型编程,动态语言,虚拟机,协程,垃圾回收等各种以前闻所未闻的概念。

对于垃圾回收,我以前是有鄙视心理的,因为我曾一直有一个错觉,当你把这个对象置为nil,则此对象即会被释放,因此垃圾收集仅仅是屏蔽了malloc/free而已(就像是C++用引用来代替指针一样),在经过一段时间实践之后,才发现之前的短视。 并由此开始研究垃圾回收算法,如三色标记清除、引用计数,指针探测等各种不同的垃圾回收算法。

在luaVM源码中对于弱类型的实现,可以说是以C语言的方式来对ADT做出了完美诠释。其实说穿了,本质上抽象都是一样的,在C++的世界里我们还可以用OO的方式来实现, 这也再次说明其实大部分OO上的编程技巧并不是OO语言所专有的,重要的是在这背后的思想。

对于C++的特性,我一般是尽可能的少用C++提供的复杂的特性,对于模板我也是仅仅在极其必要是才会使用。因此模板我其实并不是非常熟。换言之,并不能写出很精秒的模板代码。但是新公司中将板模用于自动序列化的做法确实让我惊艳到了

在接触到数据库使用之后,由于我们使用的是内存数据库,我曾一度限入内存数据库和进程内cache之间取舍的纠结之中,最后终于幡然醒悟(当然也可能是误入歧途 :D)。

在不断重构silly的过程中,除了对lua及luaVM的了解不断加深,对于TCP/IP在linux实现的实现方式也有了更深入的了解。


不过遗憾的是,2015年我制定的目标,并没有完成。

一方面是因为时间没有想象中的那么充足。
另一方面则是因为初次接受这么多新奇的概念,在1年之内我很难做到完全消化。

因此,到目前为止, 我也仅仅做到对luaVM的源码有些熟悉而已,只阅读过部分luaVM中的源码(gc部分)。更谈不上脚本语言的设计和虚拟机的实现。

那么2017年,我的目标将是2016年的延续:
1. 阅读完luaVM的每一行源码
2. 设计一个虚拟机(如果有可能实现一个栈式虚拟机和一个寄存器虚拟机)
3. 阅读《计算机程序设计艺术》
4. 实现一个完整的blog系统(基于silly)
5. 研究一下主流的分布式系统源码如spark,zookeeper等

btw, 这一年的lua使用下来,我发现了一个事实。

动态语言并不简单,由于缺少类型系统等一些除错手段,因此编写动态语言代码往往需要更深厚的编码经验和准则。

当然这也是正常的,毕竟做任何事都是要付出代价的,动态语言简洁的代价便是,你需要有更清晰的头脑。

如果你不以为然,请试着用lua写一个1W行代码的的逻辑一试便知 🙂

2015

眨眼间2015年已经过去了, 也许是我最近记忆力变差了, 总感觉好像昨天才辞职的样子。

在2015年年初辞职时, 其实我的内心非常的纠结,一方面是安逸,一方面是未知, 很难下定决定到底何去何从。

终于还是求知欲战胜了懒惰。因为我发现如果再干着同样的工作,我的技术不太可能提高太多。所以最终我还是选择了辞职,由于上一份工作的原因,相比前端程序来讲,我对于后端的兴趣更大。因此在找工作方面,我更着重于找服务器编程方面的工作。因此后来就找了一份游戏服务器工作.

现在想想刚入职时闹的一个笑话都还脸红。才入职时,基于对新人的保守使用,都是先编写UI逻辑。而客户端的UI逻辑是采用lua+异步的方式来实现的。而我当时并不知道什么是异步,甚至还想在lua中使用sleep来保证代码的时序逻辑。直到后来我在silly中集成luaVM时,我才终于知道客户端lua+CPP中异步到底是如何工作的。

虽然当时我很想直接去写服务器代码,但是即然写了客户端,也就花了大约2周时间去学了一下《3D数学基础》。再说依我的性格虽然准备写服务器, 但一点都不了解客户端,那也不是我的性格 :), 不过在学习过程中,我发现游戏客户端其实就是在用各种自然学科的知识去模拟整个世界,也就是在这时候,我却突然对客户端代码也产生了兴趣。

这时分给我的UI工作刚好做完, 手头上暂时没什么事, 就硬着头皮研究了一下客户端的引擎代码部分。物理部分和声音部分都是不开源的,因此能研究的也就只有动画部分了。在研究了几W行动画相关代码之后,终于大致知道了一些动画渲染的原理。比如骨骼动画,动画融合等。

粗略研究了一下动画原理之后, 就顺便研究了一下客户端多国语言的实现。上一份工作中,我对于自己多国语言的设计非常不满意。看完这个多国语言的实现之后,我才明白原来当是自己是过度设计了,在简化了设计之后,我只花了几十行就重新实现了一个多国语言模块。

下一阶段的开发任务来了,我终于能够编写服务器代码了(虽然只是服务器中很简单的逻辑部分), 不过却见到了一些我从没想过的C++的用法, 由此又顺便学习了一下C++的一些高级用法(比如模板推导等)。与此同时我的个人服务器框架也正基本上正式开始编写了。

在接触到服务器编程之后, 由于是TPS游戏都是开房间的,因此我一直都是思考如何才能让所有玩家都可以在同一张地图上进行游戏。在此之前一直都想不通要怎么做。在网上搜了很多文章也都没有找到什么好的办法,我只好暂时暂停了此问题的思考,转而去研究了《redis的设计与实现》来换换脑筋。在《redis的设计与实现》的最后部分,作者讲了一下redis对于集群的设计,一下令我慌然大悟

在下一个开发阶段,由于业务需求,我终于要单独写一个服务器,想想都令人激动。在摆脱了以往框架限制的同时,我也需要从头来实现一些机制。 正是在实现这些机制的同时却让我对TCP协议有了更深的了解,比如tcp的端口绑定, TIME_WAIT等。就这样基本上都是上班写代码,然后发现自己有什么理解错误就下班回来改silly.

终于开发任务完成了, 又有一段时间可以搞自己的东西了.由于很多UI上的bug都不是100%必现的,而lua的调试手段一般就是加print来打印一些变量的值。当出现bug时我们需要打印一下当时一些变量的值就非常的不方便,于是就琢磨着写了一个简单的lua调试器.这样在出现bug时就可以拿lua调试器直接attach上去来打印当时的call stack以及以些变量的值等需要的调试信息,可以大大减少了bug重现的需要。

在很早以前就知道google protobuffer这个东西,而且也对编译原理有所眼馋。在silly中上层逻辑都是使用lua来编写,在socket传输序列化时很不方便,因此老早就想实现一个类似google protobuffer的东西了。趁还有时间就花了几周看了一下编译原理的词法和语法部分,然后实现了zproto, 用作silly的配套lua序列化库。这个库直到上周才终于完成了。


总得来说,如果2014年我得到的是新的技能的话,那么2015年我得到的其实就是经验。而获取经验的最大途径并不是来自于工作,而是对于silly的一次次的重构甚至重写。

所谓的经验其实就是各种取舍,在编码的过程中, 总会遇到各种情况,在这时就需要靠经验来进行取舍。

比如linux中的seqlock就是估算了index的递增速度以及linux调度时间来大大降低了实现的复杂度。

在silly中关于连接号的管理也同样借鉴了类似的方式大大降低了实现的复杂度。

因此在编码过程中,我们需要会的不仅仅是技能,还有对于问题的取舍,怎样做到功能和复杂度的平横,才是一个程序员迫切需要解决的问题。

在2016年,我打算阅读完luaVM的源码,一个脚本语言的设计,一个栈式虚拟机的实现,仅此而已。

lua编码风格

最近都是在看lua代码, 并在其基础上进行修改和增加功能. 在代码中看到了不少个人感觉很不好的现象, 就忍不住吐槽一下.

lua做为一门动态语言, 其弱类型及灵活性, 的确大大加快了开发和修改的效率. 但是这种自由有时不加以限制的使用, 有时候可能会造成很严重的后果.

先以我有限的理解说一下lua语言对于访问控制的有限支持.

定义变量或函数只有加上local才代表局部变量或函数, 否则只要这个模块被加载就可以被其他模块访问.
require代表要去加载某个模块, 如果两次调用require去调用同一模块并不会造成同一模块的多次加载.
lua5.1之后加入的module(…,seeall)函数可以自动导出lua函数中的非local函数及变量

在我所看到的代码中到处都是大文件(大的都有1W多), 魔数, 全局变量, 循环依赖, 如果某一模块已经加载并不会再去调用rquire函数等各种问题.

全局变量的存在使得看似将程序分了多个模块, 但是由于全局变量可以被所有模块相互访问和修改, 全无依赖层次可言. 最后不得不将这些模块化为一个整体模块来使用.
魔数让看代码的人搞不情作者意图, 还多了许多可能修改错误的机会.
并没有显式调用require会导致实在看不情模块之间的依赖关系, 对于把握整个程序的结果来看非常不利.
单个文件1W行的代码就像是一大滩稀泥放在那, 让你敢看不敢碰.


虽然lua给予的代码访问限制很少, 甚至于还提供了module(…,seeall)这样方便的函数. 但是如果我们不加限制的去使用只会使程序成为一滩无法维护的烂泥.

《C Programming Language》中有这样一句话 “…限制不仅提倡了经济性, 更在某种程序上提倡了设计了优雅”.

在参考了网上大神的lua源码和《lua程序设计》以后, 我觉得可以采用部分限制来提供代码的可读和可维护性.

require函数的作用就是去加载一个没有被加载过的模块, 并将此模块的返回值记录下来, 作为每次调用require函数的返回值来使用. 那么便可以在require函数上来做文章.

首先除非有必要, 应该舍弃module(…,seeall)函数的调用, 采用返回表的方式来返回某个模块的所有导出接口. 代码如下:

--bar.lua

local bar = {}

local v1 = 3
local v2 = 4

function bar.test()
print("----bar.lua---, v1, v2", v1, v2)
end

return bar

--foo.lua
local bar = require("bar")
bar.test()

虽然相比module(…,seeall)函数来讲, 代码中可以要多打几个字. 但是相对于这种方式提供的好处来讲却是巨大的.

因为采用require函数的返回值来调用相应的模块函数, 那么就限制了只要模块有依赖就必须去显式调用require.
就算不小心写漏了local, 只要不会有意在bar表中去赋值, 那么外部模块也并不能去访问bar模块中的v1和v2变量

由于lua中字符串管理是采用类似《C接口与实现》中的atom的管理方式, 因此字符串比较与整数比较的效率几乎是一样的.
那么其实可以通过直接用字符串传入参数或采用下面这种方式来避免魔数.

--foo.bar

local foo = {}
foo.state = {ONE = "the first step of state machine", TWO = "the step is do something"}

foo.test(step)
if(step == foo.state.ONE) then
dosomething()
elesif (step == foo.state.TWO) then
dosomething()
end
end

毫无疑问我更偏爱上面的方式, 即省了注释, 还能有与使用magic number一样的效率, 在调用此函数时, 还可以减少击键次数.

当然这些限制, 只能某种程序上解决一定的问题, 提高了代码的可读性. 但是像循环依赖, 刻意的全局变量等设计问题, 是无法靠变这些限制来解决的.

入职一周

经过了大半个月的折腾, 总算是换到纯软件公司了。 有时不能不感叹天意弄人, 这次依然没能如愿做纯逻辑的后端, 不小心做了客户端。

以前对于游戏开发领域几乎没了解, 所以其实这一周几乎都是在代码和百度中度过的。

在上一份工作中, 由于工作内容的特殊性,软件需求一直变化和增加, 所以一直通过研究软件工程来做到更合理的模块划分和接口定义, 以便代码对于未知需求有一定的应变能力。 虽然需求多变, 但是其实现难度变不高,几乎不会用到算法, 惟一一次用到的一个算法(快速排序)还在在离职交接过程中解bug时才用到的。

长时间的思维定式都差点成功的让我认为, 软件的编写其实就是软件工程, 那些传说中算法, 数学等几乎不可能用到。

然而在科普游戏开发的过程中,意外发现游戏制作其实就是去创造一个新的世界。 在这个世界中一样有人, 有光线, 有重力加速度等等。

因为需要去模拟真实世界的很多属性, 那么相应的也就会去会涉及到诸多领域的知识, 如物理, 数学, 算法, 人工智能等。

抛开其学习难度, 那么这种多姿多彩的程序世界无疑是我更想去做的。

想象一下。有一天, 可以亲手将学会的知识作为另一个世界的规则去运行,那该是多么令人激动和兴奋的事啊。


btw
周三时厚着脸皮去请大牛给推荐了本书<<3D数学基础>>.

今天挤了半天时间看了6章就忍不住感叹, 要是以前我上学时教材都这样, 我当年怎么会学不好啊。

就拿最简单的向量来说, 记忆中以前学数学时, 上去就是公式怎么样, 碰到这题怎么算。 结果算是会算了, 可是依然不知道什么时间以及什么情况下可以去使用。
结果就是公式背下来了, 考试也考过去了, 但是潜意识里就觉得, 这玩意我这辈子都不会用到他。

再看看<<3D数学基础>> 每一个公式都会去讲解其内在几何原理, 使用方法等, 看上去清晰明了, 简单易懂。