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
5 changes: 5 additions & 0 deletions examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ async function runTest(
// `?novao=true` forces the per-draw attribute-binding path (VAOs off) so the
// VAO optimization can be A/B'd on a target device.
const disableVertexArrayObject = urlParams.get('novao') === 'true';
// `?strictrender=false` restores legacy draw submission for bounds-margin
// nodes (renderOnlyInViewport defaults to true) so the clipped-quad
// overhead can be A/B'd with the ?debug=true overlay on a target device.
const legacyMarginRender = urlParams.get('strictrender') === 'false';

const customSettings: Partial<RendererMainSettings> = {
...(typeof module.customSettings === 'function'
Expand All @@ -228,6 +232,7 @@ async function runTest(
...(globalTargetFPS !== undefined && { targetFPS: globalTargetFPS }),
...(debug && { enableContextSpy: true, fpsUpdateInterval: 500 }),
...(disableVertexArrayObject && { disableVertexArrayObject: true }),
...(legacyMarginRender && { renderOnlyInViewport: false }),
};

const { renderer, appElement } = await initRenderer(
Expand Down
57 changes: 57 additions & 0 deletions examples/tests/render-only-in-viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { RendererMainSettings } from '@lightningjs/renderer';
import type { ExampleSettings } from '../common/ExampleSettings.js';

/**
* Manual A/B page for the `renderOnlyInViewport` renderer setting.
*
* The scene puts a row of color quads across all three bounds zones of a
* 400px bounds margin: some in the viewport, some in the margin ring, some
* beyond it. Open with `?test=render-only-in-viewport&debug=true` and
* compare the overlay's `draws`/`quads` counters:
*
* - `&strictrender=false` (gate off, legacy behavior): margin-ring quads are
* submitted and GPU-clipped — they count.
* - default (gate on, the renderer default): margin-ring quads stay out of
* the render list — `quads` drops by the ring count, pixels identical.
*
* No `automation` export on purpose: the feature changes no pixels, so there
* is nothing for the snapshot suite to capture — behavior is asserted by the
* CoreNode/CoreTextNode unit tests.
*/

export function customSettings(
urlParams: URLSearchParams,
): Partial<RendererMainSettings> {
return {
boundsMargin: 400,
renderOnlyInViewport: urlParams.get('strictrender') !== 'false',
};
}

export default async function test({ renderer, testRoot }: ExampleSettings) {
renderer.createTextNode({
fontFamily: 'Ubuntu',
text: 'renderOnlyInViewport — compare quads with ?debug=true and ?strictrender=false',
fontSize: 28,
color: 0xffffffff,
x: 20,
y: 980,
parent: testRoot,
});

// 6 quads in the viewport (appWidth 1920), 2 in the margin ring
// (1920..2320), 5 beyond it (> 2320, never processed either way).
const colors = [0x336699ff, 0x993311ff, 0x339933ff];
for (let i = 0; i < 13; i++) {
renderer.createNode({
x: 40 + i * 320,
y: 300,
w: 280,
h: 400,
color: colors[i % 3],
parent: testRoot,
});
}

return true;
}
142 changes: 142 additions & 0 deletions src/core/CoreNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1463,4 +1463,146 @@ describe('set color()', () => {
expect(node.renderTexture).toBe(texture);
});
});

describe('renderOnlyInViewport', () => {
// Viewport is 0..200; the preload (bounds-margin) ring extends to 400.
// A node at x=250 is InBounds (margin ring); at x=50 it is InViewport;
// at x=500 it is OutOfBounds.
function boundsStage(renderOnlyInViewport: boolean): Stage {
return mock<Stage>({
strictBound: createBound(0, 0, 200, 200),
preloadBound: createBound(0, 0, 400, 200),
defaultTexture: {
state: 'loaded',
},
renderer: mock<CoreRenderer>() as CoreRenderer,
renderOnlyInViewport,
});
}

function loadedTextureNode(stage: Stage, x: number): CoreNode {
const parent = new CoreNode(stage, defaultProps());
parent.globalTransform = Matrix3d.identity();
parent.worldAlpha = 1;

const node = new CoreNode(stage, defaultProps({ parent }));
node.alpha = 1;
node.x = x;
node.y = 0;
node.w = 100;
node.h = 100;
node.texture = mock<ImageTexture>({
state: 'loaded',
setRenderableOwner: vi.fn(),
});
node.textureLoaded = true;
return node;
}

it('off: a margin-ring node is renderable (legacy behavior)', () => {
const node = loadedTextureNode(boundsStage(false), 250);

node.update(0, clippingRect);

expect(node.isRenderable).toBe(true);
});

it('on: a margin-ring node is not renderable but still owns its texture', () => {
const node = loadedTextureNode(boundsStage(true), 250);

node.update(0, clippingRect);

expect(node.isRenderable).toBe(false);
// Ownership is the load trigger and cleanup protection — it must stay.
expect(node.texture!.setRenderableOwner).toHaveBeenCalledWith(
expect.anything(),
true,
);
});

it('on: a viewport node is renderable', () => {
const node = loadedTextureNode(boundsStage(true), 50);

node.update(0, clippingRect);

expect(node.isRenderable).toBe(true);
});

it('on: a node becomes renderable when it crosses into the viewport', () => {
const node = loadedTextureNode(boundsStage(true), 250);
node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);

// Scroll it in.
node.x = 50;
node.update(1, clippingRect);
expect(node.isRenderable).toBe(true);

// And back out into the ring.
node.x = 250;
node.update(2, clippingRect);
expect(node.isRenderable).toBe(false);
});

it('on: an out-of-bounds node releases texture ownership', () => {
const node = loadedTextureNode(boundsStage(true), 500);

node.update(0, clippingRect);

expect(node.isRenderable).toBe(false);
expect(node.texture!.setRenderableOwner).not.toHaveBeenCalledWith(
expect.anything(),
true,
);
});

it('on: a margin-ring placeholder is gated the same way', () => {
const stage = boundsStage(true);
const parent = new CoreNode(stage, defaultProps());
parent.globalTransform = Matrix3d.identity();
parent.worldAlpha = 1;

const node = new CoreNode(stage, defaultProps({ parent }));
node.alpha = 1;
node.x = 250;
node.y = 0;
node.w = 100;
node.h = 100;
node.placeholderColor = 0x336699ff;
node.texture = mock<ImageTexture>({
state: 'initial',
setRenderableOwner: vi.fn(),
});

node.update(0, clippingRect);
expect(node.placeholderActive).toBe(true);
expect(node.isRenderable).toBe(false);

node.x = 50;
node.update(1, clippingRect);
expect(node.isRenderable).toBe(true);
});

it('on: color-only nodes in the margin ring are gated too', () => {
const stage = boundsStage(true);
const parent = new CoreNode(stage, defaultProps());
parent.globalTransform = Matrix3d.identity();
parent.worldAlpha = 1;

const node = new CoreNode(stage, defaultProps({ parent }));
node.alpha = 1;
node.x = 250;
node.y = 0;
node.w = 100;
node.h = 100;
node.color = 0xff0000ff;

node.update(0, clippingRect);
expect(node.isRenderable).toBe(false);

node.x = 50;
node.update(1, clippingRect);
expect(node.isRenderable).toBe(true);
});
});
});
17 changes: 16 additions & 1 deletion src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1833,7 +1833,11 @@ export class CoreNode extends EventEmitter {
// texture has failed to load, we cannot render the texture itself —
// but a placeholder color still renders in its place
this.updateTextureOwnership(false);
this.setRenderable(this.placeholderActive);
this.setRenderable(
this.placeholderActive === true &&
(this.stage.renderOnlyInViewport === false ||
this.renderState === CoreNodeRenderState.InViewport),
);
return;
}

