I’ve been playing some turn-based RPGs recently, and it occurred to me that running them at max framerate is unnecessarily wasteful. Half the time is probably spent with a fixed viewport reading text. There’s no need to maximize animation smoothness in static scenes, but lowering the FPS limit permanently would sacrifice fluidity when panning the camera and so on.
This problem was solved in Radeon Chill, but that was a proprietary Radeon- and Windows-only feature. I decided I’m going to create a cross-vendor solution for Linux.
MangoChill dynamically lowers the FPS limit during idle periods and ramps it back up on input.
Architecture
MangoChill consists of two components: the system daemon and the userspace client. Reading directly from input devices requires privileged access but works the same on X11 and Wayland. The server never interprets the raw input events; it captures only the timestamps for each.
The userspace client can subscribe to a stream of updates from the server with pre-calculated FPS limit values as of that point in time. It then relays that information to a MangoHud instance over the control socket to apply the limit in the application. This works for every app & graphics API that MangoHud supports, which should be all of them: OpenGL, Vulkan, and everything that layers over Vulkan. It requires a modified MangoHud to expose setting the FPS limit over the control socket. I’ll submit a pull request to land it upstream.
struct input_event reports kernel-provided timestamps of each event.
MangoChill requests them to be sent in the CLOCK_MONOTONIC domain by issuing an EVIOCSCLOCKID ioctl, and the rest of the pipeline uses the same domain.
This keeps everything in one monotonic timebase (generally cheap & accurate on modern systems) and avoids skew from wall-clock adjustments like NTP.
I’ve added a raw-events capture mode to measure latency of how input events propagate, from input_event, through the server collecting them, to when the client receives them:
I haven’t tweaked scheduling, and the client process may run on the same CPU core the server just did, but either way this should generally be low enough not to worry about.
Selecting the FPS limit
This is the part I struggled with the most. We need a method to translate discrete input events into a steady stream of values to use as the FPS limit. There are multiple opposing objectives:
- It needs to ramp up quickly so the app always feels responsive.
- It can’t have drastic frame-to-frame jumps for OLED VRR users like me.
- It needs some kind of inertia or long-term memory so that small bursts don’t immediately max out the framerate after a period of inactivity.
- It needs to work regardless of which mouse and polling rate you use.
You can see my attempts in this Marimo notebook.
After some searching I found existing patterns in attack-hold-release envelopes used in audio processing that fit the bill.
I ended up using an exponential envelope follower with asymmetric attack and release.
Asymmetric is the key piece here: it gives us separate controls for how fast to ramp up versus how fast to taper off at idle.
The envelope switches between attack and release phases based on how long it’s been since the last input event, with a small hold window before it starts decaying.
To make this independent of mouse polling rate, I estimate each device’s polling interval and treat it as still active in the attack phase until two expected intervals pass with no events.
We don’t look at the magnitude of the movement at all; every input is treated with equal weight.
The resulting 0..1 value is mapped linearly to min_fps..max_fps and streamed at a steady rate (200Hz by default) to the client.
I guess that signal processing book I got years ago would have been useful.
I was able to compile the same module used by the real server into WASM and make a small simulation playground for it.
I measured ~0.1 ms and 1 ms clock resolution on the web using performance.now() for Chromium and Firefox, respectively.
This should be enough for 1 kHz or 10 kHz input devices.
That demo uses the pointerrawupdate event on the web, which is not widely supported but worked on Firefox and Chromium-based browsers for me.
Install (NixOS)
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
mangochill = {
url = "github:farnoy/mangochill";
inputs.nixpkgs.follows = "nixpkgs";
# if you have them
# inputs.flake-parts.follows = "flake-parts";
};
};
outputs = { nixpkgs, mangochill, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
# ...
modules = [
mangochill.nixosModules.default
{
services.mangochill.enable = true;
}
];
};
};
}
This installs MangoChill and also my MangoHud fork.
The server needs less than 10 MiB of memory and only subscribes to input devices during active sessions.
Input events are easy to process but I paid some attention to vectorize the hot loops and made the NixOS module compile using -C target-cpu=native.
Configure MangoHud to expose its control socket:
# $XDG_CONFIG_HOME/MangoHud/MangoHud.conf
control = mangohud-%p
Run an app through MangoHud, and then:
$ mangochill-client -vv --min-fps 10 --max-fps 60 --attack-half-life-ms 600 --release-half-life-ms 2000
Future work
I want to continue tweaking the core algorithm and to add support for gamepads, perhaps even keyboards. We’d also want to daemonize the client process and configure per-game profiles as well. For now, MangoChill is only packaged up as a NixOS module and could use more distribution options.
Contributions & feedback are welcome. Please create issues or discussions on GitHub.