Skip to main content

bevy_metalfx/
lib.rs

1//! Bevy plugin for Apple MetalFX upscaling and frame interpolation.
2//!
3//! Uses `objc2-metal-fx` for MetalFX framework bindings and integrates
4//! as a render graph node replacing Bevy's built-in upscaling.
5//!
6//! <div class="warning">
7//!
8//! **If you are reading this on docs.rs, you are seeing the non-macOS stub.**
9//!
10//! Almost everything here is `#[cfg(target_os = "macos")]`, and docs.rs builds
11//! on Linux. The `present` and `gpu_timing` modules are missing from the
12//! rendered page entirely, and [`MetalFxPlugin`] shows two of its four fields.
13//!
14//! Pinning docs.rs to an Apple target does not work: it cross-compiles from a
15//! Linux container and must still build the dependency graph, and `blake3`
16//! (via `bevy_asset`) has a C build script that needs an Apple toolchain. That
17//! was tried in 0.2.0 and produced no docs page at all.
18//!
19//! For the real API: `cargo doc --open -p bevy_metalfx --all-features` on a
20//! Mac, or read the source.
21//!
22//! </div>
23//!
24//! ## Supported Modes
25//! - **Spatial**: Single-frame ML upscaling (macOS 13+)
26//! - **Temporal**: Multi-frame temporal upscaling with motion vectors (macOS 13+)
27//! - **FrameInterpolation**: Generate intermediate frames (macOS 26+, Metal 4)
28//!
29//! Each mode has a matching cargo feature, and the features are cumulative:
30//! `frame-interpolation` implies `temporal` implies `spatial`. They gate both
31//! this crate's encode paths and the `objc2-metal-fx` bindings behind them, so
32//! a narrower feature set really does compile a narrower surface.
33//!
34//! ## What is and is not verified
35//!
36//! Spatial and temporal upscaling are complete and stable. Frame interpolation
37//! computes a correct intermediate frame and, with `present::MetalFxDualPresent`
38//! enabled, presents it — at twice the accepted-present rate of a single
39//! present, with the render rate unchanged. Whether the extra frame reaches the
40//! display is **unverified**: see the `present` module for what that depends
41//! on.
42//!
43//! (Deliberately not intra-doc links: `present` is macOS-only, and docs.rs
44//! renders this page from a Linux build where the link target does not exist.)
45
46#[cfg(target_os = "macos")]
47mod platform;
48
49#[cfg(target_os = "macos")]
50mod node;
51
52#[cfg(target_os = "macos")]
53pub use node::{MetalFxConfig, MetalFxFrameTiming, MetalFxUpscaleNode};
54
55#[cfg(not(target_os = "macos"))]
56mod stub {
57    use super::MetalFxMode;
58    use bevy::prelude::*;
59
60    /// Render-world configuration (stub for non-macOS platforms).
61    ///
62    /// Field visibility mirrors the macOS definition so cross-platform code
63    /// sees one shape, not two.
64    ///
65    /// The fields are deliberately unread here: nothing on a non-macOS target
66    /// consumes them, and the struct exists so cross-platform code can name and
67    /// insert the resource without `#[cfg]`. They stopped being `pub` in 0.2,
68    /// which is what makes the lint notice — it is the point of the type, not a
69    /// gap in it. Without this the crate warns on every docs.rs build, which is
70    /// a Linux build of the default features.
71    #[allow(dead_code)]
72    #[derive(Resource, Clone, Copy, bevy::render::extract_resource::ExtractResource)]
73    pub struct MetalFxConfig {
74        pub(crate) render_scale: f32,
75        pub(crate) mode: MetalFxMode,
76        pub(crate) dynamic_res_range: Option<(f32, f32)>,
77    }
78
79    /// Render graph node (stub for non-macOS platforms — does nothing).
80    #[derive(Default)]
81    pub struct MetalFxUpscaleNode;
82}
83
84#[cfg(not(target_os = "macos"))]
85pub use stub::{MetalFxConfig, MetalFxUpscaleNode};
86
87// Not platform-gated: the Halton sequence and the jitter system are plain
88// Bevy, and the call site in `build` is gated on the feature alone. Adding
89// `target_os = "macos"` here made `--features temporal` fail to compile on
90// every other platform — the module was configured out while its caller was
91// not. Shipped broken in 0.1.0; caught by cross-checking the docs.rs target.
92#[cfg(feature = "temporal")]
93mod jitter;
94
95#[cfg(target_os = "macos")]
96pub mod gpu_timing;
97
98#[cfg(target_os = "macos")]
99pub use gpu_timing::{GpuTimingSink, GpuTimingStats};
100
101/// Display-timed dual presentation — the half of frame interpolation that
102/// lives below the render graph. Only meaningful when interpolation is built.
103#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
104pub mod present;
105
106#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
107pub use present::{display_awake, PresentSink, PresentStats};
108
109/// Shared GPU-timing sink, cloned into both the main world (for the debug
110/// server to read) and the render world (for the upscale node to push into).
111/// Holds recent per-command-buffer GPU-elapsed samples for Phase 0 bound-ness
112/// analysis. See [`gpu_timing`] for the metric caveat.
113#[cfg(target_os = "macos")]
114#[derive(bevy::prelude::Resource, Clone)]
115pub struct GpuTimingDiag(pub std::sync::Arc<GpuTimingSink>);
116
117/// Check whether MetalFX is available on this system at runtime.
118///
119/// Returns `false` on non-macOS platforms or when the MetalFX framework
120/// is not present (macOS < 13).
121pub fn is_available() -> bool {
122    #[cfg(target_os = "macos")]
123    {
124        platform::is_available_impl()
125    }
126    #[cfg(not(target_os = "macos"))]
127    {
128        false
129    }
130}
131
132/// MetalFX operating mode.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
134pub enum MetalFxMode {
135    /// Single-frame spatial upscaling. Needs only color input.
136    /// Available on macOS 13+ with Apple Silicon.
137    #[default]
138    Spatial,
139    /// Temporal upscaling with motion vectors + jitter.
140    /// Better quality than spatial but requires MotionVectorPrepass.
141    Temporal,
142    /// Frame interpolation — generates intermediate frames between rendered frames.
143    /// Requires macOS 26+ (Metal 4). Adds +1 frame of input latency.
144    FrameInterpolation,
145    /// Bypass MetalFX — render at full res with Bevy's default upscaling.
146    /// Useful for A/B benchmarking.
147    Disabled,
148}
149
150/// Configuration for the MetalFX plugin.
151pub struct MetalFxPlugin {
152    /// Render scale factor (0.25–1.0). Default 0.5 = half-res render.
153    pub render_scale: f32,
154    /// Which MetalFX mode to use.
155    pub mode: MetalFxMode,
156    /// Enable adaptive render scale — dynamically adjusts scale based on P99 frame time.
157    /// The initial `render_scale` is snapped to the nearest supported step (0.5 or 0.75).
158    /// The system will not scale outside this range. `MetalFxRenderScale` becomes mutable.
159    pub adaptive: bool,
160    /// Optional externally-owned GPU-timing sink (Phase 0 bench). When the host
161    /// (e.g. `src-tauri`) wants to read GPU timings over the debug server, it
162    /// constructs the sink, passes a clone here, and keeps a clone for the diag
163    /// provider — avoiding a process-global. `None` ⇒ the plugin makes its own.
164    #[cfg(target_os = "macos")]
165    pub gpu_timing_sink: Option<std::sync::Arc<GpuTimingSink>>,
166    /// Dual presentation for `FrameInterpolation`: present the synthesised
167    /// frame on its own drawable, ahead of the real one, so interpolation
168    /// actually raises the displayed frame rate.
169    ///
170    /// Ignored in every other mode. Turning it off keeps the interpolated frame
171    /// computed-but-unpresented, which is only useful as a measurement control
172    /// — it costs the full interpolation pass and shows nothing for it.
173    #[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
174    pub dual_present: Option<present::MetalFxDualPresent>,
175}
176
177impl Default for MetalFxPlugin {
178    fn default() -> Self {
179        Self {
180            render_scale: 0.5,
181            mode: MetalFxMode::Spatial,
182            adaptive: false,
183            #[cfg(target_os = "macos")]
184            gpu_timing_sink: None,
185            #[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
186            dual_present: None,
187        }
188    }
189}
190
191/// Main-world resource holding the render scale for resolution override systems.
192#[derive(bevy::prelude::Resource, Clone, Copy)]
193pub struct MetalFxRenderScale(pub f32);
194
195impl bevy::app::Plugin for MetalFxPlugin {
196    fn build(&self, app: &mut bevy::app::App) {
197        assert!(
198            (0.1..=1.0).contains(&self.render_scale),
199            "MetalFxPlugin: render_scale must be in [0.1, 1.0], got {}",
200            self.render_scale
201        );
202
203        if !is_available() {
204            log::warn!("MetalFX is not available on this system — plugin disabled");
205            return;
206        }
207
208        if self.mode == MetalFxMode::Disabled {
209            // Even in Disabled mode, apply resolution override if scale < 1.0.
210            // This lets Bevy's built-in bilinear upscaler handle the upscale,
211            // serving as a control condition for MetalFX benchmarks.
212            if self.render_scale < 1.0 {
213                log::info!(
214                    "MetalFX Disabled + scale={} — applying resolution override only (Bevy bilinear upscaler)",
215                    self.render_scale
216                );
217                app.insert_resource(MetalFxRenderScale(self.render_scale));
218                app.add_systems(bevy::app::PostStartup, apply_resolution_override);
219                app.add_systems(bevy::app::Update, update_resolution_on_resize);
220            } else {
221                log::info!("MetalFX mode is Disabled at full resolution — bypassing");
222            }
223            return;
224        }
225
226        log::info!(
227            "MetalFX plugin initialized: mode={:?}, render_scale={}",
228            self.mode,
229            self.render_scale
230        );
231
232        // Main-world: insert render scale resource and resolution override systems.
233        app.insert_resource(MetalFxRenderScale(self.render_scale));
234        app.insert_resource(MetalFxModeResource(self.mode));
235        // Main-world MetalFxConfig for render-world extraction. In adaptive mode
236        // the temporal scaler is built with dynamic resolution spanning the
237        // governor's scale range, so scale changes flex without a scaler rebuild.
238        let dynamic_res_range = if self.adaptive {
239            Some((SCALE_STEPS[0], SCALE_STEPS[SCALE_STEPS.len() - 1]))
240        } else {
241            None
242        };
243        app.insert_resource(MetalFxConfig {
244            render_scale: self.render_scale,
245            mode: self.mode,
246            dynamic_res_range,
247        });
248        app.add_systems(bevy::app::PostStartup, apply_resolution_override);
249        app.add_systems(bevy::app::Update, update_resolution_on_resize);
250
251        // Adaptive render scale (opt-in).
252        if self.adaptive {
253            app.insert_resource(AdaptiveScaleState::new(self.render_scale));
254            app.add_systems(
255                bevy::app::Update,
256                (
257                    adaptive_scale_system,
258                    sync_config_scale,
259                    update_resolution_on_scale_change,
260                )
261                    .chain(),
262            );
263        } else {
264            // Even without adaptive, keep config in sync for manual scale changes.
265            app.add_systems(
266                bevy::app::Update,
267                (sync_config_scale, update_resolution_on_scale_change).chain(),
268            );
269        }
270
271        // Temporal + FrameInterpolation modes: add prepass components and jitter system.
272        #[cfg(feature = "temporal")]
273        if self.mode == MetalFxMode::Temporal || self.mode == MetalFxMode::FrameInterpolation {
274            app.add_systems(bevy::app::PostStartup, setup_temporal_camera);
275            app.add_systems(bevy::app::Update, jitter::update_jitter);
276        }
277        #[cfg(not(feature = "temporal"))]
278        if self.mode == MetalFxMode::Temporal || self.mode == MetalFxMode::FrameInterpolation {
279            log::warn!(
280                "MetalFX: {:?} mode requested but 'temporal' feature not enabled — falling back to Spatial",
281                self.mode
282            );
283            app.insert_resource(MetalFxModeResource(MetalFxMode::Spatial));
284        }
285
286        // Extract MetalFxConfig from main world to render world each frame.
287        // Must be added to main app — the plugin internally finds the RenderApp sub-app.
288        #[cfg(target_os = "macos")]
289        app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
290            MetalFxConfig,
291        >::default());
292
293        // Frame interpolation needs the real inter-frame interval; mirror the
294        // main world's `Time` delta into the render world (which has no `Time`).
295        #[cfg(target_os = "macos")]
296        if self.mode == MetalFxMode::FrameInterpolation {
297            app.insert_resource(MetalFxFrameTiming::default());
298            app.add_systems(bevy::app::Update, update_frame_timing);
299            app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
300                MetalFxFrameTiming,
301            >::default());
302        }
303
304        // Dual presentation — the half of interpolation that lives below the
305        // render graph. The layer can only be found on the main thread, and the
306        // extra present is encoded in the render world, so the pointer is
307        // captured here and extracted across.
308        #[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
309        if self.mode == MetalFxMode::FrameInterpolation {
310            app.insert_resource(self.dual_present.clone().unwrap_or_default());
311            app.add_systems(bevy::app::Update, present::capture_metal_layer);
312            app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
313                present::MetalFxDualPresent,
314            >::default());
315        }
316
317        #[cfg(target_os = "macos")]
318        {
319            use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
320            use bevy::render::render_graph::{RenderGraphExt, ViewNodeRunner};
321            use bevy::render::RenderApp;
322
323            // Shared GPU-timing sink: same Arc in main world (debug server reads)
324            // and render world (upscale node pushes). Phase 0 bound-ness bench.
325            // Reuse a host-provided sink if given (so the debug provider shares
326            // the exact Arc), else make our own.
327            let timing = GpuTimingDiag(self.gpu_timing_sink.clone().unwrap_or_default());
328            app.insert_resource(timing.clone());
329
330            if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
331                render_app.insert_resource(timing);
332                render_app
333                    .add_render_graph_node::<ViewNodeRunner<MetalFxUpscaleNode>>(
334                        Core3d,
335                        MetalFxLabel,
336                    )
337                    // Run MetalFX after Bevy's UpscalingNode — we overwrite
338                    // out_texture with the ML-upscaled result via Metal blit.
339                    .add_render_graph_edges(Core3d, (Node3d::Upscaling, MetalFxLabel));
340            }
341        }
342    }
343}
344
345use bevy::camera::MainPassResolutionOverride;
346use bevy::prelude::*;
347use bevy::render::camera::MipBias;
348
349/// Texture LOD bias to apply when rendering below native resolution.
350///
351/// MetalFX (and Bevy's bilinear fallback) render the scene at `render_scale`
352/// of the output resolution, so PBR textures are sampled at a coarser mip
353/// level than the final image warrants. Biasing sampling by `log2(scale)`
354/// (negative for `scale < 1.0`) pulls in a sharper mip, restoring texture
355/// detail the upscaler would otherwise have to hallucinate. At `scale = 0.5`
356/// this is `-1.0` — exactly one mip level sharper.
357fn mip_bias_for_scale(scale: f32) -> f32 {
358    scale.clamp(0.1, 1.0).log2()
359}
360
361#[cfg(feature = "temporal")]
362use bevy::core_pipeline::prepass::{DepthPrepass, MotionVectorPrepass};
363#[cfg(feature = "temporal")]
364use bevy::render::camera::TemporalJitter;
365
366/// Main-world resource reporting the MetalFX mode actually in effect.
367///
368/// Read this rather than assuming the mode you configured: the plugin falls
369/// back to [`MetalFxMode::Spatial`] when a requested mode is unavailable at
370/// runtime, and this resource is what says so.
371///
372/// Readable, not writable — an app cannot decide which mode is active, so the
373/// field is crate-private and reached through [`MetalFxModeResource::get`].
374#[derive(Resource, Clone, Copy)]
375pub struct MetalFxModeResource(pub(crate) MetalFxMode);
376
377impl MetalFxModeResource {
378    /// The mode the plugin settled on.
379    pub fn get(&self) -> MetalFxMode {
380        self.0
381    }
382}
383
384// --- Adaptive render scale ---
385
386/// Scale steps from lowest quality (best perf) to highest quality (worst perf).
387const SCALE_STEPS: [f32; 2] = [0.5, 0.75];
388/// Number of frames in the rolling window (~2 seconds at 60fps).
389const WINDOW_SIZE: usize = 120;
390/// P99 threshold to trigger scale-down (60fps = 16.67ms).
391const P99_SCALE_DOWN_MS: f32 = 16.67;
392/// P99 threshold to trigger scale-up (generous margin below 60fps).
393const P99_SCALE_UP_MS: f32 = 12.0;
394/// Consecutive windows over threshold before scaling down.
395const WINDOWS_TO_SCALE_DOWN: u32 = 3;
396/// Consecutive windows under threshold before scaling up.
397const WINDOWS_TO_SCALE_UP: u32 = 5;
398/// Cooldown after a scale change (seconds). 10s covers temporal scaler background creation.
399const SCALE_CHANGE_COOLDOWN: f32 = 10.0;
400/// Evaluate P99 every N frames (half the window — overlapping evaluation).
401const EVAL_CADENCE_FRAMES: u32 = 60;
402
403/// Adaptive render scale state — tracks frame times and manages scale transitions.
404#[derive(Resource)]
405pub struct AdaptiveScaleState {
406    /// Rolling buffer of recent frame times (milliseconds).
407    frame_times: [f32; WINDOW_SIZE],
408    /// Write index into `frame_times` (circular buffer).
409    write_idx: usize,
410    /// Number of valid samples (grows until buffer is full).
411    sample_count: usize,
412    /// Current scale step index into `SCALE_STEPS`.
413    current_step: usize,
414    /// Consecutive evaluation windows where P99 exceeded the scale-down threshold.
415    consecutive_over: u32,
416    /// Consecutive evaluation windows where P99 was under the scale-up threshold.
417    consecutive_under: u32,
418    /// Cooldown timer (seconds remaining). No scale changes while > 0.
419    cooldown: f32,
420    /// Frame counter for evaluation cadence.
421    frames_since_eval: u32,
422}
423
424impl AdaptiveScaleState {
425    fn new(initial_scale: f32) -> Self {
426        let current_step = SCALE_STEPS
427            .iter()
428            .enumerate()
429            .min_by(|(_, a), (_, b)| {
430                (*a - initial_scale)
431                    .abs()
432                    .total_cmp(&(*b - initial_scale).abs())
433            })
434            .map(|(i, _)| i)
435            .unwrap_or(0);
436
437        Self {
438            frame_times: [0.0; WINDOW_SIZE],
439            write_idx: 0,
440            sample_count: 0,
441            current_step,
442            consecutive_over: 0,
443            consecutive_under: 0,
444            cooldown: 0.0,
445            frames_since_eval: 0,
446        }
447    }
448}
449
450/// Insert `MainPassResolutionOverride` on all Camera3d entities at startup.
451fn apply_resolution_override(
452    mut commands: Commands,
453    cameras: Query<Entity, (With<Camera3d>, Without<MainPassResolutionOverride>)>,
454    windows: Query<&Window>,
455    scale: Res<MetalFxRenderScale>,
456) {
457    let Ok(window) = windows.single() else {
458        return;
459    };
460    let w = window.physical_width();
461    let h = window.physical_height();
462    if w == 0 || h == 0 {
463        return;
464    }
465    let override_w = (w as f32 * scale.0).round() as u32;
466    let override_h = (h as f32 * scale.0).round() as u32;
467    let mip_bias = mip_bias_for_scale(scale.0);
468
469    for entity in cameras.iter() {
470        log::info!(
471            "MetalFX: setting MainPassResolutionOverride {override_w}x{override_h} \
472             (window {w}x{h}, scale {}, mip_bias {mip_bias:.3})",
473            scale.0
474        );
475        commands.entity(entity).insert((
476            MainPassResolutionOverride(UVec2::new(override_w, override_h)),
477            MipBias(mip_bias),
478        ));
479    }
480}
481
482/// Update resolution override when the window size changes.
483fn update_resolution_on_resize(
484    mut cameras: Query<&mut MainPassResolutionOverride, With<Camera3d>>,
485    windows: Query<&Window, Changed<Window>>,
486    scale: Res<MetalFxRenderScale>,
487) {
488    let Ok(window) = windows.single() else {
489        return;
490    };
491    let w = window.physical_width();
492    let h = window.physical_height();
493    if w == 0 || h == 0 {
494        return;
495    }
496    let override_w = (w as f32 * scale.0).round() as u32;
497    let override_h = (h as f32 * scale.0).round() as u32;
498
499    for mut res_override in cameras.iter_mut() {
500        log::info!("MetalFX: resize -> MainPassResolutionOverride {override_w}x{override_h}");
501        res_override.0 = UVec2::new(override_w, override_h);
502    }
503}
504
505/// Adaptive render scale system — adjusts scale based on P99 frame time.
506fn adaptive_scale_system(
507    time: Res<Time>,
508    mut state: ResMut<AdaptiveScaleState>,
509    mut scale: ResMut<MetalFxRenderScale>,
510) {
511    // Record frame time.
512    let dt_ms = time.delta_secs() * 1000.0;
513    let idx = state.write_idx;
514    state.frame_times[idx] = dt_ms;
515    state.write_idx = (idx + 1) % WINDOW_SIZE;
516    if state.sample_count < WINDOW_SIZE {
517        state.sample_count += 1;
518    }
519
520    // Tick cooldown.
521    if state.cooldown > 0.0 {
522        state.cooldown -= time.delta_secs();
523        if state.cooldown > 0.0 {
524            state.frames_since_eval = 0;
525            return;
526        }
527        state.consecutive_over = 0;
528        state.consecutive_under = 0;
529        state.frames_since_eval = 0;
530    }
531
532    // Check evaluation cadence.
533    state.frames_since_eval += 1;
534    if state.frames_since_eval < EVAL_CADENCE_FRAMES {
535        return;
536    }
537    state.frames_since_eval = 0;
538
539    // Need enough samples.
540    if state.sample_count < WINDOW_SIZE / 2 {
541        return;
542    }
543
544    // Compute P99 from rolling window.
545    let count = state.sample_count;
546    let mut sorted = state.frame_times;
547    sorted[..count].sort_by(|a, b| a.total_cmp(b));
548    let p99_idx = ((count as f32 * 0.99) as usize).min(count - 1);
549    let p99 = sorted[p99_idx];
550
551    // Evaluate thresholds with proper hysteresis.
552    // Dead zone (P99_SCALE_UP_MS..=P99_SCALE_DOWN_MS): neither counter advances or resets.
553    // This prevents jitter near the threshold from resetting accumulated evidence.
554    if p99 > P99_SCALE_DOWN_MS {
555        state.consecutive_over += 1;
556        state.consecutive_under = 0;
557    } else if p99 < P99_SCALE_UP_MS {
558        state.consecutive_under += 1;
559        state.consecutive_over = 0;
560    }
561    // Dead zone: no action — counters hold their values.
562
563    // Scale down: move to lower step.
564    if state.consecutive_over >= WINDOWS_TO_SCALE_DOWN && state.current_step > 0 {
565        let old = SCALE_STEPS[state.current_step];
566        state.current_step -= 1;
567        let new_scale = SCALE_STEPS[state.current_step];
568        log::info!(
569            "MetalFX adaptive: scale DOWN {old} -> {new_scale} (P99={p99:.2}ms > {P99_SCALE_DOWN_MS}ms)"
570        );
571        scale.0 = new_scale;
572        state.cooldown = SCALE_CHANGE_COOLDOWN;
573        state.consecutive_over = 0;
574        state.consecutive_under = 0;
575    } else if state.consecutive_under >= WINDOWS_TO_SCALE_UP
576        && state.current_step < SCALE_STEPS.len() - 1
577    {
578        // Scale up: move to higher step.
579        let old = SCALE_STEPS[state.current_step];
580        state.current_step += 1;
581        let new_scale = SCALE_STEPS[state.current_step];
582        log::info!(
583            "MetalFX adaptive: scale UP {old} -> {new_scale} (P99={p99:.2}ms < {P99_SCALE_UP_MS}ms)"
584        );
585        scale.0 = new_scale;
586        state.cooldown = SCALE_CHANGE_COOLDOWN;
587        state.consecutive_over = 0;
588        state.consecutive_under = 0;
589    }
590}
591
592/// Update resolution override when the render scale changes (adaptive mode).
593fn update_resolution_on_scale_change(
594    mut cameras: Query<(&mut MainPassResolutionOverride, &mut MipBias), With<Camera3d>>,
595    windows: Query<&Window>,
596    scale: Res<MetalFxRenderScale>,
597) {
598    if !scale.is_changed() || scale.is_added() {
599        return;
600    }
601    let Ok(window) = windows.single() else {
602        return;
603    };
604    let w = window.physical_width();
605    let h = window.physical_height();
606    if w == 0 || h == 0 {
607        return;
608    }
609    let override_w = (w as f32 * scale.0).round() as u32;
610    let override_h = (h as f32 * scale.0).round() as u32;
611    let mip_bias = mip_bias_for_scale(scale.0);
612
613    for (mut res_override, mut bias) in cameras.iter_mut() {
614        log::info!(
615            "MetalFX: scale change -> MainPassResolutionOverride {override_w}x{override_h} \
616             (scale={}, mip_bias {mip_bias:.3})",
617            scale.0
618        );
619        res_override.0 = UVec2::new(override_w, override_h);
620        bias.0 = mip_bias;
621    }
622}
623
624/// Keep main-world MetalFxConfig.render_scale in sync with MetalFxRenderScale.
625fn sync_config_scale(scale: Res<MetalFxRenderScale>, mut config: ResMut<MetalFxConfig>) {
626    if scale.is_changed() && !scale.is_added() {
627        config.render_scale = scale.0;
628    }
629}
630
631/// Mirror the main-world frame delta into [`MetalFxFrameTiming`] for extraction.
632///
633/// Clamped to a sane interval: MetalFX divides by `deltaTime` internally, so a
634/// zero (first frame, or a paused clock) would poison the interpolation, and a
635/// multi-second hitch would extrapolate motion absurdly far. The bounds span
636/// ~1000 Hz down to ~5 Hz.
637#[cfg(target_os = "macos")]
638fn update_frame_timing(time: Res<Time>, mut timing: ResMut<MetalFxFrameTiming>) {
639    timing.delta_seconds = time.delta_secs().clamp(0.001, 0.2);
640}
641
642/// Insert prepass components and jitter on Camera3d for temporal mode.
643#[cfg(feature = "temporal")]
644fn setup_temporal_camera(
645    mut commands: Commands,
646    cameras: Query<Entity, (With<Camera3d>, Without<MotionVectorPrepass>)>,
647) {
648    for entity in cameras.iter() {
649        log::info!("MetalFX temporal: adding MotionVectorPrepass + DepthPrepass + TemporalJitter + Msaa::Off");
650        commands.entity(entity).insert((
651            MotionVectorPrepass,
652            DepthPrepass,
653            TemporalJitter::default(),
654            // Disable MSAA — MetalFX temporal provides anti-aliasing via jittered
655            // accumulation. MSAA multisampled depth textures can't be partially
656            // copied to content-sized input buffers for MetalFX.
657            bevy::render::view::Msaa::Off,
658        ));
659    }
660}
661
662/// Render graph label for the MetalFX upscale node.
663#[derive(Debug, Hash, PartialEq, Eq, Clone, bevy::render::render_graph::RenderLabel)]
664pub struct MetalFxLabel;
665
666/// Probe whether a spatial scaler can be created for the given render device.
667///
668/// Extracts the raw Metal device from Bevy's `RenderDevice`, attempts to create
669/// a spatial scaler at 800x450 → 1600x900 (Bgra8Unorm), and returns `true` on success.
670/// Returns `false` on non-macOS or if scaler creation fails.
671///
672/// Intended for integration testing — not needed at runtime (the plugin handles
673/// scaler creation internally).
674pub fn probe_spatial_scaler(_render_device: &bevy::render::renderer::RenderDevice) -> bool {
675    #[cfg(target_os = "macos")]
676    {
677        use foreign_types::ForeignType;
678        use std::ffi::c_void;
679
680        if !is_available() {
681            return false;
682        }
683
684        let wgpu_dev = _render_device.wgpu_device();
685        // SAFETY: a probe on a live render device, outside the render graph, so
686        // no encoder `as_hal_mut` can be in flight to contend for the snatch lock.
687        let Some(hal_dev) = (unsafe { wgpu_dev.as_hal::<wgpu_hal::metal::Api>() }) else {
688            return false;
689        };
690        let device_ptr = {
691            // SAFETY: read while the lock is held; scaler creation retains what
692            // it needs, and the pointer does not outlive this function.
693            let dev_lock = hal_dev.raw_device().lock();
694            dev_lock.as_ptr() as *mut c_void
695        };
696
697        let fmt = bevy::render::render_resource::TextureFormat::Bgra8Unorm;
698        let Some(color_fmt) = platform::wgpu_format_to_mtl(fmt) else {
699            return false;
700        };
701
702        let scaler = unsafe {
703            platform::try_create_spatial_scaler_from_raw(
704                device_ptr, 800, 450, 1600, 900, color_fmt, color_fmt,
705            )
706        };
707        scaler.is_some()
708    }
709    #[cfg(not(target_os = "macos"))]
710    {
711        false
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn mip_bias_matches_log2_scale() {
721        // Half-res render → one mip level sharper.
722        assert!((mip_bias_for_scale(0.5) - (-1.0)).abs() < 1e-6);
723        // Quarter-res → two levels sharper.
724        assert!((mip_bias_for_scale(0.25) - (-2.0)).abs() < 1e-6);
725        // Native res → no bias.
726        assert!((mip_bias_for_scale(1.0) - 0.0).abs() < 1e-6);
727    }
728
729    #[test]
730    fn mip_bias_clamps_degenerate_scales() {
731        // Out-of-range scales must not produce NaN/±inf bias.
732        assert!(mip_bias_for_scale(0.0).is_finite());
733        assert!(mip_bias_for_scale(-1.0).is_finite());
734        assert!((mip_bias_for_scale(2.0) - 0.0).abs() < 1e-6);
735    }
736}