Retep

Deploying the MCTS Program On Site via WASM


Refresh the page to load the board.
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?

两种思路:

  1. 把javascript用<script>标签在博客中引入,然后把脚本放在public文件夹下,作为static file。这样做的好处是用最原生的方式来opt out Nextjs,比较自由,所有改动都在静态文件中,不需要在模版的javascript中加很多奇怪的条件语句来适配特殊页面。
  2. 在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.onAPI中不传递index了,只传递data,只好把index保存为data的一部分。
  • 因为mcts的程序跑得比较慢,想实现一个在等待时间内降低透明度的feature,发现仍然需要手动重新渲染。好吧,累了。
  • 玩了一玩,虽然为了运行速度已经把MCTS算法的search space减到最小了,仍然被程序🤡LAME🤡了N次。

Epilogue

Reversi这个游戏,在大一的计算机导论(VG101)中的一个lab就写过。当时用的还是MinGW+Clion+AB-tree,还只能够搜索三步,还需要手动调棋盘的参数。没想到五年后的冬天又写了一遍。