Caution
This is a 100% Claude generated repository.
A KWin (Plasma 6) effect that kills QD-OLED colour fringing — the green halo above edges and the warm one below — by shifting each colour channel's content a fraction of a pixel the other way. Whole screen, not just text.
Ported from FringeShift by Resolve: How I fixed text on my QD-OLED monitor (not MacType)
You need Plasma 6 and, critically, KWin's development headers for the exact
KWin you are running — effect plugins are not binary compatible across KWin
releases, so a plugin built against 6.5 will not load into 6.6. Check yours with
kwin_wayland --version.
| Need | Minimum | Built and tested against |
|---|---|---|
| C++ compiler | C++20 (GCC 11 / Clang 14) | GCC 15.2 |
| CMake | 3.20 | 4.x |
| Ninja | any | — |
| extra-cmake-modules (ECM) | 6.0 | 6.26.0 |
| Qt 6 | 6.6 (Plasma 6 floor) | 6.11.1 — Core, DBus, Gui, Widgets |
| KDE Frameworks 6 | 6.0 | 6.26.0 — Config, ConfigWidgets, CoreAddons, GlobalAccel, I18n, KCMUtils, WidgetsAddons |
| KWin | Plasma 6 | 6.6.6 — dev package, provides KWinConfig.cmake + effect/*.h |
| libepoxy + EGL | any | tests only; skip with -DFRINGESHIFT_BUILD_TESTS=OFF |
Both KWin ≤ 6.6 and 6.7+ are handled — the build detects KWin_VERSION and
switches two API shims.
Arch / CachyOS / EndeavourOS (kwin ships its own headers, no -devel split):
sudo pacman -S --needed base-devel cmake ninja extra-cmake-modules \
qt6-base kwin kconfig kconfigwidgets kcoreaddons kglobalaccel \
ki18n kcmutils kwidgetsaddons libepoxy libglvndFedora / Nobara:
sudo dnf install gcc-c++ cmake ninja-build extra-cmake-modules \
qt6-qtbase-devel kwin-devel kf6-kconfig-devel kf6-kconfigwidgets-devel \
kf6-kcoreaddons-devel kf6-kglobalaccel-devel kf6-ki18n-devel \
kf6-kcmutils-devel kf6-kwidgetsaddons-devel libepoxy-devel mesa-libEGL-develDebian 13+ / Ubuntu 24.10+:
sudo apt install build-essential cmake ninja-build extra-cmake-modules \
qt6-base-dev kwin-dev libkf6config-dev libkf6configwidgets-dev \
libkf6coreaddons-dev libkf6globalaccel-dev libkf6i18n-dev \
libkf6kcmutils-dev libkf6widgetsaddons-dev libepoxy-dev libegl-devopenSUSE Tumbleweed: the same set, kwin6-devel plus the kf6-*-devel
packages.
If find_package(KWin) fails, that is the one to chase — it is the least
consistently packaged piece and on some distros it is not shipped at all, in
which case you need KWin's own source tree or the Nix path below.
0. Turn off font sub-pixel rendering first. It displaces the same three channels inside the glyph rasteriser, so the two corrections stack and fringing gets worse.
kwriteconfig6 --file kdeglobals --group General --key XftSubPixel noneLog out and back in for that to reach running apps.
1. Build.
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build buildThe configure step prints Building against KWin <version> — check that it is
the KWin you actually run.
2. Install.
cmake --install build --prefix ~/.local3. Restart the compositor.
kwin_wayland --replace & # or just log out and back in4. Enable it in System Settings → Desktop Effects → Fringe Shift. Meta+Ctrl+F9 toggles it at runtime for A/B comparison.
If the effect does not appear in that list, KWin did not find the plugin. It
searches Qt's plugin paths for kwin/effects/plugins/, and whether ~/.local
is on them depends on the distro (Fedora and openSUSE install to lib64, which
often is not). Either point Qt at it —
export QT_PLUGIN_PATH=$HOME/.local/lib/qt-6/plugins:$QT_PLUGIN_PATH # lib64 on Fedora/openSUSE— set for the session rather than one shell, or install system-wide instead
with sudo cmake --install build --prefix /usr.
Warning
Rebuild after every Plasma update. An effect plugin is not binary compatible across KWin releases; a stale one silently fails to load and the effect vanishes from the list.
nix develop gives the whole toolchain and every header above, so none of the
distro packages are needed:
nix develop
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build buildnix build .# builds and runs the test suite, leaving the plugin at
result/lib/qt-6/plugins/kwin/effects/plugins/fringeshift.so. On NixOS add the
flake package to environment.systemPackages rather than installing to
~/.local, which is not on KWin's plugin path there.
flake.nix pins nixpkgs to the revision this machine's system was built from,
so kdePackages.kwin in the shell is bit-identical to the KWin that will load
the plugin. Bump that pin together with the system — nix flake update on its
own breaks the ABI match.
Configure… beside the effect in Desktop Effects opens a page with a slider and spin box per channel axis, the sharpen amount, and the two resample toggles. It writes the keys below and tells the compositor to re-read them, so Apply is enough.
A row of preset buttons loads four archived parameter sets — MPE and SPE (two readings of the panel's emitter geometry), Osorio (converged by hand on live hardware; what ships), and Triad (a conservative floor). A button only fills the controls; Apply still commits and Reset still undoes.
Same thing from the shell — kwinrc, group [Effect-fringeshift], physical
pixels, +X right and +Y down:
kwriteconfig6 --file kwinrc --group Effect-fringeshift --key GreenY 0.12
kwriteconfig6 --file kwinrc --group Effect-fringeshift --key RedY -0.06
qdbus6 org.kde.KWin /KWin reconfigure| Key | Default | Meaning |
|---|---|---|
RedX / RedY |
0.200 / -0.300 |
red content shift, px |
GreenX / GreenY |
0.050 / 0.200 |
green content shift, px |
BlueX / BlueY |
-0.300 / -0.300 |
blue content shift, px |
Sharpen |
0.0 |
unsharp amount on top of the shift, 0 – 1.5 |
Bicubic |
true |
Catmull-Rom resample; false = bilinear (softer, no ringing) |
Adaptive |
true |
gate Sharpen to text-like pixels (never gates the shift) |
ShowMask |
false |
debug: render the text mask instead of the image |
Shifts are clamped to ±1 px. ShowMask is a debug flag with no widget on the
settings page; set it by hand. With every shift at 0 and Sharpen at 0 the
effect unredirects every window, so an all-zero configuration costs nothing.
The defaults carry two separations, both 0.500 px: vertically between green and the red/blue pair beneath it (the green/magenta fringe itself), and horizontally between red and blue (red on left edges, blue on right) — a symmetric diamond in the reference diagram this reading was taken from. Why they are written the way they are is below.
- Every window pays one offscreen pass per frame while the effect is enabled.
- Alpha is not shifted, so window shadows and rounded corners stay intact; the premultiplied RGB near an alpha edge is filtered along with the colour.
- Shifts are in physical pixels and correct on HiDPI, but they assume the panel is in its native orientation — a rotated output has a rotated triad, which this does not model.
- Nothing outside the compositor is corrected.
OffscreenEffectdeliberately does not block direct scanout, so a fullscreen client that KWin hands straight to the display controller is shown uncorrected — the same honest limit the Windows version has with fullscreen-exclusive apps. - A configured shift is a nominal, not a delivered, displacement — see gain.
Everything past this point is rationale and measurement. None of it is needed to use the effect.
The QD-OLED triad puts green at the top vertex and red/blue at the bottom corners, so a nominally grey edge shows a green halo above and a warm fringe below. A constant sub-pixel shift is a convolution whose DC transfer is exactly 1, so it cancels the fringe without changing apparent stroke weight — which is why the shift is applied uniformly and is never scaled per pixel by the text mask.
This effect is the broader of the two available corrections: font sub-pixel rendering reaches text only, whereas this corrects the whole window, icons and UI chrome included.
Read directly off a labelled reference diagram of this panel's subpixel
layout — two sample points per emitter, so the repeating tile is visible, not
just one cell — and confirmed by eye on live hardware with fringe-tune.
Content shift is the emitter's position negated (an emitter sitting high or
left gets a positive, i.e. opposite, correction), except GreenX, which the
diagram places close enough to the R/B horizontal midpoint that live tuning,
not the diagram, got the final sign.
The values are written uncentred — RedY/BlueY −0.300 against GreenY
0.200 — which is the emitter geometry read literally. The shader subtracts
the mean of the three before resampling, so what reaches the resample is
±0.250 on every channel and no channel carries a larger fraction; writing the
same geometry pre-centred is bit-identical.
presets.conf (OSORIO) carries the diagram coordinates and the full
derivation, plus twenty synthetic variants for by-eye comparison via
fringe-presets. presets.legacy.conf holds the earlier readings this
superseded — a lattice average from a macro photograph (0.437/0.449 px), a
single-cell scale drawing (0.472/0.441 px), and their consensus — along with
the sets that never earned a preset button. Nothing in either file is read at
runtime.
A configured shift is a nominal, not a delivered, displacement. Catmull-Rom is an interpolating kernel, not an ideal one, so its phase response under-delivers the requested offset by an amount that grows with frequency: measured 0.982 of nominal at f = 1/16 cyc/px and 0.736 at f = 0.25, the text-edge end. Ask for the default preset's ±0.250 px of centred green and roughly 0.184 px arrives where it matters most.
Gain depends on the fraction as well as the frequency — the in-phase Laplacian
term grows as its square and partly offsets the kernel's phase lag, so smaller
shifts deliver a slightly smaller proportion (the 0.225 px default that shipped
before OSORIO read 0.733 at f = 0.25, and a 0.10 px one before that read
0.680). The response stays proportional to within a few percent (see
proportional-shift below), so this is a scale factor to be aware of when
reading a preset, not a non-linearity.
OffscreenEffect redirects each window into a texture, then redraws it through
fringeshift.frag:
- the three shifts are centred about zero, so no channel carries a larger fractional offset than the others (an uncentred set makes grey text read reddish — measured 16 % chromatic mismatch at the previous default, G +0.30 / RB −0.15; OSORIO's own raw values, G +0.20 / RB −0.30, would carry the same kind of mismatch if the shader skipped this step);
- each shift splits into an exact integer texel step plus a fraction; only the fraction is filtered;
- per channel, a Catmull-Rom (or bilinear) resample gives true sub-pixel positioning, and a Laplacian term flattens the interpolator's MTF so the resample does not soften: |MTF−1| at a 0.10 px fraction drops from 0.200 to 0.013 for bilinear, 0.056 to 0.011 for Catmull-Rom. That compensation is applied per axis — the resample is separable, so a horizontal fraction costs MTF horizontally and nothing more, and a purely vertical correction must leave horizontal detail untouched;
- resampling happens in the source encoding, not linear light — in linear light the taps re-weight toward the bright side of every edge, which is exactly the stroke-weight change this effect must avoid.
The numbers above are reproducible. tests/ builds fringeshift-measure, which
compiles the shipped fringeshift.frag against a
surfaceless EGL context, runs synthetic patterns through it, and measures the
result — no compositor, no GPU, no window on screen.
cmake --build build && ctest --test-dir build --output-on-failurenix build .# runs it too, on llvmpipe inside the sandbox. Where no usable GL
driver exists the harness exits 77 and ctest records a skip rather than a
failure.
KWin's colormanagement.glsl and saturation.glsl are compiled into the
compositor and cannot be resolved outside it, so the harness substitutes the
identity stubs in tests/stubs/. That is faithful to what is
under test: the resample deliberately happens in the source encoding, before
that tail runs.
| Check | What it pins down |
|---|---|
identity |
an all-zero set is a true no-op, so unredirecting costs nothing |
alpha-untouched |
alpha is never resampled, so shadows and corners stay intact |
dc-transfer |
mean is preserved exactly — the stroke-weight argument |
mtf-at-f0.25 |
the Laplacian term flattens the MTF; measured 1.0319 |
chromatic-match |
all three channels move the same distance, green opposed |
shift-gain |
how much of the nominal shift arrives, per frequency |
proportional-shift |
a smooth sub-pixel continuum, with no floor |
translation-invariant |
a shift common to all channels changes nothing |
integer-step-exact |
a whole-texel step costs no MTF at all |
separable-compensation |
a vertical correction leaves horizontal detail alone |
Note
Direction is deliberately out of scope: the harness cannot see the axis flip
KWin applies through GLTexture::matrix(), so every assertion is a magnitude
or a relative channel direction. That +GreenY moves green down the screen
remains a claim only a real session can confirm.
Writing XftSubPixel is the easy half. Making it reach running applications is
the Fonts module's apply path (X resources via krdb, plus a platform-theme
refresh), which most toolkits act on only at restart anyway — so a page that
wrote the key itself would look like it had worked when it had not. It reports
the setting and links to the module that owns it instead.