Skip to content

ipc/ui: support custom transition shaders and animation config - #371

Open
markg85 wants to merge 1 commit into
hyprwm:mainfrom
markg85:wallpaper_shader_transition
Open

ipc/ui: support custom transition shaders and animation config#371
markg85 wants to merge 1 commit into
hyprwm:mainfrom
markg85:wallpaper_shader_transition

Conversation

@markg85

@markg85 markg85 commented Jul 7, 2026

Copy link
Copy Markdown

AI DISCLAIMER

Yes, I used AI. I could not do the GL bits on my own (C/C++ background here, not game dev) so I kinda trust AI to do the right thing there. I did let it review itself and let another model review it too. I'm fairly confident the code is of a good quality. If it has errors then I would've made those too. I did also review all the other non-GL code and in all honestly did caught a few issues you don't even see anymore (like stat'ing the shader every frame... Ouch. The code in my opinion looks good. I'm also running this right here and now. I'm obviously going to gladly handle feedback and suggestions!

Wallpaper transition demo

I've been wanting to have this feature ever since I moved to Hyprland. At first I had build this before the wallpaper logic was shader based so that work was completely lost on my end. And now I rebuild it with the current hyprland logic, and it is buttery smooth! My intent here is to et hyprland do it's default thing and only provide a custom shader animation at the moment of transitioning. That is also why I did not provide a shader path configuration setting. To me it seemed just fine to only have this in the hyprctl path. This would allow someone like @mylinuxforwork to enjoy smooth transitions rendered on the GPU instead of these "performant" cpu tricks that have been used thus far.

Note! This is written against tag v0.8.4, not master! Consider this a draft!

This commit also contains 2 example shader. I'm guessing those should be left out for the final version but for now they serve as demo of specifically the duration handling. Below in this post also the circle reveal shader.

Usage

hyprctl hyprpaper wallpaper ",/path/to/next.png,contain,/path/to/shader.frag"

The fields, in order:

  1. monitor name (empty means all)
  2. absolute image path
  3. fit mode: stretch, cover, contain, tile
  4. optional absolute path to a .frag shader

If you leave off the fourth argument, hyprpaper uses its built-in transition shader, which is a plain crossfade.

Where the duration comes from

The transition length is read from your hyprland config, not the shader:

animation = fadeLayersIn, 1, 1.79, almostLinear
animation = fadeLayersOut, 1, 1.39, almostLinear

hyprpaper parses those two lines. The actual transition runs for the larger of the two durations. The bezier name at the end is ignored as that should probably be handled in the shader itself which i didn't change. A shader can finish its visual effect sooner than that and then hold the final frame (see below).

Interrupting a transition

If you send a new wallpaper command while a transition is still running, hyprpaper does not snap or flicker. It renders the current blended frame into an off-screen framebuffer, uses that as the new start texture, and begins a fresh transition to the new target. You can chain wallpapers as fast as you like.

The shader contract

