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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion crates/render/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
124 changes: 123 additions & 1 deletion crates/render/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -663,6 +707,8 @@ pub struct OutputRenderState {
pan: Option<f32>,
/// 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<ActiveEffect>,
}

impl OutputRenderState {
Expand All @@ -686,6 +732,7 @@ impl OutputRenderState {
intensity: 0.0,
pan: None,
view_changed: false,
effect: None,
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
108 changes: 107 additions & 1 deletion crates/render/src/shader.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ struct Uniforms {
// `tide_height`.
ground: vec4<f32>,
next_ground: vec4<f32>,
// 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<f32>,
};
@group(0) @binding(3) var<uniform> u: Uniforms;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -339,6 +376,66 @@ fn transition_blend(screen_uv: vec2<f32>, 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<f32>,
shade: f32,
light: f32,
};

// The effect playing, at a pixel at rest at `depth`.
fn effect_at(screen_uv: vec2<f32>, 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<f32>(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<f32>, depth: f32, t: f32) -> EffectAt {
let away = (screen_uv - u.effect_origin) * vec2<f32>(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<f32>(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<f32>(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<f32>) -> vec3<f32> {
let lms = vec3<f32>(
Expand Down Expand Up @@ -391,7 +488,16 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// 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<f32>(0.0), vec3<f32>(1.0));
// Light catches the brighter parts most, like a sheen, a touch warm.
let sheen = smoothstep(0.1, 0.8, dot(shaded, vec3<f32>(0.2126, 0.7152, 0.0722)));
let light = fx.light * sheen * vec3<f32>(1.0, 0.94, 0.84);
return vec4<f32>(shaded + (1.0 - shaded) * light, color.a);
}

let next_uv = crop(in.uv, u.next_uv_scale, u.next_uv_center);
Expand Down
Loading