« October 2014 | Main | December 2014 »

November 25, 2014

策划们离不开的 Excel

我相信至少在国内的游戏策划圈, Excel 是每天必不可少的存在。倒不是因为要用它制作数值表格,一切文档最终都一定是用 Excel 写的。但作为一个程序员,我相当的痛恨 Excel 文件,就好像我当初痛恨 word 一样。只有几个字就不要保存成 doc 文件啦,可现在已经没有人用 word 了,大家全转去 Excel 了。如果有可能,策划一定愿意在单元格里写脚本的,这样可以将重点标红。

提取 Excel 中的文字信息并不复杂,但真正的麻烦在于 Excel 文件对版本管理工具是极不友好的。甚至你打开一次 Excel 文件再保存关闭,也会生成一个完全不同的新版本。这是因为,文件中记录了最后修改的时间(是的,Excel 不信任文件系统里的时间);还有激活的单元格是哪一个。在这种环境下,多人协作的版本控制工具用起来绝对是一个悲剧。

我大概花了一周时间来试图解决一系列问题。结果不算成功,也不算失败。这里记录一下上周踩过的坑。

问题源于我们的项目中,策划把一切他们能生产的东西都记在了诸多的 excel 表格里。当然,和上世纪的程序员一样,大家都尽量自己维护自己的那块文件,所以即使在版本管理工具下,也基本没有冲突。但是总有那么 1% 的机会,几个人会修改同一张表格的,尤其在项目压力大时,往往实现功能的程序也会打开表格对里面的数据做一些修改。在版本控制工具下,冲突就在所难免了。尤其是我们刚刚让策划从 svn 迁移到 git 下,git 的工作流的复杂性很容易让策划的脑子不够用了(实际上受 Excel 文件格式限制,他们也只需要一个版本备份工具,其它本来就是多余的)。我开始动念头来解决问题。

首先,xlsx 文件其实是一个标准 zip 压缩包,里面打包了一系列 xml 文件。如果仅仅是需要一个文本格式,那么只需要把包解开,用一种非压缩的形式重新打包即可。

对于一些嵌入的图片,只需要用 base64 编码。由于嵌入表格的图片多半不会修改,所以并不会造成版本间的差异。

一开始,我以为这项工作两小时就能搞定,事后发现,太天真了。

我写了一个 lua 的小程序,可以读出 zip 包里的文件,对文件名排序,然后按文件名/内容的次序依次把文件连在一起形成一个大文本文件(其中的2进制内容使用 base64 编码)。这样处理后,xlsx 文件基本就是一个文本文件了。为了对版本管理工具友好,我对 xml 里的标签后增加了适当的分行。这样处理以后,版本管理工具基本能识别出表格数据每个版本的差异。

第2步,可以动手消除一些对版本有影响却对我们没有意义的数据段。比如文件的最后修改时间、激活的单元格等。这样、如果打开一个 excel 文件,保存后就不会产生差异。

那么,这是一个新的文件格式。怎么让 Excel (或 wps 等兼容产品)打开它编辑呢?

虽然第一反应是给 excel 写一个插件。但我知道拿不是一两个小时可以搞定的。所以我选择了一条弯路。写了一个脚本,可以生成一个临时目录/文件,在用户想打开一个自定义格式文件时,先转换为标准的 xlsx 临时文件,让关联的软件(excel 或 wps 等)编辑它。我们可以监控这个文件的变更时间,来即使把临时文件转换回去。当这个临时文件可写时,就表示已经停止编辑这个文件了(excel 对打开的文件有文件锁定)。这时,可以删除临时文件。

让自定义文件格式关联到这个脚本(我用 lua 编写的十多行程序),策划就可以直接双击自定义格式文件编辑了。


我天真的以为这可以解决大部分问题。但一试用就发现了问题。

策划很容易在表格中嵌入 ole 对象,比如他们最喜欢的 visio 绘图。对于 Ole 对象,是以2进制对象形式存在文件里的。但是、visio 这种 COM 对象序列化后有同样的问题:COM 对象其实也是一个包,而包里同样有时间戳。这同样会导致没有修改过的对象每次持久化的结果是不同的。

