Bevy headless 渲染下 UI 文字不显示:从抓帧误判到布局未执行的根因排查

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.rsDefaultUiCamera::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_textuinode.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 全无。三步:

  1. 相机 target 改成 Image 的位置,加 commands.entity(entity).insert(bevy::ui::IsDefaultUiCamera)
  2. 重编后抓帧验证白色文字像素 > 0,或加诊断看 nonzero_size
  3. 恢复 cargo check 确认编译,重测画面

8. 对话式 AI 速查

问法:"Bevy headless 渲染到 Image target 时 UI 不显示,怎么排查?"

回答要点:DefaultUiCamera::get() 的 fallback 只认 Window target,Image target 被 _ => false 过滤;需显式 IsDefaultUiCamera 标记或 root 节点加 TargetCameraNode.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))"
相关推荐
k4m7v2pz16 天前
R36S 双卡分离实战:告别系统游戏挤一张卡,实现真正的双卡独立
r36s·emuelec·开源掌机·双卡分离·eeroms·tf卡分区
k4m7v2pz18 天前
macOS 解压 40GB 分卷+中文密码固件镜像的五个深坑与解决方案
python·7-zip·aes加密·踩坑记录·r36s·多卷zip解压
k4m7v2pz21 天前
把 Bevy 0.14 游戏移植到 R36S 掌机:一场与“无窗口系统“的搏斗
linux·rust·bevy·r36s·rk3326·开源掌机
k4m7v2pz22 天前
R36S 掌机游戏迁移实录:EmuELEC 双卡整理,只留 FC/SFC/NES/SNES
游戏·rom·r36s·emuelec
k4m7v2pz1 个月前
Bevy 0.14.2 玩家精灵不渲染(只有背景在动)排查全记录
macos·rust·bevy
Emerson_20263 个月前
kanzi--离屏渲染
hmi·离屏渲染·智能座舱·kanzi
易生一世4 个月前
自动化Pipeline中的Kiro CLI详解
自动化·pipeline·key·headless·kiro
喵了几个咪4 个月前
Headless 架构优势:内容与展示解耦,一套 API 打通全端生态
vue.js·架构·golang·cms·react·taro·headless
VT LI10 个月前
Bevy 渲染系统 Bindless 实现与交互逻辑
bevy·bindless