fix(renderer): M97 后端判据 —— 静默降级改具名错误,取证通道拒绝降级出图

M97 的入口是一份 macOS M1 真机报告:探测认出了 Apple M1(adapters=1 /
hardware=true),日志却写着 `renderer = auto (wgpu -> tiny-skia)`,看起来
像在跑 CPU。读源码发现那行是**常量**:`probe.rs` 按 `ICED_BACKEND` 未设就把
`"auto (wgpu -> tiny-skia)"` 打出来,与实际后端无关。真正的失败被 iced 吞掉:
`iced_renderer-0.14.0/src/fallback.rs` 的 `Compositor::with_backend` 把 wgpu 的
错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。

## 判据改为单项,不再交给 iced 回退

`ICED_BACKEND` 现在**永远是单项**(`wgpu` / `tiny-skia`):能跑起来就必定是它;
起不来就必然是 `iced::Error::GraphicsCreationFailed` 里的具名错误。判据是纯函数
`renderer::decide(request, probe, gpu_compiled)`,落在 `renderer/decision.rs`,
表驱动测试逐行钉死 1~6 行 + 「除强制 CPU / 未编 wgpu 外必须留兜底」的守门。
判据表(条件 / 选哪个 / 日志该打什么 / 谁能验证)在 `renderer/mod.rs` 顶部。

## 安全网改成换进程

第一版是「打印错误 → 在本进程再 run() 一次」,Windows 实测直接 panic:
`Create event loop: RecreationAttempt`(winit 一个进程只能建一次事件循环)。
改成 `respawn`:子进程只换 `ICED_BACKEND` + `ULL_GPU_FALLBACK`,父进程透传它的
退出码。子进程看到 `ULL_GPU_FALLBACK` 必须跳过探测,否则会无限递归重开。

## 取证通道不许自己降级

带 `--screenshot`(含 `--screen=gallery`)时 wgpu 起不来就**不出图**、退出码 1,
因为换了后端出的图会撒谎;交互启动才允许降级。每张图的日志现在打**实际**后端
(`[shot] … renderer=wgpu`),以前打的是请求值 `auto`——同一个病(在需要结果的
地方打请求)在仓库里出现了两次,这是第二次。

## 可观测性

- `log` 桥(`renderer/log_bridge.rs`)把 `iced_wgpu` 的
  `Selected: AdapterInfo{…}` 接到 stderr:这是本仓库第一次能自证「真在用 GPU」。
  按 target 分级(`iced_wgpu*` Info、`wgpu*` Warn),否则 Vulkan loader 会刷 90 行噪声。
- `renderer/preflight.rs`(默认关,`ULL_PREFLIGHT=1`):开窗前拿 wgpu 的具名错误。
  实测 Windows RX 6600 XT 上 took=717ms,所以不进默认路径。
- `app/backend.rs` 的档位判据跟着改成读单项;**真降级到 CPU 的那一次面板 α 现在会
  正确强制 1.0**(以前静默降级后仍按 GPU 档判)。

Windows 实测:auto → wgpu + `Selected: AdapterInfo{…backend: Vulkan}` 出图;
`--renderer=cpu` 逐字不变;`WGPU_BACKEND=metal` 造失败 → 具名错误 → 交互路径降级
不崩(首帧 531ms)/ 取证路径拒绝出图 exit 1。macOS Metal 路径需真机复核,
命令与期望日志行写在 `renderer/mod.rs` 的「真机验收」一节。
This commit is contained in:
AskaEth committed 2026-11-03 00:26:55 +08:00
1 parent 45cd44f756
commit 31cdfd5bbd
13 files changed
+1260 -157

No files matched your search

