mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 07:18:01 -05:00
feat: introduce basic caching (#68)
Allows the contents of a node to be cached in a separate canvas which is then used for rendering. It makes it possible to correctly composite transparency, shadows and filters.
This commit is contained in:
@@ -3,7 +3,7 @@ import {Shape} from './Shape';
|
||||
export class Circle extends Shape {
|
||||
protected getPath(): Path2D {
|
||||
const path = new Path2D();
|
||||
const {width, height} = this.computedLayout();
|
||||
const {width, height} = this.computedSize();
|
||||
path.ellipse(0, 0, width / 2, height / 2, 0, 0, Math.PI * 2);
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface NodeProps {
|
||||
scaleY?: number;
|
||||
scale?: Vector2;
|
||||
layout?: LayoutProps;
|
||||
opacity?: number;
|
||||
cache?: boolean;
|
||||
}
|
||||
|
||||
export class Node<TProps extends NodeProps = NodeProps> {
|
||||
@@ -60,7 +62,7 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
public declare readonly x: Signal<number, this>;
|
||||
protected readonly customX = createSignal(0, map, this);
|
||||
protected getX(): number {
|
||||
return this.computedLayout().x;
|
||||
return this.computedPosition().x;
|
||||
}
|
||||
protected setX(value: SignalValue<number>) {
|
||||
this.customX(value);
|
||||
@@ -70,7 +72,7 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
public declare readonly y: Signal<number, this>;
|
||||
protected readonly customY = createSignal(0, map, this);
|
||||
protected getY(): number {
|
||||
return this.computedLayout().y;
|
||||
return this.computedPosition().y;
|
||||
}
|
||||
protected setY(value: SignalValue<number>) {
|
||||
this.customY(value);
|
||||
@@ -84,7 +86,7 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
this,
|
||||
);
|
||||
protected getWidth(): number {
|
||||
return this.computedLayout().width;
|
||||
return this.computedSize().width;
|
||||
}
|
||||
protected setWidth(value: SignalValue<Length>) {
|
||||
this.customWidth(value);
|
||||
@@ -130,7 +132,7 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
this,
|
||||
);
|
||||
protected getHeight(): number {
|
||||
return this.computedLayout().height;
|
||||
return this.computedSize().height;
|
||||
}
|
||||
protected setHeight(value: SignalValue<Length>) {
|
||||
this.customHeight(value);
|
||||
@@ -223,6 +225,16 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
@property(0)
|
||||
public declare readonly rotation: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetX: Signal<number, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetY: Signal<number, this>;
|
||||
|
||||
@compound({x: 'offsetX', y: 'offsetY'})
|
||||
@property(undefined, vector2dLerp)
|
||||
public declare readonly offset: Signal<Vector2, this>;
|
||||
|
||||
@property(1)
|
||||
public declare readonly scaleX: Signal<number, this>;
|
||||
|
||||
@@ -233,15 +245,16 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
@property(undefined, vector2dLerp)
|
||||
public declare readonly scale: Signal<Vector2, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetX: Signal<number, this>;
|
||||
@property(false)
|
||||
public declare readonly cache: Signal<boolean, this>;
|
||||
|
||||
@property(0)
|
||||
public declare readonly offsetY: Signal<number, this>;
|
||||
@property(1)
|
||||
public declare readonly opacity: Signal<number, this>;
|
||||
|
||||
@compound({x: 'offsetX', y: 'offsetY'})
|
||||
@property(undefined, vector2dLerp)
|
||||
public declare readonly offset: Signal<Vector2, this>;
|
||||
@computed()
|
||||
public absoluteOpacity(): number {
|
||||
return (this.parent()?.absoluteOpacity() ?? 1) * this.opacity();
|
||||
}
|
||||
|
||||
@compound(['x', 'y'])
|
||||
@property(undefined, vector2dLerp)
|
||||
@@ -279,8 +292,9 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
}
|
||||
}
|
||||
|
||||
protected children = createSignal<Node[]>([]);
|
||||
protected parent = createSignal<Node | null>(null);
|
||||
protected readonly children = createSignal<Node[]>([]);
|
||||
protected readonly parent = createSignal<Node | null>(null);
|
||||
protected readonly quality = createSignal(false);
|
||||
|
||||
public constructor({children, layout, ...rest}: TProps) {
|
||||
this.layout = new Layout(layout ?? {});
|
||||
@@ -331,50 +345,56 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
@computed()
|
||||
protected localToParent(): DOMMatrix {
|
||||
const matrix = new DOMMatrix();
|
||||
const layout = this.computedLayout();
|
||||
matrix.translateSelf(layout.x, layout.y);
|
||||
const size = this.computedSize();
|
||||
const position = this.computedPosition();
|
||||
matrix.translateSelf(position.x, position.y);
|
||||
matrix.rotateSelf(0, 0, this.rotation());
|
||||
matrix.scaleSelf(this.scaleX(), this.scaleY());
|
||||
matrix.translateSelf(
|
||||
(layout.width / -2) * this.offsetX(),
|
||||
(layout.height / -2) * this.offsetY(),
|
||||
(size.width / -2) * this.offsetX(),
|
||||
(size.height / -2) * this.offsetY(),
|
||||
);
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the position and size of this node relative to its parent.
|
||||
*/
|
||||
@computed()
|
||||
protected computedLayout(): Rect {
|
||||
this.requestLayoutUpdate();
|
||||
const rect = this.layout.getComputedLayout();
|
||||
protected computedPosition(): Vector2 {
|
||||
const mode = this.mode();
|
||||
if (mode !== 'enabled') {
|
||||
return {
|
||||
x: this.customX(),
|
||||
y: this.customY(),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
const layout = {
|
||||
this.requestLayoutUpdate();
|
||||
const rect = this.layout.getComputedLayout();
|
||||
|
||||
const position = {
|
||||
x: rect.x + (rect.width / 2) * this.offsetX(),
|
||||
y: rect.y + (rect.height / 2) * this.offsetY(),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
|
||||
const parent = this.parent();
|
||||
if (parent) {
|
||||
const parentRect = parent.layout.getComputedLayout();
|
||||
layout.x -= parentRect.x + (parentRect.width - rect.width) / 2;
|
||||
layout.y -= parentRect.y + (parentRect.height - rect.height) / 2;
|
||||
position.x -= parentRect.x + (parentRect.width - rect.width) / 2;
|
||||
position.y -= parentRect.y + (parentRect.height - rect.height) / 2;
|
||||
}
|
||||
|
||||
return layout;
|
||||
return position;
|
||||
}
|
||||
|
||||
@computed()
|
||||
protected computedSize(): Size {
|
||||
this.requestLayoutUpdate();
|
||||
const rect = this.layout.getComputedLayout();
|
||||
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,17 +471,114 @@ export class Node<TProps extends NodeProps = NodeProps> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this node should be cached or not.
|
||||
*/
|
||||
protected requiresCache(): boolean {
|
||||
return this.cache() || (this.opacity() < 1 && this.children().length > 0);
|
||||
}
|
||||
|
||||
@computed()
|
||||
protected cacheCanvas(): CanvasRenderingContext2D {
|
||||
const canvas = document.createElement('canvas').getContext('2d');
|
||||
if (!canvas) {
|
||||
throw new Error('Could not create a cache canvas');
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cache canvas with the contents of this node rendered onto it.
|
||||
*/
|
||||
@computed()
|
||||
protected cachedCanvas() {
|
||||
const context = this.cacheCanvas();
|
||||
const rect = this.getCacheRect();
|
||||
context.canvas.width = rect.width;
|
||||
context.canvas.height = rect.height;
|
||||
context.resetTransform();
|
||||
context.translate(-rect.x, -rect.y);
|
||||
this.draw(context, true);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a rectangle encapsulating the contents rendered by this node.
|
||||
*
|
||||
* @remarks
|
||||
* The returned rectangle should be in local space.
|
||||
*/
|
||||
protected getCacheRect(): Rect {
|
||||
const {width, height} = this.computedSize();
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
x: width / -2,
|
||||
y: height / -2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given context for drawing a cached node onto it.
|
||||
*
|
||||
* @remarks
|
||||
* This method is called before the contents of the cache canvas are drawn
|
||||
* on the screen. It can be used to apply effects to the entire node together
|
||||
* with its children, instead of applying them individually.
|
||||
* Effects such as transparency, shadows, and filters use this technique.
|
||||
*
|
||||
* Whether the node is cached is decided by the {@link requiresCache} method.
|
||||
*
|
||||
* @param context - The context using which the cache will be drawn.
|
||||
*/
|
||||
protected setupDrawFromCache(context: CanvasRenderingContext2D) {
|
||||
context.globalAlpha = this.opacity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render this node onto the given canvas.
|
||||
*
|
||||
* @param context - The context to draw with.
|
||||
*/
|
||||
public render(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
this.transformContext(context);
|
||||
|
||||
for (const child of this.children()) {
|
||||
child.render(context);
|
||||
if (this.requiresCache()) {
|
||||
this.transformContext(context);
|
||||
this.setupDrawFromCache(context);
|
||||
const cached = this.cachedCanvas();
|
||||
const rect = this.getCacheRect();
|
||||
context.drawImage(cached.canvas, rect.x, rect.y);
|
||||
} else {
|
||||
this.transformContext(context);
|
||||
this.draw(context);
|
||||
}
|
||||
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw this node onto the canvas.
|
||||
*
|
||||
* @remarks
|
||||
* This method is used when drawing directly onto the screen as well as onto
|
||||
* the cache canvas.
|
||||
* It assumes that the context have already been transformed to local space.
|
||||
*
|
||||
* @param context - The context to draw with.
|
||||
* @param cache - Whether the node is being drawn onto the cache canvas.
|
||||
* Certain effects can be omitted when caching and applied
|
||||
* later when the cache canvas is drawn onto the screen.
|
||||
* See {@link setupDrawFromCache} for more information.
|
||||
*/
|
||||
protected draw(context: CanvasRenderingContext2D, cache = false) {
|
||||
for (const child of this.children()) {
|
||||
child.render(context);
|
||||
}
|
||||
}
|
||||
|
||||
protected transformContext(context: CanvasRenderingContext2D) {
|
||||
const matrix = this.localToParent();
|
||||
context.transform(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Shape} from './Shape';
|
||||
export class Rect extends Shape {
|
||||
protected getPath(): Path2D {
|
||||
const path = new Path2D();
|
||||
const {width, height} = this.computedLayout();
|
||||
const {width, height} = this.computedSize();
|
||||
path.rect(-width / 2, -height / 2, width, height);
|
||||
return path;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Node, NodeProps} from './Node';
|
||||
import {Gradient, Pattern} from '../partials';
|
||||
import {compound, property} from '../decorators';
|
||||
import {compound, computed, property} from '../decorators';
|
||||
import {Signal} from '@motion-canvas/core/lib/utils';
|
||||
import {Vector2} from '@motion-canvas/core/lib/types';
|
||||
import {Vector2, Rect, rect} from '@motion-canvas/core/lib/types';
|
||||
import {vector2dLerp} from '@motion-canvas/core/lib/tweening';
|
||||
|
||||
export type CanvasStyle = null | string | Gradient | Pattern;
|
||||
@@ -10,6 +10,7 @@ export type CanvasStyle = null | string | Gradient | Pattern;
|
||||
export interface ShapeProps extends NodeProps {
|
||||
fill?: CanvasStyle;
|
||||
stroke?: CanvasStyle;
|
||||
strokeFirst?: boolean;
|
||||
lineWidth?: number;
|
||||
lineJoin?: CanvasLineJoin;
|
||||
lineCap?: CanvasLineCap;
|
||||
@@ -27,6 +28,8 @@ export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
|
||||
public declare readonly fill: Signal<CanvasStyle, this>;
|
||||
@property(null)
|
||||
public declare readonly stroke: Signal<CanvasStyle, this>;
|
||||
@property(false)
|
||||
public declare readonly strokeFirst: Signal<boolean, this>;
|
||||
@property(0)
|
||||
public declare readonly lineWidth: Signal<number, this>;
|
||||
@property('miter')
|
||||
@@ -49,6 +52,16 @@ export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
|
||||
@property(undefined, vector2dLerp)
|
||||
public declare readonly shadowOffset: Signal<Vector2, this>;
|
||||
|
||||
@computed()
|
||||
protected hasShadow(): boolean {
|
||||
return (
|
||||
!!this.shadowColor() ||
|
||||
!!this.shadowOffsetX() ||
|
||||
!!this.shadowOffsetY() ||
|
||||
!!this.shadowBlur()
|
||||
);
|
||||
}
|
||||
|
||||
protected parseCanvasStyle(
|
||||
style: CanvasStyle,
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -65,15 +78,16 @@ export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
|
||||
return style.canvasPattern(context) ?? '';
|
||||
}
|
||||
|
||||
protected applyFill(context: CanvasRenderingContext2D) {
|
||||
context.fillStyle = this.parseCanvasStyle(this.fill(), context);
|
||||
protected applyShadow(context: CanvasRenderingContext2D) {
|
||||
// TODO Consider accounting for transparency when drawing from cache.
|
||||
context.shadowColor = this.shadowColor();
|
||||
context.shadowBlur = this.shadowBlur();
|
||||
context.shadowOffsetX = this.shadowOffsetX();
|
||||
context.shadowOffsetY = this.shadowOffsetY();
|
||||
}
|
||||
|
||||
protected applyStroke(context: CanvasRenderingContext2D) {
|
||||
protected applyStyle(context: CanvasRenderingContext2D) {
|
||||
context.fillStyle = this.parseCanvasStyle(this.fill(), context);
|
||||
context.strokeStyle = this.parseCanvasStyle(this.stroke(), context);
|
||||
context.lineWidth = this.lineWidth();
|
||||
context.lineJoin = this.lineJoin();
|
||||
@@ -82,25 +96,45 @@ export abstract class Shape<T extends ShapeProps = ShapeProps> extends Node<T> {
|
||||
context.lineDashOffset = this.lineDashOffset();
|
||||
}
|
||||
|
||||
public override render(context: CanvasRenderingContext2D) {
|
||||
context.save();
|
||||
this.transformContext(context);
|
||||
|
||||
protected override draw(context: CanvasRenderingContext2D, cache = false) {
|
||||
const path = this.getPath();
|
||||
context.save();
|
||||
this.applyFill(context);
|
||||
context.fill(path);
|
||||
context.restore();
|
||||
context.save();
|
||||
this.applyStroke(context);
|
||||
context.stroke(path);
|
||||
this.applyStyle(context);
|
||||
if (!cache) {
|
||||
this.applyShadow(context);
|
||||
}
|
||||
if (this.strokeFirst()) {
|
||||
context.stroke(path);
|
||||
context.fill(path);
|
||||
} else {
|
||||
context.fill(path);
|
||||
context.stroke(path);
|
||||
}
|
||||
context.restore();
|
||||
|
||||
for (const child of this.children()) {
|
||||
child.render(context);
|
||||
super.draw(context, cache);
|
||||
}
|
||||
|
||||
protected requiresCache(): boolean {
|
||||
const hasCompositeEffect = this.opacity() < 1 || this.hasShadow();
|
||||
let separateComponents = this.children().length;
|
||||
if (this.stroke()) {
|
||||
separateComponents++;
|
||||
}
|
||||
if (this.fill()) {
|
||||
separateComponents++;
|
||||
}
|
||||
|
||||
context.restore();
|
||||
return this.cache() || (hasCompositeEffect && separateComponents > 1);
|
||||
}
|
||||
|
||||
protected override setupDrawFromCache(context: CanvasRenderingContext2D) {
|
||||
this.applyShadow(context);
|
||||
super.setupDrawFromCache(context);
|
||||
}
|
||||
|
||||
protected override getCacheRect(): Rect {
|
||||
return rect.expand(super.getCacheRect(), this.lineWidth() / 2);
|
||||
}
|
||||
|
||||
protected abstract getPath(): Path2D;
|
||||
|
||||
@@ -29,7 +29,8 @@ export class TwoDView extends Node {
|
||||
}
|
||||
|
||||
public override render(context: CanvasRenderingContext2D) {
|
||||
this.computedLayout();
|
||||
this.computedSize();
|
||||
this.computedPosition();
|
||||
super.render(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,85 @@
|
||||
import {Vector2} from './Vector';
|
||||
import {transformPoint, transformVector} from './Matrix';
|
||||
|
||||
export interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function rect(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): Rect {
|
||||
return {x, y, width, height};
|
||||
}
|
||||
|
||||
export interface rect {
|
||||
fromPoints: (...points: Vector2[]) => Rect;
|
||||
topLeft: (rect: Rect) => Vector2;
|
||||
topRight: (rect: Rect) => Vector2;
|
||||
bottomLeft: (rect: Rect) => Vector2;
|
||||
bottomRight: (rect: Rect) => Vector2;
|
||||
transform: (rect: Rect, matrix: DOMMatrix) => Rect;
|
||||
expand: (rect: Rect, amount: number) => Rect;
|
||||
}
|
||||
|
||||
rect.fromPoints = (...points: Vector2[]) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
|
||||
for (const point of points) {
|
||||
if (point.x > maxX) {
|
||||
maxX = point.x;
|
||||
}
|
||||
if (point.x < minX) {
|
||||
minX = point.x;
|
||||
}
|
||||
if (point.y > maxY) {
|
||||
maxY = point.y;
|
||||
}
|
||||
if (point.y < minY) {
|
||||
minY = point.y;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
};
|
||||
};
|
||||
|
||||
rect.topLeft = (rect: Rect) => ({x: rect.x, y: rect.y});
|
||||
rect.topRight = (rect: Rect) => ({x: rect.x + rect.width, y: rect.y});
|
||||
rect.bottomLeft = (rect: Rect) => ({x: rect.x, y: rect.y + rect.height});
|
||||
rect.bottomRight = (rect: Rect) => ({
|
||||
x: rect.x + rect.width,
|
||||
y: rect.y + rect.height,
|
||||
});
|
||||
|
||||
rect.transform = (rect: Rect, matrix: DOMMatrix) => {
|
||||
const position = transformPoint(rect, matrix);
|
||||
const size = transformVector({x: rect.width, y: rect.height}, matrix);
|
||||
|
||||
return {
|
||||
...position,
|
||||
width: size.x,
|
||||
height: size.y,
|
||||
};
|
||||
};
|
||||
|
||||
rect.expand = (rect: Rect, amount: number) => {
|
||||
return {
|
||||
x: rect.x - amount,
|
||||
y: rect.y - amount,
|
||||
width: rect.width + amount * 2,
|
||||
height: rect.height + amount * 2,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user