管窥蠡测从思考游戏到实现 2048
大家好,我是Mark24,可以叫我Mark
前言
本文比较啰嗦,更倾向于是自言自语。不过我写完回顾,这更像是这段时间,自由思考的总结 :P
不过我不是游戏领域的人,这部分都是业余摸鱼思考的记录,如果有勘误,请与我联系,非常乐意交流。
文章可能需要30分钟。
主要涉及的主题:
- 游戏之难
- 游戏基本构成
- 游戏引擎
- 游戏与交互程序
- 框架和库思考
- 语言是否是游戏的瓶颈
- 双缓冲模式
- 线程和协程的讨论
- 线程队列&中断
使用Ruby实现demo。
rb2048
项目安装: gem install rb2048
进入游戏
帮助信息: rb2048 --help
Usage: rb2048 [options]
--version verison
--size SIZE Size of board: 4-10
--level LEVEL Hard Level 2-5
开始游戏 rb2048
-- Ruby 2048 --
-------------------------------------
| 16 | 16 | 2 | 16 |
-------------------------------------
| 0 | 0 | 0 | 0 |
-------------------------------------
| 0 | 0 | 0 | 2 |
-------------------------------------
| 0 | 0 | 0 | 0 |
-------------------------------------
Score: 16 You:UP
Control: W(↑) A(←) S(↓) D(→) Q(quit) R(Restart)
升级难度 rb2048 --size=10 --level=5
-- Ruby 2048 --
-----------------------------------------------------------------------
| 8 | 16 | 0 | 0 | 0 | 0 | 0 | 2 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 16 | 0 | 16 | 0 | 8 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 16 | 8 |
-----------------------------------------------------------------------
| 0 | 16 | 0 | 8 | 0 | 0 | 0 | 0 | 0 | 2 |
-----------------------------------------------------------------------
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 8 | 8 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 8 | 0 | 0 | 0 | 0 | 4 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 0 | 0 | 4 | 0 | 0 | 0 | 0 | 0 | 0 |
-----------------------------------------------------------------------
| 0 | 4 | 0 | 0 | 4 | 8 | 0 | 0 | 0 | 16 |
-----------------------------------------------------------------------
Score: 0
Control: W(↑) A(←) S(↓) D(→) Q(quit) R(Restart)
背景
我觉得命令行的程序比较赛博朋克,一直想做个命令行的交互程序。 目前在游戏公司,虽然我不是游戏工程师,但是接触了一些游戏行业的优秀小伙伴,我也忍不住思考关于游戏的主题。
我想做的命令行交互式程序,其实和游戏的思想内核是一致的。一拍即合。
我以前做过一点点研究。记录了一些笔记。关于Ruby中如何实现交互式命令行程序。 本文也是建立在这个基础之上。
用最简单的方式实现了一个 [贪吃蛇]
rb2048心路历程
rb2048 亮点
rb2048有趣的地方在于,在设计的时候,没有简单实现了之。毕竟有太多2048了,不差这一个。
对于我不是完成一个任务。由于最近两天关注于线程的使用,于是我把线程方面的使用加入到rb2048。这算是一个实验性的例子。验证我的想法:
rb2048将:
- 用户I/O
- 游戏数据计算
- 游戏渲染
这三部分分别用单独的线程实现,用队列通信。麻雀虽小,五脏俱全。虽然粗糙,但是代表了游戏引擎典型的设计思路。 (虽然我了解的不多)
认知变化
简单说说我最近的思考吧:
1)对于计算机不同领域认识发生了变化
以前会觉得:游戏是游戏,web是web,语言是语言,元编程就是元编程……也许还有很多概念,但是渐渐现在觉得无非是一件事 —— 编程罢了。
随着看到思考的东西逐渐变多,很多计算机领域的问题,在我的角度觉得都一样。
2)第一性原理 + 交流,向内习得
这次摸着石头过河,比较新奇的体验就是,从当初一个想法到原理的讨论到最后实现。主要是思考推理,还有和优秀的同事的聊天中习得 (这里感谢 @谷神)。
- 刻意学习 VS 内在习得
现实中有很多游戏引擎。他们也许内有乾坤,不过其实是否研究他们也不重要。
我也不在乎别人的实现,或者更好地实现,是否有实现过了可以参考。其实没什么可参考的。只要我们自己想明白了,别忘了我们上面说的,他们都是一件事 —— 编程罢了。 当我们面临新问题,我们也会加强我们的 “引擎”。从思想上,他们是平等的。:P
可能与以前向外求知,现在会额外的向内思考。比较神奇的体验是,一些东西听个大概,也能盲猜个七八分。
从游戏开始聊吧
游戏之难
其实2048没啥好聊,写2048的背后是对游戏的一些思考。
其实游戏是一个比较特别的存在。他是一种比较特殊的程序,特殊在哪儿呢?
1)他是持续交互程序
不同于简单的脚本,跑完结束。或者传递一个初始参数,就像函数一样运行完结束。
他是一个持续交互的过程,随着时间累计游戏的方方面面都在变化。
2)多面平衡
不同于你写一段function就结束了。游戏要在运行的生命周期里:
- 用户交互事件
- 游戏数据计算
- 渲染视图
在至少这三个方面互相作用。
还可能有:
- 网络
- 调度
- 硬件CPU、GPU加速渲染
- AI
- 资源生成
- 数据采集
- 各种优化技术
其他周边并不展开
3)稳定的帧率
如果是60HZ的游戏,必须在 16.6ms 内完成动作进行刷新。
这也不是普通业务脚本、程序一直跑自己的线性逻辑就算了,根本不关心时间。
4)密集对象计算
简单的游戏还好,传统的模式是面向对象建模,一切看起来还算自然。
但是也出现了万人同台的游戏,这里传统的编程模式已经满足不了游戏对象的遍历了,很快会达到性能瓶颈。
这几年,出现了ECS架构(Entity-Component-System)。
小结:
其实还有各种发散。如何使用CPU、GPU加速渲染,这就不再提了。
游戏是一个非常特殊的存在,它意味着密集型计算、密集型IO混合出现的场景。我理解是比Web复杂在另一个维度上。
游戏涉及到 编程架构、网络、图形学、美术设计、资源加载…… 诸多丰富的话题。
这些就不是我这个门外汉靠管窥蠡测能够说得清的。我今天可以只谈谈我对游戏的理解和认识,以及构建2048的思考。
游戏基本构成
其实一个基本游戏可以用如下代码描述:
loop do
IOEvent
UpdateGameData
Render
end
游戏处在一个主循环中,我们依次要处理用户输入事件,根据用户输入事件进行游戏模型的变化,最后再把数据渲染在屏幕上。
这是一个单线程,主循环的例子。
现实中每个部分都可以额外变得复杂。也可以用线程单独实现。一切看需求。
游戏与交互应用程序
你会发现游戏就是交互程序。
上面的三部分,你也可以和 MVC 强行扯在一起。
- M 就是 Model 游戏数据
- V 就是 View 负责渲染视图
- C 就是 Controler 可以对应事件控制
MVC 的典型程序,除了桌面软件,Web也算是, App也算。
看似是在说游戏,实际上他们是一回事。
游戏引擎的秘密
游戏引擎其实就是框架,很佩服他们会起名字。
框架、引擎其实是一个东西,他们的特征就是一个半成品的软件。
loop do
IOEvent
UpdateGameData
Render
end
比如这个游戏循环,如果我们封装了主循环,封装了事件对象。对外暴露了一些生命周期。 这种半成品软件就是 所谓的框架,在游戏领域就是引擎。
作为下游,游戏引擎/框架的使用者来说,我们写的程序就像填空一样和主循环工作在一起。
主循环决定了什么是框架、什么是库
所以我个人觉得,决定了什么是 框架Framework 和 库Library 的本质区别是 —— 主循环。
当你的程序是一种可被调用的状态,那么基本上你的程序可以看成一个lib 当你的程序如果拥有了主循环的状态,基本宣告了不可被直接调用。那么它其实是一个 Framework了。除了各种Pattern很少见到主循环的lib 展示,不存在的原因是因为拥有主循环的程序,一般以具体的软件形态出来:
- 某种语言,比如 自带调度的 golang、自带EventLoop的JavaScript 引擎V8
- 某种框架,比如 Web框架自带监听循环
- 某种引擎,比如 游戏引擎
Framework式的程序,你的工作任务就会转向熟悉这个程序暴露的对象,期待你的程序和主循环能一起工作。
编程语言会是游戏的瓶颈么?
我们再来聊聊游戏引擎和编程语言。
Unity的背后是 C# 支撑;虚幻引擎的背后是 C++。他们采用了更底层的语言。那么问题来了,编程语言会成为制约游戏的瓶颈么?
这也是我自己思考的一个问题。
我们可能会很粗暴地觉得 动态语言普遍慢,当然是越接近底层越好。其实我更想知道,如此这样选择的标准在哪儿?
其实我们可以思考下,这个结论不难获得。
动态语言真的慢么?
其实动态语言在执行一个命令的时候,Ruby这种最后C实现;Golang最后也落在C(Golang实现自举之后,那就用汇编思考吧)。其实他们在执行一个具体操作的时候,数量级一致的。
他们其实差不多。
速度差距在哪儿呢?
1)载入环境
C、Golang这种可以打包成二进制的语言。他编译阶段会把需要执行的代码编译成二进制。
所以执行的时候载入的是所需要用到的部分功能。
Python、Ruby 这种其实 二进制是语言的解释器。运行的时候更多的时间花费在加载解释器。
不过,当你的程序复杂到涉及大量IO、基础库的时候,Golang的打包结果会趋向于接近一个解释器的大小,比如 Ruby 差不多在 30M左右。
我曾经比较过:
Golang的一个项目命令行编辑器 micro 、Ruby的一个项目命令行编辑器 diakonos
micro运行内存16M,也就是他本地大小;diakonos运行内存30M,也就是Ruby解释器差不多的大小。ruby代码会执行才加载,所以可以忽略不计。
最大的差距,在于 30-16 的载入速度差,这个量级是不同的。
2)语言构件
C语言就像是一个高级一点的汇编。C的角度一切都需要手动管理。那么其实对于底层语言,更现实一点的是会自己手动实现数据结构。
Ruby这种动态语言,内部默认会有一个数据结构。
举个例子:
比如 a = "GAME"
C语言实际上只会手动创建 "GAME" 四个字符
Python 底层可能创建一个 20字符长度的数组。存GAME。也有好处,可以不定长支持动态扩容。
在生成语言构建的时候存在速度差。 动态语言等于多创建了很多语言在内存里的解构。
3)解析时间
二进制的文件,直接载入内存执行。
动态语言有一个解析的过程。当然,也有优化空间,我们可以提前编译动态语言为虚拟机字节码。这样就获得了 对于解释器是二进制类似的东西。
4)GC时间
和C语言相比,Python、Ruby自带GC。
他们存在一个 必须 GC 暂停的那么一个问题。C语言的策略是手动回收。
双缓冲模式
我们好像列举了一大堆 动态语言的缺点似的。实际上自动管理的数据结构、自带GC、可以动态的编译执行…… 这些都是动态语言的缺点。
虽然付出了些许时间的代价。只要我们不滥用语言构件 和 特别烂的算法,真是巧妙的接近底层高效的实现。
其实我想说,动态语言至少在目标上不是特别大的瓶颈。
Java也有游戏的例子;C# 也是自带GC。GC不会是瓶颈。
语言的速度不会绝对意义上成为一个游戏组成的阻碍。
EVE 这样的大型游戏,内部使用了 巨慢的 Python 就可以说明问题。
之所以语言不一定构成拖慢游戏的原因,还有一个就是游戏和屏幕的刷新机制 —— 双缓冲模式。
其实可以理解为一个 内存空间,我们称之为 Buffer。我们有两个 Buffer,分别叫 A Buffer、B Buffer。
显示器先从A Buffer中读取数据渲染屏幕。我们程序写入B Buffer,等我们真的写完了,可慢或者快,但是无所谓,反正屏幕这时候在稳定的读取 A Buffer 内容。我们计算完毕,B Buffer中写入了我们想要的东西,这时候只要把显示器读取的指针指向B Buffer,下次屏幕就会获得我们想要的画面。这就是双缓冲模式。由于存在双缓冲解构,算快和快慢,至少不会成为画面撕裂的原因。
rb2048 使用了 Curses 库来绘制界面,而 Curses 内部使用了双缓冲模式。
线程和协程的讨论
我们自己研究了两天线程和队列。主要是Ruby的实现。
这里不教线程和协程,只记录我觉得好玩的交流结果。
Ruby线程的问题
缺点:
Ruby存在线程锁,这导致每一时刻只能运行一个线程。线程就像背后虽然有很多工人,但是只能交替的一人一锤子。
这背后的原因在于 Ruby 考虑安全更多一点 —— 线程安全。
这样的多线程无法利用CPU多核心并行的特点。希望利用多核的,可以去用 JRuby,因为Java底层没有加锁。
Ruby3中也有了无锁线程的替代品 Ractor 也可以了解下。
CRuby如果想利用多核心可以使用进程替代线程。如果设计得当,其实差不多。Ruby里面Webserver有名气的Puma采用的就是多进程实现。
优点:
加上锁最大好处是线程安全,你可以自由的编码,Ruby帮你加锁。这样多线程访问变量的时候,不会出错。
但是你退出来想,反正你自己也要加锁啊,谁加不是加。Ruby默认的线程其实书写起来非常友好。
进程、线程、协程 傻傻分不清楚
我觉得再这样介绍这三个概念,这文章太冗长了。
直接说结论吧,直观上,这三者存在量级差,不仅体现在空间资源,时间资源都差不多。
进程 >> 线程 >> 协程
比如一台机器4G内存:
可能只能实际生成几百个进程就不太行了。 同样,可以生成几千个线程,就动不了了。 协程可以生成几十万个。
他们大概就是这个差距(有更好数据支持的,请联系我)。
他们切换上下文的时间也遵循这个比较关系。
所以我们一般的策略,尽量多用协程&线程,少用进程。
如果任务独立运行还好,就怕彼此还要通信,出现互相等待的局面。
线程具有CPU亲和性(一般语言来讲)。
比如 Golang的 M:N 模型,主张 先生成 M 个线程,M 是机器CPU核心数,然后再在M个线程之间调度实际产生的N个任务。
比如 Nginx 的配置也主张 配置线程核心数和CPU核心数一致。
什么时候用线程、什么时候用协程?
线程、协程产生的原因是什么?
其实还是为了调度。
线程是细分进程下共享内存的场景;协程是为了细化调度。
因为进程、线程本质上是操作系统在调度。操作系统并不清楚什么时候应该调度。只能采用各种优先计算法、平均算法。再怎么算,也是盲人摸象罢了。
协程给了程序员一个口子,你可以用 协程在 涉及阻塞部分进行让出控制权。
简而言之,经验之谈:
涉及到 计算密集型 请用线程。
如果涉及到IO阻塞密集,请用协程。
我们的目的不是为了用而用,而是使用调度,提高我们代码执行的效率,减少等待。
硬件中断
如果说其实没有 if-else\switch\while,计算机器其实只有 goto。
如果你看过汇编,大概理解我是什么意思。
同样,计算机里进程、线程、协程背后调度的秘密,都来自于 CPU的硬件中断功能。
只不过是上下文快速切换,切换上下文多和少罢了。
2048 的实现
其实2048的关键就是相邻元素合并,实现这么一个算法,反复执行到无元素可以继续合并。再把这个应用到 x\y 方向所有行列就好了。
具体线程
目前实现成通过队列来实现通信:
IO线程,用户产生一个输入,进入事件队列。 游戏读取事件队列,开始计算游戏数据,把结果塞入渲染队列。 渲染线程,读取渲染队列数据进行渲染。
后续讨论
我和同事交流了一下,就2048而言其实可以很多方式做:
- 如果是队列依赖式
我们等于做出一个pipline的方式了
- 我们也可以解开队列阻塞
真正的自由渲染。虽然2048看不出效果
队列追赶问题
用户不断地敲击,产生时间,如果队列里一致产生数据,那不是渲染永远追不上?
多线程队列需要思考 生产者、消费者模型,需要设计匹配的方式。
解决方法
1)控制生产频率,生产和消耗相抵消
事件采样、渲染 可以保持一个频率
2)不控制生产,但是跳过生产
事件采样,可以携带时间戳。
如果渲染的时候,每次时间超时,跳过关键帧。
当然这些都是很细化的问题了。
总结
我倾向于研究一个东西,思考他的全部,寻找最佳的路径。 这些都是摸鱼结果,简单分享下。更深的感受还需要实践和交流。
后续
上文提到游戏里面最新流行 ECS 架构。ECS 抛弃了面向对象的思想,把同类数据摆放在一起,亲和CPU运行机制,方便大规模属性遍历。
ECS 应该如何用Ruby实现呢?