From 36c8df31e274c279f90e57ea8a9ac49dcd711a5b Mon Sep 17 00:00:00 2001 From: CPritch Date: Sat, 10 Oct 2026 20:13:44 +0100 Subject: [PATCH] feat: zoom in further for a stronger parallax The margin the shader zooms in by was fixed at 2.5% each side, enough for an intensity of 0.05. It now grows to half the intensity when that's more, so stronger settings no longer pull in the stretched edge. --- CHANGELOG.md | 1 + crates/render/src/renderer.rs | 45 ++++++++++++++++++++++++++++------- crates/render/src/shader.wgsl | 10 ++++---- docs/configuration.md | 2 +- 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1677d76..071369d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - shiftpaperd no longer needs a GPU with 16-bit normalised textures, as depth maps now go to the GPU as 16-bit floats. - The renderer is now its own crate, `shiftpaper-render`, with no Wayland code, so other front ends can use it. +- A `parallax_intensity` above 0.05 zooms the wallpaper in a little more, so the biggest shifts no longer stretch its edges. ## 0.4.3 - 2026-10-09 diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 88719d9..afd7039 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -293,10 +293,10 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { (0..3).map(|i| (a[i] - b[i]).powi(2)).sum::().sqrt() } -/// Screen uv to image uv for an image cropped by `uv_scale`, as crop in -/// shader.wgsl, on one axis. +/// Screen uv to image uv for an image cropped and zoomed by `uv_scale`, +/// from `zoomed`, as crop in shader.wgsl, on one axis. fn to_image(screen: f32, uv_scale: f32) -> f32 { - 0.5 + (screen - 0.5) * uv_scale * (1.0 - 2.0 * MARGIN) + 0.5 + (screen - 0.5) * uv_scale } /// The cell of a DepthGrid that image uv `image` is in, on one axis. @@ -374,8 +374,7 @@ impl ScreenCell { /// The cells of `grid` that show on a screen, once cropped by `uv_scale`. fn screen_cells(grid: &DepthGrid, uv_scale: [f32; 2]) -> Vec { // As crop in shader.wgsl, backwards. - let to_screen = - |image: f32, axis: usize| 0.5 + (image - 0.5) / (uv_scale[axis] * (1.0 - 2.0 * MARGIN)); + let to_screen = |image: f32, axis: usize| 0.5 + (image - 0.5) / uv_scale[axis]; let mut cells = Vec::with_capacity(grid.cells.len()); for row in 0..GRID_CELLS { let top = to_screen(row as f32 / GRID_CELLS as f32, 1).max(0.0); @@ -434,10 +433,24 @@ impl Random { } } -/// How far the shader zooms in on each side. Must match MARGIN in +/// How far the shader zooms in on each side, at least. Must match MARGIN in /// shader.wgsl. const MARGIN: f32 = 0.025; +/// How far the shader zooms in on each side at `intensity`: enough that the +/// biggest shift still pulls in real pixels from beyond the screen, and at +/// least MARGIN. As crop in shader.wgsl. +fn margin(intensity: f32) -> f32 { + MARGIN.max(0.5 * intensity) +} + +/// `uv_scale` zoomed in by the margin at `intensity`, as crop in +/// shader.wgsl does: how much of the image shows on each axis. +fn zoomed(uv_scale: [f32; 2], intensity: f32) -> [f32; 2] { + let zoom = 1.0 - 2.0 * margin(intensity); + [uv_scale[0] * zoom, uv_scale[1] * zoom] +} + /// A wallpaper's images decoded into memory, ready to upload. Decoding is /// the slow part, so it can happen off the main thread. pub struct DecodedWallpaper { @@ -512,6 +525,7 @@ fn fit_scale((width, height): (u32, u32), screens: &[(u32, u32)]) -> Option .map(|&(w, h)| f32::max(w as f32 / width as f32, h as f32 / height as f32)) .fold(0.0, f32::max) // The shader zooms in a little, which needs a little more detail. + // A stronger parallax zooms in a few percent more, too little to see. / (1.0 - 2.0 * MARGIN); (needed > 0.0 && needed < 1.0).then_some(needed) } @@ -558,6 +572,8 @@ pub struct OutputRenderState { uniform_buffer: wgpu::Buffer, current_offset: (f32, f32), pub target_offset: (f32, f32), + /// The intensity `step_and_write` last drew with, which sets the margin. + intensity: f32, } impl OutputRenderState { @@ -578,6 +594,7 @@ impl OutputRenderState { uniform_buffer, current_offset: (0.0, 0.0), target_offset: (0.0, 0.0), + intensity: 0.0, } } @@ -611,7 +628,8 @@ impl OutputRenderState { } self.bind_group = renderer.create_bind_group(&self.current, &next, &self.uniform_buffer); let (x, y) = self.current_offset; - let uv_scale = cover_uv_scale(self.current.size, (self.config.width, self.config.height)); + let screen = (self.config.width, self.config.height); + let uv_scale = zoomed(cover_uv_scale(self.current.size, screen), self.intensity); let trail = match style { Transition::Dissolve => { let seed = SystemTime::now() @@ -658,6 +676,7 @@ impl OutputRenderState { /// uniforms. Returns whether anything changed, so callers can skip /// redrawing once the offset has settled and no transition is running. pub fn step_and_write(&mut self, queue: &wgpu::Queue, intensity: f32) -> bool { + self.intensity = intensity; let (tx, ty) = self.target_offset; let (cx, cy) = self.current_offset; let nx = cx + (tx - cx) * 0.3; @@ -1276,7 +1295,7 @@ mod tests { ) -> f32 { // As crop, load_depth and scene_point in shader.wgsl. let point = |s: [f32; 2]| { - let image = |axis: usize| 0.5 + (s[axis] - 0.5) * uv_scale[axis] * (1.0 - 2.0 * MARGIN); + let image = |axis: usize| 0.5 + (s[axis] - 0.5) * uv_scale[axis]; let x = ((image(0) * map.width as f32) as u32).min(map.width - 1); let y = ((image(1) * map.height as f32) as u32).min(map.height - 1); let depth = f32::from(map.data[(y * map.width + x) as usize]) / 65535.0; @@ -1459,6 +1478,16 @@ mod tests { assert!(include_str!("shader.wgsl").contains(&line), "{line}"); } + #[test] + fn the_margin_covers_the_biggest_shift() { + for intensity in [0.0, 0.025, 0.05, 0.08, 0.2] { + // Offsets reach 0.5 and depths 1, so nothing shifts further. + let shift = 0.5 * intensity; + assert!(margin(intensity) >= shift.max(MARGIN), "{intensity}"); + } + assert!(include_str!("shader.wgsl").contains("max(MARGIN, 0.5 * u.intensity)")); + } + #[test] fn large_images_shrink_to_cover_the_largest_screen() { // A 16:10 screen is wider than a 3:2 image, so width sets the size. diff --git a/crates/render/src/shader.wgsl b/crates/render/src/shader.wgsl index d5dd1af..0f6bf99 100644 --- a/crates/render/src/shader.wgsl +++ b/crates/render/src/shader.wgsl @@ -63,9 +63,10 @@ struct Uniforms { }; @group(0) @binding(3) var u: Uniforms; -// Zoom in 2.5% each side so displacement pulls in real pixels from beyond -// the visible area instead of stretching the edges. Must match MARGIN in -// renderer.rs. +// Zoom in each side so displacement pulls in real pixels from beyond the +// visible area instead of stretching the edges: by at least 2.5%, and by +// half the intensity, as far as anything shifts. Must match MARGIN and +// `margin` in renderer.rs. const MARGIN: f32 = 0.025; // Transitions. Must match `shader_style` in renderer.rs. const SWEEP_IN: u32 = 0u; @@ -103,7 +104,8 @@ const BAND: f32 = 0.08; // Crop to the screen's aspect ratio and apply the margin. fn crop(uv: vec2, scale: vec2) -> vec2 { - return 0.5 + (uv - 0.5) * scale * (1.0 - 2.0 * MARGIN); + let margin = max(MARGIN, 0.5 * u.intensity); + return 0.5 + (uv - 0.5) * scale * (1.0 - 2.0 * margin); } fn load_depth(tex: texture_2d, uv: vec2) -> f32 { diff --git a/docs/configuration.md b/docs/configuration.md index 1980cc7..c470e32 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -23,7 +23,7 @@ transition_secs = 2 | Setting | Default | What it does | |---|---|---| -| `parallax_intensity` | `0.025` | How far the wallpaper moves. Past about `0.05`, the edges of the image start to show. | +| `parallax_intensity` | `0.025` | How far the wallpaper moves. Past about `0.05`, it zooms in a little more to keep the edges of the image hidden. | | `transition` | `"sweep-in"` | How one wallpaper changes into the next. See [transitions](transitions.md). | | `transition_secs` | `3` | How long a transition takes, in seconds. `0` switches straight away. | | `tracking_mode` | `"pointer"` | How the cursor is followed. See below. Needs a restart. |