From 3845b24dc4b136a640e094bb6c262ea81c5aa0ea Mon Sep 17 00:00:00 2001 From: Frank Weindel <6070611+frank-weindel@users.noreply.github.com> Date: Mon, 29 Jan 2024 16:29:36 -0500 Subject: [PATCH] Instantiate a single clipping Rect for each Node / Text Renderer State object The goal is to reduce the number of object allocations and hence garbage collection memory pressure on objects that may be rapidly replaced during animations. In this case, the clipping rect was being newly allocated each time it was changed. This change makes it so there is only one clipping rect per Node that is persistently kept throughout the lifetime of the Node. The SDF text renderer also keeps a persistent clipping rect for its purposes. This does not seem to have an impact on raw FPS performance. --- examples/tests/stress-multi-level-clipping.ts | 93 +++++++++++++++++++ src/core/CoreNode.ts | 51 +++++----- src/core/CoreTextNode.ts | 4 +- src/core/Stage.ts | 2 +- src/core/lib/utils.ts | 44 ++++++++- src/core/renderers/CoreRenderer.ts | 4 +- src/core/renderers/webgl/WebGlCoreRenderOp.ts | 6 +- src/core/renderers/webgl/WebGlCoreRenderer.ts | 3 +- .../renderers/CanvasTextRenderer.ts | 3 +- .../SdfTextRenderer/SdfTextRenderer.ts | 27 +++++- .../text-rendering/renderers/TextRenderer.ts | 4 +- 11 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 examples/tests/stress-multi-level-clipping.ts diff --git a/examples/tests/stress-multi-level-clipping.ts b/examples/tests/stress-multi-level-clipping.ts new file mode 100644 index 00000000..42085cc0 --- /dev/null +++ b/examples/tests/stress-multi-level-clipping.ts @@ -0,0 +1,93 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2023 Comcast Cable Communications Management, LLC. + * + * 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 INode } from '@lightningjs/renderer'; +import logo from '../assets/lightning.png'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import robotImg from '../assets/robot/robot.png'; + +const randomIntBetween = (from: number, to: number) => + Math.floor(Math.random() * (to - from + 1) + from); + +export default async function ({ + renderer, + testRoot, + perfMultiplier, +}: ExampleSettings) { + // create nodes + const numOuterNodes = 100 * perfMultiplier; + const nodes: INode[] = []; + let totalNodes = 0; + + const bg = renderer.createNode({ + width: 1920, + height: 1080, + color: 0xff1e293b, + parent: testRoot, + }); + + for (let i = 0; i < numOuterNodes; i++) { + const container = renderer.createNode({ + x: Math.random() * 1920, + y: Math.random() * 1080, + width: 100, + height: 100, + clipping: true, + parent: bg, + }); + const node = renderer.createNode({ + mount: 0.5, + x: 50, + y: 50, + width: 200, + height: 200, + src: robotImg, + parent: container, + }); + + nodes.push(container); + totalNodes += 2; + } + + console.log( + `Created ${numOuterNodes} clipping outer nodes with an image node nested inside. Total nodes: ${totalNodes}`, + ); + + // create animations + const animate = () => { + nodes.forEach((node) => { + node + .animate( + { + x: randomIntBetween(20, 1740), + y: randomIntBetween(20, 900), + }, + { + duration: 3000, + easing: 'ease-out', + loop: true, + stopMethod: 'reverse', + }, + ) + .start(); + }); + }; + + animate(); +} diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index bb435dd5..ece81ed9 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -37,7 +37,7 @@ import type { NodeTextureLoadedPayload, } from '../common/CommonTypes.js'; import { EventEmitter } from '../common/EventEmitter.js'; -import { intersectRect, type Rect } from './lib/utils.js'; +import { copyRect, intersectRect, type RectWithValid } from './lib/utils.js'; import { Matrix3d } from './lib/Matrix3d.js'; export interface CoreNodeProps { @@ -150,7 +150,13 @@ export class CoreNode extends EventEmitter implements ICoreNode { public globalTransform?: Matrix3d; public scaleRotateTransform?: Matrix3d; public localTransform?: Matrix3d; - public clippingRect: Rect | null = null; + public clippingRect: RectWithValid = { + x: 0, + y: 0, + width: 0, + height: 0, + valid: false, + }; public isRenderable = false; public worldAlpha = 1; public premultipliedColorTl = 0; @@ -295,7 +301,7 @@ export class CoreNode extends EventEmitter implements ICoreNode { * @todo: test for correct calculation flag * @param delta */ - update(delta: number, parentClippingRect: Rect | null = null): void { + update(delta: number, parentClippingRect: RectWithValid): void { if (this.updateType & UpdateType.ScaleRotate) { this.updateScaleRotateTransform(); this.setUpdateType(UpdateType.Local); @@ -477,28 +483,31 @@ export class CoreNode extends EventEmitter implements ICoreNode { * * Finally, the node's parentClippingRect and clippingRect properties are updated. */ - calculateClippingRect(parentClippingRect: Rect | null = null) { + calculateClippingRect(parentClippingRect: RectWithValid) { assertTruthy(this.globalTransform); + const { clippingRect, props, globalTransform: gt } = this; + const { clipping } = props; - const gt = this.globalTransform; const isRotated = gt.tb !== 0 || gt.tc !== 0; - let clippingRect: Rect | null = - this.props.clipping && !isRotated - ? { - x: gt.tx, - y: gt.ty, - width: this.width * gt.ta, - height: this.height * gt.td, - } - : null; - if (parentClippingRect && clippingRect) { - clippingRect = intersectRect(parentClippingRect, clippingRect); - } else if (parentClippingRect) { - clippingRect = parentClippingRect; - } - - this.clippingRect = clippingRect; + if (clipping && !isRotated) { + clippingRect.x = gt.tx; + clippingRect.y = gt.ty; + clippingRect.width = this.width * gt.ta; + clippingRect.height = this.height * gt.td; + clippingRect.valid = true; + } else { + clippingRect.valid = false; + } + + if (parentClippingRect.valid && clippingRect.valid) { + // Intersect parent clipping rect with node clipping rect + intersectRect(parentClippingRect, clippingRect, clippingRect); + } else if (parentClippingRect.valid) { + // Copy parent clipping rect + copyRect(parentClippingRect, clippingRect); + clippingRect.valid = true; + } } calculateZIndex(): void { diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index 0e6362f0..cf4cb466 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -32,7 +32,7 @@ import type { NodeTextFailedPayload, NodeTextLoadedPayload, } from '../common/CommonTypes.js'; -import type { Rect } from './lib/utils.js'; +import type { Rect, RectWithValid } from './lib/utils.js'; import { assertTruthy } from '../utils.js'; export interface CoreTextNodeProps extends CoreNodeProps, TrProps { @@ -318,7 +318,7 @@ export class CoreTextNode extends CoreNode implements ICoreTextNode { this.textRenderer.set.debug(this.trState, value); } - override update(delta: number, parentClippingRect: Rect | null = null) { + override update(delta: number, parentClippingRect: RectWithValid) { super.update(delta, parentClippingRect); assertTruthy(this.globalTransform); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 7a5e1769..8ba4787a 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -210,7 +210,7 @@ export class Stage extends EventEmitter { // Update tree if needed if (this.root.updateType !== 0) { - this.root.update(this.deltaTime); + this.root.update(this.deltaTime, this.root.clippingRect); } // test if we need to update the scene diff --git a/src/core/lib/utils.ts b/src/core/lib/utils.ts index 4806492b..b182e636 100644 --- a/src/core/lib/utils.ts +++ b/src/core/lib/utils.ts @@ -81,6 +81,10 @@ export interface Rect { height: number; } +export interface RectWithValid extends Rect { + valid: boolean; +} + export interface Bound { x1: number; y1: number; @@ -132,12 +136,25 @@ export function intersectBound( return createBound(0, 0, 0, 0, intersection); } -export function intersectRect(a: Rect, b: Rect): Rect { +export function intersectRect(a: Rect, b: Rect): Rect; +export function intersectRect( + a: Rect, + b: Rect, + out: T, +): T; +export function intersectRect(a: Rect, b: Rect, out?: Rect): Rect { const x = Math.max(a.x, b.x); const y = Math.max(a.y, b.y); const width = Math.min(a.x + a.width, b.x + b.width) - x; const height = Math.min(a.y + a.height, b.y + b.height) - y; if (width > 0 && height > 0) { + if (out) { + out.x = x; + out.y = y; + out.width = width; + out.height = height; + return out; + } return { x, y, @@ -145,6 +162,13 @@ export function intersectRect(a: Rect, b: Rect): Rect { height, }; } + if (out) { + out.x = 0; + out.y = 0; + out.width = 0; + out.height = 0; + return out; + } return { x: 0, y: 0, @@ -153,6 +177,24 @@ export function intersectRect(a: Rect, b: Rect): Rect { }; } +export function copyRect(a: Rect): Rect; +export function copyRect(a: Rect, out: T): T; +export function copyRect(a: Rect, out?: Rect): Rect { + if (out) { + out.x = a.x; + out.y = a.y; + out.width = a.width; + out.height = a.height; + return out; + } + return { + x: a.x, + y: a.y, + width: a.width, + height: a.height, + }; +} + export function compareRect(a: Rect | null, b: Rect | null): boolean { if (a === b) { return true; diff --git a/src/core/renderers/CoreRenderer.ts b/src/core/renderers/CoreRenderer.ts index e0e1d9d5..0a56a333 100644 --- a/src/core/renderers/CoreRenderer.ts +++ b/src/core/renderers/CoreRenderer.ts @@ -20,7 +20,7 @@ import type { CoreShaderManager } from '../CoreShaderManager.js'; import type { TextureOptions } from '../CoreTextureManager.js'; import type { Stage } from '../Stage.js'; -import type { Rect } from '../lib/utils.js'; +import type { Rect, RectWithValid } from '../lib/utils.js'; import type { Texture } from '../textures/Texture.js'; import { CoreContextTexture } from './CoreContextTexture.js'; import type { CoreRenderOp } from './CoreRenderOp.js'; @@ -39,7 +39,7 @@ export interface QuadOptions { shader: CoreShader | null; shaderProps: Record | null; alpha: number; - clippingRect: Rect | null; + clippingRect: RectWithValid; tx: number; ty: number; ta: number; diff --git a/src/core/renderers/webgl/WebGlCoreRenderOp.ts b/src/core/renderers/webgl/WebGlCoreRenderOp.ts index 73ffb971..eec02256 100644 --- a/src/core/renderers/webgl/WebGlCoreRenderOp.ts +++ b/src/core/renderers/webgl/WebGlCoreRenderOp.ts @@ -23,7 +23,7 @@ import type { WebGlCoreCtxTexture } from './WebGlCoreCtxTexture.js'; import type { WebGlCoreRendererOptions } from './WebGlCoreRenderer.js'; import type { BufferCollection } from './internal/BufferCollection.js'; import type { Dimensions } from '../../../common/CommonTypes.js'; -import type { Rect } from '../../lib/utils.js'; +import type { Rect, RectWithValid } from '../../lib/utils.js'; import type { WebGlContextWrapper } from '../../lib/WebGlContextWrapper.js'; const MAX_TEXTURES = 8; // TODO: get from gl @@ -45,7 +45,7 @@ export class WebGlCoreRenderOp extends CoreRenderOp { readonly shader: WebGlCoreShader, readonly shaderProps: Record, readonly alpha: number, - readonly clippingRect: Rect | null, + readonly clippingRect: RectWithValid, readonly dimensions: Dimensions, readonly bufferIdx: number, readonly zIndex: number, @@ -82,7 +82,7 @@ export class WebGlCoreRenderOp extends CoreRenderOp { const quadIdx = (this.bufferIdx / 24) * 6 * 2; // Clipping - if (this.clippingRect) { + if (this.clippingRect.valid) { const { x, y, width, height } = this.clippingRect; const pixelRatio = options.pixelRatio; const canvasHeight = options.canvas.height; diff --git a/src/core/renderers/webgl/WebGlCoreRenderer.ts b/src/core/renderers/webgl/WebGlCoreRenderer.ts index a4ae5472..40304418 100644 --- a/src/core/renderers/webgl/WebGlCoreRenderer.ts +++ b/src/core/renderers/webgl/WebGlCoreRenderer.ts @@ -50,6 +50,7 @@ import { compareRect, getNormalizedRgbaComponents, type Rect, + type RectWithValid, } from '../../lib/utils.js'; import type { Dimensions } from '../../../common/CommonTypes.js'; import { WebGlCoreShader } from './WebGlCoreShader.js'; @@ -412,7 +413,7 @@ export class WebGlCoreRenderer extends CoreRenderer { shaderProps: Record, alpha: number, dimensions: Dimensions, - clippingRect: Rect | null, + clippingRect: RectWithValid, bufferIdx: number, ) { const curRenderOp = new WebGlCoreRenderOp( diff --git a/src/core/text-rendering/renderers/CanvasTextRenderer.ts b/src/core/text-rendering/renderers/CanvasTextRenderer.ts index 63cdbbfb..744ba5e5 100644 --- a/src/core/text-rendering/renderers/CanvasTextRenderer.ts +++ b/src/core/text-rendering/renderers/CanvasTextRenderer.ts @@ -30,6 +30,7 @@ import { getNormalizedAlphaComponent, type BoundWithValid, createBound, + type RectWithValid, } from '../../lib/utils.js'; import type { ImageTexture } from '../../textures/ImageTexture.js'; import type { TrFontFace } from '../font-face-types/TrFontFace.js'; @@ -524,7 +525,7 @@ export class CanvasTextRenderer extends TextRenderer { override renderQuads( state: CanvasTextRendererState, transform: Matrix3d, - clippingRect: Rect | null, + clippingRect: RectWithValid, alpha: number, ): void { const { stage } = this; diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts index a60c7091..2f8c5495 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts @@ -25,6 +25,8 @@ import { type BoundWithValid, intersectRect, isBoundPositive, + type RectWithValid, + copyRect, } from '../../../lib/utils.js'; import { TextRenderer, @@ -86,6 +88,8 @@ export interface SdfTextRendererState extends TextRendererState { visibleWindow: BoundWithValid; + clippingRect: RectWithValid; + bufferNumFloats: number; bufferNumQuads: number; @@ -311,6 +315,13 @@ export class SdfTextRenderer extends TextRenderer { y2: 0, valid: false, }, + clippingRect: { + x: 0, + y: 0, + width: 0, + height: 0, + valid: false, + }, bufferNumFloats: 0, bufferNumQuads: 0, vertexBuffer: undefined, @@ -531,7 +542,7 @@ export class SdfTextRenderer extends TextRenderer { override renderQuads( state: SdfTextRendererState, transform: Matrix3d, - clippingRect: Rect | null, + clippingRect: Readonly, alpha: number, ): void { if (!state.vertexBuffer) { @@ -608,9 +619,17 @@ export class SdfTextRenderer extends TextRenderer { height: state.visibleWindow.y2 - state.visibleWindow.y1, }; - clippingRect = clippingRect - ? intersectRect(clippingRect, visibleWindowRect) - : visibleWindowRect; + if (clippingRect.valid) { + state.clippingRect.valid = true; + clippingRect = intersectRect( + clippingRect, + visibleWindowRect, + state.clippingRect, + ); + } else { + state.clippingRect.valid = true; + clippingRect = copyRect(visibleWindowRect, state.clippingRect); + } } const renderOp = new WebGlCoreRenderOp( diff --git a/src/core/text-rendering/renderers/TextRenderer.ts b/src/core/text-rendering/renderers/TextRenderer.ts index 04ceabb5..e6382d55 100644 --- a/src/core/text-rendering/renderers/TextRenderer.ts +++ b/src/core/text-rendering/renderers/TextRenderer.ts @@ -20,7 +20,7 @@ import type { EventEmitter } from '../../../common/EventEmitter.js'; import type { Stage } from '../../Stage.js'; import type { Matrix3d } from '../../lib/Matrix3d.js'; -import type { Rect } from '../../lib/utils.js'; +import type { Rect, RectWithValid } from '../../lib/utils.js'; import type { TrFontFace, TrFontFaceDescriptors, @@ -498,7 +498,7 @@ export abstract class TextRenderer< abstract renderQuads( state: StateT, transform: Matrix3d, - clippingRect: Rect | null, + clippingRect: RectWithValid, alpha: number, ): void; }