Expand All @@ -1853,6 +1857,17 @@ export class CoreNode extends EventEmitter {
newIsRenderable = true;
}

// renderOnlyInViewport: nodes in the preload margin keep texture
// ownership above (so loading proceeds) but stay out of the render list
// until they actually intersect the viewport.
if (
newIsRenderable === true &&
this.stage.renderOnlyInViewport === true &&
this.renderState !== CoreNodeRenderState.InViewport
) {
newIsRenderable = false;
}

this.updateTextureOwnership(needsTextureOwnership);
this.setRenderable(newIsRenderable);
}
Expand Down
61 changes: 61 additions & 0 deletions src/core/CoreTextNode.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
import { CoreNodeRenderState } from './CoreNode.js';
import { Stage } from './Stage.js';
import { CoreRenderer } from './renderers/CoreRenderer.js';
import { createBound } from './lib/utils.js';
Expand Down Expand Up @@ -144,3 +145,63 @@ describe('CoreTextNode (canvas) clearing text', () => {
expect(node.isRenderable).toBe(false);
});
});

describe('CoreTextNode (sdf) renderOnlyInViewport', () => {
const makeSdfRenderer = (): TextRenderer => {
const font = mock<FontHandler>({ type: 'sdf' });
return {
type: 'sdf',
font,
renderText: vi.fn(),
addQuads: vi.fn(),
renderQuads: vi.fn(),
init: vi.fn(),
} as unknown as TextRenderer;
};

function sdfNodeWithLayout(renderOnlyInViewport: boolean): CoreTextNode {
const stage = mock<Stage>({
strictBound: createBound(0, 0, 200, 200),
preloadBound: createBound(0, 0, 400, 200),
defaultTexture: { state: 'loaded' } as never,
defShaderNode: null as never,
renderer: mock<CoreRenderer>() as CoreRenderer,
renderOnlyInViewport,
});
const node = new CoreTextNode(
stage,
defaultProps({ text: 'Hello' }),
makeSdfRenderer(),
);
(node as unknown as { _cachedLayout: object })._cachedLayout = {};
node.worldAlpha = 1;
return node;
}

it('off: margin-ring SDF text is renderable (legacy behavior)', () => {
const node = sdfNodeWithLayout(false);
node.renderState = CoreNodeRenderState.InBounds;

node.updateIsRenderable();

expect(node.isRenderable).toBe(true);
});

it('on: margin-ring SDF text stays out of the render list', () => {
const node = sdfNodeWithLayout(true);
node.renderState = CoreNodeRenderState.InBounds;

node.updateIsRenderable();

expect(node.isRenderable).toBe(false);
});

it('on: viewport SDF text is renderable', () => {
const node = sdfNodeWithLayout(true);
node.renderState = CoreNodeRenderState.InViewport;

node.updateIsRenderable();

expect(node.isRenderable).toBe(true);
});
});
9 changes: 7 additions & 2 deletions src/core/CoreTextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,14 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
return;
}

