slama.dev

Motion Canvas Icon Camera and Shaders

The following part of the series briefly covers camera controls and then delves into the wonderful world of shaders – something that I had absolutely no experience with before Motion Canvas and am very sad that that’s the case.

The 3rd part of the Manim series that this series aims to follow had a section on graphs (the computer science ones), but since Motion Canvas doesn’t have native support for those, I decided to replace them with shaders instead (which Manim doesn’t have).

Special thanks for my friend Jakub Pelc for helpful pointers to shader resources. If you happen to speak Czech, his series about shaders from KSP is a great read.

Camera

Just like Manim, setting up a camera in Motion Canvas requires setting up the scene in a particular way. Specifically, we need to use the Camera node and make whatever we want to view using it its children.

Controlling

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


export default makeScene2D(function* (view) {
    const camera = createRef<Camera>();

    const circle = createRef<Circle>();
    const square = createRef<Rect>();
    const pentagon = createRef<Polygon>();

    // the scene must be set up in this way -- camera as the root, displaying its children
    view.add(
        <Camera ref={camera}>
            <Layout layout gap={50} alignItems={'center'}>
                <Circle ref={circle} size={200} stroke={'blue'} lineWidth={5} scale={0} />
                <Rect ref={square} size={300} stroke={'white'} lineWidth={5} scale={0} />
                <Polygon ref={pentagon} size={200} sides={5} stroke={'red'} lineWidth={5} scale={0} />
            </Layout>
        </Camera>
    );

    // for some reason, opacity doesn't play nice with Camera atm :(
    // that's why we're not using our standard appear animation
    //
    // https://github.com/motion-canvas/motion-canvas/issues/1057
    yield* square().scale(1, 1);

    yield* camera().zoom(1.5, 1);

    yield* all(
        circle().scale(1, 1),
        camera().centerOn(circle(), 1),
    );

    yield* all(
        pentagon().scale(1, 2),
        camera().zoom(camera().zoom() * 1.5, 2),
        camera().centerOn(pentagon(), 2),
        camera().rotation(180, 2),
    )

    // reset back to default
    yield* camera().reset(2);
});

Additionally, just like any Node, we can use signals to create more complicated animations, such as the following, which tracks a circle within a changing grid:

import {Camera, Circle, Layout, makeScene2D} from '@motion-canvas/2d';
import {all, createRef, sequence, useRandom, Vector2} from '@motion-canvas/core';
import chroma from 'chroma-js';


export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbeef);

    const circles = Array.from({length: 12 ** 2}, () => createRef<Circle>());
    const layout = createRef<Layout>();

    let colors = chroma.scale(['#fafa6e', '#2A4858']).mode('lch').colors(circles.length)

    const camera = createRef<Camera>();

    view.add(
        // initially, the circle we're following's position is undefined, so we'll default to 0,0
        <Camera ref={camera} position={() => (circles[30]().position() ?? new Vector2(0, 0))}>
            <Layout layout gap={50} ref={layout} wrap={'wrap'} width={1400}
                    alignItems={'center'}
            >
                {circles.map((ref, i) =>
                    <Circle
                        x={random.nextInt(-600, 600)}
                        y={random.nextInt(-300, 300)}
                        ref={ref} stroke={colors[i]} fill={colors[i]}
                        lineWidth={5} size={random.nextInt(10, 50)}
                    />
                )}
            </Layout>
        </Camera>
    )

    layout().children().forEach(ref => ref.save())
    layout().layout(false);

    circles.forEach(ref => ref().scale(0));
    yield* all(
        camera().scale(0).scale(1, 3),
        sequence(0.01, ...circles.map(ref => ref().scale(1, 1))),
    );

    yield* sequence(
        0.001,
        ...circles.map(ref => all(ref().restore(1), ref().opacity(1, 1))),
    );

    layout().layout(true);

    yield* all(
        layout().gap(30, 2),
        layout().width(1200, 2),
    );

    yield* all(
        layout().gap(60, 2),
        layout().width(1600, 2),
    );
});

Multi-camera

For more complicated animations, we might be interested in using multiple cameras showing the same thing, e.g. for main/side camera setups. To achieve this, we need to store everything we want to dispaly in a single Node that has to be on the top level of the scene hierarchy, which we will feed to Camera.Stage components:

import {Camera, Circle, makeScene2D, Node, Rect, Spline} from '@motion-canvas/2d';
import {
    all,
    createDeferredEffect,
    createRef,
    createSignal,
    loop,
    sequence,
    useRandom,
    Vector2
} from '@motion-canvas/core';
import chroma from 'chroma-js';

