diff --git a/examples/index.ts b/examples/index.ts index 2f8a3b1..927836d 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -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 = { ...(typeof module.customSettings === 'function' @@ -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( diff --git a/examples/tests/render-only-in-viewport.ts b/examples/tests/render-only-in-viewport.ts new file mode 100644 index 0000000..9f51b4e --- /dev/null +++ b/examples/tests/render-only-in-viewport.ts @@ -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 { + 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; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 7411be1..d9787e1 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -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({ + strictBound: createBound(0, 0, 200, 200), + preloadBound: createBound(0, 0, 400, 200), + defaultTexture: { + state: 'loaded', + }, + renderer: mock() 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({ + 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({ + 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); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 321076c..62476aa 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -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; } @@ -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); } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index c1edaf6..4168d8f 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -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'; @@ -144,3 +145,63 @@ describe('CoreTextNode (canvas) clearing text', () => { expect(node.isRenderable).toBe(false); }); }); + +describe('CoreTextNode (sdf) renderOnlyInViewport', () => { + const makeSdfRenderer = (): TextRenderer => { + const font = mock({ 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({ + strictBound: createBound(0, 0, 200, 200), + preloadBound: createBound(0, 0, 400, 200), + defaultTexture: { state: 'loaded' } as never, + defShaderNode: null as never, + renderer: mock() 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); + }); +}); diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index 639034b..ba3e4c0 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -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), ); } diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 39941e2..eee60f4 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -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; @@ -208,6 +214,7 @@ export class Stage { setBaselineMode(options.textBaselineMode); this.platform = platform; + this.renderOnlyInViewport = options.renderOnlyInViewport !== false; this.startTime = platform.getTimeStamp(); diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index d020eec..be28cbd 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -296,6 +296,30 @@ export interface RendererRuntimeSettings { */ boundsMargin: number | [number, number, number, number]; + /** + * Only submit quads for Nodes that intersect the visible viewport. + * + * @remarks + * Nodes inside the bounds margin (`boundsMargin`) but outside the visible + * viewport still update and still load their textures (the margin remains + * the preload runway), but they stay out of the render list until they + * actually intersect the viewport — no quad writes, texture binds, or draw + * calls for content the GPU would clip anyway. + * + * Set to `false` to restore the previous behavior, where margin-ring Nodes + * are fully rendered every frame and clipped by the GPU. That keeps + * render-list membership stable ahead of scrolling at the cost of + * per-frame CPU for the off-screen ring. + * + * Trade-offs when enabled: the `renderable` event and autosize patching + * fire at viewport entry instead of margin entry, render-list rebuilds + * move to the visible edge (same frequency, different timing), and + * margin-ring content inside RTT subtrees is skipped. + * + * @defaultValue `true` + */ + renderOnlyInViewport: boolean; + /** * Factor to convert app-authored logical coorindates to device logical coordinates * @@ -722,6 +746,7 @@ export class RendererMain extends EventEmitter { appHeight: settings.appHeight || 1080, textureMemory: resolvedTxSettings, boundsMargin: settings.boundsMargin || 0, + renderOnlyInViewport: settings.renderOnlyInViewport ?? true, deviceLogicalPixelRatio: settings.deviceLogicalPixelRatio || 1, devicePhysicalPixelRatio: settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1, @@ -791,6 +816,7 @@ export class RendererMain extends EventEmitter { appWidth, appHeight, boundsMargin: settings.boundsMargin!, + renderOnlyInViewport: settings.renderOnlyInViewport!, clearColor: settings.clearColor!, canvas: this.canvas, deviceLogicalPixelRatio,