1. 问题现象
把 Bevy 0.14 游戏(Mighty Rodent 逆向重写,Rust 全栈)移植到 R36S 开源掌机时,遇到一个隐蔽问题:headless 渲染下 UI 文字整体不显示。
R36S 跑 EmuELEC,没有 X11/Wayland,只能 headless 运行:主窗口 primary_window: None、禁用 WinitPlugin、使用 ScheduleRunnerPlugin 以 60fps 驱动,相机渲染到 640×480 离屏纹理,每帧 CPU 读回后写入 /dev/fb0 上屏。
现象在本机 Metal 与 R36S Mali GLES 上表现完全一致,可排除后端差异:
- 视差背景(SpriteBundle 世界坐标)正常:森林、天空、敌机全部显示。
- 主菜单 UI 层整体不显示:标题 MIGHTY RODENT、按钮文字、Debug HUD 全部缺失。
- 日志零报错:Bevy 静默吞掉,查日志像"跑得好好的"。
2. 排查手段
使用 headless 内置调试抓帧:把离屏纹理存为 BMP 文件,本机 Metal 与掌机各抓一帧对比。
3. 谬误溯源:走过的弯路
以下每条弯路都被实测推翻:
3.1 颜色对比度问题
把菜单文字浅蓝改成白色重编部署,无效------字形压根没进帧,不是对比度问题。
3.2 Mali GLES 渲染后端特有 bug
本机 Metal headless 同样没字,排除后端差异。
3.3 抓帧证明文字渲染了
BMP 分析出"文字像素"------那是蓝天白云的云朵(RGB≈255 满足颜色阈值),ASCII 渲染把云当成字形;且 save_bmp 高度符号写错(BMP 正高度=bottom-up),sips 转 PNG 整图上下颠倒,进一步误导判断。
4. 真正根因
真正根因从未在渲染层:UI 布局根本没执行。
5. 源码验证:诊断数据 + 根因代码 + 一行修复
在 headless.rs 临时加诊断系统,每 120 帧打印 UI 链路状态。关键三行:
text
[diag] camera target=Image viewport=None physical_viewport=Some(UVec2(640, 480))
[diag] default_ui_camera.get()=None ← 找不到默认 UI 相机
[diag] Node total=27 nonzero_size=0 ← 所有 UI 节点尺寸全 0
相机物理尺寸正常(640×480),但 DefaultUiCamera::get() 返回 None。破案在 Bevy 0.14 源码 bevy_ui/src/ui_node.rs 的 DefaultUiCamera::get():
rust
pub fn get(&self) -> Option<Entity> {
self.default_cameras.get_single().ok().or_else(|| {
// fallback:只认 RenderTarget::Window
self.cameras.iter()
.filter(|(_, c)| match c.target {
RenderTarget::Window(WindowRef::Primary) => true,
RenderTarget::Window(WindowRef::Entity(w)) => self.primary_window.get(w).is_ok(),
_ => false, // ← Image target 在这里被过滤!
})
.max_by_key(|(e, c)| (c.order, *e))
.map(|(e, _)| e)
})
}
headless 相机 target 是 RenderTarget::Image,fallback 的 _ => false 直接过滤 → 返回 None。连锁反应:布局系统 ui_layout_system 拿不到相机 → Node.size() 全 0 → 提取系统 extract_uinode_text 里 uinode.size().x == 0. || uinode.size().y == 0. 直接 continue 跳过 → UI 层整体不渲染。整个过程零 warn(布局对"找不到相机但世界里有相机"走的是静默分支)。
一行修复(src/headless.rs setup_headless_render):
rust
for (entity, mut cam) in cameras.iter_mut() {
cam.target = RenderTarget::Image(handle.clone());
// 显式加标记:default_cameras.get_single() 即可命中,不依赖窗口
commands.entity(entity).insert(bevy::ui::IsDefaultUiCamera);
}
修复前后实测对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| default_ui_camera.get() | None | Some(相机) |
| Node nonzero_size | 0/27 | 24/27 |
| 抓帧白色文字像素 | 0 | 1918 |
6. 落地结论:可复用规则 + 适用范围
可复用规则: 任何渲染到 RenderTarget::Image 的相机(headless / 离屏 / 截图),必须显式加 IsDefaultUiCamera 标记,否则 bevy_ui 布局系统找不到默认 UI 相机,整个 UI 层静默不渲染------日志零报错,只有加诊断才暴露。两种修法任选:
- 相机侧(推荐,一行):
commands.entity(camera_entity).insert(bevy::ui::IsDefaultUiCamera) - 根节点侧:给 root UI 节点加
TargetCamera(camera_entity),布局 fallback 也认显式 TargetCamera
适用范围: R36S / 开源掌机 headless 移植、离屏纹理渲染(截图/录像)、任何 primary_window: None 的 Bevy 应用。
排查经验(按顺序,避免重走弯路):
- 先加诊断打印
default_ui_camera.get()和Node.size(),别猜渲染层 - 抓帧分析避开背景色干扰:蓝天白云 RGB≈255 会伪造"文字像素"
- BMP 存帧注意高度符号:正高度=bottom-up,逐行从上往下写须用负高度(top-down),否则 PNG 上下颠倒
7. 人类开发者速查
症状:headless / 离屏渲染下 sprite 正常但 UI 全无。三步:
- 相机 target 改成 Image 的位置,加
commands.entity(entity).insert(bevy::ui::IsDefaultUiCamera) - 重编后抓帧验证白色文字像素 > 0,或加诊断看 nonzero_size
- 恢复
cargo check确认编译,重测画面
8. 对话式 AI 速查
问法:"Bevy headless 渲染到 Image target 时 UI 不显示,怎么排查?"
回答要点:DefaultUiCamera::get() 的 fallback 只认 Window target,Image target 被 _ => false 过滤;需显式 IsDefaultUiCamera 标记或 root 节点加 TargetCamera;Node.size() 全 0 是"布局没执行"的强信号,先加诊断确认再动手。
9. 代码 Agent 速查
bash
# 定位 headless 修复点(本仓库)
grep -n "IsDefaultUiCamera" src/headless.rs
源码锚点:Image target 被过滤处
grep -rn "_ => false" ~/.cargo/registry/src/*/bevy_ui-0.14.2/src/ui_node.rs
抓帧验证文字(修复后白色像素应 >0)
python3 -c "import struct
d=open('/tmp/frame.bmp','rb').read()
o=struct.unpack('<I',d[10:14])[0]
w=struct.unpack('<i',d[18:22])[0]
h=abs(struct.unpack('<i',d[22:26])[0])
rb=w3; rp=(4-(rb%4))%4
print(sum(1 for y in range(h) for x in range(w) if d[o+y(rb+rp)+x3+2]>230 and d[o+y(rb+rp)+x3+1]>230 and d[o+y(rb+rp)+x*3]>230))"