slama.dev

Motion Canvas Icon From Manim to Motion Canvas

So… I haven’t released a video in a while. By a while, I mean over a year at this point.

The main reason for this is my master thesis, which takes essentially all of my free time. The secondary reason is that I have severe MBS (Manim Burnout Syndrome) and therefore find it difficult to work on anything Manim-related.

The remedies to this are twofold: first off, I will be working with/joining the guys over at Polylog to produce the videos much faster than working alone, and will be learning (as the title of this post suggests) Motion Canvas.

Since I have spent a significant amount of time in Manim (even writing a series of tutorials about how to use it), I think it’s valuable to document the process of learning Motion Canvas as a former Manim user.

If you are a Manim user, this series of articles is for you – it is a re-creation of my Manim tutorial series, written from a perspective of a long-time Manim user and focused on the differences between the two.

TODO

Setting up

Follow the quickstart page to setup your development environment.

Personally, I am using the WebStorm IDE on one monitor, with the Motion Canvas editor open on the other, allowing for instant preview of the animations that I’m currently working on (you should be writing this down, Manim; live animations!).

First animations

After following the quickstart and generating the project boilerplate, we are ready to start writing animations. I won’t be explaining it since the explanation section of quickstart does a great job at doing so – let’s write some animations!

Whenever you see something like this, the Manim code will produce an animation that is not identical, but looks/feels/is-in-the-spirit of the Motion Canvas version. The goal is to learn how Motion Canvas does it, not re-create Manim.

import {Circle, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence} from '@motion-canvas/core';


export default makeScene2D(function* (view) {
    // create square and circle objects
    const rect = createRef<Rect>();
    view.add(<Rect ref={rect} size={320} stroke={'red'} lineWidth={10} x={-300}/>);

    const circle = createRef<Circle>();
    view.add(<Circle ref={circle} size={320} stroke={'blue'} lineWidth={10} x={300}/>);

    // the first call is instant, while the second produces an animation
    yield* all(
        rect().scale(0).scale(1, 1),
        rect().opacity(0).opacity(1, 1),
        circle().scale(0).scale(1, 1),
        circle().opacity(0).opacity(1, 1),
    );

    // fading them from the scene
    // sequence() plays the animations with a delay
    yield* sequence(
        0.25,
        rect().opacity(0, 1),
        circle().opacity(0, 1),
    );
});

Instead of going through what each of the line of code does (again, the explanation section does a great job at doing this), I’ll make a few observations on how Motion Canvas differs from Manim.

1) we are animating properties, not objects – for Manim, what we’re conceptually doing is transforming an object from one state to another and things magically happen; for Motion Canvas, we are animating individual properties.

2) things are always relative to the parent – when using Manim, properties of things like translation, rotation are absolute; for Motion Canvas, things are always relative to the object hierarchy. This will make more sense in later examples.

No animate syntax!

To underline point 1), here is a more complex example of changing properties. Note that I’ve factored out the animation of an object appearance into the appear function, which I’ll be using throughout the rest of the examples.

import {Circle, makeScene2D, Rect, Shape} from '@motion-canvas/2d';
import {all, Color, createRef, ThreadGenerator} from '@motion-canvas/core';


/**
 * Animate appearance of an object.
 */
export function* appear(object: Shape, duration = 1): ThreadGenerator {
    let scale = object.scale();

    yield* all(
        object.scale(0).scale(scale, duration),
        object.opacity(0).opacity(1, duration),
    );
}

