diff --git a/CHANGELOG.md b/CHANGELOG.md index 65cabb8..25b2f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Changed + +- `portal` grows like a sphere from the surface under the cursor, so it spreads over that first and wraps round things nearer and further away. +- `dissolve` grows spheres from random places in the scene, like `portal` does from the cursor, further things tending to go first. +- `tide-in` and `tide-out` find the ground in the scene, so the water rises level with it in views from above or at an angle, instead of always from the bottom of the picture. Close-ups and scenes without clear ground work as before. + ## 0.4.2 - 2026-10-06 ### Fixed diff --git a/assets/completions/_shiftpaper b/assets/completions/_shiftpaper index c5d59ed..3028895 100644 --- a/assets/completions/_shiftpaper +++ b/assets/completions/_shiftpaper @@ -70,8 +70,8 @@ _arguments "${_arguments_options[@]}" : \ '::name -- The transition to use:((sweep-in\:"A wave sweeps into the scene\: the new wallpaper'\''s nearest things appear first, in front of the old one, and it fills in towards the distance" sweep-out\:"A wave sweeps out of the scene\: the old wallpaper'\''s background gives way to the new one first, and its nearest things go last" morph\:"Everything changes together, the shape of the scene a little ahead of its colours" -dissolve\:"Patches of the new wallpaper appear at random, nearer things tending to go first" -portal\:"The new wallpaper grows out from the cursor like a bubble in the scene, following it if it moves" +dissolve\:"The new wallpaper grows like spheres in the scene from random places, further things tending to go first" +portal\:"The new wallpaper grows out from the cursor like a sphere in the scene, and follows the cursor if it moves" tide-in\:"The new wallpaper rises through the old like a tide coming in, lowest places first" tide-out\:"The old wallpaper drains away like a tide going out, uncovering the new one from the highest places down"))' \ && ret=0 diff --git a/assets/man/shiftpaper-transition.1 b/assets/man/shiftpaper-transition.1 index 85c90b6..8e4fbfe 100644 --- a/assets/man/shiftpaper-transition.1 +++ b/assets/man/shiftpaper-transition.1 @@ -31,9 +31,9 @@ sweep\-out: A wave sweeps out of the scene: the old wallpaper\*(Aqs background g .IP \(bu 2 morph: Everything changes together, the shape of the scene a little ahead of its colours .IP \(bu 2 -dissolve: Patches of the new wallpaper appear at random, nearer things tending to go first +dissolve: The new wallpaper grows like spheres in the scene from random places, further things tending to go first .IP \(bu 2 -portal: The new wallpaper grows out from the cursor like a bubble in the scene, following it if it moves +portal: The new wallpaper grows out from the cursor like a sphere in the scene, and follows the cursor if it moves .IP \(bu 2 tide\-in: The new wallpaper rises through the old like a tide coming in, lowest places first .IP \(bu 2 diff --git a/assets/reel.avif b/assets/reel.avif index 3e1c56d..0c2b421 100644 Binary files a/assets/reel.avif and b/assets/reel.avif differ diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d4283c9..1bd34c1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -79,11 +79,11 @@ pub enum Transition { /// Everything changes together, the shape of the scene a little ahead /// of its colours. Morph, - /// Patches of the new wallpaper appear at random, nearer things - /// tending to go first. + /// The new wallpaper grows like spheres in the scene from random + /// places, further things tending to go first. Dissolve, - /// The new wallpaper grows out from the cursor like a bubble in the - /// scene, following it if it moves. + /// The new wallpaper grows out from the cursor like a sphere in the + /// scene, and follows the cursor if it moves. Portal, /// The new wallpaper rises through the old like a tide coming in, /// lowest places first. diff --git a/crates/daemon/src/depth.rs b/crates/daemon/src/depth.rs index 977042a..d94c835 100644 --- a/crates/daemon/src/depth.rs +++ b/crates/daemon/src/depth.rs @@ -57,12 +57,13 @@ impl DepthMap { /// Like `ranks`, but for each pixel's rough height in the scene from /// `tide_height`: the fraction of pixels lower than each of 257 evenly /// spaced heights from -1 to 1. - pub fn height_ranks(&self) -> Vec { + pub fn height_ranks(&self, ground: &Ground) -> Vec { let mut counts = [0u32; RANK_POINTS - 1]; let width = self.width.max(1) as usize; for (i, &d) in self.data.iter().enumerate() { + let x = ((i % width) as f32 + 0.5) / width as f32; let y = ((i / width) as f32 + 0.5) / self.height as f32; - let height = tide_height(y, f32::from(d) / 65535.0); + let height = tide_height([x, y], f32::from(d) / 65535.0, ground); let bin = ((height + 1.0) * 0.5 * (RANK_POINTS - 1) as f32) as usize; counts[bin.min(RANK_POINTS - 2)] += 1; } @@ -70,25 +71,321 @@ impl DepthMap { } } -/// Where the tide takes the horizon to be, as a fraction of the way down -/// the image. Must match HORIZON in shader.wgsl. -pub const HORIZON: f32 = 0.5; +/// How many cells across and down `DepthMap::grid` divides a map into. +pub const GRID_CELLS: usize = 32; + +/// The range of depths in each cell of a coarse grid over a depth map, so +/// the CPU can tell roughly where things are in the scene without reading +/// the map back from the GPU. +#[derive(Clone)] +pub struct DepthGrid { + /// GRID_CELLS rows of GRID_CELLS cells. + pub cells: Vec, +} + +/// Depths from 0 to 1 in one cell of a `DepthGrid`. +#[derive(Clone, Copy)] +pub struct DepthCell { + pub least: f32, + pub greatest: f32, + pub mean: f32, +} + +impl DepthGrid { + /// For a wallpaper without a depth map, which the GPU sees as all 0. + pub fn flat() -> Self { + let cell = DepthCell { + least: 0.0, + greatest: 0.0, + mean: 0.0, + }; + Self { + cells: vec![cell; GRID_CELLS * GRID_CELLS], + } + } +} + +impl DepthMap { + /// The map's `DepthGrid`. A pixel at (x, y) is in the cell + /// `x * GRID_CELLS / width` across and `y * GRID_CELLS / height` down. + pub fn grid(&self) -> DepthGrid { + // The least, greatest and total depth in each cell, and how many + // pixels it has. + let mut sums = vec![(f32::MAX, f32::MIN, 0.0, 0u32); GRID_CELLS * GRID_CELLS]; + let width = self.width.max(1) as usize; + for (i, &d) in self.data.iter().enumerate() { + let col = (i % width) * GRID_CELLS / width; + let row = (i / width) * GRID_CELLS / self.height as usize; + let (least, greatest, total, count) = &mut sums[row * GRID_CELLS + col]; + let d = f32::from(d) / 65535.0; + *least = least.min(d); + *greatest = greatest.max(d); + *total += d; + *count += 1; + } + let cells = sums + .into_iter() + .map(|(least, greatest, total, count)| match count { + // A map smaller than the grid leaves some cells empty, so + // they allow any depth. + 0 => DepthCell { + least: 0.0, + greatest: 1.0, + mean: 0.5, + }, + _ => DepthCell { + least, + greatest, + mean: total / count as f32, + }, + }) + .collect(); + DepthGrid { cells } + } +} + /// How close a depth of 0 is taken to be, which keeps the farthest pixels /// at a finite distance. Must match TIDE_NEAR in shader.wgsl. pub const TIDE_NEAR: f32 = 0.1; +/// The camera's field of view, which the depth map doesn't record, so the +/// tide assumes a typical photo's: the height of the view at a distance +/// of 1, which is about 45 degrees top to bottom. +const TIDE_VIEW: f32 = 0.83; +/// How many points across and down `DepthMap::ground` looks at. +const GROUND_SAMPLES: usize = 64; +/// How many planes `DepthMap::ground` tries. +const GROUND_TRIES: usize = 400; +/// How far a point can be from a plane and still count as on it, as a +/// fraction of the point's distance. +const GROUND_TOLERANCE: f32 = 0.03; +/// How far ground that faces the camera can lean to one side, in degrees. +const GROUND_LEAN: f32 = 40.0; +/// How different in angle another plane has to be, in degrees, to count +/// as a second slope. +const GROUND_SLOPES: f32 = 15.0; + +/// The ground the tide rises from, as the four numbers `tide_height` needs. +/// Must match how tide_height in shader.wgsl uses them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Ground(pub [f32; 4]); + +impl Ground { + /// For a level camera with the horizon across the middle of the image, + /// when there's no convincing ground. + pub const LEVEL: Self = Self([0.0, -1.0, 0.5, 0.0]); -/// Roughly how high a point is in the scene, squashed into (-1, 1), from -/// how far down the image it is and its depth. Distance comes from depth, -/// and height from how far above or below the horizon the point is at -/// that distance. The model's depth has no true scale, so this is only a -/// guess, but a good enough one for water to find its level. Must match -/// tide_height in shader.wgsl. -pub fn tide_height(y: f32, depth: f32) -> f32 { + /// The plane through the scene where `normal` dotted with a point, + /// plus `offset`, is 0, with the camera on the side `normal` points to. + /// Heights are measured from it in units of how high the camera is + /// above it. + fn from_plane(normal: [f32; 3], offset: f32, aspect: f32) -> Self { + // A point at image (x, y) and distance z is z * (along x, along y, + // 1), so its height is z * (a * x + b * y + c) + 1. + let across = TIDE_VIEW * aspect; + let [nx, ny, nz] = normal; + Self([ + nx * across / offset, + -ny * TIDE_VIEW / offset, + (nz - 0.5 * nx * across + 0.5 * ny * TIDE_VIEW) / offset, + 1.0, + ]) + } +} + +/// Roughly how high a point is in the scene above the ground, squashed +/// into (-1, 1), from where it is in the image and its depth. The model's +/// depth has no true scale and the camera's field of view is a guess, so +/// this is only rough, but good enough for water to find its level. Must +/// match tide_height in shader.wgsl. +pub fn tide_height([x, y]: [f32; 2], depth: f32, ground: &Ground) -> f32 { let distance = 1.0 / (depth + TIDE_NEAR); - let height = (HORIZON - y) * distance; + let [a, b, c, d] = ground.0; + let height = distance * (a * x + b * y + c) + d; height / (1.0 + height.abs()) } +impl DepthMap { + /// The biggest flat surface in the scene that faces up or towards the + /// camera, which the tide takes to be the ground, or `Ground::LEVEL` + /// if it isn't convincing. See `convincing`. + pub fn ground(&self) -> Ground { + let aspect = self.width as f32 / self.height.max(1) as f32; + let points = self.scene_points(aspect); + let Some((mut plane, _)) = strongest_plane(&points) else { + return Ground::LEVEL; + }; + // Fit the plane to the points on it, then again to the points on + // that. + for _ in 0..2 { + let on_it: Vec<[f32; 3]> = points.iter().filter(|p| on(plane, p)).copied().collect(); + plane = fit_plane(&on_it, plane.0).unwrap_or(plane); + } + if !convincing(plane, &points) { + return Ground::LEVEL; + } + Ground::from_plane(plane.0, plane.1, aspect) + } + + /// Where a grid of GROUND_SAMPLES points across and down the map are in + /// the scene, leaving out any at a depth of 0, like sky. x is right, y + /// up and z away from the camera, at the scale `tide_height` uses. + fn scene_points(&self, aspect: f32) -> Vec<[f32; 3]> { + let (width, height) = (self.width as usize, self.height as usize); + let mut points = Vec::with_capacity(GROUND_SAMPLES * GROUND_SAMPLES); + for row in 0..GROUND_SAMPLES { + for col in 0..GROUND_SAMPLES { + let px = (col * 2 + 1) * width / (GROUND_SAMPLES * 2); + let py = (row * 2 + 1) * height / (GROUND_SAMPLES * 2); + let Some(&d) = self.data.get(py * width + px) else { + continue; + }; + if d == 0 { + continue; + } + let x = (px as f32 + 0.5) / width as f32; + let y = (py as f32 + 0.5) / height as f32; + let z = 1.0 / (f32::from(d) / 65535.0 + TIDE_NEAR); + points.push([ + z * (x - 0.5) * TIDE_VIEW * aspect, + z * (0.5 - y) * TIDE_VIEW, + z, + ]); + } + } + points + } +} + +/// A plane as a unit normal and offset, where the normal dotted with a +/// point, plus the offset, is 0. +type Plane = ([f32; 3], f32); + +/// Whether `point` is on `plane`, within GROUND_TOLERANCE. +fn on((normal, offset): Plane, point: &[f32; 3]) -> bool { + (dot(normal, *point) + offset).abs() < GROUND_TOLERANCE * point[2] +} + +/// The plane that could be the ground with the most `points` on it, and +/// how many, from planes through random sets of three points. +fn strongest_plane(points: &[[f32; 3]]) -> Option<(Plane, usize)> { + if points.len() < 3 { + return None; + } + let mut random = 0x9e37_79b9_u32; + let mut pick = || { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + points[random as usize % points.len()] + }; + let mut best = None; + let mut most = 0; + for _ in 0..GROUND_TRIES { + let [a, b, c] = [pick(), pick(), pick()]; + let Some(plane) = plane_facing_camera(cross(sub(b, a), sub(c, a)), a) else { + continue; + }; + let count = points.iter().filter(|p| on(plane, p)).count(); + if count > most { + most = count; + best = Some((plane, count)); + } + } + best +} + +/// Whether `plane`, the strongest in the scene, is convincing as the +/// ground. Close-ups and abstract images often have none, and their +/// strongest plane faces the camera but leans well to one side. Ground +/// that faces up doesn't count if there's another plane nearly as strong +/// at quite a different angle, like the two sides of a valley. +fn convincing(plane: Plane, points: &[[f32; 3]]) -> bool { + let [nx, ny, nz] = plane.0; + if ny < nx.abs() { + return -nz >= GROUND_LEAN.to_radians().cos(); + } + let rest: Vec<[f32; 3]> = points.iter().filter(|p| !on(plane, p)).copied().collect(); + let Some((other, others)) = strongest_plane(&rest) else { + return true; + }; + let count = points.len() - rest.len(); + others * 2 < count || dot(plane.0, other.0) > GROUND_SLOPES.to_radians().cos() +} + +/// The plane through `point` facing along `normal`, as a unit normal and +/// offset, flipped so the camera is on the side it faces. None if it can't +/// be the ground: if it passes through the camera, faces down the picture +/// like a ceiling, or faces more sideways than up and isn't within about +/// 45 degrees of facing the camera either, like a wall to one side. +fn plane_facing_camera(normal: [f32; 3], point: [f32; 3]) -> Option { + let length = dot(normal, normal).sqrt(); + if length < 1e-9 { + return None; + } + let mut normal = normal.map(|n| n / length); + let mut offset = -dot(normal, point); + if offset < 0.0 { + normal = normal.map(|n| -n); + offset = -offset; + } + let [nx, ny, nz] = normal; + let faces_up = ny >= nx.abs(); + let faces_camera = -nz >= 0.7; + (offset > 1e-4 && (faces_up || faces_camera)).then_some((normal, offset)) +} + +/// The plane closest to all of `points`, through their middle and facing +/// the way they spread least, starting the search from `normal`. +fn fit_plane(points: &[[f32; 3]], mut normal: [f32; 3]) -> Option { + if points.len() < 3 { + return None; + } + let n = points.len() as f32; + let middle = points + .iter() + .fold([0.0; 3], |sum, p| { + [sum[0] + p[0], sum[1] + p[1], sum[2] + p[2]] + }) + .map(|s| s / n); + let mut spread = [[0.0f32; 3]; 3]; + for p in points { + let d = sub(*p, middle); + for i in 0..3 { + for j in 0..3 { + spread[i][j] += d[i] * d[j]; + } + } + } + // The way the points spread least is the way they spread most once + // the spread is turned inside out, which repeated multiplying finds. + let total = spread[0][0] + spread[1][1] + spread[2][2]; + for _ in 0..50 { + let next: [f32; 3] = std::array::from_fn(|i| total * normal[i] - dot(spread[i], normal)); + let length = dot(next, next).sqrt(); + if length < 1e-12 { + return None; + } + normal = next.map(|v| v / length); + } + plane_facing_camera(normal, middle) +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + /// The most of a transition any one slice of depth or height can take, as /// a fraction of the picture. const SLICE_CAP: f32 = 0.02; @@ -134,13 +431,93 @@ mod tests { assert!(ranks.is_sorted()); } + /// A map of flat ground facing along `normal`, with the camera 1 above + /// it, and sky wherever the camera can't see the ground. + fn ground_map(normal: [f32; 3]) -> DepthMap { + surfaces_map(&[normal]) + } + + /// Like `ground_map`, but each pixel sees the nearest of several + /// surfaces, each 1 from the camera. + fn surfaces_map(normals: &[[f32; 3]]) -> DepthMap { + let (width, height) = (96, 64); + let aspect = width as f32 / height as f32; + let data = (0..width * height) + .map(|i| { + let x = ((i % width) as f32 + 0.5) / width as f32; + let y = ((i / width) as f32 + 0.5) / height as f32; + let ray = [(x - 0.5) * TIDE_VIEW * aspect, (0.5 - y) * TIDE_VIEW, 1.0]; + let distance = normals + .iter() + .map(|&normal| -1.0 / dot(normal, ray)) + .filter(|&d| d > 0.0) + .fold(f32::INFINITY, f32::min); + let depth = 1.0 / distance - TIDE_NEAR; + if distance > 0.0 && depth > 0.0 { + (depth.min(1.0) * 65535.0).round() as u16 + } else { + 0 + } + }) + .collect(); + DepthMap { + data, + width: width as u32, + height: height as u32, + } + } + + /// The normal of level ground seen by a camera pitched down and rolled + /// by these many degrees. + fn tilt(pitch: f32, roll: f32) -> [f32; 3] { + let (pitch, roll) = (pitch.to_radians(), roll.to_radians()); + [ + roll.sin() * pitch.cos(), + roll.cos() * pitch.cos(), + -pitch.sin(), + ] + } + + #[test] + fn ground_is_found_however_the_camera_points() { + for (name, normal) in [ + ("level", tilt(0.0, 0.0)), + ("looking down and tilted", tilt(30.0, 20.0)), + ("from above", tilt(80.0, 0.0)), + ] { + let map = ground_map(normal); + let ground = map.ground(); + let width = map.width as usize; + for (i, &d) in map.data.iter().enumerate().filter(|(_, d)| **d > 0) { + let x = ((i % width) as f32 + 0.5) / width as f32; + let y = ((i / width) as f32 + 0.5) / map.height as f32; + let depth = f32::from(d) / 65535.0; + let on = tide_height([x, y], depth, &ground); + assert!(on.abs() < 0.02, "{name}: the ground is at {on}"); + // Something nearer than the ground there stands on it. + let above = tide_height([x, y], depth + 0.05, &ground); + assert!(above > on, "{name}: {above} isn't above {on}"); + } + } + } + + #[test] + fn no_convincing_ground_is_level() { + // The two sides of a valley. + let valley = surfaces_map(&[tilt(0.0, 25.0), tilt(0.0, -25.0)]); + assert_eq!(valley.ground(), Ground::LEVEL); + // Something facing the camera but leaning well to one side. + let leaning = ground_map(tilt(90.0 - 43.0, 90.0)); + assert_eq!(leaning.ground(), Ground::LEVEL); + } + #[test] - fn lower_and_nearer_is_lower_down() { - // At the same depth, lower in the image is lower in the scene. - assert!(tide_height(0.9, 0.5) < tide_height(0.6, 0.5)); - // Far away and high in the image, like sky, is highest of all. - assert!(tide_height(0.1, 0.0) > tide_height(0.3, 0.5)); - assert!(tide_height(0.1, 0.0) < 1.0 && tide_height(1.0, 0.0) > -1.0); + fn sky_is_highest() { + let map = ground_map([0.0, 1.0, 0.0]); + let ground = map.ground(); + let sky = tide_height([0.5, 0.1], 0.0, &ground); + let near = tide_height([0.5, 0.9], 0.9, &ground); + assert!(sky > 0.5 && sky > near, "{sky}, {near}"); } #[test] @@ -150,7 +527,7 @@ mod tests { width: 4, height: 2, }; - let ranks = map.height_ranks(); + let ranks = map.height_ranks(&Ground::LEVEL); assert_eq!(ranks.len(), RANK_POINTS); assert_eq!(ranks[0], 0.0); assert_eq!(ranks[RANK_POINTS - 1], 1.0); diff --git a/crates/daemon/src/renderer.rs b/crates/daemon/src/renderer.rs index beb50e7..04a4023 100644 --- a/crates/daemon/src/renderer.rs +++ b/crates/daemon/src/renderer.rs @@ -1,10 +1,12 @@ -use crate::depth::{DepthMap, RANK_POINTS, load_depth_map}; +use crate::depth::{ + DepthCell, DepthGrid, DepthMap, GRID_CELLS, Ground, RANK_POINTS, load_depth_map, +}; use anyhow::{Context, Result}; use bytemuck::Zeroable; use image::imageops::{self, FilterType}; use shiftpaper_config::Transition; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tracing::{debug, info, warn}; use wgpu::CurrentSurfaceTexture; use wgpu::util::DeviceExt; @@ -23,52 +25,233 @@ struct Uniforms { style: u32, /// The screen's width over its height. aspect: f32, - /// The portal's trail: how many points are in use, how fast their - /// bubbles grow, and the points themselves, from `Trail`. + /// Where the spheres of a portal or dissolve grow from: how many points + /// are in use, how far a sphere grows, the points themselves, how the + /// spheres are paced and when they show, from `Trail`. trail_len: u32, - portal_speed: f32, + portal_reach: f32, trail: [[f32; 4]; TRAIL_POINTS], + portal_pace: [[f32; 4]; PACE_POINTS.div_ceil(4)], + portal_timing: [[f32; 4]; PACE_POINTS.div_ceil(4)], + /// Where the ground is in each wallpaper, for the tide. + ground: [f32; 4], + next_ground: [f32; 4], } -/// How many cursor positions a portal transition follows. Must match -/// TRAIL_POINTS in shader.wgsl. +/// How many points a trail can hold. Must match TRAIL_POINTS in +/// shader.wgsl. const TRAIL_POINTS: usize = 32; /// How far the cursor moves, in screen heights, before the portal grows /// from a new point. const TRAIL_SPACING: f32 = 0.05; -/// How much a difference in depth rank holds the portal back, in screen -/// heights. Must match PORTAL_DEPTH in shader.wgsl. -const PORTAL_DEPTH: f32 = 1.0; -/// How soft the portal's edge is, in screen heights. Must match -/// PORTAL_EDGE in shader.wgsl. -const PORTAL_EDGE: f32 = 0.04; - -/// Where the cursor has been during a portal transition. The new wallpaper -/// grows from each point like a bubble, from when the cursor got there. +/// How close a depth of 0 is taken to be, which keeps the farthest pixels +/// at a finite distance, as with the tide's TIDE_NEAR. Must match +/// PORTAL_NEAR in shader.wgsl. +const PORTAL_NEAR: f32 = 0.1; +/// How many distances `Trail::pace` covers, and times `Trail::timing` +/// does. Must match PACE_POINTS in shader.wgsl. +const PACE_POINTS: usize = 33; +/// How soft the edge of a sphere is, as a fraction of the transition. +/// Must match PORTAL_EDGE in shader.wgsl. +const PORTAL_EDGE: f32 = 0.015; +/// How many spheres a dissolve grows, at most. +const DISSOLVE_SPHERES: usize = 16; +/// How far through a dissolve its last sphere starts. +const DISSOLVE_SPAWNING: f32 = 0.7; +/// The spheres in a dissolve start at this power of how far through +/// DISSOLVE_SPAWNING they are, so below 1 they start more often as it goes. +const DISSOLVE_QUICKENING: f32 = 0.8; +/// How many times likelier a dissolve's spheres are to start among the +/// furthest things than among the nearest. +const DISSOLVE_FAR: f32 = 20.0; + +/// Where the spheres of a portal or dissolve grow from. The new wallpaper +/// grows from each point like a sphere, from its start: for the portal, +/// from when the cursor got there. #[derive(Clone, Copy)] struct Trail { - /// x and y in screen uv, then the transition's progress when the cursor - /// was there. The fourth number pads to the shader's vec4. + /// x and y in screen uv, then the transition's progress when the + /// sphere starts. The fourth number pads to the shader's vec4. They're + /// in the order they start. points: [[f32; 4]; TRAIL_POINTS], len: usize, - /// How fast the bubbles grow, so the first reaches the furthest corner - /// just as the transition ends. - speed: f32, + /// For the portal, the furthest any pixel can be from where the first + /// sphere starts, and for the dissolve, how far a sphere grows over the + /// whole transition, in the units of scene_point in shader.wgsl. + reach: f32, + /// How far through its growth a sphere is, from 0 to 1, once it has + /// grown to each of PACE_POINTS evenly spaced distances out to `reach`, + /// packed four to a vec4 for the shader. For the portal, it's the + /// fraction of the screen within that distance of the first sphere's + /// centre, so the spheres change a similar amount of the picture at + /// every moment, like the ranks the sweeps use. The dissolve's spheres + /// grow steadily. + pace: [[f32; 4]; PACE_POINTS.div_ceil(4)], + /// When a pixel switches, at each of PACE_POINTS evenly spaced times + /// from 0 to 1 that the first sphere could reach it, packed like + /// `pace`. The portal's pace already keeps it steady, so it switches + /// then. The dissolve's spheres start at different times, so instead + /// it's when that much of the screen has been reached, which keeps it + /// steady. + timing: [[f32; 4]; PACE_POINTS.div_ceil(4)], } impl Trail { - fn new(cursor: [f32; 2], aspect: f32) -> Self { - // The furthest any pixel can be from where it starts, across the - // screen and in depth. - let far_x = cursor[0].max(1.0 - cursor[0]) * aspect; - let far_y = cursor[1].max(1.0 - cursor[1]); - let reach = (far_x * far_x + far_y * far_y + PORTAL_DEPTH * PORTAL_DEPTH).sqrt(); + /// Start a portal's trail at `cursor`, over a wallpaper with depths + /// `grid`, cropped by `uv_scale` to fit a screen `aspect` wide. + fn new(cursor: [f32; 2], aspect: f32, grid: &DepthGrid, uv_scale: [f32; 2]) -> Self { + let (under, mean) = depths_around(cursor, grid, uv_scale); + let center = scene_point(cursor, mean, aspect); + let cells = screen_cells(grid, uv_scale); + let reach = cells + .iter() + .map(|cell| cell.furthest_from(cursor, under, aspect)) + .fold(1e-6, f32::max); + + // How much of the screen is within each distance. For this, each + // cell counts as its area at its middle and mean depth. + let mut within = [0.0; PACE_POINTS]; + for cell in &cells { + let d = distance(cell.middle(aspect), center); + let step = (d / reach * (PACE_POINTS - 1) as f32).ceil() as usize; + within[step.min(PACE_POINTS - 1)] += cell.area(); + } + let mut total = 0.0; + for w in &mut within { + total += *w; + *w = total; + } + let mut pace = [[0.0; 4]; PACE_POINTS.div_ceil(4)]; + for (i, w) in within.into_iter().enumerate() { + pace[i / 4][i % 4] = w / total; + } + let mut points = [[0.0; 4]; TRAIL_POINTS]; points[0] = [cursor[0], cursor[1], 0.0, 0.0]; Self { points, len: 1, - speed: reach + 2.0 * PORTAL_EDGE, + reach, + pace, + timing: table(|x| x), + } + } + + /// Random points for a dissolve, over a wallpaper with depths `grid`, + /// cropped by `uv_scale` to fit a screen `aspect` wide. The spheres + /// start more and more often, each somewhere the ones before haven't + /// reached yet, and tend to start among the furthest things first. + /// `seed` picks the points. + fn scatter(aspect: f32, grid: &DepthGrid, uv_scale: [f32; 2], seed: u32) -> Self { + let cells = screen_cells(grid, uv_scale); + // How far away each cell is, as the fraction of the screen nearer + // than it. + let mut by_depth: Vec = (0..cells.len()).collect(); + by_depth.sort_by(|&a, &b| cells[b].depths.mean.total_cmp(&cells[a].depths.mean)); + let total: f32 = cells.iter().map(ScreenCell::area).sum(); + let mut farness = vec![0.0; cells.len()]; + let (mut nearer, mut level, mut depth) = (0.0, 0.0, f32::NAN); + for i in by_depth { + // Cells at the same depth are as far as each other. + if cells[i].depths.mean != depth { + (nearer, level, depth) = (nearer + level, 0.0, cells[i].depths.mean); + } + farness[i] = nearer / total; + level += cells[i].area(); + } + + // Each sphere grows over this much of the transition, as in + // portal_arrival in shader.wgsl. + let growth = 1.0 - 2.0 * PORTAL_EDGE; + let middles: Vec<[f32; 3]> = cells.iter().map(|cell| cell.middle(aspect)).collect(); + // Where the spheres start if they grow `reach` over the transition, + // as screen uv and progress, and when the last cell is reached. + let spheres_for = |reach: f32| { + let mut random = Random::new(seed); + let mut spheres = Vec::new(); + let mut reached = vec![f32::MAX; cells.len()]; + for i in 0..DISSOLVE_SPHERES { + let start = DISSOLVE_SPAWNING + * (i as f32 / (DISSOLVE_SPHERES - 1) as f32).powf(DISSOLVE_QUICKENING); + let weights: Vec = (cells.iter().zip(&farness).zip(&reached)) + .map(|((cell, far), &at)| match at <= start { + true => 0.0, + false => cell.area() * DISSOLVE_FAR.powf(*far), + }) + .collect(); + let Some(cell) = random.pick(&weights).map(|i| &cells[i]) else { + break; + }; + let at = [ + cell.left + (cell.right - cell.left) * random.next(), + cell.top + (cell.bottom - cell.top) * random.next(), + ]; + let center = scene_point(at, depths_around(at, grid, uv_scale).1, aspect); + for (r, middle) in reached.iter_mut().zip(&middles) { + *r = r.min(start + distance(*middle, center) / reach * growth); + } + spheres.push((at, start, center)); + } + (spheres, reached.into_iter().fold(0.0, f32::max)) + }; + // The slowest the spheres can grow and still reach every cell by + // the end, so the dissolve takes the whole transition. + let (mut slow, mut fast) = (0.01, 10.0); + for _ in 0..20 { + let reach = f32::sqrt(slow * fast); + match spheres_for(reach).1 <= growth { + true => fast = reach, + false => slow = reach, + } + } + let reach = fast; + let (mut spheres, _) = spheres_for(reach); + if spheres.is_empty() { + let center = scene_point( + [0.5, 0.5], + depths_around([0.5, 0.5], grid, uv_scale).1, + aspect, + ); + spheres.push(([0.5, 0.5], 0.0, center)); + } + + // When the first sphere reaches each cell, as portal_arrival in + // shader.wgsl works it out, and how much of the screen has been + // reached by each time. A cell can hold things at quite different + // depths, so it counts as half its area at its mean depth and a + // quarter at each end of its range. + let mut arrivals = Vec::with_capacity(cells.len() * 3); + for cell in &cells { + let d = cell.depths; + for (depth, share) in [(d.least, 0.25), (d.mean, 0.5), (d.greatest, 0.25)] { + let at = scene_point(cell.center(), depth, aspect); + let first = (spheres.iter()) + .map(|&(_, start, center)| { + let pace = (distance(at, center) / reach).min(1.0); + start + PORTAL_EDGE + pace * growth + }) + .fold(f32::MAX, f32::min); + arrivals.push((first, cell.area() * share)); + } + } + let timing = table(|x| { + let reached: f32 = (arrivals.iter()) + .filter(|&&(first, _)| first <= x) + .map(|&(_, area)| area) + .sum(); + PORTAL_EDGE + reached / total * growth + }); + + let mut points = [[0.0; 4]; TRAIL_POINTS]; + for (point, &(at, start, _)) in points.iter_mut().zip(&spheres) { + *point = [at[0], at[1], start, 0.0]; + } + Self { + points, + len: spheres.len(), + reach, + pace: table(|x| x), + timing, } } @@ -86,6 +269,171 @@ impl Trail { } } +/// `f` at PACE_POINTS evenly spaced points from 0 to 1, packed four to a +/// vec4 for the shader. +fn table(f: impl Fn(f32) -> f32) -> [[f32; 4]; PACE_POINTS.div_ceil(4)] { + let mut table = [[0.0; 4]; PACE_POINTS.div_ceil(4)]; + for i in 0..PACE_POINTS { + table[i / 4][i % 4] = f(i as f32 / (PACE_POINTS - 1) as f32); + } + table +} + +/// Where a pixel at `screen` is in the scene, as scene_point in +/// shader.wgsl. +fn scene_point(screen: [f32; 2], depth: f32, aspect: f32) -> [f32; 3] { + [ + (screen[0] - 0.5) * aspect, + screen[1] - 0.5, + -(depth + PORTAL_NEAR).ln(), + ] +} + +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. +fn to_image(screen: f32, uv_scale: f32) -> f32 { + 0.5 + (screen - 0.5) * uv_scale * (1.0 - 2.0 * MARGIN) +} + +/// The cell of a DepthGrid that image uv `image` is in, on one axis. +fn grid_cell(image: f32) -> usize { + ((image * GRID_CELLS as f32) as usize).min(GRID_CELLS - 1) +} + +/// The range of depths at `screen`, which is somewhere in the range of +/// its cell of `grid` or the ones around it, in case it's just over the +/// edge, and the mean depth of its cell. +fn depths_around(screen: [f32; 2], grid: &DepthGrid, uv_scale: [f32; 2]) -> ([f32; 2], f32) { + let col = grid_cell(to_image(screen[0], uv_scale[0])); + let row = grid_cell(to_image(screen[1], uv_scale[1])); + let mut range = [f32::MAX, f32::MIN]; + for r in row.saturating_sub(1)..(row + 2).min(GRID_CELLS) { + for c in col.saturating_sub(1)..(col + 2).min(GRID_CELLS) { + let cell = grid.cells[r * GRID_CELLS + c]; + range = [range[0].min(cell.least), range[1].max(cell.greatest)]; + } + } + (range, grid.cells[row * GRID_CELLS + col].mean) +} + +/// A cell of a wallpaper's DepthGrid, and where it shows on the screen. +struct ScreenCell { + left: f32, + right: f32, + top: f32, + bottom: f32, + depths: DepthCell, +} + +impl ScreenCell { + fn area(&self) -> f32 { + (self.right - self.left) * (self.bottom - self.top) + } + + /// Its middle on the screen. + fn center(&self) -> [f32; 2] { + [ + (self.left + self.right) / 2.0, + (self.top + self.bottom) / 2.0, + ] + } + + /// Where its middle is in the scene, at its mean depth. + fn middle(&self, aspect: f32) -> [f32; 3] { + scene_point(self.center(), self.depths.mean, aspect) + } + + /// The furthest any of its pixels can be in the scene from `screen`, at + /// a depth in the range `depths`. Its pixels are within its part of the + /// screen and its range of depths, so the furthest is no further than + /// one of its corners at one end of that range, from either end of + /// `depths`. + fn furthest_from(&self, screen: [f32; 2], depths: [f32; 2], aspect: f32) -> f32 { + let mut furthest: f32 = 0.0; + for x in [self.left, self.right] { + for y in [self.top, self.bottom] { + for depth in [self.depths.least, self.depths.greatest] { + for from in depths { + let d = distance( + scene_point([x, y], depth, aspect), + scene_point(screen, from, aspect), + ); + furthest = furthest.max(d); + } + } + } + } + furthest + } +} + +/// 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 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); + let bottom = to_screen((row + 1) as f32 / GRID_CELLS as f32, 1).min(1.0); + for col in 0..GRID_CELLS { + let left = to_screen(col as f32 / GRID_CELLS as f32, 0).max(0.0); + let right = to_screen((col + 1) as f32 / GRID_CELLS as f32, 0).min(1.0); + // Cropped off the screen. + if top >= bottom || left >= right { + continue; + } + cells.push(ScreenCell { + left, + right, + top, + bottom, + depths: grid.cells[row * GRID_CELLS + col], + }); + } + } + cells +} + +/// Pseudo-random numbers, the same ones for the same seed. +struct Random(u32); + +impl Random { + fn new(seed: u32) -> Self { + // Any seed but 0, which would only give 0. + Self(seed.wrapping_mul(0x9e37_79b9) | 1) + } + + /// A number from 0 to 1. + fn next(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + (self.0 >> 8) as f32 / (1 << 24) as f32 + } + + /// An index into `weights`, more likely the bigger its weight, or None + /// if they're all 0. + fn pick(&mut self, weights: &[f32]) -> Option { + let total: f32 = weights.iter().sum(); + if total <= 0.0 { + return None; + } + let mut left = self.next() * total; + for (i, &w) in weights.iter().enumerate() { + if left < w { + return Some(i); + } + left -= w; + } + weights.iter().rposition(|&w| w > 0.0) + } +} + /// How far the shader zooms in on each side. Must match MARGIN in /// shader.wgsl. const MARGIN: f32 = 0.025; @@ -100,6 +448,10 @@ pub struct DecodedWallpaper { /// From `DepthMap::ranks` and `DepthMap::height_ranks`, for ordering /// transitions. ranks: Vec<[f32; 2]>, + /// From `DepthMap::grid`, for the portal. + grid: DepthGrid, + /// From `DepthMap::ground`, for the tide. + ground: Ground, files: WallpaperFiles, } @@ -130,19 +482,23 @@ impl DecodedWallpaper { map.resized(w, h) }); } + let ground = depth.as_ref().map_or(Ground::LEVEL, DepthMap::ground); // A flat wallpaper is all one depth, so it changes all at once, // halfway through a transition. let ranks = match &depth { Some(map) => (map.ranks().into_iter()) - .zip(map.height_ranks()) + .zip(map.height_ranks(&ground)) .map(|(depth, height)| [depth, height]) .collect(), None => vec![[0.5, 0.5]; RANK_POINTS], }; + let grid = depth.as_ref().map_or_else(DepthGrid::flat, DepthMap::grid); Ok(Self { color, depth, ranks, + grid, + ground, files: (color_path.to_path_buf(), depth_path.to_path_buf()), }) } @@ -173,6 +529,10 @@ pub struct Wallpaper { color: wgpu::TextureView, depth: wgpu::TextureView, ranks: wgpu::TextureView, + /// Where things are in the scene, roughly, for the portal. + grid: DepthGrid, + /// Where the ground is, for the tide. + ground: Ground, /// Image size in pixels, for cropping to the screen's aspect ratio. size: (u32, u32), pub files: WallpaperFiles, @@ -251,12 +611,27 @@ 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 trail = match style { + Transition::Dissolve => { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |t| t.subsec_nanos()); + Trail::scatter(self.aspect(), &self.current.grid, uv_scale, seed) + } + _ => Trail::new( + [x + 0.5, y + 0.5], + self.aspect(), + &self.current.grid, + uv_scale, + ), + }; self.transition = Some(ActiveTransition { next, start: Instant::now(), duration, style, - trail: Trail::new([x + 0.5, y + 0.5], self.aspect()), + trail, frames: 0, }); } @@ -295,7 +670,9 @@ impl OutputRenderState { Some(t) => { t.frames += 1; let progress = transition_progress(t.start.elapsed(), t.duration); - t.trail.follow([nx + 0.5, ny + 0.5], progress, aspect); + if t.style == Transition::Portal { + t.trail.follow([nx + 0.5, ny + 0.5], progress, aspect); + } (progress, &t.next, t.style, Some(t.trail)) } None => (0.0, &self.current, Transition::default(), None), @@ -309,8 +686,12 @@ impl OutputRenderState { style: shader_style(style), aspect, trail_len: trail.map_or(0, |t| t.len as u32), - portal_speed: trail.map_or(0.0, |t| t.speed), + portal_reach: trail.map_or(0.0, |t| t.reach), trail: trail.map_or([[0.0; 4]; TRAIL_POINTS], |t| t.points), + portal_pace: trail.map_or(Zeroable::zeroed(), |t| t.pace), + portal_timing: trail.map_or(Zeroable::zeroed(), |t| t.timing), + ground: self.current.ground.0, + next_ground: next.ground.0, }; queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&uniforms)); let moved = (nx - cx).abs() > 1e-5 || (ny - cy).abs() > 1e-5; @@ -574,6 +955,8 @@ impl Renderer { None => self.depth_view.clone(), }, ranks: self.upload_ranks(&decoded.ranks), + grid: decoded.grid.clone(), + ground: decoded.ground, size: decoded.color.dimensions(), files: decoded.files.clone(), } @@ -869,33 +1252,186 @@ mod tests { fn portal_constants_match_the_shader() { let src = include_str!("shader.wgsl"); for line in [ - format!("const PORTAL_DEPTH: f32 = {PORTAL_DEPTH:?};"), + format!("const PORTAL_NEAR: f32 = {PORTAL_NEAR:?};"), format!("const PORTAL_EDGE: f32 = {PORTAL_EDGE:?};"), + format!("const PACE_POINTS: u32 = {PACE_POINTS}u;"), format!("const TRAIL_POINTS: u32 = {TRAIL_POINTS}u;"), ] { assert!(src.contains(&line), "{line}"); } } + /// How far a pixel at `screen` is from where a trail from `cursor` + /// starts, as portal_arrival in shader.wgsl works it out. + fn portal_distance( + map: &DepthMap, + uv_scale: [f32; 2], + aspect: f32, + cursor: [f32; 2], + screen: [f32; 2], + ) -> 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 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; + [ + (s[0] - 0.5) * aspect, + s[1] - 0.5, + -(depth + PORTAL_NEAR).ln(), + ] + }; + let (p, c) = (point(screen), point(cursor)); + (0..3).map(|i| (p[i] - c[i]).powi(2)).sum::().sqrt() + } + + /// As pace in shader.wgsl. + fn pace(trail: &Trail, distance: f32) -> f32 { + let x = (distance / trail.reach).clamp(0.0, 1.0) * (PACE_POINTS - 1) as f32; + let i = (x as usize).min(PACE_POINTS - 2); + let (a, b) = ( + trail.pace[i / 4][i % 4], + trail.pace[(i + 1) / 4][(i + 1) % 4], + ); + a + (b - a) * (x - i as f32) + } + + /// A depth map wider than the screen, with depths from `depth` at + /// each pixel. + fn depth_map(depth: impl Fn(u32, u32) -> f32) -> DepthMap { + let (width, height) = (300, 160); + let data = (0..width * height) + .map(|i| (depth(i % width, i / width) * 65535.0) as u16) + .collect(); + DepthMap { + data, + width, + height, + } + } + + /// Pixel centres all over a 160 x 100 screen. + fn screen_pixels() -> impl Iterator { + (0..160 * 100).map(|i| { + [ + ((i % 160) as f32 + 0.5) / 160.0, + ((i / 160) as f32 + 0.5) / 100.0, + ] + }) + } + + const CURSORS: [[f32; 2]; 4] = [[0.5, 0.5], [0.0, 0.0], [0.9, 0.2], [0.3, 0.97]]; + + #[test] + fn the_portal_reaches_every_pixel() { + // A slope with sharp bumps all over it. + let map = + depth_map(|x, y| y as f32 / 160.0 * 0.6 + ((x * 7 + y * 13) % 23) as f32 / 22.0 * 0.4); + let uv_scale = cover_uv_scale((map.width, map.height), (1600, 1000)); + for cursor in CURSORS { + let trail = Trail::new(cursor, 1.6, &map.grid(), uv_scale); + for screen in screen_pixels() { + let distance = portal_distance(&map, uv_scale, 1.6, cursor, screen); + // To within a pixel. + assert!(distance <= trail.reach + 1e-3, "{cursor:?} {screen:?}"); + } + } + } + + #[test] + fn the_portal_changes_the_picture_steadily() { + // A slope, and a slope with a few specks far away, like sky through + // trees, which shouldn't hold the rest up. + let slope = |_, y| y as f32 / 160.0; + let specks = |x, y| { + if (x + y * 3) % 97 == 0 { + 0.0 + } else { + 0.5 + y as f32 / 320.0 + } + }; + for map in [depth_map(slope), depth_map(specks)] { + let uv_scale = cover_uv_scale((map.width, map.height), (1600, 1000)); + for cursor in CURSORS { + let trail = Trail::new(cursor, 1.6, &map.grid(), uv_scale); + let paces: Vec = screen_pixels() + .map(|s| pace(&trail, portal_distance(&map, uv_scale, 1.6, cursor, s))) + .collect(); + for part in [0.25, 0.5, 0.75] { + let done = + paces.iter().filter(|&&p| p <= part).count() as f32 / paces.len() as f32; + assert!((done - part).abs() < 0.1, "{cursor:?} {part} {done}"); + } + } + } + } + + /// When the pixel at `screen` switches in a dissolve, as portal_arrival + /// in shader.wgsl works it out, but without the noise. + fn dissolve_switches( + trail: &Trail, + map: &DepthMap, + uv_scale: [f32; 2], + screen: [f32; 2], + ) -> f32 { + let growth = 1.0 - 2.0 * PORTAL_EDGE; + let first = (trail.points[..trail.len].iter()) + .map(|&[x, y, start, _]| { + let d = portal_distance(map, uv_scale, 1.6, [x, y], screen); + start + PORTAL_EDGE + pace(trail, d) * growth + }) + .fold(2.0, f32::min); + // As timing in shader.wgsl. + let x = first.clamp(0.0, 1.0) * (PACE_POINTS - 1) as f32; + let i = (x as usize).min(PACE_POINTS - 2); + let (a, b) = ( + trail.timing[i / 4][i % 4], + trail.timing[(i + 1) / 4][(i + 1) % 4], + ); + a + (b - a) * (x - i as f32) + } + + #[test] + fn the_dissolve_changes_the_picture_steadily() { + // A slope with sharp bumps all over it. + let map = + depth_map(|x, y| y as f32 / 160.0 * 0.6 + ((x * 7 + y * 13) % 23) as f32 / 22.0 * 0.4); + let uv_scale = cover_uv_scale((map.width, map.height), (1600, 1000)); + for seed in 0..4 { + let trail = Trail::scatter(1.6, &map.grid(), uv_scale, seed); + let switches: Vec = screen_pixels() + .map(|s| dissolve_switches(&trail, &map, uv_scale, s)) + .collect(); + for part in [0.25, 0.5, 0.75, 1.0] { + let done = + switches.iter().filter(|&&p| p <= part).count() as f32 / switches.len() as f32; + assert!((done - part).abs() < 0.1, "{seed} {part} {done}"); + } + } + } + #[test] - fn the_first_bubble_reaches_every_corner_at_the_end() { - let aspect = 16.0 / 10.0; - for cursor in [[0.5, 0.5], [0.0, 0.0], [0.9, 0.2]] { - let trail = Trail::new(cursor, aspect); - let radius = trail.speed - PORTAL_EDGE; - for corner in [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]] { - let dx = (corner[0] - cursor[0]) * aspect; - let dy = corner[1] - cursor[1]; - // The furthest a pixel can be, counting a full step in depth. - let far = (dx * dx + dy * dy + PORTAL_DEPTH * PORTAL_DEPTH).sqrt(); - assert!(radius - PORTAL_EDGE >= far - 1e-5, "{cursor:?} {corner:?}"); + fn the_dissolve_starts_far_away_and_quickens() { + // Far away at the top. + let map = depth_map(|_, y| y as f32 / 160.0); + let uv_scale = cover_uv_scale((map.width, map.height), (1600, 1000)); + let mut first_heights = 0.0; + for seed in 0..20 { + let trail = Trail::scatter(1.6, &map.grid(), uv_scale, seed); + first_heights += trail.points[0][1] / 20.0; + let starts: Vec = trail.points[..trail.len].iter().map(|p| p[2]).collect(); + assert!(trail.len > 1 && starts[0] == 0.0, "{starts:?}"); + for gaps in starts.windows(3) { + assert!(gaps[2] - gaps[1] <= gaps[1] - gaps[0] + 1e-6, "{starts:?}"); } } + assert!(first_heights < 0.4, "{first_heights}"); } #[test] fn the_trail_follows_the_cursor_in_steps() { - let mut trail = Trail::new([0.5, 0.5], 1.0); + let mut trail = Trail::new([0.5, 0.5], 1.0, &DepthGrid::flat(), [1.0, 1.0]); trail.follow([0.51, 0.5], 0.1, 1.0); assert_eq!(trail.len, 1, "a small move doesn't add a point"); trail.follow([0.6, 0.5], 0.2, 1.0); @@ -908,14 +1444,9 @@ mod tests { } #[test] - fn tide_constants_match_the_shader() { - let src = include_str!("shader.wgsl"); - for line in [ - format!("const HORIZON: f32 = {:?};", crate::depth::HORIZON), - format!("const TIDE_NEAR: f32 = {:?};", crate::depth::TIDE_NEAR), - ] { - assert!(src.contains(&line), "{line}"); - } + fn tide_near_matches_the_shader() { + let line = format!("const TIDE_NEAR: f32 = {:?};", crate::depth::TIDE_NEAR); + assert!(include_str!("shader.wgsl").contains(&line), "{line}"); } #[test] diff --git a/crates/daemon/src/shader.wgsl b/crates/daemon/src/shader.wgsl index 595707d..d5dd1af 100644 --- a/crates/daemon/src/shader.wgsl +++ b/crates/daemon/src/shader.wgsl @@ -43,13 +43,23 @@ struct Uniforms { style: u32, // The screen's width over its height. aspect: f32, - // Where the cursor has been in a portal transition: screen uv, then the - // progress when it was there. The first `trail_len` are in use. + // Where the spheres of a portal or dissolve grow from: screen uv, then + // the progress when each starts. The first `trail_len` are in use. trail_len: u32, - // How fast each of the portal's bubbles grows, in screen heights over - // the whole transition. - portal_speed: f32, + // How far a sphere grows, in the units of `scene_point`. See `Trail` + // in renderer.rs. + portal_reach: f32, trail: array, TRAIL_POINTS>, + // How the portal's spheres are paced, PACE_POINTS of them four to + // a vec4. See `pace`. + portal_pace: array, 9>, + // When a pixel switches, from when the first sphere reaches it, packed + // the same way. See `timing`. + portal_timing: array, 9>, + // Where the ground is in each wallpaper, for the tide. See + // `tide_height`. + ground: vec4, + next_ground: vec4, }; @group(0) @binding(3) var u: Uniforms; @@ -68,12 +78,16 @@ const TIDE_OUT: u32 = 6u; // The portal's settings, explained in renderer.rs. Must match it. const TRAIL_POINTS: u32 = 32u; -const PORTAL_DEPTH: f32 = 1.0; -const PORTAL_EDGE: f32 = 0.04; +const PORTAL_NEAR: f32 = 0.1; +const PACE_POINTS: u32 = 33u; +// How soft the portal's edge is, as a fraction of the transition. +const PORTAL_EDGE: f32 = 0.015; +// How far noise pushes the portal's edge in and out, in the units of +// `scene_point`, and how many bumps it has to a unit. +const PORTAL_NOISE: f32 = 0.12; +const PORTAL_NOISE_SCALE: f32 = 6.0; -// Where the tide takes the horizon to be, down the image, and how close a -// depth of 0 is. Must match depth.rs. -const HORIZON: f32 = 0.5; +// How close a depth of 0 is for the tide. Must match depth.rs. const TIDE_NEAR: f32 = 0.1; // Just under the tide's surface the water ripples and darkens a little. // How far down that goes, in height rank, how far the ripple moves the @@ -155,18 +169,11 @@ fn value_noise(p: vec2) -> f32 { return mix(below, above, s.y); } -// Soft blobs, about six to the screen's height, with finer detail at -// their edges. -fn blobs(screen_uv: vec2) -> f32 { - let p = vec2(screen_uv.x * u.aspect, screen_uv.y) * 6.0; - return 0.7 * value_noise(p) + 0.3 * value_noise(p * 3.1 + 17.0); -} - -// Roughly how high a point is in the scene, squashed into (-1, 1). Must -// match tide_height in depth.rs, which explains it. -fn tide_height(image_y: f32, depth: f32) -> f32 { +// Roughly how high a point is in the scene above `ground`, squashed into +// (-1, 1). Must match tide_height in depth.rs, which explains it. +fn tide_height(uv: vec2, depth: f32, ground: vec4) -> f32 { let distance = 1.0 / (depth + TIDE_NEAR); - let height = (HORIZON - image_y) * distance; + let height = distance * dot(ground.xyz, vec3(uv, 1.0)) + ground.w; return height / (1.0 + abs(height)); } @@ -189,9 +196,9 @@ fn height_rank(table: texture_1d, height: f32) -> f32 { fn under_water(uv: vec2, depth: f32, next_uv: vec2, next_depth: f32, t: f32) -> f32 { let level = t * (1.0 + 2.0 * BAND) - BAND; if u.style == TIDE_IN { - return level - height_rank(ranks, tide_height(uv.y, depth)); + return level - height_rank(ranks, tide_height(uv, depth, u.ground)); } - return (1.0 - level) - height_rank(next_ranks, tide_height(next_uv.y, next_depth)); + return (1.0 - level) - height_rank(next_ranks, tide_height(next_uv, next_depth, u.next_ground)); } // Small waves across the tide's surface, which move as it does. @@ -200,27 +207,79 @@ fn wave(screen_uv: vec2, t: f32) -> f32 { + 0.5 * sin(screen_uv.x * 210.0 - t * 70.0 + screen_uv.y * 40.0); } -// How far a pixel has switched in a portal. The new wallpaper grows like -// a bubble in the scene from each point on the cursor's trail, so it -// spreads across the surface under the cursor first and has to wrap round -// things nearer or further away. -fn portal_weight(screen_uv: vec2, rank: f32, t: f32) -> f32 { - var weight = 0.0; - for (var i = 0u; i < u.trail_len && weight < 1.0; i += 1u) { +// Where a pixel is in the scene: across the screen in screen heights, then +// the log of its distance, from depth as in `tide_height`. Near any one +// point this is the scene shrunk by that point's distance, so a sphere in +// it looks like a sphere in the scene, but far things aren't spread out +// any more than near ones. Trail::new in renderer.rs relies on how far +// apart this puts any two pixels. +fn scene_point(screen_uv: vec2, depth: f32) -> vec3 { + return vec3((screen_uv - 0.5) * vec2(u.aspect, 1.0), -log(depth + PORTAL_NEAR)); +} + +// How far through its growth a portal sphere is once it has grown to +// `distance`, from `Trail::pace` in renderer.rs. +fn pace(distance: f32) -> f32 { + let x = clamp(distance / u.portal_reach, 0.0, 1.0) * f32(PACE_POINTS - 1u); + let i = min(u32(x), PACE_POINTS - 2u); + let j = i + 1u; + return mix(u.portal_pace[i / 4u][i % 4u], u.portal_pace[j / 4u][j % 4u], x - f32(i)); +} + +// When a pixel switches, as transition progress, once the first sphere +// reaches it at `arrival`, from `Trail::timing` in renderer.rs. +fn timing(arrival: f32) -> f32 { + let x = clamp(arrival, 0.0, 1.0) * f32(PACE_POINTS - 1u); + let i = min(u32(x), PACE_POINTS - 2u); + let j = i + 1u; + return mix(u.portal_timing[i / 4u][i % 4u], u.portal_timing[j / 4u][j % 4u], x - f32(i)); +} + +// Smooth noise in the scene, from 0 to 1, made of two layers of +// value_noise blended through depth. +fn scene_noise(p: vec3) -> f32 { + let q = p * PORTAL_NOISE_SCALE + 64.0; + let layer = floor(q.z); + let s = fract(q.z); + let below = value_noise(q.xy + layer * 17.0); + let above = value_noise(q.xy + (layer + 1.0) * 17.0); + return mix(below, above, s * s * (3.0 - 2.0 * s)); +} + +// The new wallpaper grows like a sphere in the scene from each point on +// the trail, starting at the surface under it: the cursor's trail for the +// portal, and random points for the dissolve. It spreads over that +// surface first and reaches things nearer or further away later, so its +// front wraps round the shape of the scene. Noise pushes its edge in and +// out, across the screen and in depth. Returns when this pixel switches, +// as transition progress, from when the first sphere reaches it. +fn portal_arrival(screen_uv: vec2, depth: f32) -> f32 { + let here = scene_point(screen_uv, depth); + let bump = (scene_noise(here) - 0.5) * PORTAL_NOISE; + // Each sphere grows over this much of the transition, so the first + // has reached everywhere by the end, at the latest. + let growth = 1.0 - 2.0 * PORTAL_EDGE; + var first = 2.0; + for (var i = 0u; i < u.trail_len; i += 1u) { let point = u.trail[i]; - let radius = (t - point.z) * u.portal_speed - PORTAL_EDGE; - let across = (screen_uv - point.xy) * vec2(u.aspect, 1.0); - // Depth only adds distance, so a bubble that can't reach this far - // across the screen can be skipped before looking up its depth. - if length(across) >= radius + PORTAL_EDGE { + // The trail is in the order the spheres start, and no sphere + // arrives before it starts, so none of the rest can arrive any + // sooner. + let start = point.z + PORTAL_EDGE; + if start >= first { + break; + } + // Depth only adds distance, so a sphere that can't get here sooner + // going straight across the screen can be skipped before looking + // up its depth. + let across = length((screen_uv - point.xy) * vec2(u.aspect, 1.0)); + if start + pace(across + bump) * growth >= first { continue; } - let point_rank = depth_rank(ranks, load_depth(depth_tex, crop(point.xy, u.uv_scale))); - let offset = vec3(across, (rank - point_rank) * PORTAL_DEPTH); - let inside = 1.0 - smoothstep(radius - PORTAL_EDGE, radius + PORTAL_EDGE, length(offset)); - weight = max(weight, inside); + let center = scene_point(point.xy, load_depth(depth_tex, crop(point.xy, u.uv_scale))); + first = min(first, start + pace(distance(here, center) + bump) * growth); } - return weight; + return timing(first); } // How far a pixel has changed into the next wallpaper, for its colour and @@ -238,14 +297,9 @@ fn transition_blend(screen_uv: vec2, depth: f32, next_depth: f32, t: f32) - // as a morph rather than a crossfade. return Blend(smoothstep(0.25, 1.0, t), smoothstep(0.0, 0.75, t), 0.0); } - if u.style == DISSOLVE { - // Half depth order, as in sweep-in, and half random blobs. - let next_rank = depth_rank(next_ranks, next_depth); - let weight = swept(mix(1.0 - next_rank, blobs(screen_uv), 0.5), t); - return Blend(weight, weight, 0.0); - } - if u.style == PORTAL { - let weight = portal_weight(screen_uv, depth_rank(ranks, depth), t); + if u.style == PORTAL || u.style == DISSOLVE { + let arrival = portal_arrival(screen_uv, depth); + let weight = smoothstep(arrival - PORTAL_EDGE, arrival + PORTAL_EDGE, t); return Blend(weight, weight, 0.0); } if u.style == TIDE_IN || u.style == TIDE_OUT { diff --git a/docs/media/dissolve.avif b/docs/media/dissolve.avif index 7794c91..9172627 100644 Binary files a/docs/media/dissolve.avif and b/docs/media/dissolve.avif differ diff --git a/docs/media/portal.avif b/docs/media/portal.avif index fa102a5..7a0278b 100644 Binary files a/docs/media/portal.avif and b/docs/media/portal.avif differ diff --git a/docs/media/tide-in.avif b/docs/media/tide-in.avif index 215a0a2..4060321 100644 Binary files a/docs/media/tide-in.avif and b/docs/media/tide-in.avif differ diff --git a/docs/media/tide-out.avif b/docs/media/tide-out.avif index 3adc2ea..7070c06 100644 Binary files a/docs/media/tide-out.avif and b/docs/media/tide-out.avif differ diff --git a/docs/transitions.md b/docs/transitions.md index f0b56b6..0af8e8a 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -14,8 +14,8 @@ Each clip below goes from one wallpaper to the other and back, using the default | **`sweep-in`** (the default)

A wave sweeps into the scene. The new wallpaper's nearest things appear first, and it fills in towards the distance. | sweep-in | | **`sweep-out`**

The old wallpaper's background gives way first, and its nearest things go last. | sweep-out | | **`morph`**

Everything changes together, the shape of the scene a little ahead of its colours. | morph | -| **`dissolve`**

Patches of the new wallpaper appear at random, nearer things tending to go first. | dissolve | -| **`portal`**

The new wallpaper grows out from your cursor like a bubble, and follows it as it moves. Move the cursor around to paint it in. | portal | +| **`dissolve`**

The new wallpaper grows like spheres from random places in the scene, more and more of them as it goes. Further things tend to go first. | dissolve | +| **`portal`**

The new wallpaper grows out from your cursor like a sphere. It spreads over whatever is under the cursor first, then reaches things nearer and further away. It follows the cursor as it moves, so you can paint it in. | portal | | **`tide-in`**

The new wallpaper rises like water, filling the lowest places first. | tide-in | | **`tide-out`**

The old wallpaper drains away like water, uncovering the highest places first. | tide-out |