Composable Sketches

I have long attempted to build up a reusable and composable system for creating animations in p5.js. Most of them worked great up until a certain point where the complexity of the animations grew beyond the capabilities of the system.

And it would appear I have not learned my lesson, so I am doing it again.

The idea

My previous attempts have been mainly inspired by video game engines and their entity-component systems. This time, I wanna to take a more creative/flexible approach to the problem.

Here's what I have in mind:

const animation = compose(/* state */, /* effects */)

animation() // draws the animation

If I'm being honest, I also think this functional approach looks cool. Has a visual simplicity that I love. Once again probably the wrong reason to do something, but nobody here to stop me.

-> Jump to implementation

In action

Let's try out that concept with a few examples

Build out some effects

To begin, let's define a few simple effects that we can compose together.

// Draws a rectangle
const shape = (size = 50) => {
  return (ctx, next) => {
    ctx.p5.rect(0, 0, size, size)
    next(ctx)
  }
}

// Centers the scene
const center = () => (ctx, next) => {
  ctx.p5.translate(ctx.p5.width / 2, ctx.p5.height / 2)
  next(ctx)
}

// Rotates the scene
const rotate = () => {
  let angle = 0

  return (ctx, next) => {
    ctx.p5.rotate((angle += 0.02))
    next(ctx)
  }
}

Composing effects

Drawing only the shape:

compose({ size: 50 }, [shape()])

In this instance we notice the square is a bit cropped, so we can add the center effect to fix that:

compose({}, [center(), shape()])

Finally, let's add some rotation to the mix:

compose({}, [center(), rotate(), shape()])

Implementation

Building out a composition system isn't exactly rocket science, but there are some quirks to it that I am discovering as we speak. I'll try to boil it down to the essentials:

Performance

Functional-style patterns aren't known for their performance, but I am not too worried about that. However I did want to avoid the overhead of creating a new function for each step in the pipeline.

Code

As of writing, I went or a simple enough approach which looks like this:

// Defines the context object passed to effects
// Contains both the p5 instance and current state
export class Context<T> {
  public p5: P5
  public state: T
}

// Represents the next step in a pipeline (called from effects)
export type NextFn<T> = (ctx: Context<T>) => any

// Effect function type that represent a visual effect to apply
// - Receives current context and next function in pipeline
// - Responsible for calling next() to continue the chain
export type Fx<T> = (ctx: Context<T>, next: NextFn<T>) => void

export function compose<T>(data: T, ...effects: (Fx<T> | Fx<T>[])[]): DrawFn {
  const steps = effects.flat()

  let state = { ...data }
  let idx = 0

  const pipeline = (ctx: Context<T>) => {
    if (!steps[idx]) return
    state = ctx.state
    steps[idx++](ctx, pipeline)
  }

  return (p5) => {
    idx = 0
    pipeline({ state, p5 })
  }
}