mirror of
https://github.com/dream-num/univer.git
synced 2026-09-01 15:29:43 +08:00
perf(render): cache shape shadows and glows (#7353)
This commit is contained in:
@@ -18,6 +18,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
combineDrawingEffectFilter,
|
||||
createDrawingEffectFilter,
|
||||
expandDrawingEffectBounds,
|
||||
resolveDrawingEffectMasks,
|
||||
resolveGlowEffect,
|
||||
resolveOuterShadowEffect,
|
||||
@@ -77,4 +78,17 @@ describe('drawing effect', () => {
|
||||
{ color: '#000000', blurRadius: 3, offsetX: 0, offsetY: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands cache bounds for the sequential glow and shadow filter chain', () => {
|
||||
expect(expandDrawingEffectBounds(
|
||||
{ left: 0, top: 0, right: 100, bottom: 50 },
|
||||
{ color: '#5b9bd5', radius: 4 },
|
||||
{ color: '#000000', blurRadius: 3, distance: 2, direction: 0 }
|
||||
)).toEqual({
|
||||
left: -13,
|
||||
top: -15,
|
||||
right: 117,
|
||||
bottom: 65,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IGlowEffect, IShadowEffect } from '@univerjs/core';
|
||||
import { ColorKit } from '@univerjs/core';
|
||||
import type { IBoundRectNoAngle } from './vector2';
|
||||
// eslint-disable-next-line import/consistent-type-specifier-style -- Keep type and value imports from one package together.
|
||||
import { ColorKit, type IGlowEffect, type IShadowEffect } from '@univerjs/core';
|
||||
|
||||
// Canvas blur visually spans about twice the DrawingML glow radius. This factor is verified against PowerPoint output.
|
||||
const DRAWINGML_GLOW_BLUR_SCALE = 0.5;
|
||||
// Canvas drop-shadow uses a Gaussian blur. Three standard deviations retain the visible effect without clipping.
|
||||
const GAUSSIAN_BLUR_BOUND_SCALE = 3;
|
||||
|
||||
export interface IResolvedDrawingShadow {
|
||||
color: string;
|
||||
@@ -105,6 +108,37 @@ export function resolveDrawingEffectMasks(
|
||||
return effects;
|
||||
}
|
||||
|
||||
export function expandDrawingEffectBounds(
|
||||
bounds: IBoundRectNoAngle,
|
||||
glow: IGlowEffect | undefined,
|
||||
outerShadow: IShadowEffect | undefined
|
||||
): IBoundRectNoAngle {
|
||||
let expandedBounds = { ...bounds };
|
||||
const effects = [resolveGlowEffect(glow), resolveOuterShadowEffect(outerShadow)];
|
||||
|
||||
for (const effect of effects) {
|
||||
if (!effect) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const blurBound = effect.blurRadius * GAUSSIAN_BLUR_BOUND_SCALE;
|
||||
const shadowBounds = {
|
||||
left: expandedBounds.left + effect.offsetX - blurBound,
|
||||
top: expandedBounds.top + effect.offsetY - blurBound,
|
||||
right: expandedBounds.right + effect.offsetX + blurBound,
|
||||
bottom: expandedBounds.bottom + effect.offsetY + blurBound,
|
||||
};
|
||||
expandedBounds = {
|
||||
left: Math.min(expandedBounds.left, shadowBounds.left),
|
||||
top: Math.min(expandedBounds.top, shadowBounds.top),
|
||||
right: Math.max(expandedBounds.right, shadowBounds.right),
|
||||
bottom: Math.max(expandedBounds.bottom, shadowBounds.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
return expandedBounds;
|
||||
}
|
||||
|
||||
export function createDrawingEffectFilter(
|
||||
glow: IGlowEffect | undefined,
|
||||
outerShadow: IShadowEffect | undefined
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './basics';
|
||||
export {
|
||||
combineDrawingEffectFilter,
|
||||
createDrawingEffectFilter,
|
||||
expandDrawingEffectBounds,
|
||||
resolveDrawingEffectMasks,
|
||||
resolveGlowEffect,
|
||||
resolveOuterShadowEffect,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IBoundRectNoAngle } from '../../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../../context';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Canvas } from '../../canvas';
|
||||
import { Shape } from '../shape';
|
||||
|
||||
class CachedShape extends Shape<Record<never, never>> {
|
||||
drawCount = 0;
|
||||
|
||||
drawCached(ctx: UniverRenderingContext, bounds: IBoundRectNoAngle): void {
|
||||
this._renderWithCache(ctx, bounds, (cacheContext) => {
|
||||
this.drawCount += 1;
|
||||
cacheContext.fillRect(bounds.left, bounds.top, bounds.right - bounds.left, bounds.bottom - bounds.top);
|
||||
});
|
||||
this.makeDirty(false);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Shape render cache', () => {
|
||||
it('reuses the cached bitmap until its local bounds change', () => {
|
||||
const mainCanvas = new Canvas({ width: 200, height: 100, pixelRatio: 1 });
|
||||
const shape = new CachedShape('cached-shape');
|
||||
const context = mainCanvas.getContext();
|
||||
|
||||
shape.drawCached(context, { left: -10, top: -10, right: 110, bottom: 60 });
|
||||
shape.drawCached(context, { left: -10, top: -10, right: 110, bottom: 60 });
|
||||
expect(shape.drawCount).toBe(1);
|
||||
|
||||
context.setTransform(0.5, 0, 0, 0.5, 0, 0);
|
||||
shape.drawCached(context, { left: -10, top: -10, right: 110, bottom: 60 });
|
||||
expect(shape.drawCount).toBe(2);
|
||||
|
||||
shape.drawCached(context, { left: -10, top: -10, right: 120, bottom: 60 });
|
||||
expect(shape.drawCount).toBe(3);
|
||||
|
||||
shape.dispose();
|
||||
mainCanvas.dispose();
|
||||
});
|
||||
});
|
||||
@@ -16,10 +16,11 @@
|
||||
|
||||
import type { IOffset, IScale, ISize, Nullable } from '@univerjs/core';
|
||||
import type { IObjectFullState } from '../basics/interfaces';
|
||||
import type { IViewportInfo, Vector2 } from '../basics/vector2';
|
||||
import type { IBoundRectNoAngle, IViewportInfo, Vector2 } from '../basics/vector2';
|
||||
import type { UniverRenderingContext } from '../context';
|
||||
import { BASE_OBJECT_ARRAY, BaseObject, ObjectType } from '../base-object';
|
||||
import { SHAPE_TYPE } from '../basics/const';
|
||||
import { Canvas } from '../canvas';
|
||||
|
||||
export type LineJoin = 'round' | 'bevel' | 'miter';
|
||||
export type LineCap = 'butt' | 'round' | 'square';
|
||||
@@ -88,6 +89,10 @@ export const SHAPE_OBJECT_ARRAY = [
|
||||
];
|
||||
|
||||
export abstract class Shape<T extends IShapeProps> extends BaseObject {
|
||||
private _renderCacheCanvas: Nullable<Canvas>;
|
||||
private _renderCacheBounds: Nullable<IBoundRectNoAngle>;
|
||||
private _renderCachePixelRatio = 0;
|
||||
|
||||
private _hoverCursor: Nullable<string>;
|
||||
|
||||
private _moveCursor: string | null = null;
|
||||
@@ -387,6 +392,73 @@ export abstract class Shape<T extends IShapeProps> extends BaseObject {
|
||||
};
|
||||
}
|
||||
|
||||
protected _renderWithCache(
|
||||
ctx: UniverRenderingContext,
|
||||
bounds: IBoundRectNoAngle,
|
||||
draw: (cacheContext: UniverRenderingContext) => void
|
||||
): void {
|
||||
const transform = ctx.getTransform();
|
||||
const scaleX = Math.hypot(transform.a, transform.b);
|
||||
const scaleY = Math.hypot(transform.c, transform.d);
|
||||
const pixelRatio = Math.max(scaleX, scaleY);
|
||||
if (pixelRatio <= Number.EPSILON) {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = Math.ceil((bounds.right - bounds.left) * pixelRatio) / pixelRatio;
|
||||
const height = Math.ceil((bounds.bottom - bounds.top) * pixelRatio) / pixelRatio;
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheBoundsChanged =
|
||||
this._renderCacheBounds?.left !== bounds.left ||
|
||||
this._renderCacheBounds?.top !== bounds.top ||
|
||||
this._renderCacheBounds?.right !== bounds.right ||
|
||||
this._renderCacheBounds?.bottom !== bounds.bottom;
|
||||
const cacheSizeChanged =
|
||||
this._renderCacheCanvas?.getWidth() !== width ||
|
||||
this._renderCacheCanvas?.getHeight() !== height ||
|
||||
this._renderCachePixelRatio !== pixelRatio;
|
||||
|
||||
if (!this._renderCacheCanvas) {
|
||||
this._renderCacheCanvas = new Canvas({
|
||||
colorService: this.getEngine()?.canvasColorService,
|
||||
width,
|
||||
height,
|
||||
pixelRatio,
|
||||
});
|
||||
} else if (cacheSizeChanged) {
|
||||
this._renderCacheCanvas.setSize(width, height, pixelRatio);
|
||||
}
|
||||
|
||||
if (this.isDirty() || cacheBoundsChanged || cacheSizeChanged) {
|
||||
const cacheContext = this._renderCacheCanvas.getContext();
|
||||
this._renderCacheCanvas.clear();
|
||||
cacheContext.save();
|
||||
cacheContext.translate(-bounds.left, -bounds.top);
|
||||
draw(cacheContext);
|
||||
cacheContext.restore();
|
||||
this._renderCacheBounds = { ...bounds };
|
||||
this._renderCachePixelRatio = pixelRatio;
|
||||
}
|
||||
|
||||
ctx.drawImage(
|
||||
this._renderCacheCanvas.getCanvasEle(),
|
||||
bounds.left,
|
||||
bounds.top,
|
||||
width,
|
||||
height
|
||||
);
|
||||
}
|
||||
|
||||
protected _releaseRenderCache(): void {
|
||||
this._renderCacheCanvas?.dispose();
|
||||
this._renderCacheCanvas = null;
|
||||
this._renderCacheBounds = null;
|
||||
this._renderCachePixelRatio = 0;
|
||||
}
|
||||
|
||||
protected _draw(ctx: UniverRenderingContext, bounds?: IViewportInfo) {
|
||||
/** abstract */
|
||||
}
|
||||
@@ -441,4 +513,9 @@ export abstract class Shape<T extends IShapeProps> extends BaseObject {
|
||||
|
||||
this.makeDirty(true);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._releaseRenderCache();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user