export default makeScene2D(function* (view) {
    const rect = createRef<Rect>();
    view.add(<Rect ref={rect} size={320} stroke={'red'} lineWidth={10} x={-300}/>);

    const circle = createRef<Circle>();
    view.add(<Circle ref={circle} size={320} stroke={'blue'} lineWidth={10} x={300}/>);

    yield* all(
        appear(rect()),
        appear(circle()),
    );

    // moving objects
    yield* all(
        rect().position.y(rect().position.y() - rect().height() / 4, 1),
        circle().position.y(circle().position.y() + circle().height() / 4, 1),
    );

    // rotating and filling the square (opacity 80%)
    // scaling and filling the circle (opacity 80%)
    yield* all(
        rect().rotation(-90, 1),
        rect().fill('rgba(255, 0, 0, 0.8)', 1),

        circle().scale(2, 1),
        circle().fill('rgba(0, 0, 255, 0.8)', 1),
    );

    // change color
    yield* all(
        rect().fill('rgba(0, 255, 0, 0.8)', 1),
        rect().stroke('rgb(0, 255, 0)', 1),

        circle().fill('rgba(255, 165, 0, 0.8)', 1),
        circle().stroke('rgb(255, 165, 0)', 1),
    );

    yield* all(
        rect().opacity(0, 1),
        circle().opacity(0, 1),
    );
});

As you can see, each property is animated individually, as opposed to Manim’s animate syntax, which performs all of the operations at the ‘same time.’

Although this seems like a minor difference at first, creating animations like this one in Manim would be essentially impossible without some crazy updater shenanigans:

import {makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, delay} from '@motion-canvas/core';
import {appear} from "../../utilities";


export default makeScene2D(function* (view) {
    const rect = createRef<Rect>();
    view.add(<Rect ref={rect} size={320} fill={'red'} stroke={'red'} lineWidth={10} x={-300}/>);

    yield* appear(rect());

    let n = 5;

    yield* all(
        // change position
        rect().x(300, n),

        // at the same time as scale
        delay(
            n / 4,
            // we can chain two scale changes by using .to()
            rect().scale(1.5, n / 4).to(1, n / 4),
        ),

        // at the same time as rotation
        delay(
            n / 8 * 3,
            rect().rotation(-90, n / 4).to(90, n / 4),
        ),
    );
});

Aligning objects

Aligning things in Motion Canvas is done with layouts, which are a powerful flexbox-based approach to object positioning. Nevertheless, I recreated Manim’s next_to, move_to and align_to method examples, since they were the bread and butter of my Manim workflow.

The main things used are the left, right, top, bottom and middle properties (referred to as the cardinal directions) that can be used to refer to parts of an object.

next_to

import {Circle, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const rectangle = createRef<Rect>();
    view.add(<Rect ref={rectangle} size={[600, 300]} stroke={'white'} lineWidth={5}/>);

    // array construct to create multiple objects
    const circles = Array.from({length: 4}, () => createRef<Circle>());

    // forEach syntax to add them all to the scene
    circles.forEach((ref) => {
        view.add(<Circle ref={ref} size={150} stroke={'white'} lineWidth={5}/>);
    });

    // map syntax for showing all elements
    yield* all(
        appear(rectangle()),
        ...circles.map(ref => appear(ref()))
    );

    // move the circles such that they surround the rectangle
    // use sequence instead of all for a delayed set of animations (it pretty :)
    yield* sequence(
        0.15,  // delay between animations
        circles[0]().right(rectangle().left().addX(-50), 1),
        circles[1]().bottom(rectangle().top().addY(-50), 1),
        circles[2]().left(rectangle().right().addX(50), 1),
        circles[3]().top(rectangle().bottom().addY(50), 1),
    );
});

move_to

import {Latex, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence} from '@motion-canvas/core';
import {appear} from "../../utilities";


export default makeScene2D(function* (view) {
    const rectangles = Array.from({length: 3}, () => createRef<Rect>());
    const numbers = Array.from({length: 3}, () => createRef<Latex>());

    rectangles.forEach((ref) => {
        view.add(<Rect ref={ref} size={300} stroke={'white'} lineWidth={5}/>);
    });

    yield* all(...rectangles.map(ref => appear(ref())));

    // align squares next to one another
    yield* all(
        rectangles[0]().right(rectangles[1]().left().addX(-50), 1),
        rectangles[2]().left(rectangles[1]().right().addX(50), 1),
    );

    // forEach can also take a second argument, which is the index!
    // set opacity to 0 to initially hide them
    numbers.forEach(
        (ref, i) => {
            view.add(<Latex tex={`${i}`} ref={ref} fill={'white'} scale={4} opacity={0}/>);
        }
    );

    // move the numbers to each of the squares
    numbers.forEach((ref, i) => {
        ref().position(rectangles[i]().position())
    })

    yield* sequence(
        0.15,
        ...numbers.map(ref => appear(ref()))
    );
});

align_to

import {Circle, makeScene2D} from '@motion-canvas/2d';
import {all, createRef, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const circles = Array.from({length: 3}, () => createRef<Circle>());

    circles.forEach((ref, i) => {
        view.add(<Circle ref={ref} size={(1 + i) * 100} stroke={'white'} lineWidth={5}/>);
    });

    yield* all(...circles.map(ref => appear(ref())));

    // align squares next to one another
    yield* all(
        circles[0]().left(circles[1]().right().addX(25), 1),
        circles[2]().right(circles[1]().left().addX(-25), 1),
    );

    // align c1 and c2 such that their bottoms are the same as c2
    yield* all(
        circles[0]().bottom(new Vector2(circles[0]().position.x(), circles[1]().bottom().y), 1),
        circles[2]().bottom(new Vector2(circles[2]().position.x(), circles[1]().bottom().y), 1),
    );

    // align all circles such that their top touches a line going through the point
    yield* all(...circles.map(ref => ref().top(new Vector2(ref().top().x, -300), 1)));
});

Not pretty…

This is partly because these things are not wrapped in utility functions (unlike Manim), so you have to write them manually. Writing these functions is not difficult, but it’s also usually not something you should be using – we have flexbox!

Flexbox!

import {Circle, Layout, Rect, makeScene2D} from '@motion-canvas/2d';
import {all, createRef} from '@motion-canvas/core';

export default makeScene2D(function* (view) {
    // yoinked directly from the documentation, not my code!

    const col1 = createRef<Layout>();
    const col3 = createRef<Layout>();
    const redBox = createRef<Layout>();

    view.add(
        <Layout layout gap={10} padding={30} width={1000} height={600}>
            <Rect ref={col1} grow={1} fill={'#242424'} radius={20}/>
            <Layout gap={10} direction="column" grow={3}>
                <Rect
                    ref={redBox}
                    grow={8}
                    fill={'red'}
                    radius={50}
                    stroke={'#fff'}
                    lineWidth={10}
                    margin={5}
                    justifyContent={'center'}
                    alignItems={'center'}
                >
                    <Circle width={20} height={20} fill={'#fff'}/>
                </Rect>
                <Rect grow={2} fill={'#242424'} radius={20}/>
            </Layout>
            <Rect ref={col3} grow={3} fill={'#242424'} radius={20}/>
        </Layout>
    );

    yield* all(col3().grow(1, 0.8), col1().grow(2, 0.8));
    yield* redBox().grow(1, 0.8);
    yield* all(col3().grow(3, 0.8), col1().grow(1, 0.8));
    yield* redBox().grow(8, 0.8);
});

The example above introduces two important concepts – the scene hierarchy, which controls the way the scene is structured, and the layout nodes, which facilitate the flexbox arrangement; feel free to read through these documentation pages to understand more about them.

Equipped with layouts, we can rewrite one of the examples above.

While doing so, we run into important concept 2) mentioned above: positioning – unlike Manim, an object’s position (and some other attributes) is always relative to its parent. This means that when we add objects to a layout, they instantly snap to their positions, and removing them snaps them back to origin (unless we changed their position in the meantime).

To create our animation, we can rely on the save and restore functions, which can remember the absolute positions in the layout and then return to them once we disable the layout, in order to nicely animate the objects getting to their positions:

