From 52d2c5d73af8633e82cb69c6b518372e275d4f8b Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 11 May 2026 09:48:49 -0400 Subject: [PATCH] perf(transform): scale-only path, eager allocation, contain cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the translate-only fast path: 1. Matrix3d.rotate(angle, out): guard angle===0 to skip Math.cos(0)/sin(0) and write an identity directly. Matches the existing instance-method guard. 2. updateLocalTransform: split rotation/scale into three branches — rotation (full multiply path), scale-only (4-mul .scale() in place), and translate-only. Avoids building a separate scaleRotate matrix and collapses the 8-mul multiply to 4 muls for the common UI-animation case of scale without rotation. 3. CoreNode now eagerly allocates localTransform and globalTransform as identity matrices in the constructor. The Global block reuses these instances via Matrix3d.copy(src, dst) instead of reassigning the field, keeping the field's hidden class stable. The dead `parent.globalTransform || lt` and `if (gt === undefined)` fallbacks are removed. _localIsTranslate now defaults to true since the eagerly allocated matrix is already identity-shape, so the very first update takes the fast path. 4. _hasContainResize cached on the node: updateIsSimple computes it once when texture/textureOptions change, and updateLocalTransform reads the boolean instead of running the optional-chain + string compare on every Local update. Stage root init updated to copy in place into the eagerly-allocated globalTransform rather than reassigning. Adds 12 unit tests covering: Matrix3d.rotate(0) identity output and reset semantics, scale-only path output (ta/td set, tb/tc untouched) including pivot algebra, constructor-time matrix allocation and instance reuse across simple/non-simple frames, and _hasContainResize flag transitions. Co-Authored-By: Claude Opus 4.7 --- src/core/CoreNode.test.ts | 139 ++++++++++++++++++++++++++++++++++ src/core/CoreNode.ts | 94 ++++++++++++++--------- src/core/Stage.ts | 5 +- src/core/lib/Matrix3d.test.ts | 33 ++++++++ src/core/lib/Matrix3d.ts | 19 ++++- 5 files changed, 251 insertions(+), 39 deletions(-) diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 4f27706..2ca7598 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -763,4 +763,143 @@ describe('set color()', () => { expect(rb.y2).toBe(120); }); }); + + describe('updateLocalTransform scale-only fast path', () => { + it('produces correct ta/td without touching tb/tc for scale-only nodes', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + const node = new CoreNode(stage, defaultProps({ parent })); + node.x = 10; + node.y = 20; + node.props.w = 100; + node.props.h = 100; + node.scaleX = 2; + node.scaleY = 3; + + node.update(0, clippingRect); + + const lt = node.localTransform!; + expect(lt.ta).toBe(2); + expect(lt.tb).toBe(0); + expect(lt.tc).toBe(0); + expect(lt.td).toBe(3); + // No pivot configured -> translation is just (x - mountTranslate) + expect(lt.tx).toBe(10); + expect(lt.ty).toBe(20); + }); + + it('applies pivot correctly under scale (no rotation)', () => { + 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 = 0; + node.y = 0; + node.pivot = 0.5; + node.scaleX = 2; + node.scaleY = 2; + + node.update(0, clippingRect); + + // Algebraically: pivot scaling around the center. + // tx = x - mountX*w + pivotX*w*(1 - sx) + // = 0 - 0 + 50*(1-2) = -50 + // ty similarly = -50 + const lt = node.localTransform!; + expect(lt.ta).toBe(2); + expect(lt.tb).toBe(0); + expect(lt.tc).toBe(0); + expect(lt.td).toBe(2); + expect(lt.tx).toBe(-50); + expect(lt.ty).toBe(-50); + }); + }); + + describe('eagerly-allocated transforms (Fix 6)', () => { + it('allocates localTransform and globalTransform on construction', () => { + const node = new CoreNode(stage, defaultProps()); + expect(node.localTransform).toBeInstanceOf(Matrix3d); + expect(node.globalTransform).toBeInstanceOf(Matrix3d); + }); + + it('initial matrices are in identity-shape', () => { + const node = new CoreNode(stage, defaultProps()); + const lt = node.localTransform!; + const gt = node.globalTransform!; + expect(lt.ta).toBe(1); + expect(lt.tb).toBe(0); + expect(lt.tc).toBe(0); + expect(lt.td).toBe(1); + expect(gt.ta).toBe(1); + expect(gt.tb).toBe(0); + expect(gt.tc).toBe(0); + expect(gt.td).toBe(1); + }); + + it('reuses the same globalTransform instance across updates', () => { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.translate(0, 0); + parent._globalIsTranslate = true; + const node = new CoreNode(stage, defaultProps({ parent })); + const gtBefore = node.globalTransform!; + + node.x = 10; + node.y = 20; + node.update(0, clippingRect); + expect(node.globalTransform).toBe(gtBefore); + + node.x = 100; + node.update(1, clippingRect); + expect(node.globalTransform).toBe(gtBefore); + + // And after going into non-simple territory the same instance is still + // mutated in place rather than reallocated. + node.props.w = 100; + node.props.h = 100; + node.pivot = 0.5; + node.rotation = Math.PI / 2; + node.update(2, clippingRect); + expect(node.globalTransform).toBe(gtBefore); + }); + + it('_localIsTranslate defaults to true so the first update can take the fast path', () => { + const node = new CoreNode(stage, defaultProps()); + expect(node._localIsTranslate).toBe(true); + }); + }); + + describe('cached _hasContainResize (Fix 4)', () => { + it('starts as false on a fresh node', () => { + const node = new CoreNode(stage, defaultProps()); + expect(node._hasContainResize).toBe(false); + }); + + it('flips to true when both texture and contain resizeMode are set', () => { + const node = new CoreNode(stage, defaultProps()); + const tex = mock({ state: 'loaded' }); + node.texture = tex; + expect(node._hasContainResize).toBe(false); + + node.textureOptions = { resizeMode: { type: 'contain' } }; + expect(node._hasContainResize).toBe(true); + }); + + it('flips back to false when texture is cleared or resizeMode changes', () => { + const node = new CoreNode(stage, defaultProps()); + const tex = mock({ state: 'loaded' }); + node.texture = tex; + node.textureOptions = { resizeMode: { type: 'contain' } }; + expect(node._hasContainResize).toBe(true); + + node.textureOptions = { resizeMode: { type: 'cover' } }; + expect(node._hasContainResize).toBe(false); + + node.textureOptions = { resizeMode: { type: 'contain' } }; + expect(node._hasContainResize).toBe(true); + + node.texture = null; + expect(node._hasContainResize).toBe(false); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index e8e0519..55ebe83 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -814,9 +814,17 @@ export class CoreNode extends EventEmitter { /** * `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. + * skip redundant ta/tb/tc/td writes between frames. Defaults to `true` + * because the matrix is eagerly allocated as an identity in the constructor. */ - public _localIsTranslate = false; + public _localIsTranslate = true; + /** + * Cached result of the texture `contain` resizeMode check used by + * `updateLocalTransform` and `updateIsSimple`. Updated whenever the + * texture or textureOptions change (via `updateIsSimple`), so the hot + * paths can avoid the optional-chain + string compare on every frame. + */ + public _hasContainResize = 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 @@ -849,6 +857,13 @@ export class CoreNode extends EventEmitter { constructor(readonly stage: Stage, props: CoreNodeProps) { super(); + // Eagerly allocate the local/global transform matrices as identity. + // This keeps the field's hidden class monomorphic (Matrix3d, not + // Matrix3d|undefined) from construction onward and lets the simple + // / translate-only fast paths take effect on the very first update. + this.localTransform = Matrix3d.identity(); + this.globalTransform = Matrix3d.identity(); + //inital update type let initialUpdateType = UpdateType.Local | UpdateType.RenderBounds | UpdateType.RenderState; @@ -1122,10 +1137,15 @@ export class CoreNode extends EventEmitter { const mountTranslateX = p.mountX * w; const mountTranslateY = p.mountY * h; - if (p.rotation !== 0 || p.scaleX !== 1 || p.scaleY !== 1) { - const scaleRotate = Matrix3d.rotate(p.rotation, Matrix3d.temp).scale( - p.scaleX, - p.scaleY, + const rotation = p.rotation; + const scaleX = p.scaleX; + const scaleY = p.scaleY; + + if (rotation !== 0) { + // Full rotation (+ optional scale + pivot) + const scaleRotate = Matrix3d.rotate(rotation, Matrix3d.temp).scale( + scaleX, + scaleY, ); const pivotTranslateX = p.pivotX * w; const pivotTranslateY = p.pivotY * h; @@ -1137,7 +1157,21 @@ export class CoreNode extends EventEmitter { ) .multiply(scaleRotate) .translate(-pivotTranslateX, -pivotTranslateY); + } else if (scaleX !== 1 || scaleY !== 1) { + // Scale (+ optional pivot) without rotation — skip the rotate matrix + // and the 8-mul multiply; `.scale()` is a 4-mul in-place op. + const pivotTranslateX = p.pivotX * w; + const pivotTranslateY = p.pivotY * h; + + this.localTransform = Matrix3d.translate( + x - mountTranslateX + pivotTranslateX, + y - mountTranslateY + pivotTranslateY, + this.localTransform, + ) + .scale(scaleX, scaleY) + .translate(-pivotTranslateX, -pivotTranslateY); } else { + // Mount (or texture-contain) only — pure translation. this.localTransform = Matrix3d.translate( x - mountTranslateX, y - mountTranslateY, @@ -1145,12 +1179,13 @@ export class CoreNode extends EventEmitter { ); } - // Handle 'contain' resize mode + // Handle 'contain' resize mode (cached check; dimensions still need + // a runtime null-check because they're populated asynchronously). const texture = p.texture; if ( - texture && - texture.dimensions && - p.textureOptions.resizeMode?.type === 'contain' + this._hasContainResize === true && + texture !== null && + texture.dimensions !== null ) { let resizeModeScaleX = 1; let resizeModeScaleY = 1; @@ -1188,13 +1223,17 @@ export class CoreNode extends EventEmitter { updateIsSimple() { const p = this.props; + // Cache the texture-contain check so updateLocalTransform doesn't have to + // run the optional-chain + string compare on every Local update. + this._hasContainResize = + p.texture !== null && p.textureOptions?.resizeMode?.type === 'contain'; this.isSimple = p.rotation === 0 && p.scaleX === 1 && p.scaleY === 1 && p.mountX === 0 && p.mountY === 0 && - !(p.texture && p.textureOptions.resizeMode?.type === 'contain'); + this._hasContainResize === false; } /** @@ -1237,6 +1276,7 @@ export class CoreNode extends EventEmitter { if (updateType & UpdateType.Global) { const lt = this.localTransform!; + const gt = this.globalTransform!; let fastPathApplied = false; if ( @@ -1246,7 +1286,7 @@ export class CoreNode extends EventEmitter { ) { // we are at the start of the RTT chain, so we need to reset the globalTransform // for correct RTT rendering - this.globalTransform = Matrix3d.identity(this.globalTransform); + Matrix3d.identity(gt); // Maintain a full scene global transform for bounds detection const parentTransform = @@ -1274,28 +1314,18 @@ export class CoreNode extends EventEmitter { this.sceneGlobalTransform, ).translateOrMultiply(lt); - this.globalTransform = Matrix3d.copy( - parent.globalTransform || lt, - this.globalTransform, - ); + Matrix3d.copy(parent.globalTransform!, gt); // Conservative: RTT chains rarely hit the translate fast path this._globalIsTranslate = false; } else { // Common non-RTT path - const parentGT = parent.globalTransform; - if ( - this.isSimple === true && - parentGT !== undefined && - parent._globalIsTranslate === true - ) { + const parentGT = parent.globalTransform!; + if (this.isSimple === true && 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) { + 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; @@ -1307,21 +1337,17 @@ export class CoreNode extends EventEmitter { this._globalIsTranslate = true; fastPathApplied = true; } else { - this.globalTransform = Matrix3d.copy( - parentGT || lt, - this.globalTransform, - ); + Matrix3d.copy(parentGT, gt); this._globalIsTranslate = - this.isSimple === true && - (parentGT === undefined || parent._globalIsTranslate === true); + this.isSimple === true && parent._globalIsTranslate === true; } } if (fastPathApplied === false) { if (this.isSimple) { - this.globalTransform!.translate(lt.tx, lt.ty); + gt.translate(lt.tx, lt.ty); } else { - this.globalTransform!.translateOrMultiply(lt); + gt.translateOrMultiply(lt); } } this.calculateRenderCoords(); diff --git a/src/core/Stage.ts b/src/core/Stage.ts index d5495bb..c528700 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -359,9 +359,10 @@ export class Stage { this.root = rootNode; - // Initialize root node properties + // Initialize root node properties. Copy in place into the eagerly-allocated + // matrices so the hidden class for these fields stays stable. rootNode.updateLocalTransform(); - rootNode.globalTransform = Matrix3d.copy(rootNode.localTransform!); + Matrix3d.copy(rootNode.localTransform!, rootNode.globalTransform); rootNode.sceneGlobalTransform = Matrix3d.copy(rootNode.localTransform!); rootNode.calculateRenderCoords(); rootNode.updateBoundingRect(); diff --git a/src/core/lib/Matrix3d.test.ts b/src/core/lib/Matrix3d.test.ts index ac7ad9b..69d3409 100644 --- a/src/core/lib/Matrix3d.test.ts +++ b/src/core/lib/Matrix3d.test.ts @@ -37,3 +37,36 @@ describe('Matrix3d.setTranslate', () => { expect(m.ty).toBe(0); }); }); + +describe('Matrix3d.rotate(0) fast path', () => { + it('produces an identity matrix for angle=0', () => { + const m = Matrix3d.rotate(0); + expect(m.ta).toBe(1); + expect(m.tb).toBe(0); + expect(m.tc).toBe(0); + expect(m.td).toBe(1); + expect(m.tx).toBe(0); + expect(m.ty).toBe(0); + }); + + it('resets a pre-populated out matrix to identity on angle=0', () => { + const m = Matrix3d.rotate(Math.PI / 4); + // Now reuse `m` with angle=0; should overwrite to identity. + Matrix3d.rotate(0, m); + expect(m.ta).toBe(1); + expect(m.tb).toBe(0); + expect(m.tc).toBe(0); + expect(m.td).toBe(1); + expect(m.tx).toBe(0); + expect(m.ty).toBe(0); + }); + + it('still produces a real rotation when angle != 0', () => { + const m = Matrix3d.rotate(Math.PI / 2); + // cos(pi/2) ~ 0, sin(pi/2) = 1 + expect(Math.abs(m.ta)).toBeLessThan(1e-10); + expect(m.tb).toBeCloseTo(-1, 10); + expect(m.tc).toBeCloseTo(1, 10); + expect(Math.abs(m.td)).toBeLessThan(1e-10); + }); +}); diff --git a/src/core/lib/Matrix3d.ts b/src/core/lib/Matrix3d.ts index 7619962..e36c439 100644 --- a/src/core/lib/Matrix3d.ts +++ b/src/core/lib/Matrix3d.ts @@ -124,11 +124,24 @@ export class Matrix3d { } public static rotate(angle: number, out?: Matrix3d): Matrix3d { - const cos = Math.cos(angle); - const sin = Math.sin(angle); - if (!out) { + if (out === undefined) { out = new Matrix3d(); } + if (angle === 0) { + // Skip Math.cos(0) / Math.sin(0) — neither V8 nor JSC constant-folds + // these calls. Identity rotation is the common case when callers + // combine rotate().scale() and only the scale is non-default. + out.ta = 1; + out.tb = 0; + out.tx = 0; + out.tc = 0; + out.td = 1; + out.ty = 0; + out.mutation = true; + return out; + } + const cos = Math.cos(angle); + const sin = Math.sin(angle); out.ta = cos; out.tb = -sin; out.tx = 0;