From cb3183227be26900b77df7bff73f015fa9d8cdd7 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 19:45:59 +0200 Subject: [PATCH] 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 --- src/editor/App.ts | 8 ++++++-- src/editor/framePreview.ts | 18 +++++++++++++++++- src/editor/viewport/SceneViewport.ts | 15 ++++++++------- tests/editor/frame-preview.test.ts | 14 +++++++++++++- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/src/editor/App.ts b/src/editor/App.ts index e64c165d..16d09ad2 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -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 // 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, diff --git a/src/editor/framePreview.ts b/src/editor/framePreview.ts index 9faab27f..f92854c5 100644 --- a/src/editor/framePreview.ts +++ b/src/editor/framePreview.ts @@ -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 { diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index f030032b..5fe714bb 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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; } diff --git a/tests/editor/frame-preview.test.ts b/tests/editor/frame-preview.test.ts index d42b00a6..0678d406 100644 --- a/tests/editor/frame-preview.test.ts +++ b/tests/editor/frame-preview.test.ts @@ -19,7 +19,7 @@ import type { import { PALETTE_KEY } from '../../src/core/model/pixelart'; import { createEmptyProject } from '../../src/core/model/Project'; import { CurveCanvas } from '../../src/editor/curve/CurveCanvas'; -import { FramePreview } from '../../src/editor/framePreview'; +import { FramePreview, isDragFrame } from '../../src/editor/framePreview'; import { GraphDrags } from '../../src/editor/graph/graphDrags'; import type { GraphCanvasHost } from '../../src/editor/graph/graphHost'; import type { InspectorHost } from '../../src/editor/inspector/host'; @@ -89,6 +89,18 @@ describe('FramePreview', () => { expect(state.preview).not.toHaveBeenCalled(); }); + it('says during its own preview that the event is a drag frame, and at no other time (#394)', () => { + const tick = frames(); + const seen: boolean[] = []; + const state = { preview: vi.fn(() => seen.push(isDragFrame())) }; + new FramePreview(state, 'drag').request(); + expect(isDragFrame()).toBe(false); + tick(); + state.preview(); + expect(seen).toEqual([true, false]); + expect(isDragFrame()).toBe(false); + }); + it('keeps two gestures of the same name apart', () => { const tick = frames(); const a = fakeState();