Filters and Effects
Note: These docs were adopted from the original Motion Canvas docs
Because Motion Canvas is built on top of the Browser's 2D Rendering Context, we can make use of several canvas operations that are provided by the Browser.
Filters
Filters let you apply various effects to your nodes. You can find all available filters on MDN.
import ...
export default makeScene2D(function* (view) {
view.fill('#141414');
const timePassed = createSignal(0);
const iconRef = createRef<Img>();
const currentEffectText = createSignal('');
yield view.add(
<>
<Img src={'/img/logo_dark.svg'} size={200} x={-200} ref={iconRef} />
<Txt
fill={'rgba(255, 255, 255, 0.6)'}
fontSize={20}
x={200}
text={() => 'Current Filter: ' + currentEffectText()}
/>
</>,
);
function* filters() {
yield currentEffectText('Blur');
yield* iconRef().filters.blur(20, 1);
yield* iconRef().filters.blur(0, 1);
yield currentEffectText('Grayscale');
yield* iconRef().filters.grayscale(1, 1);
yield* iconRef().filters.grayscale(0, 1);
yield currentEffectText('Hue');
yield* iconRef().filters.hue(360, 2);
yield currentEffectText('Contrast');
yield* iconRef().filters.contrast(0, 1);
yield* iconRef().filters.contrast(1, 1);
}
yield* all(timePassed(4, 2 * 4, linear), filters());
});
Every node has a filters
property containing an array of
filters that will be applied to the node. You can
declare this array yourself, or use the filters
property to configure
individual filters. Both ways are shown in the following example:
Some filters, like opacity
and drop-shadow
, have their own dedicated
properties directly on the Node
, class.
import ...
export default makeScene2D(function* (view) {
view.fill('#141414');
const iconRef = createRef<Img>();
yield view.add(<Img src={'/img/logo_dark.svg'} size={200} ref={iconRef} />);
// Modification happens by accessing the `filters` property.
// Individual filters don't need to be initialized. If a filter you set doesn't
// exists, it will be automatically created and added to the list of filters.
// If you have multiple filters of the same type, this will only
// modify the first instance (you can use the array method for more control).
yield* iconRef().filters.blur(10, 1);
yield* iconRef().filters.blur(0, 1);
});
Keep in mind that the order in which you apply the effects does matter, as can be seen in the following example:
import ...
export default makeScene2D(function* (view) {
view.fontFamily('monospace').fontSize(20).fill('#141414');
view.add(<Rect size={5000} fill={'#111'} />);
const t = createSignal(0);
const saturateValue = createSignal(1);
const contrastValue = createSignal(1);
view.add(
// Left Segment
<Layout x={-300} direction={'column'} alignItems={'center'} gap={20} layout>
<Circle
size={150}
fill={'#99c47a'}
filters={[saturate(saturateValue), contrast(contrastValue)]}
/>
<Layout direction={'row'} gap={20}>
<Txt fill={'#ffa'}>saturation</Txt>
<Txt fill={'#aff'}>constrast</Txt>
</Layout>
</Layout>,
);
// Right Segment
yield view.add(
<Layout x={300} direction={'column'} alignItems={'center'} gap={20} layout>
<Circle
size={150}
fill={'#99c47a'}
filters={[contrast(contrastValue), saturate(saturateValue)]}
/>
<Layout direction={'row'} gap={20}>
<Txt fill={'#aff'}>constrast</Txt>
<Txt fill={'#ffa'}>saturation</Txt>
</Layout>
</Layout>,
);
// Center Segment
view.add(
<Layout y={-10}>
<Grid size={200} stroke={'gray'} lineWidth={1} spacing={40} />
<Grid size={200} stroke={'#333'} lineWidth={1} spacing={20} />
<Rect size={200} stroke={'gray'} lineWidth={2} />
<Txt
fill={'white'}
text={'saturation'}
rotation={-90}
x={-115}
fill={'#ffa'}
/>
<Txt fill={'white'} text={'contrast'} y={115} fill={'#aff'} />
<Txt fill={'white'} text={'1'} position={[-115, 100]} />
<Txt fill={'white'} text={'1'} position={[-100, 115]} />
<Txt fill={'white'} text={'5'} position={[-115, -90]} />
<Txt fill={'white'} text={'5'} position={[100, 115]} />
<Circle
x={() => map(-150, -100, contrastValue())}
y={() => map(150, 100, saturateValue())}
fill={'white'}
size={20}
/>
</Layout>,
);
yield t(2, 8, linear);
yield* saturateValue(5, 2);
yield* contrastValue(5, 2);
yield* waitFor(1);
yield* saturateValue(1, 2);
yield* contrastValue(1, 2);
});
Masking and composite operations
Composite operations define how the thing we draw (source) interacts with what is already on the canvas (destination). Among other things, it allows us to define complex masks. MDN has a great visualisation of all available composite operations.
You can create a mask by treating one node as the "masking" / "stencil" layer, and another node as the "value" layer. The mask layer will define if the value layer will be visible or not. The value layer will be what's actually visible in the end.
import ...
const ImageSource =
'https://images.unsplash.com/photo-1685901088371-f498db7f8c46';
export default makeScene2D(function* (view) {
view.fontSize(20).fill('#141414');
const valuePosition = createSignal(new Vector2(150, -30));
const maskPosition = createSignal(new Vector2(-150, -30));
const maskLayerRotation = createSignal(0);
const valueLayerRotation = createSignal(0);
const fakeMaskLayerGroup = createRef<Node>();
const fakeValueLayerGroup = createRef<Node>();
// First show fake a Mask Layer. Funnily enough, this also makes use of masking!
yield view.add(
<Node ref={fakeMaskLayerGroup} opacity={0} cache>
<Img
src="/img/logo_dark.svg"
size={200}
position={maskPosition}
rotation={maskLayerRotation}
/>
<Grid
compositeOperation={'source-in'}
stroke={'white'}
width={1000}
height={400}
spacing={5}
lineWidth={1}
/>
</Node>,
);
yield view.add(
<Node ref={fakeValueLayerGroup} opacity={0} cache>
{/*
We do not specifically need to use the Image here, a simple Rectangle would be enough.
It is however convenient because we get the correct aspect ratio.
*/}
<Img
src={ImageSource}
width={360}
position={valuePosition}
rotation={valueLayerRotation}
/>
<Grid
compositeOperation={'source-in'}
stroke={'#ff0'}
width={1000}
rotation={45}
height={1000}
spacing={5}
lineWidth={1}
/>
</Node>,
);
// Legend (Bottom Center)
yield view.add(
<Rect
fill={'#1a1a1aa0'}
layout
direction={'row'}
gap={20}
padding={20}
bottom={() => view.getOriginDelta(Origin.Bottom)}
>
<Layout gap={5} alignItems={'center'}>
<Grid
stroke={'white'}
width={18}
height={18}
spacing={5}
lineWidth={1}
/>
<Txt fill={'white'}>Hidden Stencil / Mask Layer</Txt>
</Layout>
<Layout gap={5} alignItems={'center'}>
<Grid
stroke={'#ff0'}
rotation={45}
width={18}
height={18}
spacing={5}
lineWidth={1}
/>
<Txt fill={'white'}>Hidden Value Layer</Txt>
</Layout>
</Rect>,
);
yield* all(
fakeMaskLayerGroup().opacity(1, 1),
fakeValueLayerGroup().opacity(1, 1),
);
// Here comes the *actual* value and stencil mask. Because it got added last it will be ontop of the "fake" layers.
yield view.add(
<Node cache>
{/** Stencil / Mask Layer. It defines if the Value Layer is visible or not */}
<Img
src="/img/logo_dark.svg"
size={200}
position={maskPosition}
rotation={maskLayerRotation}
/>
{/** Value Layer. Anything from here will be visible if the Stencil Layer allows for it. */}
<Img
src={ImageSource}
width={360}
position={valuePosition}
rotation={valueLayerRotation}
compositeOperation={'source-in'}
/>
</Node>,
);
// Visible Loop
yield* all(
maskPosition(new Vector2(0, -30), 2),
valuePosition(new Vector2(0, -30), 2),
);
yield* maskLayerRotation(360, 2);
yield* valueLayerRotation(-360, 2);
yield* waitFor(1);
yield* all(
maskPosition(new Vector2(-150, -30), 2),
valuePosition(new Vector2(150, -30), 2),
);
yield* all(
fakeMaskLayerGroup().opacity(0, 1),
fakeValueLayerGroup().opacity(0, 1),
);
// Hidden Loop
yield* all(
maskPosition(new Vector2(0, -30), 2),
valuePosition(new Vector2(0, -30), 2),
);
yield* maskLayerRotation(2 * 360, 2);
yield* valueLayerRotation(2 * -360, 2);
yield* waitFor(1);
yield* all(
maskPosition(new Vector2(-150, -30), 2),
valuePosition(new Vector2(150, -30), 2),
);
});
Any of the following composite operations can be used to create a mask:
source-in
, source-out
, destination-in
, and destination-out
. There is
also a xor
operation which can be helpful if you want two value layers that
hide each other on overlap. Use the dropdown below to browse all examples.
import ...
// Image by Marek Piwnicki (https://unsplash.com/photos/_4o-1pr2oqU)
const ImageSource =
'https://images.unsplash.com/photo-1685901088371-f498db7f8c46';
export default makeScene2D(function* (view) {
view.fill('#141414');
const maskRef = createRef<Img>();
const valueRef = createRef<Img>();
yield view.add(
<Node cache>
{/** Stencil / Mask Layer. It defines if the Value Layer is visible or not */}
<Img ref={maskRef} size={250} src="/img/logo_dark.svg" />
{/** Value Layer. Anything from here will be visible if the Stencil Layer allows for it. */}
<Img
ref={valueRef}
x={100}
src={ImageSource}
width={600}
compositeOperation={'source-in'}
/>
</Node>,
);
yield maskRef().rotation(360, 4, linear);
yield* valueRef().x(-100, 1.5).wait(0.5).to(100, 1.5).wait(0.5);
});
Cached nodes
Both filters and composite operations require a cached
Node
. Filters can set it automatically, while
composite operations require you to set it explicitly on an ancestor
Node
(usually the parent node).
A cached Node
and its children are rendered on an
offscreen canvas first, before getting added to the main scene.
For filters this is needed because they are applied to the entire canvas. By
creating a new canvas and moving the elements that should get affected by the
filters over, applying filters to the entire "new" canvas, and then moving back
the result, you effectively only apply the filters to the moved elements.
To turn a Node
into a cached node, simply pass the
cache
property
<Node cache>...</Node>
// or
<Node cache={true}>...</Node>
All components inherit from Node
, so you can set
the cache on all of them.