diff --git a/.changeset/large-moose-hang.md b/.changeset/large-moose-hang.md new file mode 100644 index 000000000..0a30af5b2 --- /dev/null +++ b/.changeset/large-moose-hang.md @@ -0,0 +1,12 @@ +--- +'@antv/g-plugin-canvas-path-generator': minor +'@antv/g-plugin-canvaskit-renderer': minor +'@antv/g-plugin-canvas-renderer': minor +'@antv/g-plugin-device-renderer': minor +'@antv/g-plugin-canvas-picker': minor +'@antv/g-plugin-image-loader': minor +'@antv/g-plugin-svg-renderer': minor +'@antv/g-lite': minor +--- + +perf: optimize canvas renderer performance diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 7f3ad1fc8..1e45ca9e5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -107,6 +107,7 @@ module.exports = { { functions: false, classes: false }, ], '@typescript-eslint/no-redeclare': ['error'], + '@typescript-eslint/no-this-alias': ['error', { allowedNames: ['self'] }], '@typescript-eslint/restrict-template-expressions': 'warn', '@typescript-eslint/return-await': 'warn', '@typescript-eslint/default-param-last': 'warn', diff --git a/__tests__/demos/bugfix/1760.ts b/__tests__/demos/bugfix/1760.ts index ea0ba6512..92eaf2929 100644 --- a/__tests__/demos/bugfix/1760.ts +++ b/__tests__/demos/bugfix/1760.ts @@ -3,7 +3,7 @@ import { Canvas, Path, Line } from '@antv/g'; /** * @see https://github.com/antvis/G/issues/1760 * @see https://github.com/antvis/G/issues/1790 - * @see https://github.com/antvis/G/pull/1808 + * @see https://github.com/antvis/G/pull/1809 */ export async function issue_1760(context: { canvas: Canvas }) { const { canvas } = context; diff --git a/__tests__/demos/perf/attr-update.ts b/__tests__/demos/perf/attr-update.ts new file mode 100644 index 000000000..a6fe4c86b --- /dev/null +++ b/__tests__/demos/perf/attr-update.ts @@ -0,0 +1,105 @@ +import * as lil from 'lil-gui'; +import { Rect, Group, CanvasEvent } from '@antv/g'; +import type { Canvas } from '@antv/g'; + +export async function attrUpdate(context: { canvas: Canvas; gui: lil.GUI }) { + const { canvas, gui } = context; + console.log(canvas); + + await canvas.ready; + + const { width, height } = canvas.getConfig(); + const count = 2e4; + const root = new Group(); + const rects = []; + + const perfStore: { [k: string]: { count: number; time: number } } = { + update: { count: 0, time: 0 }, + setAttribute: { count: 0, time: 0 }, + }; + + function updatePerf(key: string, time: number) { + perfStore[key].count++; + perfStore[key].time += time; + console.log( + `average ${key} time: `, + perfStore[key].time / perfStore[key].count, + ); + } + + function update() { + // const startTime = performance.now(); + // console.time('update'); + + const rectsToRemove = []; + + // const startTime0 = performance.now(); + // console.time('setAttribute'); + for (let i = 0; i < count; i++) { + const rect = rects[i]; + rect.x -= rect.speed; + (rect.el as Rect).setAttribute('x', rect.x); + if (rect.x + rect.size < 0) rectsToRemove.push(i); + } + // console.timeEnd('setAttribute'); + // updatePerf('setAttribute', performance.now() - startTime0); + + rectsToRemove.forEach((i) => { + rects[i].x = width + rects[i].size / 2; + }); + + // console.timeEnd('update'); + // updatePerf('update', performance.now() - startTime); + } + + function render() { + for (let i = 0; i < count; i++) { + const x = Math.random() * width; + const y = Math.random() * height; + const size = 10 + Math.random() * 40; + const speed = 1 + Math.random(); + + const rect = new Rect({ + style: { + x, + y, + width: size, + height: size, + fill: 'white', + stroke: 'black', + }, + }); + root.appendChild(rect); + rects[i] = { x, y, size, speed, el: rect }; + } + } + + render(); + canvas.addEventListener(CanvasEvent.BEFORE_RENDER, () => update()); + + canvas.appendChild(root); + + canvas.addEventListener( + 'rerender', + () => { + // console.timeEnd('render'); + }, + { once: true }, + ); + + // GUI + canvas.getConfig().renderer.getConfig().enableRenderingOptimization = true; + + gui + .add( + { + enableRenderingOptimization: canvas.getConfig().renderer.getConfig() + .enableRenderingOptimization, + }, + 'enableRenderingOptimization', + ) + .onChange((result) => { + canvas.getConfig().renderer.getConfig().enableRenderingOptimization = + result; + }); +} diff --git a/__tests__/demos/perf/index.ts b/__tests__/demos/perf/index.ts index 7534392e1..efcf468e5 100644 --- a/__tests__/demos/perf/index.ts +++ b/__tests__/demos/perf/index.ts @@ -1,3 +1,4 @@ export { circles } from './circles'; export { rects } from './rect'; export { image } from './image'; +export { attrUpdate } from './attr-update'; diff --git a/__tests__/main.ts b/__tests__/main.ts index 32e8d9dd4..1993eeda7 100644 --- a/__tests__/main.ts +++ b/__tests__/main.ts @@ -1,4 +1,4 @@ -// import Stats from 'stats.js'; +import Stats from 'stats.js'; import * as lil from 'lil-gui'; import '@antv/g-camera-api'; import { Canvas, CanvasEvent, runtime } from '@antv/g'; @@ -46,7 +46,7 @@ const renderers = { }; const app = document.getElementById('app') as HTMLElement; let currentContainer = document.createElement('div'); -let canvas; +let canvas: Canvas; let prevAfter; const normalizeName = (name: string) => name.replace(/-/g, '').toLowerCase(); const renderOptions = (keyword = '') => { @@ -227,13 +227,13 @@ function createSpecRender(object) { window.__g_instances__ = [canvas]; // stats - // const stats = new Stats(); - // stats.showPanel(0); - // const $stats = stats.dom; - // $stats.style.position = 'absolute'; - // $stats.style.left = '4px'; - // $stats.style.top = '4px'; - // app.appendChild($stats); + const stats = new Stats(); + stats.showPanel(0); + const $stats = stats.dom; + $stats.style.position = 'fixed'; + $stats.style.left = '2px'; + $stats.style.top = '2px'; + // document.body.appendChild($stats); // GUI const gui = new lil.GUI({ autoPlace: false }); @@ -241,9 +241,9 @@ function createSpecRender(object) { await generate({ canvas, renderer, container: $div, gui }); - // canvas.addEventListener(CanvasEvent.AFTER_RENDER, () => { - // stats.update(); - // }); + canvas.addEventListener(CanvasEvent.AFTER_RENDER, () => { + stats.update(); + }); if ( selectRenderer.value === 'canvas' && diff --git a/packages/g-lite/src/AbstractRenderer.ts b/packages/g-lite/src/AbstractRenderer.ts index 6ac1bc364..c6c91e4bf 100644 --- a/packages/g-lite/src/AbstractRenderer.ts +++ b/packages/g-lite/src/AbstractRenderer.ts @@ -88,6 +88,7 @@ export class AbstractRenderer implements IRenderer { enableDirtyRectangleRendering: true, enableDirtyRectangleRenderingDebug: false, enableSizeAttenuation: true, + enableRenderingOptimization: false, ...config, }; } diff --git a/packages/g-lite/src/Canvas.ts b/packages/g-lite/src/Canvas.ts index 05b880aeb..0630c2a47 100644 --- a/packages/g-lite/src/Canvas.ts +++ b/packages/g-lite/src/Canvas.ts @@ -9,7 +9,16 @@ import { } from './camera'; import type { RBushNodeAABB } from './components'; import type { CustomElement } from './display-objects'; -import { DisplayObject } from './display-objects/DisplayObject'; +import type { MutationEvent } from './dom/MutationEvent'; +import { + DisplayObject, + attrModifiedEvent as attrModifiedEventCache, +} from './display-objects/DisplayObject'; +import { + insertedEvent as insertedEventCache, + removedEvent as removedEventCache, + destroyEvent as destroyEventCache, +} from './dom/Element'; import type { CanvasContext, Element, IChildNode } from './dom'; import { CustomEvent, Document, ElementEvent, EventTarget } from './dom'; import { CustomElementRegistry } from './dom/CustomElementRegistry'; @@ -122,9 +131,6 @@ export class Canvas extends EventTarget implements ICanvas { */ isMouseEvent: (event: InteractivePointerEvent) => event is MouseEvent; - /** - * double click speed (ms), default is 200ms - */ dblClickSpeed?: CanvasConfig['dblClickSpeed']; /** @@ -398,9 +404,7 @@ export class Canvas extends EventTarget implements ICanvas { this.dispatchEvent(new CustomEvent(CanvasEvent.BEFORE_DESTROY)); } if (this.frameId) { - const cancelRAF = - this.getConfig().cancelAnimationFrame || cancelAnimationFrame; - cancelRAF(this.frameId); + this.cancelAnimationFrame(this.frameId); } // unmount all children @@ -429,10 +433,11 @@ export class Canvas extends EventTarget implements ICanvas { this.dispatchEvent(new CustomEvent(CanvasEvent.AFTER_DESTROY)); } - const clearEventRetain = (event: CustomEvent) => { + const clearEventRetain = (event: CustomEvent | MutationEvent) => { event.currentTarget = null; event.manager = null; event.target = null; + (event as MutationEvent).relatedNode = null; }; clearEventRetain(mountedEvent); @@ -440,8 +445,10 @@ export class Canvas extends EventTarget implements ICanvas { clearEventRetain(beforeRenderEvent); clearEventRetain(rerenderEvent); clearEventRetain(afterRenderEvent); - - this.cancelAnimationFrame(this.frameId); + clearEventRetain(attrModifiedEventCache); + clearEventRetain(insertedEventCache); + clearEventRetain(removedEventCache); + clearEventRetain(destroyEventCache); } /** diff --git a/packages/g-lite/src/css/StyleValueRegistry.ts b/packages/g-lite/src/css/StyleValueRegistry.ts index f1a6c70ce..3c7112cb8 100644 --- a/packages/g-lite/src/css/StyleValueRegistry.ts +++ b/packages/g-lite/src/css/StyleValueRegistry.ts @@ -5,11 +5,7 @@ import { EMPTY_PARSED_PATH } from '../display-objects/constants'; import type { GlobalRuntime } from '../global-runtime'; import { GeometryAABBUpdater } from '../services'; import { AABB } from '../shapes'; -import type { - BaseStyleProps, - ParsedBaseStyleProps, - Tuple3Number, -} from '../types'; +import type { BaseStyleProps, Tuple3Number } from '../types'; import { Shape } from '../types'; import type { CSSRGB } from './cssom'; import type { @@ -615,8 +611,8 @@ export const BUILT_IN_PROPERTIES: PropertyMetadata[] = [ }, ]; -const GEOMETRY_ATTRIBUTE_NAMES = BUILT_IN_PROPERTIES.filter((n) => !!n.l).map( - (n) => n.n, +const GEOMETRY_ATTRIBUTE_NAMES = new Set( + BUILT_IN_PROPERTIES.filter((n) => !!n.l).map((n) => n.n), ); export const propertyMetadataCache: Record = {}; @@ -667,12 +663,13 @@ export class DefaultStyleValueRegistry implements StyleValueRegistry { Object.assign(object.parsedStyle, attributes); let needUpdateGeometry = !!options.forceUpdateGeometry; - - if ( - !needUpdateGeometry && - GEOMETRY_ATTRIBUTE_NAMES.some((name) => name in attributes) - ) { - needUpdateGeometry = true; + if (!needUpdateGeometry) { + for (const i in attributes) { + if (GEOMETRY_ATTRIBUTE_NAMES.has(i)) { + needUpdateGeometry = true; + break; + } + } } if (attributes.fill) { @@ -858,7 +855,7 @@ export class DefaultStyleValueRegistry implements StyleValueRegistry { if (!geometry.renderBounds) { geometry.renderBounds = new AABB(); } - const parsedStyle = object.parsedStyle as ParsedBaseStyleProps; + const parsedStyle = object.parsedStyle; const { cx = 0, cy = 0, diff --git a/packages/g-lite/src/css/parser/color.ts b/packages/g-lite/src/css/parser/color.ts index a5054ee27..81e3ff541 100644 --- a/packages/g-lite/src/css/parser/color.ts +++ b/packages/g-lite/src/css/parser/color.ts @@ -44,11 +44,11 @@ export function isCSSRGB(object: any): object is CSSRGB { * @see https://github.com/WebKit/WebKit/blob/main/Source/WebCore/css/parser/CSSParser.cpp#L97 */ export const parseColor = memoize( - (colorStr: string): CSSRGB | CSSGradientValue[] | Pattern => { + (colorStr: string | Pattern): CSSRGB | CSSGradientValue[] | Pattern => { if (isPattern(colorStr)) { return { repetition: 'repeat', - ...(colorStr as Pattern), + ...colorStr, }; } diff --git a/packages/g-lite/src/css/parser/transform-origin.ts b/packages/g-lite/src/css/parser/transform-origin.ts index 0614f359d..1bbf40a76 100644 --- a/packages/g-lite/src/css/parser/transform-origin.ts +++ b/packages/g-lite/src/css/parser/transform-origin.ts @@ -33,12 +33,8 @@ export const parseTransformOrigin = memoize( // eg. center bottom return [ - parseLengthOrPercentage( - convertKeyword2Percent(values[0]), - ) as CSSUnitValue, - parseLengthOrPercentage( - convertKeyword2Percent(values[1]), - ) as CSSUnitValue, + parseLengthOrPercentage(convertKeyword2Percent(values[0])), + parseLengthOrPercentage(convertKeyword2Percent(values[1])), ]; } return [ diff --git a/packages/g-lite/src/css/properties/CSSPropertyTransformOrigin.ts b/packages/g-lite/src/css/properties/CSSPropertyTransformOrigin.ts index 86266576b..82c22daee 100644 --- a/packages/g-lite/src/css/properties/CSSPropertyTransformOrigin.ts +++ b/packages/g-lite/src/css/properties/CSSPropertyTransformOrigin.ts @@ -1,5 +1,4 @@ import { DisplayObject } from '../../display-objects'; -import { ParsedBaseStyleProps } from '../../types'; import { UnitType, type CSSUnitValue } from '../cssom'; import type { CSSProperty } from '../CSSProperty'; @@ -15,7 +14,7 @@ export class CSSPropertyTransformOrigin > { postProcessor(object: DisplayObject) { - const { transformOrigin } = object.parsedStyle as ParsedBaseStyleProps; + const { transformOrigin } = object.parsedStyle; if ( transformOrigin[0].unit === UnitType.kPixels && transformOrigin[1].unit === UnitType.kPixels diff --git a/packages/g-lite/src/display-objects/DisplayObject.ts b/packages/g-lite/src/display-objects/DisplayObject.ts index 8d55d3cc8..6c796fe2b 100644 --- a/packages/g-lite/src/display-objects/DisplayObject.ts +++ b/packages/g-lite/src/display-objects/DisplayObject.ts @@ -35,7 +35,7 @@ const Proxy: ProxyConstructor = runtime.globalThis.Proxy type ConstructorTypeOf = new (...args: any[]) => T; -const mutationEvent: MutationEvent = new MutationEvent( +export const attrModifiedEvent: MutationEvent = new MutationEvent( ElementEvent.ATTR_MODIFIED, null, null, @@ -68,8 +68,8 @@ const $quat = quat.create(); * * attributeChanged */ export class DisplayObject< - StyleProps extends BaseStyleProps = any, - ParsedStyleProps extends ParsedBaseStyleProps = any, + StyleProps extends BaseStyleProps = BaseStyleProps, + ParsedStyleProps extends ParsedBaseStyleProps = ParsedBaseStyleProps, > extends Element { /** * contains style props in constructor's params, eg. fill, stroke... @@ -247,7 +247,7 @@ export class DisplayObject< const oldParsedValue = this.parsedStyle[name as string]; runtime.styleValueRegistry.processProperties( - this, + this as unknown as DisplayObject, { [name]: value, }, @@ -259,25 +259,22 @@ export class DisplayObject< const newParsedValue = this.parsedStyle[name as string]; if (this.isConnected) { - mutationEvent.relatedNode = this as IElement; - mutationEvent.prevValue = oldValue; - mutationEvent.newValue = value; - mutationEvent.attrName = name as string; - mutationEvent.prevParsedValue = oldParsedValue; - mutationEvent.newParsedValue = newParsedValue; + attrModifiedEvent.relatedNode = this as IElement; + attrModifiedEvent.prevValue = oldValue; + attrModifiedEvent.newValue = value; + attrModifiedEvent.attrName = name as string; + attrModifiedEvent.prevParsedValue = oldParsedValue; + attrModifiedEvent.newParsedValue = newParsedValue; if (this.isMutationObserved) { - this.dispatchEvent(mutationEvent); + this.dispatchEvent(attrModifiedEvent); } else { - mutationEvent.target = this; - this.ownerDocument.defaultView.dispatchEvent(mutationEvent, true); + attrModifiedEvent.target = this; + this.ownerDocument.defaultView.dispatchEvent(attrModifiedEvent, true); } } - if ( - ((this.isCustomElement && this.isConnected) || !this.isCustomElement) && - (this as unknown as CustomElement).attributeChangedCallback - ) { - (this as unknown as CustomElement).attributeChangedCallback( + if ((this.isCustomElement && this.isConnected) || !this.isCustomElement) { + (this as unknown as CustomElement).attributeChangedCallback?.( name, oldValue, value, diff --git a/packages/g-lite/src/dom/Document.ts b/packages/g-lite/src/dom/Document.ts index e1883aa66..e201781bd 100644 --- a/packages/g-lite/src/dom/Document.ts +++ b/packages/g-lite/src/dom/Document.ts @@ -3,7 +3,7 @@ import { runtime } from '../global-runtime'; import { BUILT_IN_PROPERTIES } from '../css'; import { Group, Text } from '../display-objects'; import type { DisplayObject } from '../display-objects'; -import type { BaseStyleProps, ParsedBaseStyleProps } from '../types'; +import type { BaseStyleProps } from '../types'; import { Shape } from '../types'; import { ERROR_MSG_METHOD_NOT_IMPLEMENTED, @@ -149,8 +149,7 @@ export class Document extends Node implements IDocument { const hitTestList: DisplayObject[] = []; rBushNodes.forEach(({ displayObject }) => { - const { pointerEvents = 'auto' } = - displayObject.parsedStyle as ParsedBaseStyleProps; + const { pointerEvents = 'auto' } = displayObject.parsedStyle; // account for `visibility` // @see https://developer.mozilla.org/en-US/docs/Web/CSS/pointer-events diff --git a/packages/g-lite/src/dom/Element.ts b/packages/g-lite/src/dom/Element.ts index 2f1338ea7..9c100135f 100644 --- a/packages/g-lite/src/dom/Element.ts +++ b/packages/g-lite/src/dom/Element.ts @@ -35,7 +35,7 @@ export function resetEntityCounter() { entityCounter = 0; } -const insertedEvent = new MutationEvent( +export const insertedEvent = new MutationEvent( ElementEvent.INSERTED, null, '', @@ -45,7 +45,7 @@ const insertedEvent = new MutationEvent( '', '', ); -const removedEvent = new MutationEvent( +export const removedEvent = new MutationEvent( ElementEvent.REMOVED, null, '', @@ -55,7 +55,7 @@ const removedEvent = new MutationEvent( '', '', ); -const destroyEvent = new CustomEvent(ElementEvent.DESTROY); +export const destroyEvent = new CustomEvent(ElementEvent.DESTROY); /** * Has following capabilities: @@ -64,8 +64,8 @@ const destroyEvent = new CustomEvent(ElementEvent.DESTROY); * * Animation */ export class Element< - StyleProps extends BaseStyleProps = any, - ParsedStyleProps extends ParsedBaseStyleProps = any, + StyleProps extends BaseStyleProps = BaseStyleProps, + ParsedStyleProps extends ParsedBaseStyleProps = ParsedBaseStyleProps, > extends Node implements IElement @@ -295,7 +295,7 @@ export class Element< destroyChildren() { for (let i = this.childNodes.length - 1; i >= 0; i--) { const child = this.childNodes[i] as this; - if (child.childNodes.length) { + if (child.childNodes.length > 0) { child.destroyChildren(); } child.destroy(); diff --git a/packages/g-lite/src/dom/EventTarget.ts b/packages/g-lite/src/dom/EventTarget.ts index d8d27c60f..7598be7bd 100644 --- a/packages/g-lite/src/dom/EventTarget.ts +++ b/packages/g-lite/src/dom/EventTarget.ts @@ -137,6 +137,10 @@ export class EventTarget implements IEventTarget { if (!skipPropagate) e.target = this; e.manager.dispatchEvent(e, e.type, skipPropagate); + } else { + // HACK Fixed the issue that after an element leaves the DOM tree, there is no associated canvas, + // which causes the removed and destroy events to not be triggered + this.emitter.emit(e.type, e); } return !e.defaultPrevented; diff --git a/packages/g-lite/src/dom/Node.ts b/packages/g-lite/src/dom/Node.ts index e428dc302..fa552b1d6 100644 --- a/packages/g-lite/src/dom/Node.ts +++ b/packages/g-lite/src/dom/Node.ts @@ -361,16 +361,19 @@ export abstract class Node extends EventTarget implements INode { /** * iterate current node and its descendants * @param callback - callback to execute for each node, return false to break - * @param assigned - whether to iterate assigned nodes */ forEach(callback: (o: INode) => void | boolean) { - const result = callback(this); + const stack: INode[] = [this]; - if (result !== false) { - const nodes = this.childNodes; - const length = nodes.length; - for (let i = 0; i < length; i++) { - nodes[i].forEach(callback); + while (stack.length > 0) { + const node = stack.pop(); + const result = callback(node); + if (result === false) { + break; + } + + for (let i = node.childNodes.length - 1; i >= 0; i--) { + stack.push(node.childNodes[i]); } } } diff --git a/packages/g-lite/src/dom/interfaces.ts b/packages/g-lite/src/dom/interfaces.ts index 6df28baf7..fa6a2af38 100644 --- a/packages/g-lite/src/dom/interfaces.ts +++ b/packages/g-lite/src/dom/interfaces.ts @@ -1,4 +1,5 @@ import type RBush from 'rbush'; +import EventEmitter from 'eventemitter3'; import type { GlobalRuntime } from '..'; import type { ICamera } from '../camera'; import type { RBushNodeAABB } from '../components'; @@ -66,7 +67,7 @@ export enum ElementEvent { } export interface IEventTarget { - // emitter: EventEmitter; + emitter: EventEmitter; // on: ( // type: string, diff --git a/packages/g-lite/src/services/RenderingService.ts b/packages/g-lite/src/services/RenderingService.ts index 40eeaccab..045447e29 100644 --- a/packages/g-lite/src/services/RenderingService.ts +++ b/packages/g-lite/src/services/RenderingService.ts @@ -134,10 +134,13 @@ export class RenderingService { this.inited = true; callback(); } else { - this.hooks.initAsync.promise().then(() => { - this.inited = true; - callback(); - }); + this.hooks.initAsync + .promise() + .then(() => { + this.inited = true; + callback(); + }) + .catch((err) => {}); } } @@ -220,52 +223,62 @@ export class RenderingService { canvasConfig: Partial, renderingContext: RenderingContext, ) { + const self = this; const { enableDirtyCheck, enableCulling } = canvasConfig.renderer.getConfig(); - // TODO: relayout - - // dirtycheck first - const { renderable } = displayObject; - // eslint-disable-next-line no-nested-ternary - const objectChanged = enableDirtyCheck - ? // @ts-ignore - renderable.dirty || renderingContext.dirtyRectangleRenderingDisabled - ? displayObject - : null - : displayObject; - - if (objectChanged) { - const objectToRender = enableCulling - ? this.hooks.cull.call(objectChanged, this.context.camera) - : objectChanged; - - if (objectToRender) { - this.stats.rendered++; - renderingContext.renderListCurrentFrame.push(objectToRender); + function internalRenderSingleDisplayObject(object: DisplayObject) { + // TODO: relayout + + // dirtycheck first + const { renderable, sortable } = object; + // eslint-disable-next-line no-nested-ternary + const objectChanged = enableDirtyCheck + ? // @ts-ignore + renderable.dirty || renderingContext.dirtyRectangleRenderingDisabled + ? object + : null + : object; + + if (objectChanged) { + const objectToRender = enableCulling + ? self.hooks.cull.call(objectChanged, self.context.camera) + : objectChanged; + + if (objectToRender) { + self.stats.rendered += 1; + renderingContext.renderListCurrentFrame.push(objectToRender); + } } - } - displayObject.renderable.dirty = false; - displayObject.sortable.renderOrder = this.zIndexCounter++; + renderable.dirty = false; + sortable.renderOrder = self.zIndexCounter; - this.stats.total++; + self.zIndexCounter += 1; + self.stats.total += 1; - // sort is very expensive, use cached result if possible - const { sortable } = displayObject; - if (sortable.dirty) { - this.sort(displayObject, sortable); - sortable.dirty = false; - sortable.dirtyChildren = []; - sortable.dirtyReason = undefined; + // sort is very expensive, use cached result if possible + if (sortable.dirty) { + self.sort(object, sortable); + sortable.dirty = false; + sortable.dirtyChildren = []; + sortable.dirtyReason = undefined; + } } - // recursive rendering its children - (sortable.sorted || displayObject.childNodes).forEach( - (child: DisplayObject) => { - this.renderDisplayObject(child, canvasConfig, renderingContext); - }, - ); + const stack = [displayObject]; + + while (stack.length > 0) { + const currentObject = stack.pop(); + + internalRenderSingleDisplayObject(currentObject); + + // recursive rendering its children + const objects = currentObject.sortable.sorted || currentObject.childNodes; + for (let i = objects.length - 1; i >= 0; i--) { + stack.push(objects[i] as unknown as DisplayObject); + } + } } private sort(displayObject: DisplayObject, sortable: Sortable) { diff --git a/packages/g-lite/src/services/SceneGraphService.ts b/packages/g-lite/src/services/SceneGraphService.ts index e77017f49..cbf97c592 100644 --- a/packages/g-lite/src/services/SceneGraphService.ts +++ b/packages/g-lite/src/services/SceneGraphService.ts @@ -583,38 +583,35 @@ export class DefaultSceneGraphService implements SceneGraphService { triggerPendingEvents() { const triggered = new Set(); - const skipped = new Set(); - - this.pendingEvents.forEach((_, element) => { - if (element.nodeName === Shape.FRAGMENT) { - element.forEach((e) => { - if (e !== element) skipped.add(e as DisplayObject); - }); - } - }); const trigger = (element: DisplayObject, detail) => { if ( - element.isConnected && - !triggered.has(element) && - !skipped.has(element) + !element.isConnected || + triggered.has(element) || + (element.nodeName as Shape) === Shape.FRAGMENT ) { - this.boundsChangedEvent.detail = detail; - this.boundsChangedEvent.target = element; - if (element.isMutationObserved) { - element.dispatchEvent(this.boundsChangedEvent); - } else { - element.ownerDocument.defaultView.dispatchEvent( - this.boundsChangedEvent, - true, - ); - } + return; + } - triggered.add(element); + this.boundsChangedEvent.detail = detail; + this.boundsChangedEvent.target = element; + if (element.isMutationObserved) { + element.dispatchEvent(this.boundsChangedEvent); + } else { + element.ownerDocument.defaultView.dispatchEvent( + this.boundsChangedEvent, + true, + ); } + + triggered.add(element); }; this.pendingEvents.forEach((affectChildren, element) => { + if ((element.nodeName as Shape) === Shape.FRAGMENT) { + return; + } + $triggerPendingEvents_detail.affectChildren = affectChildren; if (affectChildren) { element.forEach((e: DisplayObject) => { @@ -622,8 +619,8 @@ export class DefaultSceneGraphService implements SceneGraphService { }); } else trigger(element, $triggerPendingEvents_detail); }); + triggered.clear(); - skipped.clear(); this.clearPendingEvents(); } @@ -689,36 +686,38 @@ export class DefaultSceneGraphService implements SceneGraphService { informDependentDisplayObjects(object: DisplayObject) { const dependencyMap = this.displayObjectDependencyMap.get(object); - if (dependencyMap) { - Object.keys(dependencyMap).forEach((name) => { - dependencyMap[name].forEach((target) => { - this.dirtifyToRoot(target, true); - - target.dispatchEvent( - new MutationEvent( - ElementEvent.ATTR_MODIFIED, - target as IElement, - this, - this, + if (!dependencyMap) { + return; + } + + Object.keys(dependencyMap).forEach((name) => { + dependencyMap[name].forEach((target) => { + this.dirtifyToRoot(target, true); + + target.dispatchEvent( + new MutationEvent( + ElementEvent.ATTR_MODIFIED, + target as IElement, + this, + this, + name, + MutationEvent.MODIFICATION, + this, + this, + ), + ); + + if (target.isCustomElement && target.isConnected) { + if ((target as CustomElement).attributeChangedCallback) { + (target as CustomElement).attributeChangedCallback( name, - MutationEvent.MODIFICATION, this, this, - ), - ); - - if (target.isCustomElement && target.isConnected) { - if ((target as CustomElement).attributeChangedCallback) { - (target as CustomElement).attributeChangedCallback( - name, - this, - this, - ); - } + ); } - }); + } }); - } + }); } getPosition(element: INode) { diff --git a/packages/g-lite/src/types.ts b/packages/g-lite/src/types.ts index f323e51e5..272102795 100644 --- a/packages/g-lite/src/types.ts +++ b/packages/g-lite/src/types.ts @@ -430,6 +430,19 @@ export interface RendererConfig { */ enableSizeAttenuation: boolean; + /** + * Enable rendering optimization + * + * After rendering optimization is enabled, the rendering of each element in each frame will not have + * an independent canvas context state, but the state consistency is maintained by caching, + * because the save() and restore() of the canvas context state are expensive. + * + * ! Errors may occur due to manual maintenance of the canvas context state consistency + * + * @default false + */ + enableRenderingOptimization: boolean; + // plugins: } @@ -545,6 +558,10 @@ export interface CanvasConfig { supportsTouchEvents?: boolean; isTouchEvent?: (event: InteractivePointerEvent) => event is TouchEvent; isMouseEvent?: (event: InteractivePointerEvent) => event is MouseEvent; + + /** + * double click speed (ms), default is 200ms + */ dblClickSpeed?: number; /** diff --git a/packages/g-lite/src/utils/memoize.ts b/packages/g-lite/src/utils/memoize.ts index d4eb6bf2e..0281ec3f1 100644 --- a/packages/g-lite/src/utils/memoize.ts +++ b/packages/g-lite/src/utils/memoize.ts @@ -1,10 +1,14 @@ -export function memoize(func: any, resolver?: (...args: any[]) => any) { +export function memoize unknown>( + func: F, + resolver?: (...args: any[]) => any, +) { if ( typeof func !== 'function' || (resolver != null && typeof resolver !== 'function') ) { throw new TypeError('Expected a function'); } + const memoized = function (...args) { const key = resolver ? resolver.apply(this, args) : args[0]; const { cache } = memoized; @@ -17,7 +21,8 @@ export function memoize(func: any, resolver?: (...args: any[]) => any) { return result; }; memoized.cache = new (memoize.Cache || Map)(); - return memoized; + + return memoized as F; } memoize.Cache = Map; diff --git a/packages/g-plugin-canvas-path-generator/src/index.ts b/packages/g-plugin-canvas-path-generator/src/index.ts index a416bbd01..343316223 100644 --- a/packages/g-plugin-canvas-path-generator/src/index.ts +++ b/packages/g-plugin-canvas-path-generator/src/index.ts @@ -10,7 +10,9 @@ import { RectPath, } from './paths'; -export class Plugin extends AbstractRendererPlugin { +export class Plugin extends AbstractRendererPlugin<{ + pathGeneratorFactory: Record>; +}> { name = 'canvas-path-generator'; init(): void { const pathGeneratorFactory: Record> = { @@ -26,6 +28,7 @@ export class Plugin extends AbstractRendererPlugin { [Shape.IMAGE]: undefined, [Shape.HTML]: undefined, [Shape.MESH]: undefined, + [Shape.FRAGMENT]: undefined, }; // @ts-ignore diff --git a/packages/g-plugin-canvas-picker/src/CanvasPickerPlugin.ts b/packages/g-plugin-canvas-picker/src/CanvasPickerPlugin.ts index fe8635f34..6ed3dfe61 100644 --- a/packages/g-plugin-canvas-picker/src/CanvasPickerPlugin.ts +++ b/packages/g-plugin-canvas-picker/src/CanvasPickerPlugin.ts @@ -1,7 +1,6 @@ import type { BaseStyleProps, DisplayObject, - ParsedBaseStyleProps, PickingResult, RenderingPlugin, Shape, @@ -98,7 +97,7 @@ export class CanvasPickerPlugin implements RenderingPlugin { // should look up in the ancestor node const clipped = findClosestClipPathTarget(displayObject); if (clipped) { - const { clipPath } = clipped.parsedStyle as ParsedBaseStyleProps; + const { clipPath } = clipped.parsedStyle; const isHitClipPath = this.isHit( clipPath, position, diff --git a/packages/g-plugin-canvas-renderer/src/CanvasRendererPlugin.ts b/packages/g-plugin-canvas-renderer/src/CanvasRendererPlugin.ts index d6460cabb..98ed4375e 100644 --- a/packages/g-plugin-canvas-renderer/src/CanvasRendererPlugin.ts +++ b/packages/g-plugin-canvas-renderer/src/CanvasRendererPlugin.ts @@ -1,8 +1,6 @@ import type { - CSSRGB, DisplayObject, FederatedEvent, - ParsedBaseStyleProps, RBushNodeAABB, RenderingPlugin, RBush, @@ -19,10 +17,9 @@ import { Shape, Node, } from '@antv/g-lite'; -import type { PathGenerator } from '@antv/g-plugin-canvas-path-generator'; -import { isNil } from '@antv/util'; import { mat4, vec3 } from 'gl-matrix'; import type { CanvasRendererPluginOptions } from './interfaces'; +import type { Plugin } from '.'; interface Rect { x: number; @@ -31,6 +28,12 @@ interface Rect { height: number; } +export interface RenderState { + restoreStack: DisplayObject[]; + prevObject: DisplayObject; + currentContext: Map; +} + /** * support 2 modes in rendering: * * immediate @@ -39,9 +42,9 @@ interface Rect { export class CanvasRendererPlugin implements RenderingPlugin { static tag = 'CanvasRenderer'; - private context: RenderingPluginContext; + private context: Plugin['context']; - private pathGeneratorFactory: Record>; + private pathGeneratorFactory: Plugin['context']['pathGeneratorFactory']; /** * RBush used in dirty rectangle rendering @@ -56,10 +59,11 @@ export class CanvasRendererPlugin implements RenderingPlugin { private renderQueue: DisplayObject[] = []; - /** - * This stack is only used by clipPath for now. - */ - private restoreStack: DisplayObject[] = []; + #renderState: RenderState = { + restoreStack: [], + prevObject: null, + currentContext: new Map(), + }; private clearFullScreenLastFrame = false; private clearFullScreen = false; @@ -76,7 +80,7 @@ export class CanvasRendererPlugin implements RenderingPlugin { private vec3d = vec3.create(); apply(context: RenderingPluginContext, runtime: GlobalRuntime) { - this.context = context; + this.context = context as unknown as Plugin['context']; const { config, @@ -86,9 +90,16 @@ export class CanvasRendererPlugin implements RenderingPlugin { rBushRoot, // @ts-ignore pathGeneratorFactory, - } = context; + } = this.context; + let enableRenderingOptimization = + config.renderer.getConfig().enableRenderingOptimization; + + config.renderer.getConfig().enableDirtyCheck = false; + config.renderer.getConfig().enableDirtyRectangleRendering = false; + this.rBush = rBushRoot; this.pathGeneratorFactory = pathGeneratorFactory; + const contextService = context.contextService as ContextService; @@ -141,7 +152,11 @@ export class CanvasRendererPlugin implements RenderingPlugin { canvas.removeEventListener(ElementEvent.CULLED, handleCulled); this.renderQueue = []; this.removedRBushNodeAABBs = []; - this.restoreStack = []; + this.#renderState = { + restoreStack: [], + prevObject: null, + currentContext: null, + }; }); renderingService.hooks.beginFrame.tap(CanvasRendererPlugin.tag, () => { @@ -164,9 +179,12 @@ export class CanvasRendererPlugin implements RenderingPlugin { ratio > dirtyObjectRatioThreshold); if (context) { - context.resetTransform - ? context.resetTransform() - : context.setTransform(1, 0, 0, 1, 0, 0); + if (typeof context.resetTransform === 'function') { + context.resetTransform(); + } else { + context.setTransform(1, 0, 0, 1, 0, 0); + } + if (this.clearFullScreen) { this.clearRect( context, @@ -180,26 +198,47 @@ export class CanvasRendererPlugin implements RenderingPlugin { } }); + /** + * render objects by z-index + * + * - The level of the child node will be affected by the level of the parent node + */ const renderByZIndex = ( object: DisplayObject, context: CanvasRenderingContext2D, ) => { - if (object.isVisible() && !object.isCulled()) { - this.renderDisplayObject( - object, - context, - this.context, - this.restoreStack, - runtime, - ); - } - - const sorted = object.sortable.sorted || object.childNodes; + const stack = [object]; + + while (stack.length > 0) { + const currentObject = stack.pop(); + + if (currentObject.isVisible() && !currentObject.isCulled()) { + if (enableRenderingOptimization) { + this.renderDisplayObjectOptimized( + currentObject, + context, + this.context, + this.#renderState, + runtime, + ); + } else { + this.renderDisplayObject( + currentObject, + context, + this.context, + this.#renderState, + runtime, + ); + } + } - // should account for z-index - sorted.forEach((child: DisplayObject) => { - renderByZIndex(child, context); - }); + const objects = + currentObject.sortable.sorted || currentObject.childNodes; + // should account for z-index + for (let i = objects.length - 1; i >= 0; i--) { + stack.push(objects[i] as unknown as DisplayObject); + } + } }; // render at the end of frame @@ -210,6 +249,16 @@ export class CanvasRendererPlugin implements RenderingPlugin { return; } + enableRenderingOptimization = + config.renderer.getConfig().enableRenderingOptimization; + + // init + this.#renderState = { + restoreStack: [], + prevObject: null, + currentContext: this.#renderState.currentContext, + }; + this.#renderState.currentContext.clear(); this.clearFullScreenLastFrame = false; const context = contextService.getContext(); @@ -219,8 +268,17 @@ export class CanvasRendererPlugin implements RenderingPlugin { mat4.multiply(this.vpMatrix, this.dprMatrix, camera.getOrthoMatrix()); if (this.clearFullScreen) { - // console.log('canvas renderer fcp...', renderingContext.root.childNodes); - renderByZIndex(renderingContext.root, context); + // console.time('renderByZIndex'); + if (enableRenderingOptimization) { + context.save(); + renderByZIndex(renderingContext.root, context); + context.restore(); + } else { + renderByZIndex(renderingContext.root, context); + } + // console.timeEnd('renderByZIndex'); + + this.removedRBushNodeAABBs = []; } else { // console.log('canvas renderer next...', this.renderQueue); // merge removed AABB @@ -320,7 +378,7 @@ export class CanvasRendererPlugin implements RenderingPlugin { object, context, this.context, - this.restoreStack, + this.#renderState, runtime, ); } @@ -338,11 +396,11 @@ export class CanvasRendererPlugin implements RenderingPlugin { } // pop restore stack, eg. root -> parent -> child - this.restoreStack.forEach(() => { + this.#renderState.restoreStack.forEach(() => { context.restore(); }); // clear restore stack - this.restoreStack = []; + this.#renderState.restoreStack = []; }); renderingService.hooks.render.tap( @@ -372,20 +430,132 @@ export class CanvasRendererPlugin implements RenderingPlugin { } } - renderDisplayObject( + renderDisplayObjectOptimized( object: DisplayObject, context: CanvasRenderingContext2D, canvasContext: CanvasContext, - restoreStack: DisplayObject[], + renderState: RenderState, runtime: GlobalRuntime, ) { - const { nodeName } = object; + const nodeName = object.nodeName as Shape; + let updateTransform = false; + let clipDraw = false; - // console.log('canvas render:', object); + // @ts-ignore + const styleRenderer = this.context.styleRendererFactory[nodeName]; + const generatePath = this.pathGeneratorFactory[nodeName]; + + // clip path + const { clipPath } = object.parsedStyle; + if (clipPath) { + updateTransform = + !renderState.prevObject || + !mat4.exactEquals( + clipPath.getWorldTransform(), + renderState.prevObject.getWorldTransform(), + ); + + if (updateTransform) { + this.applyWorldTransform(context, clipPath); + renderState.prevObject = null; + } + + // generate path in local space + const generatePath = + this.pathGeneratorFactory[clipPath.nodeName as Shape]; + if (generatePath) { + context.save(); + clipDraw = true; + + context.beginPath(); + generatePath(context, clipPath.parsedStyle); + context.closePath(); + context.clip(); + } + } + + // fill & stroke + + if (styleRenderer) { + updateTransform = + !renderState.prevObject || + !mat4.exactEquals( + object.getWorldTransform(), + renderState.prevObject.getWorldTransform(), + ); + + if (updateTransform) { + this.applyWorldTransform(context, object); + } + + let forceUpdateStyle = !renderState.prevObject; + if (!forceUpdateStyle) { + const prevNodeName = renderState.prevObject.nodeName as Shape; + + if (nodeName === Shape.TEXT) { + forceUpdateStyle = prevNodeName !== Shape.TEXT; + } else if (nodeName === Shape.IMAGE) { + forceUpdateStyle = prevNodeName !== Shape.IMAGE; + } else { + forceUpdateStyle = + prevNodeName === Shape.TEXT || prevNodeName === Shape.IMAGE; + } + } + + styleRenderer.applyStyleToContext( + context, + object, + forceUpdateStyle, + renderState, + ); + + renderState.prevObject = object; + } + + if (generatePath) { + context.beginPath(); + generatePath(context, object.parsedStyle); + if ( + nodeName !== Shape.LINE && + nodeName !== Shape.PATH && + nodeName !== Shape.POLYLINE + ) { + context.closePath(); + } + } + + // fill & stroke + if (styleRenderer) { + styleRenderer.drawToContext( + context, + object, + this.#renderState, + this, + runtime, + ); + } + + if (clipDraw) { + context.restore(); + } + + // finish rendering, clear dirty flag + object.renderable.dirty = false; + } + + renderDisplayObject( + object: DisplayObject, + context: CanvasRenderingContext2D, + canvasContext: CanvasContext, + renderState: RenderState, + runtime: GlobalRuntime, + ) { + const nodeName = object.nodeName as Shape; // restore to its ancestor - const parent = restoreStack[restoreStack.length - 1]; + const parent = + renderState.restoreStack[renderState.restoreStack.length - 1]; if ( parent && !( @@ -393,7 +563,7 @@ export class CanvasRendererPlugin implements RenderingPlugin { ) ) { context.restore(); - restoreStack.pop(); + renderState.restoreStack.pop(); } // @ts-ignore @@ -401,17 +571,18 @@ export class CanvasRendererPlugin implements RenderingPlugin { const generatePath = this.pathGeneratorFactory[nodeName]; // clip path - const { clipPath } = object.parsedStyle as ParsedBaseStyleProps; + const { clipPath } = object.parsedStyle; if (clipPath) { this.applyWorldTransform(context, clipPath); // generate path in local space - const generatePath = this.pathGeneratorFactory[clipPath.nodeName]; + const generatePath = + this.pathGeneratorFactory[clipPath.nodeName as Shape]; if (generatePath) { context.save(); // save clip - restoreStack.push(object); + renderState.restoreStack.push(object); context.beginPath(); generatePath(context, clipPath.parsedStyle); @@ -428,16 +599,16 @@ export class CanvasRendererPlugin implements RenderingPlugin { context.save(); // apply attributes to context - this.applyAttributesToContext(context, object); + styleRenderer.applyAttributesToContext(context, object); } if (generatePath) { context.beginPath(); generatePath(context, object.parsedStyle); if ( - object.nodeName !== Shape.LINE && - object.nodeName !== Shape.PATH && - object.nodeName !== Shape.POLYLINE + nodeName !== Shape.LINE && + nodeName !== Shape.PATH && + nodeName !== Shape.POLYLINE ) { context.closePath(); } @@ -528,42 +699,6 @@ export class CanvasRendererPlugin implements RenderingPlugin { } } - /** - * TODO: batch the same global attributes - */ - private applyAttributesToContext( - context: CanvasRenderingContext2D, - object: DisplayObject, - ) { - const { stroke, fill, opacity, lineDash, lineDashOffset } = - object.parsedStyle as ParsedBaseStyleProps; - // @see https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/setLineDash - if (lineDash) { - context.setLineDash(lineDash); - } - - // @see https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/lineDashOffset - if (!isNil(lineDashOffset)) { - context.lineDashOffset = lineDashOffset; - } - - if (!isNil(opacity)) { - context.globalAlpha *= opacity; - } - - if ( - !isNil(stroke) && - !Array.isArray(stroke) && - !(stroke as CSSRGB).isNone - ) { - context.strokeStyle = object.attributes.stroke; - } - - if (!isNil(fill) && !Array.isArray(fill) && !(fill as CSSRGB).isNone) { - context.fillStyle = object.attributes.fill; - } - } - private applyWorldTransform( context: CanvasRenderingContext2D, object: DisplayObject, diff --git a/packages/g-plugin-canvas-renderer/src/index.ts b/packages/g-plugin-canvas-renderer/src/index.ts index 19511fc97..9af7608c0 100644 --- a/packages/g-plugin-canvas-renderer/src/index.ts +++ b/packages/g-plugin-canvas-renderer/src/index.ts @@ -1,9 +1,12 @@ import { AbstractRendererPlugin, Shape } from '@antv/g-lite'; +import type { PathGenerator } from '@antv/g-plugin-canvas-path-generator'; import { CanvasRendererPlugin } from './CanvasRendererPlugin'; -import type { StyleRenderer } from './shapes/styles'; -import { DefaultRenderer } from './shapes/styles/Default'; -import { ImageRenderer } from './shapes/styles/Image'; -import { TextRenderer } from './shapes/styles/Text'; +import { + type StyleRenderer, + DefaultRenderer, + TextRenderer, + ImageRenderer, +} from './shapes/styles'; import type { CanvasRendererPluginOptions } from './interfaces'; export * from './shapes/styles'; @@ -11,6 +14,7 @@ export * from './shapes/styles'; export class Plugin extends AbstractRendererPlugin<{ defaultStyleRendererFactory: Record; styleRendererFactory: Record; + pathGeneratorFactory: Record>; }> { name = 'canvas-renderer'; @@ -43,6 +47,7 @@ export class Plugin extends AbstractRendererPlugin<{ [Shape.GROUP]: undefined, [Shape.HTML]: undefined, [Shape.MESH]: undefined, + [Shape.FRAGMENT]: undefined, }; this.context.defaultStyleRendererFactory = defaultStyleRendererFactory; diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Circle.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Circle.ts deleted file mode 100644 index 037910f8b..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Circle.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class CircleRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Default.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Default.ts index babd1c1e6..092b9e40d 100644 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Default.ts +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/Default.ts @@ -4,22 +4,50 @@ import { CSSRGB, DisplayObject, GlobalRuntime, - LinearGradient, ParsedBaseStyleProps, Pattern, - RadialGradient, - Rect, - GradientType, isPattern, Shape, } from '@antv/g-lite'; import type { ImagePool } from '@antv/g-plugin-image-loader'; import { isNil } from '@antv/util'; import { CanvasRendererPlugin } from '../../CanvasRendererPlugin'; -import type { StyleRenderer } from './interfaces'; +import { OptimizedDefaultRenderer, DEFAULT_STYLE } from './OptimizedDefault'; +import { getColor, getPattern } from './helper'; -export class DefaultRenderer implements StyleRenderer { - constructor(private imagePool: ImagePool) {} +export class DefaultRenderer extends OptimizedDefaultRenderer { + applyAttributesToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + ) { + const { stroke, fill, opacity, lineDash, lineDashOffset } = + object.parsedStyle; + // @see https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/setLineDash + if (lineDash) { + context.setLineDash(lineDash); + } + + // @see https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/lineDashOffset + if (!isNil(lineDashOffset)) { + context.lineDashOffset = lineDashOffset; + } + + if (!isNil(opacity)) { + context.globalAlpha *= opacity; + } + + if ( + !isNil(stroke) && + !Array.isArray(stroke) && + !(stroke as CSSRGB).isNone + ) { + context.strokeStyle = object.attributes.stroke as string; + } + + if (!isNil(fill) && !Array.isArray(fill) && !(fill as CSSRGB).isNone) { + context.fillStyle = object.attributes.fill as string; + } + } render( context: CanvasRenderingContext2D, @@ -51,7 +79,7 @@ export class DefaultRenderer implements StyleRenderer { const isFillTransparent = (fill as CSSRGB)?.alpha === 0; const hasFilter = !!(filter && filter.length); const hasShadow = !isNil(shadowColor) && shadowBlur > 0; - const { nodeName } = object; + const nodeName = object.nodeName as Shape; const isInnerShadow = shadowType === 'inner'; const shouldDrawShadowWithStroke = hasStroke && @@ -116,7 +144,8 @@ export class DefaultRenderer implements StyleRenderer { runtime, this.imagePool, ); - context.globalCompositeOperation = 'source-over'; + context.globalCompositeOperation = + DEFAULT_STYLE.globalCompositeOperation; this.clearShadowAndFilter(context, hasFilter, true); } } @@ -163,7 +192,7 @@ export function setShadowAndFilter( hasShadow: boolean, ) { const { filter, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY } = - object.parsedStyle as ParsedBaseStyleProps; + object.parsedStyle; if (filter && filter.length) { // use raw filter string @@ -178,100 +207,6 @@ export function setShadowAndFilter( } } -export function getPattern( - pattern: Pattern, - object: DisplayObject, - context: CanvasRenderingContext2D, - canvasContext: CanvasContext, - plugin: CanvasRendererPlugin, - runtime: GlobalRuntime, - imagePool: ImagePool, -): CanvasPattern { - let $offscreenCanvas: HTMLCanvasElement; - let dpr: number; - if ((pattern.image as Rect).nodeName === 'rect') { - const { width, height } = (pattern.image as Rect).parsedStyle; - dpr = canvasContext.contextService.getDPR(); - const { offscreenCanvas } = canvasContext.config; - $offscreenCanvas = runtime.offscreenCanvasCreator.getOrCreateCanvas( - offscreenCanvas, - ) as HTMLCanvasElement; - - $offscreenCanvas.width = width * dpr; - $offscreenCanvas.height = height * dpr; - - const offscreenCanvasContext = - runtime.offscreenCanvasCreator.getOrCreateContext( - offscreenCanvas, - ) as CanvasRenderingContext2D; - - const restoreStack = []; - - // offscreenCanvasContext.scale(1 / dpr, 1 / dpr); - - (pattern.image as Rect).forEach((object: DisplayObject) => { - plugin.renderDisplayObject( - object, - offscreenCanvasContext, - canvasContext, - restoreStack, - runtime, - ); - }); - - restoreStack.forEach(() => { - offscreenCanvasContext.restore(); - }); - } - - const canvasPattern = imagePool.getOrCreatePatternSync( - object, - pattern, - context, - $offscreenCanvas, - dpr, - object.getGeometryBounds().min, - () => { - // set dirty rectangle flag - object.renderable.dirty = true; - canvasContext.renderingService.dirtify(); - }, - ); - - return canvasPattern; -} - -export function getColor( - parsedColor: CSSGradientValue, - object: DisplayObject, - context: CanvasRenderingContext2D, - imagePool: ImagePool, -) { - let color: CanvasGradient | string; - - if ( - parsedColor.type === GradientType.LinearGradient || - parsedColor.type === GradientType.RadialGradient - ) { - const bounds = object.getGeometryBounds(); - const width = (bounds && bounds.halfExtents[0] * 2) || 1; - const height = (bounds && bounds.halfExtents[1] * 2) || 1; - const min = (bounds && bounds.min) || [0, 0]; - color = imagePool.getOrCreateGradient( - { - type: parsedColor.type, - ...(parsedColor.value as LinearGradient & RadialGradient), - min: min as [number, number], - width, - height, - }, - context, - ); - } - - return color; -} - export function applyFill( context: CanvasRenderingContext2D, object: DisplayObject, diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Ellipse.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Ellipse.ts deleted file mode 100644 index 94b0f6748..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Ellipse.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class EllipseRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Image.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Image.ts index b7a43cfa8..74a9aa34c 100644 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Image.ts +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/Image.ts @@ -1,14 +1,19 @@ -import type { DisplayObject, ParsedImageStyleProps } from '@antv/g-lite'; +import type { + DisplayObject, + ParsedImageStyleProps, + GlobalRuntime, +} from '@antv/g-lite'; import { ImagePool, type ImageCache } from '@antv/g-plugin-image-loader'; import { isNil } from '@antv/util'; import { mat4 } from 'gl-matrix'; -import { setShadowAndFilter } from './Default'; -import type { StyleRenderer } from './interfaces'; import { transformRect, calculateOverlapRect } from '../../utils/math'; +import { DefaultRenderer, setShadowAndFilter } from './Default'; +import { + CanvasRendererPlugin, + type RenderState, +} from '../../CanvasRendererPlugin'; -export class ImageRenderer implements StyleRenderer { - constructor(private imagePool: ImagePool) {} - +export class ImageRenderer extends DefaultRenderer { static renderFull( context: CanvasRenderingContext2D, parsedStyle: ParsedImageStyleProps, @@ -27,7 +32,7 @@ export class ImageRenderer implements StyleRenderer { ); } - #renderDownSampled( + private renderDownSampled( context: CanvasRenderingContext2D, parsedStyle: ParsedImageStyleProps, object: DisplayObject, @@ -63,7 +68,7 @@ export class ImageRenderer implements StyleRenderer { ); } - #renderTile( + renderTile( context: CanvasRenderingContext2D, parsedStyle: ParsedImageStyleProps, object: DisplayObject, @@ -209,7 +214,7 @@ export class ImageRenderer implements StyleRenderer { const sizeOfOrigin = imageRect[2] / imageCache.size[0]; if (sizeOfOrigin < (imageCache.downSamplingRate || 0.5)) { - this.#renderDownSampled(context, parsedStyle, object, { + this.renderDownSampled(context, parsedStyle, object, { src, imageCache, drawRect: [x, y, iw, ih], @@ -227,7 +232,7 @@ export class ImageRenderer implements StyleRenderer { return; } - this.#renderTile(context, parsedStyle, object, { + this.renderTile(context, parsedStyle, object, { src, imageCache, imageRect, @@ -235,4 +240,16 @@ export class ImageRenderer implements StyleRenderer { }); } catch {} } + + // --- + + drawToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) { + this.render(context, object.parsedStyle as ParsedImageStyleProps, object); + } } diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Line.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Line.ts deleted file mode 100644 index 976c7c7ea..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Line.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class LineRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/OptimizedDefault.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/OptimizedDefault.ts new file mode 100644 index 000000000..7eb26d09e --- /dev/null +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/OptimizedDefault.ts @@ -0,0 +1,595 @@ +import { + CanvasContext, + CSSRGB, + DisplayObject, + GlobalRuntime, + ParsedBaseStyleProps, + isPattern, + Shape, +} from '@antv/g-lite'; +import type { ImagePool } from '@antv/g-plugin-image-loader'; +import { isNil } from '@antv/util'; +import { + CanvasRendererPlugin, + type RenderState, +} from '../../CanvasRendererPlugin'; +import type { StyleRenderer } from './interfaces'; +import { getColor, getPattern } from './helper'; + +const SHADOW_NUMBER_STYLE = [ + 'shadowBlur', + 'shadowOffsetX', + 'shadowOffsetY', +] as const; +const STROKE_STYLE = ['lineCap', 'lineJoin', 'miterLimit'] as const; +export const DEFAULT_STYLE = { + // common + globalAlpha: 1, + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + shadowColor: '#000', + filter: 'none' as const, + globalCompositeOperation: 'source-over' as const, + + // stroke/fill + strokeStyle: '#000', + strokeOpacity: 1, + lineWidth: 1, + lineDash: [], + lineDashOffset: 0, + lineCap: 'butt' as const, + lineJoin: 'miter' as const, + miterLimit: 10, + fillStyle: '#000', + fillOpacity: 1, + + // image +}; + +const defaultParsedStyle = {} as ParsedBaseStyleProps; + +/** + * Updating the canvas context is an expensive operation. The state of the context is cached and the actual update operation is performed only when the cache is not hit. + * + * In any case, the previous value is returned, which is convenient for temporarily updating the context and restoring it later. + */ +function updateContextIfNotHitCache< + K extends keyof CanvasRenderingContext2D | 'lineDash', + V = unknown, +>(context: CanvasRenderingContext2D, key: K, value: V, cache: Map) { + const prevValue = ( + cache.has(key) + ? cache.get(key) + : DEFAULT_STYLE[key as keyof typeof DEFAULT_STYLE] + ) as V; + + if (prevValue !== value) { + // console.log('not hit cache', key, value, prevValue, cache); + if (key === 'lineDash') { + context.setLineDash(value as number[]); + } else { + // @ts-ignore + context[key] = value; + } + cache.set(key, value); + } + + return prevValue; +} + +export class OptimizedDefaultRenderer implements StyleRenderer { + constructor(public imagePool: ImagePool) {} + + applyAttributesToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + ) {} + + render( + context: CanvasRenderingContext2D, + parsedStyle: ParsedBaseStyleProps, + object: DisplayObject, + canvasContext: CanvasContext, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) {} + + // #region common style + private applyCommonStyleToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + forceUpdate: boolean, + renderState: RenderState, + ) { + // const dpr = object.ownerDocument.defaultView.getContextService().getDPR(); + const prevStyle = forceUpdate + ? defaultParsedStyle + : renderState.prevObject.parsedStyle; + const style = object.parsedStyle; + + if (forceUpdate || style.opacity !== prevStyle.opacity) { + updateContextIfNotHitCache( + context, + 'globalAlpha', + !isNil(style.opacity) ? style.opacity : DEFAULT_STYLE.globalAlpha, + renderState.currentContext, + ); + } + + // TODO blend prop + // @ts-ignore + if (forceUpdate || style.blend !== prevStyle.blend) { + updateContextIfNotHitCache( + context, + 'globalCompositeOperation', + // @ts-ignore + !isNil(style.blend) + ? // @ts-ignore + style.blend + : DEFAULT_STYLE.globalCompositeOperation, + renderState.currentContext, + ); + } + } + // #endregion common style + + // #region stroke/fill style + private applyStrokeFillStyleToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + forceUpdate: boolean, + renderState: RenderState, + ) { + const prevStyle = forceUpdate + ? defaultParsedStyle + : renderState.prevObject.parsedStyle; + const style = object.parsedStyle; + const { lineWidth = DEFAULT_STYLE.lineWidth } = style; + const hasFill = style.fill && !(style.fill as CSSRGB).isNone; + const hasStroke = + style.stroke && !(style.stroke as CSSRGB).isNone && lineWidth > 0; + + if (hasStroke) { + if ( + forceUpdate || + object.attributes.stroke !== renderState.prevObject.attributes.stroke + ) { + const value = + !isNil(style.stroke) && + !Array.isArray(style.stroke) && + !(style.stroke as CSSRGB).isNone + ? object.attributes.stroke + : DEFAULT_STYLE.strokeStyle; + + updateContextIfNotHitCache( + context, + 'strokeStyle', + value, + renderState.currentContext, + ); + } + + if (forceUpdate || style.lineWidth !== prevStyle.lineWidth) { + updateContextIfNotHitCache( + context, + 'lineWidth', + !isNil(style.lineWidth) ? style.lineWidth : DEFAULT_STYLE.lineWidth, + renderState.currentContext, + ); + } + + if (forceUpdate || style.lineDash !== prevStyle.lineDash) { + updateContextIfNotHitCache( + context, + 'lineDash', + style.lineDash || DEFAULT_STYLE.lineDash, + renderState.currentContext, + ); + } + + if (forceUpdate || style.lineDashOffset !== prevStyle.lineDashOffset) { + updateContextIfNotHitCache( + context, + 'lineDashOffset', + !isNil(style.lineDashOffset) + ? style.lineDashOffset + : DEFAULT_STYLE.lineDashOffset, + renderState.currentContext, + ); + } + + for (let i = 0; i < STROKE_STYLE.length; i++) { + const styleName = STROKE_STYLE[i]; + if (forceUpdate || style[styleName] !== prevStyle[styleName]) { + updateContextIfNotHitCache( + context, + styleName, + !isNil(style[styleName]) + ? style[styleName] + : DEFAULT_STYLE[styleName], + renderState.currentContext, + ); + } + } + } + + if ( + hasFill && + (forceUpdate || + object.attributes.fill !== renderState.prevObject.attributes.fill) + ) { + const value = + !isNil(style.fill) && + !Array.isArray(style.fill) && + !(style.fill as CSSRGB).isNone + ? object.attributes.fill + : DEFAULT_STYLE.fillStyle; + + updateContextIfNotHitCache( + context, + 'fillStyle', + value, + renderState.currentContext, + ); + } + } + // #endregion stroke/fill style + + applyStyleToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + forceUpdate: boolean, + renderState: RenderState, + ) { + const nodeName = object.nodeName as Shape; + + this.applyCommonStyleToContext(context, object, forceUpdate, renderState); + + if (nodeName === Shape.IMAGE) { + // + } else { + this.applyStrokeFillStyleToContext( + context, + object, + forceUpdate, + renderState, + ); + } + } + + applyShadowAndFilterStyleToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + hasShadow: boolean, + renderState: RenderState, + ) { + const style = object.parsedStyle; + + if (hasShadow) { + updateContextIfNotHitCache( + context, + 'shadowColor', + style.shadowColor.toString(), + renderState.currentContext, + ); + for (let i = 0; i < SHADOW_NUMBER_STYLE.length; i++) { + const styleName = SHADOW_NUMBER_STYLE[i]; + updateContextIfNotHitCache( + context, + styleName, + style[styleName] || DEFAULT_STYLE[styleName], + renderState.currentContext, + ); + } + } + + if (style.filter && style.filter.length) { + updateContextIfNotHitCache( + context, + 'filter', + // use raw filter string + object.attributes.filter, + renderState.currentContext, + ); + } + } + + clearShadowAndFilterStyleForContext( + context: CanvasRenderingContext2D, + hasShadow: boolean, + hasFilter: boolean, + renderState: RenderState, + onlyClearShadowFilter = false, + ) { + if (hasShadow) { + updateContextIfNotHitCache( + context, + 'shadowColor', + DEFAULT_STYLE.shadowColor, + renderState.currentContext, + ); + for (let i = 0; i < SHADOW_NUMBER_STYLE.length; i++) { + const styleName = SHADOW_NUMBER_STYLE[i]; + updateContextIfNotHitCache( + context, + styleName, + DEFAULT_STYLE[styleName], + renderState.currentContext, + ); + } + } + + if (hasFilter) { + if (hasShadow && onlyClearShadowFilter) { + // save drop-shadow filter + const oldFilter = context.filter; + if (!isNil(oldFilter) && oldFilter.indexOf('drop-shadow') > -1) { + updateContextIfNotHitCache( + context, + 'filter', + oldFilter.replace(/drop-shadow\([^)]*\)/, '').trim() || + DEFAULT_STYLE.filter, + renderState.currentContext, + ); + } + } else { + updateContextIfNotHitCache( + context, + 'filter', + DEFAULT_STYLE.filter, + renderState.currentContext, + ); + } + } + } + + fillToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) { + const { fill, fillRule } = object.parsedStyle; + let resetStyle = null as unknown; + + if (Array.isArray(fill) && fill.length > 0) { + fill.forEach((gradient) => { + const prevStyle = updateContextIfNotHitCache( + context, + 'fillStyle', + getColor(gradient, object, context, this.imagePool), + renderState.currentContext, + ); + resetStyle = resetStyle ?? prevStyle; + + if (fillRule) { + context.fill(fillRule); + } else { + context.fill(); + } + }); + } else { + if (isPattern(fill)) { + const pattern = getPattern( + fill, + object, + context, + object.ownerDocument.defaultView.context, + plugin, + runtime, + this.imagePool, + ); + if (pattern) { + context.fillStyle = pattern; + resetStyle = true; + } + } + + if (fillRule) { + context.fill(fillRule); + } else { + context.fill(); + } + } + + if (resetStyle !== null) { + updateContextIfNotHitCache( + context, + 'fillStyle', + resetStyle, + renderState.currentContext, + ); + } + } + + strokeToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) { + const { stroke } = object.parsedStyle; + let resetStyle = null as unknown; + + if (Array.isArray(stroke) && stroke.length > 0) { + stroke.forEach((gradient) => { + const prevStyle = updateContextIfNotHitCache( + context, + 'strokeStyle', + getColor(gradient, object, context, this.imagePool), + renderState.currentContext, + ); + resetStyle = resetStyle ?? prevStyle; + + context.stroke(); + }); + } else { + if (isPattern(stroke)) { + const pattern = getPattern( + stroke, + object, + context, + object.ownerDocument.defaultView.context, + plugin, + runtime, + this.imagePool, + ); + if (pattern) { + const prevStyle = updateContextIfNotHitCache( + context, + 'strokeStyle', + pattern, + renderState.currentContext, + ); + resetStyle = resetStyle ?? prevStyle; + } + } + + context.stroke(); + } + + if (resetStyle !== null) { + updateContextIfNotHitCache( + context, + 'strokeStyle', + resetStyle, + renderState.currentContext, + ); + } + } + + drawToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) { + const nodeName = object.nodeName as Shape; + const style = object.parsedStyle; + const { + opacity = DEFAULT_STYLE.globalAlpha, + fillOpacity = DEFAULT_STYLE.fillOpacity, + strokeOpacity = DEFAULT_STYLE.strokeOpacity, + lineWidth = DEFAULT_STYLE.lineWidth, + } = style; + + const hasFill = style.fill && !(style.fill as CSSRGB).isNone; + const hasStroke = + style.stroke && !(style.stroke as CSSRGB).isNone && lineWidth > 0; + + if (!hasFill && !hasStroke) { + return; + } + + const hasShadow = !isNil(style.shadowColor) && style.shadowBlur > 0; + const isInnerShadow = style.shadowType === 'inner'; + const isFillTransparent = (style.fill as CSSRGB)?.alpha === 0; + const hasFilter = !!(style.filter && style.filter.length); + // Shadows can only be applied to fill() or stroke(), the default is fill() + const shouldDrawShadowWithStroke = + hasShadow && + hasStroke && + (nodeName === Shape.PATH || + nodeName === Shape.LINE || + nodeName === Shape.POLYLINE || + isFillTransparent || + isInnerShadow); + + // TODO https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/paint-order + + let originGlobalAlpha: number | null = null; + + if (hasFill) { + if (!shouldDrawShadowWithStroke) { + this.applyShadowAndFilterStyleToContext( + context, + object, + hasShadow, + renderState, + ); + } + + const updateOpacity = opacity * fillOpacity; + + originGlobalAlpha = updateContextIfNotHitCache( + context, + 'globalAlpha', + updateOpacity, + renderState.currentContext, + ); + + this.fillToContext(context, object, renderState, plugin, runtime); + + if (!shouldDrawShadowWithStroke) { + this.clearShadowAndFilterStyleForContext( + context, + hasShadow, + hasFilter, + renderState, + ); + } + } + if (hasStroke) { + let clearShadowAndFilter = false; + const updateOpacity = opacity * strokeOpacity; + const prevOpacity = updateContextIfNotHitCache( + context, + 'globalAlpha', + updateOpacity, + renderState.currentContext, + ); + originGlobalAlpha = hasFill ? originGlobalAlpha : prevOpacity; + + if (shouldDrawShadowWithStroke) { + this.applyShadowAndFilterStyleToContext( + context, + object, + hasShadow, + renderState, + ); + clearShadowAndFilter = true; + + if (isInnerShadow) { + const originBlend = context.globalCompositeOperation; + context.globalCompositeOperation = 'source-atop'; + + this.strokeToContext(context, object, renderState, plugin, runtime); + + context.globalCompositeOperation = originBlend; + this.clearShadowAndFilterStyleForContext( + context, + hasShadow, + hasFilter, + renderState, + true, + ); + } + } + + this.strokeToContext(context, object, renderState, plugin, runtime); + + if (clearShadowAndFilter) { + this.clearShadowAndFilterStyleForContext( + context, + hasShadow, + hasFilter, + renderState, + ); + } + } + + // clear + if (originGlobalAlpha !== null) { + updateContextIfNotHitCache( + context, + 'globalAlpha', + originGlobalAlpha, + renderState.currentContext, + ); + } + } +} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Path.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Path.ts deleted file mode 100644 index b74741add..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Path.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class PathRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Polygon.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Polygon.ts deleted file mode 100644 index 31d96b148..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Polygon.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class PolygonRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Polyline.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Polyline.ts deleted file mode 100644 index 901959c34..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Polyline.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class PolylineRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Rect.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Rect.ts deleted file mode 100644 index b11da2682..000000000 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Rect.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { DefaultRenderer } from './Default'; - -export class RectRenderer extends DefaultRenderer {} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/Text.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/Text.ts index 37174a0f4..94c927ef8 100644 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/Text.ts +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/Text.ts @@ -9,14 +9,18 @@ import { Pattern, } from '@antv/g-lite'; import { isNil } from '@antv/util'; -import { ImagePool } from '@antv/g-plugin-image-loader'; -import { applyFill, applyStroke, setShadowAndFilter } from './Default'; -import type { StyleRenderer } from './interfaces'; -import { CanvasRendererPlugin } from '../../CanvasRendererPlugin'; - -export class TextRenderer implements StyleRenderer { - constructor(private imagePool: ImagePool) {} +import { + DefaultRenderer, + applyFill, + applyStroke, + setShadowAndFilter, +} from './Default'; +import { + CanvasRendererPlugin, + type RenderState, +} from '../../CanvasRendererPlugin'; +export class TextRenderer extends DefaultRenderer { render( context: CanvasRenderingContext2D, parsedStyle: ParsedTextStyleProps, @@ -330,4 +334,23 @@ export class TextRenderer implements StyleRenderer { context.globalAlpha = currentGlobalAlpha; } } + + // --- + + drawToContext( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) { + this.render( + context, + object.parsedStyle as ParsedTextStyleProps, + object, + object.ownerDocument.defaultView.context, + plugin, + runtime, + ); + } } diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/helper.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/helper.ts new file mode 100644 index 000000000..b932588ec --- /dev/null +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/helper.ts @@ -0,0 +1,114 @@ +import { + CanvasContext, + CSSGradientValue, + DisplayObject, + GlobalRuntime, + LinearGradient, + Pattern, + RadialGradient, + Rect, + GradientType, +} from '@antv/g-lite'; +import type { ImagePool } from '@antv/g-plugin-image-loader'; +import { + CanvasRendererPlugin, + type RenderState, +} from '../../CanvasRendererPlugin'; + +export function getPattern( + pattern: Pattern, + object: DisplayObject, + context: CanvasRenderingContext2D, + canvasContext: CanvasContext, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + imagePool: ImagePool, +): CanvasPattern { + let $offscreenCanvas: HTMLCanvasElement; + let dpr: number; + if ((pattern.image as Rect).nodeName === 'rect') { + const { width, height } = (pattern.image as Rect).parsedStyle; + dpr = canvasContext.contextService.getDPR(); + const { offscreenCanvas } = canvasContext.config; + $offscreenCanvas = runtime.offscreenCanvasCreator.getOrCreateCanvas( + offscreenCanvas, + ) as HTMLCanvasElement; + + $offscreenCanvas.width = width * dpr; + $offscreenCanvas.height = height * dpr; + + const offscreenCanvasContext = + runtime.offscreenCanvasCreator.getOrCreateContext( + offscreenCanvas, + ) as CanvasRenderingContext2D; + + const renderState: RenderState = { + restoreStack: [], + prevObject: null, + currentContext: new Map(), + }; + + // offscreenCanvasContext.scale(1 / dpr, 1 / dpr); + + (pattern.image as Rect).forEach((object: DisplayObject) => { + plugin.renderDisplayObject( + object, + offscreenCanvasContext, + canvasContext, + renderState, + runtime, + ); + }); + + renderState.restoreStack.forEach(() => { + offscreenCanvasContext.restore(); + }); + } + + const canvasPattern = imagePool.getOrCreatePatternSync( + object, + pattern, + context, + $offscreenCanvas, + dpr, + object.getGeometryBounds().min, + () => { + // set dirty rectangle flag + object.renderable.dirty = true; + canvasContext.renderingService.dirtify(); + }, + ); + + return canvasPattern; +} + +export function getColor( + parsedColor: CSSGradientValue, + object: DisplayObject, + context: CanvasRenderingContext2D, + imagePool: ImagePool, +) { + let color: CanvasGradient | string; + + if ( + parsedColor.type === GradientType.LinearGradient || + parsedColor.type === GradientType.RadialGradient + ) { + const bounds = object.getGeometryBounds(); + const width = (bounds && bounds.halfExtents[0] * 2) || 1; + const height = (bounds && bounds.halfExtents[1] * 2) || 1; + const min = (bounds && bounds.min) || [0, 0]; + color = imagePool.getOrCreateGradient( + { + type: parsedColor.type, + ...(parsedColor.value as LinearGradient & RadialGradient), + min: min as [number, number], + width, + height, + }, + context, + ); + } + + return color; +} diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/index.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/index.ts index d40ab8bdb..22ea7c970 100644 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/index.ts +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/index.ts @@ -1,10 +1,13 @@ +import { DefaultRenderer } from './Default'; + export * from './interfaces'; -export * from './Image'; -export * from './Text'; -export * from './Rect'; -export * from './Circle'; -export * from './Ellipse'; -export * from './Line'; -export * from './Polyline'; -export * from './Polygon'; -export * from './Path'; +export { DefaultRenderer }; +export { DefaultRenderer as RectRenderer }; +export { DefaultRenderer as CircleRenderer }; +export { DefaultRenderer as LineRenderer }; +export { DefaultRenderer as PolylineRenderer }; +export { DefaultRenderer as PolygonRenderer }; +export { DefaultRenderer as PathRenderer }; +export { DefaultRenderer as EllipseRenderer }; +export { ImageRenderer } from './Image'; +export { TextRenderer } from './Text'; diff --git a/packages/g-plugin-canvas-renderer/src/shapes/styles/interfaces.ts b/packages/g-plugin-canvas-renderer/src/shapes/styles/interfaces.ts index 7cb951b52..4ab4e8ffe 100644 --- a/packages/g-plugin-canvas-renderer/src/shapes/styles/interfaces.ts +++ b/packages/g-plugin-canvas-renderer/src/shapes/styles/interfaces.ts @@ -4,9 +4,34 @@ import type { GlobalRuntime, ParsedBaseStyleProps, } from '@antv/g-lite'; -import { CanvasRendererPlugin } from '../../CanvasRendererPlugin'; +import { + CanvasRendererPlugin, + type RenderState, +} from '../../CanvasRendererPlugin'; export interface StyleRenderer { + applyStyleToContext: ( + context: CanvasRenderingContext2D, + object: DisplayObject, + forceUpdate: boolean, + renderState: RenderState, + ) => void; + + drawToContext: ( + context: CanvasRenderingContext2D, + object: DisplayObject, + renderState: RenderState, + plugin: CanvasRendererPlugin, + runtime: GlobalRuntime, + ) => void; + + // --- + + applyAttributesToContext: ( + context: CanvasRenderingContext2D, + object: DisplayObject, + ) => void; + render: ( context: CanvasRenderingContext2D, parsedStyle: ParsedBaseStyleProps, diff --git a/packages/g-plugin-canvaskit-renderer/src/CanvaskitRendererPlugin.ts b/packages/g-plugin-canvaskit-renderer/src/CanvaskitRendererPlugin.ts index 3b279c9aa..69b4f5a83 100644 --- a/packages/g-plugin-canvaskit-renderer/src/CanvaskitRendererPlugin.ts +++ b/packages/g-plugin-canvaskit-renderer/src/CanvaskitRendererPlugin.ts @@ -8,7 +8,6 @@ import { GradientType, ICamera, LinearGradient, - ParsedBaseStyleProps, Path, Pattern, RadialGradient, @@ -523,7 +522,7 @@ export class CanvaskitRendererPlugin implements RenderingPlugin { shadowBlur, shadowColor, clipPath, - } = object.parsedStyle as ParsedBaseStyleProps; + } = object.parsedStyle; // apply clipPath if (clipPath) { diff --git a/packages/g-plugin-device-renderer/src/drawcalls/Instanced.ts b/packages/g-plugin-device-renderer/src/drawcalls/Instanced.ts index 49cbd675b..bcb058026 100644 --- a/packages/g-plugin-device-renderer/src/drawcalls/Instanced.ts +++ b/packages/g-plugin-device-renderer/src/drawcalls/Instanced.ts @@ -1,10 +1,4 @@ -import type { - CSSGradientValue, - DisplayObject, - ParsedBaseStyleProps, - Pattern, - Tuple4Number, -} from '@antv/g-lite'; +import type { DisplayObject, Tuple4Number } from '@antv/g-lite'; import { CSSRGB, isPattern, isCSSRGB, parseColor, Shape } from '@antv/g-lite'; import { mat4, vec3 } from 'gl-matrix'; import { @@ -293,7 +287,7 @@ export abstract class Instanced { lineWidth = 1, visibility, increasedLineWidthForHitTesting = 0, - } = object.parsedStyle as ParsedBaseStyleProps; + } = object.parsedStyle; let fillColor: Tuple4Number = [0, 0, 0, 0]; if (isCSSRGB(fill)) { fillColor = [ @@ -730,7 +724,7 @@ export abstract class Instanced { const packedFillStroke: number[] = []; objects.forEach((object) => { - const { fill, stroke } = object.parsedStyle as ParsedBaseStyleProps; + const { fill, stroke } = object.parsedStyle; let fillColor: Tuple4Number = [0, 0, 0, 0]; if (isCSSRGB(fill)) { @@ -800,7 +794,7 @@ export abstract class Instanced { lineWidth = 1, visibility, increasedLineWidthForHitTesting = 0, - } = object.parsedStyle as ParsedBaseStyleProps; + } = object.parsedStyle; packed.push( opacity, fillOpacity, @@ -1172,10 +1166,7 @@ export abstract class Instanced { const instance = objects[0]; // should account for Line, Path, Polyline and Polyline - const fill = instance.parsedStyle[this.gradientAttributeName] as - | CSSRGB - | CSSGradientValue[] - | Pattern; + const fill = instance.parsedStyle[this.gradientAttributeName]; let texImageSource: string | TexImageSource; diff --git a/packages/g-plugin-device-renderer/src/drawcalls/InstancedPath.ts b/packages/g-plugin-device-renderer/src/drawcalls/InstancedPath.ts index 23a5ff302..1feb89647 100644 --- a/packages/g-plugin-device-renderer/src/drawcalls/InstancedPath.ts +++ b/packages/g-plugin-device-renderer/src/drawcalls/InstancedPath.ts @@ -7,7 +7,6 @@ import { ParsedPathStyleProps, Path, Shape, - ParsedBaseStyleProps, ParsedLineStyleProps, Polyline, isDisplayObject, @@ -476,7 +475,7 @@ export function updateBuffer( segmentNum?: number, subPathIndex = 0, ) { - const { lineCap, lineJoin } = object.parsedStyle as ParsedBaseStyleProps; + const { lineCap, lineJoin } = object.parsedStyle; const zIndex = object.sortable.renderOrder * RENDER_ORDER_SCALE; let defX = 0; let defY = 0; diff --git a/packages/g-plugin-image-loader/src/ImagePool.ts b/packages/g-plugin-image-loader/src/ImagePool.ts index bda1ff93f..ae03d10b0 100644 --- a/packages/g-plugin-image-loader/src/ImagePool.ts +++ b/packages/g-plugin-image-loader/src/ImagePool.ts @@ -14,6 +14,7 @@ import { parsedTransformToMat4, Image, OffscreenCanvasCreator, + ElementEvent, type CanvasContext, type GlobalRuntime, } from '@antv/g-lite'; @@ -31,6 +32,18 @@ export interface ImageCache extends Partial { } const IMAGE_CACHE = new RefCountCache(); +IMAGE_CACHE.onRefAdded = function onRefAdded( + this: RefCountCache, + ref, +) { + ref.addEventListener( + ElementEvent.DESTROY, + () => { + this.releaseRef(ref); + }, + { once: true }, + ); +}; export type GradientParams = (LinearGradient & RadialGradient) & { width: number; @@ -44,7 +57,6 @@ export type GradientParams = (LinearGradient & RadialGradient) & { export class ImagePool { static isSupportTile = !!OffscreenCanvasCreator.createCanvas(); - private imageCache: Record = {}; private gradientCache: Record = {}; private patternCache: Record = {}; diff --git a/packages/g-plugin-image-loader/src/LoadImagePlugin.ts b/packages/g-plugin-image-loader/src/LoadImagePlugin.ts index f332a3f95..166c2d6b7 100644 --- a/packages/g-plugin-image-loader/src/LoadImagePlugin.ts +++ b/packages/g-plugin-image-loader/src/LoadImagePlugin.ts @@ -4,6 +4,7 @@ import type { MutationEvent, RenderingPlugin, RenderingPluginContext, + DisplayObject, } from '@antv/g-lite'; import { ElementEvent, Shape } from '@antv/g-lite'; import { isString } from '@antv/util'; @@ -36,15 +37,19 @@ export class LoadImagePlugin implements RenderingPlugin { const { src, keepAspectRatio } = attributes; if (isString(src)) { - imagePool.getImageSync(src, object, ({ img: { width, height } }) => { - if (keepAspectRatio) { - calculateWithAspectRatio(object, width, height); - } - - // set dirty rectangle flag - object.renderable.dirty = true; - renderingService.dirtify(); - }); + imagePool.getImageSync( + src, + object as DisplayObject, + ({ img: { width, height } }) => { + if (keepAspectRatio) { + calculateWithAspectRatio(object, width, height); + } + + // set dirty rectangle flag + object.renderable.dirty = true; + renderingService.dirtify(); + }, + ); } } }; @@ -58,12 +63,15 @@ export class LoadImagePlugin implements RenderingPlugin { } if (prevValue !== newValue) { - imagePool.releaseImage(prevValue as Image['attributes']['src'], object); + imagePool.releaseImage( + prevValue as Image['attributes']['src'], + object as DisplayObject, + ); } if (isString(newValue)) { imagePool - .getOrCreateImage(newValue, object) + .getOrCreateImage(newValue, object as DisplayObject) .then(({ img: { width, height } }) => { if (object.attributes.keepAspectRatio) { calculateWithAspectRatio(object, width, height); @@ -79,23 +87,12 @@ export class LoadImagePlugin implements RenderingPlugin { } }; - function handleDestroy(e: FederatedEvent) { - const object = e.target as Image; - - if (object.nodeName !== Shape.IMAGE) { - return; - } - - imagePool.releaseImageRef(object); - } - renderingService.hooks.init.tap(LoadImagePlugin.tag, () => { canvas.addEventListener(ElementEvent.MOUNTED, handleMounted); canvas.addEventListener( ElementEvent.ATTR_MODIFIED, handleAttributeChanged, ); - canvas.addEventListener(ElementEvent.DESTROY, handleDestroy); }); renderingService.hooks.destroy.tap(LoadImagePlugin.tag, () => { @@ -104,7 +101,6 @@ export class LoadImagePlugin implements RenderingPlugin { ElementEvent.ATTR_MODIFIED, handleAttributeChanged, ); - canvas.removeEventListener(ElementEvent.DESTROY, handleDestroy); }); } } diff --git a/packages/g-plugin-image-loader/src/RefCountCache.ts b/packages/g-plugin-image-loader/src/RefCountCache.ts index 36c8c567e..f2d6723bc 100644 --- a/packages/g-plugin-image-loader/src/RefCountCache.ts +++ b/packages/g-plugin-image-loader/src/RefCountCache.ts @@ -4,6 +4,8 @@ export class RefCountCache { { value: CacheValue; counter: Set } >(); + onRefAdded(ref: CounterValue) {} + has(key: string) { return this.#cacheStore.has(key); } @@ -17,6 +19,7 @@ export class RefCountCache { value: item, counter: new Set([ref]), }); + this.onRefAdded(ref); return true; } @@ -27,7 +30,10 @@ export class RefCountCache { return null; } - cacheItem.counter.add(ref); + if (!cacheItem.counter.has(ref)) { + cacheItem.counter.add(ref); + this.onRefAdded(ref); + } return cacheItem.value; } @@ -39,7 +45,10 @@ export class RefCountCache { } cacheItem.value = { ...cacheItem.value, ...value }; - cacheItem.counter.add(ref); + if (!cacheItem.counter.has(ref)) { + cacheItem.counter.add(ref); + this.onRefAdded(ref); + } return true; } diff --git a/packages/g-plugin-svg-renderer/src/SVGRendererPlugin.ts b/packages/g-plugin-svg-renderer/src/SVGRendererPlugin.ts index 6ff8bac14..d75257ea6 100644 --- a/packages/g-plugin-svg-renderer/src/SVGRendererPlugin.ts +++ b/packages/g-plugin-svg-renderer/src/SVGRendererPlugin.ts @@ -3,7 +3,6 @@ import { FederatedEvent, LinearGradient, MutationEvent, - ParsedBaseStyleProps, RadialGradient, RenderingPlugin, RenderingPluginContext, @@ -240,8 +239,7 @@ export class SVGRendererPlugin implements RenderingPlugin { // @ts-ignore const $el = object.elementSVG?.$el; - const { fill, stroke, clipPath } = - object.parsedStyle as ParsedBaseStyleProps; + const { fill, stroke, clipPath } = object.parsedStyle; if (fill && !isCSSRGB(fill)) { this.defElementManager.createOrUpdateGradientAndPattern( diff --git a/packages/g-plugin-svg-renderer/src/shapes/defs/Shadow.ts b/packages/g-plugin-svg-renderer/src/shapes/defs/Shadow.ts index 8de996697..d2324ae01 100644 --- a/packages/g-plugin-svg-renderer/src/shapes/defs/Shadow.ts +++ b/packages/g-plugin-svg-renderer/src/shapes/defs/Shadow.ts @@ -1,4 +1,4 @@ -import type { DisplayObject, ParsedBaseStyleProps } from '@antv/g-lite'; +import type { DisplayObject } from '@antv/g-lite'; import { isNil } from '@antv/util'; import { createSVGElement } from '../../utils/dom'; @@ -21,7 +21,7 @@ export function createOrUpdateShadow( shadowColor, shadowOffsetX, shadowOffsetY, - } = object.parsedStyle as ParsedBaseStyleProps; + } = object.parsedStyle; const hasShadow = !isNil(shadowColor) && shadowBlur > 0; const shadowId = FILTER_DROPSHADOW_PREFIX + object.entity; diff --git a/site/examples/perf/canvas/demo/large-image-rendering.js b/site/examples/perf/canvas/demo/canvas-large-image-rendering.js similarity index 100% rename from site/examples/perf/canvas/demo/large-image-rendering.js rename to site/examples/perf/canvas/demo/canvas-large-image-rendering.js diff --git a/site/examples/perf/canvas/demo/canvas-rendering-optimization.js b/site/examples/perf/canvas/demo/canvas-rendering-optimization.js new file mode 100644 index 000000000..c57d60810 --- /dev/null +++ b/site/examples/perf/canvas/demo/canvas-rendering-optimization.js @@ -0,0 +1,97 @@ +import { Canvas, Rect, Group, CanvasEvent } from '@antv/g'; +import { Renderer as CanvasRenderer } from '@antv/g-canvas'; +import Stats from 'stats.js'; +import * as lil from 'lil-gui'; + +const canvasRenderer = new CanvasRenderer({ + enableRenderingOptimization: true, +}); + +// create a canvas +const canvas = new Canvas({ + container: 'container', + width: 600, + height: 500, + renderer: canvasRenderer, +}); + +canvas.addEventListener(CanvasEvent.READY, () => { + const { width, height } = canvas.getConfig(); + const root = new Group(); + const count = 1e4; + let rects = []; + + function update() { + const rectsToRemove = []; + + for (let i = 0; i < count; i++) { + const rect = rects[i]; + rect.x -= rect.speed; + rect.el.setAttribute('x', rect.x); + if (rect.x + rect.size < 0) rectsToRemove.push(i); + } + + rectsToRemove.forEach((i) => { + rects[i].x = width + rects[i].size / 2; + }); + } + + function render() { + root.destroyChildren(); + rects = []; + + for (let i = 0; i < count; i++) { + const x = Math.random() * width; + const y = Math.random() * height; + const size = 10 + Math.random() * 40; + const speed = 1 + Math.random(); + + const rect = new Rect({ + style: { + x, + y, + width: size, + height: size, + fill: 'white', + stroke: '#000', + lineWidth: 1, + }, + }); + root.appendChild(rect); + rects[i] = { x, y, size, speed, el: rect }; + } + } + + render(); + canvas.addEventListener(CanvasEvent.BEFORE_RENDER, () => update()); + + canvas.appendChild(root); + + // --- +}); + +// stats +const stats = new Stats(); +stats.showPanel(0); +const $stats = stats.dom; +$stats.style.position = 'absolute'; +$stats.style.left = '0px'; +$stats.style.top = '0px'; +const $wrapper = document.getElementById('container'); +$wrapper.appendChild($stats); +canvas.addEventListener(CanvasEvent.AFTER_RENDER, () => { + if (stats) { + stats.update(); + } +}); + +// GUI +const gui = new lil.GUI({ autoPlace: false }); +$wrapper.appendChild(gui.domElement); +const canvasConfig = { + enableRenderingOptimization: canvas.getConfig().renderer.getConfig() + .enableRenderingOptimization, +}; +gui.add(canvasConfig, 'enableRenderingOptimization').onChange((value) => { + canvas.getConfig().renderer.getConfig().enableRenderingOptimization = value; +}); diff --git a/site/examples/perf/canvas/demo/meta.json b/site/examples/perf/canvas/demo/meta.json index 83d06cb1f..3028b4814 100644 --- a/site/examples/perf/canvas/demo/meta.json +++ b/site/examples/perf/canvas/demo/meta.json @@ -1,7 +1,14 @@ { "demos": [ { - "filename": "large-image-rendering.js", + "filename": "canvas-rendering-optimization.js", + "title": { + "zh": "g-canvas 渲染优化", + "en": "Rendering-optimization with g-canvas" + } + }, + { + "filename": "canvas-large-image-rendering.js", "title": { "zh": "g-canvas 渲染高分辨率大图优化", "en": "Rendering large images optimization with g-canvas"