Files
Unlucky-Launcher/crates/app-iced/src/renderer/preflight.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

245 lines
8.4 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.
//! wgpu 自检(判据表第 7 行):在把控制权交给 iced **之前**自己跑一遍
//! 「实例 → 适配器 → 设备」,把 wgpu 的具名错误拿在手里。
//!
//! ## 为什么默认关
//!
//! 它会**重复** iced 马上要做的活(再建一个实例、再要一个设备),macOS 上一个 Metal 设备
//! 不是免费的;而且它测不了 `create_surface`(那时还没有窗口),所以它**代替不了**真跑一次。
//! 它的价值只有一个:让「不上 GPU」这个结论在**开窗之前**就有原因,验收时不用盯着一个闪窗猜。
//!
//! 开关:`ULL_PREFLIGHT=1`(判据表里登记过,不是隐藏口子)。
//!
//! ## 它镜像的是谁
//!
//! 逐字对着 `iced_wgpu-0.14.0/src/window/compositor.rs` 的 `Compositor::request()` 抄的:
//! `Backends::from_env().unwrap_or(all)`、`PowerPreference::from_env().unwrap_or(HighPerformance)`、
//! `Limits { max_bind_groups: 2, max_non_sampler_bindings: 2048, ..default }`。
//! 差一处都会让预检和真实路径说不同的话,那时预检就不是证据而是噪声了。
/// 预检开关的环境变量名(`1` / `true` 打开)。
pub const ENV_SWITCH: &str = "ULL_PREFLIGHT";
/// 预检成功:拿到的适配器摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Verdict {
/// 适配器名(`Apple M1` / `AMD Radeon RX 6600 XT` / `Microsoft Basic Render Driver`)。
pub adapter: String,
/// `device_type` 的字符串形式。
pub device_type: &'static str,
/// 命中哪个 wgpu 后端(`Metal` / `Vulkan` / `Dx12` / `Gl`)。
pub backend: String,
}
impl Verdict {
/// 一行日志。
#[must_use]
pub fn describe(&self) -> String {
format!(
"ok adapter={:?} type={} backend={}",
self.adapter, self.device_type, self.backend
)
}
}
/// 预检失败:卡在哪一步 + wgpu 自己的错误文本。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
/// 失败阶段(日志里 `stage=` 的取值)。
pub stage: &'static str,
/// wgpu 具名错误(`Display`,原样保留,不翻译成「失败」)。
pub detail: String,
}
impl Failure {
/// 一行日志(`stage=` 可直接 grep)。
#[must_use]
pub fn describe(&self) -> String {
format!("FAILED stage={} error={}", self.stage, self.detail)
}
}
/// 用户是否要求预检。
#[must_use]
pub fn requested() -> bool {
std::env::var(ENV_SWITCH).is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
}
/// 按 `ULL_PREFLIGHT` 决定跑不跑;跑了就顺手把结论打进 stderr(带耗时,别让开关成本变成传说)。
///
/// 返回 `None` = 没要求预检(调用方按判据表 1~6 行原样走)。
#[must_use]
pub fn run_if_requested() -> Option<Result<Verdict, Failure>> {
if !requested() {
return None;
}
let started = std::time::Instant::now();
let verdict = run();
let took = started.elapsed().as_secs_f64() * 1000.0;
match &verdict {
Ok(ok) => eprintln!("[gpu-preflight] {} took={took:.0}ms", ok.describe()),
Err(failure) => {
eprintln!("[gpu-preflight] {} took={took:.0}ms", failure.describe());
}
}
Some(verdict)
}
#[cfg(feature = "gpu")]
/// 真跑一遍 wgpu 初始化(不开窗、不建 surface)。
///
/// # Errors
/// 无适配器 / 设备请求被拒时返回 [`Failure`],`detail` 是 wgpu 的原话。
pub fn run() -> Result<Verdict, Failure> {
use iced::wgpu::{
Backends, ExperimentalFeatures, Features, Instance, InstanceDescriptor, Limits,
MemoryHints, PowerPreference, RequestAdapterOptions, Trace,
};
let backends = Backends::from_env().unwrap_or_else(Backends::all);
let instance = Instance::new(&InstanceDescriptor {
backends,
..Default::default()
});
let options = RequestAdapterOptions {
power_preference: PowerPreference::from_env().unwrap_or(PowerPreference::HighPerformance),
// 没有窗口,测不了 surface 兼容性——这一条只能靠真跑一次(判据表第 8 行)。
compatible_surface: None,
force_fallback_adapter: false,
};
let adapter = block_on(instance.request_adapter(&options)).map_err(|error| Failure {
stage: "request-adapter",
detail: format!("{options:?}: {error}"),
})?;
let info = adapter.get_info();
let device_type = device_type_name(info.device_type);
// iced 的 `request_device` 失败时不打日志、只把错误塞进 `RequestDeviceFailed`,
// 所以我们在这里先请求一次,把它的话原样抄下来。
let _ = block_on(adapter.request_device(&iced::wgpu::DeviceDescriptor {
label: Some("unlucky-launcher gpu preflight"),
required_features: Features::empty(),
required_limits: Limits {
max_bind_groups: 2,
max_non_sampler_bindings: 2048,
..Limits::default()
},
memory_hints: MemoryHints::MemoryUsage,
trace: Trace::Off,
experimental_features: ExperimentalFeatures::disabled(),
}))
.map_err(|error| Failure {
stage: "request-device",
detail: error.to_string(),
})?;
Ok(Verdict {
adapter: info.name,
device_type,
backend: format!("{:?}", info.backend),
})
}
#[cfg(not(feature = "gpu"))]
/// 本构建没编 wgpu:预检必然是「不可用」,但这不是错误——判据表第 4 行已经说明原因。
///
/// # Errors
/// 恒返回 [`Failure`](`stage=not-compiled`)。
pub fn run() -> Result<Verdict, Failure> {
Err(Failure {
stage: "not-compiled",
detail: "本构建未编入 wgpu(--no-default-features)".to_owned(),
})
}
#[cfg(feature = "gpu")]
/// `DeviceType` → 日志里的字符串(探测与预检**共用**这一处,别各写一份 `match`)。
#[must_use]
pub fn device_type_name(device_type: iced::wgpu::DeviceType) -> &'static str {
use iced::wgpu::DeviceType;
match device_type {
DeviceType::DiscreteGpu => "DiscreteGpu",
DeviceType::IntegratedGpu => "IntegratedGpu",
DeviceType::VirtualGpu => "VirtualGpu",
DeviceType::Cpu => "Cpu",
DeviceType::Other => "Other",
}
}
/// 把 wgpu 的 future 跑到底。
///
/// 只用 `std`:`Waker` 由 `std::task::Wake` 提供(安全 API),运行期负责 `park`。
/// 不引入 futures 执行器——这里只有一个 future,而且它在 wgpu 的回调线程上唤醒。
#[cfg(feature = "gpu")]
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Unpark(std::thread::Thread);
impl Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
fn wake_by_ref(self: &Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(Unpark(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failure_line_is_greppable() {
let failure = Failure {
stage: "request-device",
detail: "no device request succeeded".to_owned(),
};
let text = failure.describe();
assert!(
text.starts_with("FAILED stage=request-device error="),
"{text}"
);
assert!(text.contains("no device request succeeded"), "{text}");
}
#[test]
fn verdict_line_lists_the_three_facts() {
let verdict = Verdict {
adapter: "Apple M1".to_owned(),
device_type: "IntegratedGpu",
backend: "Metal".to_owned(),
};
let text = verdict.describe();
assert!(text.contains("adapter=\"Apple M1\""), "{text}");
assert!(text.contains("type=IntegratedGpu"), "{text}");
assert!(text.contains("backend=Metal"), "{text}");
}
/// 没编 wgpu 的构建:预检必须说清原因,而不是一句「失败」。
#[cfg(not(feature = "gpu"))]
#[test]
fn unbuilt_gpu_names_the_reason() {
let failure = run().unwrap_err();
assert_eq!(failure.stage, "not-compiled");
assert!(failure.detail.contains("wgpu"), "{}", failure.detail);
}
}