perf(render): cache shape shadows and glows (#7353)

This commit is contained in:
Univer
2026-07-28 14:59:30 +08:00
committed by GitHub
parent 24aa8ff89e
commit 45fee6f375
5 changed files with 184 additions and 3 deletions
@@ -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
+1
View File
@@ -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();
});
});
+78 -1
View File
@@ -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();
}
}