我是一个很懒的bloger,总想着有时间写blog,还不如多写几行代码。但仔细想想,代码是永远写不完的,而blog却不想就此荒废。所以在敷衍公司的年终总结之前,还是坐下来真正地总结一下自己在过去一年中的得失吧。
Fancystar开动
从上海回来后的空档期,研究了很多关于多线程方面的内容,再加上之前Gamebryo的经验,结果是脑袋里各种新的想法开始涌动。于是想要对老SDK进行改造。老SDK大部分是对老余GameBox的模仿,传统的单线程客户端,渲染也只运用到OpenGL 1.x的水平。最终我决定重写这3W多行的客户端代码,采用新的渲染架构,并且可以运用更多的CPU。这就是Fancystar。
多线程和Lock-Free
这是一个严重杀伤脑细胞的领域,在跌跌撞撞摸爬打滚了几个月之后,才终于悟到了一点门道。本来也酝酿过几篇文章想要总结一下这方面的心得,由于各种原因却一直未有成文。
Fancystar的第一个尝试,就是采用多线程架构,以利用更多的CPU核心。这通常有两种选择(其实还有第3种,即1和2的混合):
- 将子系统拆分到不同的线程中并行执行
- 采用任务(task)/ 调度器(scheduler)/ 工作线程(worker)模型
开始的时候Fancystar选择了1,试图将输入,逻辑,渲染,物理等各个子系统拆分到不同的线程中并行执行。这会遭遇几个问题:
首先每个object的数据同样要拆分为N个部分,分别由不同的子系统来管理,相当于每个object在不同的子系统中都有proxy(称为代理,或分身)。这些proxy相互之间独立性越强越好,但无论怎样设计,通信总是无法避免的。通信方式要么是传引用,要么是值拷贝。传引用的话,是类似于流水线的方式:proxy1更新数据—>通知proxy2数据更新完毕—>proxy2获得指针并执行自己的操作。在proxy1通知proxy2之后,proxy1(在下一帧之前)就不再允许修改共享数据了,否则就要涉及到线程同步的问题。在实际游戏逻辑中,这条规则是难以维护的。于是很自然就会想到用值拷贝。类似于double buffer (甚至triple buffer),共享数据同时存在多份拷贝,每个proxy独占一份,只在关键点同步一次。这种方法内存占用几乎翻倍,但更安全更容易维护。尽管如此,由于总想着尽量减少同步的次数和数据量,自然就会引入复杂的状态系统,以跟踪每份共享数据的变化。以上2种实现Fancystar都曾尝试使用过,但在写了一些简单的DEMO后就放弃了,因为设计出来的系统实在很难使用。
其次,这种按子系统进行拆分的方式是不利于扩展的。对于游戏来说,子系统的数量是有限的。即使在最优配置的状况下,也只能每个系统分配一个core,更多的core将会被浪费掉。此外,不同的子系统,其负载是极度不相称的。物理系统需要大量的运算,而输入和声音则想到休闲。所以最终整个系统会被最慢的那个子系统所拖累,而最慢的子系统却无法获得更多的运算时间(即使有),与此同时某些子系统所在的core却在idle。
所以最后Fancystar选择了2:整个系统还是在同一个线程中运行,但可以将运算封装成task,由scheduler进行调度,分配到不同的worker thread中去执行。core越多,worker thread就越多。采用这种方式,则主逻辑中无需考虑线程同步的问题,只要在几个热点处将运算分包出去并等待结果就可以了。Gamebryo就是采用这样的方法(虽然早已看过文档,但直到后来才意识到这种设计的优点)。在某篇介绍Capcom新引擎的论文中也提到了类似的技术(但更加彻底,复杂),并用在了 Lost Planet 和 Dead Rising 中。
尽管走了很多弯路,但总算具备了雏形。工作成果包括:线程工具库,提供atomic操作,hazard pointer,各种lock-free队列,lock-free对象池等;重新修改了object系统,提供线程安全的引用计数和回收机制,并全面采用component模式;实现 task / scheduler / worker机制;重写了异步资源管理系统;客户端框架在历经多次修改后也基本确定了下来。
OpenGL3和GLSL
对于本人来说,去年另外一个最重要的进步就是,我开始了GPU编程的旅程。从零开始设计渲染模块,在CPU和GPU间切换,写shader程序,这些都是全新的体验,感觉非常美妙。 Fancystar完全抛弃了老SDK的渲染代码,用OpenGL3 core profile 和 GLSL shader来重写。研究渲染技术,将会是今年的工作重点。


