Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 37 additions & 8 deletions crates/render/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,10 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
(0..3).map(|i| (a[i] - b[i]).powi(2)).sum::<f32>().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.
Expand Down Expand Up @@ -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<ScreenCell> {
// 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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -512,6 +525,7 @@ fn fit_scale((width, height): (u32, u32), screens: &[(u32, u32)]) -> Option<f32>
.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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -578,6 +594,7 @@ impl OutputRenderState {
uniform_buffer,
current_offset: (0.0, 0.0),
target_offset: (0.0, 0.0),
intensity: 0.0,
}
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions crates/render/src/shader.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ struct Uniforms {
};
@group(0) @binding(3) var<uniform> 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;
Expand Down Expand Up @@ -103,7 +104,8 @@ const BAND: f32 = 0.08;

// Crop to the screen's aspect ratio and apply the margin.
fn crop(uv: vec2<f32>, scale: vec2<f32>) -> vec2<f32> {
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<f32>, uv: vec2<f32>) -> f32 {
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading