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

Grid30
Cell aspect1.00
Radius0.20
Strength0.20
Threshold300
Relaxation0.900
Shift1.00
Aberration1.50
Grain0.10
Scramble1.00

Install

One command copies the composition into your project (with the @vshaders namespace registered in components.json; the plain URL https://vshaders.com/r/displacement.json works with no setup). npm dependencies install alongside.

npx shadcn@latest add @vshaders/displacement

Files that land in your tree: shaders/displacement/state.wgsl, shaders/displacement/unpack.wgsl, shaders/displacement/display.wgsl, components/displacement.tsx, lib/displacement-runner.ts, lib/shared-gpu.ts, wgsl-env.d.ts.

The shaders import WGSL modules from npm, resolved by vgpu's loader. In next.config.ts, wire @vgpu/wgsl/loader-webpack for *.wgsl under both turbopack.rules and the webpack() hook, and keep the installed wgsl-env.d.ts at your project root so .wgsl imports typecheck. This item ships its own runner (lib/displacement-runner.ts) instead of lib/run-effect.ts. The background is an image and the gallery's demo image is not included: pass the component's imageUrl prop (or runDisplacement's imageUrl option, or the handle's loadImage) pointing at an asset of your project, or the canvas renders an empty background and warns in development. Loader setup: https://vgpu.sh/docs. Effect gallery and sources: https://vshaders.com.

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.

Imports

What the passes compose, deduplicated across all of them; every module 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);
}