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

+244
View File
@@ -0,0 +1,244 @@
//! 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);
}
}