From 05d027655a3645e499ab6010904bbd8153dae09a Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 11 Jun 2026 08:13:28 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20renderOnlyInViewport=20?= =?UTF-8?q?=E2=80=94=20skip=20draw=20submission=20for=20bounds-margin=20no?= =?UTF-8?q?des?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boundsMargin currently bundles two consequences: nodes in the margin ring preload their textures AND are fully rendered every frame (quad writes, texture binds, draw-call segmentation), with the GPU clipping away the invisible fragments. On TV SoCs those GL calls are pure CPU tax for content that produces no pixels. New renderer setting renderOnlyInViewport (default false): margin-ring nodes keep full updates and texture ownership — fetch/decode/upload and cleanup protection are unchanged, the margin remains the preload runway — but they stay out of the render list until they actually intersect the viewport. Render-list rebuilds move from the margin edge to the visible edge (same frequency per crossing); per-frame quad submission drops by the ring count. Gated in CoreNode.updateIsRenderable (main path + failed-texture placeholder branch) and in CoreTextNode's SDF override, which bypasses the base method. RenderState processing already raises IsRenderable, so margin<->viewport crossings recompute renderability with no new plumbing; default-off users pay one boolean check. Adds a ?strictrender=true examples URL param (like ?novao) and a render-only-in-viewport example page so the clipped-quad overhead can be A/B'd with the ?debug=true overlay on target devices. Measured: quads dropped exactly by the margin-ring count with pixel-identical output. Co-Authored-By: Claude Fable 5 --- examples/index.ts | 5 + examples/tests/render-only-in-viewport.ts | 57 +++++++++ src/core/CoreNode.test.ts | 142 ++++++++++++++++++++++ src/core/CoreNode.ts | 17 ++- src/core/CoreTextNode.test.ts | 61 ++++++++++ src/core/CoreTextNode.ts | 9 +- src/core/Stage.ts | 7 ++ src/main-api/Renderer.ts | 25 ++++ 8 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 examples/tests/render-only-in-viewport.ts diff --git a/examples/index.ts b/examples/index.ts index 2f8a3b1..c8072cf 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=true` keeps bounds-margin nodes out of the render list + // (renderOnlyInViewport) so the clipped-quad overhead can be A/B'd with the + // ?debug=true overlay on a target device. + const renderOnlyInViewport = urlParams.get('strictrender') === 'true'; 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 }), + ...(renderOnlyInViewport && { renderOnlyInViewport: true }), }; 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..ee02068 --- /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): margin-ring quads are submitted and + * GPU-clipped — they count. + * - default (gate on): 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..e76f7e5 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('default off: a margin-ring node is renderable (current 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..4501d25 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('default off: margin-ring SDF text is renderable', () => { + 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..be4c268 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 === true; this.startTime = platform.getTimeStamp(); diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index d020eec..c944f6e 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -296,6 +296,29 @@ export interface RendererRuntimeSettings { */ boundsMargin: number | [number, number, number, number]; + /** + * Only submit quads for Nodes that intersect the visible viewport. + * + * @remarks + * By default, Nodes inside the bounds margin (`boundsMargin`) but outside + * the visible viewport are fully rendered — their quads are written, + * batched, and drawn every frame, with the GPU clipping away the invisible + * fragments. That keeps render-list membership stable ahead of scrolling at + * the cost of per-frame CPU for the off-screen ring. + * + * With this enabled, Nodes in the margin ring 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 clipped content. + * + * Trade-offs: the `renderable` event and autosize patching fire at + * viewport entry instead of margin entry, and render-list rebuilds move to + * the visible edge (same frequency, different timing). + * + * @defaultValue `false` + */ + renderOnlyInViewport: boolean; + /** * Factor to convert app-authored logical coorindates to device logical coordinates * @@ -722,6 +745,7 @@ export class RendererMain extends EventEmitter { appHeight: settings.appHeight || 1080, textureMemory: resolvedTxSettings, boundsMargin: settings.boundsMargin || 0, + renderOnlyInViewport: settings.renderOnlyInViewport ?? false, deviceLogicalPixelRatio: settings.deviceLogicalPixelRatio || 1, devicePhysicalPixelRatio: settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1, @@ -791,6 +815,7 @@ export class RendererMain extends EventEmitter { appWidth, appHeight, boundsMargin: settings.boundsMargin!, + renderOnlyInViewport: settings.renderOnlyInViewport!, clearColor: settings.clearColor!, canvas: this.canvas, deviceLogicalPixelRatio, From 3ae68b94a2f7e9b3347cedd541b24e280c3edb97 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 11 Jun 2026 08:37:29 -0400 Subject: [PATCH 2/2] feat(core)!: default renderOnlyInViewport to true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Margin-ring nodes now skip draw submission by default; set renderOnlyInViewport: false in renderer settings to restore legacy clipped-draw behavior. The examples ?strictrender param flips to an opt-out (?strictrender=false) accordingly. Verified: full 176-snapshot VRT suite passes with the default on — the gate is pixel-neutral across the entire example corpus. Co-Authored-By: Claude Fable 5 --- examples/index.ts | 10 +++---- examples/tests/render-only-in-viewport.ts | 8 +++--- src/core/CoreNode.test.ts | 2 +- src/core/CoreTextNode.test.ts | 2 +- src/core/Stage.ts | 2 +- src/main-api/Renderer.ts | 33 ++++++++++++----------- 6 files changed, 29 insertions(+), 28 deletions(-) diff --git a/examples/index.ts b/examples/index.ts index c8072cf..927836d 100644 --- a/examples/index.ts +++ b/examples/index.ts @@ -220,10 +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=true` keeps bounds-margin nodes out of the render list - // (renderOnlyInViewport) so the clipped-quad overhead can be A/B'd with the - // ?debug=true overlay on a target device. - const renderOnlyInViewport = urlParams.get('strictrender') === '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' @@ -232,7 +232,7 @@ async function runTest( ...(globalTargetFPS !== undefined && { targetFPS: globalTargetFPS }), ...(debug && { enableContextSpy: true, fpsUpdateInterval: 500 }), ...(disableVertexArrayObject && { disableVertexArrayObject: true }), - ...(renderOnlyInViewport && { renderOnlyInViewport: 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 index ee02068..9f51b4e 100644 --- a/examples/tests/render-only-in-viewport.ts +++ b/examples/tests/render-only-in-viewport.ts @@ -9,10 +9,10 @@ import type { ExampleSettings } from '../common/ExampleSettings.js'; * beyond it. Open with `?test=render-only-in-viewport&debug=true` and * compare the overlay's `draws`/`quads` counters: * - * - `&strictrender=false` (gate off): margin-ring quads are submitted and - * GPU-clipped — they count. - * - default (gate on): margin-ring quads stay out of the render list — - * `quads` drops by the ring count, pixels identical. + * - `&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 diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index e76f7e5..d9787e1 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -1499,7 +1499,7 @@ describe('set color()', () => { return node; } - it('default off: a margin-ring node is renderable (current behavior)', () => { + it('off: a margin-ring node is renderable (legacy behavior)', () => { const node = loadedTextureNode(boundsStage(false), 250); node.update(0, clippingRect); diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index 4501d25..4168d8f 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -178,7 +178,7 @@ describe('CoreTextNode (sdf) renderOnlyInViewport', () => { return node; } - it('default off: margin-ring SDF text is renderable', () => { + it('off: margin-ring SDF text is renderable (legacy behavior)', () => { const node = sdfNodeWithLayout(false); node.renderState = CoreNodeRenderState.InBounds; diff --git a/src/core/Stage.ts b/src/core/Stage.ts index be4c268..eee60f4 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -214,7 +214,7 @@ export class Stage { setBaselineMode(options.textBaselineMode); this.platform = platform; - this.renderOnlyInViewport = options.renderOnlyInViewport === true; + this.renderOnlyInViewport = options.renderOnlyInViewport !== false; this.startTime = platform.getTimeStamp(); diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index c944f6e..be28cbd 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -300,22 +300,23 @@ export interface RendererRuntimeSettings { * Only submit quads for Nodes that intersect the visible viewport. * * @remarks - * By default, Nodes inside the bounds margin (`boundsMargin`) but outside - * the visible viewport are fully rendered — their quads are written, - * batched, and drawn every frame, with the GPU clipping away the invisible - * fragments. That keeps render-list membership stable ahead of scrolling at - * the cost of per-frame CPU for the off-screen ring. + * 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. * - * With this enabled, Nodes in the margin ring 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 clipped content. - * - * Trade-offs: the `renderable` event and autosize patching fire at - * viewport entry instead of margin entry, and render-list rebuilds move to - * the visible edge (same frequency, different timing). - * - * @defaultValue `false` + * @defaultValue `true` */ renderOnlyInViewport: boolean; @@ -745,7 +746,7 @@ export class RendererMain extends EventEmitter { appHeight: settings.appHeight || 1080, textureMemory: resolvedTxSettings, boundsMargin: settings.boundsMargin || 0, - renderOnlyInViewport: settings.renderOnlyInViewport ?? false, + renderOnlyInViewport: settings.renderOnlyInViewport ?? true, deviceLogicalPixelRatio: settings.deviceLogicalPixelRatio || 1, devicePhysicalPixelRatio: settings.devicePhysicalPixelRatio || this.windowDevicePixelRatio() || 1,