Torn Vignette Portal Effect (Three.js + GLSL)

published: ⏰ 5

A real-time WebGL vignette portal shader with animated FBM noise edge wobble, fog masking, and an interactive control panel.

Loading demo…
Move mouse for parallax. Control panel below the image.

What it does

The effect renders a rectangular image inside a rounded-rectangle window whose edges are continuously distorted by layered fractal noise. A volumetric fog bleeds outward beyond the window, its shape independently modulated by a second noise field. The result is an organic, portal-like vignette that looks torn and alive.

Move the mouse over the canvas to shift the camera and get a subtle parallax. All knobs are on the control panel below the image.

Requirements

ComponentDescription
BrowserModern browser with WebGL 2.0 support
GLSLES 3.00 (WebGL 2) or ES 1.00 (WebGL 1 fallback)

How the shader works

The fragment shader is built from three composable layers on a single fullscreen plane:

Image mask – sdRoundBox

A signed-distance function for a rounded rectangle defines the “window” shape. Points inside the box have negative distance, points outside are positive. The smoothstep between innerFade and outerFade turns this distance into a 0–1 alpha:

Glsl
float d = sdRoundBox(p, vec2(uSize * uAspect, uSize), uRadius);
float imgMask = 1.0 - smoothstep(uInnerFade, uOuterFade, d);

Edge wobble – FBM noise

A 5-octave fractal Brownian motion (FBM) is sampled at each pixel and multiplied by uWobble to modulate the SDF distance. The noise is evaluated in normalized image-space coordinates so it stays anchored to the picture rather than scrolling with the viewport:

Glsl
float n1 = fbm(nc * 4.0 * uNoiseScale + vec2(uTime * 0.18, -uTime * 0.12));
float wob = (n1 - 0.5) * uWobble;
float imgMask = 1.0 - smoothstep(uInnerFade, uOuterFade, d + wob);

The two different time coefficients on X and Y create an asymmetric drift.

Volumetric fog

A second FBM pass at a different frequency (7.0 × uNoiseScale) drives the fog. The fog fades in as you move outside the window and fades out at a distance set by outerEdge:

Glsl
float fog = smoothstep(-0.30, 0.08, d + wob)
          * (1.0 - smoothstep(0.05, uOuterEdge, d + (n2 - 0.5) * 0.30));
fog = clamp(fog * (0.6 + 0.4 * n2) * uFogDensity, 0.0, 1.0);

The (0.6 + 0.4 * n2) factor adds a density ripple so the fog isn’t flat.

Pad field

The pad uniform expands the render plane beyond the image boundary. This creates free space where fog can grow without clipping the photo. The image UVs are scaled: uvImg = (vUv - 0.5) * k + 0.5 where k = 1.0 + uPad.

Compositing

Glsl
float fogMix = clamp(fog + (1.0 - imgMask), 0.0, 1.0);
vec3 color = mix(texture2D(uTexture, uvImg).rgb, uFogColor, fogMix);

Where the image is opaque (fog ≈ 0, imgMask ≈ 1), the result is the image. Where fog takes over (fog > 0 or imgMask < 1), the color shifts toward uFogColor. A final borderFade smooths the canvas edges.

Tuning parameters

ParameterDefaultEffect
pad0.30Free zone around the image; more room for fog to expand without clipping
size1.00Window size; 1.0 = frame exactly at image boundary
radius0.12Corner roundness of the window shape
outerEdge0.30How far fog extends beyond the window edge
innerFade-0.20Distance inside the window where image fade begins
outerFade0.06Distance outside the window where image fade ends
wobble0.35Edge distortion amplitude from FBM noise
speed1.0Animation time multiplier
noiseScale1.0FBM noise frequency; higher = finer detail
fogDensity1.0Overall fog opacity
parallax1.0Camera movement strength on mouse move

Full shader code

The vertex shader is minimal – it only passes the UV coordinates through:

Glsl
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

The fragment shader in full – helper functions hash, noise, fbm, sdRoundBox and the main block:

Glsl
uniform sampler2D uTexture;
uniform float uTime;
uniform vec3  uFogColor;
uniform float uFogDensity;
uniform float uNoiseScale;
uniform float uPad;
uniform float uSize;
uniform float uRadius;
uniform float uOuterEdge;
uniform float uInnerFade;
uniform float uOuterFade;
uniform float uWobble;
uniform float uAspect;
varying vec2 vUv;

float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
float noise(vec2 p) {
  vec2 i = floor(p), f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
             mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), u.y);
}
float fbm(vec2 p) {
  float v = 0.0, a = 0.5;
  mat2 rot = mat2(0.8, 0.6, -0.6, 0.8);
  for (int i = 0; i < 5; i++) { v += a * noise(p); p = rot * p * 2.0; a *= 0.5; }
  return v;
}
float sdRoundBox(vec2 p, vec2 b, float r) {
  vec2 q = abs(p) - b + r;
  return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
}

void main() {
  float k = 1.0 + uPad;
  vec2 uvImg = (vUv - 0.5) * k + 0.5;
  vec2 p  = (uvImg * 2.0 - 1.0) * vec2(uAspect, 1.0);
  vec2 nc =  uvImg * vec2(uAspect, 1.0);

  float n1 = fbm(nc * 4.0 * uNoiseScale + vec2(uTime * 0.18, -uTime * 0.12));
  float n2 = fbm(nc * 7.0 * uNoiseScale - vec2(uTime * 0.10,  uTime * 0.22));

  float d   = sdRoundBox(p, vec2(uSize * uAspect, uSize), uRadius);
  float wob = (n1 - 0.5) * uWobble;

  float imgMask = 1.0 - smoothstep(uInnerFade, uOuterFade, d + wob);
  float fog = smoothstep(-0.30, 0.08, d + wob)
            * (1.0 - smoothstep(0.05, uOuterEdge, d + (n2 - 0.5) * 0.30));
  fog = clamp(fog * (0.6 + 0.4 * n2) * uFogDensity, 0.0, 1.0);

  vec2 pe = abs(vUv * 2.0 - 1.0);
  float borderFade = smoothstep(0.0, 0.15, 1.0 - max(pe.x, pe.y));

  float fogMix = clamp(fog + (1.0 - imgMask), 0.0, 1.0);
  vec3 color = mix(texture2D(uTexture, uvImg).rgb, uFogColor, fogMix);
  float alpha = clamp(max(imgMask, fog), 0.0, 1.0) * borderFade;

  if (alpha < 0.02) discard;
  gl_FragColor = vec4(color, alpha);
}

Drop the code into any Three.js ShaderMaterial with the uniforms from the table above.

Download the full code

FilePurpose
vfx-vignette-portal.jsReady-to-run demo scene: shader, Three.js scene, lil-gui panel, parallax
vfx-vignette-portal.cssControl panel styling (dark theme, control grid)

Variants and ideas

  • Solid color background – set bgColor in the GUI. White background + black fog is the default; try #0a0a1a for a dark-void look.
  • Still image – set speed to 0. The noise stays frozen; the wobble is baked from the initial time seed.
  • Circular portal – replace sdRoundBox with length(p) - uSize and drop uRadius.
  • Multiple portals – render several planes at different depths, each with their own uniforms and time offsets.
Webgl Three.js Glsl Shader Vignette