From ea18f1e9028ea31f79aefa7c023eb9d7efe95181 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 11 May 2026 09:39:22 -0400 Subject: [PATCH] perf(transform): translate-only fast path for global transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes covering the hot update path for x/y changes on simple nodes (no rotation/scale/mount): 1. CoreNode.update Global block: when both the node is simple and the parent's global is translate-only, the resulting global is also translate-only and collapses to two adds on tx/ty — bypassing the 6-field Matrix3d.copy + 2 mul-add translate. Tracked with a per-node _globalIsTranslate flag that propagates down the tree. RTT chains conservatively opt out. 2. updateLocalTransform simple path: localTransform is guaranteed to be identity-shape (ta=1, tb=0, tc=0, td=1), so only tx/ty need to change between frames. _localIsTranslate tracks whether the matrix is still in identity-shape so a non-simple -> simple transition correctly restores it. 3. updateBoundingRect: fix `||` -> `&&` on the axis-aligned-fast-path check. With `||`, a matrix with one zero shear component and the other non-zero (e.g. tb=0, tc=-0.5) wrongly took the diagonal fast path and produced incorrect bounds. Now matches calculateRenderCoords semantics. Adds setTranslate() helper to Matrix3d for the in-place tx/ty + mutation flag write used by both #1 and #2. Adds 12 unit tests covering simple-path reuse, non-simple -> simple transitions, translate-only propagation through grandchildren, fast-path opt-out when parent has rotation, and the bounding-rect axis check. Co-Authored-By: Claude Opus 4.7 --- src/core/CoreNode.test.ts | 230 ++++++++++++++++++++++++++++++++++ src/core/CoreNode.ts | 97 +++++++++++--- src/core/lib/Matrix3d.test.ts | 39 ++++++ src/core/lib/Matrix3d.ts | 14 +++ 4 files changed, 363 insertions(+), 17 deletions(-) create mode 100644 src/core/lib/Matrix3d.test.ts diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 12d25f8..4f27706 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -533,4 +533,234 @@ describe('set color()', () => { expect(node.isSimple).toBe(false); }); }); + + describe('simple-path localTransform writes', () => { + it('reuses the same Matrix3d instance across x/y updates', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + const node = new CoreNode(stage, defaultProps({ parent })); + + node.x = 10; + node.y = 20; + node.update(0, clippingRect); + const lt = node.localTransform!; + expect(lt.tx).toBe(10); + expect(lt.ty).toBe(20); + + node.x = 100; + node.y = 200; + node.update(1, clippingRect); + // Same instance — no realloc per frame. + expect(node.localTransform).toBe(lt); + expect(lt.tx).toBe(100); + expect(lt.ty).toBe(200); + // Identity-shape preserved. + expect(lt.ta).toBe(1); + expect(lt.tb).toBe(0); + expect(lt.tc).toBe(0); + expect(lt.td).toBe(1); + expect(node._localIsTranslate).toBe(true); + }); + + it('resets ta/tb/tc/td when transitioning non-simple -> simple', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + const node = new CoreNode(stage, defaultProps({ parent })); + node.props.w = 100; + node.props.h = 100; + node.pivot = 0.5; + + // First, become non-simple via rotation — local matrix gets non-identity ta/tb/tc/td. + node.x = 50; + node.y = 50; + node.rotation = Math.PI / 2; + node.update(0, clippingRect); + expect(node._localIsTranslate).toBe(false); + const lt = node.localTransform!; + // Sanity: matrix is no longer in identity-shape + expect(lt.ta === 1 && lt.tb === 0 && lt.tc === 0 && lt.td === 1).toBe( + false, + ); + + // Clear rotation — now simple again. + node.rotation = 0; + node.x = 5; + node.y = 7; + node.update(1, clippingRect); + + // Matrix must be restored to identity-shape, NOT carrying stale rotation. + expect(node.localTransform).toBe(lt); + expect(lt.ta).toBe(1); + expect(lt.tb).toBe(0); + expect(lt.tc).toBe(0); + expect(lt.td).toBe(1); + expect(node._localIsTranslate).toBe(true); + }); + }); + + describe('translate-only global fast path', () => { + it('produces the same global translate as parent + local for simple chains', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.translate(30, 40); + // parent is set up by the test as translate-only. + parent._globalIsTranslate = true; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 5; + node.y = 7; + node.update(0, clippingRect); + + expect(node.globalTransform!.tx).toBe(35); + expect(node.globalTransform!.ty).toBe(47); + expect(node.globalTransform!.ta).toBe(1); + expect(node.globalTransform!.tb).toBe(0); + expect(node.globalTransform!.tc).toBe(0); + expect(node.globalTransform!.td).toBe(1); + expect(node._globalIsTranslate).toBe(true); + }); + + it('propagates _globalIsTranslate through grandchildren', () => { + const root = new CoreNode(stage, defaultProps()); + root.globalTransform = Matrix3d.identity(); + root._globalIsTranslate = true; + + const mid = new CoreNode(stage, defaultProps({ parent: root })); + mid.x = 10; + mid.y = 20; + mid.update(0, clippingRect); + expect(mid._globalIsTranslate).toBe(true); + + const leaf = new CoreNode(stage, defaultProps({ parent: mid })); + leaf.x = 3; + leaf.y = 4; + leaf.update(0, clippingRect); + expect(leaf._globalIsTranslate).toBe(true); + expect(leaf.globalTransform!.tx).toBe(13); + expect(leaf.globalTransform!.ty).toBe(24); + }); + + it('does not take the fast path when parent is not translate-only', () => { + const parent = new CoreNode(stage, defaultProps()); + // Parent global has a rotation baked in. + parent.globalTransform = Matrix3d.rotate(Math.PI / 2); + parent._globalIsTranslate = false; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 10; + node.y = 0; + node.update(0, clippingRect); + // Child is simple itself but parent has rotation, so the resulting + // global cannot be translate-only. + expect(node._globalIsTranslate).toBe(false); + }); + + it('clears _globalIsTranslate when the node becomes non-simple', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent._globalIsTranslate = true; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.props.w = 100; + node.props.h = 100; + node.x = 10; + node.y = 20; + node.update(0, clippingRect); + expect(node._globalIsTranslate).toBe(true); + + // Add rotation -> non-simple -> global is no longer translate-only. + node.pivot = 0.5; + node.rotation = Math.PI / 4; + node.update(1, clippingRect); + expect(node._globalIsTranslate).toBe(false); + }); + + it('restores identity-shape on globalTransform when re-entering the fast path', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent._globalIsTranslate = true; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.props.w = 100; + node.props.h = 100; + node.pivot = 0.5; + node.x = 10; + node.y = 20; + node.rotation = Math.PI / 2; + node.update(0, clippingRect); + expect(node._globalIsTranslate).toBe(false); + const gt = node.globalTransform!; + // sanity: rotation baked into the global + expect(gt.ta === 1 && gt.tb === 0 && gt.tc === 0 && gt.td === 1).toBe( + false, + ); + + // Remove rotation -> simple again -> fast path applies, must reset ta/tb/tc/td. + node.rotation = 0; + node.x = 5; + node.y = 6; + node.update(1, clippingRect); + + expect(node._globalIsTranslate).toBe(true); + expect(node.globalTransform).toBe(gt); + expect(gt.ta).toBe(1); + expect(gt.tb).toBe(0); + expect(gt.tc).toBe(0); + expect(gt.td).toBe(1); + expect(gt.tx).toBe(5); + expect(gt.ty).toBe(6); + }); + }); + + describe('updateBoundingRect axis-alignment check', () => { + it('uses 4-corner bounds when one shear component is non-zero', () => { + // Without the && fix, the axis-aligned branch fires whenever EITHER + // tb or tc is 0, which produces wrong bounds for matrices with + // a single non-zero shear and a sign that places corners outside + // the (x1,y1)–(x3,y3) diagonal. + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + const node = new CoreNode(stage, defaultProps({ parent })); + node.props.w = 100; + node.props.h = 100; + node.update(0, clippingRect); + + const gt = node.globalTransform!; + gt.ta = 1; + gt.tb = 0; + gt.tc = -0.5; + gt.td = 1; + gt.tx = 0; + gt.ty = 100; + + node.calculateRenderCoords(); + node.updateBoundingRect(); + + // Corners with the above matrix: + // TL (0, 100), TR (100, 50), BR (100, 150), BL (0, 200) + // Correct bounds: x in [0, 100], y in [50, 200]. + // Axis-aligned diagonal would yield y in [100, 150] — wrong. + const rb = node.renderBound!; + expect(rb.x1).toBe(0); + expect(rb.x2).toBe(100); + expect(rb.y1).toBe(50); + expect(rb.y2).toBe(200); + }); + + it('still uses the diagonal bounds when both shear components are zero', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + const node = new CoreNode(stage, defaultProps({ parent })); + node.props.w = 100; + node.props.h = 100; + node.x = 10; + node.y = 20; + node.update(0, clippingRect); + + const rb = node.renderBound!; + expect(rb.x1).toBe(10); + expect(rb.y1).toBe(20); + expect(rb.x2).toBe(110); + expect(rb.y2).toBe(120); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index b413049..e8e0519 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -811,6 +811,20 @@ export class CoreNode extends EventEmitter { public isRenderable = false; public renderState: CoreNodeRenderState = CoreNodeRenderState.Init; public isSimple = true; + /** + * `true` when `localTransform` is in identity-shape (ta=1, tb=0, tc=0, td=1) + * — i.e. a pure translation. Lets the simple-path `updateLocalTransform` + * skip redundant ta/tb/tc/td writes between frames. + */ + public _localIsTranslate = false; + /** + * `true` when `globalTransform` is in identity-shape (ta=1, tb=0, tc=0, td=1). + * Propagates from parent: a node's global is translate-only iff the parent's + * global is translate-only AND the node itself is `isSimple`. Default `true` + * because freshly-constructed nodes have no transform applied yet, and the + * Stage root is configured with an identity-shape global. + */ + public _globalIsTranslate = true; public worldAlpha = 1; public premultipliedColorTl = 0; @@ -1089,7 +1103,18 @@ export class CoreNode extends EventEmitter { const { x, y } = p; if (this.isSimple) { + // Fast path: when localTransform is already in identity-shape + // (ta=1, tb=0, tc=0, td=1), only tx/ty change between frames, so we + // skip the 4 redundant field writes Matrix3d.translate would do. + // _localIsTranslate becomes stale when a node was non-simple (had + // rotation/scale/mount) on a previous frame — in that case do a + // full reset to identity-translate. + if (this._localIsTranslate === true) { + this.localTransform!.setTranslate(x, y); + return; + } this.localTransform = Matrix3d.translate(x, y, this.localTransform); + this._localIsTranslate = true; return; } @@ -1157,6 +1182,8 @@ export class CoreNode extends EventEmitter { .translate(extraX, extraY) .scale(resizeModeScaleX, resizeModeScaleY); } + + this._localIsTranslate = false; } updateIsSimple() { @@ -1209,6 +1236,9 @@ export class CoreNode extends EventEmitter { } if (updateType & UpdateType.Global) { + const lt = this.localTransform!; + let fastPathApplied = false; + if ( USE_RTT && this.parentHasRenderTexture === true && @@ -1225,7 +1255,10 @@ export class CoreNode extends EventEmitter { this.sceneGlobalTransform = Matrix3d.copy( parentTransform, this.sceneGlobalTransform, - ).translateOrMultiply(this.localTransform!); + ).translateOrMultiply(lt); + + // identity * local => translate-only iff this node is simple + this._globalIsTranslate = this.isSimple; } else if ( USE_RTT && this.parentHasRenderTexture === true && @@ -1234,32 +1267,62 @@ export class CoreNode extends EventEmitter { // we're part of an RTT chain but our parent is not the main RTT node // so we need to propogate the sceneGlobalTransform of the parent // to maintain a full scene global transform for bounds detection - const parentSceneTransform = - parent.sceneGlobalTransform || this.localTransform!; + const parentSceneTransform = parent.sceneGlobalTransform || lt; this.sceneGlobalTransform = Matrix3d.copy( parentSceneTransform, this.sceneGlobalTransform, - ).translateOrMultiply(this.localTransform!); + ).translateOrMultiply(lt); this.globalTransform = Matrix3d.copy( - parent.globalTransform || this.localTransform!, + parent.globalTransform || lt, this.globalTransform, ); + + // Conservative: RTT chains rarely hit the translate fast path + this._globalIsTranslate = false; } else { - this.globalTransform = Matrix3d.copy( - parent.globalTransform || this.localTransform!, - this.globalTransform, - ); + // Common non-RTT path + const parentGT = parent.globalTransform; + if ( + this.isSimple === true && + parentGT !== undefined && + parent._globalIsTranslate === true + ) { + // Translate-only fast path: parent global and local are both pure + // translations, so the resulting global is also a pure translation + // and collapses to 2 adds on tx/ty. + let gt = this.globalTransform; + if (gt === undefined) { + gt = this.globalTransform = Matrix3d.identity(); + } else if (this._globalIsTranslate === false) { + // Transitioning back into translate-only — reset ta/tb/tc/td + // that may have been left non-identity by a prior frame. + gt.ta = 1; + gt.tb = 0; + gt.tc = 0; + gt.td = 1; + } + gt.setTranslate(parentGT.tx + lt.tx, parentGT.ty + lt.ty); + this._globalIsTranslate = true; + fastPathApplied = true; + } else { + this.globalTransform = Matrix3d.copy( + parentGT || lt, + this.globalTransform, + ); + this._globalIsTranslate = + this.isSimple === true && + (parentGT === undefined || parent._globalIsTranslate === true); + } } - if (this.isSimple) { - this.globalTransform.translate( - this.localTransform!.tx, - this.localTransform!.ty, - ); - } else { - this.globalTransform.translateOrMultiply(this.localTransform!); + if (fastPathApplied === false) { + if (this.isSimple) { + this.globalTransform!.translate(lt.tx, lt.ty); + } else { + this.globalTransform!.translateOrMultiply(lt); + } } this.calculateRenderCoords(); this.updateBoundingRect(); @@ -1503,7 +1566,7 @@ export class CoreNode extends EventEmitter { const renderCoords = (this.sceneRenderCoords || this.renderCoords) as RenderCoords; - if (transform.tb === 0 || transform.tc === 0) { + if (transform.tb === 0 && transform.tc === 0) { this.renderBound = createBound( renderCoords.x1, renderCoords.y1, diff --git a/src/core/lib/Matrix3d.test.ts b/src/core/lib/Matrix3d.test.ts new file mode 100644 index 0000000..ac7ad9b --- /dev/null +++ b/src/core/lib/Matrix3d.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { Matrix3d } from './Matrix3d.js'; + +describe('Matrix3d.setTranslate', () => { + it('writes tx/ty without touching ta/tb/tc/td', () => { + const m = Matrix3d.identity(); + m.setTranslate(10, 20); + expect(m.tx).toBe(10); + expect(m.ty).toBe(20); + expect(m.ta).toBe(1); + expect(m.tb).toBe(0); + expect(m.tc).toBe(0); + expect(m.td).toBe(1); + }); + + it('updates the float array on subsequent getFloatArr() calls', () => { + const m = Matrix3d.identity(); + m.setTranslate(5, 7); + // First read populates the array + const arr = m.getFloatArr(); + expect(arr[6]).toBe(5); + expect(arr[7]).toBe(7); + + // Mutate, then expect getFloatArr() to pick up the change. + m.setTranslate(9, 11); + const arr2 = m.getFloatArr(); + expect(arr2[6]).toBe(9); + expect(arr2[7]).toBe(11); + // Same array reference reused (no GC pressure). + expect(arr2).toBe(arr); + }); + + it('overwrites prior tx/ty values', () => { + const m = Matrix3d.translate(100, 200); + m.setTranslate(0, 0); + expect(m.tx).toBe(0); + expect(m.ty).toBe(0); + }); +}); diff --git a/src/core/lib/Matrix3d.ts b/src/core/lib/Matrix3d.ts index 1caefe4..7619962 100644 --- a/src/core/lib/Matrix3d.ts +++ b/src/core/lib/Matrix3d.ts @@ -160,6 +160,20 @@ export class Matrix3d { return this; } + /** + * Writes `tx`/`ty` directly and marks the matrix mutated. + * + * Caller is responsible for ensuring `ta`/`tb`/`tc`/`td` are already the + * desired values — typically the identity rotation/scale (1, 0, 0, 1). + * Used by hot paths that know the matrix is in identity-shape so the + * 4 redundant field writes performed by `Matrix3d.translate` can be skipped. + */ + public setTranslate(x: number, y: number): void { + this.tx = x; + this.ty = y; + this.mutation = true; + } + public scale(sx: number, sy: number): Matrix3d { this.ta = this.ta * sx; this.tb = this.tb * sy;