2020新的十年

乍一看缺了2019年的内容,其实因为大部分记录都留在了推上。

而因为大环境变得越来越难,所以并不想在公开场合讨论什么了,都是旧的牢骚,没有任何事情变得更好。

今年就不写游戏记录了,得空专门整一篇。

还是记录下年初debug的收获,抓取了pixi.js的的WebGL调用记录,发现做简单的2d游戏用不了几个函数,这里也就47个而已。

它们是:

gl.activeTexture, gl.attachShader, gl.bindBuffer, gl.bindFramebuffer, gl.bindTexture, gl.blendFunc, gl.bufferData, gl.bufferSubData, gl.clear, gl.clearColor, gl.compileShader, gl.createBuffer, gl.createFramebuffer, gl.createProgram, gl.createShader, gl.createTexture, gl.deleteShader, gl.disable, gl.disableVertexAttribArray, gl.drawElements, gl.enable, gl.enableVertexAttribArray, gl.framebufferTexture2D, gl.frontFace, gl.getActiveAttrib, gl.getActiveUniform, gl.getAttribLocation, gl.getExtension, gl.getParameter, gl.getProgramParameter, gl.getShaderParameter, gl.getUniformLocation, gl.isContextLost, gl.linkProgram, gl.pixelStorei, gl.scissor, gl.shaderSource, gl.texImage2D, gl.texParameteri, gl.uniform1f, gl.uniform1i, gl.uniform1iv, gl.uniform3fv, gl.uniformMatrix3fv, gl.useProgram, gl.vertexAttribPointer, gl.viewport

分分类的话,大概是这么几种。

查询设备状态或扩展接口

gl.getExtension
gl.getParameter
gl.isContextLost

操作和设置属性

gl.disable
gl.enable
gl.blendFunc
gl.frontFace
gl.scissor
gl.viewport

清屏和绘制

gl.clearColor
gl.clear
gl.drawElements

操作vertex属性数组

gl.vertexAttribPointer
gl.enableVertexAttribArray
gl.disableVertexAttribArray

操作各种texture对象

gl.activeTexture
gl.createTexture
gl.bindTexture
gl.pixelStorei
gl.texImage2D
gl.texParameteri

操作framebuffer对象

gl.bindFramebuffer
gl.createFramebuffer
gl.framebufferTexture2D

操作各种buffer对象

gl.createBuffer
gl.bindBuffer
gl.bufferData
gl.bufferSubData

关于shader的接口

gl.createShader
gl.shaderSource
gl.compileShader
gl.getShaderParameter
gl.createProgram
gl.attachShader
gl.linkProgram
gl.getProgramParameter
gl.deleteShader
gl.useProgram
gl.getUniformLocation
gl.getAttribLocation
gl.uniformMatrix3fv
gl.uniform3fv
gl.uniform1f
gl.getActiveAttrib
gl.getActiveUniform
gl.uniform1i
gl.uniform1iv

除了shader部分比较繁琐以外,其他的都很简单,而shader相关的也可以很容易的封装起来。
但实际上由于opengl自带状态,从api调用的录制结果脑补当前场景,非常困难。
比如到底操作的是哪个buffer?究竟哪个texture是活动的?framebuffer的数据都是设置正确了吗?
再加上最好能自动合并渲染批次,所以pixi.js这样的库还是很有必要的。

说到多线程的Bug

刚才提到了多线程,想起来以前遇到的bug,记录下免得以后忘记了。

起因是一个小哥试图优化多线程的loading,在没看完其他代码的情况下,觉得应该在idle的时候把线程的priority降低,给其他更重要的革命工作让出CPU。

然后他没想到的是,loading会调用::Sleep(),结果就是idle之后常睡不醒,优先级太低了其他革命工作都忙得不行,哪有机会让loading出来说话。

他更没想到的是,其他革命工作是依赖于loading的结果的,毕竟革命也要吃饭,没有loading的数据啥也干不了。

所以结果就是loading被负优化,反而变得更慢了,整个游戏也变得更慢。老婆听我讲了这个bug之后做了个特别棒的比喻:

导游带一堆客人去景点,然后让客人自己排队入园。客人问导游你不进去么?导游说我去很多次了你们排着我在外面歇着。没想到门票都在导游手里,一堆人在门口嚷嚷半天谁也进不去,于是都等着了。

