Chengyuan Ma's Blog

幽居默处而观万物之变,尽其自然之理,而断之于中

0%

背景

最近比较无聊,偶尔会和同学们一起在Minecraft的私服上玩一会,现在地狱交通已经初见雏形了,nether hub已经搭好了,然而地狱交通网络应该基于哪一种运输方式还没有确定。常见的快速运输方式有两种:piston bolt和冰船。

很显然按照每天半个小时的游戏时间是不可能有精力做双向的piston bolt的,况且论最高速度冰船比piston bolt还是快上许多的,因此几乎毫无疑议地大家都选择了冰船。

这篇文章是在代码角度对于高级冰船技术的分析。

全文的代码均为使用官方mapping以及CFR编译器进行反编译所得(但愿这并不违反EULA)。

带方向校准的高端冰船

冰船系统一直为人所诟病,也是其不如piston bolt的一点是:它在运动中是需要玩家控制的。不仅要一直按着W,还要使用AD确保船头一直向前。这在直道冰船上还好,但在斜线冰船道上就非常非常麻烦。

幸好,Rays Works在几个月前分享了解决方案。其方案的思想是:

如果一开始船就放正,那么就只需要W而不需要左右调整了!

那怎么把船放正呢?

观察一下代码(net/minecraft/world/item/BoatItem):

@Override
public InteractionResultHolder<ItemStack>use(Level level, Player player, InteractionHand interactionHand) {
    Object object;
    ItemStack itemStack = player.getItemInHand(interactionHand);
    HitResult hitResult = BoatItem.getPlayerPOVHitResult(level, player, ClipContext.Fluid.ANY);
    ...
    if (hitResult.getType() == HitResult.Type.BLOCK) {
        object = new Boat(level, hitResult.getLocation().x, hitResult.getLocation().y, hitResult.getLocation().z);
        ((Boat) object).setType(this.type);
        ((Boat) object).yRot = player.yRot; // !
        ...
        return InteractionResultHolder.success(itemStack);
    }
    return InteractionResultHolder.pass(itemStack);
}

yRot自然是Y-axis Rotation的意思,也就是说放下船的方向直接等于当时玩家的视角方向。

因此如果一开始视角方向, 那么问题就解决了!

如何确保每一次放船的时候视角方向都是一个固定值呢?

Rays Works的解决方案是:用另外一艘船!

net/minecraft/client/player/LocalPlayer当中:

@Override
public boolean startRiding(Entity entity, boolean bl) {
    if (!super.startRiding(entity, bl)) {
        return false;
    }
    ...
    if (entity instanceof Boat) { // !!!
        this.yRotO = entity.yRot;
        this.yRot = entity.yRot;
        this.setYHeadRot(entity.yRot);
    }
    return true;
}

可以看到,当玩家乘上船的一瞬间,玩家的视角方向会强制调整到船的方向。

因此我们只需要准备一艘校准船,确保这艘船的角度正确,之后要使用的时候直接先做上这艘船再立刻放船再坐上新放的船就可以了(简单来说就是三连右键)。

这就是Rays Works视频的主旨,我这里只是找到了对应的源代码而已。

放置角度与Minecraft的角度系统

Rays Works在视频当中宣称通过它的方法可以把冰道修到任何角度与朝向。

似乎没有什么不对?只要把校准船放到任意角度就行了。

然而在Ray的视频当中可以看到,有的时候明明船放下去是45°,不一会就转到了46.4°,Ray坦言这是这套冰船系统唯一比较麻烦的地方。Ray提出的解决方案是:

If that happens, just remove this boat and place in a new one. ……and do it a couple times it actually fixes itself.

并认为这是一个随机行为,具有一定意义上的周期性,等一会就好了。

事实并非如此。有一些角度是无论如何都放不到的,刚放下去一会就会微微一转,然后角度就变了。属实糟心,我在自己服务器里面放了20次都没有放成功。那个时候我终于意识到Ray的claim可能是在扯淡,于是去看了下代码。

代码里面似乎没有任何一处体现了“微微一转”这种行为。最上面BoatItem的代码就是直接把yRot设成了玩家的yRot。我用全文搜索对于yRot产生修改的片段,没有一处是这样的。yRot是用float存储的,应该不会产生舍入误差这种事情。

整个事情让我对于Minecraft的屎山代码不由生出一份敬畏,我开始觉得这是玄学问题,直到看见net/minecraft/network/protocol/game/ClientboundAddEntityPacket的构造函数……

public class ClientboundAddEntityPacket implements Packet<ClientGamePacketListener> {
    ...
    private int xRot;
    private int yRot;

    public ClientboundAddEntityPacket(int n, UUID uUID, double d, double d2, double d3, float f, float f2, EntityType<?> entityType, int n2, Vec3 vec3) {
        ...
        this.xRot = Mth.floor(f * 256.0f / 360.0f);
        this.yRot = Mth.floor(f2 * 256.0f / 360.0f);
        ...
    }
}

以及net/minecraft/client/multiplayer/PacketListener如何处理这个packet的:

 @Override
 public void handleAddEntity(ClientboundAddEntityPacket clientboundAddEntityPacket) {
     Entity entity;
     ...
     if (entity != null) {
         entity.xRot = (float)(clientboundAddEntityPacket.getxRot() * 360) / 256.0 f;
         entity.yRot = (float)(clientboundAddEntityPacket.getyRot() * 360) / 256.0 f;
         ...
     }
 }

哦……原来还真有舍入误差这回事……Mojang大概想省空间,虽然rotation在客户端和服务端内部都是float存储的,但是在网络传输的时候硬是塞到了byte里面。于是在这一层转换的帮助下,Minecraft理论上就只有256种可能的角度了。

我猜测的整个放船的流程大致如下:

  1. 客户端玩家手持船右键
  2. 客户端发出一个ServerboundUseItemPacket
  3. 服务端接收到ServerboundUseItemPacket,然后执行BoatItem有关的代码
  4. 服务端新增船的实体,想客户端发送ClientboundAddEntityPacket,这个时候角度被舍入了
  5. 客户端收到ClientboundAddEntityPacket,将本地的船同步为舍入的角度,在10gt的插值作用下体现为“微微一转”
  6. 玩家坐上去,这个时候一系列网络同步把服务端的船的角度也舍入了
sequenceDiagram
    participant Client
    participant Server
    Note left of Client: Player right clicks
    Client->>Server: ServerboundUseItemPacket
    Note right of Server: Execute code in <br/> BoatItem.use
    Server->>Client: ClientboundAddEntityPacket

因此,微微一转的问题其实根本就不是Ray说的随机事件,而是在网络传输当中舍入误差加上客户端线性插值带来的现象。看似的随机只是因为F3的精度不够,44.99和45.01都显示为45.0,虽然从代码当中我们可以清晰地看到两个数的舍入结果是不一样的。

这么做的好处是对于90°,45°等特殊角这一层舍入可以保证绝对的精确,避免了放成45.01°然后在船上开1000格偏离航道的惨剧。缺点是很多角度就不能精确达到了。两个相邻的可达角度差1.40625°,大概勉强可以接受。

话又说回来了,ServerboundMovePlayerPacket这种同步玩家信息倒是还是用float作为载体的。所以玩家的角度还是很精确的?难道是因为玩家相对于实体少很多所以有更多流量可以挥霍?

我再次对于Minecraft的代码肃然起敬。

辅助高级冰船的Scarpet脚本

在知道这些之后我们就可以有针对性地设计冰道了,因为人比较懒所以我写了一个scarpet的脚本:

__command() -> null;

_ice_mark(x, y, z) -> create_marker('ICE', l(x + 0.5, y + 0.5, z + 0.5));

_mark_x(y, x1, z1, x2, z2, w) -> (
    if(x1 > x2, l(x1, x2, z1, z2) = l(x2, x1, z2, z1));
    k = (z2 - z1) / (x2 - x1);
    for(range(x1, x2 + 1),
        x = _; z = round((x - x1) * k + z1);
        for(range(-w + 1, w), _ice_mark(x, y, z + _))
    )
);

_mark_z(y, x1, z1, x2, z2, w) -> (
    if(z1 > z2, l(x1, x2, z1, z2) = l(x2, x1, z2, z1));
    k = (x2 - x1) / (z2 - z1);
    for(range(z1, z2 + 1),
        z = _; x = round((z - z1) * k + x1);
        for(range(-w + 1, w), _ice_mark(x + _, y, z))
    )
);

mark(y, x1, z1, x2, z2, w) -> (
    dx = abs(x1 - x2); dz = abs(z1 - z2);
    if(dx == 0 || dx < dz, _mark_z(y, x1, z1, x2, z2, w),
        _mark_x(y, x1, z1, x2, z2, w))
);

clear() -> remove_all_markers();

_wrap_deg(x) -> (
    tmp = x % 360.0;
    if(tmp < -180, tmp + 360, tmp > 180, tmp - 360, tmp)
);

_wrap_internal(x) -> (
    tmp = x % 360.0;
    if (tmp < 0, tmp + 360, tmp)
);

_angle(x1, z1, x2, z2) -> -atan2(x2 - x1, z2 - z1);

_floor_angle(x) -> _wrap_deg(floor(x * 256 / 360.0) * 360 / 256);

angle(x1, z1, x2, z2) -> (
    theta = _angle(x1, z1, x2, z2);
    print(str('exact yaw: %f', theta));
    lb = floor_angle(_wrap_internal(theta));
    ub = floor_angle(_wrap_internal(theta + 360.0 / 256));
    print(str('floored yaw (lb): %f', lb));
    print(str('floored yaw (ub): %f', ub));
    ret = if(abs(lb - theta) < abs(ub - theta), lb, ub);
    print(str('suggested yaw: %f', ret));
);

_best_angle(x1, z1, x2, z2) -> (
    theta = _angle(x1, z1, x2, z2);
    lb = floor_angle(_wrap_internal(theta));
    ub = floor_angle(_wrap_internal(theta + 360.0 / 256));
    if(abs(lb - theta) < abs(ub - theta), lb, ub)
);

adjust(x1, z1, x2, z2, dir) -> (
    x = _best_angle(x1, z1, x2, z2);
    ov = 360; opt = 0;
    print(str('yaw: %f', x));
    if(
        dir == 'x',
        for(range(x2 - 1000, x2 + 1000), 
            tmp = abs(_angle(x1, z1, _, z2) - x);
            if (tmp < ov, ov = tmp; opt = _)
        );
        print(str('%f %f %f %f', x1, z1, opt, z2)),
        dir == 'z',
        for(range(z2 - 1000, z2 + 1000), 
            tmp = abs(_angle(x1, z1, x2, _) - x);
            if (tmp < ov, ov = tmp; opt = _)
        );
        print(str('%f %f %f %f', x1, z1, x2, opt)),
        print('direction must either be x or z')
    )
)

用法如下:

  1. /iceboat y x1 z1 x2 z2 w标记一条从(x_1,y,z_1)(x_2,y,z_2)的,宽度为2w-1的冰道。

  2. /iceboat clear清除标记。

  3. /iceboat angle x1 z1 x2 z2计算站在(x_1,z_1)望向(x_2,z_2)的视角方向,最近的两个可达角度,以及推荐的可达角度。

  4. /iceboat adjust x1 z1 x2 z2 x/z固定(x_1,z_1),沿x/z轴在(x_2,z_2)附近寻找最贴近可达角度的方块坐标。考虑到在几千格之后哪怕0.7°的角度差都会有导致最终百格的偏差,这个命令非常重要。在算法实现角度,用三分写是最快的,但是考虑到从-180°到180°有一个不连续间断点,导致写三分会涉及到一些繁琐的细节。我比较懒,所以就用了比较慢的写法(这个写法其实也有明显bug,但是我不准备修)。

常见的使用流程:

  1. 确定冰道的两点。
  2. 固定一点(通常是nether hub或者已经固定留好空间的端点),使用/iceboat adjust微调另一端点的位置。同时计算得出角度。
  3. 使用/iceboat mark标记放冰的位置。
  4. 放冰。
  5. 使用/iceboat clear清除标记。

目前比较不人性化的一点是carpet的create_marker用的是隐形盔甲架实现的,hitbox的制约导致有的时候放不了方块,用area effect cloud可能可以解决这个问题。

PanDownload前几天被封了。

网络上那个群情激奋啊,看B站连“百度的七宗罪”都出来了。还有人把GFW做的DNS污染,域名置换等和PanDownload做的“多线程下载百度云盘”相并列,不乏讥讽地质问为何遭到制裁的是看起来程度最轻的PanDownload。

我不是PanDownload的使用者,自己也很少用百度云盘,或许称不上是特别利益相关者吧。

但看到这个新闻的时候我是很矛盾的。

我讨厌百度吗?

肯定的,我从八年前开始用百度云盘,从不限速的年代一路用到限速的坑爹时代,不恨是不可能的。

我喜欢PanDownload吗?

喜欢的,我自己两三年以前还在用很多油猴的提取直连的脚本,这种绕开限速薅羊毛的软件我不要太喜闻乐见。

那在这次事件中我支持PanDownload吗?

我其实想保持中立。

我们对于PanDownload等一众软件的推崇源于对于百度免费用户限速的痛恶。由俭入奢易由奢入俭难,对于限速的痛恶则源于几年前限速不存在的事实。

但是仔细想想,免费的大云盘这件天上掉馅饼的事情合理吗?

参照如今硬盘的价格以及带宽的价格,我觉得百度留着云盘业务肯定不是做慈善的。在线存储是一种服务,是企业生产的一种产品,收费这件事情本身是无可厚非的。放眼国外市场,Dropbox,Google Drive等也没有做慈善的。不是空间小就是下载慢。

要说百度可恶,也就可恶给用户提供了“一开始的美好”,用2TB的存储空间诱惑用户,等用户粘性培养上来之后,在几年前再限速到一个坑爹的地步,这个时候你左右为难。付钱吧,跟几年前比感觉亏了,不付钱吧,自己很多数据还存着呢。反正百度这几年的潜台词是越来越明显了:不付钱别玩,让你免费用已经是施舍你了,还BB那么多干嘛。

这句话很让人恼火,非常让人恼火,但是仔细思考一下,似乎是对的。云盘是一个市场,在市场里哪有永远白嫖的份。这个时候最好的结局是期待着市场竞争逼着百度降价。但是我国云盘市场的竞争着实不够激烈,百度隐隐形成垄断之势,降价看来是遥遥无期了(话又说回来,一个月25块钱的价格换算成美元大概与Google Drive差不多,其实真没那么多好抱怨的)。

这个时候PanDownload以及一众多线程下载软件出现了。几十个线程一起下,让免费用户看到了曙光。

我们不妨把PanDownload看做是网盘市场的一个有力的竞争者(虽然竞争并非其初衷),把它的下载服务看做是一个全新的网盘产品,它提供的“网盘产品”的竞争性优势在于

  1. 和已有百度网盘资料的完全兼容
  2. 免费用户的高速下载

如果PanDownload的网盘产品真的是独立而全新的,那百度该死,谁也不能阻止PanDownload一统江湖。

但是事实不是这个样子的,PanDownload提供的这个如此有竞争性的“产品”,是基于百度网盘的生产资料与资本建立起来的。PanDownload运用的多线程下载,亦或是其他软件的直链提取,这些都不是百度网盘官方提供的API,也不是百度网盘官方认可的。

因此,全民的喜闻乐见与对于百度的痛恨,也改变不了PanDownload不正当竞争(视作竞争者)或者说侵犯百度权益(是做第三方软件)的事实。

单纯地因为PanDownload是在造福人类,是在薅垃圾公司的羊毛,就对这种行为的本质视而不见,其实是违背法治社会的初衷的。

这就仿佛所有人都认为张三是人渣,李四替天行道把张三打死了就可以不犯法一样。事实上,哪怕张三是在逃的杀人犯过街喊打喊打,如果不处于类似正当防卫的情形,李四把张三打死也是不对的。故意杀人就是故意杀人。何况百度作为一家公司目前来看还没有在云盘的运营中触犯法律(或许有垄断?不清楚)。前几天在B站上看罗翔老师说“法律要考虑民众的诉求,但是要超越民众的偏见”。

百度这叫体量大,如果是一家新创业的公司,被这么一集体白嫖,搞得破产了怎么办?这种事情不是没有发生过。难道法律里面还要加入一条“如果体量够大而且民众不爽,某某法规就失效”?

如果对百度不爽,就尽量避免去用它。现在搭建一个自己的网盘已经是很简单的一件事情了。

何况部分用户离不开百度网盘的原因大概是因为下”资源“,所谓”资源“,又大致可以分为免费(说的不好一点就是盗版)的电影与一些淫秽资源。后者我不想多说啥(其实吧,我觉得一些黄站之于内容提供者也是PanDownload之于百度网盘的存在),但是免费下盗版竟然还有理了?

没错,一直白嫖一直爽是中文互联网的常态,我自己也爱装破解版软件,看视频听音乐能不付钱就不付钱,但是理直气壮地说出来又是另一回事情了。百度给你下就不错了,PanDownload让你下爽了就下爽了,但是这种处于灰色地带的软件,要较真起来,我们真的不占理。

或许吧,网民一直隐隐有一种心态,在网络上白嫖是对的,反正就是一堆数据拷来拷去,百度云盘也就是一个网站而已,付费下载闻所未闻。这种心态在我看来是有问题的。垄断着实可恶,但是支付给服务提供者报酬的道理还是不错的。

因此,我觉得PanDownload虽然对于我们是一款好软件,但是最近取缔PanDownload这一事,我觉得还是无可厚非的。

自己最近又在用JVM系的语言开新坑。这次终于说服自己从Jetbrains的温床里脱离出来改用了大家都在用的Gradle!感觉良好,在这里简单写一下大概咋用,给之后的自己留一个参考吧。

为什么自己要用Gradle呢?

  1. Gradle作为一个开源的构建系统不与任何IDE绑定(比如说IDEA),这相当于增加了代码的兼容性。
  2. Gradle里面可以直接从Maven Central上下库下来,省了我手动下库导入的过程,库更新了也好维护。这对我来说非常地有诱惑力。实测也非常舒服。
  3. Gradle从设计上来说非常灵活,Gradle默认的构建脚本是用Groovy写的(当然也可以用Kotlin但是这似乎不是主流的样子),而不是只有标记功能的XML,这使Gradle一下子非常强大。
  4. 大家都在用的样子。
  5. IDEA对于Gradle的支持非常棒!(所以到头来自己还是没有逃脱JB的舒适圈……)

虽然说Gradle的功能很强大我目前的用法还是最初级的。我目前用的Gradle代码大致如下:

plugins {
    id 'java'
    id 'org.jetbrains.kotlin.jvm' version '1.3.61'
}

group 'chengyuan'
version '1.0-SNAPSHOT'

sourceCompatibility = 1.8
project.ext.lwjglVersion = "3.2.3"
project.ext.lwjglNatives = "natives-windows"

repositories {
    maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' }
    maven { url "https://oss.sonatype.org/content/repositories/snapshots/" }
    mavenCentral()
}

dependencies {
    implementation platform("org.lwjgl:lwjgl-bom:$lwjglVersion")
    
    implementation "org.lwjgl:lwjgl"
    implementation "org.lwjgl:lwjgl-assimp"
    implementation "org.lwjgl:lwjgl-bgfx"
    implementation "org.lwjgl:lwjgl-glfw"
    implementation "org.lwjgl:lwjgl-nanovg"
    implementation "org.lwjgl:lwjgl-nuklear"
    implementation "org.lwjgl:lwjgl-openal"
    implementation "org.lwjgl:lwjgl-opengl"
    implementation "org.lwjgl:lwjgl-par"
    implementation "org.lwjgl:lwjgl-stb"
    implementation "org.lwjgl:lwjgl-vulkan"
    runtimeOnly "org.lwjgl:lwjgl::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-assimp::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-bgfx::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-glfw::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-nanovg::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-nuklear::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-openal::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-opengl::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-par::$lwjglNatives"
    runtimeOnly "org.lwjgl:lwjgl-stb::$lwjglNatives"
    
    testCompile group: "junit", name: "junit", version: "4.12"
}

compileKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

compileTestKotlin {
    kotlinOptions.jvmTarget = "1.8"
}

首先是plugins这段,这一段虽然重要但一般用不着特别操心,按照我的理解大概是语言支持的插件。

接下来是版本、JVM版本之类的,看着也很明白。

然后可以申明一些接下来会用到的常量。

repository告诉Gradle从哪里找库。常见的源有内置的函数,例如mavenCentral()jcenter()。如果是其他的Maven源也可以指定URL。比如说我这里用了阿里云的Maven镜像,在国内速度就会快上很多。

接下来是dependencies,这个是重头戏。

最常见的是implementation,这个后面一般跟库的Maven id。如果想直接引用本地文件可以用files(...),如果是一个平台一个版本的可以用platform(...)。基本上这就覆盖了99%的用途。

此外还有runtimeOnly,这个和implementation的区别就是runtimeOnly的库是不参与编译的,只在运行期有效。这一般都是用JNI的一众native库和一些二级库。

还有testCompile,用这个修饰的库是只在跑单元测试的时候依赖的,因此基本上都是一些测试有关的库。我人懒,平时懒得写单元测试,因此这个我基本上不用。

这些就是我目前摸索出来的Gradle的基本用法,目前的感觉就是把IDEA里面的很多GUI的操作文字化,非常舒适,省了很多麻烦。更复杂的用法例如自定义build task等都可以在官网上找到(文档很丰富也是Gradle比较吸引人的点)。