A custom shader is GLSL ES 3.00 (#version 300 es). It is compiled once and cached. hyprpaper stats the file at the start of each transition and recompiles only when the mtime changed, so editing the shader and reissuing the command picks up the change.

The host sets these uniforms every frame:

uniform sampler2D tex1;     // start texture
uniform sampler2D tex2;     // end texture
uniform float progress;     // 0.0 at start, 1.0 at end
uniform float alpha;         // overall alpha, usually 1.0
uniform vec2  topLeft;       // output box top-left in pixels
uniform vec2  fullSize;      // output box size in pixels
uniform float radius;        // corner rounding (0 for wallpaper)
uniform vec2  tex1Size;      // start texture size in pixels
uniform vec2  tex2Size;      // end texture size in pixels
uniform int   fitMode;       // 0 stretch, 1 cover, 2 contain, 3 tile
uniform vec2  randomPixel;   // a random point in 0..1, new per transition
uniform float u_duration;    // the config duration in seconds

Two vertex attributes come in: pos and texcoord, both in 0..1. The fragment shader reads v_texcoord. Write to layout(location = 0) out vec4 fragColor.

One GLES gotcha: you cannot initialize a uniform with a default value in the shader. u_duration is always 0 unless the host sets it, which it does every frame. So if your shader wants its own visual length that differs from the host duration, declare a const float and remap progress against u_duration yourself.

The simplest possible shader is just mix(texture(tex1, v_texcoord), texture(tex2, v_texcoord), progress).

A shader can override its own visual length

The host transition lasts u_duration seconds. If your effect should complete in, say, half a second regardless of that, pick a constant and compress progress:

const float SHADER_DURATION = 0.5;

float p = (u_duration > 0.0)
    ? clamp(progress * (u_duration / SHADER_DURATION), 0.0, 1.0)
    : progress;

When progress reaches 1.0 the host stops the transition. So if your effect finishes early, the remaining frames just hold the final state until the host duration elapses. The circle reveal below does exactly that.

Full example: circle reveal

This reveals the new wallpaper outward from a random point. The visual sweep finishes in 0.5 seconds; after that it holds the new image until the host duration ends. It also handles cover and contain fit modes so neither texture gets stretched oddly.

#version 300 es

precision highp float;

in vec2 v_texcoord;

uniform sampler2D tex1;
uniform sampler2D tex2;
uniform float progress;
uniform float alpha;

uniform vec2 topLeft;
uniform vec2 fullSize;
uniform float radius;

uniform vec2 tex1Size;
uniform vec2 tex2Size;
uniform int  fitMode;
uniform vec2 randomPixel;
uniform float u_duration;

const float SHADER_DURATION = 0.5;

layout(location = 0) out vec4 fragColor;

vec2 containUV(vec2 uv, vec2 texSize, vec2 boxSize) {
    float boxAspect = boxSize.x / boxSize.y;
    float texAspect = texSize.x / texSize.y;

    vec2 scale;
    vec2 offset = vec2(0.0);

    if (texAspect > boxAspect) {
        scale.x = 1.0;
        scale.y = boxAspect / texAspect;
        offset.y = (1.0 - scale.y) * 0.5;
    } else {
        scale.x = texAspect / boxAspect;
        scale.y = 1.0;
        offset.x = (1.0 - scale.x) * 0.5;
    }

    return (uv - offset) / scale;
}

vec2 coverUV(vec2 uv, vec2 texSize, vec2 boxSize) {
    float boxAspect = boxSize.x / boxSize.y;
    float texAspect = texSize.x / texSize.y;

    vec2 scale;
    vec2 offset = vec2(0.0);

    if (texAspect > boxAspect) {
        scale.x = boxAspect / texAspect;
        scale.y = 1.0;
        offset.x = (1.0 - scale.x) * 0.5;
    } else {
        scale.x = 1.0;
        scale.y = texAspect / boxAspect;
        offset.y = (1.0 - scale.y) * 0.5;
    }

    return (uv - offset) / scale;
}

void main() {
    vec2 uv = v_texcoord;

    float p = (u_duration > 0.0)
        ? clamp(progress * (u_duration / SHADER_DURATION), 0.0, 1.0)
        : progress;

    vec2 uv1 = uv;
    vec2 uv2 = uv;

    if (fitMode == 1) {
        uv1 = coverUV(uv, tex1Size, fullSize);
        uv2 = coverUV(uv, tex2Size, fullSize);
    } else if (fitMode == 2) {
        uv1 = containUV(uv, tex1Size, fullSize);
        uv2 = containUV(uv, tex2Size, fullSize);
    }

    vec4 startColor = texture(tex1, uv1);
    vec4 endColor = texture(tex2, uv2);

    // Contain mode: outside the letterboxed area is black.
    if (fitMode == 2) {
        if (uv1.x < 0.0 || uv1.x > 1.0 || uv1.y < 0.0 || uv1.y > 1.0)
            startColor = vec4(0.0, 0.0, 0.0, 1.0);
        if (uv2.x < 0.0 || uv2.x > 1.0 || uv2.y < 0.0 || uv2.y > 1.0)
            endColor = vec4(0.0, 0.0, 0.0, 1.0);
    }

    // randomPixel is the circle center in 0..1.
    vec2 pixelPos = uv * fullSize;
    vec2 centerPixel = randomPixel * fullSize;

    float dist = distance(pixelPos, centerPixel);

    // Farthest corner from the center, so the reveal reaches the whole screen.
    float maxDist = 0.0;
    maxDist = max(maxDist, distance(vec2(0.0, 0.0), centerPixel));
    maxDist = max(maxDist, distance(vec2(fullSize.x, 0.0), centerPixel));
    maxDist = max(maxDist, distance(vec2(0.0, fullSize.y), centerPixel));
    maxDist = max(maxDist, distance(fullSize, centerPixel));

    float normalizedDist = dist / maxDist;

    // smoothstep gives the circle a soft anti-aliased edge.
    float edgeWidth = 0.02;
    float circleMask = smoothstep(p - edgeWidth, p + edgeWidth, normalizedDist);

    vec4 blended = mix(endColor, startColor, circleMask);

    fragColor = blended * alpha;
}

Things to keep in mind

  • progress is the only value that changes meaningfully frame to frame. Treat
    everything else as constant per transition.
  • randomPixel is regenerated at the start of each transition. Use it or
    ignore it.
  • For contain, sample coordinates can fall outside 0..1. Decide what you want
    there. This example paints it black.
  • Keep the shader cheap. It runs once per output pixel per frame for the whole
    transition.

@markg85

markg85 commented Jul 7, 2026

Copy link
Copy Markdown
Author

Ahh, good thing it's draft! I did just discover a few issues in the different modes. On it.

A little later then intended but that's resolved.

@markg85
markg85 force-pushed the wallpaper_shader_transition branch from 91317e1 to 1ec1728 Compare July 19, 2026 22:12
@markg85

markg85 commented Jul 19, 2026

Copy link
Copy Markdown
Author

@vaxerski This and it's companion PRs are ready for review.

I can rebase this against master if you want. Though i prefer to have feedback in it's current state as this is far easier for me to test/run then to swap to master.

@markg85
markg85 marked this pull request as ready for review July 19, 2026 22:19
@markg85
markg85 force-pushed the wallpaper_shader_transition branch 2 times, most recently from 885252e to 5fde1a9 Compare July 22, 2026 15:33
Adds the shader_path request (hyprpaper_wallpaper v2) so a custom fragment
shader can be supplied per wallpaper apply, and an existing monitor wallpaper
now transitions to the new path instead of replacing outright.

- IPC::CWallpaperObject stores and forwards the shader path.
- CConfigManager::getAnimationConfig reads fadeLayersIn/fadeLayersOut
  (enabled + duration) from hyprland.conf, cached per name.
- CWallpaperTarget::transitionTo / replaceImmediate delegate to the image
  element; CUI reuses the existing target when one already exists for the
  monitor, animating only when an animation config is enabled.
- Example custom shaders under shaders/ (circle-reveal, diagonal-wipe).
@markg85
markg85 force-pushed the wallpaper_shader_transition branch from 5fde1a9 to 356667c Compare August 16, 2026 00:08
@markg85

markg85 commented Aug 16, 2026

Copy link
Copy Markdown
Author

Review this please.

@markg85

markg85 commented Aug 28, 2026

Copy link
Copy Markdown
Author

It's becoming hard to keep this working, please review! I want to merge this for the next release if possible. @vaxerski

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant