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;