diff --git a/packages/dev/core/src/Misc/snapshotRenderingHelper.ts b/packages/dev/core/src/Misc/snapshotRenderingHelper.ts index 64fbcf43f79..e50ad41384b 100644 --- a/packages/dev/core/src/Misc/snapshotRenderingHelper.ts +++ b/packages/dev/core/src/Misc/snapshotRenderingHelper.ts @@ -32,6 +32,7 @@ export class SnapshotRenderingHelper { private _disableRenderingRefCount = 0; private _currentPerformancePriorityMode = ScenePerformancePriority.BackwardCompatible; private _pendingCurrentPerformancePriorityMode?: ScenePerformancePriority; + private _isEnabling = false; /** * Creates a new snapshot rendering helper @@ -100,6 +101,10 @@ export class SnapshotRenderingHelper { }); } + public get isReady() { + return !this._isEnabling; + } + /** * Enable snapshot rendering * Use this method instead of engine.snapshotRendering=true, to make sure everything is ready before enabling snapshot rendering. @@ -114,6 +119,7 @@ export class SnapshotRenderingHelper { return; } + this._isEnabling = true; this._disableRenderingRefCount = 0; this._currentPerformancePriorityMode = this._pendingCurrentPerformancePriorityMode ?? this._scene.performancePriority; @@ -122,13 +128,24 @@ export class SnapshotRenderingHelper { this._scene.executeWhenReady(() => { if (this._disableRenderingRefCount > 0) { + this._isEnabling = false; return; } // Make sure a full frame is rendered before enabling snapshot rendering, so use "+2" instead of "+1" - this._executeAtFrame(this._engine.frameId + 2, () => { + const targetFrameId = this._engine.frameId + 2; + this._executeAtFrame(targetFrameId, () => { this._engine.snapshotRendering = true; }); + + // Render one frame with snapshot rendering enabled to make sure everything is ready + this._executeAtFrame( + targetFrameId + 1, + () => { + this._isEnabling = false; + }, + "always" + ); }); } @@ -158,7 +175,7 @@ export class SnapshotRenderingHelper { } this._pendingCurrentPerformancePriorityMode = undefined; }, - true + "whenDisabled" ); }); } @@ -274,9 +291,9 @@ export class SnapshotRenderingHelper { } } - private _executeAtFrame(frameId: number, func: () => void, executeWhenModeIsDisabled = false) { + private _executeAtFrame(frameId: number, func: () => void, mode: "whenEnabled" | "whenDisabled" | "always" = "whenEnabled") { const obs = this._engine.onEndFrameObservable.add(() => { - if ((this._disableRenderingRefCount > 0 && !executeWhenModeIsDisabled) || (this._disableRenderingRefCount === 0 && executeWhenModeIsDisabled)) { + if (mode !== "always" && ((this._disableRenderingRefCount > 0 && mode === "whenEnabled") || (this._disableRenderingRefCount === 0 && mode === "whenDisabled"))) { this._engine.onEndFrameObservable.remove(obs); return; } diff --git a/packages/tools/viewer-alpha/src/viewer.ts b/packages/tools/viewer-alpha/src/viewer.ts index 7f1ced8bf45..295e8037a85 100644 --- a/packages/tools/viewer-alpha/src/viewer.ts +++ b/packages/tools/viewer-alpha/src/viewer.ts @@ -34,6 +34,7 @@ import { Viewport } from "core/Maths/math.viewport"; import { GetHotSpotToRef } from "core/Meshes/abstractMesh.hotSpot"; import { SnapshotRenderingHelper } from "core/Misc/snapshotRenderingHelper"; import { BuildTuple } from "core/Misc/arrayTools"; +import { Logger } from "core/Misc/logger"; const toneMappingOptions = ["none", "standard", "aces", "neutral"] as const; export type ToneMapping = (typeof toneMappingOptions)[number]; @@ -146,6 +147,11 @@ export type ViewerDetails = { * @returns A token that should be disposed when the request for suspending rendering is no longer needed. */ suspendRendering(): IDisposable; + + /** + * Marks the scene as mutated, which will trigger a render on the next frame (unless rendering is suspended). + */ + markSceneMutated(): void; }; export type ViewerOptions = Partial< @@ -154,6 +160,13 @@ export type ViewerOptions = Partial< * Called once when the viewer is initialized and provides viewer details that can be used for advanced customization. */ onInitialized: (details: Readonly) => void; + + /** + * When enabled, rendering will be suspended when no scene state driven by the Viewer has changed. + * This can reduce resource CPU/GPU pressure when the scene is static. + * Enabled by default. + */ + autoSuspendRendering: boolean; }> >; @@ -208,6 +221,8 @@ export class ViewerHotSpotResult { public visibility: number = NaN; } +// TODO: Need to add properties for anything in the upper layers, such as clearColor, so we can mark the scene as mutated. + /** * @experimental * Provides an experience for viewing a single 3D model. @@ -301,6 +316,8 @@ export class Viewer implements IDisposable { private _contrast: number; private _exposure: number; + private readonly _autoSuspendRendering: boolean = true; + private _sceneMutated = false; private _suspendRenderCount = 0; private _isDisposed = false; @@ -322,6 +339,7 @@ export class Viewer implements IDisposable { private readonly _engine: AbstractEngine, options?: ViewerOptions ) { + this._autoSuspendRendering = options?.autoSuspendRendering ?? true; { const scene = new Scene(this._engine); @@ -360,12 +378,21 @@ export class Viewer implements IDisposable { }); const camera = new ArcRotateCamera("Viewer Default Camera", 0, 0, 1, Vector3.Zero(), scene); + camera.onViewMatrixChangedObservable.add(() => { + this._sceneMutated = true; + }); + + scene.onClearColorChangedObservable.add(() => { + this._sceneMutated = true; + }); + this._details = { viewer: this, scene, camera, model: null, suspendRendering: this._suspendRendering.bind(this), + markSceneMutated: () => (this._sceneMutated = true), }; } this._details.scene.skipFrustumClipping = true; @@ -435,6 +462,7 @@ export class Viewer implements IDisposable { this._snapshotHelper.disableSnapshotRendering(); material.microSurface = 1.0 - value; this._snapshotHelper.enableSnapshotRendering(); + this._sceneMutated = true; } } this.onSkyboxBlurChanged.notifyObservers(); @@ -501,6 +529,7 @@ export class Viewer implements IDisposable { this._details.scene.imageProcessingConfiguration.isEnabled = this._toneMappingEnabled || this._contrast !== 1 || this._exposure !== 1; this._snapshotHelper.enableSnapshotRendering(); + this._sceneMutated = true; } /** @@ -609,6 +638,7 @@ export class Viewer implements IDisposable { this._activeAnimation.goToFrame(value * (this._activeAnimation.to - this._activeAnimation.from)); this.onAnimationProgressChanged.notifyObservers(); this._autoRotationBehavior.resetLastInteractionTime(); + this._sceneMutated = true; } } @@ -700,6 +730,7 @@ export class Viewer implements IDisposable { this._isLoadingModel = false; this.onLoadingProgressChanged.notifyObservers(); this._snapshotHelper.enableSnapshotRendering(); + this._sceneMutated = true; } }); } @@ -785,6 +816,7 @@ export class Viewer implements IDisposable { throw e; } finally { this._snapshotHelper.enableSnapshotRendering(); + this._sceneMutated = true; } }); } @@ -898,6 +930,22 @@ export class Viewer implements IDisposable { return true; } + private get _shouldRender() { + // We should render if: + // 1. Auto suspend rendering is disabled. + // 2. The scene has been mutated. + // 3. The snapshot helper is not yet in a ready state. + // 4. An animation is playing. + // 5. Animation is paused, but any individual animatable hasn't transitioned to a paused state yet. + return ( + !this._autoSuspendRendering || + this._sceneMutated || + !this._snapshotHelper.isReady || + this.isAnimationPlaying || + this._details.model?.animationGroups.some((group) => group.animatables.some((animatable) => animatable.animationStarted)) + ); + } + private _suspendRendering(): IDisposable { this._renderLoopController?.dispose(); this._suspendRenderCount++; @@ -917,11 +965,30 @@ export class Viewer implements IDisposable { private _beginRendering(): void { if (!this._renderLoopController) { + let renderedLastFrame = false; const render = () => { - this._details.scene.render(); - if (this.isAnimationPlaying) { - this.onAnimationProgressChanged.notifyObservers(); - this._autoRotationBehavior.resetLastInteractionTime(); + // When we resume rendering, continue rendering until the scene reports it is ready. + const shouldRender = this._shouldRender || (renderedLastFrame && !this._details.scene.isReady(true)); + if (shouldRender) { + this._sceneMutated = false; + this._details.scene.render(); + + if (!renderedLastFrame) { + Logger.Log("Viewer Resumed Rendering"); + renderedLastFrame = true; + } + + if (this.isAnimationPlaying) { + this.onAnimationProgressChanged.notifyObservers(); + this._autoRotationBehavior.resetLastInteractionTime(); + } + } else { + this._details.camera.update(); + + if (renderedLastFrame) { + Logger.Log("Viewer Suspended Rendering"); + renderedLastFrame = false; + } } }; @@ -934,6 +1001,10 @@ export class Viewer implements IDisposable { disposed = true; this._engine.stopRenderLoop(render); this._renderLoopController = null; + + if (renderedLastFrame) { + Logger.Log("Viewer Suspended Rendering"); + } } }, }; diff --git a/packages/tools/viewer-alpha/src/viewerElement.ts b/packages/tools/viewer-alpha/src/viewerElement.ts index bf075e75ece..c147ea1f4f0 100644 --- a/packages/tools/viewer-alpha/src/viewerElement.ts +++ b/packages/tools/viewer-alpha/src/viewerElement.ts @@ -468,6 +468,12 @@ export class HTML3DElement extends LitElement { @property({ reflect: true }) public engine: NonNullable = getDefaultEngine(); + /** + * When true, the scene will be rendered even if no scene state has changed. + */ + @property({ attribute: "render-when-idle", type: Boolean }) + public renderWhenIdle = false; + /** * The model URL. */ @@ -725,7 +731,7 @@ export class HTML3DElement extends LitElement { override update(changedProperties: PropertyValues): void { super.update(changedProperties); - if (changedProperties.get("engine")) { + if (changedProperties.get("engine") || changedProperties.has("renderWhenIdle")) { this._tearDownViewer(); this._setupViewer(); } else { @@ -935,6 +941,7 @@ export class HTML3DElement extends LitElement { await createViewerForCanvas(canvas, { engine: this.engine, + autoSuspendRendering: !this.renderWhenIdle, onInitialized: (details) => { this._viewerDetails = details; diff --git a/packages/tools/viewer-alpha/src/viewerFactory.ts b/packages/tools/viewer-alpha/src/viewerFactory.ts index 4ce3d795962..8b267aa0d82 100644 --- a/packages/tools/viewer-alpha/src/viewerFactory.ts +++ b/packages/tools/viewer-alpha/src/viewerFactory.ts @@ -32,12 +32,6 @@ export async function createViewerForCanvas(canvas: HTMLCanvasElement, options?: const finalOptions = { ...defaultCanvasViewerOptions, ...options }; const disposeActions: (() => void)[] = []; - // If the canvas is resized, note that the engine needs a resize, but don't resize it here as it will result in flickering. - let needsResize = false; - const resizeObserver = new ResizeObserver(() => (needsResize = true)); - resizeObserver.observe(canvas); - disposeActions.push(() => resizeObserver.disconnect()); - // Create an engine instance. let engine: AbstractEngine; switch (finalOptions.engine ?? getDefaultEngine()) { @@ -60,6 +54,15 @@ export async function createViewerForCanvas(canvas: HTMLCanvasElement, options?: // Override the onInitialized callback to add in some specific behavior. const onInitialized = finalOptions.onInitialized; finalOptions.onInitialized = (details) => { + // If the canvas is resized, note that the engine needs a resize, but don't resize it here as it will result in flickering. + let needsResize = false; + const resizeObserver = new ResizeObserver(() => { + needsResize = true; + details.markSceneMutated(); + }); + resizeObserver.observe(canvas); + disposeActions.push(() => resizeObserver.disconnect()); + // Resize if needed right before rendering the Viewer scene to avoid any flickering. const beforeRenderObserver = details.scene.onBeforeRenderObservable.add(() => { if (needsResize) { diff --git a/packages/tools/viewer-alpha/test/apps/web/index.html b/packages/tools/viewer-alpha/test/apps/web/index.html index 9601ae26f39..7cf53e509e9 100644 --- a/packages/tools/viewer-alpha/test/apps/web/index.html +++ b/packages/tools/viewer-alpha/test/apps/web/index.html @@ -186,6 +186,8 @@ await new Promise((resolve) => setTimeout(resolve, 2000)); //viewerElement.source = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/BrainStem/glTF-Binary/BrainStem.glb"; //viewerElement.environment = ""; + //viewerElement.skyboxBlur = 0; + //viewerElement.contrast = 4; // error case //viewerElement.source = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/main/2.0/BrainStem/glTF-Binary/BrainStem2.glb"; await new Promise((resolve) => setTimeout(resolve, 2000));