最后有一点点想吐槽,就是Groovy似乎单引号和双引号都可以表示字符串。双引号的字符串支持插值,这似乎是唯一的予以区别。IDE默认生成的是单引号,LWJGL的打包器生成的是双引号,似乎最通用的是双引号,自己到底是用双引号还是单引号呢?这种东西真的是万恶之源。

上一次发文章已经是三个星期以前了。

感到一丝焦虑,但三个星期中确实没有做成值得写一写的事情是一个令人沮丧的事实。

开学了人忙了是一个原因。人一忙,一个矛盾愈发凸显。

理性上对于系统化学习的追求与感性上沉溺于碎片化知识平台的矛盾。

自己要应付AP考试还有一整本的Gregory Mankiw的经济学要看。

自己现在还在看柏拉图的《理想国》,书很好看,觉得很有启发。

地理的等级考也是要准备起来的。

文书也是要做起准备的。

个人的项目也是要做的。

退一步讲,自己还有一个追番TODO List还要十几部番要追,轻小说TODO List还有几部要抽空看。

当时间就剩下这么一点,知乎这么一个搅屎棍的存在就显得令人恼火了。

自己之前一直看不起快手和抖音,觉得这种垃圾玩意纯粹就是让人上瘾的。

可悲的是,自己也成了知乎的奴隶。

确实我在鄙视链上比抖音快手高一级,但是当鄙视链的每一根链环都足够拴着你吃屎的时候,其实区别不大。

知乎不知不觉成为了我常逛的唯一平台。居然占据了30%的耗电,难道这不恐怖吗?

只是为了看时事,结果刷了一个小时,难道这不恐怖吗?

我觉得是时候好好反思反思了。

要是这一个小时有营养就算了。

现在的知乎,90%的回答就是在玩烂梗。时政问题下就是两个极端的口诛笔伐,喊入关的喊入关,吹国外的吹国外,骂公知的骂公知。党同伐异,贴上标签就开骂,评论区里友善度堪比电竞祖安。

国家自信的大旗已经举起,反公知的政治正确俨然形成。这着实是好事,但是被这股情绪裹挟着消磨时间又是另一回事了。

人们说政治是一个门槛看起来低实际上高得很的学科。谁都能对政治发表一番高论,但是要使理论逻辑自洽,预测准确一致就需要非常高的水平。

知乎上大体是前者。山高死而入关兴,大家都在喊入关,要么就国家拟人化走一波,大家还就好这一口。

敢情我是来看波兰球动画的呢?

不然要么就恶政隐或者膜蛤走一波,下面的评论狂喊冲塔冲塔枪毙枪毙药丸药丸下个账号再见,不亦乐乎,谁说天朝子民不可妄议朝政?

但是我现在真的觉得让我们普罗大众表达诉求可以,玩键盘政治或许不值得提倡。

我记得16年刚玩知乎的时候,还是有很多很有意义的回答的。比如说人民群众喜闻乐见的知乎如意勺系列,虽然问题虚构荒诞,但是科普的目的确实达到了。每一篇文章洋洋洒洒干货十足,比起现在一千万和蜗牛的虚构问题不知道高到哪里去了。

现在还有这么多干货文章吗?有的,引经据典的回答终究是有的,之前在知乎上看到的毕导的科普视频也是很好的。但是这些都被大大稀释了,到现在,为了这么一篇好文章刷过50篇朱一旦,营销号,观察者网或者环球时报似乎不值得。

我不知道是资本的入驻使知乎变了味,还是推荐算法使然。

我倾向于后者。

推荐算法实在是太恐怖了。

它恐怖就恐怖在它是一个正反馈系统,平衡点是不稳定的。只要思想稍有偏差,就会迅速滚到一个极端去。

它迎合你,让你每一时刻都享受英雄所见略同的快感。很多时候看前两句句你就知道这篇文章要讲啥,但是禁不住讲的东西你喜欢,于是又看了下去。很多时候回答就只有两句,篇幅完全不重要。我逛知乎的心态已经从以往的获取知识,到寻求附和与认同为主,能看到啥就学啥为辅的情况了,非常可悲。自己当真就没有独立思考的能力和对此的自信?

小群体内的自我认同除了加深偏执以外不会引入新的思想,甚至还会降低你对异见的容忍度,看到不一样的观点就烦躁,哪怕它的长篇大论是真的有论点有论据有深度可以学到东西的。知乎里现在键政领域两极分化,推荐算法功不可没。

当一个问答网站将迎合用户,附和用户作为首要任务,而不是去挑战用户的认知,扩充用户的头脑的话,这个网站可就真的忘了初心,出了大问题了。

或许也没有忘初心,万一人家初心就是赚钱呢。

现在想想我每天干嘛?去知乎看人嘲讽美国防疫,还是看人调侃美股八熔八耻?

但是我又不是靠知乎获得这些信息的。我每天一大早都是自己去JHU的网站上查人数,自己查道指的。小米内置的资讯在遇到大新闻的时候也没落下。也就是我不断心理暗示知乎是一个获得时政新闻的平台,其实根本不是。那我天天去干嘛?我贱不贱啊?贱不贱啊?

自己完全是被动地被知乎牵着鼻子走,可太悲哀了。

另一方面,知乎的产品设计实在是太高明了。

我觉得我还是要回归搜索引擎。

搜索引擎有两个好,一个是它没有针对个人的推荐算法,另一个是你永远有主动权。你搜索什么它给什么。你不搜它不给,爽到。搜出来的结果分散于网络,不容易对于一个平台形成粘性。因此,只要搜索引擎的算法不是特别差(比如说Google),用搜索引擎就不会有大问题。

被搜索引擎带到知乎和直接在知乎搜索栏里搜索是完全两个不一样的概念。

搜索引擎的覆盖面比知乎高,那我为啥不用搜索引擎呢?

我要学习技术,我为啥不去SO、V2EX、SF之类的呢?

同样是刷手机,我为啥不去B站上看看罗翔追追番呢?前者能普法,后者比看垃圾梗更娱乐一点。

为什么呢?

自己之前真的是懒。妄想着知乎一个平台就够了,结果被灌屎,被偶尔尝到的糖吊着胃口,持续地被喂屎。

太糟糕了,实在是太糟糕了。

丹麦哲学家 Søren Kierkegaard 讲过一句话,现在想起来觉得实在是太对了。

People demand freedom of speech as a compensation for the freedom of thought which they seldom use.

把知乎卸了。搜索引擎搜到还是会看一下,但是不会主动逛了。

但愿不会真香。


其实吧,我觉得,自己退出知乎的这个决定在我看来也是很可悲的。

因为这是我对于无法解决实际问题的一个逃避。

控制不住逛知乎,知乎用户粘性很高是外因,自己不自制是内因。外因通过内因而起作用。

有的时候我觉得虽然学校的政治课大多弱智,但是一些矛盾论以及唯物辩证的思路终究还是受用的。

自己究竟什么时候才会认认真真审视自己自制力不够的问题呢?

我如何才能避免自己看知乎变成看B站呢?

我自以为知乎比B站容易上瘾,先这么自以为着吧。与自己的颓废欲望做出妥协。


搜索引擎让我掌握了主动权,但是主动权往往是不够的。我要主动搜啥呢?

正如YPM里面Humphrey说的,I have to know everything so that I can decide whether I need them!

我也有同学说知乎平台里面有很多宝藏回答,我自己也觉得很多时候知乎的热榜里有热点新闻,这是知乎的价值。

可以料想到的是,我现在退出知乎以后,终究要有一个信息平台顶替它的位置,keeping myself well-informed.

这个平台会是什么呢?

我不期望是一个正反馈的系统,今日头条之类的肯定不行。

直接看新闻?新闻是一个无反馈的系统,但为了避免被带入一家的政治立场当中去就要各种立场的新闻都看,不知道有没有时间。

并没有人设计过负反馈的信息平台,估计也不会有人设计这种吃力不讨好的玩意,虽然这是最好的。

朋友圈或许可以,让亲戚朋友代我受这些正反馈平台的戕害自然是最好不过的了(感觉自己的思想还是有点阴暗),反正他们在朋友圈几句话也不足以左右我本身的判断,他们转发的文章我也未必要看,我要的就是这件事情本身,这样我就可以自己去搜了。

唉。有的时候我觉得学数理之类的不用区分立场,逻辑与实验自有定论,省了我的脑细胞,是我作为理科生的幸运,但让我缺乏批判性思考的锻炼,又是我作为理科生的不幸。以至于面对正反馈带来的立场极端化不得不采取“闭关锁国”的战略,我也不知道是不是好事。我一直认为,如果有人能够依靠自己的批判性思维带来的负反馈效用力压推荐算法的正反馈,以至于在主流平台当中依然可以保持相对地客观,那是真的厉害。

另一种自暴自弃的思路莫过于,创造一种统一的政治立场,有一个绝对的权威来树立绝对的政治正确,这样大家都处在同一个立场,提前到达了正反馈的极限,正反馈也就没用了。这正如西方对于我国的态度。一个小圈子里互相认同而偏执是悲哀,但是如果几十亿人不断地相互认同而产生的根深蒂固的思潮,我也不知道是好是坏。它确实湮灭了所有的异见也降低了头脑在这个问题的负担,但也导致新思维的短缺。站在民智发展的角度来看,这样很危险,但是站在统战的角度上,这样似乎又不是不行。

我从未学过认知论也从未学过教育学,但是我推断人类的认知过程本身不可避免地是一个先天的正反馈过程,别人的认可是激励,别人的反驳是惩罚。在基础教育的过程中,“别人”单指老师与同学,因此这个思路被不断提倡,而当到了网络的辩论场,人们自动就会把“别人”的定义延拓了。要逆转这个态势,审慎地对待别人的认可,积极地面对别人的反驳,小心翼翼地避免矫枉过正,在这个时代还要对抗推荐算法,实在是不容易。而如果只有一个人能够做到这一步,那么这个人被孤立于群体之中,也未必如之前一样快乐。

因此我越看《理想国》,越觉得像里面的苏格拉底一样在辩论中不恼羞成怒诉诸于人身攻击,反而通过设问,在多个立场之间来回切换,将辩论用作真正探寻真理的途径而非自我膨胀的乱骂一通,实在是一种非常高明的智慧。我觉得自己离这个境界差远了,而对于是否所有人都能拥有这个智慧,是否应该拥有这份智慧,拥有这份智慧之后是否会感到快乐,我现在很悲观。

唉,自己社科不行,也只能乱发牢骚一通。

我几个月前看到国外有人分享用LaTeX实时记1700页数学笔记的感受,当时就被惊艳到了,心生向往。这次因为疫情待在家里上在线课程,总算是给了我一个带电脑进课堂的机会。于是我也仿照原作者的做法尝试了用记LaTeX笔记,这篇文章就来写一写我配置环境时遇到的坑和使用感受。

