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}