import {Latex, Layout, makeScene2D, Rect} from '@motion-canvas/2d';
import {all, createRef, sequence, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const rectangles = Array.from({length: 3}, () => createRef<Rect>());
    const numbers = Array.from({length: 3}, () => createRef<Latex>());

    // create the layout to place the things into
    const layout = createRef<Layout>();
    view.add(<Layout layout ref={layout} gap={50}>
        {rectangles.map((ref, i) =>
            <Rect ref={ref} grow={1} size={300} stroke={'white'} lineWidth={5}
                  justifyContent={'center'} alignItems={'center'}
            >
                <Latex tex={`${i}`}
                       ref={numbers[i]} fill={'white'}
                       scale={4} opacity={0}/>
            </Rect>
        )}
    </Layout>)

    // save this state of nodes (they should end up like this)
    rectangles.forEach(ref => ref().save())

    // disable the layout, moving the rects back to origin
    // in reality, their position has always been (0, 0),
    // but the layout previously dictated their position
    layout().layout(false);

    // scatter them around the screen to make it look cooler
    rectangles[0]().scale(0.5)
    rectangles[0]().position(new Vector2(100, 200))
    rectangles[0]().rotation(30)
    rectangles[0]().opacity(0)

    rectangles[1]().scale(0.7)
    rectangles[1]().position(new Vector2(-50, -100))
    rectangles[1]().rotation(230)
    rectangles[1]().opacity(0)

    rectangles[2]().scale(0.4)
    rectangles[2]().position(new Vector2(-200, 150))
    rectangles[2]().rotation(-150)
    rectangles[2]().opacity(0)

    yield* sequence(0.15, ...rectangles.map(ref => appear(ref())));

    // restoring them (in an animated way) returns them to the layout,
    // since we're restoring the absolute values of its attributes
    yield* all(...rectangles.map(ref => ref().restore(1)));

    // now we can re-enable the layout, since nothing will change
    layout().layout(true);

    yield* sequence(0.15, ...numbers.map(ref => appear(ref())));

    // since they're in a flexbox, we can do cool flexbox stuff!
    yield* all(
        layout().width(1200, 1),
        ...rectangles.map((ref, i) => ref().height(100 * (i + 3), 1)),
    )
});

Typesetting text and math

For typesetting LaTeX\LaTeX and regular text, we can use the <Txt> and <Latex> nodes. We can also rejoice, since they support diffing between different contents, which was one of my largest Manim painpoints!

import {Latex, makeScene2D, Txt} from '@motion-canvas/2d';
import {all, createRef} from '@motion-canvas/core';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const text = createRef<Txt>();
    const math = createRef<Latex>();

    view.add(<>
        // text is empty for now since we're animating writing it
        <Txt ref={text} fill={"white"} x={-300}></Txt>

        // we're separating things to be diffed with {{...}}
        <Latex ref={math} fill={"white"} x={300} tex={"{{\\sum_{i = 0}}}{{^\\infty}} {{\\frac{1}{2^i}}} = {{2}}"}></Latex>
    </>)

    yield* all(
        text().text("Hello Motion Canvas!", 1),
        appear(math(), 1),
    );

    // can be diffed!
    yield* all(
        text().text("Hello everyone!", 1),
        math().tex("{{\\sum_{i = 0}}}{{^{42}}} {{\\frac{1}{2^i}}} = {{13}}", 1),
    );
});

Tasks

Sort

Since TypeScript doesn’t provide a way to seed its random number generator, you can use Motion Canvas’ useRandom to create a pseudo-random generator that will provide deterministic numbers each time an animation is played (see the randomness documentation page for more).

Author's Solution
import {Layout, makeScene2D, Rect, Shape} from '@motion-canvas/2d';
import {all, createRef, Reference, sequence, ThreadGenerator, useRandom} from '@motion-canvas/core';


/**
 * Set colors for the rectangles.
 * @param i First highlighted rectangle.
 * @param j Second highlighted rectangle.
 * @param sortedStart Where the sorted array starts.
 */