export default makeScene2D(function* (view) {
    let random = useRandom(0xdeadbef2);

    const circles = Array.from({length: 8 ** 2}, () => createRef<Circle>());
    const basePositions = Array.from({length: 8 ** 2}, () => new Vector2(random.nextInt(-600, 600), random.nextInt(-300, 300)));

    let colors = chroma.scale(['#fafa6e', '#2A4858']).mode('lch')
        .colors(circles.length)

    // we got two cameras now
    const mainCamera = createRef<Camera>();
    const sideCamera = createRef<Camera>();

    // we also want to animate where the side camera is looking
    // these objects will visualize where the side camera is focused on
    const circle = createRef<Circle>();
    const circleCameraRect = createRef<Rect>();

    const sideWidth = view.width() / 6;
    const sideHeight = view.height() / 6;
    const sideZoom = 1.5;

    // for multi-camera stuff, we need a node that we'll pass to the cameras
    let scene = <Node>
        <Circle ref={circle} fill={'white'}/>
        <Rect ref={circleCameraRect}
              lineWidth={3} stroke={'#444'}
              scale={0} size={[sideWidth, sideHeight]}
              position={circle().position}
              radius={10}
        />
        {
            circles.map((ref, i) =>
                <Circle
                    x={basePositions[i].x}
                    y={basePositions[i].y}
                    ref={ref}
                    stroke={colors[i]} fill={colors[i]}
                    lineWidth={5} size={15}
                    rotation={random.nextInt(0, 360)}
                />
            )
        }
    </Node>;

    // add the cameras, both of which will look at the same node
    view.add(
        <>
            <Camera.Stage
                cameraRef={mainCamera}
                scene={scene}
                size={[view.width(), view.height()]}
            />
            <Camera.Stage
                cameraRef={sideCamera}
                scene={scene}
                size={[sideWidth, sideHeight]}
                position={new Vector2(view.width() / 2, -view.height() / 2)
                    .sub(new Vector2(sideWidth / 2 * sideZoom, -sideHeight / 2 * sideZoom))
                    .sub(new Vector2(50, -50))}
                stroke={'#aaa'}
                fill={'000a'}
                scale={0}
                lineWidth={3}
                radius={10}
                smoothCorners
            />
        </>,
    );

    // the side camera should keep looking at the circle
    sideCamera().position(circle().position);

    // show circles
    circles.forEach(ref => ref().scale(0));
    yield* sequence(0.01, ...circles.map(ref => ref().scale(1, 1)));

    // create a repulsion force around the white circle
    createDeferredEffect(() => {
        circles.forEach((ref, i) => {
            const pos = basePositions[i];

            const vector = pos.sub(circle().position())

            const direction = vector.normalized;
            const distance = vector.magnitude;

            const strength = 1 / 50;

            // we need to push at least as much as the radius of the circle (to not collide)
            const pushStrength = Math.max(
                Math.sqrt(distance) * circle().width() * strength,
                circle().width(),
            )

            const pushVector = pos.add(direction.mul(pushStrength));

            ref().position(pushVector);
        });
    })

    // to animate appearing the side camera, we need a reference **to its rectangle**
    // this is because Camera.Stage is a shorthand for the following:
    //
    // <Rect >  // want to get reference to this!
    //   <Camera ref={sideCamera} scene={scene} />
    // </Rect>
    //
    // using ref={...} doesn't work at the moment (likely a bug), so we do this instead
    const rect = view.children()[view.children.length - 1];

    yield* all(
        circle().size(100, 1),
        rect.scale(0).scale(sideZoom, 1),
        circleCameraRect().scale(1, 1),
    );

    // move it using a nice hand-crafted spline :)
    const spline = createRef<Spline>();
    const progress = createSignal(0);

    let h = 150;
    let w = 400;

    view.add(
        <Spline
            ref={spline}
            points={[[0, 0], [0, -h], [-w, -h], [-w, h], [w, h], [w, -h], [0, -h], [0, 0]]}
            smoothness={0.6}
        />
    )

    circle().position(() => spline().getPointAtPercentage(progress()).position)

    // loop the size scaling indefinitely in the background to make the dots pulse
    // also loop the camera
    yield loop(() => circle().size(50, 1).to(100, 1))

    yield* progress(1, 10);
});

Shaders

Basics

Shaders are (broadly speaking) tiny programs that are designed to run in a massively parallel way, and (in our case) have the following properties:

For Motion Canvas, they are written in glsl, which is a C-like shader language. If you aren’t familiar with it, I’d suggest to read the following short tutorial, but you should be able to read through the examples here without issues since the language is very simple.

Also, if you find the following examples interesting, make sure to read the incredible Book of Shaders – an online book that dives deep into the world of pixel shaders, and is a must-read for anyone with eyes.

Getting back to Motion Canvas, here is a simple example that creates a gradient shine effect on a circle:

#version 300 es
precision highp float;

// use defaut inputs (called 'uniforms')
#include "@motion-canvas/core/shaders/common.glsl"


void main() {
    // sample the color at the UV of the current run of the shader
    outColor = texture(sourceTexture, sourceUV);

    // generate a random-ish color to make a nice gradient effect
    vec3 col = 0.5 + 0.5 * cos(time * 3.0 + sourceUV.xyx + vec3(0, 2, 4));

    // write the resulting color to the node
    outColor.rgb = col;
}
import {Circle, makeScene2D} from '@motion-canvas/2d';
import {createRef, waitFor} from '@motion-canvas/core';

