- Edited
tl;dr disable temporal dithering on your M1/M2/M3 with Stillcolor.
I got 16” M3 Max MBP about a month ago and it’s been absolute hell. By far the worst screen I’ve ever used. It’s like staring into lasers. 1600 nits! Previously I’ve used a mid-2012 retina MBP for almost a decade, and briefly a 2019 16” Intel MBP (at reduced brightness). Those gave me no eyestrain, no dry eyes, no light sensitivity, no inability to focus.
So within the first 24 hours of getting the M3 Max I found LEDStrain and learned about PWM and temporal dithering, and so did my adventure begin of trying to make my new laptop usable for more than 1 hour a day.
At first I thought I could just connect it to an external monitor and adjust the color profile and brightness and everything will be fine. By the time I realized how futile those hacks were my 2-week return window was through.
I tried everything mentioned on this forum, including @NewDwarf boot-args, BetterDisplay with mirrored virtual displays, Iris, SwitchResX, etc. Disabled motion, transparency and True Tone, switched to sRGB, dimmed blue light, turned on Dark Mode, turned off Dark Mode. These measures helped a tiny bit, but my eyes still became severely fatigued after 1 hour of use (even on external monitors).
If you can’t measure it, you can’t fix it
I wanted to see how PWM and temporal dithering look like. I needed quantify these things to determine if 1) they were the cause 2) if any other display adjustments I make have an effect.
Detecting PWM
Used my phone camera in manual video mode with a really fast shutter speed (1/12000). Detected PWM on
- 16” M3 Max MBP (edit: thin bars, vertical/horizontal depending on the phone's shutter direction)
- 2020 iPad Pro (thin bars)
- iPhone 15 Pro (diagonal waves)
PWM-free:
- mid-2012 15” MBP with Retina display
- M2 MacBook Air (need to re-test)
- LG 32” 4K UltraFine (IPS)
- Samsung 32” G7 (IPS)
- BenQ 24” GL2450-b (TN)
Interestingly, some of these PWM-free displays which I’ve used for years were suddenly giving me severe eyestrain when connected to the M3 Max.
Detecting Temporal Dithering (aka FRC)
To visualize temporal dithering you can run a true video capture of your screen though ffmpeg, which can create a diff of each successive frame pair and output a new video of those diff frames.
- Install ffmpeg using MacPorts
sudo port install ffmpeg
- Transform your video
input.mov
in Terminal with this command:
ffmpeg -i input.mov -sws_flags full_chroma_int+bitexact+accurate_rnd -vf "format=gbrp,tblend=all_mode=grainextract,eq=contrast=-60" -c:v v210 -pix_fmt yuv422p10le diff.mov
This uses a filter called time blend (watch this crazy demo) which layers every frame on the frame preceding it using grainextract
blend mode (previously called difference128
). It gets the absolute difference of each RGB value in a pixel then adds 128. So if there’s no change from frame to frame, the output pixel should be RGB(128 128 128). We then adjust the contrast to make the difference more visible, and finally output a lossless video. I’m not an ffmpeg expert but the above command does the job. To output a compressed mp4 use -c:v libx264 diff.mp4
. This does the job of demonstrating dithering at much lower file size. Not pixel perfect but passable.
Using QuickTime screen recording
This doesn’t work. I analyzed a lot of screen recordings from the 2012 MBP and the M3 Max and came to the conclusion that the recordings are at least a step before the application of temporal dithering. Whatever looks like dithering here is likely an artifact of compression.
Using a video capture card
Encouraged by @Seagull capture card thread, I got a Blackmagicdesign UltraStudio Recorder 3G. It accepts HDMI input and connects to your Mac using Thunderbolt 3. It can capture QuickTime Uncompressed 10-bit RGB at 1080p60. This is more than enough for our needs. But you gotta be careful with uncompressed videos, 3-4 seconds clock in at ~2GB.
Here are the results from the devices I had access to:
- 15” 2012 MBP: no dithering
- M2 Mac mini: dithers
- M2 MacBook Air 13”: dithers
- 16” M3 Max: dithers
Sample video demonstrating dithering (lossy compression)
Dithering ON, M3 Max, Sonoma desktop, 1s capture @ 1080p60
- INPUT: input-M3MaxSonomaDesktop-1080p60-1s-dithering.mp4
- TIME BLEND: diff-M3MaxSonomaDesktop-1080p60-1s-dithering.mp4
Just viewing the time blend video is pure torture! Makes you acutely aware of the muscles behind your eyes. If anyone wants the uncompressed recordings I can upload those but they’re heavy and look similar the compressed ones.
Time blend videos with dithering disabled simply show a plain gray screen.
Some findings about dithering:
- Dithering happens at the refresh rate, I tested up to 60Hz. If you set your display to 24Hz, pixels will flicker 24 times a second instead of 60 (obviously).
- If you play a 60fps video on a 60Hz display while dithering is enabled, you actually get no dithering in the video for the most part. There’s no time to dither.
Now that I knew that my laptop applies temporal dithering, I knew it was the cause for my eyestrain. Because the eyestrain was there even while using PWM-free displays, and it’s the only perceivable difference in output from my 2012 MBP and my M3 Max.
The hunt for a technical solution
I read all over this forum that it was impossible to disable dithering because the new Apple silicon GPUs are only capable of handling 10-bit color, and are always dithering no matter what. Seemed unbelievable and I was determined to find a solution with code.
I read this progress report on the Asahi Linux blog by marcan (they’re doing some incredibly hard and important work over there) about reverse engineering the M1 DCP (Display Coprocessor). They proved the CPU and DCP communicate back and forth. Messages like
IOMobileFramebufferAP::setDisplayRefreshProperties()
at the DCP interface were encouraging and hinted at an ability to configure the display.
And over at their DCP tracer a IOMobileFramebufferAP::enable_disable_dithering(unsigned int)
message was traced. All evidence that are there mechanisms in place to control dithering. The question now became how to send those messages.
In my rabbit hole dive I also came across 2 important tools:
ioreg
which shows your I/O Registry- AllRez which dumps all display info in macOS.
So in the logs I saw a property called enableDither = Yes
under a service called IOMobileFramebufferShim
. All of this now seemed inter-related, and the puzzle pieces were falling into place.
Based on all of that I figured a good starting point would be IOMobileFramebuffer
which iPhone Development Wiki describes as a “a kernel extension for managing the screen framebuffer. It is controlled by the user-land framework IOMobileFramework.” On macOS it’s called IOMobileFramebuffer. It’s a private framework with not much literature on it. One way to examine it is to run it through a disassembler.
So I loaded /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e
into Hopper, selected IOMobileFramebuffer
and started looking at the symbols and found 2 interesting routines:
_IOMobileFramebufferEnableDisableDithering
_kern_EnableDisableDithering
My best guess was that _IOMobileFramebufferEnableDisableDithering
is a wrapper around _kern_EnableDisableDithering
which does the real work. Or is it the other way around?
_kern_EnableDisableDithering
is a very simple function, the crux of it is this:
IOConnectCallScalarMethod(r0, 0x1e, &var_10, 0x1, 0x0, 0x0)
Calls selector 30 on the IOMobileFramebufferShim
object with a boolean value. So I quickly made a command line project in Xcode that does just that and this is what I got:
(iokit/common) unsupported function
Uh oh! Disappointing. But I was not about to give up-- IOKit can return kIOReturnUnsupported
for a variety of reasons, one of course being a complete lack of implementation, another being invalid or out of bounds arguments, or possibly a lack of privilege.
So I then loaded the IOMobileFramebuffer framework dynamically with the help of this gist and invoked IOMobileFramebufferEnableDisableDithering
directly and as expected got the same result.
Maybe calling it wasn’t allowed from user space? But I wanted to dig deeper before messing around in kernel space.
So then I stumbled upon IOConnectSetCFProperty
and I remembered enableDither = Yes
from the registry. I thought I’ll just set it directly on the IOMFB object and maybe it will interpret it and affect the display downstream. So I did that and got:
(iokit/common) unsupported function
Again. I was starting to lose hope at this point but in a last ditch attempt I thought I will just modify the I/O Registry directly, even though my understanding was that the registry was a lens into device state and modifying it from the top won’t affect the devices per se.
So I did just that.
kern_return_t ret = IORegistryEntrySetCFProperty(service, CFSTR("enableDither"), kCFBooleanFalse);
And got:
(os/kern) successful
Awesome! I ran ioreg -lw0 | grep -i enableDither
to see if the registry was touched.
| | | | "enableDither" = No
| | | | "enableDither" = No
| | | | "enableDither" = No
| | | | "enableDither" = No
| | | | "enableDither" = No
I thought I was dreaming! I wanted to verify so I plugged my capture card, recorded 3 seconds and ran it through ffmpeg and I couldn’t believe it! Dithering was gone! It was that simple.
Seeing is believing
The below video is a capture of YouTube playing a 1080p60 video.
At first dithering is enabled, then at 00:03:59 dithering is disabled and watch for yourself.
Dithering ON then OFF, M3 Max, Sonoma, YouTube @ 60fps, 1080p60
- INPUT: input-M3MaxSonomaYouTube60fps-1080p60-DitheringOnThenOff.mp4
- TIME BLEND: diff-M3MaxSonomaYouTube60fps-1080p60-DitheringOnThenOff.mp4
- TIME BLEND: YouTube link with bad compression
It turns out modifying the I/O Registry is a common way to tweak driver and device settings and there’s even a command line tool for that. However, the device driver/kernel class must allow and implement the inherited IOService::setProperties method for it to work.
Over the next couple of days I verified it with a few more recordings, including ones where I disable dithering mid-recording and the effect was immediate. Best of all I didn’t need all this proof-- I could simply use my computer again and suffer minimal eyestrain.
The downside of this method is that enableDither
is reset back to Yes
on computer restart. There’s possibly a way to avoid this by modifying the driver’s plist which might contain those properties, but that’s an exercise for another time. A simpler solution is Stillcolor which I developed to disable dithering on login and whenever a new display is connected.
Introducing Stillcolor for macOs
Stillcolor is a lightweight menu bar app which simply disables dithering on login and whenever a new device connects. It’s pretty much in beta at the moment and needs M1/M2/M3 and macOS >= 13 to run. Tested on macOS 14 only, so will appreciate feedback from everyone here.
Please bear in mind that there could be unintended consequences from disabling dithering, so use the app at your own risk. The app is released under the MIT license.
(For some reason Chrome gives a suspicious download blocked warning-- I don’t know know why it does that, you can safely ignore it)
Make sure to enable “Launch at login”
To check wether it did the job, run the following in Terminal:
ioreg -lw0 | grep -i enableDither
Should see 1 or more ”enableDither” = No
.
To re-enable dithering simply uncheck “Disable Dithering.”
A visual test that works for me is the Lagom LCD Gradient (banding) test
Set your built-in display’s color profile to sRGB at full brightness and look carefully at the gray parts, you should be able to see subtle banding when you disable dithering which happens in realtime.
Disabling dithering alongside other measures such reducing brightness and blue light will make using Macs enjoyable again. I think the built-in displays are still awful, and I recommend using an external monitor which you’ve previously been comfortable with.
Credits
Special thanks to my brother Ibraheem for letting me test on his Mac and display!