Ollin

Slit scan

SlitScan keeps a rolling history of frames. It rebuilds the image so that every pixel comes from a different moment, and a delay you supply picks the moment. A left-to-right delay gives you the classic slit scan, where each column reads a little further into the past. A radial delay makes time ripple outward. A gray map made from any picture turns that picture into a time-displacement lens. Slow motion stretches into ribbons, and fast motion shears into combs, because the delay has turned time into a spatial dimension.

Two panels. The first is the newest frame: a dancer on one leg, both forearms raised and crossed in front of the face. The second is the slit-scanned version of the same two seconds. There one arm has become a curved fan of a dozen hands, sweeping down and out to the right

Contents

Building the history

SlitScan(capacity: Int = 48)       // how deep the past reaches
history.append(frame)              // add the newest frame, once per draw()
history.count                    // frames held so far
history.clear()                  // drop the history

The reach into the past is the depth times the source frame rate, so a depth of 48 frames at 60 fps spans 0.8 seconds. The first push fixes the size of the history. A frame of any other size is skipped, and you get one note about it.

Reading it back

history.image(delay: (Vector2) -> Double) -> Image?
history.image(delay map: Image) -> Image?

The closure form hands you each pixel's normalized position, which runs 0...1 on both axes from a top-left origin. Your closure returns how far into the past that pixel reads. 0 is the newest frame, and 1 is the oldest frame still held.

history.append(frame)
if let warped = history.image(delay: { uv in uv.x }) {         // the classic scan
    drawImage(warped, in: canvasRectangle)
}

// other delays to try:
{ uv in dist(uv.x, uv.y, 0.5, 0.5) / 0.71 }     // time ripples outward
{ uv in 1 - uv.y }                              // the bottom edge is now
{ uv in noise(uv.x * 3, uv.y * 3) }             // turbulent time

The map form reads a gray image instead of calling a closure. Black means now, white means the oldest frame held, and a gray in between reads a frame between the two. Any picture can be the lens that way, such as a gradient, a face, or a snapshot of a generator's output. The map may be any size, because it is sampled across the frame.

Feeding it video or camera

The history takes CPU-pixel images. A painted Image and a Camera frame push straight in. A VideoPlayer frame is texture-backed, so read it through the player's snapshot() first and push the result. Under a headless export the pushes happen on the export clock, so a video-fed slit scan reproduces frame for frame.

Practical notes

  • Memory is width x height x 4 x frames bytes. Push modest sizes rather than full canvases, such as a camera feed or a painting a few hundred pixels across. You can draw the composed image scaled up, like any other image.
  • Each pixel reads its nearest frame, so a shallow history shows visible time steps, which look like combs. More frames make the sweep smoother. The stepping also reads as texture, so many sketches keep it.
  • Slow motion stretches, fast motion shears. A subject that drifts along the delay axis smears into a long ribbon. A subject that moves across that axis ripples instead. If everything only blurs, slow the subject down or deepen the history.
  • Deterministic given the pushed frames, so an export reproduces, and a slit scan fed by a painted source is snapshot-safe.

Related: Video (playback and snapshot()), Vision (the camera feed), Images (the Image type). Also Effects, the feedback layer, which is the GPU version of this idea and holds the previous frame rather than a frame history.

↑↓ move ↩ open