function* setColors(rectangles: Array<Reference<Shape>>, i: number, j: number, duration: number, sortedStart: number): ThreadGenerator {
    yield* all(
        ...rectangles.map((ref, idx) => {
            if (idx >= sortedStart) {
                return ref().fill('rgb(89,255,79)', duration)
            } else if (idx == i || idx == j) {
                return ref().fill('yellow', duration)
            } else {
                return ref().fill('white', duration)
            }
        })
    )
}

/**
 * Swap the two rectangles.
 * @param i First rectangle to swap.
 * @param j Second rectangle to swap.
 */
function* swap(rectangles: Array<Reference<Shape>>, i: number, j: number, duration: number): ThreadGenerator {
    yield* all(
        rectangles[i]().height(rectangles[j]().height(), duration),
        rectangles[j]().height(rectangles[i]().height(), duration),
    )
}


export default makeScene2D(function* (view) {
    let random = useRandom();
    let n = 20;

    const rectangles = Array.from({length: n}, () => createRef<Rect>());
    const values = Array.from({length: n}, () => random.nextFloat());

    const layout = createRef<Layout>();
    view.add(<Layout layout ref={layout} gap={20} width={1400} height={800} alignItems={'end'}>
        {rectangles.map(ref =>
            // if the width is not set, fucky things happen... idk man
            // I think this is a flexbox thing that I don't understand yet
            <Rect ref={ref} grow={1} width={(1400 - (n - 1) * 20) / 20} stroke={'white'} fill={'white'}/>
        )}
    </Layout>)

    yield* sequence(
        0.025,
        ...rectangles.map((ref, i) => ref().height(values[i] * layout().height(), 1)),
    );

    let speedSlow = 0.5;
    let speedFast = 0.07;

    // bubble sort go brrrrrrrr
    for (let i = 0; i < n - 1; i++) {
        let swapped = false;

        let speed = i == 0 ? speedSlow : speedFast;

        for (let j = 0; j < n - i - 1; j++) {
            yield* setColors(rectangles, j, j + 1, speed, n - i);

            if (values[j] > values[j + 1]) {
                // Swap elements
                [values[j], values[j + 1]] = [values[j + 1], values[j]];
                yield* swap(rectangles, j, j + 1, speed);
                swapped = true;
            }
        }

        // If no two elements were swapped in the inner loop, then the array is already sorted.
        if (!swapped) break;
    }

    yield* setColors(rectangles, 0, 1, 1, -1);
});

Again, a couple of things:

Author's Solution
import {Curve, Latex, Layout, Line, makeScene2D, Rect, Txt} from '@motion-canvas/2d';
import {all, createRef, createRefMap, delay, Reference, sequence, useRandom, Vector2} from '@motion-canvas/core';
import {appear} from "../../utilities";


/**
 * Small function for getting the position slightly below an object.
 * @param object
 */
function under(object: Curve): Vector2 {
    return object.bottom().addY(20);
}

/**
 * Create an arrow used for the binary search.
 * @param ref Reference to it.
 * @param color Color of the arrow.
 * @param object Under which object to place the arrow.
 */
function getArrow(ref: Reference<Line>, color: string, object: Curve) {
    return <Line
        ref={ref}
        points={[[0, 0], [0, 100]]} stroke={color}
        arrowSize={25} lineWidth={10}
        position={under(object)}
        startArrow
    />
}

/**
 * An animation of an arrow appearing.
 */
function* showArrow(ref: Line) {
    let lineWidth = ref.lineWidth();
    let arrowSize = ref.arrowSize();

    ref.opacity(0);
    ref.end(0);
    ref.lineWidth(0);
    ref.arrowSize(0);

    yield* all(
        ref.lineWidth(lineWidth, 1),
        ref.arrowSize(arrowSize, 1),
        ref.opacity(1, 1),
        ref.end(1, 1),
    )
}