系统选择

原文的作者采用的是Ubuntu发行版+bspwm作为桌面管理器,但是bspwm太小众,网上的资料很少。而且实测装在Ubuntu上默认的配置在视觉上不协调(还要自己去装一个像样的状态栏,总之就是太烦了)。相比之下i3作为另一个Tiling Window Manager比bspwm资料多多了,也比较适合我这种小白。权衡之下我选用Manjaro + i3。在Virtual Box下安装。

配置镜像

这一步不是必须的,因为Manjaro的默认源在国内的速度也还可以接受(~400kBps)。但是换了交大的源之后可以到10MBps:

sudo pacman -Syy
sudo pacman-mirrors -i -c China -m rank
sudo pacman -Syyu

然后编辑/etc/pacman.conf加上ArchLinuxCN的交大镜像:

[archlinuxcn]
SigLevel = Optional TrustedOnly
Server = https://mirrors.sjtug.sjtu.edu.cn/archlinux-cn/$arch

最后导入一下密钥环,镜像就配置完了:

sudo pacman -Syy && sudo pacman -S archlinuxcn-keyring

安装Virtual Box GA

Manjaro默认对于虚拟机的支持不是很好。我不选择在VMWare上装Manjaro的主要原因也是因为我没有找到VMWare下自动分辨率比较简单的解决方案。在Virtual Box下只要装Virtual Box Guest Addition就可以了,美滋滋。

先安装基本的编译套件:

sudo pacman -S gcc make

然后查看一下Linux内核版本:

uname -r

是5.4,因此安装5.4的headers:

sudo pacman -S linux54-headers

之后在Virtual Box菜单下插入GA的iso文件。我最初是手动挂载的(因为找不到i3里面怎么开文件管理器),就是sudo blkid看一下设备然后mount(如果出现不支持iso9660直接重启)。后来意识到文件管理器的名字是pcmanfm,再发现默认可以直接mod + F3打开文件管理器,于是就可以在上面直接操作了。cd进去运行autorun.sh跑完重启。就发现可以自动适应外部分辨率以及共享剪贴板了,非常方便。

配置键位

默认Manjaro配置的mod键是super,也就是Win键。但是实测Win键在虚拟机环境下会有小问题(例如Win + L总是会被Windows先拦截导致锁屏)。所以我到~/.i3/config里面把mod键改成了alt。我还把i3默认的jkl:键位改成了和Vim相一致的hjkl键位。

中文相关

Manjaro默认的中文渲染很有问题(装系统的时候居然没有中文字体!)。因此需要手动配置中文的渲染。

我基本上是按照Arch Wiki上的Android类字体配置的。但是Arch Wiki上没有配置给终端用的等宽字体,需要个人发挥一下。我最喜欢的等宽字体是Sarasa Gothic(因为有中文)(去除中文我现在最喜欢的是Jetbrains Mono)。我发现ArchLinuxCN的源上已经有这款字体了,于是直接安装:

sudo pacman -S ttf-sarasa-gothic

然后修改~/.Xresources

URxvt.font: xft:Sarasa Term SC:size=12

重启一下就好了(其实不用重启,xrdb ~/.Xresources也行?)

接着是输入法。我原来准备装搜狗的,但是搜狗因为各种奇奇怪怪的原因(比如说字体找不到,比如说默认装fcitx的时候没有qt4的支持需要额外安装)不能用,在各种坑之后我决定不用它改用sunpinyin+cloud pinyin的组合,试用下来也是相当令人满意的:

sudo pacman -S fcitx-im
sudo pacman -S fcitx-configtool
sudo pacman -S fcitx-sunpinyin fcitx-cloudpinyin

安装后在~/.xprofile里面加上下列几行启用fcitx

export GTK_IM_MODULE=fcitx
export QT_IM_MODULE=fcitx
export XMODIFIERS="@im=fcitx"
fcitx -d

最后一行是我自己加上的,因为似乎由于一些原因fcitx它开机不自启……

配置TeX

终于可以开始进入正题了,首先安装TeXLive:

sudo pacman -S texlive-most texlive-langchinese

接着安装Vim Plug,在~/.vimrc里面写入自动安装脚本(手动安装亦可)以及一些基本的配置:

set number
set tabstop=4
set softtabstop=4
set shiftwidth=4
set smartindent

syntax on

if empty(glob('~/.vim/autoload/plug.vim'))
    silent !curl -fLo ~/.vim/autoload/plug.vim --create-dirs
        \ https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim
    autocmd VimEnter * PlugInstall --sync | source $MYVIMRC
endif

接着安装所必要的VimTeX插件以及UltiSnips插件并进行相关的配置:

call plug#begin('~/.vim/plugged')

Plug 'lervag/vimtex'
let g:tex_flavor='latex'
let g:vimtex_view_method='zathura'
let g:vimtex_quickfix_mode=0
set conceallevel=1
let g:tex_conceal='abdmg'

Plug 'sirver/ultisnips'
let g:UltiSnipsExpandTrigger='<tab>'
let g:UltiSnipsJumpForwardTrigger='<tab>'
let g:UltiSnipsJumpBackwardTrigger='<s-tab>'
let g:UltiSnipsSnippetDirectories=['mysnippets']

call plug#end()

安装Zathura作为PDF阅读器:

sudo pacman -S zathura zathura-pdf-mupdf

我查到Manjaro i3终端默认的背景色是#222D31,因此在~/.config/zathura/zathurarc里面写入

set default-bg "#222D31"
set recolor "true"
set recolor-lightcolor "#222D31"
set recolor-darkcolor "#FFFFFF"

这样Zathura打开白色的pdf文档的时候就会自动重新着色,完成了色调上的统一。

Vimtex默认使用latexmk编译,latexmk默认调用pdflatex。因为要用中文我们希望它调用xelatex,于是在~/.latexmkrc里面加上

$pdf_mode = 1;
$pdflatex = 'xelatex --shell-escape %O %S';

现在随便打开一个tex文档,打出\ll,预览就冒出来了,非常方便。

配置Snippets

我基本上复制了作者原博客上的大部分snippets的定义。再加上了一些我觉得比较常用的:

snippet exm "example" bA
\begin{example}
$1
\end{example}
$0
endsnippet

snippet sln "solution" bA
\begin{solution}
$1
\end{solution}
$0
endsnippet

snippet -> "arrow" A
\Rightarrow
endsnippet

snippet sc "since" wA
\because
endsnippet

snippet tf "therefore" wA
\therefore
endsnippet

snippet tx "text" wA
\text{$1}$0
endsnippet

snippet par "()" wA
\left($1\right)$0
endsnippet

snippet pbk "[]" wA
\left[$1\right]$0
endsnippet

snippet pbr "{}" wA
\left\\{$1\right\\}$0
endsnippet

snippet pabs "||" wA
\left|$1\right|$0
endsnippet

snippet enum "enumerate" bwA
\begin{enumerate}
$1
\end{enumerate}
$0
endsnippet

snippet ml "multiline math" bwA
\begin{gather*}
$1
\end{gather*}
$0
endsnippet

笔记源文件的preamable定义如下,使用fleqn选项对所有方程进行左对齐,并定义examplesolution环境方便记例题和解答:

\documentclass[fleqn]{ctexart}

\usepackage{amsthm}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{tikz}

\newtheorem*{solution}{解}
\newtheorem{example}{例}[subsection]

\begin{document}
    \maketitle
    ...
\end{document}

最后的效果如下:

3RRrp6.png

成果:

3Rojqf.png

对比手写版:

3Ro6PJ.jpg

是不是看起来还不错?

使用感受

优点

  • 格式内容分离,便于重新排版与整理
  • 字体统一美观(自己手写的字实在是太丑了哇)
  • 例题的自动编号很赞!

缺点

  • Vim原来就有两个常用的模式,输入法的使用又增加了一个模式。来回shift切换打断了编辑的流畅性,降低了效率。尤其是很多时候输入法还没切回英文就jj一下或者切片段,结果还要删掉,按shift,然后重新打,有的时候着实糟心。

  • Snippets多固然增加了效率,但是也增加了记忆的负担。对于我这种新手来说,过多的snippets只是把打LaTeX的时间转换成回忆用哪个snippet比较好的时间,一下子增加太多snippets也会降低效率。

  • 错字的存在。搜狗输入法对于偶尔的打错具有自动修正的功能,但是无论是谷歌拼音还是sunpinyin在这方面都不行,加上很多时候这些输入法的选词并不智能,一个个看过来选词其实也是非常耗费时间的。

  • 还是错字,例如“已知”错打成“一直”,英文可以配置spell checker做实时的纠正,这在原文当中也有提到,但是中文目前在这方面还缺乏支持。我听说百度有这方面的接口,但是不知道对于数学笔记专业性这么强的语料效果怎么样。Vim插件我是肯定没找到。

  • 笔记的结构比较松散,在笔记本上我们可以拿箭头划来划去,有旁注也可以轻松插入,但是这在LaTeX当中绝非易事。很多时候数学笔记的格式很容易让人陷入纠结,比如说我乱打的一段

    y=kx+b,\quad (k\neq 0)

    \Rightarrow x=\frac{y-b}{k}又因为已知k=\tan \alpha...

    这种格式要怎么排版呢?

    1. 是整个作为一个大的display style math,中文文字作为\text{}镶嵌其中?
    2. 还是一行一个display-style math?
    3. 还是有中文字的行用inline math,然后纯数学符号的行用display math?
    4. 要不要用gather*这种环境?

    又比如说如下这段:

    \begin{cases}x=1 \\y=2\end{cases}\begin{cases}x=2 \\y=1\end{cases}

    手写起来非常简单,但是LaTeX代码呢?

    $\begin{cases} x=1 \\ y=2 \end{cases}$
    或
    $\begin{cases} x=2 \\ y=1 \end{cases}$

    一下子复杂了不少,更别说为了中间的“或”字需要输入法切换。这是snippets也难以挽救的低效。

  • 我还没有试过画图。但是这一定是一个天坑。

  • 如果编译失败那几乎是当场去世(悲)

总结

就我个人体验而言,用LaTeX记笔记虽然能够达到非常高的排版质量,但是并没有传说中的那么高效(尤其是在使用中文的背景之下)。用LaTeX整理笔记自然是极好的,但是用LaTeX在课堂上实时记笔记绝对属于一大作死行为(更别说你也不知道老师收不收LaTeX记得笔记对不?)

似乎博客又有一个月没有更了呢而且一个月前挖的坑似乎还没有填完? 持续咕咕咕

我前几天玩Minecraft挖矿的时候我就一直在思考一个问题

如何自动化MC里面像挖矿这么无脑的操作呢?

其实现成的想法还是很多的:

  1. 用粘液块和红石(Slimestone)实现半自动化(例如SciCraft里面的盾构)。SciCraft用这个方法挖了103000颗钻石,优点是纯原版,缺点是其实还是很肝,而且技术性非常强。
  2. 利用mod的解决方案:像IC2,Mekanism这些mod其实都有自动化的解决方案。优点是比较简单。缺点是非原版,通用性比较差,而且自动挖矿在这些mod里面基本上也是大后期了,搭这一套措施很肝。
  3. 利用客户端mod的解决方案:利用FreeCam或者透视这种客户端mod可以在服务端原版的情况下达到类似自动化的效果,但是缺点是这个是作弊。
  4. Carpet Mod:这个mod的/player命令是在服务端生成一个假的玩家实体并进行操作,好处就是简单,快捷,不会掉线。而且Carpet现在搭了Fabric的顺风车更新得非常勤快。缺点就是服务端要装Carpet,如果不是自己开服基本用不了,而且说实话Carpet的自动化比较简单(本来是配合SciCraft那一套设计的)。
  5. MineRL:直接利用强化学习解决一切自动化问题。优点是几乎啥都可以干,缺点是技术含量非常非常高,而且训练的硬件要求非常高。说实话我觉得是overkill。

这些方案各有优缺点,其实对于我这种玩偶尔玩第三方服务器的人2、3、4基本上就可以否决掉了,1的话门槛比较高,5的话没有那硬件资源。所以这几天我自己试验了一个适合我需求的方案,还算不错。

初步设计

简单来说呢,我的想法是这个样子的

  1. 不断截取游戏窗口
  2. 通过OCR读取F3界面的字符
  3. 获得玩家的位置
  4. 通过闭环控制器控制玩家行动
  5. 通过模拟键盘和鼠标事件来进行实际的控制

这个解决方案和MineRL靠的比较近,都是比较贴近于实际玩游戏的过程。我最看重的一个有点就是这个解决方案完全不需要修改客户端,而且因为F3界面格式万年不变而且从很早版本就有了导致这个解决方案的版本兼容性超强,即使是玩第三方服务器第三方客户端也丝毫不虚。那么剩下来的就是用Python实现这一连串过程了。

用到的第三方库

import cv2 as cv
import numpy as np

import win32gui
import win32ui
import win32con

import keyboard
import mouse

上面是图像处理标准操作,中间是Win32标准操作,下面是鼠标和按键模拟用的(其实用win32api也可以解决,但是我还是想保留一点OS兼容性)。

游戏窗口截取

因为我用的是Windows所以这个部分理所应当地用Pywin32来做,我个人对于Win32 API不是特别地熟悉(常年面向MSDN编程),Pywin32做接口之后就更搞不清楚了,于是上网上东抄抄西抄抄就做完了。我把这部分代码理了一下,通用性还是比较高的。

Hwnd = int

def get_window_with_title(keyword: str) -> Hwnd:
    """
    Gets the first window whose title contains the keyword, if any
    !!! WIN 32 ONLY !!!
    :param keyword: the keyword
    :return: the first window whose title contains keyword, None if not found
    """

    def callback(hwnd: Hwnd, extra: List[Hwnd]):
        if win32gui.IsWindowVisible(hwnd) and win32gui.IsWindowEnabled(hwnd) \
                and keyword in win32gui.GetWindowText(hwnd):
            extra.append(hwnd)

    ret: List[Hwnd] = []
    win32gui.EnumWindows(callback, ret)
    return ret[0] if ret else None


def capture_screen_of(hwnd: Hwnd, rect=None) -> np.ndarray:
    """
    Capture screen of hwnd, using win32 API
    !!! WIN 32 ONLY !!!
    :param hwnd: the window handle
    :param rect: the rectangle, default value None, which means to capture the whole window
    :return: the screen capture (RGBA) in numpy array format
    """
    window_dc = win32gui.GetWindowDC(hwnd)
    dc_object = win32ui.CreateDCFromHandle(window_dc)
    compat_dc = dc_object.CreateCompatibleDC()
    bitmap = win32ui.CreateBitmap()
    if not rect:
        rect = win32gui.GetWindowRect(hwnd)
        rect = (0, 0, rect[2] - rect[0], rect[3] - rect[1])
    width, height = rect[2] - rect[0], rect[3] - rect[1]
    start = (rect[0], rect[1])
    bitmap.CreateCompatibleBitmap(dc_object, width, height)
    compat_dc.SelectObject(bitmap)
    compat_dc.BitBlt((0, 0), (width, height), dc_object, start, win32con.SRCCOPY)
    img = np.frombuffer(bitmap.GetBitmapBits(True), dtype='uint8')
    img.shape = (height, width, 4)
    dc_object.DeleteDC()
    compat_dc.DeleteDC()
    win32gui.ReleaseDC(hwnd, window_dc)
    win32gui.DeleteObject(bitmap.GetHandle())
    return img

(出于常年写静态语言的习惯还是加上了type annotation)

OCR

这是最头大的部分了,我一开始的想法非常天真:Tesseract OCR,G家这么好的轮子不用干啥。结果

  1. Tesseract下载下来好几十MB,这瞬间让整个项目的dependencies复杂了起来。
  2. Tesseract似乎很慢,跑一次F3界面要跑0.5秒,我这算法要是按照2FPS的速度跑那玩家的反射弧要长出天际了。其实考虑到Tesseract很多都是在用LSTM这种速度也不奇怪。
  3. Tesseract对于Minecraft默认像素风字体的支持不好。如果要提升效果就要额外train。

这三个因素加起来最终还是把Tesseract枪毙了。Python常见的OCR解决方案似乎只有这一家,所以只能自己设计OCR算法了。

其实在我的这个场景里OCR的要求不高,因为

  1. 因为是截屏所以像素亮度很平整,也不会有扭曲或者畸变
  2. 我可以获取到Minecraft的字体文件

这两个因素在一起让这里的OCR退化成了template matching。

从客户端jar文件里提取出来字体文件本身不是一件特别复杂的事,唯一可能复杂一点的地方就是要把字体位图当中透明色变成灰色:

font_atlas_file = "unicode_page_00.png" # Minecraft的Unicode字体位图
font_atlas = cv.imread(font_atlas_file, cv.IMREAD_UNCHANGED)
font_atlas = cv.threshold(font_atlas[:, :, 3], 127, 255, cv.THRESH_BINARY)[1]

接下来是文字提取。经过我的测试Minecraft的F3界面文字在灰度图像下的前景色在220-225左右非常固定,而且文字背后半透明灰色的遮罩使文字的周围不会出现干扰。这样提取文字部分只需要threshold一下就解决了,运用OpenCV的代码非常简短:

f3_color = (221, 225)
...
# cap是截图
img = cv.cvtColor(cap, cv.COLOR_BGR2GRAY if cap.shape[2] == 3 else cv.COLOR_BGRA2GRAY)
img = cv.threshold(img, f3_color[1], 255, cv.THRESH_TOZERO_INV)[1]
img = cv.threshold(img, f3_color[0] - 1, 255, cv.THRESH_BINARY)[1]

接下来只要对于字符集当中的每一个文字,从字体位图里面找到点阵字体然后跑模板匹配就行了

chars: List[Tuple[int, int, str]] = []
for ch in charset:
    font = bitmap_fonts[ord(ch)]
    x = cv.matchTemplate(img, font, cv.TM_CCORR_NORMED)
    # noinspection PyTypeChecker
    chars.extend(zip(*np.where(x > match_threshold), repeat(ch)))

最后是把识别出来的单个字符串联成行。这一部分的主要思路是把纵坐标在一定范围内的字符按照横坐标排序然后顺次拼接就行了。下面的代码还可以做亿点优化,但是这一部分不是本算法的瓶颈(上面的模式匹配才是),所以我懒了:

img_width = img.shape[1]
chars.sort(key=lambda tup: tup[0] * img_width + tup[1])
last_y = -1 - max_line_height_deviation
lines: List[List[Tuple[int, int, str]]] = []
for ch in chars:  
    if ch[0] - last_y > max_line_height_deviation:
        last_y = ch[0]
        lines.append([])
    lines[-1].append(ch)
ret: List[str] = []
for line in lines: 
    line_str: str = ""
    error = True
    for (i, ch) in enumerate(line):
        if i > 0 and ch[1] - line[i - 1][1] > whitespace_width:  
            line_str += ' '
        line_str += ch[2]
        error &= ch[2] in ['.', '-', '_']  
    if not error:
            ret.append(line_str)
return ret

注意要集中处理的是有些字符会因为字体的原因容易被错误识别,例如., _, -(后两个在MC的字体里面根本就是一样的)。这一部分要去除。同时因为空格无论怎么样都不会被模板匹配识别出来,所以要通过字间距的差别来插入。

这个算法的运行时间是和字符集大小成正比的,因此字符集的精简很重要。在这里我其实只采用了0123456789XYZFLa./-这些字符,这些数字和符号确保可以正常识别数字,XYZ确保可以识别坐标,Fa确保可以识别朝向(Facing),L确保可以识别指向(Looking at)。总体来说这一部分的算法虽然简陋但是堪堪够用。在精简字符集后可以跑到20FPS,已经是Tesseract的10倍多了。

在试验过程中比较有意思的一个发现是Minecraft的默认ASCII字体渲染是一个比较复杂的过程,似乎不是按照ascii.png上的内容直接来的(我的理解是似乎加了一些hint),导致模板匹配的思路在ASCII字体的时候是行不通的,必须在设置里面强制开启Unicode字体。

读取玩家信息

在OCR之后读取玩家信息就是水到渠成的事情了。我的OCR已经把行都划好了,只需要选取特定字符开头的行做一下字符串分割和parse就完成了。

闭环控制

最后一步当然是通过一个闭环控制器来控制玩家的运动。这一部分出于我在机器人社的经验我糊了一个P controller ID部分懒得写了来控制玩家的朝向,再写了个bang bang control来控制玩家的XZ坐标(在生存模式下Y坐标一般来说是控制不了的)。稍微比较烦的地方就是MC对于方位角的表示方式,需要一些分类讨论和转换。控制频率100Hz,效果差强人意,已经足够作为一个proof-of-concept了:

def control_thread_func(coord_tol: float = 0.5, yaw_tol: float = 2, pitch_tol: float = 2):
    """
    The control thread. If abs(error_target) <= x_target, then the target will be considered reached
    :param coord_tol: coordinate tolerance
    :param yaw_tol: yaw tolerance
    :param pitch_tol: pitch tolerance
    :return: runs forever
    """
    kp_yaw, kp_pitch = 0.5, 0.4 
    global target_yaw
    global target_pitch
    global reached_yaw
    global reached_pitch
    global reached_coord
    w, a, s, d = False, False, False, False # 记录WASD四个键状态
    move_threshold = 0.5 # 如果在一个坐标分量上的偏差小于这个值那就不按这个分量上的按键了
    turn_threshold = 1.5 # 每一次鼠标至少要动这么多

    def key_cond(val, key, cond):
        if val and not cond:
            keyboard.release(key)
            return False
        if not val and cond:
            keyboard.press(key)
            return True
        return val

    while True:
        if not in_control or not player_info:
            time.sleep(0.05)
            continue

        yaw, pitch = player_info.facing
        coord = xyz2xz(player_info.coord)

        # 坐标控制
        error_coord = math.hypot(target_coord[0] - coord[0], target_coord[1] - coord[1])
        reached_coord = error_coord < coord_tol
        theta = math.atan2(target_coord[0] - coord[0], target_coord[1] - coord[1])
        ws = math.cos(theta + math.radians(yaw)) * error_coord
        ad = math.sin(theta + math.radians(yaw)) * error_coord
        w = key_cond(w, "w", ws > move_threshold)
        s = key_cond(s, "s", ws < -move_threshold)
        a = key_cond(a, "a", ad > move_threshold)
        d = key_cond(d, "d", ad < -move_threshold)

        # 摄像头朝向控制
        target_pitch = max(-90, min(90, target_pitch))
        if auto_yaw and not reached_coord: 
            target_yaw = -math.degrees(math.atan2(target_x - x, target_z - z))
        if target_yaw > 180:
            target_yaw -= 180
        if target_yaw < -180:
            target_yaw += 180
        error_yaw = target_yaw - yaw
        if abs(error_yaw - 360) < abs(error_yaw):
            error_yaw -= 360
        if abs(error_yaw + 360) < abs(error_yaw):
            error_yaw += 360
        error_pitch = target_pitch - pitch
        reached_yaw = abs(error_yaw) < yaw_tol
        reached_pitch = abs(error_pitch) < pitch_tol
        if not reached_yaw:
            d_yaw = kp_yaw * error_yaw
            if abs(d_yaw) < turn_threshold:
                d_yaw = math.copysign(turn_threshold, error_yaw)
        else:
            d_yaw = 0
        if not reached_pitch:
            d_pitch = kp_pitch * error_pitch
            if abs(d_pitch) < turn_threshold:
                d_pitch = math.copysign(turn_threshold, error_pitch)
        else:
            d_pitch = 0
        mouse.move(d_yaw, d_pitch, absolute=False)

        time.sleep(0.01)

测试

写完之后我写了一个简单的挖矿脚本,可以挖1x2的矿道并且插上火把点亮:

def wait_turn():
    time.sleep(0.01)
    while player_info and (not reached_yaw or not reached_pitch):
        time.sleep(0.05)


def wait_coord():
    time.sleep(0.01)
    while player_info and not reached_coord:
        time.sleep(0.05)

def start_mining():
    global in_control
    global target_coord
    global target_yaw
    global target_pitch
    if player_info is None:
        return
    if player_info.target_block is None:
        return
    direction = xyz2xz(player_info.target_block - np.floor(player_info.coord))
    target_coord = xyz2xz(player_info.coord)
    target_yaw = -math.degrees(math.atan2(direction[0], direction[1]))
    in_control = True
    target_pitch = 0
    moved = 0
    wait_turn()
    try:
        while True:
            moved += 1
            mouse.press() # 开始挖摄像头正对的方向
            while np.linalg.norm(xyz2xz(player_info.target_block - np.floor(player_info.coord))) < 1.:
                time.sleep(0.01)
            mouse.release() # 挖完了
            if moved % 5 == 0: # 每挖五格就插一个火把(在副手)
                target_yaw += 30
                wait_turn()
                mouse.right_click()
                time.sleep(0.1)
                target_yaw -= 30
            target_pitch = 40 # 低头
            wait_turn()
            mouse.press() # 开始挖下面的那一格
            while np.linalg.norm(xyz2xz(player_info.target_block - np.floor(player_info.coord))) < 1.:
                time.sleep(0.01)
            mouse.release()
            target_coord = xyz2xz(np.floor(player_info.coord)) + np.array([0.5, 0.5]) + 0.7 * direction # 前进!
            target_pitch = 0 # 双眼平视前方
            wait_turn()
            wait_coord()
    except Exception:
        in_control = False
        mouse.release()
        print("exit")
        return

这个脚本运行的非常顺畅(虽然并不总是成功,这和玩家坐标控制的精度有很大关系)。已经初步有了实用价值。看起来我这个技术路线总体还是可行的。当然现在的功能非常简陋,但是F3的信息非常丰富,如果拓展一下用途还是非常丰富的。当然最重要的还是这个脚本可以适用于所有有F3界面的Minecraft版本,版本兼容性堪称完美,爽到。

写这个脚本花了2个小时,就是为了挖一个简单的矿,这么算来似乎有点亏?!

问题

前几天在做探究性题目的时候碰到了一个组合恒等式:

\sum_{k=1}^nk2^k\binom{2n-k-1}{n-1}=n\binom{2n}{n}
这个恒等式的右边最初是Mathematica给出的,当时把就我惊到了,一开始想用combinatorial proof的方法来证,但是想了两个晚上也想不出,自己的数学实在是太烂啦=_= 之后在翻《具体数学》的时候学了一点超几何函数的皮毛,总算使用超几何的方法诡异地证出来了。我又在Math StackExchange上发帖征集了一些初等的证明,在这里做一个汇总。

初等证明

引理1:

\sum_{k=n}^{2n}2^{-k}\binom{k}{n}=1
证明:源于这个帖子。这个式子等价于:
\sum_{k=n}^{2n}2^{2n-k}\binom{k}{n}=4^n \Rightarrow \sum_{k=0}^{n}2^{k}\binom{2n-k}{n}=4^n
利用数学归纳法,令
\begin{aligned}
    S_n&=\sum_{k=0}^{n}2^{k}\binom{2n-k}{n}\\
    &=\sum_{k=0}^{n}2^{k}\binom{2n-k}{n-k} \\
    &=\sum_{k=0}^{n}2^{n-k}\binom{n+k}{n}
\end{aligned}
于是
\begin{aligned}
    S_{n+1} &= \sum_{k=0}^{n+1}2^{n+1-k}\binom{n+1+k}{n+1} \\
    &= \sum_{k=0}^{n+1}2^{n+1-k}\left[\binom{n+k}{n+1} + \binom{n+k}{n}\right] \\
    &= \binom{2n+1}{n} + 2\sum_{k=0}^{n}2^{n-k}\binom{n+k}{n} + \sum_{k=0}^{n+1}2^{n+1-k}\binom{n+k}{n+1}\\
    &= \binom{2n+1}{n} + 2S_n + \binom{2n+1}{n+1} + \sum_{k=1}^{n}2^{n+1-k}\binom{n+k}{n+1} \\
    &= \binom{2n+2}{n+1} + 2\cdot4^n + \sum_{k=0}^{n-1}2^{n-k}\binom{n+1+k}{n+1} \\
    &= \binom{2n+2}{n+1} + 2\cdot4^n + \frac{1}{2}\left[\sum_{k=0}^{n+1}2^{n+1-k}\binom{n+1+k}{n+1}-2\binom{2n+1}{n+1}-\binom{2n+2}{n+1}\right] \\
    &= 2\cdot4^n + \frac{1}{2}S_{n+1} \\
    \Rightarrow S_{n+1}&=4^{n+1}
\end{aligned}

结合数学归纳法,得证。

引理2:

\sum_{p=m}^{2m}p2^{-p}\binom{p}{m} = (2m+1)-\frac{m+1}{2^{2m+1}}\binom{2m+2}{m+1}
证明:源于这个帖子
\begin{aligned}
\sum_{p=m}^{2m}p2^{-p}\binom{p}{m} &= \sum_{p=m}^{2m}(p+1)2^{-p}\binom{p}{m} -\sum_{p=m}^{2m}2^{-p}\binom{p}{m} \\
&= \sum_{p=m}^{2m}(m+1)2^{-p}\binom{p+1}{m+1} - 1\\
&= (m+1) \left[2\sum_{p+1=m+1}^{2m+2}2^{-(p+1)}\binom{p+1}{m+1}-2^{-2m-1}\binom{2m+2}{m+1}\right] - 1 \\
&= (m+1)\left[2-\frac{1}{2^{2m+1}}\binom{2m+2}{m+1}\right] - 1\\
&= (2m+1)-\frac{m+1}{2^{2m+1}}\binom{2m+2}{m+1}
\end{aligned}
得证。

原式证明:结合引理1和2,令m=n-1,p=2n-k-1

\begin{aligned}
    \sum_{k=1}^nk2^k\binom{2n-k-1}{n-1}&=\sum_{p=m}^{2m}(2m+1-p)2^{2m+1-p}\binom{p}{m} \\
    &=2^{2m+1}\sum_{p=m}^{2m}(2m+1-p)2^{-p}\binom{p}{m} \\
    &= 2^{2m+1}\left[(2m+1)-(2m+1)+\frac{m+1}{2^{2m+1}}\binom{2m+2}{m+1}\right] \\
    &= (m+1)\binom{2m+2}{m+1}\\
    &= n\binom{2n}{n}
\end{aligned}
整个证明神乎其技,学不来学不来,感觉自己实在是太菜了。

生成函数证明

帖子里有,看起来非常短,我对于生成函数还不熟练,积极学习中~TODO: 看懂了整理上来。

组合证明

帖子里有,看起来非常不短,似乎用了很多non-trivial的论证方法,试图理解中~TODO: 看懂了整理上来。

超几何证明

这个证明是我在翻《基础数学》看到超几何函数那一节的时候想的,是这四个证明中唯一一个我原创的证明(悲)。用到了超几何函数,蒟蒻高中生感觉非常炫酷.jpg

首先把等式左边改成无限求和:

\begin{aligned}
\sum_{k=1}^{n}k2^k\binom{2n-d-1}{n-1} &= \sum_{k=1}^{n}k2^k\binom{2n-k-1}{n-k} \\
&= \sum_{k=1}^{\infty}k2^k\binom{2n-k-1}{n-k} \\
&= \sum_{k=0}^{\infty}(k+1)2^{k+1}\binom{2n-k-2}{n-k-1}
\end{aligned}
若令t_k=(k+1)2^{k+1}\binom{2n-k-2}{n-k-1},则观察到:
\begin{aligned}
\frac{t_{k+1}}{t_k} &= \frac{(k+2)2^{k+2}\binom{2n-k-3}{n-k-2}}{(k+1)2^{k+1}\binom{2n-k-2}{n-k-1}} \\
&= \frac{2(k+2)[k+(1-n)]}{(k+1)[k+(2-2n)]} \\
\end{aligned}
是一个关于k的有理式,因此引入高斯超几何函数:
\begin{aligned}
\mathrm{LHS} &=t_0\cdot {}_2F_1(2,1-n;2-2n;2)\\
&=2\binom{2n-2}{n-1} {}_2F_1(2,1-n;2-2n;2)
\end{aligned}
考虑化简超几何项,利用如下Kummer二次变换(他1836年论文的Eq.54):
{}_2F_1(a,b;2b;z)=\frac{1}{(1-z)^{a/2}}{}_2F_1\left(\frac{1}{2}a,b-\frac{1}{2}a;b+\frac{1}{2};\frac{z^2}{4z-4}\right)
则得到:
{}_2F_1(2,1-n;2-2n;2)=-{}_2F_1(1,-n;\frac{3}{2}-n;1)
因为都是实参数且1-n<\frac{3}{2}-n,因此运用高斯定理:
\begin{aligned}
{}_2F_1(1,-n;\frac{3}{2}-n;1) &= \frac{\Gamma\left(\frac{3}{2}-n\right)\Gamma\left(\frac{1}{2}\right)}{\Gamma\left(\frac{1}{2}-n\right)\Gamma\left(\frac{3}{2}\right)} \\
&= 2\left(\frac{1}{2}-n\right) \\
&= 1-2n
\end{aligned}
一路代回去:
\begin{aligned}
\mathrm{LHS} &= 2\binom{2n-2}{n-1} {}_2F_1(2,1-n;2-2n;2) \\
&= 2(2n-1)\binom{2n-2}{n-1} \\
&= 2n\binom{2n-1}{n-1} \\
&= n\binom{2n}{n} = \mathrm{RHS}
\end{aligned}
证毕。

整个证明过程颇有一种高射炮打蚊子的既视感。超几何函数各种公式一大堆还千奇百怪,套就完事了,然而无论是Kummer的二次变换还是高斯定理我都不知道怎么证(悲)。 我已经把Ahlfors的Complex Analysis存起来了,决定有空看看(不知道会不会有这方面的内容)。

嘛,在初等证明存在的情况下,用这种严重超纲的做法只能给人一种装逼的错觉虽然我感觉有点开心?,别人看你搞不好都跟关爱智障一样。MSE上果然大佬太多,向大佬低头.jpg。

Julia Plots啥都好,就是原生对于Unicode的支持还不完善(可见用Julia的国人似乎不多)。这篇文章简要汇总一下几个不同的backend对于中文的workaround。

GR Backend

GR在支持Unicode字符上进展感觉其实不积极。具体可以看这个issue,修了一年还是没有比较好的成果(作者说是一个extensive patch...估计一开始在架构里写死了),只修到了支持Latin-1的地步,目前提供的临时解决方案是:

ENV["GKS_ENCODING"]="utf-8"
using Plots

在Windows上实测对于png格式的图片还是不行,但是对于SVG是可以的(但是因为计算布局的时候字符串长度算的是有问题的,所以legend的框会偏小)。

PyPlot Backend

PyPlot背后调用的是matplotlib,因此PyPlot默认对于中文的支持不利主要也来自于matplotlib对中文的支持问题。对于后者网上改rcParams的解决方案已经比较普及了。在Julia里面,我们只需要把rcParams暴露出来改就行了:

using Plots
pyplot()
rcParams = Plots.PyCall.PyDict(Plots.PyPlot.matplotlib."rcParams")
rcParams["font.sans-serif"] = ["Sarasa UI SC"]
rcParams["axes.unicode_minus"] = false

其中的"Sarasa UI SC"可以替换成电脑上安装的中文字体。这样中文标题就一点问题也没有了,但是似乎在Juno里面PyPlot画出来的图不会自动缩放,令强迫症稍微感到一丝不适。

PlotlyJS Backend

原生支持。

InspectDR Backend

原生支持。

我在之前用JavaScript实现了PageDewarp的核心算法。但是众所周知,JavaScript作为一门解释性语言并不适合运算密集型的任务,导致算法运行一次要30秒钟……这个效率虽然不能算特别差,我还是不满意的。所以又花了两天学习了WebAssembly把瓶颈算法用C重写了,现在在手机端一次也只需要1秒左右,效果自然是极好的。但是WebAssembly毕竟还是新科技,和现有的一些框架融合的确实不算好,在应用的过程中踩了非常多的坑,尤其是对于我这种1个月前还是Web萌新的人更是新坑老坑一起踩到怀疑人生(悲)。

WebAssembly & Emscripten

什么是WebAssembly?

WebAssembly是一种通过将静态的系统编程语言(比如说C,C++,Rust)AOT编译成一种基于栈虚拟机架构的字节码,然后在浏览器端再次实时JIT执行,来逼近这些语言native performance的技术。

(吐槽:我最初看到WebAssembly的时候有一种莫名的,很强烈的Java Applet的既视感。)

和CLR类似,WebAssembly只是字节码的标准,至于将特定语言编译成字节码的实现则是那些语言自己的事情。在目前的生态下,C和C++有名为Emscripten的基于LLVM的编译器,Rust则有它们自己的一套toolchain,两者都处于勉强凑活的水平。

就我个人而言我其实觉得Rust在这方面做得最好,官方维护,官方文档。wasm_bindgen也给自定义数据结构类型提供了很好的支持,非常适合大项目开发。但是我只是实现一个小算法,Rust的那一套太重了,有一种boilerplate会比真正代码多的预感,所以我最终选择了C(至于为什么不是C++,那是因为name mangling已经破坏了我对它FFI能力产生了非常深的心理阴影)。

前面说了,C对接WebAssembly的工具链是Emscripten。现在Emscripten的生态已经相对比较成熟了,跟着官方的Getting Started走基本上就没有问题。写C的时候就按照正常的写法(我感觉Emscripten对于libc里面的大多数常用函数都做了适配)写就可以了。算法的代码很短:

#include <math.h>
#include <stdlib.h>
#include <emscripten.h>

typedef struct v3 {
    float x, y, z;
} v3;
v3 add(v3 a, v3 b) {
    return (v3) { .x = a.x + b.x, .y = a.y + b.y, .z = a.z + b.z };
}
v3 mul(v3 a, float k) {
    return (v3) { .x = a.x * k, .y = a.y * k, .z = a.z * k };
}
v3 a2v(float *a) {
    return (v3) { .x = a[0], .y = a[1], .z = a[2] };
}
EMSCRIPTEN_KEEPALIVE
unsigned char *dewarp(
        unsigned char *src, int srcWidth, int srcHeight,
        int dstWidth, int dstHeight,
        float *cpX, float *cpY,
        float *upperLeft, float *baseVec, float *normalVec, 
        float *vertical, float d
        ) {
    v3 ul = a2v(upperLeft);
    v3 bv = a2v(baseVec);
    v3 nv = a2v(normalVec);
    v3 vt = a2v(vertical);
    unsigned char *dst = malloc(4 * dstWidth * dstHeight);
    for (int x = 0; x < dstWidth; x++) {
        v3 top = add(ul, add(mul(bv, cpX[x]), mul(nv, cpY[x])));
        for (int y = 0; y < dstHeight; y++) {
            v3 c = add(top, mul(vt, (float)y / dstHeight));
            float sx = c.x / (1 + c.z / d); 
            float sy = c.y / (1 + c.z / d); 
            sx = (sx + 0.5f) * srcWidth;
            sy = sy * srcWidth + 0.5f * srcHeight;
            int di = (y * dstWidth + x) * 4;
            int si = ((int)round(sy) * srcWidth + (int)round(sx)) * 4;
            dst[di] = src[si];
            dst[di + 1] = src[si + 1];
            dst[di + 2] = src[si + 2];
            dst[di + 3] = src[si + 3];
        }
    }
    return dst;
}

注意函数前面加的EMSCRIPTEN_KEEPALIVE宏,这个宏一方面导出了函数,另一方面防止LLVM过于智能把这段函数当成dead code消除掉或者内联掉。如果不用这个宏,就需要在编译选项里加上-s EXPORTED_FUNCTIONS="['_dewarp']"。注意Emscripten在导出函数名称的时候都在前面加上了_,直接引用的时候不能漏掉。

另外一个要注意的点是程序内部是无法直接访问JavaScript里面的变量的,我用的是Vue,那就更不用说了。所有要用到的context都需要在参数里面传进来。而目前来说,Emscripten对于自定义结构体之类的支持很少,传参基本上只支持primitive types和指针。除此以外,在指针方面吧,Emscripten并没有自动帮我们做好数组的转换,这一部分需要自行编写,一维的还容易一点,高维的写起来就更难受了。可以参考这个repo

function copyToHeap(typedArray) {
  let size = typedArray.length * typedArray.BYTES_PER_ELEMENT;
  let offset = Module._malloc(size);
  Module.HEAPU8.set(new Uint8Array(typedArray.buffer), offset);
  return offset;
}

Module["dewarp"] = function(src, srcWidth, srcHeight, dstWidth, dstHeight, cpX, cpY, upperLeft, baseVec, normalVec, vertical, d) {
  let pSrc = copyToHeap(src);
  let pCpX = copyToHeap(cpX), pCpY = copyToHeap(cpY);
  let pUL = copyToHeap(upperLeft), pBV = copyToHeap(baseVec);
  let pNV = copyToHeap(normalVec), pVertical = copyToHeap(vertical);
  let pDst = Module._dewarp(pSrc, srcWidth, srcHeight, dstWidth, dstHeight, pCpX, pCpY, pUL, pBV, pNV, pVertical, d);
  let ret = new Uint8ClampedArray(4 * dstWidth * dstHeight);
  ret.set(new Uint8ClampedArray(Module.HEAPU8.buffer, pDst, 4 * dstWidth * dstHeight));
  Module._free(pSrc); Module._free(pDst);
  Module._free(pCpX); Module._free(pCpY);
  Module._free(pUL); Module._free(pBV);
  Module._free(pNV); Module._free(pVertical);
  return ret;
};

WebAssembly内部是简单的线性内存空间,可以使用Module.HEAPU8来获取一个U8的View。调用的时候直接Module._dewarp,依然注意要在C函数名前面加_。官方还提供了了ccall的调用方式,会自动对字符串做转码,在这里并不需要。在调用时指针一律当做整数处理。在返回时直接把返回数组在内存中的数据复制一份就行了。之后记得全部free掉就行了。

编译的指令是:

emcc -O3 dewarp.c -o dewarp.js --post-js dewarp_post.js \
    -s ENVIRONMENT="web" \
    -s MODULARIZE=1 \
    -s ALLOW_MEMORY_GROWTH=1

