A frame rate limiter for native Metal games on macOS (Apple Silicon). It injects as a dynamic library (DYLD_INSERT_LIBRARIES) to cap a game's frame rate to a configurable, live-tunable target, independent of VSync.
This is intended for fanless Apple Silicon laptops, where rendering frames beyond the display's refresh rate (or what the game needs) wastes battery and generates heat.
The limiter hooks -[CAMetalLayer nextDrawable] (the core call games use to request framebuffers) and inserts a calculated delay using mach_wait_until to pace the render loop.
Unlike simple display presentation delays, delaying nextDrawable creates back-pressure in the render pipeline. This causes the game engine to stall naturally, lowering GPU utilization and saving power.
- Zero busy-waiting: It sleeps rather than spins to conserve energy.
- Adaptive VSync: If the target frame rate is set above the display refresh rate, the library turns off the layer's VSync (
displaySyncEnabled) to minimize input latency (at the cost of screen tearing). At or below the refresh rate, the game's original VSync setting is respected.
When the game is moved to another Space or alt-tabbed, the limiter drops to FRAME_LIMIT_BG_FPS (default 10) and stops suppressing App Nap, then restores the foreground cap when you switch back.
To build the library:
make buildThis compiles:
build/frame_limiter.dylib(ad-hoc signed for Apple Silicon compatibility)build/minimal_metal_app(a test harness)
Steam on macOS cannot pass environment variables through launch options, and using %command% wrapper scripts fails with an OS execution error (Valve issue #5548).
To get around this, the install-lsenv.sh script replaces the game's executable with a compiled C wrapper that sets the required environment variables before launching the real game (renamed to Executable.real).
By compiling a binary wrapper and copying the original game's entitlements, macOS still recognizes the app identity, ensuring Game Mode and fullscreen compositor bypass optimizations remain active.
# Install (replaces the game executable with the C wrapper; defaults to 80 fps if FPS is omitted)
./scripts/install-lsenv.sh install "/path/to/Game.app" 80
# Uninstall (restores the original executable and plist backups)
./scripts/install-lsenv.sh uninstall "/path/to/Game.app"Note: Game updates through Steam or the App Store will overwrite the wrapper executable. You will need to re-run the install script after any game update.
For standalone apps launched outside of Steam, you can run them directly from the terminal with the environment variables pre-set:
DYLD_INSERT_LIBRARIES=/Users/aatricks/Documents/Dev/FrameLimiter/build/frame_limiter.dylib \
FRAME_LIMIT_FPS=80 \
"/path/to/Game.app/Contents/MacOS/Game"The frame rate cap can be changed instantly on the fly without restarting the game. However, toggling the Metal HUD requires restarting the game, as macOS only checks the HUD environment variable at startup.
Use the flctl tool to control these settings (saved to ~/.framelimiter.fps and ~/.framelimiter.hud respectively):
./scripts/flctl 30 # Cap to 30 fps (takes effect immediately)
./scripts/flctl off # Disable the cap (takes effect immediately)
./scripts/flctl on # Restore the last active cap (takes effect immediately)
./scripts/flctl toggle # Toggle the cap on/off (takes effect immediately)
./scripts/flctl hud off # Hide the Metal HUD overlay (requires game restart)
./scripts/flctl hud on # Show the Metal HUD overlay (requires game restart)
./scripts/flctl status # Show current status (fps cap and HUD state)Or write to the files directly:
# Set target frame rate
echo 30 > ~/.framelimiter.fps
# Show/hide Metal HUD
echo 0 > ~/.framelimiter.hud # Hide
echo 1 > ~/.framelimiter.hud # Show
# Background cap applied while the game is occluded / on another Space (requires game restart;
# read once at launch). 0 disables background throttling.
echo 10 > ~/.framelimiter.bgfps # Cap to 10 fps when not visible (default)
echo 0 > ~/.framelimiter.bgfps # Don't throttle when backgroundedYou can bind flctl to system-wide shortcuts. For example, in Hammerspoon:
local fl = "/Users/aatricks/Documents/Dev/FrameLimiter/scripts/flctl"
hs.hotkey.bind({"cmd","alt"}, "L", function() hs.execute(fl.." toggle", true) end)
hs.hotkey.bind({"cmd","alt"}, "[", function() hs.execute(fl.." 30", true) end)
hs.hotkey.bind({"cmd","alt"}, "]", function() hs.execute(fl.." 80", true) end)On fixed 60Hz displays (like most fanless MacBooks):
- Below 60 FPS: Stick to integer divisors of 60 (30, 20, or 15 fps) to prevent judder. Frame rates like 40 or 45 will stutter because frames won't line up with the display's refresh cycles.
- At 60 FPS: Matches the display refresh while saving power.
- Above 60 FPS (e.g. 80): Lowers input latency, but requires VSync to be disabled (handled automatically) which causes tearing.
Configure behavior by setting these before launching:
| Variable | Default | Description |
|---|---|---|
FRAME_LIMIT_FPS |
unset | Target FPS. Unset or 0 disables the limiter. |
FRAME_LIMIT_FILE |
$TMPDIR/framelimiter.fps |
Control file to watch for runtime changes. |
FRAME_LIMIT_REFRESH |
60 |
Screen refresh rate (Hz) for VSync switching. |
FRAME_LIMIT_BG_FPS |
10 |
FPS cap applied while the game is occluded / on another Space / not the active app. 0 disables background throttling entirely. While backgrounded the limiter also releases its App Nap assertion so macOS can throttle the process. |
FRAME_LIMIT_LOG |
0 |
1 to log periodic FPS; 2 for per-frame timing details. |
FRAME_LIMIT_SIGNALS |
0 |
Set 1 to enable SIGUSR1/SIGUSR2 target stepping (+/- 5 fps). |
FRAME_LIMIT_QOS |
1 |
Forces user-interactive QoS on the render thread. |
FRAME_LIMIT_NONAP |
1 |
Disables macOS App Nap throttling for the game process. |
MTL_HUD_ENABLED |
1 |
macOS native Metal HUD overlay toggle. Set to 0 to hide it. |
If a game runs with a Hardened Runtime or enforces Library Validation, macOS will ignore DYLD_INSERT_LIBRARIES.
Check the game's executable:
./scripts/check-target.sh "/path/to/Game.app/Contents/MacOS/Game"If it has a hardened runtime, you can re-sign it locally with validation disabled:
- Extract current entitlements:
codesign -d --entitlements ents.plist "/path/to/Game.app/Contents/MacOS/Game" - Add these keys to
ents.plist:<key>com.apple.security.cs.disable-library-validation</key> <true/> <key>com.apple.security.cs.allow-dyld-environment-variables</key> <true/>
- Re-sign the app:
codesign -f -s - --options runtime --entitlements ents.plist "/path/to/Game.app" xattr -dr com.apple.quarantine "/path/to/Game.app"
- Anti-Cheat: Do not use on games with active anti-cheat (Easy Anti-Cheat, BattlEye, VAC). Dylib injection and re-signing will trigger bans.
- Translation Layers: Does not support games running via Wine, CrossOver, Whisky, or GPTK.
- Metal Only: Requires the game to render via
CAMetalLayer. OpenGL games are not supported.
Verify the limiter using the minimal test app:
# Headless test capped at 30 fps
make run-minimal FPS=30
# Windowed test capped at 80 fps with logging
WINDOWED=1 FRAME_LIMIT_LOG=1 ./scripts/run-minimal.sh 80Keep the test window active for accurate timing; macOS throttles background processes.
MIT