corundum-framework-gui 0.7.0

The look shared by sapphire and asteria apps: egui shell, system fonts, Lucide icons, translations
//! Renderer setup: backend selection and device-loss reporting.
//!
//! On Windows the app asks for Vulkan rather than letting wgpu pick: the DX12
//! device is removed whenever a Remote Desktop session reconnects or the display
//! configuration changes, and egui-wgpu turns that into an unrecoverable panic
//! (see [`device_lost_explanation`]). Every other OS gets wgpu's primary
//! backends. `WGPU_BACKEND` overrides the choice everywhere, so
//! `WGPU_BACKEND=dx12` is the escape hatch on Windows when Vulkan misbehaves
//! (for example, a Vulkan overlay layer that crashes the app).
//!
//! The device asks for wgpu's default limits only as far as the adapter has them: some
//! adapters fall short of the defaults (the iOS simulator's Metal one), and asking for
//! more than the adapter has fails the device's creation, and with it the window.
//!
//! On Android a debug build leaves out wgpu's debug labels ([`instance_flags`]): the
//! emulator's Vulkan driver crashes the app when wgpu names its objects.

use std::sync::Arc;

use eframe::{egui_wgpu, wgpu};

/// Renderer configuration for [`eframe::NativeOptions`].
pub fn wgpu_options() -> egui_wgpu::WgpuConfiguration {
    let mut options = egui_wgpu::WgpuConfiguration::default();
    if let egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_setup {
        setup.instance_descriptor.backends = backends();
        setup.instance_descriptor.flags = instance_flags(setup.instance_descriptor.flags);
        let egui_default = Arc::clone(&setup.device_descriptor);
        setup.device_descriptor = Arc::new(move |adapter| {
            let mut descriptor = egui_default(adapter);
            descriptor.required_limits = descriptor
                .required_limits
                .or_worse_values_from(&adapter.limits());
            descriptor
        });
    }
    options
}

/// `flags` (wgpu's, from the build and the `WGPU_*` variables), less the debug labels on
/// Android: the emulator's Vulkan driver (`vulkan.ranchu`) dies with SIGSEGV in
/// `vkSetDebugUtilsObjectNameEXT`, which wgpu calls for each label. Validation stays.
fn instance_flags(flags: wgpu::InstanceFlags) -> wgpu::InstanceFlags {
    if cfg!(target_os = "android") {
        flags - wgpu::InstanceFlags::DEBUG
    } else {
        flags
    }
}

/// [`default_backends`], unless `WGPU_BACKEND` says otherwise.
fn backends() -> wgpu::Backends {
    wgpu::Backends::from_env().unwrap_or_else(default_backends)
}

/// Vulkan on Windows, where DX12 loses its device on every Remote Desktop
/// reconnect; wgpu's primary backends (Vulkan, Metal, DX12, browser WebGPU)
/// everywhere else, so macOS gets Metal and Linux picks what it has. Android adds GLES.
fn default_backends() -> wgpu::Backends {
    if cfg!(windows) {
        wgpu::Backends::VULKAN
    } else if cfg!(target_os = "android") {
        // Vulkan where the device has a usable driver, GLES where it does not (older
        // phones, and emulators whose host GPU is not passed through).
        wgpu::Backends::VULKAN | wgpu::Backends::GL
    } else {
        wgpu::Backends::PRIMARY
    }
}

/// Report which adapter was chosen and arm the device-loss callback.
///
/// The startup line is how you confirm which backend actually took effect.
pub fn on_render_state(render_state: Option<&egui_wgpu::RenderState>) {
    let Some(render_state) = render_state else {
        tracing::warn!("no wgpu render state; skipping GPU diagnostics");
        return;
    };

    let info = render_state.adapter.get_info();
    tracing::info!(
        backend = ?info.backend,
        adapter = %info.name,
        device_type = ?info.device_type,
        driver = %info.driver_info,
        "wgpu renderer ready"
    );

    // Fires before egui-wgpu notices, so this is the only place the real reason
    // is available.
    render_state
        .device
        .set_device_lost_callback(|reason, message| {
            tracing::error!(?reason, %message, "GPU device lost");
        });
}

/// Install a panic hook that translates egui-wgpu's staging-buffer panic.
///
/// The hook only adds an explanation; the previous hook still runs, so the
/// original message and any backtrace are preserved.
pub fn install_panic_hook() {
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if let Some(explanation) = panic_message(info).and_then(device_lost_explanation) {
            tracing::error!("{explanation}");
        }
        previous(info);
    }));
}

