Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions src/core/CoreNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ImageTexture>({ 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<ImageTexture>({ 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);
});
});
});
94 changes: 60 additions & 34 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -1137,20 +1157,35 @@ 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,
this.localTransform,
);
}

// 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;
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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 (
Expand All @@ -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 =
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
5 changes: 3 additions & 2 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions src/core/lib/Matrix3d.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading