ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Ebitengine 无头运行指南:用 exp/vmhost 以隐藏窗口驱动任意 Game、注入输入、截图与断言音频

Ebitengine 无头运行指南:用 exp/vmhost 以隐藏窗口驱动任意 Game、注入输入、截图与断言音频 游戏开发图形学【免费下载链接】ebitenA dead simple 2D game engine for Go项目地址https://gitcode.com/GitHub_Trending/eb/ebiten点击查看免费下载本指南讲解如何在不修改游戏源码的前提下用 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.VMGuestEndpointrun.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 调用。连接端点Endpointguest 通过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.VMGuestEndpointrun.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.goFlag含义默认值-pkgguest 包路径如./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派生的编号 PNGframe.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 21 || cat /tmp/run.log用cmp -s做字节比对时先确认两个文件都存在缺失的文件来自静默失败的捕获也会被读成差异伪装成一次像素回归。跨代码变更的黄金基线要对代码变更前后做黄金图比对用git show commit:file提取或临时提交来捕获提交状态的基线——不要用git stash往返stash 列表是仓库全局的、跨 worktree 共享一次误操作的git stash pop可能抓走属于别人的无关 stash 条目。驱动如何驱动 guest一次 Update 跑完全程整个运行发生在一个 hostUpdate中_driver/main.god.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 终止、崩溃或超时时变为非 nilpanic 的 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()) // 原始 PCM32 位小端浮点双通道交织采样率 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.ErrShortBufferVolume()返回的 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 的文本输入会话。已知陷阱Gotchashost 与 guest 必须是同一个 ebiten 版本。host/guest 协议是版本锁定的。驱动在你当前模块里构建 guest所以二者自动匹配——但针对不同 ebiten 版本构建的 guest例如预编译二进制会在握手时失败。host 仍然需要显示/图形上下文。它是无窗口windowless不是无显示displaylessmacOS 上从普通 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.gostartGuest/tickAndFrame辅助函数构建 guest、监听、握手、推进一 tick 并请求帧、阻塞到渲染完成、合成进外部屏幕以及 exp/vmhost/readpixels_test.go。这些测试使用internal/testing的MainWithRunLoop它只能在 ebiten 模块内部 import。本文基于 ebiten commit7de1780bd2026-09-27编写并据此验证。exp/vmhost是实验性 API如果它此后有变动驱动与本文的代码片段可能需要更新。赞分享游戏开发图形学【免费下载链接】ebitenA dead simple 2D game engine for Go项目地址https://gitcode.com/GitHub_Trending/eb/ebiten点击查看免费下载相关推荐HandyControl WindowAttach 附加属性实战任意元素拖动窗体、屏蔽 AltF4 与隐藏任务管理器入口HandyControl WindowAttach 附加属性实战任意元素拖动窗体、屏蔽 AltF4 与隐藏任务管理器入口 WindowAttach 是 HaUI组件桌面应用终极免费方案3步搞定Windows微信批量消息发送终极免费方案3步搞定Windows微信批量消息发送 还在为一个个手动发送微信消息而烦恼吗面对节日祝福、工作通知、活动邀请等需要批量发送的场景传统的手动操作桌面应用即时通讯RPAHugging Face Transformers 中的 BERTology 工具深入访问 BERT 隐藏状态与注意力头Hugging Face Transformers 中的 BERTology 工具深入访问 BERT 隐藏状态与注意力头 BERTology“BERT 学”人工智能大模型深度学习NLP预训练微调模型推理服务上一篇Mac Mouse Fix让你的普通鼠标在macOS上超越苹果触控板下一篇gemma.cpp 低配设备提速8GB 内存实测完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表