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.
import{Camera,Circle,Layout,makeScene2D,Polygon,Rect}from'@motion-canvas/2d';import{all,createRef}from'@motion-canvas/core';exportdefaultmakeScene2D(function*(view){constcamera=createRef<Camera>();constcircle=createRef<Circle>();constsquare=createRef<Rect>();constpentagon=createRef<Polygon>();// the scene must be set up in this way -- camera as the root, displaying its children
view.add(<Cameraref={camera}><Layoutlayoutgap={50}alignItems={'center'}><Circleref={circle}size={200}stroke={'blue'}lineWidth={5}scale={0}/><Rectref={square}size={300}stroke={'white'}lineWidth={5}scale={0}/><Polygonref={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';importchromafrom'chroma-js';exportdefaultmakeScene2D(function*(view){letrandom=useRandom(0xdeadbeef);constcircles=Array.from({length: 12**2},()=>createRef<Circle>());constlayout=createRef<Layout>();letcolors=chroma.scale(['#fafa6e','#2A4858']).mode('lch').colors(circles.length)constcamera=createRef<Camera>();view.add(// initially, the circle we're following's position is undefined, so we'll default to 0,0
<Cameraref={camera}position={()=>(circles[30]().position()??newVector2(0,0))}><Layoutlayoutgap={50}ref={layout}wrap={'wrap'}width={1400}alignItems={'center'}>{circles.map((ref,i)=><Circlex={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';importchromafrom'chroma-js';exportdefaultmakeScene2D(function*(view){letrandom=useRandom(0xdeadbef2);constcircles=Array.from({length: 8**2},()=>createRef<Circle>());constbasePositions=Array.from({length: 8**2},()=>newVector2(random.nextInt(-600,600),random.nextInt(-300,300)));letcolors=chroma.scale(['#fafa6e','#2A4858']).mode('lch').colors(circles.length)// we got two cameras now
constmainCamera=createRef<Camera>();constsideCamera=createRef<Camera>();// we also want to animate where the side camera is looking
// these objects will visualize where the side camera is focused on
constcircle=createRef<Circle>();constcircleCameraRect=createRef<Rect>();constsideWidth=view.width()/6;constsideHeight=view.height()/6;constsideZoom=1.5;// for multi-camera stuff, we need a node that we'll pass to the cameras
letscene=<Node><Circleref={circle}fill={'white'}/><Rectref={circleCameraRect}lineWidth={3}stroke={'#444'}scale={0}size={[sideWidth,sideHeight]}position={circle().position}radius={10}/>{circles.map((ref,i)=><Circlex={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.StagecameraRef={mainCamera}scene={scene}size={[view.width(),view.height()]}/><Camera.StagecameraRef={sideCamera}scene={scene}size={[sideWidth,sideHeight]}position={newVector2(view.width()/2,-view.height()/2).sub(newVector2(sideWidth/2*sideZoom,-sideHeight/2*sideZoom)).sub(newVector2(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)=>{constpos=basePositions[i];constvector=pos.sub(circle().position())constdirection=vector.normalized;constdistance=vector.magnitude;conststrength=1/50;// we need to push at least as much as the radius of the circle (to not collide)
constpushStrength=Math.max(Math.sqrt(distance)*circle().width()*strength,circle().width(),)constpushVector=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
constrect=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 :)
constspline=createRef<Spline>();constprogress=createSignal(0);leth=150;letw=400;view.add(<Splineref={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
yieldloop(()=>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:
run on each pixel of the canvas/object/node/whatever we’re applying the shader to
can only communicate by taking inputs and producing outputs (no cross-communication!)
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#version 300 esprecisionhighpfloat;// use defaut inputs (called 'uniforms')#include "@motion-canvas/core/shaders/common.glsl"voidmain(){// sample the color at the UV of the current run of the shaderoutColor=texture(sourceTexture,sourceUV);// generate a random-ish color to make a nice gradient effectvec3col=0.5+0.5*cos(time*3.0+sourceUV.xyx+vec3(0,2,4));// write the resulting color to the nodeoutColor.rgb=col;}
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:
and expect the following output, representing the color of the pixel:
1
outvec4outColor;
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:
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 esprecisionhighpfloat;#include "@motion-canvas/core/shaders/common.glsl"uniformvec2aPos;uniformfloataOpacity;uniformvec2aScale;uniformvec2bPos;uniformfloatbOpacity;uniformvec2bScale;/**
* 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.
*/voidmain(){// Convert object positions from screen coordinates to normalized (0 to 1)vec2aNormalized=(aPos+resolution/2.0)/resolution;vec2bNormalized=(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!vec2vecA=sourceUV-aNormalized;vecA.x*=resolution.x/resolution.y;vec2vecB=sourceUV-bNormalized;vecB.x*=resolution.x/resolution.y;floatdistA=pow(length(vecA),2.0*aScale.x);floatdistB=pow(length(vecB),2.0*bScale.x);// Normalize the influences so they sum to 1floattotalDistance=distA+distB;floatdistInfluenceA=distA/totalDistance;floatdistInfluenceB=distB/totalDistance;// the colors are flipped because the larger the distance, the smaller the colorvec3colorA=vec3(aOpacity*0.5,0.0,0.0);vec3colorB=vec3(0.0,0.0,bOpacity*0.5);// Final color is a weighted blend of colorA and colorB based on influencevec3blendedColor=distInfluenceB*colorA+distInfluenceA*colorB;// Output the final color with full opacityoutColor=vec4(blendedColor,1.0);}
import{Circle,makeScene2D,Rect}from'@motion-canvas/2d';import{all,createRef,createSignal,easeInOutExpo,loop,sequence,Vector2}from'@motion-canvas/core';importshaderfrom'./shader-advanced.glsl';exportdefaultmakeScene2D(function*(view){constcircle=createRef<Circle>();constsquare=createRef<Rect>();view.add(<><Circlesize={300}lineWidth={30}ref={circle}fill={'rgb(255,0,0)'}stroke={'rgb(200,0,0)'}x={-300}scale={0}opacity={0}/>,<Rectsize={300}lineWidth={30}ref={square}fill={'rgb(0,0,255)'}stroke={'rgb(0,0,200)'}x={300}scale={0}opacity={0}/><Rectwidth={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
yieldloop(()=>circle().scale(0.5,1).to(1,1))yieldloop(()=>square().scale(1,1).to(0.5,1))// rotate a few times around origin
letprogress=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);});