所以说优化要先做benchmark,脑补的优化基本都是负优化,没有benchmakr数据支持,你优化个毛线?这事儿我没少干过,就不多吐槽了。

但是必须强调下,文中的优化小哥可不是我,我是修bug的那个。俗话说的好,前人栽树后人乘凉,前人挖坑后人吃瘪。革命工作嘛,总是会有这样那样的问题,别抱怨了好好搬砖吧。

程序员的执念

昨天在看一个吉里吉里(krkr)Timer问题,开启Timer后,游戏会随机崩溃,报错说Timer已经被删除。

题外话,我个人是很讨厌Timer这种东西的。我见过很多糟糕的Timer设计,而且它容易被滥用。使用者会产生一种“我在做并行逻辑”的错觉,而且大部分设计者在设计有关Timer的逻辑时,并没有完全想明白。随口设计出诸如“延迟x秒发生这个,再延迟y秒发生那个”,当这些逻辑交织在一起的时候,无论是设计还是实现都会非常困难,edge case很多,最终导致产生不可预期的结果。

但Timer本身不复杂,对于krkr这样针对电子小说的游戏引擎更是如此。我随口说了句总不至于多线程吧?毕竟krkr内部的解释器是单线程的,所以Timer触发的逻辑肯定得在主线程里执行。但这样一来解释不了Bug的成因,为何会出现删除Timer后OnTimer函数仍然被调用,并且在OnTimer里面操作Timer的时候又说Timer已经被删除?我脑补了两个原因:

1、删除Timer不会终止它的OnTimer函数,这种设计是引擎缺陷。

2、OnTimer触发后就无法被取消了,这种设计也是有问题的。

脑补当然没用,看代码就好了。于是我翻出krkr的源码,找到Timer的实现,还真是多线程……然后就脑补了20种多线程的常见问题。等读完源码,发现逻辑虽然饶了几圈,但bug是没有的,大致上是这样的过程:

krkr首先启用一个单独的线程来处理Timer触发的逻辑,它通过::Sleep来控制Timer的等待(很不精确,但省CPU)。被触发的Timer通过PostMessage传递给隐藏Win32 HWND,然后在独立的UtilWndProc执行逻辑。因为UtilWndProc是由主线程创建的WindowProc callback,所以和解释器处在同一个线程。在这个消息处理函数中,Timer逻辑被丢给了自带的Event模块,Event模块执行实际的OnTimer。krkr保证了如果一个Timer被删除,它在Event中注册的所有OnTimer事件同时也会被删除。已经在等待队列的Timer,检查到自己被删了就直接return,什么都不会发生。

我个人觉得这个Timer实现的太复杂了,可能20多年前CPU宝贵,krkr没有一个持续tick的函数,只能通过多线程来实现吧。而windows里这样传递消息也是krkr固有的模式,其他人提到过:http://bbs.niratea.org/test/read.cgi/krkr/1323994344/389

总之我假设的两个问题都是不存在的。这样一来就是上层逻辑的问题了,于是仔细看了看,发现了真正的原因(WTF,为什么一开始不看?):

1、删除一个krkr对象,并不会自动把对象引用置为void。这个操作非常c++,但对于自带gc的脚本引擎来说还是挺猎奇的。这个错误的假设造成了一些逻辑问题。

2、Timer被多次创建,且保存在parent的成员变量上。这样新创建的Timer会导致旧的Timer引用丢失,而gc并不会第一时间删除这些timer。一旦新的Timer被删除,旧的OnTimer函数被触发的时候,旧的OnTimer函数里通过成员变量访问的“自身”已经是新的Timer,所以会出现问题。

修复很简单,每次创建新Timer前记得删除旧的即可。

这个问题其实可以通过引擎的设计来避免,毕竟用户可能会犯错。OnTimer如果能传入自己,用户就没有必要通过额外的方式保存Timer的引用,这样OnTimer可以保证操作的timer对象肯定是有效的。用户即使重复创建Timer,造成的结果也无非是Timer被重复触发,而不是抛出异常。另外带gc的脚本引擎提供删除功能真的好么?20年前内存大概也非常宝贵吧。

当然,正确的使用Timer才是解决问题的根本。不过我之前就说过,Timer是个容易被误用的模块。尤其是游戏,大部分基于Timer的设计逻辑都是经不起推敲的(个人看法),能少用就少用吧。