解决这个问题并不复杂。我注意到 7zip 是可以打开 COM 对象文件的。在网上下载 7zip 的源代码,很容易就写出一个 COM 对象文件的解码程序(大约几十行 C 代码)。我们要做的仅仅是把包里的文件时间抹掉即可。


如果进行到这里就停住,我就不会认为我在浪费时间。起码我解决了一个问题:没有编辑的 excel 文件无论保存多少次都是无差异的。文件格式变成了文本,大致上可(被程序员)读了。

可我贪心的想把问题解决的彻底点,设计一个自己的格式会更好一些。就是一个比 csv 格式强一些的,保留了有限的版面格式信息(包括单元格的宽高、颜色、边框等)的易于用文本编辑器编辑修改的文件。

我们可以把重要的信息都集中放在一起,不重要的格式信息放在另一部分。如果版本冲突,可以自由抛弃一个版本的格式信息而采用另一个版本的,这样不至于损坏数据本身。

csv 格式设计的很糟糕(比如那个双引号)。也不利于做版本比较合并(易于人编辑且容易合并的格式应该是一个单元格一行,而不是按行保存。且 csv 会丢失重要的公式文本。

我设计的格式大致是这样的:

[!sheet]
name:名字
[data]
A1:123
B1:234
C1:=SUM(A1,B1)
A2:Hello World
[value]
B1:357
[style]
...

在数据段保留有公式信息,把公式计算值放在 value 段。然后把版面信息全部放去 style 段。这样,即使 value 和 style 段全破坏,也可以直接用 excel 打开修复整张表格。有了这种格式,程序员会更愿意用 vi 打开文件直接编辑,他们绝对不想再安装 excel 的(尤其是对 linux 程序员)。找到想修改的单元格比 csv 文件更容易,也可以用最自然的 \ 转义。和 excel 原始格式不同,我们不把 string 保存在表格数据之外的地方(excel 文件会生成一个叫 sharedstrings 的 xml 文件保存所有的字符串,在表格数据里只做 index 引用)。且把更容易引起冲突的公式计算值隔离开(这样,不会因为有人分别修改了 A1 和 B1 而导致 C1 的计算值在两个版本中都不正确)。


当我大致实现完后,我发现我严重低估了 excel 的板式描述的复杂性。要完全复原 excel 里看到的所有版式信息,需要记录和分析很多的数据才能保持一致。全部实现完这样所需要的时间远远超过我的预期,所以我不想在这上面浪费时间了。

当然,做这件事情让我好好了解了一下 excel 文档的结构(大致浏览了 ISO-IEC-29500-1)。这里,我不想把半成品的工具开源。但可以提供一个副产品:我写了一个小程序,可以把 excel 转换为文本。这个工具可以帮助我们在命令行 grep excel 里的数据。需要这个工具的同学可以去我的 github 页面看 xlsx2txt 这个项目


另一些值得记一下的东西:

上周我考察了许多 xlsx 的读写库,有 go 版本的,python 版本的,lua 版本的。

lua 版本我找到一个叫 xlsxwriter 的东西。简单的阅读发现,性能上有许多提高的空间。学会一门语言,不在于学会它的语法,而在于熟悉这种语言下解决问题的各种惯用法。比如 lua 的字符串处理简单而强大,你需要熟悉它。

function Xmlwriter._escape_attributes(attribute)    
    attribute = string.gsub(attribute, '&', '&') 
    attribute = string.gsub(attribute, '"', '"')    
    attribute = string.gsub(attribute, '<', '&lt;')  
    attribute = string.gsub(attribute, '>', '&gt;')  
    return attribute        
end

这个函数有问题吗?

这样写会更好一些:

local escape_attrib_tbl = {
    ['&'] = '&amp;',
    ['"'] = '&quot;',
    ['<'] = '&lt;',
    ['>'] = '&gt;',
}

function Xmlwriter._escape_attributes(attribute)    
    attribute = string.gsub(attribute, '[&"<>]', escape_attrib_tbl)
    return attribute
end

如果你想知道能好多少,可以跑一下测试

November 13, 2014

skynet 的 UDP 支持

考虑了很久, 最终还是给 skynet 加上了基本的 UDP 支持. 虽然大多数情况下, 我不赞成使用 UDP 协议. 尤其是在网络游戏领域. 但考虑到 skynet 已经不仅仅应用于游戏领域, 我想, 加入有限的 UDP 支持是有意义的.

btw, 根据最近的反馈看,有人把 skynet 用于交换机(由于使用的是 powerpc 的 CPU ,帮助解决了一些大小端 bug );有应用于证券领域;还有做视频广播的。另外,把 skynet 用于 web 开发的应该也有人在,就简单的测试来看,性能方面不比把 lua 集成到 nginx 差。

目前,UDP 这部分代码已经完成,放在 github 里一个叫 udp 的分支上,不久以后会合并到主干上。由于我自己没有什么这方面的需求,所以还需要有 udp 需求的同学读一下代码,实际使用,这样才可能发现潜在的问题。

关于这部分的设计以及 api 文档,我补充在 wiki 上了


最近对网络消息分发做了点小优化,方法值得记录一下:

当有若干消息需要处理时,简单的方案是把消息放到一个队列里,然后依次从队列取出来处理。

但这么做有一个小问题:单条的消息处理过程可能被阻塞挂起,如果等待一条消息处理完再处理下一条就会因为上一条消息的阻塞而延缓之后的消息处理。

为了避免这种情况,我们可以用 skynet.fork 启动多个 coroutine 来分别处理队列消息。但这样做会增加许多 coroutine 。如果消息处理流程并不会阻塞,这种方法又有点浪费(本可以用一个 coroutine 搞定)。

最好的方法是:一旦 coroutine 挂起,就立刻开启新的 coroutine 继续处理队列中的消息;如果没有挂起而是处理完了,就接着处理下一条。但是,这样就需要修改调度器了。

我想到一个折中的方案:

每次判断队列是否为空,如果不为空,就调用 skynet.fork 出自己这个函数处理这个消息队列。并接下来马上循环处理消息队列中的消息。

这样,如果消息处理中不会挂起的话,fork 出来的 coroutine 会发现队列已经为空立刻退出;如果消息处理挂起,那么 fork 的 coroutine 会接着处理没处理的消息。由于消息队列只有一个,同一条消息不会被 pop 两次,所以整个流程是正确的(且保证了消息处理开始时点的次序)。

创建的 coroutine 的个数也仅比上面提到的理想方案多一个而已。


最后, 计划在今年内组织一次 skynet 的线下交流活动。地点在广州,时间未定。有兴趣的同学可以去邮件列表报名。

November 04, 2014

RLA 文件中的法线信息提取

最近想在游戏中加一点简单的环境光,因为游戏中使用的都是 2d 图片,那么最廉价的方法应该是给图片加上法线图了。

好在我们游戏的原始图素都是用 3d 建模,然后再用平面修整的。基本几何结构信息可以从模型提取出来。当然,我们并不真的需要自己写程序去从模型中计算出最终渲染图的法线信息。所有渲染软件都可以输出它。

比如 3ds max ,如果你把渲染结果输出成 .rla 文件,那么就可以勾选 normal zbuffer 等额外的通道输出。

记得我读大学时写过一个 rla 文件解析程序,当时是为了提取里面的 Z 通道。这都过了十多年了,果然完全找不回当年写的代码了,也忘记曾经怎么实现的,所以就从头来搞。

动手之前先查阅了 imagemagick 的文档,发现它虽然能提取 rla 文件,但仅限于不同的颜色及 alpha 通道。核对了源代码 确认了这一点。google 也没有找到有人开源过相关库,那么只好自己写了。

RLA 是 wavefront 的 3d 图片文件格式。网上能找到格式说明

因为有固定长度的 C 结构头,解析出文件头还是很容易的。可惜不知道为什么,3ds max 导出的 rla 文件头中,即使导出了多个附加通道,NumOfAuxChannels 项还是 0 。

按 offset table 提取出 scanlines 后,试着解了一下 RGB 以及 alpha 通道都是正确的。暂时不理会文件头,每个 scanline 后都附有其它 channel (如果导出了的话)。暂时不知道如何识别每个通道对应着什么,不过如果你只导出 normal 通道的话,那么多出来的通道当然就是法线图了。

btw, 每个 scanline 节的最后,都有一些莫名其妙的数据,也不知道如何识别单个 scanline 结束标记。不过这些不影响我们做下面的工作。

做一个简单的测试会发现,如果在 max 里导出 Z 通道,那么在 rla 文件里会多 4 个通道出来;如果导出 normal 通道,会再多出 4 个通道。

按 rla 的格式说明,4 个通道的值可以合成一个 float 也可以合成一个 32bit 整数。 Z 通道看起来是一个 float ,而 normal 通道则比较像整数。(从导出图像上看,不像是 4 张独立的 8bit 通道图)

用 google 没有找到太多信息,搜到一些相关资料是来源于 3ds max 的 sdk 头文件。里面提到一个叫 DeCompressNormal 的函数。输入一个 uint32 ,输出三个 float 。可惜没有实现的源代码。

我反汇编了包含 DeCompressNormal 实现的动态库 geom.dll :

.text:67678890 ; class Point3 __cdecl DeCompressNormal(unsigned long)
.text:67678890                 public ?DeCompressNormal@@YA?AVPoint3@@K@Z
.text:67678890 ?DeCompressNormal@@YA?AVPoint3@@K@Z proc near
.text:67678890
.text:67678890 var_C           = dword ptr -0Ch
.text:67678890 arg_0           = dword ptr  4
.text:67678890 arg_4           = dword ptr  8
.text:67678890
.text:67678890                 sub     esp, 14h
.text:67678893                 mov     eax, [esp+14h+arg_0]
.text:67678897                 mov     edx, [esp+14h+arg_4]
.text:6767889B                 fld     ds:flt_676877EC   ; -1.0
.text:676788A1                 mov     ecx, edx
.text:676788A3                 and     ecx, 3FFh
.text:676788A9                 fld     ds:flt_676877E8  ; 1.9569471e-3
.text:676788AF                 mov     [esp+14h+var_C], ecx
.text:676788B3                 fild    [esp+14h+var_C]
.text:676788B7                 fmul    st, st(1)
.text:676788B9                 fadd    st, st(2)
.text:676788BB                 mov     ecx, edx
.text:676788BD                 fstp    dword ptr [eax+8]
.text:676788C0                 shr     ecx, 0Ah
.text:676788C3                 and     ecx, 3FFh
.text:676788C9                 mov     [esp+14h+var_C], ecx
.text:676788CD                 fild    [esp+14h+var_C]
.text:676788D1                 fmul    st, st(1)
.text:676788D3                 fadd    st, st(2)
.text:676788D5                 fstp    dword ptr [eax+4]
.text:676788D8                 shr     edx, 14h
.text:676788DB                 and     edx, 3FFh
.text:676788E1                 mov     [esp+14h+var_C], edx
.text:676788E5                 fild    [esp+14h+var_C]
.text:676788E9                 fmulp   st(1), st
.text:676788EB                 faddp   st(1), st
.text:676788ED                 fstp    dword ptr [eax]
.text:676788EF                 add     esp, 14h
.text:676788F2                 retn
.text:676788F2 ?DeCompressNormal@@YA?AVPoint3@@K@Z endp

稍有汇编基础就可以很容易看出,这个函数其实就是把一个 32bit 整数按 10bit 一段分成三段,每段是一个 0 到 1023 的整数,然后把这三个整数调整为 -1 到 1 之间的浮点数。

ps. 这里的常量 1.9569471e-3 是 1/511 。


最终,我把美术给我的 rla 文件。提取出一张色彩图:

color.png

和一张法线图:

normal.png

顺手用 ejoy2d 写了一个简单的例子(定义一个 shader 传入鼠标位置做为光源方向,算出和法线图上的法向量的夹角作为光强,叠加到最终的屏幕上)。效果还不错。

light.gif