From 8ce95b59605fbbbee9665a6f825e3da17a53bea7 Mon Sep 17 00:00:00 2001 From: CPritch Date: Sun, 11 Oct 2026 10:27:04 +0100 Subject: [PATCH] feat: play effects over a wallpaper in shiftpaper-render OutputRenderState::start_effect plays a short effect over the wallpaper showing, starting from a point on the screen, for front ends to mark a moment like a phone unlocking. Each moves through the depth of the scene and leaves it exactly as it was: - ripple: waves spread from the point across the screen and back through the scene at once, so they reach near things before far ones beside them, moving and lighting the picture as they pass. - swell: a band rolls from the nearest things to the furthest by depth rank, each part of the scene leaning toward the viewer as it passes. - glint: a band of warm light passes back through the scene the same way, catching the brighter parts most. Effects show over a wallpaper at rest, not during a transition. --- CHANGELOG.md | 4 ++ crates/render/src/lib.rs | 2 +- crates/render/src/renderer.rs | 124 +++++++++++++++++++++++++++++++++- crates/render/src/shader.wgsl | 108 ++++++++++++++++++++++++++++- 4 files changed, 235 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af63b6f..27cbded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - `shiftpaper-render` can show any part of a wallpaper, zoomed in and moved, and pan across it, for front ends that let people frame their photos or follow a launcher's pages. - A wallpaper bigger than the GPU's largest texture is shrunk to fit it, instead of failing to show. +### Added + +- `shiftpaper-render` can play a short effect over a wallpaper at rest, for front ends to mark a moment such as a phone unlocking: `ripple` spreads from a point through the scene, reaching near things before far ones beside them; `swell` rolls from near to far, each part of the scene leaning toward the viewer as it passes; `glint` passes a band of light back through the scene. Each leaves the wallpaper exactly as it was. + ### Fixed - Mali's Vulkan driver no longer crashes compiling the shader. It couldn't cope with textures passed to functions. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b13e2ac..ad0b934 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,6 @@ mod depth; mod renderer; pub use renderer::{ - DecodedWallpaper, OutputRenderState, Renderer, View, Wallpaper, WallpaperFiles, + DecodedWallpaper, Effect, OutputRenderState, Renderer, View, Wallpaper, WallpaperFiles, }; pub use shiftpaper_config::Transition; diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 93bef10..97a3f8f 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -38,6 +38,11 @@ struct Uniforms { /// Where the ground is in each wallpaper, for the tide. ground: [f32; 4], next_ground: [f32; 4], + /// The effect playing, from `shader_effect`, or 0 for none, how far + /// through it is, from 0 to 1, and where it starts from, in screen uv. + effect: u32, + effect_progress: f32, + effect_origin: [f32; 2], } /// How many points a trail can hold. Must match TRAIL_POINTS in @@ -637,6 +642,45 @@ pub struct Wallpaper { pub view: View, } +/// A short effect played over the wallpaper showing, such as as a phone +/// unlocks. Each one moves through the depth of the scene for a moment and +/// leaves it exactly as it was. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Effect { + /// A ripple spreads from a point like a drop in water, over the + /// surface there first and on through the scene, wrapping round things + /// nearer and further away, as the portal does. + Ripple, + /// A swell rolls from the nearest things to the furthest, each pushing + /// toward the viewer for a moment as it passes. + Swell, + /// A band of light passes through the scene from near to far, and each + /// surface lifts a little as it catches it. + Glint, +} + +impl Effect { + pub const ALL: [Effect; 3] = [Effect::Ripple, Effect::Swell, Effect::Glint]; + + /// Its name, as front ends can ask for it. + pub fn as_str(self) -> &'static str { + match self { + Effect::Ripple => "ripple", + Effect::Swell => "swell", + Effect::Glint => "glint", + } + } +} + +/// An effect that's under way. +#[derive(Clone, Copy)] +struct ActiveEffect { + effect: Effect, + start: Instant, + duration: Duration, + origin: [f32; 2], +} + /// A transition to another wallpaper that's under way. struct ActiveTransition { next: Wallpaper, @@ -663,6 +707,8 @@ pub struct OutputRenderState { pan: Option, /// Whether the view or pan changed since `step_and_write` last ran. view_changed: bool, + /// From `start_effect`, until it's run its course. + effect: Option, } impl OutputRenderState { @@ -686,6 +732,7 @@ impl OutputRenderState { intensity: 0.0, pan: None, view_changed: false, + effect: None, } } @@ -759,6 +806,24 @@ impl OutputRenderState { }); } + /// Play `effect` over the wallpaper showing for `duration`, starting + /// from `origin` in screen uv, from 0 to 1 across and down, where it + /// starts from one place. An effect already playing stops. Effects show + /// over a wallpaper at rest, not during a transition. + pub fn start_effect(&mut self, effect: Effect, duration: Duration, origin: [f32; 2]) { + self.effect = Some(ActiveEffect { + effect, + start: Instant::now(), + duration, + origin, + }); + } + + /// Whether an effect is playing. + pub fn is_playing_effect(&self) -> bool { + self.effect.is_some() + } + /// Once a transition has run its course, make its wallpaper the current /// one. Call after drawing, so the final frame of the transition has /// been shown. @@ -813,6 +878,10 @@ impl OutputRenderState { next.view, self.pan, ); + // An effect that's run its course draws one last frame without it. + let effect = self.effect.filter(|e| e.start.elapsed() < e.duration); + let had_effect = self.effect.is_some(); + self.effect = effect; let uniforms = Uniforms { cursor_offset: [nx, ny], intensity, @@ -830,11 +899,14 @@ impl OutputRenderState { portal_timing: trail.map_or(Zeroable::zeroed(), |t| t.timing), ground: self.current.ground.0, next_ground: next.ground.0, + effect: effect.map_or(0, |e| shader_effect(e.effect)), + effect_progress: effect.map_or(0.0, |e| effect_progress(e.start.elapsed(), e.duration)), + effect_origin: effect.map_or([0.5, 0.5], |e| e.origin), }; queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); let moved = (nx - cx).abs() > 1e-5 || (ny - cy).abs() > 1e-5; let view_changed = std::mem::take(&mut self.view_changed); - moved || view_changed || self.transition.is_some() + moved || view_changed || self.transition.is_some() || had_effect } } @@ -852,6 +924,25 @@ fn shader_style(style: Transition) -> u32 { } } +/// The shader's number for each effect, from 1, as 0 is none. Must match +/// the constants in shader.wgsl. +fn shader_effect(effect: Effect) -> u32 { + match effect { + Effect::Ripple => 1, + Effect::Swell => 2, + Effect::Glint => 3, + } +} + +/// How far through an effect we are, from 0 to 1, evenly: each effect +/// eases itself in the shader. +fn effect_progress(elapsed: Duration, duration: Duration) -> f32 { + if duration.is_zero() { + return 1.0; + } + (elapsed.as_secs_f32() / duration.as_secs_f32()).min(1.0) +} + /// How far through a transition we are, eased in and out so it /// starts and settles gently. Reaches exactly 1 once `duration` has passed. fn transition_progress(elapsed: Duration, duration: Duration) -> f32 { @@ -1448,6 +1539,37 @@ mod tests { } } + #[test] + fn effect_numbers_match_the_shader() { + let src = include_str!("shader.wgsl"); + assert!(src.contains("const NO_EFFECT: u32 = 0u;")); + for (name, effect) in [ + ("EFFECT_RIPPLE", Effect::Ripple), + ("EFFECT_SWELL", Effect::Swell), + ("EFFECT_GLINT", Effect::Glint), + ] { + let line = format!("const {name}: u32 = {}u;", shader_effect(effect)); + assert!(src.contains(&line), "{line}"); + } + } + + #[test] + fn effects_have_their_own_names() { + for effect in Effect::ALL { + let same = Effect::ALL.iter().filter(|e| e.as_str() == effect.as_str()); + assert_eq!(same.count(), 1, "{}", effect.as_str()); + } + } + + #[test] + fn effects_run_evenly_to_their_end() { + let second = Duration::from_secs(1); + assert_eq!(effect_progress(Duration::ZERO, second), 0.0); + assert_eq!(effect_progress(second / 4, second), 0.25); + assert_eq!(effect_progress(second * 2, second), 1.0); + assert_eq!(effect_progress(second, Duration::ZERO), 1.0); + } + #[test] fn portal_constants_match_the_shader() { let src = include_str!("shader.wgsl"); diff --git a/crates/render/src/shader.wgsl b/crates/render/src/shader.wgsl index 4e21c3d..52254c9 100644 --- a/crates/render/src/shader.wgsl +++ b/crates/render/src/shader.wgsl @@ -63,6 +63,11 @@ struct Uniforms { // `tide_height`. ground: vec4, next_ground: vec4, + // The effect playing, one of the constants below, how far through it + // is, from 0 to 1, and where it starts from, in screen uv. + effect: u32, + effect_progress: f32, + effect_origin: vec2, }; @group(0) @binding(3) var u: Uniforms; @@ -100,6 +105,38 @@ const WATER_DEPTH: f32 = 0.15; const RIPPLE: f32 = 0.0025; const WATER_SHADE: f32 = 0.18; +// Effects, played over a wallpaper at rest. Must match `shader_effect` in +// renderer.rs. +const NO_EFFECT: u32 = 0u; +const EFFECT_RIPPLE: u32 = 1u; +const EFFECT_SWELL: u32 = 2u; +const EFFECT_GLINT: u32 = 3u; +// How far the ripple's distances go into depth: a pixel's distance from +// where it starts is how far across the screen it is, in screen heights, +// plus this much of how far back it is by depth rank, so it wraps round +// near things before going on behind them. +const RIPPLE_DEPTH: f32 = 0.9; +// How long its waves are, in those units, how far behind the front they +// die away, and how far they move the picture at first, as a fraction of +// the screen. +const RIPPLE_WAVELENGTH: f32 = 0.085; +const RIPPLE_TRAIL: f32 = 0.32; +const RIPPLE_AMOUNT: f32 = 0.022; +// How much lighter its crests are, and darker its troughs. +const RIPPLE_LIGHT: f32 = 0.22; +// How wide the swell is, in depth rank, how far it pushes things toward +// the viewer, as a fraction of their distance from the middle of the +// screen, and how much it lightens them. +const SWELL_BAND: f32 = 0.16; +const SWELL_AMOUNT: f32 = 0.06; +const SWELL_LIGHT: f32 = 0.08; +// How wide the glint's band of light is, in depth rank, how bright, and +// how far it lifts what it lights, as a fraction of the screen. +const GLINT_BAND: f32 = 0.06; +const GLINT_LIGHT: f32 = 0.5; +const GLINT_LIFT: f32 = 0.006; +const PI: f32 = 3.14159265; + // Half the width of the band of ranks part way through switching at any // moment, in the sweeps, the dissolve and the tide. Narrower gives a // crisper edge, wider a softer one. @@ -339,6 +376,66 @@ fn transition_blend(screen_uv: vec2, depth: f32, next_depth: f32, t: f32) - return Blend(weight, weight, 0.0); } +// What an effect does to a pixel: moves where its colour comes from, in +// screen uv, darkens or lightens it by `shade`, then adds `light`. +struct EffectAt { + offset: vec2, + shade: f32, + light: f32, +}; + +// The effect playing, at a pixel at rest at `depth`. +fn effect_at(screen_uv: vec2, depth: f32) -> EffectAt { + let t = u.effect_progress; + if u.effect == EFFECT_RIPPLE { + return ripple(screen_uv, depth, t); + } + // The swell and glint travel through depth rank from the nearest + // things to the furthest, like the sweeps, so each takes a similar + // share of the effect whatever the scene. + let near_first = 1.0 - depth_rank(false, depth); + if u.effect == EFFECT_SWELL { + let x = (near_first - travelled(t, SWELL_BAND)) / SWELL_BAND; + let swell = exp(-x * x) * (1.0 - t); + // Taking its colour from nearer the middle makes a part of the + // scene look closer, as if it came toward the viewer. + return EffectAt(-(screen_uv - 0.5) * swell * SWELL_AMOUNT, 1.0 + swell * SWELL_LIGHT, 0.0); + } + let x = (near_first - travelled(t, GLINT_BAND)) / GLINT_BAND; + let glint = exp(-x * x) * sqrt(1.0 - t); + return EffectAt(vec2(0.0, glint * GLINT_LIFT), 1.0, glint * GLINT_LIGHT); +} + +// Where a band `band` wide has got to through depth rank at `t`: from +// just before the nearest things at the start to just past the furthest at +// the end, so every pixel sees it whole, easing in and out. +fn travelled(t: f32, band: f32) -> f32 { + let eased = t * t * (3.0 - 2.0 * t); + return eased * (1.0 + 4.0 * band) - 2.0 * band; +} + +// A ripple from the effect's origin, like a drop in water. It crosses the +// screen and goes back through the scene at once, so it reaches near +// things before far ones beside them and wraps round them, at a steady +// pace from the origin to the furthest corner. The waves behind its front +// move the picture to and from the origin, and light and shade it, dying +// away as it goes. +fn ripple(screen_uv: vec2, depth: f32, t: f32) -> EffectAt { + let away = (screen_uv - u.effect_origin) * vec2(u.aspect, 1.0); + let back = 1.0 - depth_rank(false, depth); + let distance = length(away) + RIPPLE_DEPTH * back; + // Far enough to pass the furthest corner of the screen, at the back, + // with its trail. + let corner = length(vec2(max(u.effect_origin.x, 1.0 - u.effect_origin.x) * u.aspect, max(u.effect_origin.y, 1.0 - u.effect_origin.y))); + let front = (corner + RIPPLE_DEPTH + RIPPLE_TRAIL) * t; + let behind = front - distance; + // Only behind the front, fading out over the trail behind it. + let trail = select(0.0, exp(-behind / RIPPLE_TRAIL), behind >= 0.0); + let wave = sin(behind / RIPPLE_WAVELENGTH * 2.0 * PI) * trail * (1.0 - t); + let outward = away / max(length(away), 0.001) / vec2(u.aspect, 1.0); + return EffectAt(outward * wave * RIPPLE_AMOUNT, 1.0 + wave * RIPPLE_LIGHT, 0.0); +} + // Linear sRGB to Oklab, from https://bottosson.github.io/posts/oklab/ fn to_oklab(c: vec3) -> vec3 { let lms = vec3( @@ -391,7 +488,16 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Cursor right (+x) shifts near pixels left. Scaled by uv_scale so a // cropped axis moves the same amount on screen as an uncropped one. let shift = u.cursor_offset * depth * u.intensity; - return textureSample(color_tex, tex_sampler, uv - shift * u.uv_scale); + if u.effect == NO_EFFECT { + return textureSample(color_tex, tex_sampler, uv - shift * u.uv_scale); + } + let fx = effect_at(in.uv, depth); + let color = textureSample(color_tex, tex_sampler, uv + (fx.offset - shift) * u.uv_scale); + let shaded = clamp(color.rgb * fx.shade, vec3(0.0), vec3(1.0)); + // Light catches the brighter parts most, like a sheen, a touch warm. + let sheen = smoothstep(0.1, 0.8, dot(shaded, vec3(0.2126, 0.7152, 0.0722))); + let light = fx.light * sheen * vec3(1.0, 0.94, 0.84); + return vec4(shaded + (1.0 - shaded) * light, color.a); } let next_uv = crop(in.uv, u.next_uv_scale, u.next_uv_center);