Files
Unlucky-Launcher/crates/app-iced/src/probe.rs
T
AskaEth 31cdfd5bbd 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` 的「真机验收」一节。
2026-11-03 00:26:55 +08:00

227 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 渲染后端探测 + 自动降级(D27)。
//!
//! ## 为什么需要探测
//!
//! iced 0.14 的 `Renderer` 本身就是「wgpu → tiny-skia」的运行时回退枚举(M60 Q1):
//! **不设** `ICED_BACKEND` 时,拿不到 GPU 适配器会自动降级到 CPU,零代码。
//! 需要自研的只有**判据**:
//!
//! - 本机会枚举出 `Microsoft Basic Render Driver [Cpu]`(= WARP 软光栅),
//! 所以「有没有适配器」不能当「有没有 GPU」(M60 实测),必须看 `device_type`。
//! - 没有硬件 GPU 时要**主动**把 CPU 放到候选首位,否则 wgpu 会先花时间尝试 WARP,
//! 观感与行为都难料。
//!
//! ## M97 订正:判据在 [`crate::renderer::decide`],日志不再说谎
//!
//! 本模块只负责**探测**(`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;
/// 单个适配器的摘要(只留日志要用的字段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterSummary {
/// 适配器名(如 `AMD Radeon RX 6600 XT`)。
pub name: String,
/// `device_type` 的字符串形式(`DiscreteGpu` / `IntegratedGpu` / `Cpu` / …)。
pub device_type: &'static str,
}
/// 探测结论。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Probe {
/// 枚举到的全部适配器(含 WARP 这类软光栅)。
pub adapters: Vec<AdapterSummary>,
/// 是否存在**真**硬件适配器(`device_type` 既不是 `Cpu` 也不是 `Other`)。
pub hardware: bool,
}
impl Probe {
/// 探测本机渲染能力(编入 `wgpu` 时真的枚举适配器)。
#[cfg(feature = "gpu")]
pub fn run() -> Self {
run_wgpu()
}
/// 探测本机渲染能力(`--no-default-features` 的纯 CPU 构建:没有 wgpu,直接判定无硬件)。
#[cfg(not(feature = "gpu"))]
pub fn run() -> Self {
Self::default()
}
/// 一行日志:适配器清单 + 判定。
pub fn describe(&self) -> String {
if self.adapters.is_empty() {
return format!("adapters=0 hardware={}", self.hardware);
}
let list = self
.adapters
.iter()
.map(|adapter| format!("{} [{}]", adapter.name, adapter.device_type))
.collect::<Vec<_>>()
.join(" | ");
format!(
"adapters={} [{list}] hardware={}",
self.adapters.len(),
self.hardware
)
}
}
/// 把 [`renderer::decide`] 的结论落到进程环境变量,并把**判据**打进日志(不带预检)。
///
/// 这个入口给「没有重试能力」的调用方(组件库 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` 时才有值)。
///
/// 注意这里是「判据」不是「结果」:日志里不会出现「实际用了哪个后端」这种我们观测不到的话。
/// 实际后端的取证有两条,都在 [`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 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 自身)。
std::env::set_var("ICED_BACKEND", decision.primary.env_value());
let note = if !gpu_compiled && request != Backend::Cpu {
";本构建未编入 wgpu(--no-default-features),只能 CPU"
} else {
""
};
eprintln!(
"[probe] {} :: request={} -> ICED_BACKEND={}{note}",
probe.describe(),
request.name(),
decision.primary.env_value()
);
eprintln!("[probe] {}", decision.describe());
if decision.primary == renderer::Kind::Wgpu {
eprintln!(
"[probe] 取证:出现 `[gpu] iced_wgpu: …Selected: AdapterInfo…` ⇒ GPU 真生效;\
出现 `[gpu] wgpu 初始化失败` ⇒ 已降级 CPU"
);
}
decision
}
#[cfg(feature = "gpu")]
fn run_wgpu() -> Probe {
// `iced::wgpu` 就是 wgpu crate 本身(`pub use iced_renderer::wgpu::wgpu` 再导出),
// 所以不需要单独依赖 wgpu,版本也不会和 iced 打架。
use iced::wgpu::{Backends, Instance, InstanceDescriptor};
let instance = Instance::new(&InstanceDescriptor {
backends: Backends::all(),
..Default::default()
});
let adapters = instance
.enumerate_adapters(Backends::all())
.into_iter()
.map(|adapter| {
let info = adapter.get_info();
AdapterSummary {
name: info.name,
device_type: renderer::preflight::device_type_name(info.device_type),
}
})
.collect::<Vec<_>>();
let hardware = adapters
.iter()
.any(|adapter| !matches!(adapter.device_type, "Cpu" | "Other"));
Probe { adapters, hardware }
}
#[cfg(test)]
mod tests {
use super::*;
fn probe(types: &[&'static str]) -> Probe {
let adapters = types
.iter()
.map(|device_type| AdapterSummary {
name: "adapter".to_owned(),
device_type,
})
.collect::<Vec<_>>();
Probe {
adapters,
hardware: types
.iter()
.any(|device_type| !matches!(*device_type, "Cpu" | "Other")),
}
}
#[test]
fn warp_only_machine_counts_as_no_gpu() {
// 本机实测会枚举出 WARP:`Microsoft Basic Render Driver [Cpu]`
let warp = probe(&["Cpu"]);
assert!(!warp.hardware, "只有 WARP 不能当「有 GPU」");
assert_ne!(
renderer::decide(Backend::Auto, &warp, true).primary,
renderer::Kind::Wgpu
);
}
#[test]
fn describe_lists_every_adapter() {
let text = probe(&["DiscreteGpu", "Cpu"]).describe();
assert!(text.contains("DiscreteGpu"), "{text}");
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}");
}
}
}