feat: throttle scene rendering to animation framerate (#5422)

This commit is contained in:
David Luzar 2022-07-07 11:47:37 +02:00 committed by GitHub
parent c725f84334
commit b6bb74d08d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 89 additions and 51 deletions

View File

@ -166,7 +166,7 @@ import {
isAndroid, isAndroid,
} from "../keys"; } from "../keys";
import { distance2d, getGridPoint, isPathALoop } from "../math"; import { distance2d, getGridPoint, isPathALoop } from "../math";
import { renderScene } from "../renderer"; import { renderSceneThrottled } from "../renderer/renderScene";
import { invalidateShapeForElement } from "../renderer/renderElement"; import { invalidateShapeForElement } from "../renderer/renderElement";
import { import {
calculateScrollCenter, calculateScrollCenter,
@ -1193,7 +1193,8 @@ class App extends React.Component<AppProps, AppState> {
element.id !== this.state.editingElement.id element.id !== this.state.editingElement.id
); );
}); });
const { atLeastOneVisibleElement, scrollBars } = renderScene(
renderSceneThrottled(
renderingElements, renderingElements,
this.state, this.state,
this.state.selectionElement, this.state.selectionElement,
@ -1216,24 +1217,25 @@ class App extends React.Component<AppProps, AppState> {
isExporting: false, isExporting: false,
renderScrollbars: !this.device.isMobile, renderScrollbars: !this.device.isMobile,
}, },
({ atLeastOneVisibleElement, scrollBars }) => {
if (scrollBars) {
currentScrollBars = scrollBars;
}
const scrolledOutside =
// hide when editing text
isTextElement(this.state.editingElement)
? false
: !atLeastOneVisibleElement && renderingElements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside });
}
this.scheduleImageRefresh();
},
); );
if (scrollBars) {
currentScrollBars = scrollBars;
}
const scrolledOutside =
// hide when editing text
isTextElement(this.state.editingElement)
? false
: !atLeastOneVisibleElement && renderingElements.length > 0;
if (this.state.scrolledOutside !== scrolledOutside) {
this.setState({ scrolledOutside });
}
this.history.record(this.state, this.scene.getElementsIncludingDeleted()); this.history.record(this.state, this.scene.getElementsIncludingDeleted());
this.scheduleImageRefresh();
// Do not notify consumers if we're still loading the scene. Among other // Do not notify consumers if we're still loading the scene. Among other
// potential issues, this fixes a case where the tab isn't focused during // potential issues, this fixes a case where the tab isn't focused during
// init, which would trigger onChange with empty elements, which would then // init, which would trigger onChange with empty elements, which would then

View File

@ -1 +0,0 @@
export { renderScene } from "./renderScene";

View File

@ -47,7 +47,11 @@ import {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils"; import {
viewportCoordsToSceneCoords,
supportsEmoji,
throttleRAF,
} from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { THEME_FILTER } from "../constants"; import { THEME_FILTER } from "../constants";
import { import {
@ -568,6 +572,32 @@ export const renderScene = (
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars }; return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
}; };
/** renderScene throttled to animation framerate */
export const renderSceneThrottled = throttleRAF(
(
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
selectionElement: NonDeletedExcalidrawElement | null,
scale: number,
rc: RoughCanvas,
canvas: HTMLCanvasElement,
renderConfig: RenderConfig,
callback?: (data: ReturnType<typeof renderScene>) => void,
) => {
const ret = renderScene(
elements,
appState,
selectionElement,
scale,
rc,
canvas,
renderConfig,
);
callback?.(ret);
},
{ trailing: true },
);
const renderTransformHandles = ( const renderTransformHandles = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
renderConfig: RenderConfig, renderConfig: RenderConfig,

View File

@ -39,7 +39,7 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -14,7 +14,7 @@ import { reseed } from "../random";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -16,7 +16,7 @@ import { KEYS } from "../keys";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -14,7 +14,7 @@ import { reseed } from "../random";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -20,7 +20,7 @@ import { t } from "../i18n";
const { h } = window; const { h } = window;
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
const finger1 = new Pointer("touch", 1); const finger1 = new Pointer("touch", 1);

View File

@ -18,7 +18,7 @@ const mouse = new Pointer("mouse");
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -16,7 +16,7 @@ import { Keyboard, Pointer } from "./helpers/ui";
// Unmount ReactDOM from root // Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!); ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene"); const renderScene = jest.spyOn(Renderer, "renderSceneThrottled");
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
renderScene.mockClear(); renderScene.mockClear();

View File

@ -126,47 +126,54 @@ export const debounce = <T extends any[]>(
}; };
// throttle callback to execute once per animation frame // throttle callback to execute once per animation frame
export const throttleRAF = <T extends any[]>(fn: (...args: T) => void) => { export const throttleRAF = <T extends any[]>(
let handle: number | null = null; fn: (...args: T) => void,
opts?: { trailing?: boolean },
) => {
let timerId: number | null = null;
let lastArgs: T | null = null; let lastArgs: T | null = null;
let callback: ((...args: T) => void) | null = null; let lastArgsTrailing: T | null = null;
const scheduleFunc = (args: T) => {
timerId = window.requestAnimationFrame(() => {
timerId = null;
fn(...args);
lastArgs = null;
if (lastArgsTrailing) {
lastArgs = lastArgsTrailing;
lastArgsTrailing = null;
scheduleFunc(lastArgs);
}
});
};
const ret = (...args: T) => { const ret = (...args: T) => {
if (process.env.NODE_ENV === "test") { if (process.env.NODE_ENV === "test") {
fn(...args); fn(...args);
return; return;
} }
lastArgs = args; lastArgs = args;
callback = fn; if (timerId === null) {
if (handle === null) { scheduleFunc(lastArgs);
handle = window.requestAnimationFrame(() => { } else if (opts?.trailing) {
handle = null; lastArgsTrailing = args;
lastArgs = null;
callback = null;
fn(...args);
});
} }
}; };
ret.flush = () => { ret.flush = () => {
if (handle !== null) { if (timerId !== null) {
cancelAnimationFrame(handle); cancelAnimationFrame(timerId);
handle = null; timerId = null;
} }
if (lastArgs) { if (lastArgs) {
const _lastArgs = lastArgs; fn(...(lastArgsTrailing || lastArgs));
const _callback = callback; lastArgs = lastArgsTrailing = null;
lastArgs = null;
callback = null;
if (_callback !== null) {
_callback(..._lastArgs);
}
} }
}; };
ret.cancel = () => { ret.cancel = () => {
lastArgs = null; lastArgs = lastArgsTrailing = null;
callback = null; if (timerId !== null) {
if (handle !== null) { cancelAnimationFrame(timerId);
cancelAnimationFrame(handle); timerId = null;
handle = null;
} }
}; };
return ret; return ret;