From 30fb3a23166d944308d2978ff7e38b10a2617ca3 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Wed, 23 Sep 2026 11:08:11 +0200 Subject: [PATCH] Dragging previews once per frame instead of on every move (#213) Scene view, texture tab and graph called state.preview() on every pointer move. Each call rebuilds panels, the editor run and generated images, so a drag in a busy project cost up to 34 ms per move. The model is still written on every move. Only the preview now waits for the next frame. - New FramePreview in src/editor/framePreview.ts. request() goes into the move handler, cancel() into the release. The rule lives in one place instead of six copies. - Scene view: object drag, polygon corners and tilemap strokes use it. - Texture tab: frames, slice edges and collision corners use it. - Graph: nodes, groups and comments use it through GraphDrags. That covers the state machine, shader and particle tabs as well. - Vector art is unchanged. Its frame already writes the model since #129. Findings: - The texture tab paid twice per move (232 project events for 120 moves). Pixi sends a second pointer event 4 to 8 ms after each real move as a hover update. It now lands in the same frame for free. - Inspector scrubbing and gradient stop dragging still preview on every input. Both lie outside the three canvases of the issue. Measured with a throwaway probe, 120 moves at 8 ms, 120 extra objects: scene 26.2 -> 18.5 ms/move, texture 34.2 -> 17.9, graph 28.8 -> 17.0. Project events dropped from 120/232/119 to 48/48/41. Tests: - ./check.sh: 147 files, 2204 tests, exit 0 (before 146 / 2199). - 5 new unit tests in tests/editor/frame-preview.test.ts, including the real GraphDrags. Counter-checks (direct preview in the graph, no cancel on release, one key per name) each fail the expected test. - e2e 1-16,29,31,36,37,39-43: 708 checks passed. - e2e 1-16,26,28: 634 checks passed. - No new e2e check, the check sum stays at 1314. Co-Authored-By: Claude Opus 5.5 --- src/editor/framePreview.ts | 29 ++++++ src/editor/graph/graphDrags.ts | 13 ++- src/editor/texture/TextureCanvas.ts | 8 +- src/editor/viewport/SceneViewport.ts | 5 +- src/editor/viewport/polygonCorners.ts | 9 +- src/editor/viewport/tilemapPainting.ts | 9 +- tests/editor/frame-preview.test.ts | 119 +++++++++++++++++++++++++ 7 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 src/editor/framePreview.ts create mode 100644 tests/editor/frame-preview.test.ts diff --git a/src/editor/framePreview.ts b/src/editor/framePreview.ts new file mode 100644 index 00000000..9faab27f --- /dev/null +++ b/src/editor/framePreview.ts @@ -0,0 +1,29 @@ +import { cancelFrameWork, onFrame } from '../ui/render'; +import type { EditorState } from './state'; + +let instances = 0; + +/** + * A drag's `state.preview()`, at most once per frame (#213). The gesture still writes the model on + * every pointer move; only the event that rebuilds panels, the run in the editor and generated + * pictures waits for the frame, which a mouse reporting faster than the screen would otherwise + * outrun (#129). `cancel` on release: the command writes the end state and emits for itself. + */ +export class FramePreview { + private readonly key: string; + + constructor( + private readonly state: Pick, + name: string, + ) { + this.key = `${name}:${(instances += 1)}`; + } + + request(): void { + onFrame(this.key, () => this.state.preview()); + } + + cancel(): void { + cancelFrameWork(this.key); + } +} diff --git a/src/editor/graph/graphDrags.ts b/src/editor/graph/graphDrags.ts index b3ca769d..b502c339 100644 --- a/src/editor/graph/graphDrags.ts +++ b/src/editor/graph/graphDrags.ts @@ -11,6 +11,7 @@ import { travellers, type Travellers, } from './annotationDrag'; +import { FramePreview } from '../framePreview'; import type { AnnotationView } from './AnnotationView'; import type { GraphCanvasHost } from './graphHost'; import { GRID_STEP } from './graphView'; @@ -36,7 +37,11 @@ export class GraphDrags { moved: boolean; } | null = null; - constructor(private readonly host: GraphCanvasHost) {} + private readonly perFrame: FramePreview; + + constructor(private readonly host: GraphCanvasHost) { + this.perFrame = new FramePreview(host.state, 'graph-drag'); + } /** A press on a node: the selection travels with it. False when there is no graph to move in. */ grabNodes(global: Point): boolean { @@ -95,7 +100,7 @@ export class GraphDrags { const step = this.host.snapping() ? GRID_STEP : 0; // Each thing snaps for itself, node or box, so what lands on the grid is what was grabbed. placeTravellers(graph, drag.along, (start) => dragOffset(start, dx, dy, step)); - this.host.state.preview(); + this.perFrame.request(); } /** Moves or resizes the dragged box in the model, for the preview; committed on release. */ @@ -118,7 +123,7 @@ export class GraphDrags { const offset = dragOffset(drag.grabbed.start, dx, dy, step); placeTravellers(graph, drag.along, () => offset); } - this.host.state.preview(); + this.perFrame.request(); } /** Puts the previewed boxes back and records the whole gesture as one undo step. False when no box was dragged. */ @@ -126,6 +131,7 @@ export class GraphDrags { const drag = this.box; if (!drag) return false; this.box = null; + this.perFrame.cancel(); const graph = this.host.asset()?.graph; const assetId = this.host.assetId(); if (!drag.moved || !graph || !assetId) return true; @@ -140,6 +146,7 @@ export class GraphDrags { releaseNodes(): void { const drag = this.nodes; this.nodes = null; + this.perFrame.cancel(); const graph = this.host.asset()?.graph; const assetId = this.host.assetId(); if (!drag?.moved || !graph || !assetId) return; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 03c630ec..45f3e3c2 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -33,6 +33,7 @@ import { } from '../polygonEdit'; import { dashedLine } from '../../viewport/symbols'; import { textureLibrary } from '../../viewport/TextureLibrary'; +import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; import { drawChecker } from './checker'; import { SheetCanvas } from './SheetCanvas'; @@ -118,9 +119,11 @@ export class TextureCanvas extends SheetCanvas { /** The outline a click adds corners to; for another frame, or one gone, the frame's last one. */ private active = { frameId: '', index: -1 }; private readonly stopPictures: () => void; + private readonly perFrame: FramePreview; constructor(private readonly state: EditorState) { super(); + this.perFrame = new FramePreview(state, 'texture-drag'); this.content.addChild(this.image, this.overlay, this.labelLayer); state.on('project', () => this.redraw()); @@ -344,7 +347,7 @@ export class TextureCanvas extends SheetCanvas { const frame = corner && this.frame(corner.frameId); if (!corner || !frame) return; setFrameCollision(frame, corner.outlines); - this.state.preview(); + this.perFrame.request(); } private startDrag( @@ -424,10 +427,11 @@ export class TextureCanvas extends SheetCanvas { frame, normalizeFrameRect(dragFrame(drag.start, drag.handle, dx, dy), asset.width, asset.height), ); - this.state.preview(); + this.perFrame.request(); } protected release(): void { + this.perFrame.cancel(); const corner = this.corner; this.corner = null; const frame = corner && this.frame(corner.frameId); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 70fee04f..0e69565b 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -23,6 +23,7 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { cellAtPoint } from '../../core/model/tilemap'; import { footprintCorners } from '../../core/properties/bounds'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; +import { FramePreview } from '../framePreview'; import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; @@ -109,6 +110,7 @@ export class SceneViewport extends FreeViewport { private editorWorld: World | null = null; private lastRays: CastRay[] = []; private dragging: ObjectDrag | null = null; + private readonly perFrame = new FramePreview(this.state, 'scene-drag'); private marquee: Marquee | null = null; private dropPreview: DropPreview | null = null; private lastGizmos: GizmoInfo[] = []; @@ -232,7 +234,7 @@ export class SceneViewport extends FreeViewport { z: item.start.z, }); } - this.state.preview(); + this.perFrame.request(); } else if (this.marquee) { const local = this.toLocal(e.global); this.marquee.current = { x: local.x, y: local.y }; @@ -303,6 +305,7 @@ export class SceneViewport extends FreeViewport { } const drag = this.dragging; this.dragging = null; + this.perFrame.cancel(); if (drag && !drag.moved && drag.onClick) this.state.select(objectSelection(drag.holderId, [drag.onClick])); if (!drag || !drag.moved) return; diff --git a/src/editor/viewport/polygonCorners.ts b/src/editor/viewport/polygonCorners.ts index e0647058..9bb113b5 100644 --- a/src/editor/viewport/polygonCorners.ts +++ b/src/editor/viewport/polygonCorners.ts @@ -8,6 +8,7 @@ import { pressCount, Theme } from '../../ui'; import type { DrawItem } from '../../viewport/items'; import { type View, worldToScreen } from '../../viewport/view'; import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit'; +import { FramePreview } from '../framePreview'; import type { SceneViewHost } from './sceneViewHost'; /** Screen pixels around a polygon's corner or edge that grab it. */ @@ -41,8 +42,11 @@ export class PolygonCornerEditor { private corner: CornerDrag | null = null; /** The corners drawn for the edited polygon, in global pixels (test hook). */ private lastCorners: { objectId: string; corners: Vec2[] } | null = null; + private readonly perFrame: FramePreview; - constructor(private readonly host: SceneViewHost) {} + constructor(private readonly host: SceneViewHost) { + this.perFrame = new FramePreview(host.state, 'polygon-corner'); + } /** A corner is being dragged. */ get active(): boolean { @@ -123,7 +127,7 @@ export class PolygonCornerEditor { const ref = corner && holder && objectRef(this.host.state.project, holder, corner.objectId); if (!corner || !ref) return; ref.setOverride(COLLIDER_POINTS_KEY, listOf(corner.points)); - this.host.state.preview(); + this.perFrame.request(); } /** @@ -134,6 +138,7 @@ export class PolygonCornerEditor { const corner = this.corner; if (!corner) return false; this.corner = null; + this.perFrame.cancel(); const state = this.host.state; const holder = this.host.holder(); const ref = holder && objectRef(state.project, holder, corner.objectId); diff --git a/src/editor/viewport/tilemapPainting.ts b/src/editor/viewport/tilemapPainting.ts index 529fe6a8..bf6cfd3a 100644 --- a/src/editor/viewport/tilemapPainting.ts +++ b/src/editor/viewport/tilemapPainting.ts @@ -21,6 +21,7 @@ import { import { Theme } from '../../ui'; import type { DrawItem } from '../../viewport/items'; import { type View, worldToScreen } from '../../viewport/view'; +import { FramePreview } from '../framePreview'; import { paintedLayerId, paintedTilemap, pickTile } from '../panels/TilemapPanel'; import type { TilemapTool } from '../state'; import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost'; @@ -54,8 +55,11 @@ interface PaintStroke { /** The tilemap tools of the scene view: brush, eraser, rectangle, fill and pick. */ export class TilemapPainter { private stroke: PaintStroke | null = null; + private readonly perFrame: FramePreview; - constructor(private readonly host: SceneViewHost) {} + constructor(private readonly host: SceneViewHost) { + this.perFrame = new FramePreview(host.state, 'tile-stroke'); + } /** A stroke is under way. */ get active(): boolean { @@ -198,13 +202,14 @@ export class TilemapPainter { else delete layer.cells[key]; changed = true; } - if (changed) this.host.state.preview(); + if (changed) this.perFrame.request(); } /** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */ private endStroke(): void { const stroke = this.stroke; this.stroke = null; + this.perFrame.cancel(); if (!stroke) return; const layer = this.strokeLayer(stroke); if (layer) diff --git a/tests/editor/frame-preview.test.ts b/tests/editor/frame-preview.test.ts new file mode 100644 index 00000000..20dfcb5e --- /dev/null +++ b/tests/editor/frame-preview.test.ts @@ -0,0 +1,119 @@ +/** + * A drag writes the model on every pointer move but tells the editor once per frame (#213): the + * model event rebuilds panels and pictures, and a mouse reports faster than the screen draws. + */ +import type { Application } from 'pixi.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { GraphAsset } from '../../src/core/model/Asset'; +import { FramePreview } from '../../src/editor/framePreview'; +import { GraphDrags } from '../../src/editor/graph/graphDrags'; +import type { GraphCanvasHost } from '../../src/editor/graph/graphHost'; +import type { EditorState } from '../../src/editor/state'; +import { initRenderLoop, resetRenderLoop } from '../../src/ui/render'; + +/** One frame of the render loop, the way the ticker runs it. */ +function frames(): () => void { + const added: Array<() => void> = []; + const app = { + render(): void {}, + ticker: { + add: (fn: () => void) => added.push(fn), + remove: () => undefined, + }, + }; + initRenderLoop(app as unknown as Application); + return () => added.forEach((fn) => fn()); +} + +function fakeState() { + return { + selectedAnnotationIds: [] as string[], + selectedNodeIds: ['n1'], + preview: vi.fn(), + execute: vi.fn(() => true), + }; +} + +describe('FramePreview', () => { + beforeEach(() => resetRenderLoop()); + + it('turns any number of requests in one frame into a single preview', () => { + const tick = frames(); + const state = fakeState(); + const preview = new FramePreview(state, 'drag'); + for (let i = 0; i < 5; i++) preview.request(); + expect(state.preview).not.toHaveBeenCalled(); + tick(); + expect(state.preview).toHaveBeenCalledTimes(1); + tick(); + expect(state.preview).toHaveBeenCalledTimes(1); + }); + + it('drops the waiting preview on cancel', () => { + const tick = frames(); + const state = fakeState(); + const preview = new FramePreview(state, 'drag'); + preview.request(); + preview.cancel(); + tick(); + expect(state.preview).not.toHaveBeenCalled(); + }); + + it('keeps two gestures of the same name apart', () => { + const tick = frames(); + const a = fakeState(); + const b = fakeState(); + new FramePreview(a, 'drag').request(); + new FramePreview(b, 'drag').request(); + tick(); + expect(a.preview).toHaveBeenCalledTimes(1); + expect(b.preview).toHaveBeenCalledTimes(1); + }); +}); + +describe('dragging a node in the graph', () => { + beforeEach(() => resetRenderLoop()); + + function rig() { + const state = fakeState(); + const asset = { + id: 'b', + graph: { nodes: [{ id: 'n1', type: 'Add', x: 0, y: 0, params: {} }], edges: [] }, + } as unknown as GraphAsset; + const host = { + state: state as unknown as EditorState, + assetId: () => 'b', + asset: () => asset, + nodeBoxes: () => [{ id: 'n1', x: 0, y: 0, w: 100, h: 40 }], + toGraph: (p: { x: number; y: number }) => ({ x: p.x, y: p.y }), + zoom: () => 1, + snapping: () => false, + } as unknown as GraphCanvasHost; + return { state, node: asset.graph.nodes[0]!, drags: new GraphDrags(host) }; + } + + it('moves the node with every pointer move and previews once a frame', () => { + const tick = frames(); + const { state, node, drags } = rig(); + drags.grabNodes({ x: 10, y: 10 }); + for (let i = 1; i <= 5; i++) { + drags.move({ x: 10 + i * 10, y: 10 }); + expect(node.x).toBe(i * 10); + } + expect(state.preview).not.toHaveBeenCalled(); + tick(); + expect(state.preview).toHaveBeenCalledTimes(1); + }); + + it('leaves nothing for the frame after the release: the command has the last word', () => { + const tick = frames(); + const { state, node, drags } = rig(); + drags.grabNodes({ x: 10, y: 10 }); + drags.move({ x: 50, y: 30 }); + drags.releaseNodes(); + expect(state.execute).toHaveBeenCalledTimes(1); + expect(node.x).toBe(0); + tick(); + expect(state.preview).not.toHaveBeenCalled(); + }); +});