fn panic_message<'a>(info: &'a std::panic::PanicHookInfo<'_>) -> Option<&'a str> {
    let payload = info.payload();
    payload
        .downcast_ref::<&str>()
        .copied()
        .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
}

const DEVICE_LOST_EXPLANATION: &str = concat!(
    "The GPU device was lost. egui-wgpu reports this as a staging buffer failure, ",
    "but the buffer size is not the problem: wgpu swallows a lost device and returns ",
    "None, and egui-wgpu panics on that None. On Windows this usually means a Remote ",
    "Desktop reconnect or a display change removed the graphics device. Restart the app.",
);

/// Recognise egui-wgpu's staging-buffer panic and say what it actually means.
///
/// Both the index-data and vertex-data branches of `Renderer::update_buffers`
/// share the same opening, so one substring covers them. Returns `None` for any
/// other panic message.
fn device_lost_explanation(panic_msg: &str) -> Option<&'static str> {
    panic_msg
        .contains("Failed to create staging buffer for ")
        .then_some(DEVICE_LOST_EXPLANATION)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The real text from egui-wgpu 0.34.3 `renderer.rs`, index-buffer branch.
    const INDEX_PANIC: &str = "Failed to create staging buffer for index data. \
        Index count: 16113. Required index buffer size: 64452. \
        Actual size 99168 and capacity: 99168 (bytes)";

    /// The vertex-buffer branch of the same function panics with parallel wording.
    const VERTEX_PANIC: &str = "Failed to create staging buffer for vertex data. \
        Vertex count: 4028. Required vertex buffer size: 128896. \
        Actual size 198336 and capacity: 198336 (bytes)";

    #[test]
    fn explains_the_index_buffer_panic() {
        assert!(device_lost_explanation(INDEX_PANIC).is_some());
    }

    #[test]
    fn explains_the_vertex_buffer_panic() {
        assert!(device_lost_explanation(VERTEX_PANIC).is_some());
    }

    #[test]
    fn explanation_names_the_real_cause() {
        let msg = device_lost_explanation(INDEX_PANIC).unwrap();
        assert!(
            msg.contains("device"),
            "should say the device was lost: {msg}"
        );
        assert!(
            msg.contains("Remote Desktop"),
            "should name the usual Windows trigger: {msg}"
        );
    }

    #[test]
    fn ignores_unrelated_panics() {
        assert_eq!(
            device_lost_explanation("index out of bounds: len is 3"),
            None
        );
        assert_eq!(device_lost_explanation(""), None);
    }

    #[cfg(windows)]
    #[test]
    fn defaults_to_vulkan_on_windows_without_an_env_override() {
        // `backends()` reads WGPU_BACKEND, which is not set in the test harness.
        assert_eq!(backends(), wgpu::Backends::VULKAN);
    }

    #[cfg(target_os = "android")]
    #[test]
    fn defaults_to_vulkan_and_gl_on_android_without_an_env_override() {
        // `backends()` reads WGPU_BACKEND, which is not set in the test harness.
        assert_eq!(backends(), wgpu::Backends::VULKAN | wgpu::Backends::GL);
    }

    #[cfg(not(any(windows, target_os = "android")))]
    #[test]
    fn defaults_to_the_primary_backends_elsewhere_without_an_env_override() {
        // `backends()` reads WGPU_BACKEND, which is not set in the test harness.
        assert_eq!(backends(), wgpu::Backends::PRIMARY);
    }

    #[test]
    fn android_drops_the_debug_labels_and_keeps_validation() {
        let flags = instance_flags(wgpu::InstanceFlags::debugging());
        assert_eq!(
            flags.contains(wgpu::InstanceFlags::DEBUG),
            !cfg!(target_os = "android")
        );
        assert!(flags.contains(wgpu::InstanceFlags::VALIDATION));
    }

    #[test]
    fn the_default_is_vulkan_on_windows_and_adds_gl_on_android() {
        assert_eq!(
            default_backends(),
            if cfg!(windows) {
                wgpu::Backends::VULKAN
            } else if cfg!(target_os = "android") {
                wgpu::Backends::VULKAN | wgpu::Backends::GL
            } else {
                wgpu::Backends::PRIMARY
            }
        );
    }
}