Skip to main content

COPs Black Hole Distortion

· 3 min read

A COPs OpenCL kernel that creates gravitational lensing-style distortion by pulling pixels toward a mask boundary. It uses an SDF (signed distance field) and its gradient to generate UV offsets.

How It Works

The effect calculates UV offsets by:

  1. Reading the SDF value (distance from the black hole boundary)
  2. Following the gradient direction (slope) toward the center
  3. Computing offset strength based on distance with controllable falloff
  4. Optionally using multiple substeps for smoother results

Important: When calculating the slope/gradient, a Gaussian kernel produces better results than the default slope node, which only samples immediate X and Y neighbors. The Gaussian kernel provides smoother, more accurate gradients.

Parameters

ParameterDefaultDescription
strength12.0Overall pull strength in pixels
radius80.0Influence radius (should match SDF scale)
falloff1.5Falloff curve shape (0-3 typical range)
epsilon1.0Stabilizer to prevent singularity near SDF=0
maxstep8.0Maximum offset per substep (pixels)
steps1Number of substeps (3-5 for smoother distortion)

Implementation

// Blackhole UV Offset (float2)
// Inputs you already have:
#bind layer sdf float // signed distance (>0 outside, <=0 inside)
#bind layer slope float2 // your precomputed gradient (gx, gy)

// Output (single vec2)
#bind layer &uv_off float2 // UV offset in pixels (write-only here)

// Params
#bind parm strength float val=12.0 // overall pull (px)
#bind parm radius float val=80.0 // influence scale (match SDF units)
#bind parm falloff float val=1.5 // shape of falloff (0..3 typical)
#bind parm epsilon float val=1.0 // stabilizer near sdf≈0
#bind parm maxstep float val=8.0 // per-step clamp (px)
#bind parm steps int val=1 // 1=one-shot, 3-5 = smoother

inline float2 safe_norm2(float2 v)
{
float m2 = v.x*v.x + v.y*v.y;
float inv = rsqrt(fmax(m2, 1e-12f));
return (float2)(v.x*inv, v.y*inv);
}

@KERNEL
{
float d0 = @sdf;
if (d0 <= 0.0) { @uv_off.set((float2)(0.0f, 0.0f)); return; }

float2 pos = (float2)(@ix, @iy); // walk in pixel space
float2 total = (float2)(0.0f, 0.0f);
int N = max(1, @steps);

for (int k=0; k<N; ++k)
{
int2 pi = convert_int2_rtn(pos); // nearest pixel; bilerp if you prefer

float Sd = @sdf.bufferIndex(pi);
if (Sd <= 0.0f) break;

float2 g = @slope.bufferIndex(pi);
float2 dir = -safe_norm2(g); // pull toward the hole

// tempered 1/(d+eps) with smooth radius cutoff
float t = clamp(1.0f - Sd / @radius, 0.0f, 1.0f);
float step_len = (@strength / (float)N) * powr(t, @falloff) / (Sd + @epsilon);
step_len = fmin(step_len, @maxstep);

float2 delta = dir * step_len;
total += delta;
pos += delta; // re-evaluate field locally next substep
}

@uv_off.set(total); // pixel-space offset; add to your UV outside
}

Usage Notes

  • SDF Input: Positive values outside the mask, zero/negative inside
  • Slope Calculation: Use a Gaussian kernel blur before computing gradients for smoother results
  • Substeps: Increase steps to 3-5 for smoother, more accurate distortion paths
  • Stability: Adjust epsilon if you see artifacts near the boundary