Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
359 changes: 359 additions & 0 deletions packages/2d/src/lib/components/Camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,359 @@
import {
all,
DEFAULT,
easeInOutCubic,
InterpolationFunction,
modify,
PossibleVector2,
Reference,
SignalValue,
SimpleSignal,
threadable,
ThreadGenerator,
TimingFunction,
tween,
unwrap,
Vector2,
} from '@revideo/core';
import {cloneable, signal} from '../decorators';
import {Curve} from './Curve';
import {Node, NodeProps} from './Node';
import {Rect, RectProps} from './Rect';

export interface CameraProps extends NodeProps {
/**
* {@inheritDoc Camera.scene}
*/
scene?: Node;

/**
* {@inheritDoc Camera.zoom}
*/
zoom?: SignalValue<number>;
}

/**
* A node representing an orthographic camera.
*
* @preview
* ```tsx editor
* import {Camera, Circle, makeScene2D, Rect} from '@motion-canvas/2d';
* import {all, createRef} from '@motion-canvas/core';
*
* export default makeScene2D(function* (view) {
* const camera = createRef<Camera>();
* const rect = createRef<Rect>();
* const circle = createRef<Circle>();
*
* view.add(
* <>
* <Camera ref={camera}>
* <Rect
* ref={rect}
* fill={'lightseagreen'}
* size={100}
* position={[100, -50]}
* />
* <Circle
* ref={circle}
* fill={'hotpink'}
* size={120}
* position={[-100, 50]}
* />
* </Camera>
* </>,
* );
*
* yield* all(
* camera().centerOn(rect(), 3),
* camera().rotation(180, 3),
* camera().zoom(1.8, 3),
* );
* yield* camera().centerOn(circle(), 2);
* yield* camera().reset(1);
* });
* ```
*/
export class Camera extends Node {
/**
* The scene node that the camera is rendering.
*/
@signal()
public declare readonly scene: SimpleSignal<Node, this>;

public constructor({children, ...props}: CameraProps) {
super(props);

if (!this.scene()) {
this.scene(new Node({}));
}

if (children) {
this.scene().add(children);
}
}

/**
* The zoom level of the camera.
*
* @defaultValue 1
*/
@cloneable(false)
@signal()
public declare readonly zoom: SimpleSignal<number, this>;

protected getZoom(): number {
return 1 / this.scale.x();
}

protected setZoom(value: SignalValue<number>) {
this.scale(modify(value, unwrapped => 1 / unwrapped));
}

protected getDefaultZoom() {
return this.scale.x.context.getInitial();
}

protected *tweenZoom(
value: SignalValue<number>,
duration: number,
timingFunction: TimingFunction,
interpolationFunction: InterpolationFunction<number>,
): ThreadGenerator {
const from = this.scale.x();
yield* tween(duration, v => {
this.zoom(
1 / interpolationFunction(from, 1 / unwrap(value), timingFunction(v)),
);
});
}

/**
* Resets the camera's position, rotation and zoom level to their original
* values.
*
* @param duration - The duration of the tween.
* @param timingFunction - The timing function to use for the tween.
*/
@threadable()
public *reset(
duration: number,
timingFunction: TimingFunction = easeInOutCubic,
): ThreadGenerator {
yield* all(
this.position(DEFAULT, duration, timingFunction),
this.zoom(DEFAULT, duration, timingFunction),
this.rotation(DEFAULT, duration, timingFunction),
);
}

/**
* Centers the camera on the specified position without changing the zoom
* level.
*
* @param position - The position to center the camera on.
* @param duration - The duration of the tween.
* @param timingFunction - The timing function to use for the tween.
* @param interpolationFunction - The interpolation function to use for the
* tween.
*/
public centerOn(
position: PossibleVector2,
duration: number,
timingFunction?: TimingFunction,
interpolationFunction?: InterpolationFunction<Vector2>,
): ThreadGenerator;
/**
* Centers the camera on the specified node without changing the zoom level.
*
* @param node - The node to center the camera on.
* @param duration - The duration of the tween.
* @param timingFunction - The timing function to use for the tween.
* @param interpolationFunction - The interpolation function to use for the
* tween.
*/
public centerOn(
node: Node,
duration: number,
timingFunction?: TimingFunction,
interpolationFunction?: InterpolationFunction<Vector2>,
): ThreadGenerator;
@threadable()
public *centerOn(
positionOrNode: Node | PossibleVector2,
duration: number,
timing: TimingFunction = easeInOutCubic,
interpolationFunction: InterpolationFunction<Vector2> = Vector2.lerp,
): ThreadGenerator {
const position =
positionOrNode instanceof Node
? positionOrNode
.absolutePosition()
.transformAsPoint(this.scene().worldToLocal())
: positionOrNode;
yield* this.position(position, duration, timing, interpolationFunction);
}

/**
* Makes the camera follow a path specified by the provided curve.
*
* @remarks
* This will not change the orientation of the camera. To make the camera
* orient itself along the curve, use {@link followCurveWithRotation} or
* {@link followCurveWithRotationReverse}.
*
* If you want to follow the curve in reverse, use {@link followCurveReverse}.
*
* @param curve - The curve to follow.
* @param duration - The duration of the tween.
* @param timing - The timing function to use for the tween.
*/
@threadable()
public *followCurve(
curve: Curve,
duration: number,
timing: TimingFunction = easeInOutCubic,
): ThreadGenerator {
yield* tween(duration, value => {
const t = timing(value);
const point = curve
.getPointAtPercentage(t)
.position.transformAsPoint(curve.localToWorld());

this.position(point);
});
}

/**
* Makes the camera follow a path specified by the provided curve in reverse.
*
* @remarks
* This will not change the orientation of the camera. To make the camera
* orient itself along the curve, use {@link followCurveWithRotation} or
* {@link followCurveWithRotationReverse}.
*
* If you want to follow the curve forward, use {@link followCurve}.
*
* @param curve - The curve to follow.
* @param duration - The duration of the tween.
* @param timing - The timing function to use for the tween.
*/
@threadable()
public *followCurveReverse(
curve: Curve,
duration: number,
timing: TimingFunction = easeInOutCubic,
) {
yield* tween(duration, value => {
const t = 1 - timing(value);
const point = curve
.getPointAtPercentage(t)
.position.transformAsPoint(curve.localToWorld());

this.position(point);
});
}

/**
* Makes the camera follow a path specified by the provided curve while
* pointing the camera the direction of the tangent.
*
* @remarks
* To make the camera follow the curve without changing its orientation, use
* {@link followCurve} or {@link followCurveReverse}.
*
* If you want to follow the curve in reverse, use
* {@link followCurveWithRotationReverse}.
*
* @param curve - The curve to follow.
* @param duration - The duration of the tween.
* @param timing - The timing function to use for the tween.
*/
@threadable()
public *followCurveWithRotation(
curve: Curve,
duration: number,
timing: TimingFunction = easeInOutCubic,
) {
yield* tween(duration, value => {
const t = timing(value);
const {position, normal} = curve.getPointAtPercentage(t);
const point = position.transformAsPoint(curve.localToWorld());
const angle = normal.flipped.perpendicular.degrees;

this.position(point);
this.rotation(angle);
});
}

/**
* Makes the camera follow a path specified by the provided curve in reverse
* while pointing the camera the direction of the tangent.
*
* @remarks
* To make the camera follow the curve without changing its orientation, use
* {@link followCurve} or {@link followCurveReverse}.
*
* If you want to follow the curve forward, use
* {@link followCurveWithRotation}.
*
* @param curve - The curve to follow.
* @param duration - The duration of the tween.
* @param timing - The timing function to use for the tween.
*/
@threadable()
public *followCurveWithRotationReverse(
curve: Curve,
duration: number,
timing: TimingFunction = easeInOutCubic,
) {
yield* tween(duration, value => {
const t = 1 - timing(value);
const {position, normal} = curve.getPointAtPercentage(t);
const point = position.transformAsPoint(curve.localToWorld());
const angle = normal.flipped.perpendicular.degrees;

this.position(point);
this.rotation(angle);
});
}

protected override transformContext(context: CanvasRenderingContext2D) {
const matrix = this.localToParent().inverse();
context.transform(
matrix.a,
matrix.b,
matrix.c,
matrix.d,
matrix.e,
matrix.f,
);
}

public override hit(position: Vector2): Node | null {
const local = position.transformAsPoint(this.localToParent());
return this.scene().hit(local);
}

protected override async drawChildren(context: CanvasRenderingContext2D) {
await (this.scene() as Camera).drawChildren(context);
}

// eslint-disable-next-line @typescript-eslint/naming-convention
public static Stage({
children,
cameraRef,
scene,
...props
}: RectProps & {cameraRef?: Reference<Camera>; scene?: Node}) {
const camera = new Camera({scene: scene, children});

cameraRef?.(camera);

return new Rect({
clip: true,
...props,
children: [camera],
});
}
}
12 changes: 12 additions & 0 deletions packages/2d/src/lib/components/Node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,18 @@ export class Node implements Promisable<Node> {
return this.parent()?.worldToLocal() ?? new DOMMatrix();
}

/**
* Get the parent-to-world matrix for this node.
*
* @remarks
* This matrix transforms vectors from local space of this node's parent to
* world space.
*/
@computed()
public parentToWorld(): DOMMatrix {
return this.parent()?.localToWorld() ?? new DOMMatrix();
}

/**
* Get the local-to-parent matrix for this node.
*
Expand Down
1 change: 1 addition & 0 deletions packages/2d/src/lib/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './Audio';
export * from './Bezier';
export * from './Camera';
export * from './Circle';
export * from './Code';
export * from './CubicBezier';
Expand Down
Loading