displacement@0.1.0
Displacement
A fine grid of cells shearing an image apart under the cursor — with memory: every tile a fast stroke touches snaps out of place and stays there, sliding home over the next moments as the per-cell state relaxes. RGB channels fringe on every moved tile, and a stepped film grain rides only the displaced areas. Works over any texture; the gallery feeds it a still image.
Controls
Install
The item URL works with no components.json setup. Files land in your tree and npm dependencies install alongside; the shader needs vgpu's WGSL loader wired once, in Setup.
Passes
Rendered as 3 shader passes into offscreen textures each frame, in this order; only the display pass draws to the canvas.
state.wgsl · The memory: one texel per cell relaxes home and takes inverse-distance impulse kicks from a fast pointer.
unpack.wgsl · Writes the decoded background image from a storage buffer into its target, once per load. Demo plumbing.
display.wgsl · Renders the image cover-fitted, shifted by whole-cell offsets; the only pass that draws to the canvas.
Between them the passes compose these modules, deduplicated across all of them; every one resolves from npm.
import { hash2 } from "@vgpu/wgsl-std/hash";Source
The display pass, displacement/display.wgsl; the other passes follow below. Copy them and they are yours.
// displacement · display — a fine grid of cells shearing an image apart
// under the cursor, with memory. The canvas shows the background image,
// cover-fitted, perfectly seamless at rest: sweep the pointer fast and every
// cell the stroke touches snaps out of place as a whole tile and *stays*
// out of place, sliding home as state.wgsl relaxes it. There are no drawn
// cell borders — the mosaic reads purely from the offset being constant
// across each cell (the state texture is one texel per cell), which tears
// the image along cell boundaries wherever neighbours disagree. Moved tiles
// fringe their RGB channels apart, and a chunky, stepped film grain rides
// only the displaced areas.
//
// The technique — a cell-resolution offset field with impulse kicks and
// relaxation over live content — is learned from studying the cell-
// displacement effects around the web (Canvas UI's Displacement among them,
// MIT + Commons Clause); this WGSL is written from scratch for vshaders'
// two-pass architecture with its own parameterization, not a port.
// Works display-referred: the image's sRGB pixels pass through untouched.
import { hash2 } from "@vgpu/wgsl-std/hash";
// Tunable members follow resolution/time. Defaults live in lib/effects.ts and
// must be set by the runner: an unset uniform member reads as zero.
struct Uniforms {
resolution: vec2f,
// Seconds; steps the grain ticks.
time: f32,
// Width / height of the background image.
imageAspect: f32,
// Live cell grid (the state texture may be larger).
cols: f32,
rows: f32,
// Multiplier on how far a displaced cell shifts the image.
shift: f32,
// Chromatic fringing on displaced cells: R/B over- and undershoot.
aberration: f32,
// Film grain opacity over displaced areas.
grain: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var stateTex: texture_2d<f32>;
@group(0) @binding(2) var imageTex: texture_2d<f32>;
@group(0) @binding(3) var imageSampler: sampler;
// Canvas uv → image uv, cover-fit: the image is scaled up to fill the canvas
// and the overflowing axis is center-cropped.
fn coverUv(uv: vec2f, canvasAspect: f32) -> vec2f {
var scale = vec2f(1.0);
let imageAspect = max(uniforms.imageAspect, 1e-3);
if (canvasAspect > imageAspect) {
scale.y = imageAspect / canvasAspect;
} else {
scale.x = canvasAspect / imageAspect;
}
return (uv - vec2f(0.5)) * scale + vec2f(0.5);
}
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let res = max(uniforms.resolution, vec2f(1.0));
let uv = position.xy / res;
let canvasAspect = res.x / res.y;
// This pixel's cell, read as one texel: the whole tile shares one offset,
// and that quantization alone produces the mosaic tears.
let cell = vec2i(floor(uv * vec2f(max(uniforms.cols, 1.0), max(uniforms.rows, 1.0))));
let last = max(vec2i(textureDimensions(stateTex)) - vec2i(1), vec2i(0));
let offset = textureLoad(stateTex, clamp(cell, vec2i(0), last), 0).xy;
let push = offset * 0.02 * uniforms.shift;
// A displaced cell shows the image translated with it; the aberration makes
// the red and blue samples over/undershoot, so only moved tiles fringe.
// The push applies in canvas uv (screen-isotropic), then each sample maps
// through the cover fit; clamping just inside the edge avoids wrap bleed.
let split = 0.08 * uniforms.aberration;
let lo = vec2f(0.001);
let hi = vec2f(0.999);
let uvR = clamp(coverUv(uv - push * (1.0 + split), canvasAspect), lo, hi);
let uvG = clamp(coverUv(uv - push, canvasAspect), lo, hi);
let uvB = clamp(coverUv(uv - push * (1.0 - split), canvasAspect), lo, hi);
var color = vec3f(
textureSampleLevel(imageTex, imageSampler, uvR, 0.0).r,
textureSampleLevel(imageTex, imageSampler, uvG, 0.0).g,
textureSampleLevel(imageTex, imageSampler, uvB, 0.0).b,
);
// Chunky film grain, only where tiles are actually displaced: speck cells
// of a few pixels, advancing in discrete ticks so it flickers like print,
// fading in with the push and gone completely at rest.
let pushPx = length(push * res);
let grainGate = smoothstep(1.5, 18.0, pushPx);
let speck = floor(position.xy / 3.0);
let tick = floor(uniforms.time * 18.0);
let grainNoise = hash2(speck * 0.73 + vec2f(tick * 0.37, tick * 0.113)).x - 0.5;
color += grainNoise * 0.3 * uniforms.grain * grainGate;
return vec4f(clamp(color, vec3f(0.0), vec3f(1.0)), 1.0);
}displacement/state.wgsl
// displacement · state — the per-cell memory that makes the effect. One texel
// per grid cell holds that cell's accumulated offset (cell units, RG signed).
// Each frame every cell relaxes toward home, and while the pointer moves
// fast enough the runner feeds an impulse: cells near the stroke take a kick
// proportional to the pointer's *travel* this frame, with an inverse-distance
// falloff — a hard spike right under the cursor, a soft halo around it. That
// impulse profile, not a smooth bump, is what tears tiles out of place.
// A one-frame seed impulse scrambles the whole grid on load, so the image
// assembles itself as the cells relax in.
import { hash2 } from "@vgpu/wgsl-std/hash";
struct Uniforms {
// Resolution of this pass's render target (the state texture).
resolution: vec2f,
// Timestep in seconds, clamped by the runner.
dt: f32,
// Live cell grid: texels at or beyond cols/rows idle at zero.
cols: f32,
rows: f32,
// Corrects vertical cell distances for non-square cells, so the radius is
// round on screen: (canvasH * cols) / (canvasW * rows).
rowScale: f32,
// Pointer position in cell coordinates.
pointerCell: vec2f,
// This frame's impulse (cell units): pointer travel × strength × the
// speed gate, zero when idle or below the threshold.
kick: vec2f,
// Influence radius around the pointer, in cells.
radiusCells: f32,
// Per-60Hz-frame retention of a cell's offset — the healing speed.
relaxation: f32,
// One-frame load scramble: amplitude of a random offset per cell.
seed: f32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var stateTex: texture_2d<f32>;
@fragment
fn fs_main(@builtin(position) position: vec4f) -> @location(0) vec4f {
let texel = vec2i(position.xy);
let last = max(vec2i(textureDimensions(stateTex)) - vec2i(1), vec2i(0));
var offset = textureLoad(stateTex, clamp(texel, vec2i(0), last), 0).xy;
// Frame-rate independent relaxation: the control means "per 60Hz frame".
let dt = clamp(uniforms.dt, 0.0, 1.0 / 30.0);
offset *= pow(clamp(uniforms.relaxation, 0.0, 0.999), dt * 60.0);
// The kick: inverse-distance power, clamped hard under the cursor. Cells
// outside the radius are untouched; cells at its edge get 1×; the cell the
// cursor is on gets the cap. Distances are corrected to be round on screen.
let d = vec2f(
uniforms.pointerCell.x - f32(texel.x),
(uniforms.pointerCell.y - f32(texel.y)) * uniforms.rowScale,
);
let dist = length(d);
let radius = max(uniforms.radiusCells, 1e-3);
if (dist < radius) {
let power = min(radius / max(dist, 1e-3), 8.0);
offset += uniforms.kick * power;
}
// Load scramble: one frame of hashed chaos that the relaxation heals.
if (uniforms.seed > 0.0) {
offset += (hash2(vec2f(texel) * 0.291 + vec2f(1.7, 4.1)) - vec2f(0.5)) * 2.0 * uniforms.seed;
}
return vec4f(offset, 0.0, 1.0);
}displacement/unpack.wgsl
// displacement · unpack — writes the decoded background image from a storage
// buffer into the image target, one u32 RGBA texel per element, 1:1 (the
// target is sized to the image; display.wgsl does the cover fit). The bridge
// for the demo image and dropped replacements: vgpu targets have no
// copy_dst, so the pixels arrive by buffer instead. Demo plumbing.
struct Uniforms {
// Resolution of the image target, texels; equals the buffered image's size.
resolution: vec2f,
// Dimensions of the buffered image, texels.
imageSize: vec2f,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> pixels: array<u32>;
@fragment
fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
let size = max(uniforms.imageSize, vec2f(1.0));
let texelCoord = vec2u(clamp(uv, vec2f(0.0), vec2f(0.9999)) * size);
let raw = pixels[texelCoord.y * u32(size.x) + texelCoord.x];
let r = f32(raw & 0xffu) / 255.0;
let g = f32((raw >> 8u) & 0xffu) / 255.0;
let b = f32((raw >> 16u) & 0xffu) / 255.0;
return vec4f(r, g, b, 1.0);
}