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:
Jacob
2022-09-25 23:44:17 +02:00
committed by GitHub
parent d38c1724e1
commit 6420d362d0
6 changed files with 286 additions and 55 deletions

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -29,7 +29,8 @@ export class TwoDView extends Node {
}
public override render(context: CanvasRenderingContext2D) {
this.computedLayout();
this.computedSize();
this.computedPosition();
super.render(context);
}
}

View File

@@ -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,
};
};