Pixel bending with butter and crook

Going pixel deep is one of the most fascinating aspects of drawing on canvas. It’s also the kind of job that lends itself nicely to using typed arrays and therefore offloading data processing tasks onto a web worker. A whole range of Photoshop filters may be recreated in JavaScript this way.

assets/thief.png

Original Jewel thief Frank Hohimer (James Caan) hard at work, still from Michael Mann's slick neo-noir Thief (1981)

And while chroma keying, blur, or thresholding are well documented and somewhat less than challenging to put together, here’s the core particulars for doing displacement mapping and pixel sorting, a couple of effects I found myself eager to port, because existing solutions looked either incomplete, or bloated and out of date.

Butter for pixel sorting

In the world of data moshing, pixel sorting is king. Looking for pointers on Github, I hit upon this very literal, almost word for word take on Kim Asendorf’s often cited Processing script, a little woolly for my taste, good enough to start with nevertheless.

Some of the features I sought to improve in my fork: Too many options, too much repetition, significantly though accepting ImageData for input/output. But in the end, all I kept was the name. The main idea is to sort() each row or column of pixels in an image if they happen to fall above or below a given threshold color, along the lines of,

// Adapted from
// https://github.com/thewhodidthis/butter

// Note, cutoff in hex includes alpha channel,
// assuming RGBA, but may be inverted for BE systems
const threshold = 'ffffffff'

// In the context of an `Uint32Array` decked input, the two
// extremes would be: BLACK = 0, WHITE = 4294967295
const limit = parseInt(threshold.match(/.{1,2}/g).reverse().join(''), 16)

// Rotate a quarter of a circle before and
// after processing for sorting in the vertical dimension
function applySorting(pixel, i, line) {
  // Sort approved
  const bang = phase * pixel > limit

  // For tracking matches
  this.mark = 0

  // Match found, take note
  if (!this.mark && bang) {
    this.mark = i
  }

  // Time's up
  if (!bang && this.mark) {
    // Assuming typed array input
    line.subarray(this.mark, i).sort()

    // Reset
    this.mark = 0
  }
}

Crook for pixel shifting

Displacement mapping involves going through the color values in one image (color map) to rearrange the contents of another. More specifically, for each pixel in the lookup image the amount of shift produced is proportional to its distance from a baseline gray across separate RGB channels. In essence,

// Determine color distance above and below mid-gray, snippet from
// https://github.com/thewhodidthis/crook
function calculateColorShift(color, scale) {
  const ratio = scale * ((color - 128) / 256)

  return Math.sign(ratio) * Math.round(Math.abs(ratio))
}

Furthermore, what happens when displacement values overstep the image boundaries comes into play, with options for allowing transparent gaps, clamping, or wrapping round the edges,

// Clamping, displacement value only goes up to a certain max
const snap = (v, max) => (v >= max ? max - 1 : Math.max(v, 0))

// Wrapping, displacement value inverted past the edges
const wrap = (v, max) => (v >= max || v < 0 ? v + (max * Math.sign(v)) : v)

// Allows for using source pixel when shift out of bounds
const skip = (v, max) => (v >= max || v < 0 ? NaN : v)

Composed

Product Adding intensity and texture with butter and crook combined

Because both filters accept and return ImageData, having them act in sequence can be as simple as,

import butter from '@thewhodidthis/butter'
import crook from '@thewhodidthis/crook'

const canvas = document.createElement('canvas')
const target = canvas.getContext('2d')

const buffer = canvas.cloneNode().getContext('2d')
const shadow = canvas.cloneNode().getContext('2d')

const { width: w, height: h } = canvas

// Fill lookup with noise
for (let i = 0; i < w * h; i += 1) {
  const x = i % w
  const y = Math.floor(i / w)
  const c = Math.floor(Math.random() * 255)

  shadow.fillStyle = `rgba(${c}, ${c}, ${c}, ${c})`
  shadow.fillRect(x, y, 1, 1)
}

const sort = butter({
  // Target pixels exceeding dark gray
  threshold: '303030ff'
})

const warp = crook({
  // Use reds for horizontal, blues for vertical displacement
  channel: { x: 0, y: 2 },
  // 0: Transparency, 1: Clamping, 2: Wrapping
  mode: 1,
  // Shift five pixels in each direction
  scale: { x: 5, y: 5 }
})

const master = document.createElement('img')

master.addEventListener('load', () => {
  buffer.drawImage(master, 0, 0)

  const source = buffer.getImageData(0, 0, w, h)
  const lookup = shadow.getImageData(0, 0, w, h)
  
  // Process in place
  target.putImageData(sort(warp(source, lookup)), 0, 0)
})

master.setAttribute('src', 'path/to/image.png')

Reference