1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-24 12:05:21 -04:00

shader-based scrolling wallpaper mode (#1802)

* feat: parallax-scroll wallpaper

Add a `Scrolling` wallpaper fill mode that translates the wallpaper crop
with the active workspace — like Android home-screen parallax, but along
niri's vertical workspace axis.

The image is scaled to cover the screen on its non-scroll axis, and the
active workspace index drives a fractional offset into the cropped
overflow along the scroll axis. Scroll position is spring-animated
CPU-side and handed to a minimal single-texture shader as a UV offset.
Per-monitor scroll position is published into SessionData so the lock
screen renders the same crop as the active workspace, keeping visual
continuity across lock/unlock.

Two implementation details worth calling out for review:

- QSG_USE_SIMPLE_ANIMATION_DRIVER=1 is exported to the spawned quickshell
  process. The default animation driver advances in fixed ~16ms steps,
  capping the scroll at 60Hz and desyncing it from compositor motion on
  high-refresh displays; the simple driver advances by real elapsed time,
  restoring native-refresh pacing. Removing it visibly regresses to 60Hz.

- The wallpaper survives wl_output rebind cycles (e.g. OLED image-cleaning
  on DPMS soft-off), which otherwise leave a stuck or void background.
  Recovery re-anchors the scroll target on output-lifecycle signals,
  rebuilds the ShaderEffect against the current render context, and
  re-attaches the wallpaper-layer surface on unlock for parallax-active
  monitors — guarded against lock state so the shader gets reliable frame
  hints.

* simplify bindings and gate lock screen shader in a loader

---------

Co-authored-by: bbedward <bbedward@gmail.com>
This commit is contained in:
hecate cantus
2026-06-23 21:14:42 -07:00
committed by GitHub
parent aea5189abb
commit 1a39b7f66c
10 changed files with 590 additions and 20 deletions
+20
View File
@@ -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")
+17
View File
@@ -2015,6 +2015,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;
+68 -13
View File
@@ -200,21 +200,22 @@ Item {
}
}
Image {
Loader {
id: wallpaperBackground
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
readonly property string wallpaperSource: {
var w = SessionData.getMonitorWallpaper(screenName);
return (w && !w.startsWith("#")) ? encodeFileUrl(w) : "";
}
readonly property string fillModeName: SessionData.getMonitorWallpaperFillMode(screenName)
active: wallpaperSource !== ""
asynchronous: false
sourceComponent: fillModeName === "Scrolling" ? scrollWallpaperComp : plainWallpaperComp
layer.enabled: true
layer.effect: MultiEffect {
autoPaddingEnabled: false
blurEnabled: true
@@ -231,6 +232,60 @@ Item {
}
}
Component {
id: plainWallpaperComp
Image {
source: wallpaperBackground.wallpaperSource
fillMode: Theme.getFillMode(wallpaperBackground.fillModeName)
smooth: true
cache: true
asynchronous: false
}
}
Component {
id: scrollWallpaperComp
Item {
Image {
id: scrollSource
anchors.fill: parent
visible: false
source: wallpaperBackground.wallpaperSource
asynchronous: false
cache: true
}
ShaderEffectSource {
id: scrollSrc
sourceItem: scrollSource
hideSource: true
live: false
}
ShaderEffect {
anchors.fill: parent
readonly property var scrollPos: SessionData.getMonitorScrollPosition(screenName)
property variant source1: scrollSrc
property variant source2: scrollSrc
property real progress: 0.0
property real fillMode: Theme.getShaderFillMode(wallpaperBackground.fillModeName)
property real scrollX: scrollPos.scrollX
property real scrollY: scrollPos.scrollY
property real imageWidth1: scrollSource.implicitWidth > 0 ? scrollSource.implicitWidth : 1
property real imageHeight1: scrollSource.implicitHeight > 0 ? scrollSource.implicitHeight : 1
property real imageWidth2: imageWidth1
property real imageHeight2: imageHeight1
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")
}
}
}
Rectangle {
anchors.fill: parent
color: "black"
@@ -764,7 +819,7 @@ Item {
property string text: root.passwordBuffer
property int cursorPosition: text.length
signal accepted()
signal accepted
function clampCursorPosition() {
cursorPosition = Math.max(0, Math.min(cursorPosition, text.length));
+2 -2
View File
@@ -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
+414 -3
View File
@@ -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;
@@ -59,6 +63,15 @@ Variants {
property string actualTransitionType: transitionType
property bool isInitialized: false
property string scrollMode: SettingsData.wallpaperFillMode
property bool scrollingEnabled: scrollMode === "Scrolling"
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() {
@@ -70,6 +83,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;
@@ -96,7 +130,7 @@ Variants {
property string _deferredSource: ""
readonly property bool overviewBlurActive: CompositorService.isNiri && SettingsData.blurWallpaperOnOverview && NiriService.inOverview && currentWallpaper.source !== ""
readonly property var backingWindow: Window.window
readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
readonly property bool renderActive: !source || effectActive || overviewBlurActive || pendingWallpaper !== "" || _deferredSource !== "" || frameAnim.running || currentWallpaper.status === Image.Loading || nextWallpaper.status === Image.Loading
property int _settleFrames: 3
function invalidate() {
@@ -128,6 +162,7 @@ Variants {
target: Quickshell
function onScreensChanged() {
root.invalidate();
root._onOutputRebind();
}
}
@@ -150,6 +185,9 @@ Variants {
if (IdleService.isShellLocked)
return;
root.invalidate();
// Catches silent rebinds during lock that no signal reports.
if (root.effectiveScrolling)
surfaceReattach.restart();
}
}
@@ -161,11 +199,38 @@ Variants {
}
}
// Workspace/scale signals don't re-fire when the output list is
// unchanged across a rebind, so re-derive scroll state by hand.
function _onOutputRebind() {
if (!root.scrollingEnabled)
return;
root.firstScrollUpdate = true;
Qt.callLater(root.updateWorkspaceData);
parallaxLoader.rebuild();
if (root.effectiveScrolling && !IdleService.isShellLocked)
surfaceReattach.restart();
}
// Bouncing visible re-attaches a layer surface wedged by a rebind;
// debounced so a burst of signals yields one re-attach.
Timer {
id: surfaceReattach
interval: 0
repeat: false
onTriggered: {
wallpaperWindow.visible = false;
Qt.callLater(() => {
wallpaperWindow.visible = true;
});
}
}
Connections {
target: NiriService
function onDisplayScalesChanged() {
root._recheckScreenScale();
root.invalidate();
root._onOutputRebind();
}
}
@@ -174,6 +239,7 @@ Variants {
function onWlrOutputAvailableChanged() {
root._recheckScreenScale();
root.invalidate();
root._onOutputRebind();
}
}
@@ -187,6 +253,7 @@ Variants {
} else {
root._recheckScreenScale();
}
root._onOutputRebind();
}
}
@@ -210,6 +277,8 @@ Variants {
function getFillMode(modeName) {
switch (modeName) {
case "Scrolling":
return Image.PreserveAspectCrop;
case "Stretch":
return Image.Stretch;
case "Fit":
@@ -231,8 +300,199 @@ 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.invalidate();
}
}
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: {
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) {
surfaceReattach.restart();
}
}
Component.onDestruction: {
SessionData.clearMonitorScrollPosition(modelData.name);
}
onScrollingEnabledChanged: {
if (scrollingEnabled) {
firstScrollUpdate = true;
updateWorkspaceData();
} else {
frameAnim.stop();
}
}
onEffectiveScrollingChanged: {
publishScrollPosition();
}
onSourceChanged: {
@@ -265,6 +525,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() {
@@ -301,6 +572,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)];
}
@@ -347,16 +623,60 @@ 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))
@@ -364,6 +684,13 @@ Variants {
if (status === Image.Error) {
log.warn("failed to load active wallpaper for", modelData.name + ":", source);
}
if (status === Image.Ready) {
imageMetrics.capture(implicitWidth, implicitHeight);
}
}
onSourceChanged: {
imageMetrics.reset();
}
}
@@ -377,6 +704,7 @@ Variants {
retainWhileLoading: true
smooth: true
cache: true
sourceSize: Qt.size(root.textureWidth, root.textureHeight)
fillMode: root.getFillMode(SessionData.getMonitorWallpaperFillMode(modelData.name))
@@ -434,6 +762,89 @@ 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.effectiveScrolling && !root.effectActive && imageMetrics.ready && parallaxImage.status === Image.Ready
sourceComponent: parallaxScrollComp
// Rebuild after a rebind orphans the texture; callLater defeats
// sourceComponent coalescing.
function rebuild() {
if (!active)
return;
sourceComponent = null;
Qt.callLater(() => {
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
@@ -652,7 +1063,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
+27 -2
View File
@@ -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;
}
@@ -0,0 +1,36 @@
// ===== wp_parallax_scroll.frag =====
// Parallax scrolling wallpaper shader: samples a single pre-scaled texture
// and applies a CPU-computed UV offset (scrollX/scrollY) along the overflow
// axis. Independent of the transition-effect shaders.
#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;
}
Binary file not shown.
Binary file not shown.