export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbeff);
    let n = 10;

    const rectangles = Array.from({length: n}, () => createRef<Rect>());
    const values = Array.from({length: n}, () => random.nextInt(0, n));

    values.sort((a, b) => a - b);

    const layout = createRef<Layout>();
    view.add(<Layout layout ref={layout} gap={40}>
        {rectangles.map((ref, i) =>
            <Rect ref={ref} grow={1} size={120} stroke={'white'} lineWidth={8} opacity={0}>
                <Latex tex={`${values[i]}`} fill={'white'} layout={false} scale={1.5}/>
            </Rect>
        )}
    </Layout>)

    // showing the rectangles
    yield* sequence(
        0.025,
        ...rectangles.map(ref => appear(ref(), 1)),
    );

    // target number stuff
    const text = createRef<Txt>();
    const target = createRef<Txt>();
    view.add(
        <Layout layout y={-200} scale={2} gap={10}>
            <Txt ref={text} fill={'white'}/>
            <Txt ref={target} fill={'white'} fontWeight={900}/>
        </Layout>
    );

    let targetNumber = values[random.nextInt(0, values.length - 1)];

    // display the target number
    yield* sequence(
        0.25,
        text().text(`Target: `, 1),
        target().text(`${targetNumber}`, 1),
        delay(
            0.5,
            all(
                target().scale(1.5, 0.5).to(1, 0.5),
                rectangles[values.indexOf(targetNumber)]()
                    .fill('rgba(255, 255, 255, 0.25)', 0.5)
                    .to('rgba(255, 255, 255, 0.0)', 0.5),
                rectangles[values.indexOf(targetNumber)]().lineWidth(15, 0.5).to(8, 0.5),
                rectangles[values.indexOf(targetNumber)]().scale(1.1, 0.5).to(1, 0.5),
            )
        )
    )

    // createRefMap for organizing multiple refs in a nicer way
    const searchArrows = createRefMap<Line>();

    // left/right arrows
    view.add(<>
        {getArrow(searchArrows.left, 'white', rectangles[0]())}
        {getArrow(searchArrows.right, 'white', rectangles[rectangles.length - 1]())}
    </>)

    yield* all(...searchArrows.mapRefs(ref => showArrow(ref)))

    let left = 0;
    let right = values.length - 1;

    while (left <= right) {
        const mid = Math.floor((left + right) / 2);

        view.add(getArrow(searchArrows.mid, 'orange', rectangles[mid]()))

        yield* showArrow(searchArrows.mid())

        if (values[mid] === targetNumber) {
            // move arrows to the found element and highlight it
            yield* all(
                searchArrows.left().opacity(0, 1),
                searchArrows.left().position(searchArrows.mid().position, 1),
                searchArrows.right().opacity(0, 1),
                searchArrows.right().position(searchArrows.mid().position, 1),
                searchArrows.mid().stroke('lightgreen', 1),
                rectangles[mid]().stroke('lightgreen', 1),
                rectangles[mid]().fill('rgba(0, 255, 0, 0.2)', 1),
                sequence(0.1, ...rectangles.slice(left - 1, mid).map(ref => ref().opacity(0.25, 1))),
                sequence(0.1, ...rectangles.slice(mid + 1, right + 2).reverse().map(ref => ref().opacity(0.25, 1))),
            );

            break;
        }

        if (values[mid] < targetNumber) {
            // left to middle
            yield* all(
                searchArrows.left().position(under(rectangles[mid + 1]()), 1),
                searchArrows.mid().opacity(0, 1),
                // slice shenanigans so the fading looks smooth
                sequence(0.1, ...rectangles.slice(left, mid + 1).map(ref => ref().opacity(0.25, 1)))
            );

            left = mid + 1;
        } else {
            // right to middle
            yield* all(
                searchArrows.right().position(under(rectangles[mid - 1]()), 1),
                searchArrows.mid().opacity(0, 1),
                // slice shenanigans so the fading looks smooth (reverse so its right-to-left)
                sequence(0.1, ...rectangles.slice(mid, right + 1).reverse().map(ref => ref().opacity(0.25, 1)))
            );

            right = mid - 1;
        }

        searchArrows.mid().remove();
    }
});