(左) 纪念第一个shader程式 (右) 用shader实现的tile map
游戏
去年主要参与了两个项目,一个是业余时间开发的bombman,以插件的形式将Fancystar跑在浏览器上面,游戏可以native client和web两种形式运行。该项目最后无疾而终了,业余团队在凝聚力上还是有很大的问题。另一个就是工作室的angel,是个很愉快的项目,我的主要工作集中在编辑器和AI上面,下个月就杀青了,敬请期待,呵呵。
读书
在Kindle上速读了一遍 OpenGL Super Bible 5th,整体上对OpenGL3和GLSL有了一定的了解。第5版完全抛弃了老旧的API,从core profile和shader开始阐述新的OpenGL架构,这应该是最好的OpenGL的入门书了吧。第二本读的是 Texturing & Modeling,讲程序纹理和建模技术的。在读了前面几章之后,决定系统地学习现代计算机图形学,于是转而去读 Real-Time Rendering。这是一本超棒的书,作者站在很高的位置来阐述问题,经常给我带来“哦,原来如此”的感觉。第4章之后的内容对我来说逐渐变得艰深起来,往往需要根据书中提供的reference阅读更多的扩展资料才能理解书中所阐述的问题。争取今年能把此书读完,并将其中的理论和技术应用到Fancystar中。
此外我还读了大量关于并发编程,内存模型,以及Lock-Free算法的论文,有时间的话会将这些资料整理出来跟大家分享。
最后
在2011岁末获得可爱LOLI一只,感谢上苍!

Posted in 其他, 游戏开发, 编程札记.
Tagged with others.
By 黑鸟
– 2012年01月8日
游戏是一个模拟虚拟世界的实时程序,它接受用户的输入,然后输出图像和声音:

输入
游戏程序的输入是一个有限的消息集合。实际运行中,每条消息都带有timestamp用以记录发送时间。两条消息相等不单内容相等,timestamp也必须相等。因此给定一组初始状态,同样的消息序列必定可以得到唯一确定的运算结果。
不同的游戏所接受的消息集合各不相同。例如:最基本的飞行射击游戏(《1945》),它接受的消息集合为 { 前,后,左,右,射击 };而只有一个按钮的UI界面,它接受的消息集合为{ 光标位置(x,y),点击 }。通常需要将各种输入设备的硬件信号映射到游戏所支持的输入集合:

最简单的做法就是直接把所有硬件消息作为游戏的输入集合。例如对于PC来说,就是鼠标移动+所有的鼠标/键盘按键;对于Console来说就是摇杆+按键。
输出
游戏程序的输出为图像和声音,通过专门的API向显示/声音设备发送指令,将逻辑世界以人类的认知方式描绘出来:

