diff --git a/src/data_panel_layout.ts b/src/data_panel_layout.ts index e35846805..9836bc7a2 100644 --- a/src/data_panel_layout.ts +++ b/src/data_panel_layout.ts @@ -92,6 +92,7 @@ export interface ViewerUIState showPerspectiveSliceViews: TrackableBoolean; showAxisLines: TrackableBoolean; wireFrame: TrackableBoolean; + adaptiveDownsampling: TrackableBoolean; showScaleBar: TrackableBoolean; scaleBarOptions: TrackableValue; visibleLayerRoles: WatchableSet; @@ -174,6 +175,7 @@ export function getCommonViewerState(viewer: ViewerUIState) { layerManager: viewer.layerManager, showAxisLines: viewer.showAxisLines, wireFrame: viewer.wireFrame, + adaptiveDownsampling: viewer.adaptiveDownsampling, visibleLayerRoles: viewer.visibleLayerRoles, selectedLayer: viewer.selectedLayer, visibility: viewer.visibility, diff --git a/src/display_context.ts b/src/display_context.ts index e71e77670..4c89f5f70 100644 --- a/src/display_context.ts +++ b/src/display_context.ts @@ -19,6 +19,7 @@ import { TrackableValue } from "#src/trackable_value.js"; import { animationFrameDebounce } from "#src/util/animation_frame_debounce.js"; import type { Borrowed } from "#src/util/disposable.js"; import { RefCounted } from "#src/util/disposable.js"; +import { FramerateMonitor } from "#src/util/framerate.js"; import type { mat4 } from "#src/util/geom.js"; import { parseFixedLengthArray, verifyFloat01 } from "#src/util/json.js"; import { NullarySignal } from "#src/util/signal.js"; @@ -396,6 +397,7 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { rootRect: DOMRect | undefined; resizeGeneration = 0; boundsGeneration = -1; + private framerateMonitor = new FramerateMonitor(); // Panels ordered by `drawOrder`. If length is 0, needs to be recomputed. private orderedPanels: RenderedPanel[] = []; @@ -557,6 +559,8 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { ++this.frameNumber; this.updateStarted.dispatch(); const gl = this.gl; + const ext = this.framerateMonitor.getTimingExtension(gl); + const query = this.framerateMonitor.startFrameTimeQuery(gl, ext); this.ensureBoundsUpdated(); this.gl.clearColor(0.0, 0.0, 0.0, 0.0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); @@ -580,6 +584,8 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { gl.clear(gl.COLOR_BUFFER_BIT); this.gl.colorMask(true, true, true, true); this.updateFinished.dispatch(); + this.framerateMonitor.endFrameTimeQuery(gl, ext, query); + this.framerateMonitor.grabAnyFinishedQueryResults(gl); } getDepthArray(): Float32Array { @@ -607,4 +613,8 @@ export class DisplayContext extends RefCounted implements FrameNumberCounter { } return depthArray; } + + getLastFrameTimesInMs(numberOfFrames: number = 10) { + return this.framerateMonitor.getLastFrameTimesInMs(numberOfFrames); + } } diff --git a/src/layer_group_viewer.ts b/src/layer_group_viewer.ts index d19578524..cd7c0799e 100644 --- a/src/layer_group_viewer.ts +++ b/src/layer_group_viewer.ts @@ -94,6 +94,7 @@ export interface LayerGroupViewerState { mouseState: MouseSelectionState; showAxisLines: TrackableBoolean; wireFrame: TrackableBoolean; + adaptiveDownsampling: TrackableBoolean; showScaleBar: TrackableBoolean; scaleBarOptions: TrackableScaleBarOptions; showPerspectiveSliceViews: TrackableBoolean; @@ -356,6 +357,9 @@ export class LayerGroupViewer extends RefCounted { get wireFrame() { return this.viewerState.wireFrame; } + get adaptiveDownsampling() { + return this.viewerState.adaptiveDownsampling; + } get showScaleBar() { return this.viewerState.showScaleBar; } diff --git a/src/layer_groups_layout.ts b/src/layer_groups_layout.ts index 8cde729c6..c56636b6c 100644 --- a/src/layer_groups_layout.ts +++ b/src/layer_groups_layout.ts @@ -407,6 +407,7 @@ function getCommonViewerState(viewer: Viewer) { mouseState: viewer.mouseState, showAxisLines: viewer.showAxisLines, wireFrame: viewer.wireFrame, + adaptiveDownsampling: viewer.adaptiveDownsampling, showScaleBar: viewer.showScaleBar, scaleBarOptions: viewer.scaleBarOptions, showPerspectiveSliceViews: viewer.showPerspectiveSliceViews, diff --git a/src/perspective_view/panel.ts b/src/perspective_view/panel.ts index a38bd43e2..7a2c6df00 100644 --- a/src/perspective_view/panel.ts +++ b/src/perspective_view/panel.ts @@ -58,6 +58,10 @@ import type { TrackableRGB } from "#src/util/color.js"; import type { Owned } from "#src/util/disposable.js"; import type { ActionEvent } from "#src/util/event_action_map.js"; import { registerActionListener } from "#src/util/event_action_map.js"; +import { + DownsamplingBasedOnFrameRateCalculator, + FrameTimingMethod, +} from "#src/util/framerate.js"; import { kAxes, kZeroVec4, mat4, vec3, vec4 } from "#src/util/geom.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; import type { @@ -81,8 +85,11 @@ import { MultipleScaleBarTextures } from "#src/widget/scale_bar.js"; import type { RPC } from "#src/worker_rpc.js"; import { SharedObject } from "#src/worker_rpc.js"; +const REDRAW_DELAY_AFTER_CAMERA_MOVE = 300; + export interface PerspectiveViewerState extends RenderedDataViewerState { wireFrame: WatchableValueInterface; + adaptiveDownsampling: WatchableValueInterface; orthographicProjection: TrackableBoolean; showSliceViews: TrackableBoolean; showScaleBar: TrackableBoolean; @@ -185,6 +192,20 @@ v4f_fragColor = vec4(accum.rgb / accum.a, revealage); `); } +// Copy the depth from opaque pass to the depth buffer for OIT. +// This copy is required because the OIT depth buffer might be +// smaller than the main depth buffer. +function defineDepthCopyShader(builder: ShaderBuilder) { + builder.addOutputBuffer("vec4", "v4f_fragData0", 0); + builder.addOutputBuffer("vec4", "v4f_fragData1", 1); + builder.setFragmentMain(` + v4f_fragData0 = vec4(0.0, 0.0, 0.0, 1.0); + v4f_fragData1 = vec4(0.0, 0.0, 0.0, 1.0); + vec4 v0 = getValue0(); + gl_FragDepth = 1.0 - v0.r; +`); +} + // Copy the max projection color to the OIT buffer function defineMaxProjectionColorCopyShader(builder: ShaderBuilder) { builder.addOutputBuffer("vec4", "v4f_fragData0", 0); @@ -262,6 +283,23 @@ export class PerspectivePanel extends RenderedDataPanel { return this.navigationState.displayDimensionRenderInfo; } + // the frame rate calculator is used to determine if downsampling should be applied + // after a camera move + // if a high downsample rate is applied, it persists for a few frames + // to avoid flickering when the camera is moving + private frameRateCalculator = new DownsamplingBasedOnFrameRateCalculator( + 10 /* numberOfStoredFrameDeltas */, + 8 /* maxDownsamplingFactor */, + 16 /* desiredFrameTimingMs */, + 60 /* downsamplingPersistenceDurationInFrames */, + ); + private redrawAfterMoveTimeOutId = -1; + private hasTransparent = false; + + get isCameraMoving() { + return this.redrawAfterMoveTimeOutId !== -1; + } + /** * If boolean value is true, sliceView is shown unconditionally, regardless of the value of * this.viewer.showSliceViews.value. @@ -327,6 +365,9 @@ export class PerspectivePanel extends RenderedDataPanel { protected maxProjectionColorCopyHelper = this.registerDisposer( OffscreenCopyHelper.get(this.gl, defineMaxProjectionColorCopyShader, 2), ); + protected offscreenDepthCopyHelper = this.registerDisposer( + OffscreenCopyHelper.get(this.gl, defineDepthCopyShader, 1), + ); protected maxProjectionPickCopyHelper = this.registerDisposer( OffscreenCopyHelper.get(this.gl, defineMaxProjectionPickCopyShader, 2), ); @@ -417,6 +458,23 @@ export class PerspectivePanel extends RenderedDataPanel { this, ); + this.registerDisposer( + this.viewer.navigationState.changed.add(() => { + // Don't mark camera moving on picking requests + if (this.isMovingToMousePositionOnPick) { + return; + } + if (this.redrawAfterMoveTimeOutId !== -1) { + window.clearTimeout(this.redrawAfterMoveTimeOutId); + } + this.redrawAfterMoveTimeOutId = window.setTimeout(() => { + this.redrawAfterMoveTimeOutId = -1; + this.frameRateCalculator.resetLastFrameTime(); + this.context.scheduleRedraw(); + }, REDRAW_DELAY_AFTER_CAMERA_MOVE); + }), + ); + registerActionListener( element, "rotate-via-mouse-drag", @@ -717,7 +775,7 @@ export class PerspectivePanel extends RenderedDataPanel { this.gl.RGBA, this.gl.FLOAT, ), - depthBuffer: this.offscreenFramebuffer.depthBuffer!.addRef(), + depthBuffer: new DepthStencilRenderbuffer(this.gl), }), ); } @@ -911,9 +969,8 @@ export class PerspectivePanel extends RenderedDataPanel { const { visibleLayers } = this.visibleLayerTracker; - let hasTransparent = false; + this.hasTransparent = false; let hasMaxProjection = false; - let hasAnnotation = false; // Draw fully-opaque layers first. @@ -925,7 +982,7 @@ export class PerspectivePanel extends RenderedDataPanel { hasAnnotation = true; } } else { - hasTransparent = true; + this.hasTransparent = true; if (renderLayer.isVolumeRendering) { hasMaxProjection = hasMaxProjection || @@ -974,16 +1031,41 @@ export class PerspectivePanel extends RenderedDataPanel { /*dppass=*/ WebGL2RenderingContext.KEEP, ); - if (hasTransparent) { + if (this.hasTransparent) { //Draw transparent objects. + let volumeRenderingBufferWidth = width; + let volumeRenderingBufferHeight = height; + + if (this.viewer.adaptiveDownsampling.value && this.isCameraMoving) { + this.frameRateCalculator.setFrameDeltas( + this.context.getLastFrameTimesInMs( + this.frameRateCalculator.numberOfStoredFrameDeltas, + ), + ); + const downsamplingFactor = + this.frameRateCalculator.calculateDownsamplingRateBasedOnFrameDeltas( + FrameTimingMethod.MEAN, + ); + if (downsamplingFactor > 1) { + const originalRatio = width / height; + volumeRenderingBufferWidth = Math.round(width / downsamplingFactor); + volumeRenderingBufferHeight = Math.round( + volumeRenderingBufferWidth / originalRatio, + ); + } + } + // Create max projection buffer if needed. let bindMaxProjectionBuffer: () => void = () => {}; let bindMaxProjectionPickingBuffer: () => void = () => {}; if (hasMaxProjection) { const { maxProjectionConfiguration } = this; bindMaxProjectionBuffer = () => { - maxProjectionConfiguration.bind(width, height); + maxProjectionConfiguration.bind( + volumeRenderingBufferWidth, + volumeRenderingBufferHeight, + ); }; gl.depthMask(true); bindMaxProjectionBuffer(); @@ -996,7 +1078,10 @@ export class PerspectivePanel extends RenderedDataPanel { const { maxProjectionPickConfiguration } = this; bindMaxProjectionPickingBuffer = () => { - maxProjectionPickConfiguration.bind(width, height); + maxProjectionPickConfiguration.bind( + volumeRenderingBufferWidth, + volumeRenderingBufferHeight, + ); }; bindMaxProjectionPickingBuffer(); gl.clear( @@ -1005,15 +1090,25 @@ export class PerspectivePanel extends RenderedDataPanel { ); } - // Compute accumulate and revealage textures. - gl.depthMask(false); - gl.enable(WebGL2RenderingContext.BLEND); + // Copy the depth buffer from the offscreen framebuffer to the transparent framebuffer. + gl.depthMask(true); + gl.clearDepth(1.0); const { transparentConfiguration } = this; renderContext.bindFramebuffer = () => { - transparentConfiguration.bind(width, height); + transparentConfiguration.bind( + volumeRenderingBufferWidth, + volumeRenderingBufferHeight, + ); }; renderContext.bindFramebuffer(); - gl.clearDepth(1.0); + gl.clear(WebGL2RenderingContext.DEPTH_BUFFER_BIT); + this.offscreenDepthCopyHelper.draw( + this.offscreenFramebuffer.colorBuffers[OffscreenTextures.Z].texture, + ); + + // Compute accumulate and revealage textures. + gl.depthMask(false); + gl.enable(WebGL2RenderingContext.BLEND); gl.clearColor(0.0, 0.0, 0.0, 1.0); gl.clear(WebGL2RenderingContext.COLOR_BUFFER_BIT); renderContext.emitter = perspectivePanelEmitOIT; @@ -1024,6 +1119,7 @@ export class PerspectivePanel extends RenderedDataPanel { WebGL2RenderingContext.ONE_MINUS_SRC_ALPHA, ); renderContext.emitPickID = false; + for (const [renderLayer, attachment] of visibleLayers) { if (renderLayer.isTransparent) { renderContext.depthBufferTexture = @@ -1096,6 +1192,7 @@ export class PerspectivePanel extends RenderedDataPanel { // Copy transparent rendering result back to primary buffer. gl.disable(WebGL2RenderingContext.DEPTH_TEST); this.offscreenFramebuffer.bindSingle(OffscreenTextures.COLOR); + gl.viewport(0, 0, width, height); gl.blendFunc( WebGL2RenderingContext.ONE_MINUS_SRC_ALPHA, WebGL2RenderingContext.SRC_ALPHA, diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index c62b6ec6f..e480d3fa6 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -446,6 +446,8 @@ export abstract class RenderedDataPanel extends RenderedPanel { this.attemptToIssuePickRequest(); } + protected isMovingToMousePositionOnPick = false; + constructor( context: Borrowed, element: HTMLElement, @@ -616,7 +618,9 @@ export abstract class RenderedDataPanel extends RenderedPanel { registerActionListener(element, "move-to-mouse-position", () => { const { mouseState } = this.viewer; if (mouseState.updateUnconditionally()) { + this.isMovingToMousePositionOnPick = true; this.navigationState.position.value = mouseState.position; + this.isMovingToMousePositionOnPick = false; } }); diff --git a/src/ui/viewer_settings.ts b/src/ui/viewer_settings.ts index 5c41ad8fa..17c0b206d 100644 --- a/src/ui/viewer_settings.ts +++ b/src/ui/viewer_settings.ts @@ -125,6 +125,7 @@ export class ViewerSettingsPanel extends SidePanel { ); addCheckbox("Wire frame rendering", viewer.wireFrame); addCheckbox("Enable prefetching", viewer.chunkQueueManager.enablePrefetch); + addCheckbox("Enable adaptive downsampling", viewer.adaptiveDownsampling); const addColor = (label: string, value: WatchableValueInterface) => { const labelElement = document.createElement("label"); diff --git a/src/util/framerate.spec.ts b/src/util/framerate.spec.ts new file mode 100644 index 000000000..ae8b1e848 --- /dev/null +++ b/src/util/framerate.spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { + DownsamplingBasedOnFrameRateCalculator, + FrameTimingMethod, +} from "#src/util/framerate.js"; + +describe("FrameRateCounter", () => { + it("calculates valid fps for evenly spaced frames", () => { + const frameRateCounter = new DownsamplingBasedOnFrameRateCalculator(10); + for (let i = 0; i < 10; i++) { + frameRateCounter.addFrame(i * 100); + if (i === 0) { + expect(frameRateCounter.calculateFrameTimeInMs()).toEqual(0); + } else { + expect(frameRateCounter.calculateFrameTimeInMs()).toEqual(100); + } + } + }); + it("calculates valid fps for many frames", () => { + const frameRateCounter = new DownsamplingBasedOnFrameRateCalculator(9); + for (let i = 0; i < 10; i++) { + frameRateCounter.addFrame(i * 100); + } + frameRateCounter.resetLastFrameTime(); + for (let i = 0; i < 10; i++) { + frameRateCounter.addFrame(i * 10); + } + expect(frameRateCounter.calculateFrameTimeInMs()).toEqual(10); + expect( + frameRateCounter.calculateFrameTimeInMs(FrameTimingMethod.MEDIAN), + ).toEqual(10); + }); + it("removes last frame after reset", () => { + const frameRateCounter = new DownsamplingBasedOnFrameRateCalculator(10); + expect(frameRateCounter.calculateFrameTimeInMs()).toEqual(0); + for (let i = 0; i < 10; i++) { + frameRateCounter.addFrame(i * 100); + } + expect(frameRateCounter.calculateFrameTimeInMs()).toEqual(100); + frameRateCounter.resetLastFrameTime(); + for (let i = 0; i < 5; i++) { + frameRateCounter.addFrame(i * 200); + } + expect( + frameRateCounter.calculateFrameTimeInMs(FrameTimingMethod.MEAN), + ).toEqual(140); + expect( + frameRateCounter.calculateFrameTimeInMs(FrameTimingMethod.MAX), + ).toEqual(200); + }); +}); diff --git a/src/util/framerate.ts b/src/util/framerate.ts new file mode 100644 index 000000000..9010915c2 --- /dev/null +++ b/src/util/framerate.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2024 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class FramerateMonitor { + private timeElapsedQueries: (WebGLQuery | null)[] = []; + private warnedAboutMissingExtension = false; + private storedTimeDeltas: number[] = []; + + constructor( + private numStoredTimes: number = 10, + private queryPoolSize: number = 10, + ) { + if (this.queryPoolSize < 1) { + throw new Error( + `Query pool size must be at least 1, but got ${queryPoolSize}.`, + ); + } + } + + getTimingExtension(gl: WebGL2RenderingContext) { + const ext = gl.getExtension("EXT_disjoint_timer_query_webgl2"); + if (ext === null && !this.warnedAboutMissingExtension) { + console.warn( + "EXT_disjoint_timer_query_webgl2 extension not available. " + + "Cannot measure frame time.", + ); + this.warnedAboutMissingExtension = true; + } + return ext; + } + + startFrameTimeQuery(gl: WebGL2RenderingContext, ext: any) { + if (ext === null) { + return null; + } + const query = gl.createQuery(); + if (query !== null) { + gl.beginQuery(ext.TIME_ELAPSED_EXT, query); + } + return query; + } + + endFrameTimeQuery( + gl: WebGL2RenderingContext, + ext: any, + query: WebGLQuery | null, + ) { + if (ext !== null && query !== null) { + gl.endQuery(ext.TIME_ELAPSED_EXT); + } + if (this.timeElapsedQueries.length >= this.queryPoolSize) { + const oldestQuery = this.timeElapsedQueries.shift(); + if (oldestQuery !== null && oldestQuery !== undefined) { + gl.deleteQuery(oldestQuery); + } + } + this.timeElapsedQueries.push(query); + } + + grabAnyFinishedQueryResults(gl: WebGL2RenderingContext) { + const deletedQueryIndices: number[] = []; + for (let i = 0; i < this.timeElapsedQueries.length; i++) { + const query = this.timeElapsedQueries[i]; + if (query !== null) { + const available = gl.getQueryParameter( + query, + gl.QUERY_RESULT_AVAILABLE, + ); + if (available) { + const result = gl.getQueryParameter(query, gl.QUERY_RESULT) / 1e6; + this.storedTimeDeltas.push(result); + gl.deleteQuery(query); + deletedQueryIndices.push(i); + } + } + } + for (let i = deletedQueryIndices.length - 1; i >= 0; i--) { + this.timeElapsedQueries.splice(i, 1); + } + if (this.storedTimeDeltas.length > this.numStoredTimes) { + this.storedTimeDeltas = this.storedTimeDeltas.slice(-this.numStoredTimes); + } + } + + getLastFrameTimesInMs( + numberOfFrames: number = 10, + ) { + return this.storedTimeDeltas.slice(-numberOfFrames); + } + + getQueries() { + return this.timeElapsedQueries; + } +} + +export enum FrameTimingMethod { + MEDIAN = 0, + MEAN = 1, + MAX = 2, +} + +export class DownsamplingBasedOnFrameRateCalculator { + private lastFrameTime: number | null = null; + private frameDeltas: number[] = []; + private downsamplingRates: number[] = []; + constructor( + public numberOfStoredFrameDeltas: number = 10, + private maxDownsamplingFactor: number = 8, + private desiredFrameTimingMs = 1000 / 60, + private downsamplingPersistenceDurationInFrames = 15, + ) { + if (numberOfStoredFrameDeltas < 1) { + throw new Error( + `Number of stored frame deltas must be at least 1, ` + + `but got ${numberOfStoredFrameDeltas}.`, + ); + } + if (maxDownsamplingFactor < 2) { + throw new Error( + `Max downsampling factor must be at least 2, ` + + `but got ${maxDownsamplingFactor}.`, + ); + } + } + resetLastFrameTime() { + this.lastFrameTime = null; + this.downsamplingRates = []; + } + + addFrame(timestamp: number = Date.now()) { + if (this.lastFrameTime !== null) { + const frameDelta = timestamp - this.lastFrameTime; + if (frameDelta < 0) { + throw new Error( + `Frame delta should be non-negative, but got ${frameDelta}. ` + + `This can happen if the clock is reset or if the ` + + `timestamp is generated in the future.`, + ); + } + this.frameDeltas.push(timestamp - this.lastFrameTime); + if (this.frameDeltas.length > this.numberOfStoredFrameDeltas) { + this.frameDeltas.shift(); + } + } + this.lastFrameTime = timestamp; + } + + calculateFrameTimeInMs( + method: FrameTimingMethod = FrameTimingMethod.MAX, + ): number { + if (this.frameDeltas.length < 1) { + return 0; + } + switch (method) { + case FrameTimingMethod.MEDIAN: + return this.calculateMedianFrameTime(); + case FrameTimingMethod.MEAN: + return this.calculateMeanFrameTime(); + case FrameTimingMethod.MAX: + return Math.max(...this.frameDeltas); + } + } + + private calculateMeanFrameTime(): number { + return ( + this.frameDeltas.reduce((a, b) => a + b, 0) / this.frameDeltas.length + ); + } + + private calculateMedianFrameTime(): number { + const sortedFrameDeltas = this.frameDeltas.slice().sort((a, b) => a - b); + const midpoint = Math.floor(sortedFrameDeltas.length / 2); + return sortedFrameDeltas.length % 2 === 1 + ? sortedFrameDeltas[midpoint] + : (sortedFrameDeltas[midpoint - 1] + sortedFrameDeltas[midpoint]) / 2; + } + + calculateDownsamplingRateBasedOnFrameDeltas( + method: FrameTimingMethod = FrameTimingMethod.MAX, + ): number { + const frameDelta = this.calculateFrameTimeInMs(method); + if (frameDelta === 0) { + return Math.min(4, this.maxDownsamplingFactor); + } + let downsampleFactorBasedOnFramerate = Math.max( + frameDelta / this.desiredFrameTimingMs, + 1, + ); + // Round to the nearest power of 2. + downsampleFactorBasedOnFramerate = Math.min( + Math.pow(2, Math.round(Math.log2(downsampleFactorBasedOnFramerate))), + this.maxDownsamplingFactor, + ); + // Store the downsampling rate. + this.downsamplingRates.push(downsampleFactorBasedOnFramerate); + if ( + this.downsamplingRates.length > + this.downsamplingPersistenceDurationInFrames + ) { + this.downsamplingRates.shift(); + } + // Return the maximum downsampling rate over the last few frames. + return Math.max(...this.downsamplingRates); + } + + getFrameDeltas(): number[] { + return this.frameDeltas; + } + + setFrameDeltas(frameDeltas: number[]) { + this.frameDeltas = frameDeltas.slice(-this.numberOfStoredFrameDeltas); + } +} diff --git a/src/viewer.ts b/src/viewer.ts index 465f56741..37f0b630f 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -295,6 +295,7 @@ class TrackableViewerState extends CompoundTrackable { this.add("layers", viewer.layerSpecification); this.add("showAxisLines", viewer.showAxisLines); this.add("wireFrame", viewer.wireFrame); + this.add("adaptiveDownsampling", viewer.adaptiveDownsampling); this.add("showScaleBar", viewer.showScaleBar); this.add("showDefaultAnnotations", viewer.showDefaultAnnotations); @@ -458,6 +459,7 @@ export class Viewer extends RefCounted implements ViewerState { ); showAxisLines = new TrackableBoolean(true, true); wireFrame = new TrackableBoolean(false, false); + adaptiveDownsampling = new TrackableBoolean(true, true); showScaleBar = new TrackableBoolean(true, true); showPerspectiveSliceViews = new TrackableBoolean(true, true); visibleLayerRoles = allRenderLayerRoles();