#[cfg(target_os = "macos")]
mod platform;
#[cfg(target_os = "macos")]
mod node;
#[cfg(target_os = "macos")]
pub use node::{MetalFxConfig, MetalFxFrameTiming, MetalFxUpscaleNode};
#[cfg(not(target_os = "macos"))]
mod stub {
use super::MetalFxMode;
use bevy::prelude::*;
#[allow(dead_code)]
#[derive(Resource, Clone, Copy, bevy::render::extract_resource::ExtractResource)]
pub struct MetalFxConfig {
pub(crate) render_scale: f32,
pub(crate) mode: MetalFxMode,
pub(crate) dynamic_res_range: Option<(f32, f32)>,
}
#[derive(Default)]
pub struct MetalFxUpscaleNode;
}
#[cfg(not(target_os = "macos"))]
pub use stub::{MetalFxConfig, MetalFxUpscaleNode};
#[cfg(feature = "temporal")]
mod jitter;
#[cfg(target_os = "macos")]
pub mod gpu_timing;
#[cfg(target_os = "macos")]
pub use gpu_timing::{GpuTimingSink, GpuTimingStats};
#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
pub mod present;
#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
pub use present::{display_awake, PresentSink, PresentStats};
#[cfg(target_os = "macos")]
#[derive(bevy::prelude::Resource, Clone)]
pub struct GpuTimingDiag(pub std::sync::Arc<GpuTimingSink>);
pub fn is_available() -> bool {
#[cfg(target_os = "macos")]
{
platform::is_available_impl()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MetalFxMode {
#[default]
Spatial,
Temporal,
FrameInterpolation,
Disabled,
}
pub struct MetalFxPlugin {
pub render_scale: f32,
pub mode: MetalFxMode,
pub adaptive: bool,
#[cfg(target_os = "macos")]
pub gpu_timing_sink: Option<std::sync::Arc<GpuTimingSink>>,
#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
pub dual_present: Option<present::MetalFxDualPresent>,
}
impl Default for MetalFxPlugin {
fn default() -> Self {
Self {
render_scale: 0.5,
mode: MetalFxMode::Spatial,
adaptive: false,
#[cfg(target_os = "macos")]
gpu_timing_sink: None,
#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
dual_present: None,
}
}
}
#[derive(bevy::prelude::Resource, Clone, Copy)]
pub struct MetalFxRenderScale(pub f32);
impl bevy::app::Plugin for MetalFxPlugin {
fn build(&self, app: &mut bevy::app::App) {
assert!(
(0.1..=1.0).contains(&self.render_scale),
"MetalFxPlugin: render_scale must be in [0.1, 1.0], got {}",
self.render_scale
);
if !is_available() {
log::warn!("MetalFX is not available on this system — plugin disabled");
return;
}
if self.mode == MetalFxMode::Disabled {
if self.render_scale < 1.0 {
log::info!(
"MetalFX Disabled + scale={} — applying resolution override only (Bevy bilinear upscaler)",
self.render_scale
);
app.insert_resource(MetalFxRenderScale(self.render_scale));
app.add_systems(bevy::app::PostStartup, apply_resolution_override);
app.add_systems(bevy::app::Update, update_resolution_on_resize);
} else {
log::info!("MetalFX mode is Disabled at full resolution — bypassing");
}
return;
}
log::info!(
"MetalFX plugin initialized: mode={:?}, render_scale={}",
self.mode,
self.render_scale
);
app.insert_resource(MetalFxRenderScale(self.render_scale));
app.insert_resource(MetalFxModeResource(self.mode));
let dynamic_res_range = if self.adaptive {
Some((SCALE_STEPS[0], SCALE_STEPS[SCALE_STEPS.len() - 1]))
} else {
None
};
app.insert_resource(MetalFxConfig {
render_scale: self.render_scale,
mode: self.mode,
dynamic_res_range,
});
app.add_systems(bevy::app::PostStartup, apply_resolution_override);
app.add_systems(bevy::app::Update, update_resolution_on_resize);
if self.adaptive {
app.insert_resource(AdaptiveScaleState::new(self.render_scale));
app.add_systems(
bevy::app::Update,
(
adaptive_scale_system,
sync_config_scale,
update_resolution_on_scale_change,
)
.chain(),
);
} else {
app.add_systems(
bevy::app::Update,
(sync_config_scale, update_resolution_on_scale_change).chain(),
);
}
#[cfg(feature = "temporal")]
if self.mode == MetalFxMode::Temporal || self.mode == MetalFxMode::FrameInterpolation {
app.add_systems(bevy::app::PostStartup, setup_temporal_camera);
app.add_systems(bevy::app::Update, jitter::update_jitter);
}
#[cfg(not(feature = "temporal"))]
if self.mode == MetalFxMode::Temporal || self.mode == MetalFxMode::FrameInterpolation {
log::warn!(
"MetalFX: {:?} mode requested but 'temporal' feature not enabled — falling back to Spatial",
self.mode
);
app.insert_resource(MetalFxModeResource(MetalFxMode::Spatial));
}
#[cfg(target_os = "macos")]
app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
MetalFxConfig,
>::default());
#[cfg(target_os = "macos")]
if self.mode == MetalFxMode::FrameInterpolation {
app.insert_resource(MetalFxFrameTiming::default());
app.add_systems(bevy::app::Update, update_frame_timing);
app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
MetalFxFrameTiming,
>::default());
}
#[cfg(all(target_os = "macos", feature = "frame-interpolation"))]
if self.mode == MetalFxMode::FrameInterpolation {
app.insert_resource(self.dual_present.clone().unwrap_or_default());
app.add_systems(bevy::app::Update, present::capture_metal_layer);
app.add_plugins(bevy::render::extract_resource::ExtractResourcePlugin::<
present::MetalFxDualPresent,
>::default());
}
#[cfg(target_os = "macos")]
{
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy::render::render_graph::{RenderGraphExt, ViewNodeRunner};
use bevy::render::RenderApp;
let timing = GpuTimingDiag(self.gpu_timing_sink.clone().unwrap_or_default());
app.insert_resource(timing.clone());
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.insert_resource(timing);
render_app
.add_render_graph_node::<ViewNodeRunner<MetalFxUpscaleNode>>(
Core3d,
MetalFxLabel,
)
.add_render_graph_edges(Core3d, (Node3d::Upscaling, MetalFxLabel));
}
}
}
}
use bevy::camera::MainPassResolutionOverride;
use bevy::prelude::*;
use bevy::render::camera::MipBias;
fn mip_bias_for_scale(scale: f32) -> f32 {
scale.clamp(0.1, 1.0).log2()
}
#[cfg(feature = "temporal")]
use bevy::core_pipeline::prepass::{DepthPrepass, MotionVectorPrepass};
#[cfg(feature = "temporal")]
use bevy::render::camera::TemporalJitter;
#[derive(Resource, Clone, Copy)]
pub struct MetalFxModeResource(pub(crate) MetalFxMode);
impl MetalFxModeResource {
pub fn get(&self) -> MetalFxMode {
self.0
}
}
const SCALE_STEPS: [f32; 2] = [0.5, 0.75];
const WINDOW_SIZE: usize = 120;
const P99_SCALE_DOWN_MS: f32 = 16.67;
const P99_SCALE_UP_MS: f32 = 12.0;
const WINDOWS_TO_SCALE_DOWN: u32 = 3;
const WINDOWS_TO_SCALE_UP: u32 = 5;
const SCALE_CHANGE_COOLDOWN: f32 = 10.0;
const EVAL_CADENCE_FRAMES: u32 = 60;
#[derive(Resource)]
pub struct AdaptiveScaleState {
frame_times: [f32; WINDOW_SIZE],
write_idx: usize,
sample_count: usize,
current_step: usize,
consecutive_over: u32,
consecutive_under: u32,
cooldown: f32,
frames_since_eval: u32,
}
impl AdaptiveScaleState {
fn new(initial_scale: f32) -> Self {
let current_step = SCALE_STEPS
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| {
(*a - initial_scale)
.abs()
.total_cmp(&(*b - initial_scale).abs())
})
.map(|(i, _)| i)
.unwrap_or(0);
Self {
frame_times: [0.0; WINDOW_SIZE],
write_idx: 0,
sample_count: 0,
current_step,
consecutive_over: 0,
consecutive_under: 0,
cooldown: 0.0,
frames_since_eval: 0,
}
}
}
fn apply_resolution_override(
mut commands: Commands,
cameras: Query<Entity, (With<Camera3d>, Without<MainPassResolutionOverride>)>,
windows: Query<&Window>,
scale: Res<MetalFxRenderScale>,
) {
let Ok(window) = windows.single() else {
return;
};
let w = window.physical_width();
let h = window.physical_height();
if w == 0 || h == 0 {
return;
}
let override_w = (w as f32 * scale.0).round() as u32;
let override_h = (h as f32 * scale.0).round() as u32;
let mip_bias = mip_bias_for_scale(scale.0);
for entity in cameras.iter() {
log::info!(
"MetalFX: setting MainPassResolutionOverride {override_w}x{override_h} \
(window {w}x{h}, scale {}, mip_bias {mip_bias:.3})",
scale.0
);
commands.entity(entity).insert((
MainPassResolutionOverride(UVec2::new(override_w, override_h)),
MipBias(mip_bias),
));
}
}
fn update_resolution_on_resize(
mut cameras: Query<&mut MainPassResolutionOverride, With<Camera3d>>,
windows: Query<&Window, Changed<Window>>,
scale: Res<MetalFxRenderScale>,
) {
let Ok(window) = windows.single() else {
return;
};
let w = window.physical_width();
let h = window.physical_height();
if w == 0 || h == 0 {
return;
}
let override_w = (w as f32 * scale.0).round() as u32;
let override_h = (h as f32 * scale.0).round() as u32;
for mut res_override in cameras.iter_mut() {
log::info!("MetalFX: resize -> MainPassResolutionOverride {override_w}x{override_h}");
res_override.0 = UVec2::new(override_w, override_h);
}
}
fn adaptive_scale_system(
time: Res<Time>,
mut state: ResMut<AdaptiveScaleState>,
mut scale: ResMut<MetalFxRenderScale>,
) {
let dt_ms = time.delta_secs() * 1000.0;
let idx = state.write_idx;
state.frame_times[idx] = dt_ms;
state.write_idx = (idx + 1) % WINDOW_SIZE;
if state.sample_count < WINDOW_SIZE {
state.sample_count += 1;
}
if state.cooldown > 0.0 {
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
state.frames_since_eval = 0;
return;
}
state.consecutive_over = 0;
state.consecutive_under = 0;
state.frames_since_eval = 0;
}
state.frames_since_eval += 1;
if state.frames_since_eval < EVAL_CADENCE_FRAMES {
return;
}
state.frames_since_eval = 0;
if state.sample_count < WINDOW_SIZE / 2 {
return;
}
let count = state.sample_count;
let mut sorted = state.frame_times;
sorted[..count].sort_by(|a, b| a.total_cmp(b));
let p99_idx = ((count as f32 * 0.99) as usize).min(count - 1);
let p99 = sorted[p99_idx];
if p99 > P99_SCALE_DOWN_MS {
state.consecutive_over += 1;
state.consecutive_under = 0;
} else if p99 < P99_SCALE_UP_MS {
state.consecutive_under += 1;
state.consecutive_over = 0;
}
if state.consecutive_over >= WINDOWS_TO_SCALE_DOWN && state.current_step > 0 {
let old = SCALE_STEPS[state.current_step];
state.current_step -= 1;
let new_scale = SCALE_STEPS[state.current_step];
log::info!(
"MetalFX adaptive: scale DOWN {old} -> {new_scale} (P99={p99:.2}ms > {P99_SCALE_DOWN_MS}ms)"
);
scale.0 = new_scale;
state.cooldown = SCALE_CHANGE_COOLDOWN;
state.consecutive_over = 0;
state.consecutive_under = 0;
} else if state.consecutive_under >= WINDOWS_TO_SCALE_UP
&& state.current_step < SCALE_STEPS.len() - 1
{
let old = SCALE_STEPS[state.current_step];
state.current_step += 1;
let new_scale = SCALE_STEPS[state.current_step];
log::info!(
"MetalFX adaptive: scale UP {old} -> {new_scale} (P99={p99:.2}ms < {P99_SCALE_UP_MS}ms)"
);
scale.0 = new_scale;
state.cooldown = SCALE_CHANGE_COOLDOWN;
state.consecutive_over = 0;
state.consecutive_under = 0;
}
}
fn update_resolution_on_scale_change(
mut cameras: Query<(&mut MainPassResolutionOverride, &mut MipBias), With<Camera3d>>,
windows: Query<&Window>,
scale: Res<MetalFxRenderScale>,
) {
if !scale.is_changed() || scale.is_added() {
return;
}
let Ok(window) = windows.single() else {
return;
};
let w = window.physical_width();
let h = window.physical_height();
if w == 0 || h == 0 {
return;
}
let override_w = (w as f32 * scale.0).round() as u32;
let override_h = (h as f32 * scale.0).round() as u32;
let mip_bias = mip_bias_for_scale(scale.0);
for (mut res_override, mut bias) in cameras.iter_mut() {
log::info!(
"MetalFX: scale change -> MainPassResolutionOverride {override_w}x{override_h} \
(scale={}, mip_bias {mip_bias:.3})",
scale.0
);
res_override.0 = UVec2::new(override_w, override_h);
bias.0 = mip_bias;
}
}
fn sync_config_scale(scale: Res<MetalFxRenderScale>, mut config: ResMut<MetalFxConfig>) {
if scale.is_changed() && !scale.is_added() {
config.render_scale = scale.0;
}
}
#[cfg(target_os = "macos")]
fn update_frame_timing(time: Res<Time>, mut timing: ResMut<MetalFxFrameTiming>) {
timing.delta_seconds = time.delta_secs().clamp(0.001, 0.2);
}
#[cfg(feature = "temporal")]
fn setup_temporal_camera(
mut commands: Commands,
cameras: Query<Entity, (With<Camera3d>, Without<MotionVectorPrepass>)>,
) {
for entity in cameras.iter() {
log::info!("MetalFX temporal: adding MotionVectorPrepass + DepthPrepass + TemporalJitter + Msaa::Off");
commands.entity(entity).insert((
MotionVectorPrepass,
DepthPrepass,
TemporalJitter::default(),
bevy::render::view::Msaa::Off,
));
}
}
#[derive(Debug, Hash, PartialEq, Eq, Clone, bevy::render::render_graph::RenderLabel)]
pub struct MetalFxLabel;
pub fn probe_spatial_scaler(_render_device: &bevy::render::renderer::RenderDevice) -> bool {
#[cfg(target_os = "macos")]
{
use foreign_types::ForeignType;
use std::ffi::c_void;
if !is_available() {
return false;
}
let wgpu_dev = _render_device.wgpu_device();
let Some(hal_dev) = (unsafe { wgpu_dev.as_hal::<wgpu_hal::metal::Api>() }) else {
return false;
};
let device_ptr = {
let dev_lock = hal_dev.raw_device().lock();
dev_lock.as_ptr() as *mut c_void
};
let fmt = bevy::render::render_resource::TextureFormat::Bgra8Unorm;
let Some(color_fmt) = platform::wgpu_format_to_mtl(fmt) else {
return false;
};
let scaler = unsafe {
platform::try_create_spatial_scaler_from_raw(
device_ptr, 800, 450, 1600, 900, color_fmt, color_fmt,
)
};
scaler.is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mip_bias_matches_log2_scale() {
assert!((mip_bias_for_scale(0.5) - (-1.0)).abs() < 1e-6);
assert!((mip_bias_for_scale(0.25) - (-2.0)).abs() < 1e-6);
assert!((mip_bias_for_scale(1.0) - 0.0).abs() < 1e-6);
}
#[test]
fn mip_bias_clamps_degenerate_scales() {
assert!(mip_bias_for_scale(0.0).is_finite());
assert!(mip_bias_for_scale(-1.0).is_finite());
assert!((mip_bias_for_scale(2.0) - 0.0).abs() < 1e-6);
}
}