@solidtv/renderer 1.5.1 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/core/CoreNode.d.ts +26 -0
- package/dist/src/core/CoreNode.js +32 -7
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +7 -2
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.d.ts +6 -0
- package/dist/src/core/Stage.js +9 -0
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/lib/collectionUtils.d.ts +1 -1
- package/dist/src/core/lib/collectionUtils.js +13 -31
- package/dist/src/core/lib/collectionUtils.js.map +1 -1
- package/dist/src/main-api/Renderer.d.ts +23 -0
- package/dist/src/main-api/Renderer.js +2 -0
- package/dist/src/main-api/Renderer.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/CoreNode.test.ts +249 -0
- package/src/core/CoreNode.ts +64 -9
- package/src/core/CoreTextNode.test.ts +62 -0
- package/src/core/CoreTextNode.ts +7 -2
- package/src/core/Stage.ts +9 -0
- package/src/core/lib/collectionUtils.test.ts +91 -0
- package/src/core/lib/collectionUtils.ts +13 -34
- package/src/main-api/Renderer.ts +26 -0
|
@@ -13,6 +13,7 @@ import { premultiplyColorABGR } from '../utils.js';
|
|
|
13
13
|
describe('set color()', () => {
|
|
14
14
|
const defaultProps = (overrides?: Partial<CoreNodeProps>): CoreNodeProps => ({
|
|
15
15
|
alpha: 0,
|
|
16
|
+
ignoreParentAlpha: false,
|
|
16
17
|
autosize: false,
|
|
17
18
|
boundsMargin: null,
|
|
18
19
|
clipping: false,
|
|
@@ -264,6 +265,112 @@ describe('set color()', () => {
|
|
|
264
265
|
});
|
|
265
266
|
});
|
|
266
267
|
|
|
268
|
+
describe('ignoreParentAlpha', () => {
|
|
269
|
+
const makeParent = (worldAlpha: number) => {
|
|
270
|
+
const parent = new CoreNode(stage, defaultProps());
|
|
271
|
+
parent.globalTransform = Matrix3d.identity();
|
|
272
|
+
parent.worldAlpha = worldAlpha;
|
|
273
|
+
return parent;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
it('multiplies parent world alpha by default', () => {
|
|
277
|
+
const parent = makeParent(0.5);
|
|
278
|
+
const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
|
|
279
|
+
|
|
280
|
+
node.update(0, clippingRect);
|
|
281
|
+
|
|
282
|
+
expect(node.worldAlpha).toBeCloseTo(0.4);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('uses own alpha only when enabled', () => {
|
|
286
|
+
const parent = makeParent(0.5);
|
|
287
|
+
const node = new CoreNode(
|
|
288
|
+
stage,
|
|
289
|
+
defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
node.update(0, clippingRect);
|
|
293
|
+
|
|
294
|
+
expect(node.worldAlpha).toBe(0.8);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('keeps its own world alpha while the parent fades toward 0', () => {
|
|
298
|
+
const parent = makeParent(0.01);
|
|
299
|
+
const node = new CoreNode(
|
|
300
|
+
stage,
|
|
301
|
+
defaultProps({ parent, alpha: 1, ignoreParentAlpha: true }),
|
|
302
|
+
);
|
|
303
|
+
node.w = 100;
|
|
304
|
+
node.h = 100;
|
|
305
|
+
node.color = 0xffffffff;
|
|
306
|
+
|
|
307
|
+
node.update(0, clippingRect);
|
|
308
|
+
|
|
309
|
+
expect(node.worldAlpha).toBe(1);
|
|
310
|
+
expect(node.isRenderable).toBe(true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('premultiplies colors with the node own alpha when enabled', () => {
|
|
314
|
+
const parent = makeParent(0.25);
|
|
315
|
+
const node = new CoreNode(
|
|
316
|
+
stage,
|
|
317
|
+
defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
|
|
318
|
+
);
|
|
319
|
+
node.w = 100;
|
|
320
|
+
node.h = 100;
|
|
321
|
+
node.color = 0xff0000ff;
|
|
322
|
+
|
|
323
|
+
node.update(0, clippingRect);
|
|
324
|
+
|
|
325
|
+
expect(node.premultipliedColorTl).toBe(
|
|
326
|
+
premultiplyColorABGR(0xff0000ff, 0.8),
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('toggling the setter recomputes world alpha', () => {
|
|
331
|
+
const parent = makeParent(0.5);
|
|
332
|
+
const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
|
|
333
|
+
|
|
334
|
+
node.update(0, clippingRect);
|
|
335
|
+
expect(node.worldAlpha).toBeCloseTo(0.4);
|
|
336
|
+
|
|
337
|
+
node.ignoreParentAlpha = true;
|
|
338
|
+
node.update(1, clippingRect);
|
|
339
|
+
expect(node.worldAlpha).toBe(0.8);
|
|
340
|
+
|
|
341
|
+
node.ignoreParentAlpha = false;
|
|
342
|
+
node.update(2, clippingRect);
|
|
343
|
+
expect(node.worldAlpha).toBeCloseTo(0.4);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('setting the same value does not flag an update', () => {
|
|
347
|
+
const node = new CoreNode(stage, defaultProps());
|
|
348
|
+
const updateTypeBefore = node.updateType;
|
|
349
|
+
|
|
350
|
+
node.ignoreParentAlpha = false;
|
|
351
|
+
|
|
352
|
+
expect(node.updateType).toBe(updateTypeBefore);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('descendants inherit the node world alpha as usual', () => {
|
|
356
|
+
const parent = makeParent(0.5);
|
|
357
|
+
const node = new CoreNode(
|
|
358
|
+
stage,
|
|
359
|
+
defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
|
|
360
|
+
);
|
|
361
|
+
const child = new CoreNode(
|
|
362
|
+
stage,
|
|
363
|
+
defaultProps({ parent: node, alpha: 0.5 }),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
node.update(0, clippingRect);
|
|
367
|
+
child.update(0, clippingRect);
|
|
368
|
+
|
|
369
|
+
expect(node.worldAlpha).toBe(0.8);
|
|
370
|
+
expect(child.worldAlpha).toBeCloseTo(0.4);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
267
374
|
describe('autosize system', () => {
|
|
268
375
|
it('should initialize with autosize disabled', () => {
|
|
269
376
|
const node = new CoreNode(stage, defaultProps());
|
|
@@ -1463,4 +1570,146 @@ describe('set color()', () => {
|
|
|
1463
1570
|
expect(node.renderTexture).toBe(texture);
|
|
1464
1571
|
});
|
|
1465
1572
|
});
|
|
1573
|
+
|
|
1574
|
+
describe('renderOnlyInViewport', () => {
|
|
1575
|
+
// Viewport is 0..200; the preload (bounds-margin) ring extends to 400.
|
|
1576
|
+
// A node at x=250 is InBounds (margin ring); at x=50 it is InViewport;
|
|
1577
|
+
// at x=500 it is OutOfBounds.
|
|
1578
|
+
function boundsStage(renderOnlyInViewport: boolean): Stage {
|
|
1579
|
+
return mock<Stage>({
|
|
1580
|
+
strictBound: createBound(0, 0, 200, 200),
|
|
1581
|
+
preloadBound: createBound(0, 0, 400, 200),
|
|
1582
|
+
defaultTexture: {
|
|
1583
|
+
state: 'loaded',
|
|
1584
|
+
},
|
|
1585
|
+
renderer: mock<CoreRenderer>() as CoreRenderer,
|
|
1586
|
+
renderOnlyInViewport,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function loadedTextureNode(stage: Stage, x: number): CoreNode {
|
|
1591
|
+
const parent = new CoreNode(stage, defaultProps());
|
|
1592
|
+
parent.globalTransform = Matrix3d.identity();
|
|
1593
|
+
parent.worldAlpha = 1;
|
|
1594
|
+
|
|
1595
|
+
const node = new CoreNode(stage, defaultProps({ parent }));
|
|
1596
|
+
node.alpha = 1;
|
|
1597
|
+
node.x = x;
|
|
1598
|
+
node.y = 0;
|
|
1599
|
+
node.w = 100;
|
|
1600
|
+
node.h = 100;
|
|
1601
|
+
node.texture = mock<ImageTexture>({
|
|
1602
|
+
state: 'loaded',
|
|
1603
|
+
setRenderableOwner: vi.fn(),
|
|
1604
|
+
});
|
|
1605
|
+
node.textureLoaded = true;
|
|
1606
|
+
return node;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
it('off: a margin-ring node is renderable (legacy behavior)', () => {
|
|
1610
|
+
const node = loadedTextureNode(boundsStage(false), 250);
|
|
1611
|
+
|
|
1612
|
+
node.update(0, clippingRect);
|
|
1613
|
+
|
|
1614
|
+
expect(node.isRenderable).toBe(true);
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('on: a margin-ring node is not renderable but still owns its texture', () => {
|
|
1618
|
+
const node = loadedTextureNode(boundsStage(true), 250);
|
|
1619
|
+
|
|
1620
|
+
node.update(0, clippingRect);
|
|
1621
|
+
|
|
1622
|
+
expect(node.isRenderable).toBe(false);
|
|
1623
|
+
// Ownership is the load trigger and cleanup protection — it must stay.
|
|
1624
|
+
expect(node.texture!.setRenderableOwner).toHaveBeenCalledWith(
|
|
1625
|
+
expect.anything(),
|
|
1626
|
+
true,
|
|
1627
|
+
);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it('on: a viewport node is renderable', () => {
|
|
1631
|
+
const node = loadedTextureNode(boundsStage(true), 50);
|
|
1632
|
+
|
|
1633
|
+
node.update(0, clippingRect);
|
|
1634
|
+
|
|
1635
|
+
expect(node.isRenderable).toBe(true);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
it('on: a node becomes renderable when it crosses into the viewport', () => {
|
|
1639
|
+
const node = loadedTextureNode(boundsStage(true), 250);
|
|
1640
|
+
node.update(0, clippingRect);
|
|
1641
|
+
expect(node.isRenderable).toBe(false);
|
|
1642
|
+
|
|
1643
|
+
// Scroll it in.
|
|
1644
|
+
node.x = 50;
|
|
1645
|
+
node.update(1, clippingRect);
|
|
1646
|
+
expect(node.isRenderable).toBe(true);
|
|
1647
|
+
|
|
1648
|
+
// And back out into the ring.
|
|
1649
|
+
node.x = 250;
|
|
1650
|
+
node.update(2, clippingRect);
|
|
1651
|
+
expect(node.isRenderable).toBe(false);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it('on: an out-of-bounds node releases texture ownership', () => {
|
|
1655
|
+
const node = loadedTextureNode(boundsStage(true), 500);
|
|
1656
|
+
|
|
1657
|
+
node.update(0, clippingRect);
|
|
1658
|
+
|
|
1659
|
+
expect(node.isRenderable).toBe(false);
|
|
1660
|
+
expect(node.texture!.setRenderableOwner).not.toHaveBeenCalledWith(
|
|
1661
|
+
expect.anything(),
|
|
1662
|
+
true,
|
|
1663
|
+
);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it('on: a margin-ring placeholder is gated the same way', () => {
|
|
1667
|
+
const stage = boundsStage(true);
|
|
1668
|
+
const parent = new CoreNode(stage, defaultProps());
|
|
1669
|
+
parent.globalTransform = Matrix3d.identity();
|
|
1670
|
+
parent.worldAlpha = 1;
|
|
1671
|
+
|
|
1672
|
+
const node = new CoreNode(stage, defaultProps({ parent }));
|
|
1673
|
+
node.alpha = 1;
|
|
1674
|
+
node.x = 250;
|
|
1675
|
+
node.y = 0;
|
|
1676
|
+
node.w = 100;
|
|
1677
|
+
node.h = 100;
|
|
1678
|
+
node.placeholderColor = 0x336699ff;
|
|
1679
|
+
node.texture = mock<ImageTexture>({
|
|
1680
|
+
state: 'initial',
|
|
1681
|
+
setRenderableOwner: vi.fn(),
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
node.update(0, clippingRect);
|
|
1685
|
+
expect(node.placeholderActive).toBe(true);
|
|
1686
|
+
expect(node.isRenderable).toBe(false);
|
|
1687
|
+
|
|
1688
|
+
node.x = 50;
|
|
1689
|
+
node.update(1, clippingRect);
|
|
1690
|
+
expect(node.isRenderable).toBe(true);
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
it('on: color-only nodes in the margin ring are gated too', () => {
|
|
1694
|
+
const stage = boundsStage(true);
|
|
1695
|
+
const parent = new CoreNode(stage, defaultProps());
|
|
1696
|
+
parent.globalTransform = Matrix3d.identity();
|
|
1697
|
+
parent.worldAlpha = 1;
|
|
1698
|
+
|
|
1699
|
+
const node = new CoreNode(stage, defaultProps({ parent }));
|
|
1700
|
+
node.alpha = 1;
|
|
1701
|
+
node.x = 250;
|
|
1702
|
+
node.y = 0;
|
|
1703
|
+
node.w = 100;
|
|
1704
|
+
node.h = 100;
|
|
1705
|
+
node.color = 0xff0000ff;
|
|
1706
|
+
|
|
1707
|
+
node.update(0, clippingRect);
|
|
1708
|
+
expect(node.isRenderable).toBe(false);
|
|
1709
|
+
|
|
1710
|
+
node.x = 50;
|
|
1711
|
+
node.update(1, clippingRect);
|
|
1712
|
+
expect(node.isRenderable).toBe(true);
|
|
1713
|
+
});
|
|
1714
|
+
});
|
|
1466
1715
|
});
|
package/src/core/CoreNode.ts
CHANGED
|
@@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js';
|
|
|
46
46
|
import { createAnimation } from './animations/CoreAnimation.js';
|
|
47
47
|
import type { CoreShaderNode } from './renderers/CoreShaderNode.js';
|
|
48
48
|
import { AutosizeMode, Autosizer } from './Autosizer.js';
|
|
49
|
-
import { removeChild } from './lib/collectionUtils.js';
|
|
49
|
+
import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js';
|
|
50
50
|
|
|
51
51
|
export enum CoreNodeRenderState {
|
|
52
52
|
Init = 0,
|
|
@@ -63,11 +63,6 @@ const NO_CLIPPING_RECT: RectWithValid = {
|
|
|
63
63
|
valid: false,
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
-
// Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on
|
|
67
|
-
// every z-index reorder.
|
|
68
|
-
const compareZIndex = (a: CoreNode, b: CoreNode): number =>
|
|
69
|
-
a.props.zIndex - b.props.zIndex;
|
|
70
|
-
|
|
71
66
|
const CoreNodeRenderStateMap: Map<CoreNodeRenderState, string> = new Map();
|
|
72
67
|
CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init');
|
|
73
68
|
CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds');
|
|
@@ -136,6 +131,7 @@ export enum UpdateType {
|
|
|
136
131
|
* @remarks
|
|
137
132
|
* CoreNode Properties Updated:
|
|
138
133
|
* - `worldAlpha` = `parent.worldAlpha` * `alpha`
|
|
134
|
+
* (or just `alpha` when `ignoreParentAlpha` is enabled)
|
|
139
135
|
*/
|
|
140
136
|
WorldAlpha = 64,
|
|
141
137
|
|
|
@@ -250,6 +246,29 @@ export interface CoreNodeProps {
|
|
|
250
246
|
* @default `1`
|
|
251
247
|
*/
|
|
252
248
|
alpha: number;
|
|
249
|
+
/**
|
|
250
|
+
* When enabled, the Node's world alpha is computed from its own
|
|
251
|
+
* {@link alpha} only, ignoring the alpha inherited from its ancestors.
|
|
252
|
+
*
|
|
253
|
+
* @remarks
|
|
254
|
+
* Normally `worldAlpha = parent.worldAlpha * alpha`, so fading a parent
|
|
255
|
+
* fades every descendant with it. With `ignoreParentAlpha` enabled this
|
|
256
|
+
* Node keeps rendering at its own alpha while its parent (and the rest of
|
|
257
|
+
* the subtree) fades.
|
|
258
|
+
*
|
|
259
|
+
* Subtrees whose world alpha reaches exactly 0 are culled from rendering
|
|
260
|
+
* entirely, so this Node still disappears once an ancestor hits alpha 0 —
|
|
261
|
+
* the prop only has an effect while every ancestor's alpha is above 0.
|
|
262
|
+
* This keeps the fully-transparent subtree cull free of bookkeeping.
|
|
263
|
+
*
|
|
264
|
+
* Descendants of this Node inherit from its world alpha as usual.
|
|
265
|
+
*
|
|
266
|
+
* Has no effect inside a render-to-texture subtree: the RTT root's
|
|
267
|
+
* composited quad is still faded as a single unit by its own world alpha.
|
|
268
|
+
*
|
|
269
|
+
* @default `false`
|
|
270
|
+
*/
|
|
271
|
+
ignoreParentAlpha: boolean;
|
|
253
272
|
/**
|
|
254
273
|
* Autosize
|
|
255
274
|
*
|
|
@@ -1453,7 +1472,10 @@ export class CoreNode extends EventEmitter {
|
|
|
1453
1472
|
}
|
|
1454
1473
|
|
|
1455
1474
|
if (updateType & UpdateType.WorldAlpha) {
|
|
1456
|
-
this.worldAlpha =
|
|
1475
|
+
this.worldAlpha =
|
|
1476
|
+
props.ignoreParentAlpha === true
|
|
1477
|
+
? props.alpha
|
|
1478
|
+
: parent.worldAlpha * props.alpha;
|
|
1457
1479
|
updateType |=
|
|
1458
1480
|
UpdateType.PremultipliedColors |
|
|
1459
1481
|
UpdateType.Children |
|
|
@@ -1833,7 +1855,11 @@ export class CoreNode extends EventEmitter {
|
|
|
1833
1855
|
// texture has failed to load, we cannot render the texture itself —
|
|
1834
1856
|
// but a placeholder color still renders in its place
|
|
1835
1857
|
this.updateTextureOwnership(false);
|
|
1836
|
-
this.setRenderable(
|
|
1858
|
+
this.setRenderable(
|
|
1859
|
+
this.placeholderActive === true &&
|
|
1860
|
+
(this.stage.renderOnlyInViewport === false ||
|
|
1861
|
+
this.renderState === CoreNodeRenderState.InViewport),
|
|
1862
|
+
);
|
|
1837
1863
|
return;
|
|
1838
1864
|
}
|
|
1839
1865
|
|
|
@@ -1853,6 +1879,17 @@ export class CoreNode extends EventEmitter {
|
|
|
1853
1879
|
newIsRenderable = true;
|
|
1854
1880
|
}
|
|
1855
1881
|
|
|
1882
|
+
// renderOnlyInViewport: nodes in the preload margin keep texture
|
|
1883
|
+
// ownership above (so loading proceeds) but stay out of the render list
|
|
1884
|
+
// until they actually intersect the viewport.
|
|
1885
|
+
if (
|
|
1886
|
+
newIsRenderable === true &&
|
|
1887
|
+
this.stage.renderOnlyInViewport === true &&
|
|
1888
|
+
this.renderState !== CoreNodeRenderState.InViewport
|
|
1889
|
+
) {
|
|
1890
|
+
newIsRenderable = false;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1856
1893
|
this.updateTextureOwnership(needsTextureOwnership);
|
|
1857
1894
|
this.setRenderable(newIsRenderable);
|
|
1858
1895
|
}
|
|
@@ -2165,7 +2202,7 @@ export class CoreNode extends EventEmitter {
|
|
|
2165
2202
|
}
|
|
2166
2203
|
|
|
2167
2204
|
sortChildren() {
|
|
2168
|
-
this.children
|
|
2205
|
+
sortByZIndexStable(this.children);
|
|
2169
2206
|
this.stage.requestRenderListUpdate();
|
|
2170
2207
|
}
|
|
2171
2208
|
|
|
@@ -2508,6 +2545,24 @@ export class CoreNode extends EventEmitter {
|
|
|
2508
2545
|
this.childUpdateType |= UpdateType.WorldAlpha;
|
|
2509
2546
|
}
|
|
2510
2547
|
|
|
2548
|
+
get ignoreParentAlpha(): boolean {
|
|
2549
|
+
return this.props.ignoreParentAlpha;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
set ignoreParentAlpha(value: boolean) {
|
|
2553
|
+
if (this.props.ignoreParentAlpha === value) {
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
this.props.ignoreParentAlpha = value;
|
|
2557
|
+
this.setUpdateType(
|
|
2558
|
+
UpdateType.PremultipliedColors |
|
|
2559
|
+
UpdateType.WorldAlpha |
|
|
2560
|
+
UpdateType.Children |
|
|
2561
|
+
UpdateType.IsRenderable,
|
|
2562
|
+
);
|
|
2563
|
+
this.childUpdateType |= UpdateType.WorldAlpha;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2511
2566
|
get autosize(): boolean {
|
|
2512
2567
|
return this.props.autosize;
|
|
2513
2568
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { mock } from 'vitest-mock-extended';
|
|
3
3
|
import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
|
|
4
|
+
import { CoreNodeRenderState } from './CoreNode.js';
|
|
4
5
|
import { Stage } from './Stage.js';
|
|
5
6
|
import { CoreRenderer } from './renderers/CoreRenderer.js';
|
|
6
7
|
import { createBound } from './lib/utils.js';
|
|
@@ -16,6 +17,7 @@ const defaultProps = (
|
|
|
16
17
|
): CoreTextNodeProps => ({
|
|
17
18
|
// CoreNodeProps
|
|
18
19
|
alpha: 1,
|
|
20
|
+
ignoreParentAlpha: false,
|
|
19
21
|
autosize: false,
|
|
20
22
|
boundsMargin: null,
|
|
21
23
|
clipping: false,
|
|
@@ -144,3 +146,63 @@ describe('CoreTextNode (canvas) clearing text', () => {
|
|
|
144
146
|
expect(node.isRenderable).toBe(false);
|
|
145
147
|
});
|
|
146
148
|
});
|
|
149
|
+
|
|
150
|
+
describe('CoreTextNode (sdf) renderOnlyInViewport', () => {
|
|
151
|
+
const makeSdfRenderer = (): TextRenderer => {
|
|
152
|
+
const font = mock<FontHandler>({ type: 'sdf' });
|
|
153
|
+
return {
|
|
154
|
+
type: 'sdf',
|
|
155
|
+
font,
|
|
156
|
+
renderText: vi.fn(),
|
|
157
|
+
addQuads: vi.fn(),
|
|
158
|
+
renderQuads: vi.fn(),
|
|
159
|
+
init: vi.fn(),
|
|
160
|
+
} as unknown as TextRenderer;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
function sdfNodeWithLayout(renderOnlyInViewport: boolean): CoreTextNode {
|
|
164
|
+
const stage = mock<Stage>({
|
|
165
|
+
strictBound: createBound(0, 0, 200, 200),
|
|
166
|
+
preloadBound: createBound(0, 0, 400, 200),
|
|
167
|
+
defaultTexture: { state: 'loaded' } as never,
|
|
168
|
+
defShaderNode: null as never,
|
|
169
|
+
renderer: mock<CoreRenderer>() as CoreRenderer,
|
|
170
|
+
renderOnlyInViewport,
|
|
171
|
+
});
|
|
172
|
+
const node = new CoreTextNode(
|
|
173
|
+
stage,
|
|
174
|
+
defaultProps({ text: 'Hello' }),
|
|
175
|
+
makeSdfRenderer(),
|
|
176
|
+
);
|
|
177
|
+
(node as unknown as { _cachedLayout: object })._cachedLayout = {};
|
|
178
|
+
node.worldAlpha = 1;
|
|
179
|
+
return node;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('off: margin-ring SDF text is renderable (legacy behavior)', () => {
|
|
183
|
+
const node = sdfNodeWithLayout(false);
|
|
184
|
+
node.renderState = CoreNodeRenderState.InBounds;
|
|
185
|
+
|
|
186
|
+
node.updateIsRenderable();
|
|
187
|
+
|
|
188
|
+
expect(node.isRenderable).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('on: margin-ring SDF text stays out of the render list', () => {
|
|
192
|
+
const node = sdfNodeWithLayout(true);
|
|
193
|
+
node.renderState = CoreNodeRenderState.InBounds;
|
|
194
|
+
|
|
195
|
+
node.updateIsRenderable();
|
|
196
|
+
|
|
197
|
+
expect(node.isRenderable).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('on: viewport SDF text is renderable', () => {
|
|
201
|
+
const node = sdfNodeWithLayout(true);
|
|
202
|
+
node.renderState = CoreNodeRenderState.InViewport;
|
|
203
|
+
|
|
204
|
+
node.updateIsRenderable();
|
|
205
|
+
|
|
206
|
+
expect(node.isRenderable).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
});
|
package/src/core/CoreTextNode.ts
CHANGED
|
@@ -227,9 +227,14 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
|
|
|
227
227
|
return;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
// For SDF, check if we have a cached layout
|
|
230
|
+
// For SDF, check if we have a cached layout. renderOnlyInViewport gates
|
|
231
|
+
// SDF text the same way CoreNode gates quads: margin-ring text stays out
|
|
232
|
+
// of the render list until it intersects the viewport.
|
|
231
233
|
this.setRenderable(
|
|
232
|
-
this.checkBasicRenderability() === true &&
|
|
234
|
+
this.checkBasicRenderability() === true &&
|
|
235
|
+
this._cachedLayout !== null &&
|
|
236
|
+
(this.stage.renderOnlyInViewport === false ||
|
|
237
|
+
this.renderState === CoreNodeRenderState.InViewport),
|
|
233
238
|
);
|
|
234
239
|
}
|
|
235
240
|
|
package/src/core/Stage.ts
CHANGED
|
@@ -97,6 +97,12 @@ export class Stage {
|
|
|
97
97
|
public readonly renderer: CoreRenderer;
|
|
98
98
|
public readonly root: CoreNode;
|
|
99
99
|
public boundsMargin: [number, number, number, number];
|
|
100
|
+
/**
|
|
101
|
+
* When true, nodes inside the bounds margin but outside the viewport keep
|
|
102
|
+
* loading textures yet stay out of the render list. Read by
|
|
103
|
+
* `CoreNode.updateIsRenderable` on the scroll path.
|
|
104
|
+
*/
|
|
105
|
+
public readonly renderOnlyInViewport: boolean;
|
|
100
106
|
public readonly defShaderNode: CoreShaderNode | null = null;
|
|
101
107
|
public strictBound: Bound;
|
|
102
108
|
public preloadBound: Bound;
|
|
@@ -208,6 +214,7 @@ export class Stage {
|
|
|
208
214
|
setBaselineMode(options.textBaselineMode);
|
|
209
215
|
|
|
210
216
|
this.platform = platform;
|
|
217
|
+
this.renderOnlyInViewport = options.renderOnlyInViewport !== false;
|
|
211
218
|
|
|
212
219
|
this.startTime = platform.getTimeStamp();
|
|
213
220
|
|
|
@@ -372,6 +379,7 @@ export class Stage {
|
|
|
372
379
|
w: appWidth,
|
|
373
380
|
h: appHeight,
|
|
374
381
|
alpha: 1,
|
|
382
|
+
ignoreParentAlpha: false,
|
|
375
383
|
autosize: false,
|
|
376
384
|
boundsMargin: null,
|
|
377
385
|
clipping: false,
|
|
@@ -1032,6 +1040,7 @@ export class Stage {
|
|
|
1032
1040
|
w: props.w ?? 0,
|
|
1033
1041
|
h: props.h ?? 0,
|
|
1034
1042
|
alpha: props.alpha ?? 1,
|
|
1043
|
+
ignoreParentAlpha: props.ignoreParentAlpha ?? false,
|
|
1035
1044
|
autosize: props.autosize ?? false,
|
|
1036
1045
|
boundsMargin: props.boundsMargin ?? null,
|
|
1037
1046
|
clipping: props.clipping ?? false,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { CoreNode } from '../CoreNode.js';
|
|
3
|
+
import { sortByZIndexStable } from './collectionUtils.js';
|
|
4
|
+
|
|
5
|
+
const makeNode = (zIndex: number, tag: number): CoreNode =>
|
|
6
|
+
({ props: { zIndex }, tag } as unknown as CoreNode);
|
|
7
|
+
|
|
8
|
+
const tags = (nodes: CoreNode[]): number[] => {
|
|
9
|
+
const out: number[] = [];
|
|
10
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
11
|
+
out.push((nodes[i] as unknown as { tag: number }).tag);
|
|
12
|
+
}
|
|
13
|
+
return out;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('sortByZIndexStable', () => {
|
|
17
|
+
it('should sort nodes ascending by zIndex', () => {
|
|
18
|
+
const nodes = [makeNode(3, 0), makeNode(1, 1), makeNode(2, 2)];
|
|
19
|
+
|
|
20
|
+
sortByZIndexStable(nodes);
|
|
21
|
+
|
|
22
|
+
expect(tags(nodes)).toEqual([1, 2, 0]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should preserve insertion order for equal zIndex values', () => {
|
|
26
|
+
const nodes = [
|
|
27
|
+
makeNode(1, 0),
|
|
28
|
+
makeNode(0, 1),
|
|
29
|
+
makeNode(1, 2),
|
|
30
|
+
makeNode(0, 3),
|
|
31
|
+
makeNode(1, 4),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
sortByZIndexStable(nodes);
|
|
35
|
+
|
|
36
|
+
expect(tags(nodes)).toEqual([1, 3, 0, 2, 4]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should be stable for arrays longer than 10 elements', () => {
|
|
40
|
+
// V8 on Chrome < 70 switched to an unstable quicksort above 10 elements;
|
|
41
|
+
// this guards the case the native sort got wrong.
|
|
42
|
+
const nodes: CoreNode[] = [];
|
|
43
|
+
for (let i = 0; i < 20; i++) {
|
|
44
|
+
nodes.push(makeNode(i % 2, i));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
sortByZIndexStable(nodes);
|
|
48
|
+
|
|
49
|
+
const expected: number[] = [];
|
|
50
|
+
for (let i = 0; i < 20; i += 2) {
|
|
51
|
+
expected.push(i);
|
|
52
|
+
}
|
|
53
|
+
for (let i = 1; i < 20; i += 2) {
|
|
54
|
+
expected.push(i);
|
|
55
|
+
}
|
|
56
|
+
expect(tags(nodes)).toEqual(expected);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should handle negative and fractional zIndex values', () => {
|
|
60
|
+
const nodes = [
|
|
61
|
+
makeNode(0.5, 0),
|
|
62
|
+
makeNode(-2, 1),
|
|
63
|
+
makeNode(1000000000, 2),
|
|
64
|
+
makeNode(0, 3),
|
|
65
|
+
makeNode(-2, 4),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
sortByZIndexStable(nodes);
|
|
69
|
+
|
|
70
|
+
expect(tags(nodes)).toEqual([1, 4, 3, 0, 2]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should leave an already sorted array unchanged', () => {
|
|
74
|
+
const nodes = [makeNode(0, 0), makeNode(0, 1), makeNode(1, 2)];
|
|
75
|
+
|
|
76
|
+
sortByZIndexStable(nodes);
|
|
77
|
+
|
|
78
|
+
expect(tags(nodes)).toEqual([0, 1, 2]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle empty and single-element arrays', () => {
|
|
82
|
+
const empty: CoreNode[] = [];
|
|
83
|
+
const single = [makeNode(5, 0)];
|
|
84
|
+
|
|
85
|
+
sortByZIndexStable(empty);
|
|
86
|
+
sortByZIndexStable(single);
|
|
87
|
+
|
|
88
|
+
expect(empty.length).toBe(0);
|
|
89
|
+
expect(tags(single)).toEqual([0]);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -1,44 +1,23 @@
|
|
|
1
1
|
import type { CoreNode } from '../CoreNode.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
// Stable in-place sort by zIndex. Array.prototype.sort is not stable on
|
|
4
|
+
// Chrome < 70 (V8 quicksorts arrays longer than 10), and equal-zIndex
|
|
5
|
+
// siblings must keep insertion order or paint order silently reshuffles.
|
|
6
|
+
// Children arrays are small and nearly sorted (kept sorted; a re-sort
|
|
7
|
+
// usually follows a single zIndex change), so insertion sort is O(n) in
|
|
8
|
+
// practice and allocation-free.
|
|
9
|
+
export const sortByZIndexStable = (nodes: CoreNode[]): void => {
|
|
10
|
+
const len = nodes.length;
|
|
11
|
+
for (let i = 1; i < len; i++) {
|
|
9
12
|
const node = nodes[i]!;
|
|
10
|
-
const
|
|
11
|
-
//create bucket if it doesn't exist
|
|
12
|
-
if (buckets[index] === undefined) {
|
|
13
|
-
buckets[index] = [];
|
|
14
|
-
bucketIndices.push(index);
|
|
15
|
-
}
|
|
16
|
-
buckets[index]!.push(node);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
//sort each bucket using insertion sort
|
|
20
|
-
for (let i = 1; i < bucketIndices.length; i++) {
|
|
21
|
-
const key = bucketIndices[i]!;
|
|
13
|
+
const z = node.props.zIndex;
|
|
22
14
|
let j = i - 1;
|
|
23
|
-
while (j >= 0 &&
|
|
24
|
-
|
|
15
|
+
while (j >= 0 && nodes[j]!.props.zIndex > z) {
|
|
16
|
+
nodes[j + 1] = nodes[j]!;
|
|
25
17
|
j--;
|
|
26
18
|
}
|
|
27
|
-
|
|
19
|
+
nodes[j + 1] = node;
|
|
28
20
|
}
|
|
29
|
-
|
|
30
|
-
//flatten buckets
|
|
31
|
-
let idx = 0;
|
|
32
|
-
for (let i = 0; i < bucketIndices.length; i++) {
|
|
33
|
-
const bucket = buckets[bucketIndices[i]!]!;
|
|
34
|
-
for (let j = 0; j < bucket.length; j++) {
|
|
35
|
-
nodes[idx++] = bucket[j]!;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
//clean up
|
|
40
|
-
buckets.length = 0;
|
|
41
|
-
bucketIndices.length = 0;
|
|
42
21
|
};
|
|
43
22
|
|
|
44
23
|
export const incrementalRepositionByZIndex = (
|