From 645057f53880d86f806819fb23ceca39f2494da4 Mon Sep 17 00:00:00 2001 From: Sharad Mayank Sinha Date: Tue, 3 Nov 2026 01:52:39 +0530 Subject: [PATCH 1/3] Add Metal frame pacing and FPS limiter plugin Meters frame acquisition and presentation duration on CAMetalLayer to deliver flat frametimes aligned with display vblanks. Includes universal dylib build rules and an example descriptor slider. --- Makefile | 25 +++- examples/README.md | 1 + examples/plugins/40-fpslimit.conf | 26 ++++ plugins/fpslimit/Makefile | 21 +++ plugins/fpslimit/fpslimit.m | 232 ++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 examples/plugins/40-fpslimit.conf create mode 100644 plugins/fpslimit/Makefile create mode 100644 plugins/fpslimit/fpslimit.m diff --git a/Makefile b/Makefile index 128769d1..14c9b817 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ DEPS := $(OBJS:.o=.d) anchorcheck callscheck sigdb-fixtures webpatch-fixtures peicon-fixtures ceg-check panel-behavior app-tests \ featcheck featstagecheck runnervariantcheck conf-fixtures confcheck \ tests-list overlay-shim overlay-shim-install overlay-shim-tests \ - overlay-shim-bench iconmaker icon \ + overlay-shim-bench fpslimit fpslimit-install iconmaker icon \ appinfo helpers-install ntdll-resolve bridge runcheck compatcheck envpasscheck \ compatsvc-check prefixstate-check defaults-check compatpage-check defaults-drift shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck bundlecheck launch-shell activatecheck FORCE @@ -594,6 +594,29 @@ overlay-shim-bench: printf ' %-8s ' "$$a"; $(OUT_DIR)/passbench.$$a || exit 1; \ done +FPSLIMIT_SRC := plugins/fpslimit/fpslimit.m +FPSLIMIT_ARM := $(OUT_DIR)/libfpslimit.arm64.dylib +FPSLIMIT_X86 := $(OUT_DIR)/libfpslimit.x86_64.dylib +FPSLIMIT := $(OUT_DIR)/libfpslimit.dylib +FPSLIMIT_FLAGS := -mmacosx-version-min=$(MIN_VER) $(AVAIL) -dynamiclib -O2 -Wall -Wextra \ + -install_name @rpath/libfpslimit.dylib \ + -framework Metal -framework QuartzCore \ + -framework CoreFoundation -framework Foundation + +fpslimit: $(FPSLIMIT) + +$(FPSLIMIT): $(FPSLIMIT_SRC) + @mkdir -p $(OUT_DIR) + $(CC) -arch arm64 $(FPSLIMIT_FLAGS) -o $(FPSLIMIT_ARM) $< + $(CC) -arch x86_64 $(FPSLIMIT_FLAGS) -o $(FPSLIMIT_X86) $< + lipo -create $(FPSLIMIT_ARM) $(FPSLIMIT_X86) -output $@ + codesign -fs - $@ + @lipo -info $@ + +fpslimit-install: $(FPSLIMIT) + $(call install_atomically,$(FPSLIMIT),$(SUPPORT_DIR)/libfpslimit.dylib) + @echo "==> Installed: $(SUPPORT_DIR)/libfpslimit.dylib" + ICONMAKER := $(OUT_DIR)/iconmaker iconmaker: $(ICONMAKER) diff --git a/examples/README.md b/examples/README.md index 46b70288..5b112513 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ and NotProton never writes to them. | `plugins/10-framescope.conf` | a feature and its seven settings in one file: a toggle that injects a dylib, a dropdown, a slider over a range, a slider that snaps to values with labelled ticks, a sub-option of a sub-option, and lines that appear only while the row is on | | `plugins/20-renderdoc.conf` | a toggle with one sub-option, writing `0` when it is switched off rather than clearing the launch option | | `plugins/30-kosmickrisp.conf` | one toggle that both injects a dylib and sets two environment variables | +| `plugins/40-fpslimit.conf` | a snapping slider that meters frame submissions to display refresh intervals with an optional VSync toggle | | `variants/dxmt-example.conf` | a runner built from a CrossOver copy with your own files over it | These are the real descriptors behind three tools, not invented ones, so `requires` points diff --git a/examples/plugins/40-fpslimit.conf b/examples/plugins/40-fpslimit.conf new file mode 100644 index 00000000..95ebe6c9 --- /dev/null +++ b/examples/plugins/40-fpslimit.conf @@ -0,0 +1,26 @@ +# Frame pacing for Metal backends (D3DMetal, DXMT, DXVK, WineD3D). +# Injects universal libfpslimit to meter frame submissions and pace presentation to display vblanks. +[NOTPROTON_FPS_LIMIT] +label = FPS Limiter +description = Hardware frame pacing for Metal (flat frametimes). +section = dev +type = slider +inject = 1 +requires = Library/Application Support/notproton/libfpslimit.dylib +requires = out/libfpslimit.dylib +default = off +choice = off = Off +choice = 30 = 30 FPS +choice = 40 = 40 FPS +choice = 60 = 60 FPS +choice = 120 = 120 FPS +choice = 144 = 144 FPS +description_on = Frames are paced with sub-microsecond precision and synchronized to display refresh intervals. + +# Optional sub-option: disable VSync quantization +[NOTPROTON_ENV_FPS_NO_VSYNC] +label = FPS Limiter: disable VSync +description = Presents frames immediately on GPU finish rather than synchronizing to display refresh ticks. +depends = NOTPROTON_FPS_LIMIT +on = 1 +off = 0 diff --git a/plugins/fpslimit/Makefile b/plugins/fpslimit/Makefile new file mode 100644 index 00000000..ab3a9482 --- /dev/null +++ b/plugins/fpslimit/Makefile @@ -0,0 +1,21 @@ +CC ?= clang +MIN_VER ?= 15.0 +OUT ?= ../../out/libfpslimit.dylib + +all: $(OUT) + +$(OUT): fpslimit.m + @mkdir -p $(dir $(OUT)) + $(CC) -arch arm64 -arch x86_64 \ + -mmacosx-version-min=$(MIN_VER) \ + -dynamiclib -O2 -Wall -Wextra \ + -framework Metal -framework QuartzCore -framework Foundation \ + -install_name @rpath/libfpslimit.dylib \ + -o $(OUT) fpslimit.m + codesign -fs - $(OUT) + @echo "==> Built universal $(OUT)" + +clean: + rm -f $(OUT) + +.PHONY: all clean diff --git a/plugins/fpslimit/fpslimit.m b/plugins/fpslimit/fpslimit.m new file mode 100644 index 00000000..d27e367e --- /dev/null +++ b/plugins/fpslimit/fpslimit.m @@ -0,0 +1,232 @@ +#import +#import +#import +#import +#import +#import +#import +#import +#import + +#if defined(__arm64__) || defined(__aarch64__) + #define CPU_YIELD() __builtin_arm_yield() +#else + #include + #define CPU_YIELD() _mm_pause() +#endif + +typedef struct { + uint64_t next_target; + uint64_t interval_ticks; + uint64_t spin_margin_ticks; + uint64_t tick_rem; + uint64_t rem_accum; + uint64_t fps; + os_unfair_lock lock; + bool initialized; +} layer_pace_state_t; + +@interface NPLayerPaceWrapper : NSObject { +@public + layer_pace_state_t state; +} +@end + +@implementation NPLayerPaceWrapper +@end + +static const char kPaceKey = 0; +static id (*orig_nextDrawable)(id, SEL) = NULL; +static void (*orig_present)(id, SEL) = NULL; + +static uint64_t g_fps = 0; +static uint64_t g_interval_ticks = 0; +static uint64_t g_tick_rem = 0; +static uint64_t g_spin_margin_ticks = 0; +static CFTimeInterval g_target_duration = 0.0; +static bool g_disable_vsync = false; +static mach_timebase_info_data_t g_timebase; + +static inline void pace_frame(layer_pace_state_t *st) { + static _Thread_local bool s_qos_set = false; + if (!s_qos_set) { + pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); + s_qos_set = true; + } + + os_unfair_lock_lock(&st->lock); + + uint64_t now = mach_absolute_time(); + if (!st->initialized) { + st->next_target = now; + st->initialized = true; + os_unfair_lock_unlock(&st->lock); + return; + } + + // Exact Bresenham clock remainder carryover + st->rem_accum += st->tick_rem; + uint64_t extra = st->rem_accum / st->fps; + st->rem_accum %= st->fps; + + uint64_t target = st->next_target + st->interval_ticks + extra; + + // Only reset cadence if missed by more than a full frame (true lag spike / window pause) + if (now >= target + st->interval_ticks) { + st->next_target = now; + os_unfair_lock_unlock(&st->lock); + return; + } + + st->next_target = target; + os_unfair_lock_unlock(&st->lock); + + // Hybrid sleep: kernel wait until spin margin, then spin-yield for microsecond precision + if (now < target) { + if (target > now + st->spin_margin_ticks) { + mach_wait_until(target - st->spin_margin_ticks); + } + + while (mach_absolute_time() < target) { + CPU_YIELD(); + } + } +} + +static void hook_present(id self, SEL _cmd) { + static _Thread_local bool s_in_present = false; + if (s_in_present) { + if (orig_present) orig_present(self, _cmd); + return; + } + + s_in_present = true; + if (g_target_duration > 0.0) { + [(id)self presentAfterMinimumDuration:g_target_duration]; + s_in_present = false; + return; + } + + if (orig_present) { + orig_present(self, _cmd); + } + s_in_present = false; +} + +static void ensure_drawable_swizzled(Class d_cls) { + static os_unfair_lock swizzle_lock = OS_UNFAIR_LOCK_INIT; + static Class swizzled_class = nil; + if (!d_cls || swizzled_class == d_cls) return; + + os_unfair_lock_lock(&swizzle_lock); + if (swizzled_class != d_cls) { + Method m = class_getInstanceMethod(d_cls, sel_registerName("present")); + if (m) { + orig_present = (void (*)(id, SEL))method_getImplementation(m); + method_setImplementation(m, (IMP)hook_present); + fprintf(stderr, "[notproton-fpslimit] Swizzled [%s present] -> presentAfterMinimumDuration (%.4fs)\n", + class_getName(d_cls), g_target_duration); + } + swizzled_class = d_cls; + } + os_unfair_lock_unlock(&swizzle_lock); +} + +static id hook_nextDrawable(id self, SEL _cmd) { + if (g_disable_vsync) { + CAMetalLayer *layer = (CAMetalLayer *)self; + if (layer.displaySyncEnabled) { + layer.displaySyncEnabled = NO; + } + } + + id drawable = orig_nextDrawable(self, _cmd); + + if (!drawable || g_fps == 0) { + return drawable; + } + + ensure_drawable_swizzled(object_getClass(drawable)); + + NPLayerPaceWrapper *wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(self, &kPaceKey); + if (!wrap) { + wrap = [[NPLayerPaceWrapper alloc] init]; + wrap->state.fps = g_fps; + wrap->state.interval_ticks = g_interval_ticks; + wrap->state.tick_rem = g_tick_rem; + wrap->state.spin_margin_ticks = g_spin_margin_ticks; + wrap->state.lock = OS_UNFAIR_LOCK_INIT; + wrap->state.initialized = false; + objc_setAssociatedObject(self, &kPaceKey, wrap, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [wrap release]; + } + + pace_frame(&wrap->state); + return drawable; +} + +__attribute__((constructor)) +static void np_fpslimit_init(void) { + const char *fps_env = getenv("NOTPROTON_FPS_LIMIT"); + if (!fps_env || !*fps_env || atof(fps_env) <= 1.0) { + fps_env = getenv("NOTPROTON_ENV_FPS_LIMIT"); + } + if (!fps_env || !*fps_env || atof(fps_env) <= 1.0) { + fps_env = getenv("FPS_LIMIT"); + } + if (!fps_env || !*fps_env) { + return; + } + + double fps_val = atof(fps_env); + if (fps_val <= 0.0) { + return; + } + + g_fps = (uint64_t)(fps_val + 0.5); + if (g_fps == 0) return; + + // Safety margin of 2ms prevents clock jitter from missing a vblank multiple + CFTimeInterval base_duration = 1.0 / (double)g_fps; + CFTimeInterval margin = 0.002; + g_target_duration = (base_duration > margin + 0.002) ? (base_duration - margin) : base_duration; + + const char *no_vsync_env = getenv("NOTPROTON_FPS_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_ENV_FPS_NO_VSYNC"); + if (!no_vsync_env) no_vsync_env = getenv("FPS_NO_VSYNC"); + if (no_vsync_env && (strcmp(no_vsync_env, "1") == 0 || strcmp(no_vsync_env, "true") == 0)) { + g_disable_vsync = true; + } + const char *vsync_off = getenv("NOTPROTON_FPS_VSYNC"); + if (!vsync_off) vsync_off = getenv("FPS_VSYNC"); + if (vsync_off && strcmp(vsync_off, "0") == 0) { + g_disable_vsync = true; + } + + mach_timebase_info(&g_timebase); + + uint64_t ticks_per_sec = (1000000000ULL * (uint64_t)g_timebase.denom) / (uint64_t)g_timebase.numer; + g_interval_ticks = ticks_per_sec / g_fps; + g_tick_rem = ticks_per_sec % g_fps; + + // 0.75ms spin margin in Mach ticks for extra stability + g_spin_margin_ticks = (750000ULL * (uint64_t)g_timebase.denom) / (uint64_t)g_timebase.numer; + + Class layer_cls = objc_getClass("CAMetalLayer"); + if (layer_cls) { + Method m = class_getInstanceMethod(layer_cls, sel_registerName("nextDrawable")); + if (m) { + orig_nextDrawable = (id(*)(id, SEL))method_getImplementation(m); + method_setImplementation(m, (IMP)hook_nextDrawable); + } + } + + Class d_cls = objc_getClass("CAMetalDrawable"); + if (d_cls) { + ensure_drawable_swizzled(d_cls); + } + + fprintf(stderr, "[notproton-fpslimit] Active: target %llu FPS (cadence: %.4fs, vsync: %s)\n", + g_fps, g_target_duration, g_disable_vsync ? "disabled" : "enabled"); +} From c107149a0a7b2711ca4bb67c0410e6f818b133b1 Mon Sep 17 00:00:00 2001 From: Sharad Mayank Sinha Date: Thu, 5 Nov 2026 12:22:31 +0530 Subject: [PATCH 2/3] fpslimit: remove present swizzle, eliminate frame debt, and pace before acquire --- plugins/fpslimit/fpslimit.m | 121 ++++++++++++++---------------------- 1 file changed, 46 insertions(+), 75 deletions(-) diff --git a/plugins/fpslimit/fpslimit.m b/plugins/fpslimit/fpslimit.m index d27e367e..6153bcef 100644 --- a/plugins/fpslimit/fpslimit.m +++ b/plugins/fpslimit/fpslimit.m @@ -36,14 +36,13 @@ @implementation NPLayerPaceWrapper @end static const char kPaceKey = 0; +static os_unfair_lock g_wrap_lock = OS_UNFAIR_LOCK_INIT; static id (*orig_nextDrawable)(id, SEL) = NULL; -static void (*orig_present)(id, SEL) = NULL; static uint64_t g_fps = 0; static uint64_t g_interval_ticks = 0; static uint64_t g_tick_rem = 0; static uint64_t g_spin_margin_ticks = 0; -static CFTimeInterval g_target_duration = 0.0; static bool g_disable_vsync = false; static mach_timebase_info_data_t g_timebase; @@ -71,8 +70,8 @@ static inline void pace_frame(layer_pace_state_t *st) { uint64_t target = st->next_target + st->interval_ticks + extra; - // Only reset cadence if missed by more than a full frame (true lag spike / window pause) - if (now >= target + st->interval_ticks) { + // Zero debt carryover: if a frame took longer than interval, drop debt immediately + if (now >= target) { st->next_target = now; os_unfair_lock_unlock(&st->lock); return; @@ -82,54 +81,36 @@ static inline void pace_frame(layer_pace_state_t *st) { os_unfair_lock_unlock(&st->lock); // Hybrid sleep: kernel wait until spin margin, then spin-yield for microsecond precision - if (now < target) { - if (target > now + st->spin_margin_ticks) { - mach_wait_until(target - st->spin_margin_ticks); - } - - while (mach_absolute_time() < target) { - CPU_YIELD(); - } + if (target > now + st->spin_margin_ticks) { + mach_wait_until(target - st->spin_margin_ticks); } -} -static void hook_present(id self, SEL _cmd) { - static _Thread_local bool s_in_present = false; - if (s_in_present) { - if (orig_present) orig_present(self, _cmd); - return; + while (mach_absolute_time() < target) { + CPU_YIELD(); } - - s_in_present = true; - if (g_target_duration > 0.0) { - [(id)self presentAfterMinimumDuration:g_target_duration]; - s_in_present = false; - return; - } - - if (orig_present) { - orig_present(self, _cmd); - } - s_in_present = false; } -static void ensure_drawable_swizzled(Class d_cls) { - static os_unfair_lock swizzle_lock = OS_UNFAIR_LOCK_INIT; - static Class swizzled_class = nil; - if (!d_cls || swizzled_class == d_cls) return; +static inline NPLayerPaceWrapper *get_or_create_wrap(id layer) { + NPLayerPaceWrapper *wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(layer, &kPaceKey); + if (__builtin_expect(wrap != nil, 1)) { + return wrap; + } - os_unfair_lock_lock(&swizzle_lock); - if (swizzled_class != d_cls) { - Method m = class_getInstanceMethod(d_cls, sel_registerName("present")); - if (m) { - orig_present = (void (*)(id, SEL))method_getImplementation(m); - method_setImplementation(m, (IMP)hook_present); - fprintf(stderr, "[notproton-fpslimit] Swizzled [%s present] -> presentAfterMinimumDuration (%.4fs)\n", - class_getName(d_cls), g_target_duration); - } - swizzled_class = d_cls; + os_unfair_lock_lock(&g_wrap_lock); + wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(layer, &kPaceKey); + if (!wrap) { + wrap = [[NPLayerPaceWrapper alloc] init]; + wrap->state.fps = g_fps; + wrap->state.interval_ticks = g_interval_ticks; + wrap->state.tick_rem = g_tick_rem; + wrap->state.spin_margin_ticks = g_spin_margin_ticks; + wrap->state.lock = OS_UNFAIR_LOCK_INIT; + wrap->state.initialized = false; + objc_setAssociatedObject(layer, &kPaceKey, wrap, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [wrap release]; } - os_unfair_lock_unlock(&swizzle_lock); + os_unfair_lock_unlock(&g_wrap_lock); + return wrap; } static id hook_nextDrawable(id self, SEL _cmd) { @@ -140,28 +121,28 @@ static void ensure_drawable_swizzled(Class d_cls) { } } - id drawable = orig_nextDrawable(self, _cmd); - - if (!drawable || g_fps == 0) { - return drawable; + if (g_fps == 0) { + return orig_nextDrawable(self, _cmd); } - ensure_drawable_swizzled(object_getClass(drawable)); + NPLayerPaceWrapper *wrap = get_or_create_wrap(self); - NPLayerPaceWrapper *wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(self, &kPaceKey); - if (!wrap) { - wrap = [[NPLayerPaceWrapper alloc] init]; - wrap->state.fps = g_fps; - wrap->state.interval_ticks = g_interval_ticks; - wrap->state.tick_rem = g_tick_rem; - wrap->state.spin_margin_ticks = g_spin_margin_ticks; - wrap->state.lock = OS_UNFAIR_LOCK_INIT; - wrap->state.initialized = false; - objc_setAssociatedObject(self, &kPaceKey, wrap, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [wrap release]; + // 1. Pace CPU BEFORE driver request + pace_frame(&wrap->state); + + // 2. Acquire drawable from Metal swapchain + id drawable = orig_nextDrawable(self, _cmd); + + // 3. Only resync if driver stalled by a real margin (> spin_margin_ticks) + if (drawable) { + uint64_t post_acquire = mach_absolute_time(); + os_unfair_lock_lock(&wrap->state.lock); + if (post_acquire > wrap->state.next_target + wrap->state.spin_margin_ticks) { + wrap->state.next_target = post_acquire; + } + os_unfair_lock_unlock(&wrap->state.lock); } - pace_frame(&wrap->state); return drawable; } @@ -186,11 +167,6 @@ static void np_fpslimit_init(void) { g_fps = (uint64_t)(fps_val + 0.5); if (g_fps == 0) return; - // Safety margin of 2ms prevents clock jitter from missing a vblank multiple - CFTimeInterval base_duration = 1.0 / (double)g_fps; - CFTimeInterval margin = 0.002; - g_target_duration = (base_duration > margin + 0.002) ? (base_duration - margin) : base_duration; - const char *no_vsync_env = getenv("NOTPROTON_FPS_NO_VSYNC"); if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_NO_VSYNC"); if (!no_vsync_env) no_vsync_env = getenv("NOTPROTON_ENV_FPS_NO_VSYNC"); @@ -210,7 +186,7 @@ static void np_fpslimit_init(void) { g_interval_ticks = ticks_per_sec / g_fps; g_tick_rem = ticks_per_sec % g_fps; - // 0.75ms spin margin in Mach ticks for extra stability + // 0.75ms spin margin in Mach ticks g_spin_margin_ticks = (750000ULL * (uint64_t)g_timebase.denom) / (uint64_t)g_timebase.numer; Class layer_cls = objc_getClass("CAMetalLayer"); @@ -222,11 +198,6 @@ static void np_fpslimit_init(void) { } } - Class d_cls = objc_getClass("CAMetalDrawable"); - if (d_cls) { - ensure_drawable_swizzled(d_cls); - } - - fprintf(stderr, "[notproton-fpslimit] Active: target %llu FPS (cadence: %.4fs, vsync: %s)\n", - g_fps, g_target_duration, g_disable_vsync ? "disabled" : "enabled"); + fprintf(stderr, "[notproton-fpslimit] Active: target %llu FPS (vsync: %s)\n", + g_fps, g_disable_vsync ? "disabled" : "enabled"); } From ee4070d774ab1f514710b3cc083582f4cff51303 Mon Sep 17 00:00:00 2001 From: Sharad Mayank Sinha Date: Thu, 5 Nov 2026 21:14:56 +0530 Subject: [PATCH 3/3] fpslimit: adopt work-compensated pacing to prevent late-game stalls --- plugins/fpslimit/fpslimit.m | 108 +++++++++++++----------------------- 1 file changed, 38 insertions(+), 70 deletions(-) diff --git a/plugins/fpslimit/fpslimit.m b/plugins/fpslimit/fpslimit.m index 6153bcef..7ad610c9 100644 --- a/plugins/fpslimit/fpslimit.m +++ b/plugins/fpslimit/fpslimit.m @@ -8,17 +8,9 @@ #import #import -#if defined(__arm64__) || defined(__aarch64__) - #define CPU_YIELD() __builtin_arm_yield() -#else - #include - #define CPU_YIELD() _mm_pause() -#endif - typedef struct { - uint64_t next_target; + uint64_t last_frame_time; uint64_t interval_ticks; - uint64_t spin_margin_ticks; uint64_t tick_rem; uint64_t rem_accum; uint64_t fps; @@ -42,54 +34,9 @@ @implementation NPLayerPaceWrapper static uint64_t g_fps = 0; static uint64_t g_interval_ticks = 0; static uint64_t g_tick_rem = 0; -static uint64_t g_spin_margin_ticks = 0; static bool g_disable_vsync = false; static mach_timebase_info_data_t g_timebase; -static inline void pace_frame(layer_pace_state_t *st) { - static _Thread_local bool s_qos_set = false; - if (!s_qos_set) { - pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); - s_qos_set = true; - } - - os_unfair_lock_lock(&st->lock); - - uint64_t now = mach_absolute_time(); - if (!st->initialized) { - st->next_target = now; - st->initialized = true; - os_unfair_lock_unlock(&st->lock); - return; - } - - // Exact Bresenham clock remainder carryover - st->rem_accum += st->tick_rem; - uint64_t extra = st->rem_accum / st->fps; - st->rem_accum %= st->fps; - - uint64_t target = st->next_target + st->interval_ticks + extra; - - // Zero debt carryover: if a frame took longer than interval, drop debt immediately - if (now >= target) { - st->next_target = now; - os_unfair_lock_unlock(&st->lock); - return; - } - - st->next_target = target; - os_unfair_lock_unlock(&st->lock); - - // Hybrid sleep: kernel wait until spin margin, then spin-yield for microsecond precision - if (target > now + st->spin_margin_ticks) { - mach_wait_until(target - st->spin_margin_ticks); - } - - while (mach_absolute_time() < target) { - CPU_YIELD(); - } -} - static inline NPLayerPaceWrapper *get_or_create_wrap(id layer) { NPLayerPaceWrapper *wrap = (NPLayerPaceWrapper *)objc_getAssociatedObject(layer, &kPaceKey); if (__builtin_expect(wrap != nil, 1)) { @@ -103,7 +50,7 @@ static inline void pace_frame(layer_pace_state_t *st) { wrap->state.fps = g_fps; wrap->state.interval_ticks = g_interval_ticks; wrap->state.tick_rem = g_tick_rem; - wrap->state.spin_margin_ticks = g_spin_margin_ticks; + wrap->state.rem_accum = 0; wrap->state.lock = OS_UNFAIR_LOCK_INIT; wrap->state.initialized = false; objc_setAssociatedObject(layer, &kPaceKey, wrap, OBJC_ASSOCIATION_RETAIN_NONATOMIC); @@ -125,24 +72,48 @@ static inline void pace_frame(layer_pace_state_t *st) { return orig_nextDrawable(self, _cmd); } + // 1. Acquire the drawable from Metal FIRST. + // Any swapchain or GPU wait time happens here and is absorbed as work. + id drawable = orig_nextDrawable(self, _cmd); + if (!drawable) { + return nil; + } + NPLayerPaceWrapper *wrap = get_or_create_wrap(self); + layer_pace_state_t *st = &wrap->state; - // 1. Pace CPU BEFORE driver request - pace_frame(&wrap->state); + os_unfair_lock_lock(&st->lock); - // 2. Acquire drawable from Metal swapchain - id drawable = orig_nextDrawable(self, _cmd); + uint64_t now = mach_absolute_time(); + if (!st->initialized) { + st->last_frame_time = now; + st->initialized = true; + os_unfair_lock_unlock(&st->lock); + return drawable; + } - // 3. Only resync if driver stalled by a real margin (> spin_margin_ticks) - if (drawable) { - uint64_t post_acquire = mach_absolute_time(); - os_unfair_lock_lock(&wrap->state.lock); - if (post_acquire > wrap->state.next_target + wrap->state.spin_margin_ticks) { - wrap->state.next_target = post_acquire; - } - os_unfair_lock_unlock(&wrap->state.lock); + // Fractional Bresenham remainder carryover + st->rem_accum += st->tick_rem; + uint64_t extra = st->rem_accum / st->fps; + st->rem_accum %= st->fps; + + uint64_t target = st->last_frame_time + st->interval_ticks + extra; + + // Work-Compensated Check: + // If the GPU/driver already took longer than the frame interval (now >= target), + // DO NOT SLEEP. Return immediately so the pipeline never cascades into hitches. + if (now >= target) { + st->last_frame_time = now; + os_unfair_lock_unlock(&st->lock); + return drawable; } + st->last_frame_time = target; + os_unfair_lock_unlock(&st->lock); + + // Sleep only the remaining headroom without burning CPU cores in a spin loop + mach_wait_until(target); + return drawable; } @@ -186,9 +157,6 @@ static void np_fpslimit_init(void) { g_interval_ticks = ticks_per_sec / g_fps; g_tick_rem = ticks_per_sec % g_fps; - // 0.75ms spin margin in Mach ticks - g_spin_margin_ticks = (750000ULL * (uint64_t)g_timebase.denom) / (uint64_t)g_timebase.numer; - Class layer_cls = objc_getClass("CAMetalLayer"); if (layer_cls) { Method m = class_getInstanceMethod(layer_cls, sel_registerName("nextDrawable"));