Monocurl
Download

lesson 05

Parallel Animations

So far, each play statement runs one animation to completion before the next one starts. This lesson covers how to run animations concurrently, how to build reusable animation helpers, and when to pass explicit references.

Playing In Parallel

Passing a list to play runs all items simultaneously. The scene waits until every branch finishes.

video04-parallel.mcl
let soft = |col| lerp(WHITE, col, 0.22)

mesh circle = center{1.4l} fill{soft(BLUE)} stroke{BLUE, 2} Circle(0.42)
mesh label = center{0.9d} color{BLUE} Text("parallel", 0.55)

slide "Parallel"
    play [
        Write(1.2, [&label]),
        Grow(1.0, [&circle])
    ]
    play Wait(0.5)

    circle = center{1.4r} fill{soft(ORANGE)} stroke{ORANGE, 2} Circle(0.55)
    label = center{0.9d} color{ORANGE} Text("done", 0.55)
    play [
        Trans(1.2, [&circle]),
        Write(1.0, [&label])
    ]

Each branch should own its own mesh. Having multiple concurrent animations operate on the same mesh will cause a runtime error.

Animation Blocks

anim {} creates an animation block — a deferred sequence of code and play statements. The block does nothing when defined; it runs only when passed to play.

mesh ball = Square(1)

slide "Animation Block"
  let DoSomething = anim {
      ball = shift{2r} ball
      play Lerp(1.2, [&ball])
      play Wait(0.4)
      ball = shift{2l} ball
      play Lerp(1.2, [&ball])
  }
  
  # Nothing has happened yet. Now play it:
  play DoSomething

Animation blocks behave like coroutines (also called promises in other languages): when played, they execute step by step, yielding at each play statement. This is why print inside an anim {} block only appears in the transcript after the playhead passes that line.

Progressors

A progressor is an idiom where a lambda accepts a leader by references and mutates it in an animation block. The & syntax passes a reference rather than a copy.

video04-parallel.mcl
let soft = |col| lerp(WHITE, col, 0.22)

let MoveBy = |&m, delta| anim {
    m = shift{delta} m
    play Lerp(1.2, [&m])
}

mesh ball = center{1.5l} fill{soft(BLUE)} stroke{BLUE, 2} Circle(0.42)
mesh pulse = []

slide "Progressors"
    play Set()
    play Wait(0.5)

    let pulse_ring = anim {
        pulse = fill{CLEAR} stroke{CYAN, 3} Circle(0.35)
        play Grow(1.3)
        pulse = fill{CLEAR} stroke{CLEAR, 3} Circle(0.9)
        play Trans(1.5)
    }

    play [
        MoveBy(&ball, 3r),
        pulse_ring
    ]

MoveBy takes &ball (a reference to the leader) and delta. Inside the block, m = shift{delta} m mutates the leader through the reference. When MoveBy is played as part of the parallel list, it executes concurrently with pulse_ring.

Progressors allow you to modify scene state in parallel with animations.

Explicit References

The animation engine usually infers which leaders are dirty and should be animated. But it is aggressive and animates all dirty leaders together. In two cases you should be explicit:

1. You changed a leader but don't want it in this animation. Pass [&specific_leader] to limit the animation to just that leader.

2. Parallel branches both modify related geometry. Being explicit about which branch owns which leader prevents accidental interference.

# Without explicit refs, Lerp would animate both ball and label together
ball.pos = 1.4r
label = next_to{ball, 1d, 0.2} Text("moved", 0.55)

play [
    Lerp(1.2, [&ball]),    # ball moves smoothly
    Set([&label])           # label snaps to new position instantly
]

Delay

The delay{} modifier offsets when an animation starts within a parallel block.

video04-parallel.mcl
let soft = |col| lerp(WHITE, col, 0.22)

slide "Stagger"
    mesh a = center{1.4l} fill{soft(BLUE)} stroke{BLUE, 2} Circle(0.38)
    mesh b = center{0r} fill{soft(ORANGE)} stroke{ORANGE, 2} Circle(0.38)
    mesh c = center{1.4r} fill{soft(GREEN)} stroke{GREEN, 2} Circle(0.38)
  
    play [
        Fade(0.8, [&a]),
        delay{0.2} Fade(0.8, [&b]),
        delay{0.4} Fade(0.8, [&c])
    ]
    play Wait(0.6)

See if you can think of how delay might be implemented.

Walkthrough: 3D Camera Animation

