Deploying the MCTS Program On Site via WASM
Info因为懒,所以这个Reversi的规则是只要一方无子可下就游戏结束。
考完期末考试,还有两天空闲时间才出去旅游。正好前一篇博客写了个MCTS跑Reversi的程序,打算一鼓作气做成一个可以玩的成品。技术选型是WASM和d3.js,个人感觉是非常契合这个场景的。顺便记录一下遇到的一些坑。简陋的设计如下,主要三个部分,用户前端部分包括d3.js实现的棋盘,收到用户输入后调用javascript写的胶水代码API,进一步调用更底层的cpp的API,完成一次交互。
编译到WASM
WASM(WebAssembly), 可以在浏览器虚拟机上跑的汇编指令集,完美契合我想在浏览器上运行cpp program的需求。而且在用户端跑程序,不需要写API打接口,没有网络延时,不需要负载均衡,很爽。主要工作就是写javascript和cpp的API。
cpp通过Emscripten编译到wasm模块,可以直接用js在浏览器里面运行。但这个玩意儿是个游戏,涉及到用户输入和程序的交互,所以设计应该是把wasm作为一个library,所以需要设计js和wasm的API。Emscripten里面有很多cpp和JS的交互的文档。
Javascript
关于javascript的API设计,作为一个棋盘前端,渲染所需要的只有两个:当前棋盘和下一步用户可以走的格子。其实也可以前端维护一个棋盘,cpp维护一个棋盘,只和cpp互相传递最纯粹nextMove
。但这意味着要把cpp的游戏逻辑那坨*山用javascript重写一遍,是万万做不得的。所以确定了javascript胶水代码的API是
runMcts(nextMove)
(拿到用户的nextMove,运行MCTS,返回新的棋盘)getActions
(获取当前棋盘下用户可以走哪些位置)。
CPP
关于cpp的浇水代码的API设计,比较复杂,和cpp的MCTS实现有关系。简而言之就是在cpp文件中保存了一个棋盘数组,一个更新路径数组(每次获得nextMove
就append),一个actions
数组(保存下一步可以走的格子),还有这三个数组的分别长度各为一个变量。最重要的是写API把这些内部变量的地址暴露给javascript,javascript才能读取它们的值。
一些坑包括:
EMSCRIPTEN_KEEPALIVE
保证没有被主程序调用的函数不会被emcc优化掉(或者在编译指令里显式导出)。extern "C"
保证不会产生name mangling。Module.setValue()
(见这里)的pointer是byte
单位,不会通过type
自动推算。- 可以直接在cpp文件中设置断点的plugin,但是要把视频中的
fdebug-compilation-dir
换成source-map-base
。 - 所有
ccall
等等API都需要在DOMContentLoaded
发生后才能正常运行。 -
不要用
run
作为函数名。🫠
调试的时候可以直接用emrun
起本地服务。编译完的wasm试着在浏览器里跑了一下,感觉还是比本地慢不少的。
D3.js写个交互页面
游戏必然需要用户输入。好游戏必然不可能让用户用cli打坐标交互,所以要画一个棋盘。由于想直接在博客上跑这个游戏,会遇到很多奇怪的问题。博客用的是next.js
,以SSR和SSG闻名。然而用模版简化了写常规博客的工作,必然会增加在博客上加一些奇怪东西的成本。
要在博客上画这个棋盘,构思是在博客的markdown内嵌入一个html节点,外部加载js往这个节点上append图形。因为之前用过d3.js
,恰好符合这个库的使用场景。然而事情并没有那么简单:
markdown内嵌html?
markdown只是html的又一抽象罢了,最终还是要编译回html的。我们要做的事情就是在编译过程中保留所写的html节点,使得在最终serve的html中仍然留有这个节点。好在别人已经帮我们实现过了:rehype-raw。
怎么运行javascript?
两种思路:
- 把javascript用
<script>
标签在博客中引入,然后把脚本放在public文件夹下,作为static file。这样做的好处是用最原生的方式来opt out Nextjs,比较自由,所有改动都在静态文件中,不需要在模版的javascript中加很多奇怪的条件语句来适配特殊页面。 - 在javascript中作为常规函数
import
。这样的好处是可以利用React的自洽的功能,比如生命周期hooks,不用去hack。缺点就是比较丑陋,需要良好的software engineering来弥补。
我开始尝试第一种方法,发现第一次load page的时候,棋盘不会渲染,一定要刷新才行。检查了一下发现我测试的时候用到了DOMContentLoaded
。在react中,不会全部重新刷新html,所以这个事件在浏览博客的时候,不管进入几个页面,都只会触发一次。然而棋盘渲染的逻辑一定要在DOM完全加载后才可以执行(不然root node都找不到)最后还是只能回到react的useEffect
。
如果用第二种方法,遇到的问题是wasm文件的serving问题…emcc生成的glue code会在本地加载wasm,但如果把glue code和别的javascript一起打包的话,运行的路径就变了,react application在运行时找不到wasm。如果要完全推倒用react wasm的框架来写,又不是很高兴🙃,所以就这样摆烂吧。
D3?
d3.js
是一个非常非常优雅的库。你只需要定义数据,就可以把轻松地把数据map到所有的svg图形。遇到的几个问题有:
- 用惯了react,以为数据绑定以后一定是会数据更新后就自动更新渲染。想多了,要手动清除svg节点再用新的数据重新渲染。
Selection.on
API中不传递index了,只传递data,只好把index保存为data的一部分。- 因为mcts的程序跑得比较慢,想实现一个在等待时间内降低透明度的feature,发现仍然需要手动重新渲染。好吧,累了。
- 玩了一玩,虽然为了运行速度已经把MCTS算法的search space减到最小了,仍然被程序🤡LAME🤡了N次。
Epilogue
Reversi这个游戏,在大一的计算机导论(VG101)中的一个lab就写过。当时用的还是MinGW+Clion+AB-tree,还只能够搜索三步,还需要手动调棋盘的参数。没想到五年后的冬天又写了一遍。