Looking for a Working GNOME Grayscale & Eye Strain Filter? How I Fixed "Tint All" & Built Pasynkov Tint
If you spend long hours in front of a monitor, using a grayscale (monochrome) filter or a warm color tint (amber/sepia) is one of the most effective ways to reduce eye strain, minimize digital fatigue, and cut down on dopamine-driven screen distractions.
However, Linux users on Ubuntu and GNOME Shell face a common headache when trying popular extensions like Tint All, Desaturate All, or GNOME Bedtime Mode:
โก The Telegram Drop Issue:
You turn on your grayscale or amber eye strain filter. Everything works initially. Then you open Telegram Desktop, click on a chat channel or video, and poof โ your entire color filter instantly turns off! Switch to another window, and it comes back. Focus Telegram again, and it drops.
If you check GNOME logs using journalctl -f /usr/bin/gnome-shell, you will find repeating GPU framebuffer errors:
gnome-shell: Failed to create offscreen effect framebuffer:
Failed to create texture 2d due to size/format constraints
For years, users searching for a reliable Tint All alternative or a permanent GNOME Bedtime Mode fix were told this was an unfixable Mutter/Wayland bug.
In this article, I will explain the technical root cause of why traditional GNOME Shell color filters fail, and how I built Pasynkov Tint โ a high-performance, per-actor GNOME color tint & grayscale extension engineered to eliminate Wayland framebuffer drops.
๐ Table of Contents
- ๐ Feature & Performance Comparison
- ๐ The Root Cause Post-Mortem
- ๐ก The Solution: Per-Actor Single-Pass GLSL Engine
- ๐ ๏ธ Copy-Paste Solution for Developers
- โจ Features of Pasynkov Tint
- โ Frequently Asked Questions (FAQ)
- ๐ Quick 1-Line Installation
๐ Feature & Performance Comparison
To understand why extensions like Tint All and GNOME Bedtime Mode drop their filters on Wayland, we need to look under the hood of GNOME Shell's rendering pipeline.
| Feature / Metric | Traditional Extensions (Tint All, Bedtime Mode) | Pasynkov Tint |
|---|---|---|
| Grayscale / Monochrome Filter | โ Yes | โ Yes |
| Custom Color Tints (Amber, Sepia, Cyan, Green) | โ ๏ธ Limited | โ Full (with dynamic presets) |
| Mouse Wheel Intensity Control | โ No | โ Yes (Scroll over top bar icon) |
| Telegram / Qt6 App Compatibility | โ Drops / Crashes | โ Highly Stable (No Drops) |
| Overview (Super Key) Support | โ ๏ธ Partial | โ Fully Filtered |
Custom Side Docks (right-dock, Dash) |
โ Missing | โ Fully Filtered |
| GPU Texture Allocations Per Frame | 2 Full-Screen Multi-Monitor FBOs | 1 Bounded Actor FBO Per Element |
| GPU VRAM Overhead | High (~100MB+ multi-monitor FBOs) | Ultra Low (< 5MB) |
| GPU Architecture | Stacked uiGroup Offscreen Effects |
Single-Pass Per-Actor GLSL Engine |
๐ The Root Cause Post-Mortem
The Full-Screen Framebuffer Trap
Traditional extensions apply desaturation and color tinting by attaching two native Clutter effects (Clutter.DesaturateEffect + Clutter.BrightnessContrastEffect) directly to Main.uiGroup (the root container of the entire GNOME Shell desktop).
Because both effects inherit from Clutter.OffscreenEffect, GNOME Shell is forced to allocate two full-screen GPU textures per frame:
TRADITIONAL ARCHITECTURE (CRASH-PRONE):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Entire Desktop Scene Graph (Main.uiGroup) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโ
โ Offscreen Texture #1 โ <-- Multi-Monitor FBO 1
โ (BrightnessContrastEffect) โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโ
โ Offscreen Texture #2 โ <-- Multi-Monitor FBO 2 (Fails in Telegram!)
โ (DesaturateEffect Grayscale) โ
โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโ
โ
โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโ
โ Final Display Output โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
What Happens When Telegram Desktop Focuses
Telegram Desktop is built on Qt 6 / 5.15. When you click channels or view media in Telegram, Qt triggers rapid Wayland sub-surface updates, damage region recalculations, and HiDPI scale adjustments.
During these Wayland surface updates, Mutter recalculates the paint volume of Main.uiGroup. Cogl fails to allocate the multi-monitor offscreen texture (cogl_texture_2d_new_with_size returns NULL), and Clutter silently skips the rendering pass.
The effect remains attached according to actor.get_effect(), but your screen reverts to full color!
๐ก The Solution: Per-Actor Single-Pass GLSL Engine
To build a truly reliable GNOME eye strain filter, we completely redesigned the shader architecture in Pasynkov Tint.
PASYNKOV TINT ARCHITECTURE (100% STABLE):
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Individual Screen Components โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโค
โ MetaWindowActor โ Main.panel โ _backgroundGroup โ
โ (App Windows) โ (Top Bar) โ (Desktop Wallpaper) โ
โโโโโโโโโโฌโโโโโโโโโดโโโโโโโโโฌโโโโโโโโโโดโโโโโโโโโโโฌโโโโโโโโโโโโโโ
โ โ โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโ
โ Single-Pass GLSLโโ Single-Pass GLSLโโ Single-Pass GLSL โ <-- Small, actor-sized FBOs
โ (Desat + Tint) โโ (Desat + Tint) โโ (Desat + Tint) โ (Never exceeds GPU limits!)
โโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโ
โ โ โ
โโโโโโโโโโผโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโ
โ Final Display Output (Zero Drops, 60+ FPS) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Step 1: Single-Pass GLSL Shader (Shell.GLSLEffect)
Instead of stacking two separate offscreen effects, we wrote a single custom GLSL fragment shader that performs desaturation, brightness/contrast scaling, and RGB tinting in a single GPU pass:
#ifndef PASYNKOV_TINT_UNIFORMS
#define PASYNKOV_TINT_UNIFORMS
uniform float u_intensity;
uniform float u_desat;
uniform vec3 u_tint;
uniform float u_tint_mix;
#endif
// ITU-R BT.709 luma desaturation
float luma = dot(cogl_color_out.rgb, vec3(0.2126, 0.7152, 0.0722));
vec3 desatd = mix(cogl_color_out.rgb, vec3(luma), u_desat * u_intensity);
// Single-pass RGB tint multiplication
vec3 tinted = desatd * u_tint;
cogl_color_out.rgb = mix(desatd, tinted, u_tint_mix * u_intensity);
๐ก GLSL Preprocessor Guard Note:
We wrapped uniform declarations in#ifndef PASYNKOV_TINT_UNIFORMS. Without this guard, Cogl's snippet linker fails witherror: u_intensity redeclaredwhen multiple shader instances are created, causing window actors to turn completely invisible!
Step 2: Per-Actor Mapping
Instead of applying our shader to the massive Main.uiGroup, we attach shader instances to individual actors:
- Every application window (
MetaWindowActor) - Top Bar (
Main.panel) - Desktop Wallpaper (
Main.layoutManager._backgroundGroup) - Workspace Overview (
Main.overview._overview/ Super Key view) - Custom side docks (
right-dock, Dash to Dock, etc.)
// Sync shader instances across active application windows
_syncWindowActors() {
const currentActors = new Set(global.get_window_actors());
for (const actor of currentActors) {
if (this._windowEffects.has(actor)) continue;
const effect = new PasynkovTintEffect();
effect.setParams(this._intensity, preset.desat, preset.tint, preset.tintMix);
actor.add_effect_with_name('pasynkov-tint-unified', effect);
const destroyId = actor.connect('destroy', () => {
this._windowEffects.delete(actor);
});
this._windowEffects.set(actor, { effect, destroyId });
}
}
๐ ๏ธ Copy-Paste Solution: Use Single-Pass Desaturation in Your Extension
If you're building a GNOME Shell extension and need a lightweight desaturation effect that doesn't drop frames, here is the ready-to-use GJS class:
const { GObject, Shell } = imports.gi;
const SHADER_DECL = `
#ifndef DESAT_SHADER_UNIFORMS
#define DESAT_SHADER_UNIFORMS
uniform float u_factor;
#endif
`;
const SHADER_CODE = `
float luma = dot(cogl_color_out.rgb, vec3(0.2126, 0.7152, 0.0722));
cogl_color_out.rgb = mix(cogl_color_out.rgb, vec3(luma), u_factor);
`;
var LightweightDesatEffect = GObject.registerClass(
class LightweightDesatEffect extends Shell.GLSLEffect {
_init() {
super._init();
this.add_glsl_snippet(Shell.SnippetHook.FRAGMENT, SHADER_DECL, SHADER_CODE, false);
this._uFactor = this.get_uniform_location('u_factor');
this._factor = 1.0;
}
setFactor(factor) {
this._factor = factor;
this.queue_repaint();
}
vfunc_paint_target(nodeOrCtx, paintCtxOrUndef) {
this.set_uniform_float(this._uFactor, 1, [this._factor]);
if (paintCtxOrUndef !== undefined)
super.vfunc_paint_target(nodeOrCtx, paintCtxOrUndef);
else
super.vfunc_paint_target(nodeOrCtx);
}
});
โจ Features of Pasynkov Tint
If you want a comfortable, eye-friendly Linux desktop experience, Pasynkov Tint includes several productivity features:
- ๐จ 6 Color Presets: Off, Amber (warm night reading), Green, Cyan, Sepia, and pure Grayscale (Monochrome).
- ๐๏ธ Mouse Wheel Scrolling: Hover over the top bar panel icon and scroll your mouse wheel to adjust filter intensity from 5% to 100% in real time.
- ๐ฑ๏ธ Instant Click Cycling: Left-click the panel icon to quickly cycle presets without opening menus.
- ๐ฅ๏ธ Desktop OSD Feedback: Displays real-time pop-up notification feedback (
Pasynkov Tint ยท Amber ยท 65%). - โ Emergency Reset Switch: Built-in reset button in the popup menu to instantly restore default screen settings if needed.
- โ๏ธ GTK4 / Libadwaita Preferences: Full-featured settings window with compatibility across GNOME 42, 43, 44, 45, and 46+.
โ Frequently Asked Questions (FAQ)
Does Pasynkov Tint work with GNOME's built-in Night Light?
Yes! GNOME Night Light operates at the display server / gamma level, while Pasynkov Tint runs inside Mutter's Clutter scene graph. They complement each other smoothly.
Does the filter show up in screenshots or screen recordings?
No. Because effects are applied per-actor in Mutter's scene graph without mutating system gamma ramps, full-resolution screenshots (PrtScn) and OBS screen recordings remain in natural full color.
Does it work on Wayland and X11?
Yes! Tested and fully supported on Ubuntu 22.04 LTS (GNOME 42) through Ubuntu 24.04 LTS / Arch Linux (GNOME 46+) under both Wayland and X11 sessions.
๐ How to Install Pasynkov Tint
You can install Pasynkov Tint instantly with a single command in your Linux terminal:
curl -fsSL https://raw.githubusercontent.com/pefbrute/Pasynkov-Tint/main/install.sh | bash
Or manually clone and run the installer script:
git clone https://github.com/pefbrute/Pasynkov-Tint.git
cd Pasynkov-Tint
./install.sh
๐ Try Pasynkov Tint Today
Check out Pasynkov Tint on GitHub:
๐ Pasynkov Tint Repository on GitHub (pefbrute/Pasynkov-Tint)
If you found this technical deep dive helpful or use the GLSL snippet in your extension, feel free to star the repo and leave a comment below!
How do you manage eye strain on Linux? Do you use grayscale modes or color tinting? Let's discuss in the comments!

Top comments (0)