To see these patterns working together, consider the 3D Camera Animation example that ships with Monocurl. Here is how you would reason through building it.

The goal is: show a flat colored grid, then simultaneously lift it into a surface and orbit the camera around it.

Step 1 — Build the initial state in init.

The grid starts flat, the camera starts at the default position looking at the origin. Define a color function and set up the meshes.

let samples = 24
let height = |x, y| 1.15 * ((x - 0.5)^2 + (y - 0.5)^2)
let color_keys = [0 -> BLUE, 0.15 -> YELLOW, 0.3 -> ORANGE, 0.55 -> RED]

let color_at = |pos, idx| keyframe_lerp(color_keys, height(pos[0], -pos[1]))

Step 2 — First slide: reveal the initial scene.

slide "Flat Grid"
    mesh grid = stroke{BLACK, 1.5} ColorGrid(
        |pos, idx| BLACK,
        [0, 1, samples],
        [-1, 0, samples]
    )
    mesh axis = Axis3d(basis: [1r, 1d, 1b], color: BLACK, [1u, 1u, 1b])

    play [Fade(0.8, [&axis, &grid]), Write(0.8, [&title])]

Step 3 — Second slide: lift the grid and move the camera in parallel.

Each action is an anim {} block with its own internal steps. They run together in one play [...].

slide "Surface And Camera"
    let lift_grid = anim {
        # First, recolor the flat grid
        grid = stroke{BLACK, 1.5} ColorGrid(color_at, [0, 1, samples], [-1, 0, samples])
        play Trans(0.8, [&grid])

        # Then, lift vertices to match the height function
        grid = point_map{|p| [p[0], p[1], height(p[0], p[1] + 1)]} grid
        play Trans(1.8, [&grid])
    }

    let move_camera = anim {
        camera = Camera([2.2, -2.1, 1.45], [0.5, -0.5, 0.35], [0, 0, 1])
        play CameraLerp(&camera, 2.6)
    }

    play [lift_grid, move_camera]

The key insight: lift_grid and move_camera are completely independent. lift_grid owns grid; move_camera owns camera. Because they do not share leaders, they can run in parallel without interference.

CameraLerp is a specialized animation for camera movement that produces more visually pleasing arcs than plain Lerp for camera positions.

video04-parallel.mcl
let samples = 24
let height = |x, y| 1.15 * ((x - 0.5) ^ 2 + (y - 0.5) ^ 2)
let color_keys = [0 -> BLUE, 0.15 -> YELLOW, 0.3 -> ORANGE, 0.55 -> RED]

let color_at = |pos, idx| {
    let value = height(pos[0], -pos[1])
    # keyframe_lerp turns a scalar field into a smooth multi-stop surface gradient.
    return keyframe_lerp(color_keys, value)
}

slide "Flat Grid"
    mesh grid = stroke{BLACK, 1.5} ColorGrid(
        |pos, idx| BLACK,
        [0, 1, samples],
        [-1, 0, samples]
    )
    mesh axis =
        shift{[0, 0, -0.01]} # draw below function
        axis_style{"x", 0, 1, "x"}
        axis_style{"y", 0, 1, "y"}
        axis_style{"z", 0, 1, "z"}
        Axis3d(
            basis: [1r, 1d, 1b],
            color: BLACK,
            grid_color: LIGHT_GRAY,
            [1u, 1u, 1b]
        )
    mesh monocurl = center{0.7u} Text("Monocurl", 2)

    play [Fade(0.8, [&axis, &grid]), Write(0.8, &monocurl)]

slide "Surface And Camera"
    # an anim block is analogous to a coroutine in other languages
    # it does nothing until it is played
    # (you can try experiment with the playhead to see when the
    # "Got here" is actually printed)
    let lift_grid = anim {
        grid = stroke{BLACK, 1.5} ColorGrid(
            color_at,
            [0, 1, samples],
            [-1, 0, samples]
        )
        play Trans(0.8, [&grid])

        print "Got here"

        grid =
            point_map{|point| [point[0], point[1], height(point[0], point[1] + 1)]}
            grid
        play Trans(1.8, [&grid])
    }

    # camera lerp is a specialize anim for interpolating 
    # camera positions, lerp "works" as well 
    # but is visually less pelaseing
    let move_camera = anim {
        camera = Camera([2.2, -2.1, 1.45], [0.5, -0.5, 0.35], [0, 0, 1])
        play CameraLerp(&camera, 2.6)
    }


    play [lift_grid, move_camera]
    play Wait(0.4)