+72 -84
View File
@@ -8,16 +8,25 @@
//!
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位(`tiny-skia,wgpu`),
//! 否则 wgpu 会先花时间尝试 WARP,观感与行为都难料。
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位,否则 wgpu 会先花时间尝试 WARP,
//! 观感与行为都难料。
//!
//! ## 语义订正(与 M60 备忘录的差异,以源码 + 实测为准)
//! ## M97 订正:判据在 [`crate::renderer::decide`],日志不再说谎
//!
//! M60 记「`ICED_BACKEND=wgpu` 会**关闭**回退」。读 `iced_renderer-0.14.0/src/fallback.rs`
//! 得到的是:**候选列表**语义——列表里没有 `tiny-skia` 时才真的关闭回退。
//! 所以本 crate 的「优先 GPU」写 `wgpu,tiny-skia`(首选 GPU,拿不到仍降级,**绝不硬失败**),
//! 而不是 `wgpu`。
//! 本模块只负责**探测**(`Probe::run`)和**落地**([`apply`]);决策表、兜底、日志格式
//! 全部搬到了 [`crate::renderer`] 的模块文档里(那张判据表就是验收标准)。
//!
//! 三条被删掉/改掉的东西,别再加回来:
//!
//! 1. **`ICED_BACKEND` 不再写候选列表**(`wgpu,tiny-skia` / `tiny-skia,wgpu` 这种)。
//! 列表语义让 iced 自己回退,而它的回退是静默的:`iced_renderer-0.14.0/src/fallback.rs`
//! 把 wgpu 的错误装进 `errors` 向量,tiny-skia 一成功就整体丢弃。现在只写**一项**,
//! 起不来就必然是具名错误,由 `app::boot` 打印并重开降级。
//! 2. **`auto` + 有硬件不再「不设变量」**。那等于把决策权交给一条我们看不见的路径。
//! 3. **`renderer = auto (wgpu -> tiny-skia)` 这行删了**。它是按「变量未设」打出来的常量,
//! 跟实际后端无关——M97 的 macOS 报告就是被它误导的。
use crate::renderer::{self, Decision};
use crate::Backend;
/// 单个适配器的摘要(只留日志要用的字段)。
@@ -72,49 +81,37 @@ impl Probe {
}
}
/// 按请求与探测结果决定 `ICED_BACKEND` 的值(`None` = 不干预,交给 iced 自己回退)。
/// 把 [`renderer::decide`] 的结论落到进程环境变量,并把**判据**打进日志(不带预检)。
///
/// 纯函数,好让「降级矩阵」被测试钉死。
pub fn backend_env(request: Backend, probe: &Probe, gpu_compiled: bool) -> Option<&'static str> {
match (request, gpu_compiled) {
// 命令行 --software-renderer / --renderer=cpu:只留 CPU 候选。
(Backend::Cpu, _) => Some("tiny-skia"),
// 用户点名要 GPU,但没编进 wgpu:只能 CPU,日志里说明。
(Backend::Gpu, false) => Some("tiny-skia"),
// 优先 GPU,但保留 tiny-skia 兜底(候选列表语义:不会硬失败)。
(Backend::Gpu, true) => Some("wgpu,tiny-skia"),
// 自动:有硬件就让 iced 走默认顺序(wgpu 优先,自动回退)。
(Backend::Auto, true) if probe.hardware => None,
// 自动 + 没编进 wgpu:只有 CPU 候选,别把 wgpu 写进列表(会与「未编入 wgpu」的日志自相矛盾)。
(Backend::Auto, false) => Some("tiny-skia"),
// 自动 + 有 wgpu 但没硬件(含只有 WARP 的机器):CPU 起步,wgpu 留作兜底。
(Backend::Auto, true) => Some("tiny-skia,wgpu"),
}
/// 这个入口给「没有重试能力」的调用方(组件库 harness,`--screen=gallery`)用:它写完变量就
/// 直接 `iced::application(..).run()`,拿不到 `app/boot.rs` 的「换进程降级」。
pub fn apply(request: Backend, probe: &Probe) -> Decision {
apply_with_preflight(request, probe, None)
}
/// 把决策落到进程环境变量,并把探测结果 + 生效值打进日志。
/// 同上,但接受 [`renderer::preflight`] 的结果(`ULL_PREFLIGHT=1` 时才有值)。
///
/// 返回**实际生效**的后端描述(`"auto (iced: wgpu -> tiny-skia)"` 之类)。
pub fn apply(request: Backend, probe: &Probe) -> String {
/// 注意这里是「判据」不是「结果」:日志里不会出现「实际用了哪个后端」这种我们观测不到的话。
/// 实际后端的取证有两条,都在 [`crate::renderer`] 的判据表里:
/// ① `[gpu] iced_wgpu: …Selected: AdapterInfo…`(iced 自己在创建设备时打的);
/// ② 起不来时的具名错误 + `app/boot.rs` 的重开降级。
///
/// 预检失败就把 wgpu 降下来(判据表第 7 行),免得为了拿一条错误去闪一次窗。
pub fn apply_with_preflight(
request: Backend,
probe: &Probe,
preflight: Option<&Result<renderer::preflight::Verdict, renderer::preflight::Failure>>,
) -> Decision {
let gpu_compiled = cfg!(feature = "gpu");
let env = backend_env(request, probe, gpu_compiled);
let mut decision = renderer::decide(request, probe, gpu_compiled);
if matches!(preflight, Some(Err(_))) {
decision = renderer::demote_after_preflight(decision);
}
// ICED_BACKEND 是进程级环境变量,必须在 `iced::application(..).run()` 之前写。
// 仓库是 edition 2021 → `set_var` 仍是安全 API(升 2024 后要改成子进程 exec 自身)。
if let Some(value) = env {
std::env::set_var("ICED_BACKEND", value);
}
let effective = if gpu_compiled {
match (request, env) {
(Backend::Cpu, _) => "tiny-skia (CPU,强制)".to_owned(),
(_, None) => "auto (wgpu -> tiny-skia)".to_owned(),
(_, Some("wgpu,tiny-skia")) => "wgpu.then(tiny-skia) (优先 GPU)".to_owned(),
(_, Some(value)) => format!("{value} (CPU 起步)"),
}
} else {
"tiny-skia (本构建未编入 wgpu,只有 CPU)".to_owned()
};
std::env::set_var("ICED_BACKEND", decision.primary.env_value());
let note = if !gpu_compiled && request != Backend::Cpu {
";本构建未编入 wgpu(--no-default-features),只能 CPU"
@@ -126,18 +123,24 @@ pub fn apply(request: Backend, probe: &Probe) -> String {
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
probe.describe(),
request.name(),
env.unwrap_or("<unset>")
decision.primary.env_value()
);
eprintln!("[probe] renderer = {effective}");
eprintln!("[probe] {}", decision.describe());
if decision.primary == renderer::Kind::Wgpu {
eprintln!(
"[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;\
出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU"
);
}
effective
decision
}
#[cfg(feature = "gpu")]
fn run_wgpu() -> Probe {
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
use iced::wgpu::{Backends, DeviceType, Instance, InstanceDescriptor};
use iced::wgpu::{Backends, Instance, InstanceDescriptor};
let instance = Instance::new(&InstanceDescriptor {
backends: Backends::all(),
@@ -149,16 +152,9 @@ fn run_wgpu() -> Probe {
.into_iter()
.map(|adapter| {
let info = adapter.get_info();
let device_type = match info.device_type {
DeviceType::DiscreteGpu => "DiscreteGpu",
DeviceType::IntegratedGpu => "IntegratedGpu",
DeviceType::VirtualGpu => "VirtualGpu",
DeviceType::Cpu => "Cpu",
DeviceType::Other => "Other",
};
AdapterSummary {
name: info.name,
device_type,
device_type: renderer::preflight::device_type_name(info.device_type),
}
})
.collect::<Vec<_>>();
@@ -195,39 +191,12 @@ mod tests {
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
let warp = probe(&["Cpu"]);
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
assert_eq!(
backend_env(Backend::Auto, &warp, true),
Some("tiny-skia,wgpu")
assert_ne!(
renderer::decide(Backend::Auto, &warp, true).primary,
renderer::Kind::Wgpu
);
}
#[test]
fn auto_with_real_gpu_leaves_iced_alone() {
let gpu = probe(&["DiscreteGpu", "Cpu"]);
assert!(gpu.hardware);
assert_eq!(backend_env(Backend::Auto, &gpu, true), None);
}
#[test]
fn explicit_choices_always_keep_a_fallback() {
let gpu = probe(&["DiscreteGpu"]);
let none = probe(&[]);
// 优先 GPU 但候选列表里留 tiny-skia:拿不到适配器时降级而不是报错
assert_eq!(
backend_env(Backend::Gpu, &gpu, true),
Some("wgpu,tiny-skia")
);
assert_eq!(
backend_env(Backend::Gpu, &none, true),
Some("wgpu,tiny-skia")
);
assert_eq!(backend_env(Backend::Cpu, &gpu, true), Some("tiny-skia"));
// 没编进 wgpu 的构建绝不写 wgpu(连候选列表也不许出现,reviewer P3-4)
assert_eq!(backend_env(Backend::Gpu, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &gpu, false), Some("tiny-skia"));
assert_eq!(backend_env(Backend::Auto, &none, false), Some("tiny-skia"));
}
#[test]
fn describe_lists_every_adapter() {
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
@@ -235,4 +204,23 @@ mod tests {
assert!(text.contains("Cpu"), "{text}");
assert!(text.contains("hardware=true"), "{text}");
}
/// 判据矩阵本身在 [`crate::renderer::decide`] 的测试里逐行钉死(表驱动 + 兜底守门)。
/// 这里只守 `apply` 这一层的**落地**契约:写进环境变量的永远是**单项**。
///
/// 抓得住:谁把候选列表(`wgpu,tiny-skia`)加回来——那会让 iced 的静默回退复活。
/// 抓不住:iced 运行期真的选了谁(那是判据表第 8 行的事)。
#[test]
fn apply_writes_exactly_one_backend() {
let gpu = probe(&["IntegratedGpu"]);
for request in [Backend::Auto, Backend::Gpu, Backend::Cpu] {
let decision = renderer::decide(request, &gpu, true);
let value = decision.primary.env_value();
assert!(
!value.contains(','),
"ICED_BACKEND 只许单项,收到 {value:?}"
);
assert!(matches!(value, "wgpu" | "tiny-skia"), "{value}");
}
}
}