import shader from './shader.glsl';
import {appear} from "../../utilities";

export default makeScene2D(function* (view) {
    const circle = createRef<Circle>();

    view.add(
        <Circle
            size={400}
            lineWidth={50}
            ref={circle}
            shaders={shader}
            fill={'rgb(255,0,0)'}
            stroke={'rgba(200,0,0,0.5)'}
        />
    );

    yield* appear(circle(), 2);
    yield* waitFor(3);
});

The code should be quite straightforward, but let’s comment on a few things, mainly for those who are learning about shaders for the first time (just like me when writing this example).

First of, each Motion Canvas shader will recieve a set of the following inputs:

in vec2 screenUV;
in vec2 sourceUV;
in vec2 destinationUV;

and expect the following output, representing the color of the pixel:

out vec4 outColor;

Uniforms

Each shader also recieves a set of values called uniforms (since their value stays the same for all shaders in a single render) of a number of useful things:

uniform float time;
uniform float deltaTime;
uniform float framerate;
uniform int frame;
uniform vec2 resolution;
uniform sampler2D sourceTexture;
uniform sampler2D destinationTexture;
uniform mat4 sourceMatrix;
uniform mat4 destinationMatrix;

Fortunately, we are not limited to the default uniforms – we can provide any we want from Motion Canvas. Here is a more complex example that does exactly that to create a dynamic background effect by using uniforms to provide values of the object positions, opacities and scales:

#version 300 es
precision highp float;

#include "@motion-canvas/core/shaders/common.glsl"

uniform vec2 aPos;
uniform float aOpacity;
uniform vec2 aScale;

uniform vec2 bPos;
uniform float bOpacity;
uniform vec2 bScale;

/**
 * A shader that creates an effect of regions around objects A and B.
 * The intensities and colors are based on their positions, scale and opacity.
 */
void main() {
    // Convert object positions from screen coordinates to normalized (0 to 1)
    vec2 aNormalized = (aPos + resolution / 2.0) / resolution;
    vec2 bNormalized = (bPos + resolution / 2.0) / resolution;

    // Compute the distance from the UV to object A and object B
    // We have to account for the fact that resolution might not be square!
    vec2 vecA = sourceUV - aNormalized;
    vecA.x *= resolution.x / resolution.y;

    vec2 vecB = sourceUV - bNormalized;
    vecB.x *= resolution.x / resolution.y;

    float distA = pow(length(vecA), 2.0 * aScale.x);
    float distB = pow(length(vecB), 2.0 * bScale.x);

    // Normalize the influences so they sum to 1
    float totalDistance = distA + distB;
    float distInfluenceA = distA / totalDistance;
    float distInfluenceB = distB / totalDistance;

    // the colors are flipped because the larger the distance, the smaller the color
    vec3 colorA = vec3(aOpacity * 0.5, 0.0, 0.0);
    vec3 colorB = vec3(0.0, 0.0, bOpacity * 0.5);

    // Final color is a weighted blend of colorA and colorB based on influence
    vec3 blendedColor = distInfluenceB * colorA + distInfluenceA * colorB;

    // Output the final color with full opacity
    outColor = vec4(blendedColor, 1.0);
}

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

import shader from './shader-advanced.glsl';


export default makeScene2D(function* (view) {
    const circle = createRef<Circle>();
    const square = createRef<Rect>();

    view.add(
        <>
            <Circle
                size={300} lineWidth={30}
                ref={circle}
                fill={'rgb(255,0,0)'} stroke={'rgb(200,0,0)'}
                x={-300}
                scale={0} opacity={0}
            />,
            <Rect
                size={300} lineWidth={30}
                ref={square}
                fill={'rgb(0,0,255)'} stroke={'rgb(0,0,200)'}
                x={300}
                scale={0} opacity={0}
            />
            <Rect
                width={1920}
                height={1080}
                shaders={ {
                    fragment: shader,
                    // notice that they are signals!
                    uniforms: {
                        aPos: circle().position,
                        aOpacity: circle().opacity,
                        aScale: circle().scale,
                        bPos: square().position,
                        bOpacity: square().opacity,
                        bScale: square().scale,
                    },
                } }
                zIndex={-1}
            />
        </>
    );

    yield* sequence(
        0.5,
        all(
            circle().scale(1, 1),
            circle().opacity(1, 1),
        ),
        all(
            square().scale(1, 1),
            square().opacity(1, 1),
        ),
    );

    // alternate sizes of object A and object B
    yield loop(() => circle().scale(0.5, 1).to(1, 1))
    yield loop(() => square().scale(1, 1).to(0.5, 1))

    // rotate a few times around origin
    let progress = createSignal(0);

    circle().position(() => Vector2.fromRadians(progress()).mul(-300));
    square().position(() => Vector2.fromRadians(progress()).mul(300));
    square().rotation(() => square().position().degrees)

    yield* progress(2 * Math.PI, 10, easeInOutExpo);
});