// For SDF, check if we have a cached layout
// For SDF, check if we have a cached layout. renderOnlyInViewport gates
// SDF text the same way CoreNode gates quads: margin-ring text stays out
// of the render list until it intersects the viewport.
this.setRenderable(
this.checkBasicRenderability() === true && this._cachedLayout !== null,
this.checkBasicRenderability() === true &&
this._cachedLayout !== null &&
(this.stage.renderOnlyInViewport === false ||
this.renderState === CoreNodeRenderState.InViewport),
);
}

Expand Down
7 changes: 7 additions & 0 deletions src/core/Stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ export class Stage {
public readonly renderer: CoreRenderer;
public readonly root: CoreNode;
public boundsMargin: [number, number, number, number];
/**
* When true, nodes inside the bounds margin but outside the viewport keep
* loading textures yet stay out of the render list. Read by
* `CoreNode.updateIsRenderable` on the scroll path.
*/
public readonly renderOnlyInViewport: boolean;
public readonly defShaderNode: CoreShaderNode | null = null;
public strictBound: Bound;
public preloadBound: Bound;
Expand Down Expand Up @@ -208,6 +214,7 @@ export class Stage {
setBaselineMode(options.textBaselineMode);

this.platform = platform;
this.renderOnlyInViewport = options.renderOnlyInViewport !== false;

this.startTime = platform.getTimeStamp();

Expand Down
Loading
Loading