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);