Unity的美好与绝望

先说好的吧。可能目前最令我满意的就是IMGUI这部分API的定义,写Editor Plugin和In-game Debug Menu爽到飞起,很快就能出效果。

UI一向是很麻烦的东西,要保存状态,还要异步,还要同步View和Model的数据。很多框架动不动就是MVC,各种模式,用起来都痛苦的一比。这个IMGUI非常简单粗暴,反正也不能用来开发更复杂的玩意儿,设计和使用契合度非常高。

目前用到的其他特色的设计,比如Component Based,和别的商业引擎比起来大同小异。当然从价格和可用性的角度,Unity更胜一筹,以后有机会再多扯点。

主要还是吐槽,就说点看似美好其实比较坑的东西吧。

官方文档

全还是比较全的,这个我主要吐槽两点。

第一是组织方式,有些很重要的东西(比如对象的生命周期),或者说Unity和其他引擎比很不一样的东西(例如特殊目录,Editor/Resources/),隐藏在犄角旮旯等着你来发现。基本上都是在别处找到更直接的教程,然后拿到关键字,再到官方来看细节。

第二是官方真喜欢视频教程啊,不能搜还看得慢,动不动20分钟说点屁事。不能写点图文教程吗?比如IMGUI的各种用法,第三方写的比官方好用多了。

资源加载

三种方式,内在逻辑截然不同。

以Editor内操作为主,基于Reference的这套可能比较适合小游戏,或者非程序员开发的游戏。只要一切资源都是经过Editor操作和绑定的,那么后续过程也都是自动的。官方强力推荐这种,美术和策划友好的东西。

所有不能在Editor上绑定的数据,都需要Load-on-demand。一但牵扯到Load-on-demand,需要动态加载数据,事情就来了。

其实加载资源(Loading)是非常简单粗暴的操作。储存方面就是打个包,把数据和标识存起来。读取方面,只要给定一个标识(比如路径或者id),然后决定是同步加载还是异步即可。同步立刻返回结果,异步就给个handle或者允许callback。这样的底层设计几乎可以满足所有基本需求,其他花哨的玩意儿再上面继续包装即可。

但Unity的坑无处不在。首先是打包,Unity有两套截然不同的资源管理方式,分别对应Resources目录下的文件和Asset Bundle,姑且称为R和A。

R的打包是自动的,所有此目录下的文件都会在运行时打包,方便的很。这么好的东西,官方给出的Best Practice居然是“别用”。写文档的朋友,你以为自己很幽默吗?不能用的东西做出来吃屎呢……

A的打包是手动的,只不过没有现成UI可用,需要在Editor里调用API。但官方文档写的稀烂,打包和加载的过程同DLC的资料搅合在一起,还要牵扯到Cache和Server。乍一看仿佛要启动什么web service做host才能使用,打开还得从WWW下载。其实只要StreamingAssets目录一放,自己写脚本打包就好。

别忙,坑还没完。

R打包出来的资源是不带扩展名的,因为自动打包的时候就是根据扩展名来的,打好就全给删了。加载的时候遇到重名,请提供type。再出现重复我就不知道要怎么玩了……R的路径是相对于Resources的,这个倒还能理解。

A打包出来的资源,标识是写入的时候给定的数据,如果写的时候带了扩展,那就是有扩展名,目测不能重复。

所有“测试的时候想用R,发布的时候想用A”的同学,请自己写wrapper吧……真的好痛苦好痛苦。实际上更好的做法是,测试的时候直接读取Editor里面的AssetDatabase,并不需要使用Resources。官方说的“别用”就是字面意思,但您能提示下还有别的选择吗?直接介绍大家用AssetsBundle真的很不友好啊。

序列化和热更新 Hot-swapping

Unity一大特色就是各种自动,写个public直接就能在Editor上改了。而且改改c#,一回头自动把新的dll热更新上去,数据都还在。

听着很美好,实际情况是,改完代码出现大量Null Reference的异常……

https://gist.github.com/cobbpg/a74c8a5359554eb3daa5

这个小哥大概记录了一下,我踩了半天坑再补充一点:

1、首先是ISerializationCallbackReceiver,调用时机很诡异,Editor下如果你选中了带有这个接口的对象,Inspector会疯狂触发OnBeforeSerialize()。存起来很慢的东西就悲剧了。而OnAfterDeserialize()在Swap的时候会触发多次,目测Editor有创建多个实例,有些数据是没准备好的,还得自助检查。

2、在Editor里创建的各种引用,例如Component都会存下来。但如果动态AddComponent出来的玩意儿就悲剧了,在OnBeforeSerialize()之前就会被干掉,请自己重新动态创建。后来证明只要是Unity的Component,引用都会正确的保存下来,核心的混乱点还是Editor里有多个实例,初始化状态各不相同。

3、虽说需要动态创建,但特么的所有AddComponent这类方法,甚至GetTime都不允许在callback种调用。大概恢复数据的时候是多线程环境?所以只能自己搞个boolean做dirty check,然后在Update()里面实际恢复数据。实际上参考官方的例子,可以在OnEnable里面做恢复处理,比Update要省。

4、虽然官方号称大部分数据类型都可以自动保存,但“大部分”这种说法就意味着你需要被干很多次才能修复“少部分”的问题。比如标记为Serializable的数据,运行时可以为null,但Swap的时候会变成non-null状态。例如字符串变成了””,结构体变成了初始状态,最坑的是Array,里面的null元素会被填充。如果有逻辑依赖于null-check就悲剧了。

以后遇到新的再补充。

补充个新坑:打包的时候如果用BuildAssetBundleOptions.None这个option,看似人畜无害的默认选项居然是用LZMA压缩整个包,第一次开启会做解压缩操作,慢的一笔。最好的方式是使用ChunkBasedCompression,这个选项开包很快。港真我不知道None这个选项除了给网络下载几件衣服的DLC用以外还有什么应用场景,官方文档用这个名字(None是压缩的,咱还提供UncompressedAssetBundle选项哦~),并且用在代码范例真的好么?

2018第一篇总不能是忏悔吧

唉,还是得忏悔。

最近半年玩了600小时游戏的我实在太那啥了……当然代码也写了不少就是了,只能说肝功能严重受损吧。

命运2虽不是那么好玩,三个多月每天肝也搞了160小时。多人游戏果然有毒,凑起伙儿来就停不住。(为将来的MHW埋下伏笔)

后来120小时白了DQ11,感动得不行。DQ可是咱入坑游戏的系列,我要是有空真得写个千八百字的来诉诉衷肠。

华纳的中土世界不对胃口,强行玩了16个小时还是放弃了,还不如AC(这算夸奖么)

巫师3,Dishonored和地平线资料片都随便玩了一两个小时,权当填充空白。

值得一提的是,暗黑3的资料片终于让我给打穿了。前后花了将近20个小时,一路上各种捡暗金装备还是挺爽的。但剧情太弱了,关卡也很弱,有种自动生成的感觉(此处命运2的资料片可以一战)。打完发现我没选赛季人物,直接GG。

好了,最后要说的就是那个了。对的,就是那个。(BGM响起)

《怪物猎人世界》在1月26日发售了!

接下来的一切都发生得如此迅速,等我回过神的时候,已经是3月14日了。在这一天,我成为了苍蓝星,整个大陆的猎人都向我发来祝贺。

\(T__T)/

以后的日子大概需要加速填坑吧——如果岚龙崩霸煌黑没有DLC的话。

BTW,倒是真的弃用国内SNS了,不想打个字还得斟酌半天,累人。

日后尽量写点Unity的使用笔记,还有Javascript的踩坑记录,看时间和心情吧。

2017年底赶稿

一般来说这种事情不会发生,那就是我整整两年都没更新blog……

其实16年研究了不少新东西,17年也是。然而没写blog就导致没有记录,心得体会也就难以保存下来。脑子是个好东西,但时间的力量更加强大,上了年纪的我不能再靠记忆力啦。

这都怪weibo和wx这些碎片化的SNS黑洞(好像之前谁说要弃用SNS来着?)

总之,16年到17年的变化还是蛮大的,物理上移动了很长的距离,生活节奏也变得完全不同。16年玩得最多的居然是掌机,通关了火纹if和皇骑前传。主机方面,跟着太阳战士毫无意外的白了黑魂3之后就放下了,直到2017年PS4的爆发。仁王、地平线、FF12以及命运2吃掉了我大量的业余时间,都是值得一玩的作品,让我重燃了对AAA的信心。

悲剧的是因为办公室和家里各有侧重的缘故,PC平台被基本搁置了,勉强有命运2撑场面。好像随着生活方式的变化,主机+电视变得更加合适,至少可以一个人玩一个人看。

好多年不怎么动弹的我,因为这一圈跑动,主动见到了不少很久没见的朋友。也因为新居住地是旅游胜地,我被动的见了不少朋友。作为社交懒惰症患者,这也挺难得~

还能说啥呢?对2018(的怪物猎人世界)充满期待吧……

2016迟来的忏悔

根本没有弃用任何SNS,有没有!!整一年,什么都没写。

2015超级超级忙,项目的事情就不多说了,都是辛酸泪。但Steam上总算搞出点花样,感觉不错。年底各种折腾装机,Linux,跨平台编译,树莓派等等,玩得杂,忘得快。

当然,这些内容我慢慢补。历年的传统,先谈谈玩了什么游戏。

去年爹妈来过年,帮我稍了块3DS烧录卡,于是乎终于可以玩跨区和汉化的游戏了。通关《勇气默示录》和《逆转裁判》之后,本以为《怪物猎人4》是中文版,有希望继续玩下去,但马赛克太重还是放弃了。妄我买了两份卡带和两份主机。

后来忙得只有碎片时间,就主要攻略炉石传说了。AAA当然不会错过,巫师3让我换了GTX960,然而并没有通关。尝试了Bioshock Infinite,也没有通关。但同样是突突突的Call of Duty-MW3就很快通关,可见5个小时才是最合适的游戏长度。

接下来在华丽丽的九月,各路大作隆重登场,然后我基本上就沉迷了。

首先一口气通关了合金装备3、4、5,还看了各种历代回顾,写了万字长文。为此购买的豪华版游戏和主机简直让我大出血!但PS4绝对值回票价,因为后来玩了上百小时的Bloodborne,最近还白金了。人生第一白,献给这么重口的游戏,我觉得我的性格已经被改变了。

天涯明月刀有很多老同事参与,我也玩了玩,画面不错,其他不想多说。PC上的怪猎OL移植版终于公测,风评不好,不敢试,遂作罢。

去年没怎么多玩Indie。Her Story是个非常妙的作品,深得我心,5小时通关。BattleBlock Theater也不错,玩着挺逗的,然而并不能让我不停的玩下去。Transistor让人眼前一亮,可惜去年AAA攻势凶猛,都统统搁置了。PS4会员给了好多Indie,但只有个潜行的动作游戏我稍微玩了玩,关卡太难弃了。

今年会继续推Dark Souls,并不准备完美通关,太累人了。这种hardcore游戏,体验体验就好,不能耽搁填坑大业。

是的,今年一定要填坑,说什么也不能拖了。

心宽体胖

上午空着肚子研究了半天css和php,把blog的模板重新规划了一下:

  • 页面自适应各种分辨率,这样才对得起我的2k显示器。
  • Archives显示最近50年的文章链接,估计是够用了。
  • 时间显示精确到分钟,大幅缓解了我的强迫症。

最终感想:好饿。

Calling Convention

之前并没有很在乎这方面的事情,直到最近需要在某个bcb编译的程序中集成steam sdk。

故事是这样的,steam sdk提供的接口是c++代码,官方号称支持visual studio和gcc两种编译器。但我不想先做一层包装转换成c的接口再使用,所以打算强行在bcb中链接这些lib。

现在问题来了,由于c++的abi很混乱,微软又喜欢玩花活,一旦接口是c++的格式(class, virtual table)就很微妙。这就牵扯到了calling convention的事情,也就是函数调用约定。这件事情本来很简单,在c的时代只要解决两个问题就好:1、参数个数是不是固定的。2、参数是如何被放到堆栈中的。第二个问题无关痛痒,所以c规定参数一律是从右到左压入栈中,这样用的时候就可以从左到右依次弹出。

所以calling convention只和第一个问题有关,这样就诞生了两个关键字:

__cdecl:支持不固定个数的参数,调用方caller负责清理堆栈。好处是printf之类的东西可以实现,坏处是函数内部不能对堆栈玩花活,因为管理权在外面。