选项的意义如下:

  • -O3:优化级别,和一般的C编译器类似。-O3是最激进的优化之一。除了在字节码上优化还会minify生成的接口js。
  • -o dewarp.js:生成的接口文件名。
  • --post-js dewarp_post.js:把dewarp_post.js(也就是上面copyToHeap所在的文件)附到生成的dewarp.js后。类似地还有--pre-js
  • -s ENVIRONMENT="web":默认Emscripten在编译接口文件的时候会同时加入node和浏览器环境下不同的加载方案。我们这边只需要浏览器环境,加上这个选项有助于减少生成的接口js大小。何况在使用webpack的情况下不开这个webpack会傻乎乎地把fs加到dependencies里面然后报错,这个时候要不这里加选项要不webpack那里额外配置二选一。
  • -s MODULARIZE=1:在写Emscripten的js端的文件的时候Module是一个关键的变量。在默认情况下Emscripten把它定义为全局变量。这会造成命名污染。如果加上这个选项,编译出来的代码就相当于是在一个大的function里面。这个函数接受一个预先加了点东西的Module对象,加上自己的私货(还有我们在dewarp_post.js里面定义的helper函数)之后返回完成的Module。确保不会产生命名污染的问题。同时Emscripten会自动把这个大函数设成module.exports
  • -s ALLOW_MEMORY_GROWTH=1:默认的内存空间是固定的,这个选项允许内存空间的扩张。图像处理算法占的内存比较大,不加这个选项会爆内存。

另外还有两个常用的选项:

  • -s EXPORTED_FUNCTIONS="['_foo', ...]":指定要导出的函数,这里的名称要加下划线。等号右边是js列表的写法。如果在C源码要导出的函数前加上EMSCRIPTEN_KEEPALIVE的话这里就不需要再指明了。
  • -s EXTRA_EXPORTED_RUNTIME_METHODS="['ccall', ...]":默认Emscripten不会把很多API导出,因为这有助于减小接口文件的大小,但是如果要用ccallcwrap之类的API的话就要在这里指明。

(自己一开始-s开头的选项一个都不知道要加,然后疯狂出问题,就暴毙了)

编译完之后会生成两个文件,一个是接口的js文件,一个是wasm文件,里面存放的就是字节码了。

Javascript端的磨合

Emscripten上的教程在这里说得非常轻巧:只要<script/>引入,然后接口js会自动加载wasm文件,然后调用方法就行了。这样做是没错,但是忽略了一点:大家现在都在用webpack之类的打包器,这类打包器面对WebAssembly需要特殊的配置才能确保其顺利运行。

我之前一直避免自己动webpack配置这种东西,因为我觉得Vue CLI帮我安顿得挺好的自己一搞搞不好怎么挂的都不知道。刚开始配置我这个萌新就真马上跌进坑里了:网上教程里面写的都是webpack.config.js,然后我一直试都没有效果,纳闷了很久,才发觉因为我用的是Vue CLI,所以配置都在vue.config.js里面:

module.exports = {
  ...
  configureWebpack: {
    // 原来webpack.config.js的内容
  }
};

这种坑有经验的开发者都不会进去吧?还是我太菜了

同时注意如果开启ESLint的话,注意一定要在.eslintignore文件里面加上接口js,一方面自动生成的js不需要lint,另一方面由于上下文不足lint下来会有很多误报。

在webpack的配置上,对于两种文件分别采取如下策略:

  • wasm文件就是普通文件,应该使用file-loader
  • js文件由于采用-s MODULARIZE=1编译开关,会把一个函数挂载到module.exports上,这个时候就要用exports-loader加载。

我这个萌新一开始对于loader的概念很困惑。因为我一开始觉得webpack一个打包的东西有哪里需要load呢?在那里用这些loader呢?后来意识到loader机制的意思是把

import foo from "bar"

转变成

var foo = some_loader("bar");

这件事情其实是挺有意思的。静态语言写多了就觉得模块导入语句应该有固定的语义才对,结果到JavaScript这里发现import其实纯粹是类似语法糖的元素,语义不固定=_=。

倒腾一圈下来我项目当中的webpack配置如下:

{
  module: {
    rules: [
      {
        test: /dewarp\.js$/,
        loader: "exports-loader"
      },
      {
        test: /dewarp\.wasm$/,
        type: "javascript/auto",
        loader: "file-loader",
      }
    ]
  },
}

还没完,在javascript代码里面这样写:

// 指定用exports-loader,所以wasmInterface现在就是Emscripten导出的那个返回Module的大函数
import wasmInterface from "./wasm/dewarp.js"
// 指定用file-loader,所以wasmBytecode现在就是指向wasm文件的路径
import wasmBytecode from "./wasm/dewarp.wasm"

let wasmModule = wasmInterface({
  // Emscripten导出的函数可以接受一个已经塞了东西的对象作为Module的基础
  // 可以在里面定义Emscripten文档里面写的一些特殊函数,比如说这里的locateFile
  // 如果定义了,在加载字节码的时候就会调用这个函数获取path的真实有效值,因为webpack
  // 调整了文件位置关系,而Emscripten生成接口脚本时写入的是生成时的文件位置,因此我们
  // 就必须在这个函数里告诉它真实的文件位置,不然会404翻车
  locateFile(path) {
    return path.endsWith(".wasm") ? wasmBytecode : path;
  }
}); // 创建对象之后自动加载
wasmModule.onRuntimeInitialized = () => {
  // 文件加载完毕
};

在这么一番操作之后,我才终于可以在原来的地方写上

wasmModule.dewarp(...) // 调用dewarp_post.js里面的helper函数,间接调用C函数

整个过程挺折腾的,但事实也确实证明这么折腾一番是值得的,性能提升三十倍,非常愉悦。

结论

总体下来我觉得WebAssembly现在技术上已经趋于成熟了,像我第一次积累了经验之后以后再用WebAssembly不踩那些坑的话体验是相当不错的。当然对于Emscripten来说在调用时的转化上目前还不是那么智能,还有很多要改进的地方(这也可能和JavaScript太放飞自我的设计有关?),使用Rust的开发我还没有试过,看官方文档上Game of Life的demo觉得非常有意思(我觉得Rust真的是绝对的后起之秀,各种方面的)。

更有意思的是,这还不是优化的终点。我听说WebAssembly还有一个SIMD的提案,这似乎又可以让性能提升若干个台阶(当然我不知道目前的JIT VM能不能根据字节码进行自动向量化)。还真的就是啥native上的技术都往web端搬,浏览器也是啥活现在都要揽=_=,估计过几年就真成一个小的操作系统了(ChromeOS: Hold my chrome :)

进展!

自己写的网页终于功能全部完成啦!

现在已经把成品网页放到博客上来了,上面几个链接最右边那个PageDewarp就是,戳进去就行了。

放几张成品:

lhyM24.jpg

lhyKGF.png

效果还是相当不错的。如果有二值化就更好了。

卷曲复原

在确定了曲面的各个参数之后,整个纸面的曲面就有了,剩下的就是把卷曲复原,输出结果。

卷曲复原的过程是一个图像上坐标映射的过程。我们遍历结果图像上的每一个坐标,唯一地确立其在输入图像上的源坐标,把源坐标的像素复制进来(或许再使用一些采样的技巧),拼成最终的结果,就成了。

更具体一点,如果输出图像是w\times h,我们就需要把页面曲面水平分成w等分,垂直分成h等分,形成一个网格。结果图像上的(i, j)像素就是网格中(i,j)对应点的三维坐标在原始图像上投影点对应的像素。

垂直等分是简单的,因为在最早的假设里面我就假设页面的垂直边沿是直线。

但是水平边沿是一条曲线,或者更精确的说,可以被近似看为一条分段三次样条曲线。怎么等分这样一个曲线呢?

这种事情用数学解决是最理想的,只要能够解析地计算出任意区间曲线的长度,配合二分这个问题就解决了。但是三次函数曲线长度的积分是非初等的且无比复杂的,所以数学方法行不通。

lh4vlV.png

(Well,好像Mathematica告诉我算还是可以算的……)

于是只能使用不那么优雅的数值方法解决了。飞快地写了一个Simpson Integration,效果看起来还不错,嘛反正精度不够缩小步长就可以了,和后面的计算比这个又不是瓶颈。

// spline: 两端为(0, 0)和(1, 0)的分段样条曲线
// res: 等分的数量
const step = 0.0001;
let len = 0, t = [];
for (let i = 0; i <= this.nodeCount; i++) {
    let [a, b, c, d] = spline[i]; // a + bx + cx^2 + dx^3
    let xLeft = i === 0 ? 0 : this.splineNodes[i - 1][0];
    let xRight = i === this.nodeCount ? 1 : this.splineNodes[i][0];
    let f = x => Math.hypot(1, ((3 * d * x + 2 * c) * x + b));
    for (let x = xLeft; x < xRight; x += step) {
        let h = Math.min(xRight - x, step);
        len += (f(x) + 4 * f(x + h / 2) + f(x + h)) * h / 6;
        t.push([x, len, ((d * x + c) * x + b) * x + a]);
    }
}
let ret = [], p = 0;
for (let i = 1; i <= res; i++) {
    let l = i / res * len;
    while (p + 1 < t.length && t[p + 1][1] <= l) p++;
    let lt = t[p], rt = p + 1 === t.length ? [1, len, 0] : t[p + 1];
    ret.push(l - lt[1] <= rt[1] - l ? [lt[0], lt[2]] : [rt[0], rt[2]]);
}
// ret 就是曲线上的点了

anyway,反正积的函数也没有那么畸形,也没那么着急优化,代码写得乱一点也就凑活着过去了(疯狂为自己的懒找借口)

求出曲线一连串等分点之后就可以做复原了:

// vadd, multiply: 向量和,数乘
// criticalPoints: 曲线等分点
for (let x = 0; x < resultWidth; x++) { // 遍历结果的每一列像素
    const top = vadd(this.upperLeft3D,
        multiply(this.baseVec, criticalPoints[x][0]), multiply(this.normalVec, criticalPoints[x][1])); 
    for (let y = 0; y < resultHeight; y++) { 
        let coord = multiply(this.toCanvasCoord(this.project(
            vadd(top, multiply(vertical, y / resultHeight)))), width / this.canvasWidth); // (x, y)对应的图像的原坐标
        let di = (y * resultWidth + x) * 4;
        let si = (Math.round(coord[1]) * width + Math.round(coord[0])) * 4;
        // R, G, B, alpha四通道
        imageDataResult.data[di] = imageDataOriginal.data[si];
        imageDataResult.data[di + 1] = imageDataOriginal.data[si + 1];
        imageDataResult.data[di + 2] = imageDataOriginal.data[si + 2];
        imageDataResult.data[di + 3] = imageDataOriginal.data[si + 3];
    }
}

大概是OI写惯了觉得10^3\times 10^3级别的运算没啥,直觉告诉我这一段代码跑得会很快。然而现实是残酷的,这段代码要跑三十秒左右,也不知道是哪里出了问题,V8的JIT都拯救不了。

目前只能先用这么慢的代码将就着了,回头有空用WebAssembly试图优化一下吧。接下来应该都是工程问题了。