Game World
游戏逻辑的核心过程就是以一定的频率,对虚拟世界中的每一件物体执行运算,从而模拟世界的运转。每个运算周期就是一个逻辑帧,其中每个虚拟物体都会依次获得CPU时间来执行如下3个步骤的运算:
- 根据外部输入的消息改变自身的状态
- 根据虚拟世界的状况决策将要采取的行为(AI系统)
- 在虚拟世界的物理框架内执行特定的行为(物理系统)
所有的虚拟物体所要执行的运算都不外乎以上3个部分。RPG中的怪物就是包含所有3个步骤的最好的例子。有些物体只需要其中之一二,例如:由玩家操纵的勇者不需要Think,而像桌子这样的静态物体最多只会有Act。
模块拆分和并行化
从程序角度对主要任务进行描述之后,模块的拆分就变得显而易见了。
首先基本框架可以分为Input,GameWorld,Render和Sound四个部分。这四个部分的功能相对独立,并且以类似的模式运作。Input以特定的频率读取硬件输入并映射到游戏输入集合;GameWorld也是以特定的频率对整个世界进行运算;Render同样按照自己的频率从GameWorld采样瞬时数据并转化为多边形/像素输出到图形硬件(类似于照相机);Sound同理,只不过输出的是声音而已。理想情况下,只要通信条件满足,这四个部分可以任意部署到不同的Client上运行(这里,Client可以是一个线程,一个进程,甚至一台远端机器),而相互之间除了通信协议之外不需要知道任何东西。
其次对于游戏中的虚拟物体,采用Core+Proxy的方式来表示。Core是在GameWorld中足以保证其逻辑的完整表述,而Proxy则是在其他模块中维护专门数据的分身。例如在渲染模块中会存在一个对应的Render Proxy,其持有根据Core生成的多边形和纹理数据。Core和Proxy之间存在通信管道,更新后的数据会由Core广播给各个Proxy(或者由各个Proxy在需要的时候向Core查询?)。嗯,这其实就是经典的Model-View结构
最后值得思考的并行点在Think和Act这两个阶段。如果两个物体的Think(或Act)之间没有关联,是完全可以分配到不同的workflow中并行执行。不同类型物体之间的依赖关系可以通过静态分析得到,这可以作为执行过程中workflow分配的依据。
Posted in 游戏开发, 编程札记.
Tagged with architecture, game.
By 黑鸟
– 2011年09月8日
粗略分析了CEGUI的渲染流程,总结一下供日后参考。CEGUI版本是0.7.5,OpenGL渲染器。
首先在CEGUI里面每张图片,每个字符都是一个quad,每个quad由2个三角面组成,包括6个顶点的坐标,颜色,纹理坐标,是发送给GPU的最基础的渲染单元。要注意的是,CEGUI并不局限于quad,它可以构造任意多的三角面以生成各种形状。要绘制一个窗口需要很多顶点数据,例如一个简单的button,背景图像需要1个quad(根据背景类型不同可能会更多),有4个文字的话又需要4个quad,总共就有5个quad,30个顶点。所以每个Window对象都有一个GeometryBuffer对象用来缓存自己的顶点数据。
窗口绘制输出的目的地称为RenderingSurface,所以每个窗口都要从属于某个RenderingSurface,否则无法显示。在渲染的时候,CEGUI会遍历所有的窗口,并将该窗口的GeometryBuffer依次提交到该窗口所属surface的渲染队列中去。对于CEGUI来说,这个过程就是“窗口绘制”,同时会触发绘制消息,而真正的绘制操作其实是在这之后。当所有相关的GeometryBuffer都被push进队列,RenderingSurface::draw就会被调用,此时顶点数据才真正提交到Renderer进行渲染输出。Renderer输出的目的地称为RenderTarget,由具体实现而定,可能是Frame buffer,Off-screen buffer,或者Texture object。在当前OpenGLRenderer中,是用纹理对象来实现的。之所以要引入RenderTarget,是为了能缓存surface的输出。
RenderingSurface有两个派生类,一个是RenderingRoot,它是Renderer默认的surface类型,在当前CEGUI的实现中只是对RenderingSurface的简单封装,没有任何额外的功能;第二个是RenderingWindow,它的作用是将渲染队列中的内容绘制到一张纹理图像上面,然后再用该纹理来绘制自己的GeometryBuffer到其他surface上面去。CEGUI 0.7中新增的窗口旋转和各种窗口特效就是通过这种方式来实现的。
在CEGUI中,每个Window对象都有自己的GeometryBuffer,但并不是每个窗口都有自己的surface。对于普通的四平八稳的窗口,它们共享由Renderer创建的RenderingRoot对象;而只有使用了旋转或特效的窗口才会创建属于自己的RenderingWindow(不要忘记,这是一个surface派生类哦)。成员函数Window::getTargetRenderingSurface用于获取窗口对象的surface,从中可以看出surface的从属关系:
RenderingSurface& Window::getTargetRenderingSurface() const
{
if (d_surface)
// 优先使用自己的surface
return *d_surface;
else if (d_parent)
// 如果自己没有surface,则使用父窗口的
return d_parent->getTargetRenderingSurface();
else
// 最后如果自己已经是顶层窗口
// 则使用Renderer默认的surface
return System::getSingleton().getRenderer()->\
getDefaultRenderingRoot();
}
实际的渲染调用流程从System::RenderGUI开始,它首先清空顶层窗口的surface渲染队列,然后调用顶层窗口的Window::render函数:
void Window::render()
{
// 是否可见
if (!isVisible())
return;
// 获取render context,其中包含了当前窗口所从属的surface
RenderingContext ctx;
getRenderingContext(ctx);
// 如果是自己的surface则清空geometry buffer
if (ctx.owner == this)
ctx.surface->clearGeometry();
// 如果没有surface或者surface被标记为无效
if (!d_surface || d_surface->isInvalidated())
{
// 绘制自己(生成顶点数据并提交给surface)
drawSelf(ctx);
// 递归调用所有子窗口的render
const size_t child_count = getChildCount();
for (size_t i = 0; i < child_count; ++i)
d_drawList[i]->render();
}
// 如果是自己的surface则提交GeometryBuffer到GPU进行渲染输出
if (ctx.owner == this)
ctx.surface->draw();
}
在Window::drawSelf中:
void Window::drawSelf(const RenderingContext& ctx)
{
// 构造GeometryBuffer
bufferGeometry(ctx);
// 提交GeometryBuffer到surface
queueGeometry(ctx);
}
void Window::bufferGeometry(const RenderingContext&)
{
// 仅当需要的时候才重新构造顶点数据
if (d_needsRedraw)
{
// 清空GeometryBuffer
d_geometry->reset();
// 触发相关的CEGUI渲染消息
WindowEventArgs args(this);
onRenderingStarted(args);
// HACK: ensure our rendered string
// content is up to date
getRenderedString();
// 这里才是真正产生顶点数据的地方
if (d_windowRenderer)
// 如果有指定的WindowRenderer
// 则交给它来处理
d_windowRenderer->render();
else
// 否则调用虚函数让用户自己负责生成
populateGeometryBuffer();
// 触发渲染结束消息
args.handled = 0;
onRenderingEnded(args);
// mark ourselves as no longer needed a redraw.
d_needsRedraw = false;
}
}
值得注意的是,CEGUI会记录各种窗口状态,只有在需要的时候才会重新构建窗口对象的GeometryBuffer,同样只有在需要的时候,才会重新绘制RenderingSurface。
上面代码中还提到了WindowRenderer(注意区分RenderingWindow)。这个class的作用是根据looknfeel的描述来生成生成顶点数据。在CEGUIFalagardWRBase工程下面有一大堆Fal开头的class,例如FalButton,FalEditbox等等,就是专门干这些工作的。经过WindowRenderer的层层调用,最终会落到以下三个component上面:
- FrameComponent:生成边框和背景
- ImageryComponent:生成静态图像
- TextComponent:生成文字
这几个component都是由FalagardComponentBase继承而来,作用是根据各种不同的配置,例如背景的样式,是否是边框,文字的排版方式等等,生成顶点数据。
综上,CEGUI的控件逻辑,控件样式,渲染数据是完全分离的。渲染部分采用两级缓存。第一级缓存用于记录顶点数据(GeometryBuffer);第二级缓存将渲染结果保存在纹理上面(RenderingSurface)。
(完)
Posted in 游戏开发, 编程札记.
Tagged with CEGUI.
By 黑鸟
– 2011年07月16日
一直在写渲染相关的东西,前两天想放松一下,就用GAE搭了个照片日志。基本的设计思路是,每天用照片记录下生活的点滴,然后上传到网络供亲友间分享和日后回味。照片按拍摄日期进行管理和检索,并以日志的形式来显示。
相对于现有的各大门户提供的相册服务,大都是以照片集的方式来进行管理,貌似还没有原生支持日志式发布的产品。而且界面复杂,揉合了各种杂七杂八的社交功能(甚至广告),非常不喜(尤其以QQ空间为最)。所以我将页面简洁作为首要的设计原则。此外,GAE有1G的免费空间,足以媲美大部分的免费相册。借助GAE部署工具,照片更新上传速度非常迅速。而免费用户有1G的下行带宽,在我实际使用中,即使在开着VPN的情况下,访问速度也很令人满意。
但有一个最致命的缺陷,这也是我在部署到GAE服务器之后才发现的,那就是Google App被墙了(Fuck GFW 10,000遍啊!)。我原本是写了命令行发布工具的,结果部署之后死活连不上服务器,枉我还调试了半天。最后只好改为基于HTML的客户端,走浏览器以便使用各种翻墙工具。
具体效果请访问我的相册日志(请开启翻墙模式),而代码则托管在Google Code上。
工程下包括3个目录:
- Client:客户端照片更新工具和帐号管理工具,用Python3写的
- Server:部署到GAE的服务器部分,Python2.6
- Tools:照片处理工具(基于FreeImage,目前只有Windows版本)
第一次使用的时候需要到 client/config.py 和 server/config.py 下设置一些参数,包括本地照片目录,你的APP地址,照片规格等。然后运行 client/user.py,创建管理员帐号。这样初始化就完成了。每天更新照片的时候就执行 client/upadate_photos.py,它会自动搜索指定日期的照片并调整尺寸,然后复制到Server目录下。完成之后会弹出一个页面,你可以为每一张照片添加说明,并提交到服务器。最后就是运行GAE部署工具,将照片文件实际上传到GAE的服务器。
整个过程还是不够简单。如果对网络通信熟悉,应该可以再写一个脚本来自动化整个流程(原本我也是这样做的,但由于墙的存在…)。对于普通用户来说,可能还需要一个漂亮简洁的客户端。但作为我的玩具,暂时这样也就足够了
。当然,我是欢迎有兴趣的朋友加入进来继续完善的。
Posted in 互联网, 编程札记.
Tagged with GAE, Internet.
By 黑鸟
– 2011年04月23日
最近评论