- 游戏开发
- 图形学
【免费下载链接】ebiten
A dead simple 2D game engine for Go
本指南讲解如何在不修改游戏源码的前提下,用 Ebitengine 仓库自带的exp/vmhost实验性包,把任意实现了ebiten.Game的应用作为guest塞进一个host驱动程序中无头运行:没有可见窗口、可以超实时推进 tick、注入键盘/鼠标/触摸/手柄/文本输入、把渲染帧读回为像素做断言或导出 PNG,还能按播放器逐个观察音频。读完你可以直接复现"点击/按键序列才出现的 bug"、做黄金图(golden-image)像素回归、给多页应用逐页截图,或让 AI Agent 端到端驱动一个应用。
何时使用、何时不用
这个方案的核心价值是把"需要人肉操作 + 真实窗口"的验证流程变成可编程、可重复、确定性的脚本。
适合用它:
- 需要注入输入(按键、鼠标、滚轮、字符、触摸、手柄)并观察应用响应——特别是只在某次点击/拖拽/按键序列之后才复现的 bug;
- 需要快进到某个状态再断言——确定性推进 N 个 tick(压缩墙钟时间,长序列近乎瞬间完成,而非真实等待),然后检查结果帧;需要逐帧检查时再穿插一帧一帧地走;
- 想要黄金图 / 像素断言,但不想让人手动运行应用再贴截图;
- 只想截一张图——不加任何输入脚本,跑 N 个 tick 后导出帧即可;
- 想检查音频——验证应用是否播放了预期声音、采样率和音量是否正确,按播放器读取未混音的原始 PCM,全程不真正出声。
不适合用它:
- 测试的是不含渲染的纯逻辑——直接写普通 Go 测试;
- 目标平台是 android / ios / js——
vmhost仅支持桌面端。
工作原理:Guest 与 Host 的心智模型
整个系统只有 host 接触 GPU 或显示器,guest 完全无头:
- Guest= 被测应用。它的
ebiten.RunGame不再打开窗口,而是通过一个 socket 连接到 host,把图形命令转发给 host 而不是自己渲染。宿主通过RunGameOptions.VMGuestEndpoint(run.go)或EBITENGINE_VM_ENDPOINT环境变量把端点交给 guest。 - Host= 你运行的小驱动程序。它本身也是一个
ebiten.Game,通过ebiten.SetWindowVisible(false)隐藏窗口。它在真实 GPU 上回放 guest 的绘制命令,并通过vmhost.GuestSession对外暴露AdvanceTicks/AdvanceFrame/WaitFrame/CompositeFrame,从而步进 guest、注入输入、读回像素、观察其音频。
从源码结构看,
GuestSession内部有一个后台 goroutine 独占连接与 guest 的镜像图像(guestsession.go),所以一个缓慢甚至卡死的 guest 永远不会阻塞 host 自身的帧循环;CompositeFrame与Close必须在 host 的帧内(Update或Draw)调用且不能并发,其余方法可从任意 goroutine 调用。
连接端点(Endpoint)
guest 通过endpoint找到 host——即 host 监听地址的 URL,例如unix:///path/to/socket或tcp://127.0.0.1:PORT(驱动程序用vmhost.EndpointURLFromAddr从自己的 listener 构造)。端点到达 guest 有两种方式:
- 带
-tags ebitenginevmguest构建:guest 读取EBITENGINE_VM_ENDPOINT环境变量,源码零改动——这是驱动使用的方式; - 不带构建标签:应用代码自己设置
RunGameOptions.VMGuestEndpoint(run.go中该字段的文档明确指出:非空时游戏作为该 host 的 guest 运行而不是打开窗口)。
两种激活方式在 exp/vmhost/guest_test.go 的activateByEnv/activateByOptions两种模式中都有端到端测试覆盖。
驱动程序:从哪来、怎么用
宿主驱动源码位于 _driver/main.go。请把它当作起点模板而非成品工具:复制它,按你的场景改写输入脚本、断言和屏幕尺寸。它只在"无输入截图"这一最简场景下可以原样运行;它是技能资产而非稳定命令,不要依赖它的命令行参数。
以ebiten 仓库根目录为工作目录运行(下文所有-pkg ./examples/...路径都是仓库相对路径)。如果要测试你自己模块里的应用,把驱动复制或调用到那个模块根目录,让-pkg指向那里的 guest 包。驱动 import 了github.com/hajimehoshi/ebiten/v2/exp/vmhost,因此 host 与 guest 必须解析到同一 Ebitengine 版本——用你模块的go.mod或replace指令保证二者对齐。
步骤 1:无输入,只截一帧
直接对任意 guest 包运行仓库自带的驱动:
go run ./skills/run-ebitengine-app-headless/_driver \ -pkg ./examples/rotate -ticks 60 -out /tmp/frame.png驱动支持的 flag 与默认值(见 _driver/main.go):
| Flag | 含义 | 默认值 |
|---|---|---|
-pkg | guest 包路径,如./examples/paint | 空(必须指定) |
-ticks | 转储帧并退出前要运行的 tick 数 | 60 |
-out | 最终帧的 PNG 输出路径 | frame.png |
-w/-h | 逻辑屏幕尺寸(设备无关像素) | 320 × 240 |
在受限沙箱中,go run可能需要可写的GOCACHE或授权使用正常 Go 构建缓存。
步骤 2:带输入——脚本化 tick 序列
把驱动复制到临时目录,编辑Update中的INPUT SCRIPT块,然后运行副本(.前缀目录不会进入go build ./...,用完记得删除):
mkdir -p .vmdriver && cp skills/run-ebitengine-app-headless/_driver/main.go .vmdriver/ # 编辑 .vmdriver/main.go —— 即 Update 中的 INPUT SCRIPT 块 go run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png注入器都在*vmhost.GuestSession上。注入的状态在下一个AdvanceTicks时被 guest 观察到,并且一直持续到被改变,所以时序来自你在运行中"哪里"注入:把运行切分成若干AdvanceTicks段,在段之间注入:
// 键盘 d.guest.PressKey(ebiten.KeyArrowRight) d.guest.ReleaseKey(ebiten.KeyArrowRight) d.guest.TypeRune('a') // 一个输入的字符(input chars) // 鼠标 —— 坐标是屏幕外的设备无关像素 d.guest.MoveCursor(160, 120) d.guest.PressMouseButton(ebiten.MouseButtonLeft) d.guest.ReleaseMouseButton(ebiten.MouseButtonLeft) d.guest.ScrollWheel(0, -1) // 触摸 —— 先是 id,再是设备无关像素 d.guest.PressTouch(0, 100, 100) d.guest.MoveTouch(0, 120, 100) d.guest.ReleaseTouch(0) // 手柄 —— 每次调用都是完整快照;见 vmhost.GamepadState d.guest.UpdateGamepads([]vmhost.GamepadState{ /* ... */ }) // 手柄触摸板 —— TouchSurfaces 列出每个 surface 当前的触摸, // 坐标在 [0, 1],(0, 0) 在左上角 d.guest.UpdateGamepads([]vmhost.GamepadState{{ ID: 0, Name: "Pad", TouchSurfaces: [][]vmhost.GamepadTouchState{ {{ID: 1, X: 0.25, Y: 0.5}}, }, }})手柄触摸跨UpdateGamepads调用以ID识别:上一次调用在同一 surface 上报出的相同 ID 的触摸是同一根手指(它在 guest 中保持自己的ebiten.GamepadTouchID),其余的都是新触摸。想抬起手指就发送一个不含它的快照;想按下新手指就用上一次快照没有的 ID。每次调用都是 guest 跟踪的一份快照,所以一次调用中漏掉某个触摸就会结束它,即使下一次调用之前没有运行任何 tick。一个 surface 最多同时跟踪 16 个触摸。
tick 数用的是 guest 自己的时间单位——见下文「Ticks 与 TPS」把"应用时间秒数"换算成AdvanceTicks次数。
组合示例——用-ticks作为总运行长度:先让应用稳定 30 tick,按住一次点击 1 tick,再运行剩余 tick,作为INPUT SCRIPT块。总运行较短时先减少稳定 tick 数:
d.guest.AdvanceTicks(30) // 让应用稳定下来 d.guest.MoveCursor(160, 120) d.guest.PressMouseButton(ebiten.MouseButtonLeft) d.guest.AdvanceTicks(1) // 按住按钮运行一个 tick d.guest.ReleaseMouseButton(ebiten.MouseButtonLeft) if rest := *ticks - 31; rest > 0 { d.guest.AdvanceTicks(rest) }步骤 3:多状态——逐个快照
驱动的snapshot(i)方法渲染 guest 的当前状态,并写到由-out派生的编号 PNG(frame.png→frame_00.png、frame_01.png、…)。在INPUT SCRIPT块的输入段之间调用它,就能让应用走过 N 个状态并逐个捕获——实践中这是价值最高的模式(例如点击多页应用的每一页并全部做黄金图比对):
d.guest.AdvanceTicks(30) // 让应用稳定下来 for page := 0; page < pageCount; page++ { if page > 0 { d.guest.MoveCursor(nextButtonX, nextButtonY) // 点击“下一页” d.guest.PressMouseButton(ebiten.MouseButtonLeft) d.guest.AdvanceTicks(1) d.guest.ReleaseMouseButton(ebiten.MouseButtonLeft) d.guest.AdvanceTicks(30) } if err := d.snapshot(page); err != nil { return err } }每次snapshot自己会跑AdvanceFrame/WaitFrame/CompositeFrame序列,所以编号 PNG 是精确帧;Update结束时最终的-out帧仍照常写出。
步骤 4:检查输出
用可用的看图工具查看 PNG。要做程序化断言,就在Update中CompositeFrame之后读取像素并比较——d.screen.ReadPixels(buf)给出预乘 alpha 的 RGBA,每像素 4 字节。输入坐标与-w/-h是逻辑设备无关像素,但d.screen和 PNG 是物理像素(逻辑尺寸 × DeviceScaleFactor;例如 320×240 的逻辑屏在 2x 显示器上会产出 640×480 的 PNG)。断言尺寸用b := d.screen.Bounds();物理w*h图像的中心像素位于4*((h/2)*w + w/2)。做黄金图检查:先转储一次、人工过目,之后每次运行都把字节与保存的 PNG 比较——陷阱见下文「验证一次运行」。
验证一次运行
判定成功要看输出 PNG 是否出现(或驱动最后那行wrote frame日志)——永远不要通过管道观察到的退出状态来判定。不要把驱动的输出接给tail -1或送到/dev/null:驱动失败时,它真正的一行错误(main里的fmt.Fprintln)很容易被丢弃,只剩go run的泛泛exit status 1,无从诊断。把完整输出存进日志文件,失败时再读:
go run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png \ > /tmp/run.log 2>&1 || cat /tmp/run.log用cmp -s做字节比对时,先确认两个文件都存在:缺失的文件(来自静默失败的捕获)也会被读成差异,伪装成一次像素回归。
跨代码变更的黄金基线
要对代码变更前后做黄金图比对,用git show <commit>:<file>提取或临时提交来捕获提交状态的基线——不要用git stash往返:stash 列表是仓库全局的、跨 worktree 共享,一次误操作的git stash pop可能抓走属于别人的无关 stash 条目。
驱动如何驱动 guest:一次 Update 跑完全程
整个运行发生在一个 hostUpdate中(_driver/main.go):
d.guest.AdvanceTicks(*ticks) // 背靠背跑完每个 tick,无实时节奏 d.guest.AdvanceFrame() // 请求最终帧 d.guest.WaitFrame() // 阻塞直到所有已入队 tick 跑完且帧已渲染 d.guest.CompositeFrame() // 把帧合成进 host 拥有的屏幕图像;在断言里检查布尔返回值AdvanceTicks(*ticks)一次性把所有 tick 入队;WaitFrame随后阻塞直到它们全部跑完、最终帧渲染完毕,所以一次等待覆盖全部。这个顺序保证了捕获的确定性——屏幕恰好反映运行结束时的状态。全程只渲染这一帧,因为 tick 不转发渲染,只有帧才转发。
这压缩了墙钟时间——这正是 VM 的意义所在。guest 不受 host 约 60 TPS 的节奏约束,所以一次 1000 tick 的运行近乎瞬间完成,而不是约 16 秒的真实时间。想捕获中间帧或手动编排输入节奏,就改为穿插调用:AdvanceTicks(n)、AdvanceFrame、WaitFrame、CompositeFrame,循环。PendingTicks与WaitTicks跟踪积压量;tick 会被合并,所以一次排入大量 tick 代价很低。
guest.Err()在 guest 终止、崩溃或超时时变为非 nil;panic 的 guest 会把堆栈打印到 stderr,驱动随即停止。驱动还给接受 guest 连接设了截止时间,这样从不拨号 host 的 guest 会失败而不是无限挂起(_driver/main.go中为 10 秒,examples/vm中为 30 秒)。
Ticks 与 TPS
一个 tick 就是一次 guest 的Update调用,AdvanceTicks(n)恰好运行 n 次。guest 的 TPS 从不限流或缩放这个数量——要跑多少个 tick、以什么实时速率(如果有的话)跑,完全是 host 的选择。
TPS 在这里的作用是单位换算。guest 的游戏逻辑是按"Update每秒跑 TPS 次真实时间"来写的,所以它的计时器、动画、冷却都以该单位计数。d.guest.RequestedTPS()报告游戏通过ebiten.SetTPS请求的值(标准 60,直到被修改)。要模拟一段应用时间,就把秒数乘以该值:一个 30-TPS 游戏里的一秒是AdvanceTicks(30),而不是 60。
RequestedTPS还可能返回ebiten.SyncWithFPS(-1),表示游戏把 tick 绑定到渲染帧而不是固定速率。此时没有秒到 tick 的换算;把一个 tick 当作一帧,通过观察应用来选计数。
观察音频
guest 不播放本地音频;它启动的每个音频流都会通过创建会话时注册的OnAudioStream处理器交给 host,作为独立的、未混音的*vmhost.GuestAudioStream。驱动已经接好:d.onAudioStream收集流,d.appendAudioStreams(nil)交回仍然打开的流(丢弃 guest 已关闭的)。音频方法是 goroutine 安全的、不绑定帧(不像CompositeFrame/Close),所以任何地方都能检查;样本数据按需从 guest 拉取。等 tick 跑完后再把它放进Update(例如WaitFrame之后,这样运行启动的流已经送达):
rate := d.guest.AudioSampleRate() // 每通道样本/秒;音频播放前为 0 for i, s := range d.appendAudioStreams(nil) { // guest 每个仍打开的播放器对应一项 slog.Info("guest audio player", "i", i, "playing", s.IsPlaying(), "volume", s.Volume(), "position", s.Position()) // 原始 PCM:32 位小端浮点,双通道交织,采样率 `rate`。 // Read 按需拉取;volume 会报告但不会应用到这些样本。 buf := make([]byte, 4096) n, _ := s.Read(buf) // 暂停时返回 0 字节;guest 关闭/结束源时返回 io.EOF slog.Info("read PCM", "bytes", n, "rate", rate) }OnAudioStream每个流触发一次,发生在调用它们的AdvanceTicks和WaitTicks期间的调用 goroutine 上(见 guestsession.go 中对NewGuestSessionOptions.OnAudioStream的说明)。驱动的处理器只暂存句柄;样本从Update读取,如上面代码。流在io.EOF之后仍然有效(seek 后重放会产生更多样本);一旦 guest 关闭它的播放器,IsClosed()返回 true 且Read永久报告io.EOF,于是appendAudioStreams丢弃它——只返回 guest 仍然打开的流。底层行为可在 guestaudiostream.go 中确认:Read每次调用都从 guest 按需拉取、只返回完整样本、缓冲区小于一个样本(8 字节)时报io.ErrShortBuffer;Volume()返回的 guest 音量不会应用到样本上,由 host 在播放时应用。
想真正听到而不是只检查,就把流作为源喂给 host 的audio.Player——参见 examples/vm/main.go 中updateAudio的做法:它按 guest 采样率创建audio.Context,为每个仍在播放的流新建audio.NewPlayerF32(stream)并SetBufferSize(time.Second/20)后Play(),再把stream.Volume()应用上去。这个例子也演示了 host 侧更完整的职责:构建并启动 guest、转发窗口输入、合成帧、镜像手柄/设备振动与光标形状、用vmhostutil.ComposerForwarder代理 guest 的文本输入会话。
已知陷阱(Gotchas)
- host 与 guest 必须是同一个 ebiten 版本。host/guest 协议是版本锁定的。驱动在你当前模块里构建 guest,所以二者自动匹配——但针对不同 ebiten 版本构建的 guest(例如预编译二进制)会在握手时失败。
- host 仍然需要显示/图形上下文。它是无窗口(windowless),不是无显示(displayless):macOS 上从普通 shell 即可运行;在裸的无头 Linux CI 上,你仍需要 Xvfb 或 EGL 环境来创建隐藏窗口。guest 两者都不需要。
SetOutsideScreen必须在第一次AdvanceTicks之前调用,且图像是物理像素(逻辑尺寸 ×DeviceScaleFactor)。驱动已处理;若你改屏幕尺寸请保留这一点。- 从 host 帧内调用
GuestSession.Close。驱动在Update内、转储帧之后关闭 guest。复制模板时保持这个形态;cmd.Wait可在ebiten.RunGame返回之后运行。失去 host 会以无错误方式结束已关闭 guest 的RunGame,所以它自己以 0 退出——驱动等待它而不是杀掉它,并把非零退出视为真正的 guest 崩溃(_driver/main.go的waitForGuestExit还加了 30 秒上限,防止卡死的 guest 挂起整个运行)。 - 启动偶尔不稳定。连续运行有时第一次尝试失败,而完全相同的重跑却成功(根因未查明;可能是 socket/握手时序)。调试前先重试一次。
- 墙钟时间不可确定。tick数量是精确的,但使用
time.Now()的代码仍然会在 tick 之间看到真实时间。 exp/vmhost是实验性的——其 API 可能变化。- Unix socket 路径长度有限(macOS 上约 104 字节),所以驱动把 socket 放在一个短的临时目录。Unix socket 在所有桌面目标上都可用,包括 Windows;不方便用文件系统 socket 时,
tcp回环监听(127.0.0.1:0)是等效替代。
仓库内的替代方案:Go 测试
不想用独立驱动、想从go test里把应用当 guest 跑,就仿照包自身的端到端测试:exp/vmhost/guest_test.go(startGuest/tickAndFrame辅助函数:构建 guest、监听、握手、推进一 tick 并请求帧、阻塞到渲染完成、合成进外部屏幕)以及 exp/vmhost/readpixels_test.go。这些测试使用internal/testing的MainWithRunLoop,它只能在 ebiten 模块内部 import。
本文基于 ebiten commit
7de1780bd(2026-09-27)编写并据此验证。exp/vmhost是实验性 API,如果它此后有变动,驱动与本文的代码片段可能需要更新。
- 游戏开发
- 图形学
【免费下载链接】ebiten
A dead simple 2D game engine for Go
相关推荐
HandyControl WindowAttach 附加属性实战:任意元素拖动窗体、屏蔽 Alt+F4 与隐藏任务管理器入口
HandyControl WindowAttach 附加属性实战:任意元素拖动窗体、屏蔽 Alt+F4 与隐藏任务管理器入口 WindowAttach 是 Ha
UI组件桌面应用终极免费方案:3步搞定Windows微信批量消息发送
终极免费方案:3步搞定Windows微信批量消息发送 还在为一个个手动发送微信消息而烦恼吗?面对节日祝福、工作通知、活动邀请等需要批量发送的场景,传统的手动操作
桌面应用即时通讯RPAHugging Face Transformers 中的 BERTology 工具:深入访问 BERT 隐藏状态与注意力头
Hugging Face Transformers 中的 BERTology 工具:深入访问 BERT 隐藏状态与注意力头 BERTology(“BERT 学”
人工智能大模型深度学习NLP预训练微调模型推理服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考