From 88d54c3d0858e53537cd780c56834bbb9fda9fbc Mon Sep 17 00:00:00 2001 From: hecate cantus Date: Sat, 2 May 2026 10:51:29 -0700 Subject: [PATCH 1/2] feat: parallax-scroll wallpaper A new `scrolling` wallpaper fill mode that translates the wallpaper crop with the active workspace, similar to android wallpapers & lateral launcher pages, but with vertically-scrolling workspaces, instead. The image is scaled to cover the screen on its non-scroll axis; the workspace index drives a fractional offset into the cropped overflow on the scroll axis. Per-monitor scroll position is published into SessionData so the lock screen renders the same crop as the active workspace, preserving visual continuity across lock/unlock. Spring-driven scroll animations run at native refresh on high-Hz displays (QSG_USE_SIMPLE_ANIMATION_DRIVER=1 exported to the spawned quickshell process). Survives wl_output rebind cycles (e.g. OLED image-cleaning when DPMS soft-off) via output-lifecycle re-anchoring of the scroll target, ShaderEffect rebuild against the current render context, and a wallpaper-layer surface re-attach on unlock for parallax-active monitors. Issues encountered included, but were not limited to: - race conditions upon unlock/display reseat: inconsistent void/'stuck' wallpaper backgrounds. Resurfacing needed to be guarded against unlock state so that the shader gets reliable frame hints. - default QSG animation driver always throttling/frame-hinting at 16ms, which lead to disjointed motion pacing between compositor animations and wallpaper shader --- core/cmd/dms/shell.go | 6 + quickshell/Common/SessionData.qml | 20 + quickshell/Common/Theme.qml | 17 + quickshell/Modules/Lock/LockScreenContent.qml | 42 +- quickshell/Modules/Settings/WallpaperTab.qml | 4 +- quickshell/Modules/WallpaperBackground.qml | 421 +++++++++++++++++- quickshell/Shaders/frag/wp_fade.frag | 29 +- quickshell/Shaders/frag/wp_parallax.frag | 72 +++ .../Shaders/frag/wp_parallax_scroll.frag | 35 ++ quickshell/Shaders/qsb/wp_fade.frag.qsb | Bin 5123 -> 6286 bytes quickshell/Shaders/qsb/wp_parallax.frag.qsb | Bin 0 -> 3975 bytes .../Shaders/qsb/wp_parallax_scroll.frag.qsb | Bin 0 -> 1693 bytes 12 files changed, 633 insertions(+), 13 deletions(-) create mode 100644 quickshell/Shaders/frag/wp_parallax.frag create mode 100644 quickshell/Shaders/frag/wp_parallax_scroll.frag create mode 100644 quickshell/Shaders/qsb/wp_parallax.frag.qsb create mode 100644 quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb diff --git a/core/cmd/dms/shell.go b/core/cmd/dms/shell.go index b481d7b3a..245b528da 100644 --- a/core/cmd/dms/shell.go +++ b/core/cmd/dms/shell.go @@ -225,6 +225,9 @@ func runShellInteractive(session bool) { if os.Getenv("QT_QPA_PLATFORM") == "" { cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") } + if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { + cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") + } cmd.Env = appendLogEnv(cmd.Env) @@ -470,6 +473,9 @@ func runShellDaemon(session bool) { if os.Getenv("QT_QPA_PLATFORM") == "" { cmd.Env = append(cmd.Env, "QT_QPA_PLATFORM=wayland;xcb") } + if os.Getenv("QSG_USE_SIMPLE_ANIMATION_DRIVER") == "" { + cmd.Env = append(cmd.Env, "QSG_USE_SIMPLE_ANIMATION_DRIVER=1") + } cmd.Env = appendLogEnv(cmd.Env) diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 2fbedca95..8f7087710 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -114,6 +114,26 @@ Singleton { property var monitorWallpapersLight: ({}) property var monitorWallpapersDark: ({}) property var monitorWallpaperFillModes: ({}) + + // Map: screenName -> { scrollX, scrollY } (0-100 range, like workspace percentage) + property var monitorScrollPositions: ({}) + + function setMonitorScrollPosition(screenName, scrollX, scrollY) { + var newPositions = Object.assign({}, monitorScrollPositions); + newPositions[screenName] = { scrollX: scrollX, scrollY: scrollY }; + monitorScrollPositions = newPositions; + } + + function getMonitorScrollPosition(screenName) { + return monitorScrollPositions[screenName] || { scrollX: 50, scrollY: 50 }; + } + + function clearMonitorScrollPosition(screenName) { + var newPositions = Object.assign({}, monitorScrollPositions); + delete newPositions[screenName]; + monitorScrollPositions = newPositions; + } + property string wallpaperTransition: "fade" readonly property var availableWallpaperTransitions: ["none", "fade", "wipe", "disc", "stripes", "iris bloom", "pixelate", "portal"] property var includedTransitions: availableWallpaperTransitions.filter(t => t !== "none") diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index 875ad38ed..f35844273 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1883,6 +1883,23 @@ Singleton { } } + // Returns numeric fillMode value for shader use (matches shader calculateUV logic) + function getShaderFillMode(modeName) { + switch (modeName) { + case "Stretch": return 0; + case "Fit": + case "PreserveAspectFit": return 1; + case "Fill": + case "PreserveAspectCrop": return 2; + case "Tile": return 3; + case "TileVertically": return 4; + case "TileHorizontally": return 5; + case "Pad": return 6; + case "Scrolling": return 7; + default: return 2; + } + } + function snap(value, dpr) { const s = dpr || 1; return Math.round(value * s) / s; diff --git a/quickshell/Modules/Lock/LockScreenContent.qml b/quickshell/Modules/Lock/LockScreenContent.qml index 1a5bb5d78..830981712 100644 --- a/quickshell/Modules/Lock/LockScreenContent.qml +++ b/quickshell/Modules/Lock/LockScreenContent.qml @@ -191,20 +191,50 @@ Item { } Image { - id: wallpaperBackground - + id: wallpaperSource + visible: false anchors.fill: parent source: { var currentWallpaper = SessionData.getMonitorWallpaper(screenName); return (currentWallpaper && !currentWallpaper.startsWith("#")) ? encodeFileUrl(currentWallpaper) : ""; } - fillMode: Theme.getFillMode(SessionData.getMonitorWallpaperFillMode(screenName)) - smooth: true asynchronous: false cache: true - visible: source !== "" - layer.enabled: true + } + + ShaderEffectSource { + id: srcWallpaper + sourceItem: wallpaperSource + hideSource: true + live: false + } + + ShaderEffect { + id: wallpaperBackground + anchors.fill: parent + visible: wallpaperSource.source !== "" + + property variant source1: srcWallpaper + property variant source2: srcWallpaper // Same source for lock screen (no transition) + property real progress: 0.0 + + readonly property string fillModeName: SessionData.getMonitorWallpaperFillMode(screenName) + readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName) + property real fillMode: Theme.getShaderFillMode(fillModeName) + property real scrollX: scrollPos.scrollX + property real scrollY: scrollPos.scrollY + property real imageWidth1: wallpaperSource.implicitWidth > 0 ? wallpaperSource.implicitWidth : 1 + property real imageHeight1: wallpaperSource.implicitHeight > 0 ? wallpaperSource.implicitHeight : 1 + property real imageWidth2: wallpaperSource.implicitWidth > 0 ? wallpaperSource.implicitWidth : 1 + property real imageHeight2: wallpaperSource.implicitHeight > 0 ? wallpaperSource.implicitHeight : 1 + property real screenWidth: width > 0 ? width : 1 + property real screenHeight: height > 0 ? height : 1 + property vector4d fillColor: Qt.vector4d(0, 0, 0, 1) + + fragmentShader: Qt.resolvedUrl("../../Shaders/qsb/wp_fade.frag.qsb") + + layer.enabled: true layer.effect: MultiEffect { autoPaddingEnabled: false blurEnabled: true diff --git a/quickshell/Modules/Settings/WallpaperTab.qml b/quickshell/Modules/Settings/WallpaperTab.qml index c0f83c18d..e9c84d293 100644 --- a/quickshell/Modules/Settings/WallpaperTab.qml +++ b/quickshell/Modules/Settings/WallpaperTab.qml @@ -307,9 +307,9 @@ Item { DankButtonGroup { id: fillModeGroup - property var internalModes: ["Stretch", "Fit", "Fill", "Tile", "TileVertically", "TileHorizontally", "Pad"] + property var internalModes: ["Stretch", "Fit", "Fill", "Scrolling", "Tile", "TileVertically", "TileHorizontally", "Pad"] anchors.horizontalCenter: parent.horizontalCenter - model: [I18n.tr("Stretch", "wallpaper fill mode"), I18n.tr("Fit", "wallpaper fill mode"), I18n.tr("Fill", "wallpaper fill mode"), I18n.tr("Tile", "wallpaper fill mode"), I18n.tr("Tile V", "wallpaper fill mode"), I18n.tr("Tile H", "wallpaper fill mode"), I18n.tr("Pad", "wallpaper fill mode")] + model: [I18n.tr("Stretch", "wallpaper fill mode"), I18n.tr("Fit", "wallpaper fill mode"), I18n.tr("Fill", "wallpaper fill mode"), I18n.tr("Scroll", "wallpaper fill mode"), I18n.tr("Tile", "wallpaper fill mode"), I18n.tr("Tile V", "wallpaper fill mode"), I18n.tr("Tile H", "wallpaper fill mode"), I18n.tr("Pad", "wallpaper fill mode")] selectionMode: "single" buttonHeight: 28 minButtonWidth: 48 diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 15763ed41..75d0dee2a 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -7,7 +7,11 @@ import qs.Widgets import qs.Services Variants { + id: variants readonly property var log: Log.scoped("WallpaperBackground") + // An entry present in PanelWindow.onCompleted means we're recreating + // after a wl_output rebind, not at initial startup. + property var _seenScreens: ({}) model: { if (SessionData.isGreeterMode) { return Quickshell.screens; @@ -52,6 +56,19 @@ Variants { property string actualTransitionType: transitionType property bool isInitialized: false + property string scrollMode: SettingsData.wallpaperFillMode + property bool scrollingEnabled: scrollMode === "Scrolling" + property bool isVerticalScrolling: CompositorService.isNiri + property int currentWorkspaceIndex: 0 + property int totalWorkspaces: 1 + // Also requires the image to overflow on the compositor's scroll + // axis — niri scrolls Y, Hyprland scrolls X — otherwise the + // currentWallpaper Fill fallback handles it. + property bool effectiveScrolling: scrollingEnabled && totalWorkspaces > 1 + && (!imageMetrics.ready + || (CompositorService.isNiri && imageMetrics.nativeWidth / imageMetrics.nativeHeight < root.textureWidth / root.textureHeight - 0.01) + || (CompositorService.isHyprland && imageMetrics.nativeWidth / imageMetrics.nativeHeight > root.textureWidth / root.textureHeight + 0.01)) + Connections { target: SessionData function onIsLightModeChanged() { @@ -63,6 +80,27 @@ Variants { } } } + + Connections { + target: NiriService + enabled: CompositorService.isNiri && root.scrollingEnabled + + function onAllWorkspacesChanged() { + root.updateWorkspaceData(); + } + } + + Connections { + target: CompositorService.isHyprland ? Hyprland : null + enabled: CompositorService.isHyprland && root.scrollingEnabled + + function onRawEvent(event) { + if (event.name === "workspace" || event.name === "workspacev2") { + root.updateWorkspaceData(); + } + } + } + onTransitionTypeChanged: { if (transitionType !== "random") { actualTransitionType = transitionType; @@ -89,6 +127,8 @@ Variants { property bool useNextForEffect: false property string pendingWallpaper: "" property string _deferredSource: "" + // ANDed into parallaxLoader.active; bounced to rebuild the ShaderEffect after a wl_output rebind. + property bool _parallaxRefreshGate: true readonly property bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== "" Connections { @@ -109,6 +149,30 @@ Variants { } } + // Re-anchor scroll target on output lifecycle; workspace signals + // don't re-fire when niri's list is unchanged across a rebind. + function _reanchorScroll() { + if (!root.scrollingEnabled) return; + root.firstScrollUpdate = true; + Qt.callLater(root.updateWorkspaceData); + } + + // Bounce the gate so parallaxLoader re-instantiates its ShaderEffect. + // Qt.callLater is required — Qt would otherwise coalesce the toggle. + function _refreshParallaxPipeline() { + if (!root._parallaxRefreshGate) return; + root._parallaxRefreshGate = false; + Qt.callLater(() => { root._parallaxRefreshGate = true; }); + } + + // Force WlrLayershell surface re-attach by bouncing visible. The + // Qt.callLater is the meaningful delay — Qt needs an event-loop + // tick between destroy and recreate. + function _reattachSurface() { + wallpaperWindow.visible = false; + Qt.callLater(() => { wallpaperWindow.visible = true; }); + } + Connections { target: wallpaperWindow function onWidthChanged() { @@ -126,6 +190,8 @@ Variants { function onScreensChanged() { root._renderSettling = true; renderSettleTimer.restart(); + root._reanchorScroll(); + root._refreshParallaxPipeline(); } } @@ -135,6 +201,8 @@ Variants { root._recheckScreenScale(); root._renderSettling = true; renderSettleTimer.restart(); + root._reanchorScroll(); + root._refreshParallaxPipeline(); } } @@ -144,6 +212,8 @@ Variants { root._recheckScreenScale(); root._renderSettling = true; renderSettleTimer.restart(); + root._reanchorScroll(); + root._refreshParallaxPipeline(); } } @@ -173,6 +243,8 @@ Variants { } else { root._recheckScreenScale(); } + root._reanchorScroll(); + root._refreshParallaxPipeline(); } } @@ -182,6 +254,11 @@ Variants { if (!IdleService.isShellLocked) { root._renderSettling = true; renderSettleTimer.restart(); + // Unilateral re-attach on every unlock for parallax- + // active surfaces — catches both detected rebinds and + // silent ones the lifecycle signals miss. Non-parallax + // wallpapers don't have the wedge risk so leave them be. + if (root.effectiveScrolling) root._reattachSurface(); } } } @@ -200,6 +277,8 @@ Variants { function getFillMode(modeName) { switch (modeName) { + case "Scrolling": + return Image.PreserveAspectCrop; case "Stretch": return Image.Stretch; case "Fit": @@ -221,14 +300,209 @@ Variants { } } + function updateWorkspaceData() { + if (!scrollingEnabled) return; + + let newTargetX = 50.0; + let newTargetY = 50.0; + + if (CompositorService.isNiri) { + const outputWorkspaces = NiriService.allWorkspaces.filter( + ws => ws.output === modelData.name + ); + totalWorkspaces = outputWorkspaces.length; + + const activeWs = outputWorkspaces.find(ws => ws.is_active); + currentWorkspaceIndex = activeWs ? activeWs.idx : 0; + + const scrollPercent = totalWorkspaces > 1 + ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 + : 0.0; + + newTargetY = scrollPercent; + } else if (CompositorService.isHyprland) { + const workspaces = Hyprland.workspaces?.values || []; + const monitorWorkspaces = workspaces.filter( + ws => ws.monitor?.name === modelData.name + ).sort((a, b) => a.id - b.id); + + totalWorkspaces = monitorWorkspaces.length; + const focusedId = Hyprland.focusedWorkspace?.id; + currentWorkspaceIndex = monitorWorkspaces.findIndex(ws => ws.id === focusedId); + + if (currentWorkspaceIndex < 0) currentWorkspaceIndex = 0; + + const scrollPercent = totalWorkspaces > 1 + ? ((currentWorkspaceIndex - 1) / (totalWorkspaces - 1)) * 100.0 + : 0.0; + + newTargetX = scrollPercent; + } + + scrollAnim.startAnimation(newTargetX, newTargetY); + } + + property bool firstScrollUpdate: true + + QtObject { + id: scrollAnim + property real startTime: 0 + property real startX: 0.0 + property real startY: 0.0 + property real targetX: 0.0 + property real targetY: 0.0 + + property real damping: CompositorService.isNiri ? 63.25 : 89.44 + property real stiffness: CompositorService.isNiri ? 1000.0 : 2000.0 + property real mass: 1.0 + + function springPositionJS(t, from, to) { + if (t <= 0) return from; + const beta = damping / (2 * mass); + const omega0 = Math.sqrt(stiffness / mass); + const x0 = from - to; + const envelope = Math.exp(-beta * t); + if (Math.abs(x0 * envelope) < 0.01) return to; + + if (Math.abs(beta - omega0) < 0.0001) { + return to + envelope * (x0 + beta * x0 * t); + } else if (beta < omega0) { + const omega1 = Math.sqrt(omega0 * omega0 - beta * beta); + return to + envelope * (x0 * Math.cos(omega1 * t) + (beta * x0 / omega1) * Math.sin(omega1 * t)); + } else { + const omega2 = Math.sqrt(beta * beta - omega0 * omega0); + const cosh = (x) => (Math.exp(x) + Math.exp(-x)) / 2; + const sinh = (x) => (Math.exp(x) - Math.exp(-x)) / 2; + return to + envelope * (x0 * cosh(omega2 * t) + (beta * x0 / omega2) * sinh(omega2 * t)); + } + } + + function startAnimation(newTargetX, newTargetY) { + const now = Date.now() / 1000.0; + const t = Math.max(0, frameAnim.currentTime - startTime); + const currentX = springPositionJS(t, startX, targetX); + const currentY = springPositionJS(t, startY, targetY); + + if (Math.abs(newTargetX - currentX) < 0.01 && Math.abs(newTargetY - currentY) < 0.01) { + if (root.firstScrollUpdate) root.firstScrollUpdate = false; + return; + } + + // First update: use much stiffer spring for quick snap-to + if (root.firstScrollUpdate) { + root.firstScrollUpdate = false; + damping = 200.0; + stiffness = 8000.0; + } else { + // Restore normal spring parameters + damping = CompositorService.isNiri ? 63.25 : 89.44; + stiffness = CompositorService.isNiri ? 1000.0 : 2000.0; + } + + startX = currentX; + startY = currentY; + targetX = newTargetX; + targetY = newTargetY; + startTime = frameAnim.running ? frameAnim.currentTime : now; + if (!frameAnim.running) { + frameAnim.currentTime = now; + frameAnim.running = true; + } + } + } + + // CPU-side scroll position - computed once per frame instead of per-pixel in shader + // Initialize at (0, 0) to avoid pillarbox flash; first update will snap to correct position + property real currentScrollX: 0.0 + property real currentScrollY: 0.0 + + function publishScrollPosition() { + if (effectiveScrolling) { + SessionData.setMonitorScrollPosition(modelData.name, currentScrollX, currentScrollY); + } else { + // Not scrolling - publish centered (50, 50) + SessionData.setMonitorScrollPosition(modelData.name, 50, 50); + } + } + + FrameAnimation { + id: frameAnim + running: false + + property real currentTime: 0 + + onRunningChanged: { + if (running) { + currentTime = Date.now() / 1000.0; + } else { + root.publishScrollPosition(); // Animation settled + // Hold the render loop open so the final settled frame + // commits before updatesEnabled drops out from under us. + root._renderSettling = true; + renderSettleTimer.restart(); + } + } + + onTriggered: { + // Clamp huge frameTime from a paused-render-loop wakeup; + // otherwise the spring's `t` jumps past settling. + const dt = frameTime > 0.1 ? 0.0 : frameTime; + currentTime += dt; + + const t = currentTime - scrollAnim.startTime; + root.currentScrollX = scrollAnim.springPositionJS(t, scrollAnim.startX, scrollAnim.targetX); + root.currentScrollY = scrollAnim.springPositionJS(t, scrollAnim.startY, scrollAnim.targetY); + + const settledX = Math.abs(scrollAnim.targetX - root.currentScrollX) < 0.01; + const settledY = Math.abs(scrollAnim.targetY - root.currentScrollY) < 0.01; + + if (settledX && settledY) { + running = false; + } + } + } + Component.onCompleted: { if (typeof wallpaperWindow.updatesEnabled !== "undefined") - wallpaperWindow.updatesEnabled = Qt.binding(() => !root.source || root.effectActive || root._renderSettling || root.overviewBlurActive || root._overviewBlurSettling || root.pendingWallpaper !== "" || root._deferredSource !== "" || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading); + wallpaperWindow.updatesEnabled = Qt.binding(() => !root.source || root.effectActive || root._renderSettling || root.overviewBlurActive || root._overviewBlurSettling || root.pendingWallpaper !== "" || root._deferredSource !== "" || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading); if (!source) { root._renderSettling = false; } isInitialized = true; + + if (scrollingEnabled) { + updateWorkspaceData(); + } + + Qt.callLater(publishScrollPosition); + + // Detect rebind via _seenScreens; schedule surface re-attach + // (deferred to unlock if locked). + const wasSeen = variants._seenScreens[modelData.name] === true; + variants._seenScreens[modelData.name] = true; + // If currently locked, the unlock handler will re-attach; + // otherwise re-attach now. + if (wasSeen && root.effectiveScrolling && !IdleService.isShellLocked) { + root._reattachSurface(); + } + } + + Component.onDestruction: { + SessionData.clearMonitorScrollPosition(modelData.name); + } + + onScrollingEnabledChanged: { + if (scrollingEnabled) { + firstScrollUpdate = true; + updateWorkspaceData(); + } else { + frameAnim.stop(); + } + } + + onEffectiveScrollingChanged: { + publishScrollPosition(); } onSourceChanged: { @@ -263,6 +537,17 @@ Variants { root.screenScale = CompositorService.getScreenScale(modelData); currentWallpaper.source = newSource; nextWallpaper.source = ""; + + // Reset scroll state for new image - will snap to correct position on first update + if (scrollingEnabled) { + firstScrollUpdate = true; + currentScrollX = 0.0; + currentScrollY = 0.0; + scrollAnim.startX = 0.0; + scrollAnim.startY = 0.0; + scrollAnim.targetX = 0.0; + scrollAnim.targetY = 0.0; + } } function startTransition() { @@ -299,6 +584,11 @@ Variants { return; } + if (root.effectiveScrolling) { + setWallpaperImmediate(newPath); + return; + } + if (root.transitionType === "random") { root.actualTransitionType = SessionData.includedTransitions.length === 0 ? "none" : SessionData.includedTransitions[Math.floor(Math.random() * SessionData.includedTransitions.length)]; } @@ -348,18 +638,70 @@ Variants { property int textureWidth: Math.min(Math.round(modelData.width * screenScale), maxTextureSize) property int textureHeight: Math.min(Math.round(modelData.height * screenScale), maxTextureSize) + QtObject { + id: imageMetrics + property real nativeWidth: 0 + property real nativeHeight: 0 + property bool ready: nativeWidth > 0 && nativeHeight > 0 + + function capture(w, h) { + if (nativeWidth === 0 && w > 0) { + nativeWidth = w; + nativeHeight = h; + } + } + + function reset() { + nativeWidth = 0; + nativeHeight = 0; + } + + readonly property real canvasWidth: { + if (!ready || !root.effectiveScrolling) return root.textureWidth; + const imageAspect = nativeWidth / nativeHeight; + const screenAspect = root.textureWidth / root.textureHeight; + if (imageAspect < screenAspect) { + return root.textureWidth; + } else { + return root.textureHeight * imageAspect; + } + } + + readonly property real canvasHeight: { + if (!ready || !root.effectiveScrolling) return root.textureHeight; + const imageAspect = nativeWidth / nativeHeight; + const screenAspect = root.textureWidth / root.textureHeight; + if (imageAspect < screenAspect) { + return root.textureWidth / imageAspect; + } else { + return root.textureHeight; + } + } + } + Image { id: currentWallpaper anchors.fill: parent - visible: true + visible: !root.effectiveScrolling opacity: 1 layer.enabled: false asynchronous: true retainWhileLoading: true smooth: true cache: true + sourceSize: Qt.size(root.textureWidth, root.textureHeight) fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name)) + + onStatusChanged: { + if (status === Image.Ready) { + imageMetrics.capture(implicitWidth, implicitHeight); + } + } + + onSourceChanged: { + imageMetrics.reset(); + } } Image { @@ -372,6 +714,7 @@ Variants { retainWhileLoading: true smooth: true cache: true + sourceSize: Qt.size(root.textureWidth, root.textureHeight) fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name)) @@ -427,6 +770,78 @@ Variants { recursive: false } + // Parallax scrolling pipeline — bypasses transition machinery. + Image { + id: parallaxImage + visible: false + width: imageMetrics.canvasWidth + height: imageMetrics.canvasHeight + source: root.effectiveScrolling ? currentWallpaper.source : "" + asynchronous: true + smooth: true + cache: true + sourceSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight) + fillMode: Image.Stretch + } + + ShaderEffectSource { + id: srcParallax + sourceItem: root.effectiveScrolling && imageMetrics.ready && parallaxImage.status === Image.Ready ? parallaxImage : null + hideSource: false + live: true + mipmap: false + recursive: false + textureSize: Qt.size(imageMetrics.canvasWidth, imageMetrics.canvasHeight) + } + + // Pre-computed UV parameters for shader + QtObject { + id: parallaxUV + readonly property real imageAspect: imageMetrics.ready ? imageMetrics.canvasWidth / imageMetrics.canvasHeight : 1.0 + readonly property real screenAspect: root.textureWidth / root.textureHeight + + // Scale factor to fit image to screen (preserving aspect, cropping excess) + readonly property real scale: Math.max(root.textureWidth / imageMetrics.canvasWidth, root.textureHeight / imageMetrics.canvasHeight) + readonly property real scaledWidth: imageMetrics.canvasWidth * scale + readonly property real scaledHeight: imageMetrics.canvasHeight * scale + + // UV scale: portion of texture visible on screen + readonly property real uvScaleX: root.textureWidth / scaledWidth + readonly property real uvScaleY: root.textureHeight / scaledHeight + + // Scroll range: how much UV space we can scroll through + // Only allow scrolling in the dimension where image exceeds screen + readonly property real scrollRangeX: imageAspect > screenAspect + 0.01 ? (1.0 - uvScaleX) : (1.0 - uvScaleX) * 0.5 + readonly property real scrollRangeY: imageAspect < screenAspect - 0.01 ? (1.0 - uvScaleY) : (1.0 - uvScaleY) * 0.5 + readonly property bool scrollsHorizontal: imageAspect > screenAspect + 0.01 + readonly property bool scrollsVertical: imageAspect < screenAspect - 0.01 + } + + Loader { + id: parallaxLoader + anchors.fill: parent + active: root._parallaxRefreshGate && root.effectiveScrolling && !root.effectActive && imageMetrics.ready && parallaxImage.status === Image.Ready + sourceComponent: parallaxScrollComp + } + + Component { + id: parallaxScrollComp + ShaderEffect { + anchors.fill: parent + + property variant source: srcParallax.sourceItem ? srcParallax : srcDummy + + property real scrollX: root.currentScrollX + property real scrollY: root.currentScrollY + property real uvScaleX: parallaxUV.uvScaleX + property real uvScaleY: parallaxUV.uvScaleY + property real scrollRangeX: parallaxUV.scrollsHorizontal ? parallaxUV.scrollRangeX : 0.0 + property real scrollRangeY: parallaxUV.scrollsVertical ? parallaxUV.scrollRangeY : 0.0 + + fragmentShader: Qt.resolvedUrl("../Shaders/qsb/wp_parallax_scroll.frag.qsb") + } + } + Loader { id: effectLoader anchors.fill: parent @@ -647,7 +1062,7 @@ Variants { sourceComponent: MultiEffect { anchors.fill: parent - source: effectLoader.active ? effectLoader.item : currentWallpaper + source: effectLoader.active ? effectLoader.item : (parallaxLoader.active ? parallaxLoader.item : currentWallpaper) blurEnabled: true blur: 0.8 blurMax: 75 diff --git a/quickshell/Shaders/frag/wp_fade.frag b/quickshell/Shaders/frag/wp_fade.frag index 31a404d38..ef5eb6690 100644 --- a/quickshell/Shaders/frag/wp_fade.frag +++ b/quickshell/Shaders/frag/wp_fade.frag @@ -13,7 +13,7 @@ layout(std140, binding = 0) uniform buf { float progress; // Fill mode parameters - float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad + float fillMode; // 0=stretch, 1=fit, 2=crop, 3=tile, 4=tileV, 5=tileH, 6=pad, 7=scroll float imageWidth1; // Width of source1 image float imageHeight1; // Height of source1 image float imageWidth2; // Width of source2 image @@ -21,6 +21,10 @@ layout(std140, binding = 0) uniform buf { float screenWidth; // Screen width float screenHeight; // Screen height vec4 fillColor; // Fill color for empty areas (default: black) + + // Scroll position (0-100 range, only used when fillMode >= 6.5) + float scrollX; + float scrollY; } ubuf; vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { @@ -54,12 +58,33 @@ vec2 calculateUV(vec2 uv, float imgWidth, float imgHeight) { vec2 tileUV = uv * vec2(ubuf.screenWidth, ubuf.screenHeight) / vec2(imgWidth, imgHeight); transformedUV = vec2(fract(tileUV.x), uv.y); } - else { + else if (ubuf.fillMode < 6.5) { + // fillMode 6 = Pad vec2 screenPixel = uv * vec2(ubuf.screenWidth, ubuf.screenHeight); vec2 imageOffset = (vec2(ubuf.screenWidth, ubuf.screenHeight) - vec2(imgWidth, imgHeight)) * 0.5; vec2 imagePixel = screenPixel - imageOffset; transformedUV = imagePixel / vec2(imgWidth, imgHeight); } + else { + // fillMode 7 = Scroll (Crop with variable offset) + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledImageSize = vec2(imgWidth, imgHeight) * scale; + vec2 offset = (scaledImageSize - vec2(ubuf.screenWidth, ubuf.screenHeight)) / scaledImageSize; + + // Determine scroll axis based on aspect ratio + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + vec2 scrollOffset = vec2( + scrollHorizontal ? offset.x * (ubuf.scrollX / 100.0) : offset.x * 0.5, + scrollVertical ? offset.y * (ubuf.scrollY / 100.0) : offset.y * 0.5 + ); + + transformedUV = uv * (vec2(1.0) - offset) + scrollOffset; + } return transformedUV; } diff --git a/quickshell/Shaders/frag/wp_parallax.frag b/quickshell/Shaders/frag/wp_parallax.frag new file mode 100644 index 000000000..c5929df22 --- /dev/null +++ b/quickshell/Shaders/frag/wp_parallax.frag @@ -0,0 +1,72 @@ +// ===== wp_parallax.frag ===== +// Parallax wallpaper shader - optimized version with CPU-side spring calculation +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source1; +layout(binding = 2) uniform sampler2D source2; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + float progress; + + // Pre-computed scroll position from CPU (replaces per-pixel spring calculation) + float scrollX; + float scrollY; + + float imageWidth1; + float imageHeight1; + float imageWidth2; + float imageHeight2; + float screenWidth; + float screenHeight; + vec4 fillColor; +} ubuf; + +vec2 calculateParallaxUV(vec2 uv, float imgWidth, float imgHeight) { + float imageAspect = imgWidth / imgHeight; + float screenAspect = ubuf.screenWidth / ubuf.screenHeight; + + bool scrollHorizontal = imageAspect > screenAspect + 0.01; + bool scrollVertical = imageAspect < screenAspect - 0.01; + + float scale = max(ubuf.screenWidth / imgWidth, ubuf.screenHeight / imgHeight); + vec2 scaledSize = vec2(imgWidth, imgHeight) * scale; + + vec2 uvScale = vec2(ubuf.screenWidth, ubuf.screenHeight) / scaledSize; + vec2 uvScrollRange = vec2(1.0) - uvScale; + + vec2 scrollOffset = vec2( + scrollHorizontal ? (ubuf.scrollX / 100.0) * uvScrollRange.x : uvScrollRange.x * 0.5, + scrollVertical ? (ubuf.scrollY / 100.0) * uvScrollRange.y : uvScrollRange.y * 0.5 + ); + + return uv * uvScale + scrollOffset; +} + +vec4 sampleParallax(sampler2D tex, vec2 uv, float imgWidth, float imgHeight) { + if (imgWidth <= 0.0 || imgHeight <= 0.0) { + return ubuf.fillColor; + } + + vec2 transformedUV = calculateParallaxUV(uv, imgWidth, imgHeight); + + if (transformedUV.x < 0.0 || transformedUV.x > 1.0 || + transformedUV.y < 0.0 || transformedUV.y > 1.0) { + return ubuf.fillColor; + } + + return texture(tex, transformedUV); +} + +void main() { + vec2 uv = qt_TexCoord0; + + vec4 color1 = sampleParallax(source1, uv, ubuf.imageWidth1, ubuf.imageHeight1); + vec4 color2 = sampleParallax(source2, uv, ubuf.imageWidth2, ubuf.imageHeight2); + + fragColor = mix(color1, color2, ubuf.progress) * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/frag/wp_parallax_scroll.frag b/quickshell/Shaders/frag/wp_parallax_scroll.frag new file mode 100644 index 000000000..99e0ce2e9 --- /dev/null +++ b/quickshell/Shaders/frag/wp_parallax_scroll.frag @@ -0,0 +1,35 @@ +// ===== wp_parallax_scroll.frag ===== +// Minimal parallax scrolling shader - single texture, UV offset only +// For performance debugging - isolated from transition effects +#version 450 + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(binding = 1) uniform sampler2D source; + +layout(std140, binding = 0) uniform buf { + mat4 qt_Matrix; + float qt_Opacity; + + float scrollX; // 0-100 scroll position + float scrollY; // 0-100 scroll position + float uvScaleX; // Pre-computed: screenWidth / scaledImageWidth + float uvScaleY; // Pre-computed: screenHeight / scaledImageHeight + float scrollRangeX; // Pre-computed: 1.0 - uvScaleX (or 0 if not scrollable) + float scrollRangeY; // Pre-computed: 1.0 - uvScaleY (or 0 if not scrollable) +} ubuf; + +void main() { + vec2 uv = qt_TexCoord0; + + // Apply UV scale and scroll offset + vec2 scrollOffset = vec2( + ubuf.scrollRangeX * (ubuf.scrollX / 100.0), + ubuf.scrollRangeY * (ubuf.scrollY / 100.0) + ); + + vec2 finalUV = uv * vec2(ubuf.uvScaleX, ubuf.uvScaleY) + scrollOffset; + + fragColor = texture(source, finalUV) * ubuf.qt_Opacity; +} diff --git a/quickshell/Shaders/qsb/wp_fade.frag.qsb b/quickshell/Shaders/qsb/wp_fade.frag.qsb index a91469598420110aa396eac01d30734ca3f61e9d..75a6ba131cb22b92eb430e74c862a9e7b8618c68 100644 GIT binary patch literal 6286 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 EA|!X4NdN!< literal 5123 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 diff --git a/quickshell/Shaders/qsb/wp_parallax.frag.qsb b/quickshell/Shaders/qsb/wp_parallax.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..47c5edbb8db8d88d250f5a4eb485c560b2ae65b9 GIT binary patch literal 3975 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 literal 0 HcmV?d00001 diff --git a/quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb b/quickshell/Shaders/qsb/wp_parallax_scroll.frag.qsb new file mode 100644 index 0000000000000000000000000000000000000000..9a3d462f6dc354decbd94917fd644d68f8078eb3 GIT binary patch literal 1693 zcmV;O24eXD02w=Yob6fbZ`)K9zfB+6(mlE-9UH}4*_Lc2%}P4P`k>IjHmOsqCX`g6 zvRuc>436FTk+n?yfDjU&_{iVHNB$hXAUNmVbDbL}X*VR0V7PMZ^YiaK?s?oe8)NJw zV{8P@Avh=5a~3k4RhY&0*(&qk&u4X3fM+Q<*&lSUJ_i~&&l;d$uqIb_xVkWZtm+us zWf7<|b^@;Q{wsxbf;$iqJ_RQs7`Gu##>R=UqOd3A%|%jKXM3Pzf=b8^$mxoVE_s|` z0Z@Iu2!~aPUV1h9HqjpsqEEo;+N=yJ2GFh8{@SZ5NDmu>6eha@+5|Nhiqi2^d0j>6 zu{uam9!NR`Mmz}F2U8ZvSt|U{QHrm6jM25OKqrP%(`mFn4qdWB=sD&Ra)P9xL%Lf zC*&K7THp&Ym zj}wOaXGkA&FbSV$d2NL}1?&l4U%?r4qMvgV!vy(3`8;8lK)ygRqu;wEUsUuj5qC^^ zK1mqLh+pMd`Q=mje2Ug3mDd%V%Ql6o@qQ4J_dxqjgxk%U**w^PNmgLk)`6@8) z!*!nQeMtCyvWM~m!leI2(!UQ;q*!MwK%c(>GD^Bw!;?zQP(GcIZcgWT?#n!uIYf7=D5iU75 zh;v=xOcO5sy+@ep?70Qy*$N-Sb_Y_D*o?L{k|j49P#c^ytfHMKK9Q?)H_oDA=Ud!&=>tGI1iP0mnhyv zCEjJie=D|(QK+-H-Ba!9w&e%5=W6!~h0LCBnKqZ6+0AEr zTEp>-aG?jbT>vvd=tpL#?M98v%Zvs`%Lt3w^H6_kgucDMz%gzC!Sy}Ew8H}x4NTv2 zoN6z$twK?|Y#NSL?SpOis%{!?)9PcAX! zEQ_J!)m3PBG!?9Bvsk2jAvdEXx!VcucFfHwUK_S+IA3gm!z=`MM{z5YOqOMSb2;s9 zSFFNV)6qZojb_ORjap#`%!Sr|82Q#*DJv2-BVFad3%96tz+onn=*WE*AE{EOBj*bL z%`W@Dzy9I=`dR6(wN8J{zd8M7yZu_ri$a8pS_9I(>N%eOCbnC=_)e$&g+5EsmpvUL zznZ{rSUxY%L(R9Eb`V;AcDEqY>U%Hrmhm0U*C8<)~s2`b#)I> zDVzki*vpk^PZ0A5gQ(j*)NgyKGSxlY7X!ITyYqtrpuS142M0{OljLL6I58Eui!6$K zFVlFa)d?}lWidbH&Yq(izGpMg6PrPA z9mF=;Q)3O0e(iqTe)G4zXB@xVyym-xml!?TaYVYZN)C^H^-#vt n6Vr*~Im$Ck?;?r!5%FIYc=Y*a=fpf8$z>!bb@=fo3YV1S7BEuk literal 0 HcmV?d00001 From cc8350b5d84dd8b98ec0d0d64f50b59f3a11a5a1 Mon Sep 17 00:00:00 2001 From: hecate cantus Date: Tue, 5 May 2026 13:34:26 -0700 Subject: [PATCH 2/2] fix: keep parallax wallpaper surface in frame schedule during idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the spring settles, the wallpaper window's wayland surface stops committing — `updatesEnabled` drops, and even within the 1s `_renderSettling` tail nothing is actually dirty, so QSG skips render+ commit. The compositor in turn drops the surface from its frame schedule. The next workspace switch then cold-starts: the first ~3 frames land late while the surface is reinserted, producing visible hitching on the leading edge of the spring. Add a 30Hz heartbeat timer that, while parallax is active and the spring isn't running, bumps a `_parallaxHeartbeat` property bound as an unused uniform on the parallax `ShaderEffect`. Property change dirties the effect, QSG renders+commits a single frame per tick, the `wl_surface.frame` callback chain stays alive, and the surface keeps its slot in the compositor's vsync schedule. Heartbeat naturally pauses when `frameAnim.running` is true (real spring motion already drives commits at native rate) and resumes the moment it stops. --- quickshell/Modules/WallpaperBackground.qml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/quickshell/Modules/WallpaperBackground.qml b/quickshell/Modules/WallpaperBackground.qml index 75d0dee2a..43cbda890 100644 --- a/quickshell/Modules/WallpaperBackground.qml +++ b/quickshell/Modules/WallpaperBackground.qml @@ -129,6 +129,9 @@ Variants { property string _deferredSource: "" // ANDed into parallaxLoader.active; bounced to rebuild the ShaderEffect after a wl_output rebind. property bool _parallaxRefreshGate: true + // Bumped by parallaxHeartbeatTimer to keep the wayland surface in the + // compositor's frame schedule when the spring is settled. + property real _parallaxHeartbeat: 0 readonly property bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== "" Connections { @@ -275,6 +278,18 @@ Variants { onTriggered: root._overviewBlurSettling = false } + // Lazy heartbeat: while parallax is active and the spring is settled, + // dirty the scene at ~30Hz so the wayland surface keeps committing. + // Without this, the surface drops out of the compositor's frame + // schedule during idle and the next scroll cold-starts. + Timer { + id: parallaxHeartbeatTimer + interval: 33 + repeat: true + running: root.effectiveScrolling && !frameAnim.running + onTriggered: root._parallaxHeartbeat = (root._parallaxHeartbeat + 1) % 1024 + } + function getFillMode(modeName) { switch (modeName) { case "Scrolling": @@ -833,6 +848,7 @@ Variants { property real scrollX: root.currentScrollX property real scrollY: root.currentScrollY + property real heartbeat: root._parallaxHeartbeat property real uvScaleX: parallaxUV.uvScaleX property real uvScaleY: parallaxUV.uvScaleY property real scrollRangeX: parallaxUV.scrollsHorizontal ? parallaxUV.scrollRangeX : 0.0