__stdcall:参数个数固定,被调用方callee负责清理堆栈。好处就是函数结尾直接用ret xx解决所有问题,坏处是参数个数固定,因为编译期就得决定ret之后的字节数。顺道说,COM中所有调用都是__stdcall。

目前为止一切还是有序的,直到link的时候,出现了name mangling的问题。M$和Borland使用了截然相反的方式来修饰函数名,具体可以参考《Using Visual C++ DLLs with C++Builder》中的Table A。还好双方都提供了解决方法,任选一种就可以生成合适的符号名,可以愉快的link。

即使link没有问题,问题也还远未解决,一旦开始使用c++的接口,run-time的问题就浮现出来。这就要提到M$的发明:

__thiscall:用于non-static member function,会把this放入ecx,再调用函数。好处就是this指针非常容易找到,调试方便。坏处就是……这是M$的发明,而非标准。还好时至今日很多编译器都支持了__thiscall,巨硬不再孤家寡人。

实际上bcb对this的解决方法非常简单,就是把它当作第一个函数的参数,照样传递进去即可。调试时候也不见得有多麻烦,而且如今编译器对代码的优化非常风骚,ecx在入口可能还有用,进入函数体之后几乎就完全无用了。

__fastcall:时代的眼泪,从名字看很拉风,但定义特别复杂。具体就是说把前几个较小的参数从寄存器里传入,而不是压入堆栈中。这样调用双方省去了很多进出栈的操作,故名fast。时至今日,这些开销似乎没什么人在乎了。

其他的调用约定我没怎么看就不说了,手头的情况只和后两种有关。这个bcb的程序诞生于近20年前,作者大概觉得函数调用开销是一件很重要的事情,所以默认全部函数都采用__fastcall。而steam sdk的c++接口全部用默认的__thiscall,运行的时候caller把参数往寄存器一丢了事,而callee还苦苦在ecx和堆栈中寻找,代码就跑飞了。

解决方案非常悲剧,对于每个class的每个non-static member function,都提供一个静态函数作为wrapper。内部帮他们把参数位置转换一下,然后再调用sdk中的实际代码。

用macro封装的效果如下所示(1个参数)

#define STEAM_API_CALL_WRAPPER1(ret, type, interface, index, var1)	\
ret STEAM_API_CALL type##_##interface(type * thisptr, var1) {	   	\
	asm { 						\
		MOV eax, [ebp + 4 * 3];	\
		push eax;				\
		MOV ecx, thisptr;		\
		MOV edx, [ecx];			\
		CALL [edx + 4 * index];	\
	} 							\
}
//

使用之前必须逐个定义,还得注意虚函数在虚表中的位置。不幸中的万幸是bcb和vs的虚表排列都是一样的。万幸中的不幸是,steam sdk中居然还有头文件和编译好的lib中虚表里函数顺序不一致的情况。大概是某个程序员手贱没事就调整函数的位置,又不肯重新编译sdk吧……

STEAM_API_CALL_WRAPPER1(bool, ISteamUserStats, SetAchievement, 7, const char *pchName);

经过这么多折腾之后,终于成功在bcb中链接运行了steam sdk。接下来发现还有callback的问题,steam要调用从bcb程序里传入的函数指针!解决方法和上面类似,callback函数也需要有一层wrapper……

只要对所有使用到的接口做上述处理,最终一切都和谐美好的运行起来,完全没有使用额外的dll,直接在bcb中使用了steam_api.dll。我研究了一下网上已有的实现,都是采用包装一个wrapper dll的方式。我这边的方法相对安全一些,否则wrapper可以被轻松的替换掉。当然咯,steam有提供额外的drm措施,简单换掉wrapper并不能移除steam绑定就是了。所以如果不是像我这样有强迫症的话,写个wrapper dll是最简单直接的方式。

结论是如果以后需要写什么对外发布的sdk,还是以c接口的形式提供最具有兼容性。

弃用各种SNS

鉴于没有多少时间可以被杀掉,我决定弃用各种SNS。
前段时间因为搬家折腾了五六周,现在终于安顿下来,又要准备开始过年。

先把自己的工作环境整理好,接下来就是工作时间。
坑已经挖了很多,必须赶紧填起来……

现在问题来了:哪个坑的优先级比较高呢?