Keep drags at full speed in large projects (#394)

Dragging a band of a Filter or a Parametric EQ ran at about 30 fps
in a large project. Each frame of a drag announces the changed
project, and every listener of that event ran, also those whose work
nobody sees while the drag goes on.

- FramePreview marks its own frame, read through isDragFrame().
- The Scene view behind another tab skips such a frame and draws on
  coming back, as it already did while a game runs (#265).
- preloadProjectFiles skips it as well. It walks every asset and
  checks every bake, which costs milliseconds in a large project.
- The command that ends a drag emits for itself, and a drag that
  changes nothing ends with a plain preview, so both catch up then.
- This holds for every drag through FramePreview, the knobs of the
  devices in the graph included.

Measured on stein2 with a copy of a project of 162 assets, a band of
a Parametric EQ dragged at 250 pointer moves a second for 3 s:

- Listeners per frame: 17 ms before, 7.4 ms after.
- Hidden Scene view: 814 ms before, 53 ms after.
- preloadProjectFiles: 514 ms before, 0.4 ms after.
- What is left is mostly the Hierarchy and Assets panels on screen
  (about 3 and 1.3 ms a frame). They stay as they are.

Tests:

- Unit frame-preview, play-throttle: 29 passed (1 new)
- Counter-check: without the mark the new test fails
- e2e 34 (melektaus-atx): ALL PASSED, 136 checks
- Gate (melektaus-atx): CHECK EXIT 0, 288 files, 4417 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 19:45:59 +02:00
1 parent 3f7eb03143
commit cb3183227b
4 files changed
+44 -11

No files matched your search

+6 -2
View File
@@ -18,6 +18,7 @@ import { installLoginHook } from './auth/loginHook';
import { consoleLog } from './console/log';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { isDragFrame } from './framePreview';
import { startPlugins } from './plugins/startPlugins';
import { CelUploadStore } from './pixelart/celUploadStore';
import { EditorState } from './state';
@@ -74,8 +75,11 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
// Drawn pictures (#185) are looked up in the project this session holds; the next session
// installs its own, which is also what lets go of everything the old one had drawn.
textureLibrary.setPictureFactory(projectPictureFactory(() => state.project));
// Asked on every change, which is cheap – a file that is held or on its way is skipped.
state.on('project', () => preloadProjectFiles(state.project));
// Asked on every change but a drag's frame (#394): in a large project the walk over all assets
// costs milliseconds, and the command that ends the drag asks again.
state.on('project', () => {
if (!isDragFrame()) preloadProjectFiles(state.project);
});
const editor = new Editor(
app,
root,
+17 -1
View File
@@ -2,6 +2,15 @@ import { cancelFrameWork, onFrame } from '../ui/render';
import type { EditorState } from './state';
let instances = 0;
let inDragFrame = false;
/**
* Whether the `project` event going on is a drag's frame. What nobody sees during it – a hidden view,
* files to load ahead – may wait for the command that ends the drag (#394).
*/
export function isDragFrame(): boolean {
return inDragFrame;
}
/**
* A drag's `state.preview()`, at most once per frame (#213). The gesture still writes the model on
@@ -20,7 +29,14 @@ export class FramePreview {
}
request(): void {
onFrame(this.key, () => this.state.preview());
onFrame(this.key, () => {
inDragFrame = true;
try {
this.state.preview();
} finally {
inDragFrame = false;
}
});
}
cancel(): void {
+8 -7
View File
@@ -26,7 +26,7 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { cellAtPoint } from '../../core/model/tilemap';
import { footprintCorners, iconCorners } from '../../core/properties/bounds';
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
import { FramePreview } from '../framePreview';
import { FramePreview, isDragFrame } from '../framePreview';
import { panelPolicy } from '../playThrottle';
import { type EditorState, objectSelection } from '../state';
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel';
@@ -204,15 +204,16 @@ export class SceneViewport extends FreeViewport {
});
}
/** Set when a redraw was skipped behind the game tab (#265); the next time on screen draws it. */
/**
* Set when a redraw was skipped behind another tab: while a game runs (#265), or for a drag's frame
* (#394). The next time on screen draws it.
*/
private missedRedraw = false;
override redraw(): void {
if (
!this.visible &&
this.state.mode === 'play' &&
!panelPolicy(this.state.project.whilePlaying).hiddenSceneView
) {
const playing =
this.state.mode === 'play' && !panelPolicy(this.state.project.whilePlaying).hiddenSceneView;
if (!this.visible && (playing || isDragFrame())) {
this.missedRedraw = true;
return;
}