[{"data":1,"prerenderedAt":235},["ShallowReactive",2],{"post-rustpal-webgpu-neural-upscale":3},{"id":4,"title":5,"body":6,"cover":225,"date":226,"description":227,"extension":228,"meta":229,"navigation":230,"path":231,"seo":232,"stem":233,"__hash__":234},"posts/posts/rustpal-webgpu-neural-upscale.md","仙剑 DOS 版：移植到 Rust，再用神经网络实时超分",{"type":7,"value":8,"toc":217},"minimark",[9,13,24,31,36,45,48,61,69,75,79,82,85,88,91,94,98,101,104,172,178,181,184,187,199,202,208,211,214],[10,11,12],"p",{},"2019 年我在 GitHub 上 fork 过一个仙剑 DOS 版的仓库，就是个 DOSBox 打包，下载下来跑模拟器。这个方案我一直不太满意，游戏数据只有几十 MB，外面却要再套一层 DOS 环境，画面是 320×200 直接拉伸。",[10,14,15,16,23],{},"这两天我把仓库彻底重做了一遍：引擎从 SDLPAL 的 C 代码完整移植到 Rust，两万四千行，直接在 macOS、Linux、Windows 上运行；同一份代码编译成 wasm 跑在浏览器里，",[17,18,22],"a",{"href":19,"rel":20},"https://madeye.github.io/Legend-of-Sword-and-Fairy/",[21],"nofollow","在线试玩","；输出 1280×720，原版 320×200 的游戏帧由一个 FP16 神经网络实时放大。开发花了三天，7 月 17 日移植，18 日超分，19 日调优。这篇按三块记录：移植、超分、kernel 调优。",[10,25,26],{},[27,28],"img",{"alt":29,"src":30},"增强版开场菜单，720p 输出","/assets/2026/pal-menu-720p.png",[32,33,35],"h2",{"id":34},"rust-移植","Rust 移植",[10,37,38,39,44],{},"移植的参考是 ",[17,40,43],{"href":41,"rel":42},"https://github.com/sdlpal/sdlpal",[21],"SDLPAL","，PAL_CLASSIC 经典模式，只保留 DOS 版代码路径。顺序自底向上，先数据解压和字库，再引擎和音频，最后脚本解释器、UI 和战斗。仙剑的游戏逻辑几乎全在脚本里，剧情、对话、触发器、进战斗都是 opcode 驱动的，近三千行的 script.rs 是全仓库最大的文件，也是最花时间的部分。",[10,46,47],{},"另一大块工夫花在验证上：",[49,50,51,55,58],"ul",{},[52,53,54],"li",{},"YJ_1 解压和 C 实现逐字节对比，全部 1159 个压缩块一致。",[52,56,57],{},"OPL/RIX 音乐：20 首曲子各跑 30 秒，和 C++ 原实现逐字节一致。opl.rs 刻意照搬 C 的循环和查表，怕换写法换出 bug。",[52,59,60],{},"97 个单元测试加 4 个端到端测试：比如用脚本 opcode 触发一场战斗，再拿同一个随机种子直接打一遍，经验和金钱必须逐 bit 相同。",[10,62,63,64,68],{},"当然啦，这个移植是我让 Claude 自动完成的，方法在之前的博文里也多次提过，比如 ",[17,65,67],{"href":66},"/blog/porting-mihomo-to-rust-with-claude","mihomo 移植那篇","，这里只补一句：移植这种有标准答案的工作，AI 产出的上限很高，前提是验证做在它前面。",[10,70,71],{},[27,72],{"alt":73,"src":74},"战斗画面实录","/assets/2026/pal-battle-demo.gif",[32,76,78],{"id":77},"ai-实时超分","AI 实时超分",[10,80,81],{},"320×200 放到今天的屏幕上，nearest neighbor 放大全是方块，xBR 和 Anime4K 能把边缘做锐利但补不出细节。浏览器版我先上了这两个滤镜，随后把目标换成真正的神经网络：Real-ESRGAN 的 animevideov3 模型，专门处理动画和游戏画面，结构是 SRVGGNetCompact，18 层 3×3 卷积，64 通道，FP16 权重打包后 1.19 MB。",[10,83,84],{},"推理没有走 ONNX Runtime Web，整个网络手写成一个 shader module，我管它叫 mega kernel：全部权重预装进一个 GPU buffer，一帧 18 个 dispatch 录进一次 command submit，每层的权重偏移走一个带 256 字节动态偏移的 uniform buffer。激活值用 NHWC 排布的 vec4\u003Cf16>，两个 8 MB 的 storage buffer 来回 ping-pong。最后一层 conv_last 把 4 倍 pixel shuffle、nearest 上采样残差、clamp 全部融合进去，直接写出 1280×800 的 rgba8unorm 纹理。权重用个 Python 脚本从 ONNX 模型打包，38,736 个 mat4x4，带 round-trip 自检。",[10,86,87],{},"浏览器里有个别扭的约束：游戏画面的 canvas 已经占了 WebGL2 context，神经网络只能另起一个 WebGPU canvas 盖在上面。帧率对不齐就丢中间帧，网络跑 40 到 50 ms 一帧的时候引擎不等它。任何一步失败，降级回 xBR。",[10,89,90],{},"原生版复用同一份权重，.mega.bin 用 include_bytes! 嵌进二进制，wgpu 设备直接借 pixels 的，Metal 和 Vulkan 上跑同一套 dispatch 顺序。浏览器和原生用完全相同的权重和 kernel，这是我给自己定的约束，两边可以随时对拍。",[10,92,93],{},"精度的参照是 onnxruntime-web 的 FP16 推理：最大差 2/255，平均 0.11，偏差超过 2 的子像素一个都没有。误差来自累加顺序，视觉上不可见。代价是需要 shader-f16 这个 WebGPU feature，不支持的设备上浏览器版降级到 xBR，原生版退回 nearest。",[32,95,97],{"id":96},"webgpu-kernel-优化","WebGPU kernel 优化",[10,99,100],{},"初版 mega kernel 在 M4 上跑 48 ms 中位（纯 f16 累加；f32 累加要 89 ms），折合 21 到 25 fps，能用，但离 60 fps 很远。16 层 conv_mid 占了大约九成时间，先拿它开刀。",[10,102,103],{},"调优继续交给 AI 自动完成。可惜 Fable5 和 GPT5.6sol 都拒绝给我做 DL kernel 优化，只能上 Kimi K3 了。先写了一个 sweep 页面（nn-tune.html），21 个带名字的变体，GPU timestamp 计时，每个变体先把全网络输出读回内存和基线逐字节对比，一致才认它的耗时。sweep 页面的实测结果（M4，全网络中位耗时）：",[105,106,107,120],"table",{},[108,109,110],"thead",{},[111,112,113,117],"tr",{},[114,115,116],"th",{},"变体",[114,118,119],{},"耗时",[121,122,123,132,140,148,156,164],"tbody",{},[111,124,125,129],{},[126,127,128],"td",{},"基线 8x8x1，64 线程，每线程 16 个累加器",[126,130,131],{},"54.4 ms",[111,133,134,137],{},[126,135,136],{},"channel split=2，128 线程，8 累加器",[126,138,139],{},"30.2 ms",[111,141,142,145],{},[126,143,144],{},"split=4 加每线程 2 像素，256 线程",[126,146,147],{},"26.5 ms（采用）",[111,149,150,153],{},[126,151,152],{},"权重放进 workgroup memory",[126,154,155],{},"42.5 ms",[111,157,158,161],{},[126,159,160],{},"每线程 2 像素但不做 split",[126,162,163],{},"61.2 ms",[111,165,166,169],{},[126,167,168],{},"手动展开 in-channel 循环",[126,170,171],{},"无收益，编译器已经展开过了",[10,173,174],{},[27,175],{"alt":176,"src":177},"三种并行策略的线程组织：channel split 把输出通道均分到 z 层，每线程累加器从 16 压到 4","/assets/2026/chart-webgpu-parallel-strategy.svg",[10,179,180],{},"结论有两个。一是 occupancy 比共享内存重要：channel split 把每线程累加器从 16 压到 4，驻留线程数翻四倍，收益立竿见影；权重放 workgroup memory 反而输了，加载和同步得不偿失。二是每线程多算像素必须和 split 配合，单独上 2 像素会 register spill，比基线还慢。",[10,182,183],{},"最终配置 @workgroup_size(8, 8, 4)，18×10 的 halo tile，23 KB workgroup 存储。这个 23 KB 得多说一句：WebGPU 只保证 16 KB，23 KB 要请求更高的 limit，拿不到的机器退到 split=2 的 12.8 KB 配置，仍有 1.8 倍加速。",[10,185,186],{},"原生版把 kernel 挪到 wgpu，从 187 ms 一路修到 27 ms，主要是三件事：",[188,189,190,193,196],"ol",{},[52,191,192],{},"naga 默认给 workgroup memory 做零初始化，23 KB 的 tile 只用一个线程填，这一项就花掉约 110 ms。设 zero_initialize_workgroup_memory: false 解决。",[52,194,195],{},"naga 的强制 loop bounding 挡住编译器展开循环，卷积链慢 2.5 倍。换 create_shader_module_trusted，关掉 bounds check 和 loop bounding。",[52,197,198],{},"conv_last 同样改成 channel split 的 8x8x4，6.0 ms 降到 1.6 ms，输出逐字节一致。",[10,200,201],{},"之后把 conv_last 的 split 移植回浏览器版，Dawn 上耗时持平（26.48 对 26.54 ms），但浏览器和原生从此跑同一份 kernel。我也试过手写 MSL 绕过 naga，没有收益，WGSL 留下。顺带说下计时方法：Metal 的 pass 边界时间戳在这台机器上不可靠，读数能小到亚毫秒，最后全部改 wall clock，15 轮每轮 4 帧取中位和最小值，每个变体同样先过逐字节对比再谈耗时。",[10,203,204],{},[27,205],{"alt":206,"src":207},"游戏场景，神经网络实时放大到 720p","/assets/2026/pal-scene.png",[32,209,210],{"id":210},"结语",[10,212,213],{},"这次重制几乎没动美术。原版 320×200 的手绘像素画，线条干净，色块大，正好是这类模型最拿手的输入。一个像素没重画，超分补出来的细节我个人觉得明显好过 xBR。全平台移植加超分加调优三天做完，真正费劲的是移植的逐字节验证和 kernel 的调参，超分本身反倒是现成模型套上去就能用。",[10,215,216],{},"也想借这个例子给游戏厂商提个建议：不少当年的好作品卡在过时的分辨率和平台上，新一代玩家已经玩不到了，按这套思路重制一遍成本并不高，能让现在的小孩也玩上当年的优秀作品。",{"title":218,"searchDepth":219,"depth":219,"links":220},"",2,[221,222,223,224],{"id":34,"depth":219,"text":35},{"id":77,"depth":219,"text":78},{"id":96,"depth":219,"text":97},{"id":210,"depth":219,"text":210},"/assets/covers/rustpal-webgpu-neural-upscale.jpg","2026-07-19T00:00:00.000Z","把 1995 年的仙剑 DOS 版从 SDLPAL 完整移植到 Rust，跑进浏览器，再用 Real-ESRGAN 做实时超分。记录移植的验证方法、mega kernel 设计和 WebGPU 调优的实测数字。","md",{},true,"/posts/rustpal-webgpu-neural-upscale",{"title":5,"description":227},"posts/rustpal-webgpu-neural-upscale","P5DSCIRW32YP9X0cHdqTZ6jC9le2up2KaXCgSFPjzL8",1784531416718]