DEV Community

Cover image for Looking for a Working GNOME Grayscale & Eye Strain Filter? How I Fixed "Tint All" & Built Pasynkov Tint
Fedor Pasynkov
Fedor Pasynkov

Posted on

Looking for a Working GNOME Grayscale & Eye Strain Filter? How I Fixed "Tint All" & Built Pasynkov Tint

Looking for a Working GNOME Grayscale & Eye Strain Filter? How I Fixed "Tint All" & Built Pasynkov Tint

Pasynkov Tint Telegram Showcase

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
Enter fullscreen mode Exit fullscreen mode

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

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          โ”‚
               โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

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)                  โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก GLSL Preprocessor Guard Note:

We wrapped uniform declarations in #ifndef PASYNKOV_TINT_UNIFORMS. Without this guard, Cogl's snippet linker fails with error: u_intensity redeclared when 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 });
    }
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ› ๏ธ 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);
    }
});
Enter fullscreen mode Exit fullscreen mode

โœจ 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
Enter fullscreen mode Exit fullscreen mode

Or manually clone and run the installer script:

git clone https://github.com/pefbrute/Pasynkov-Tint.git
cd Pasynkov-Tint
./install.sh
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”— 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)