Skip to content

Commit 9ae8cd2

Browse files
authored
perf: cull off-screen blocks from display list to reduce stage.update() cost (#7738)
* feat: cull off-screen blocks during stage.update * fix: recompute culling after zoom in setBlockScale * fix: remove redundant visible check from isVisible * fix: guard viewport culling during initialization * test: add guard clause tests * test: add showBlocks and setBlockScale coverage * fix: recompute culling after all block operations * fix: guard culling during initialization * fix: remove redundant culling call in moveAllBlocksExcept * fix: update culling while dragging * Revert "fix: update culling while dragging" This reverts commit 0a55d23. * fix: update culling while dragging * fix: exempt drag group from per-frame culling * docs: explain why _endDeferCheckBounds re-culls
1 parent 67d81d7 commit 9ae8cd2

4 files changed

Lines changed: 274 additions & 0 deletions

File tree

js/__tests__/blocks.test.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,170 @@ global.splitSolfege = jest.fn();
106106
global.updateTemperaments = jest.fn();
107107
global.showZoomOverlay = jest.fn();
108108

109+
describe("Viewport Culling", () => {
110+
let mockActivity;
111+
let blocks;
112+
113+
beforeEach(() => {
114+
mockActivity = {
115+
storage: {},
116+
trashcan: {},
117+
turtles: {},
118+
boundary: {},
119+
macroDict: {},
120+
palettes: { dict: {}, show: jest.fn() },
121+
logo: { synth: { loadSynth: jest.fn() } },
122+
blocksContainer: { x: 0, y: 0 },
123+
canvas: { width: 800, height: 600 },
124+
refreshCanvas: jest.fn(),
125+
errorMsg: jest.fn(),
126+
setSelectionMode: jest.fn(),
127+
stopLoadAnimation: jest.fn(),
128+
setHomeContainers: jest.fn(),
129+
__tick: jest.fn()
130+
};
131+
blocks = new Blocks(mockActivity);
132+
});
133+
134+
it("should mark blocks inside the viewport as visible", () => {
135+
blocks.blockList = [
136+
{ trash: false, container: { x: 100, y: 100 }, width: 50, height: 30 },
137+
{ trash: false, container: { x: 0, y: 0 }, width: 800, height: 600 },
138+
{ trash: false, container: { x: 400, y: 300 }, width: 10, height: 10 }
139+
];
140+
141+
blocks._updateViewportCulling();
142+
143+
expect(blocks.blockList[0]._viewportVisible).toBe(true);
144+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
145+
expect(blocks.blockList[2]._viewportVisible).toBe(true);
146+
});
147+
148+
it("should mark blocks outside the viewport as not visible", () => {
149+
blocks.blockList = [
150+
{ trash: false, container: { x: -200, y: 100 }, width: 50, height: 30 },
151+
{ trash: false, container: { x: 900, y: 100 }, width: 50, height: 30 },
152+
{ trash: false, container: { x: 100, y: -100 }, width: 50, height: 30 },
153+
{ trash: false, container: { x: 100, y: 700 }, width: 50, height: 30 }
154+
];
155+
156+
blocks._updateViewportCulling();
157+
158+
expect(blocks.blockList[0]._viewportVisible).toBe(false);
159+
expect(blocks.blockList[1]._viewportVisible).toBe(false);
160+
expect(blocks.blockList[2]._viewportVisible).toBe(false);
161+
expect(blocks.blockList[3]._viewportVisible).toBe(false);
162+
});
163+
164+
it("should handle scrolled viewport offset", () => {
165+
mockActivity.blocksContainer.x = -200;
166+
mockActivity.blocksContainer.y = -100;
167+
168+
blocks.blockList = [
169+
{ trash: false, container: { x: 0, y: 0 }, width: 50, height: 30 },
170+
{ trash: false, container: { x: 300, y: 200 }, width: 50, height: 30 },
171+
{ trash: false, container: { x: 1000, y: 800 }, width: 50, height: 30 }
172+
];
173+
174+
blocks._updateViewportCulling();
175+
176+
// vp rect = (200, 100) to (1000, 700)
177+
// Block at (0,0) with w=50,h=30: (0+50) <= 200 → off-screen left
178+
expect(blocks.blockList[0]._viewportVisible).toBe(false);
179+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
180+
expect(blocks.blockList[2]._viewportVisible).toBe(false);
181+
});
182+
183+
it("should skip trashed blocks without modifying their visibility", () => {
184+
blocks.blockList = [
185+
{
186+
trash: true,
187+
container: { x: -500, y: -500 },
188+
width: 50,
189+
height: 30,
190+
_viewportVisible: true
191+
},
192+
{ trash: false, container: { x: 100, y: 100 }, width: 50, height: 30 }
193+
];
194+
195+
blocks._updateViewportCulling();
196+
197+
expect(blocks.blockList[0]._viewportVisible).toBe(true);
198+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
199+
});
200+
201+
it("should consider edge-aligned blocks as visible", () => {
202+
blocks.blockList = [
203+
{ trash: false, container: { x: 0, y: 0 }, width: 1, height: 600 },
204+
{ trash: false, container: { x: 799, y: 0 }, width: 1, height: 600 },
205+
{ trash: false, container: { x: 0, y: 599 }, width: 800, height: 1 }
206+
];
207+
208+
blocks._updateViewportCulling();
209+
210+
// One pixel inside the viewport edge
211+
expect(blocks.blockList[0]._viewportVisible).toBe(true);
212+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
213+
expect(blocks.blockList[2]._viewportVisible).toBe(true);
214+
});
215+
216+
it("should handle zero-dimension blocks (async bitmap not yet loaded)", () => {
217+
blocks.blockList = [
218+
{ trash: false, container: { x: -100, y: -100 }, width: 0, height: 0 },
219+
{ trash: false, container: { x: 100, y: 100 }, width: 0, height: 0 }
220+
];
221+
222+
blocks._updateViewportCulling();
223+
224+
// Zero-dim blocks are kept visible until dimensions stabilize
225+
expect(blocks.blockList[0]._viewportVisible).toBe(true);
226+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
227+
});
228+
229+
it("should skip null entries in blockList", () => {
230+
blocks.blockList = [
231+
null,
232+
{ trash: false, container: { x: 100, y: 100 }, width: 50, height: 30 }
233+
];
234+
235+
blocks._updateViewportCulling();
236+
237+
// Should not throw and remaining blocks should still be culled
238+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
239+
});
240+
241+
it("should skip blocks without a container", () => {
242+
blocks.blockList = [
243+
{ trash: false, container: null, width: 50, height: 30 },
244+
{ trash: false, container: { x: 100, y: 100 }, width: 50, height: 30 }
245+
];
246+
247+
blocks._updateViewportCulling();
248+
249+
// Block without container should be skipped, block with container processed
250+
expect(blocks.blockList[0]._viewportVisible).toBe(undefined);
251+
expect(blocks.blockList[1]._viewportVisible).toBe(true);
252+
});
253+
254+
it("should showBlocks without throwing", () => {
255+
blocks.blockList = [];
256+
blocks.showBlocks();
257+
258+
expect(mockActivity.palettes.show).toHaveBeenCalled();
259+
expect(blocks.visible).toBe(true);
260+
expect(mockActivity.refreshCanvas).toHaveBeenCalled();
261+
});
262+
263+
it("should update culling during setBlockScale", async () => {
264+
blocks.blockList = [];
265+
await blocks.setBlockScale(0.8);
266+
267+
expect(blocks.blockScale).toBe(0.8);
268+
expect(blocks.blockList[0]).toBeUndefined();
269+
expect(mockActivity.refreshCanvas).toHaveBeenCalled();
270+
});
271+
});
272+
109273
describe("Blocks Foundation", () => {
110274
let mockActivity;
111275

js/activity.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,10 @@ class Activity {
584584
* 3. GIF animations are playing
585585
* This eliminates unnecessary 60fps updates when idle.
586586
*/
587+
// Track last container position to avoid per-frame culling recompute.
588+
this._lastCullContainerX = undefined;
589+
this._lastCullContainerY = undefined;
590+
587591
this._startRenderLoop = () => {
588592
if (this._renderLoopRunning) return;
589593
this._renderLoopRunning = true;
@@ -597,6 +601,18 @@ class Activity {
597601
const isInteracting = this.isDragging || this.isSelecting;
598602

599603
if (this.stageDirty || hasActiveTweens || hasActiveGifs || isInteracting) {
604+
// Recompute culling when container moved.
605+
if (
606+
this.blocks &&
607+
this.blocksContainer &&
608+
(this._lastCullContainerX !== this.blocksContainer.x ||
609+
this._lastCullContainerY !== this.blocksContainer.y)
610+
) {
611+
this.blocks._updateViewportCulling();
612+
this._lastCullContainerX = this.blocksContainer.x;
613+
this._lastCullContainerY = this.blocksContainer.y;
614+
}
615+
600616
this.stage.update();
601617
this.stageDirty = false;
602618
// Continue the loop if there's work or ongoing interaction
@@ -2854,6 +2870,10 @@ class Activity {
28542870
this.stage.canvas.width = w;
28552871
this.stage.canvas.height = h;
28562872

2873+
// Viewport size changed — recompute culling on next render frame.
2874+
this._lastCullContainerX = undefined;
2875+
this._lastCullContainerY = undefined;
2876+
28572877
// Firefox large canvas warning
28582878
const isFirefox = navigator.userAgent.includes("Firefox");
28592879
const canvasArea = w * h;

js/block.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,7 @@ class Block {
281281
this.collapsed = false; // Is this collapsible block collapsed?
282282
this.inCollapsed = false; // Is this block in a collapsed stack?
283283
this.trash = false; // Is this block in the trash?
284+
this._viewportVisible = true; // Is this block within the current viewport?
284285
this.loadComplete = false; // Has the block finished loading?
285286
this.label = null; // Editable textview in DOM.
286287
this.labelattr = null; // Editable textview in DOM.
@@ -2016,6 +2017,7 @@ class Block {
20162017
// If it is not in the trash and not in collapsed, then show it.
20172018
if (!this.trash && !this.inCollapsed) {
20182019
this.container.visible = true;
2020+
this._viewportVisible = true;
20192021
if (this.isCollapsible()) {
20202022
if (this.collapsed) {
20212023
this.bitmap.visible = false;
@@ -3278,6 +3280,13 @@ class Block {
32783280
// Cache the drag group once on mousedown instead of
32793281
// recomputing the tree traversal on every pressmove.
32803282
that.blocks.cacheDragGroup(thisBlock);
3283+
// Track the drag group for viewport culling exemption during drag,
3284+
// so off-screen stack siblings remain visible while being dragged
3285+
// into view (avoids "pop-in" on release).
3286+
const group = that.blocks._cachedDragGroup;
3287+
if (group && group.length > 0) {
3288+
that.blocks._dragActiveGroup = new Set(group);
3289+
}
32813290
// Invalidate the top-block cache since a drag may
32823291
// disconnect blocks, changing the topology.
32833292
that.blocks.invalidateTopBlockCache();

js/blocks.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ class Blocks {
358358
this._checkBoundsScheduled = false;
359359
// Cached drag group computed once on mousedown, reused during pressmove
360360
this._cachedDragGroup = null;
361+
// Blocks in the active drag group are exempt from viewport culling
362+
// during the drag to avoid "pop-in" when off-screen siblings are
363+
// dragged into view. Cleared on pressup/mouseout.
364+
this._dragActiveGroup = null;
361365
// Cached top-block map for moveAllBlocksExcept edge-scroll
362366
this._topBlockCache = null;
363367
// Throttle timestamp for edge-scroll calls
@@ -548,6 +552,9 @@ class Blocks {
548552
// Rebuild spatial grid after scale change repositions blocks
549553
this._rebuildSpatialGrid();
550554

555+
// Viewport changed — recompute culling.
556+
this._updateViewportCulling();
557+
551558
/** Force a refresh. */
552559
await delayExecution(100);
553560
this.activity.refreshCanvas();
@@ -1172,6 +1179,14 @@ class Blocks {
11721179
this._checkBoundsPending = false;
11731180
this.scheduleCheckBounds();
11741181
}
1182+
1183+
if (this._deferCheckBoundsCount === 0) {
1184+
// Re-cull now — docks repositioned blocks relative to the
1185+
// viewport. The render loop only re-culls on container move,
1186+
// so without this, a block that shifted on-screen would stay
1187+
// invisible until the user scrolls.
1188+
this._updateViewportCulling();
1189+
}
11751190
};
11761191

11771192
/**
@@ -3544,6 +3559,26 @@ class Blocks {
35443559
myBlock.container.snapToPixelEnabled = true;
35453560
myBlock.container.x = 0;
35463561
myBlock.container.y = 0;
3562+
3563+
// Support viewport culling via _viewportVisible (eye icon takes priority).
3564+
myBlock.container._origIsVisible = myBlock.container.isVisible;
3565+
myBlock.container.isVisible = function () {
3566+
if (!myBlock._viewportVisible) {
3567+
// During a drag, show blocks in the active drag group even
3568+
// if they are off-screen, so the user sees the entire stack
3569+
// follow the cursor smoothly instead of "popping in" on release.
3570+
if (
3571+
myBlock.blocks &&
3572+
myBlock.blocks._dragActiveGroup &&
3573+
myBlock.blocks._dragActiveGroup.has(myBlock.blockIndex)
3574+
) {
3575+
return this._origIsVisible.call(this);
3576+
}
3577+
return false;
3578+
}
3579+
return this._origIsVisible.call(this);
3580+
};
3581+
35473582
this._updateSpatialGrid(this.blockList.length - 1);
35483583

35493584
/** and we need to load the images into the container. */
@@ -4022,6 +4057,7 @@ class Blocks {
40224057
*/
40234058
this.clearCachedDragGroup = () => {
40244059
this._cachedDragGroup = null;
4060+
this._dragActiveGroup = null;
40254061
};
40264062

40274063
/**
@@ -7771,6 +7807,49 @@ class Blocks {
77717807
}
77727808
};
77737809

7810+
/***
7811+
* Culls off-screen blocks from display list rendering.
7812+
* Recompute after scroll, pan, resize, or project load.
7813+
*/
7814+
this._updateViewportCulling = () => {
7815+
const canvas = this.activity.canvas;
7816+
// Skip culling until the canvas has been initialized with valid dimensions.
7817+
// Calling culling too early (e.g. during project load before layout is final)
7818+
// would mark on-screen blocks as off-screen, and since the viewport never
7819+
// moves afterward, they'd stay invisible.
7820+
if (!canvas || !canvas.width || !canvas.height) return;
7821+
7822+
const container = this.activity.blocksContainer;
7823+
if (!container) return;
7824+
7825+
// Viewport rect in container-space
7826+
const vpLeft = -container.x;
7827+
const vpTop = -container.y;
7828+
const vpRight = vpLeft + canvas.width;
7829+
const vpBottom = vpTop + canvas.height;
7830+
7831+
for (let i = 0; i < this.blockList.length; i++) {
7832+
const block = this.blockList[i];
7833+
if (!block || block.trash || !block.container) {
7834+
continue;
7835+
}
7836+
const c = block.container;
7837+
// AABB overlap test against viewport rect.
7838+
// Skip blocks with zero dimensions (async bitmap not yet loaded)
7839+
// to avoid culling them before their size is known.
7840+
if (!block.width || !block.height) {
7841+
block._viewportVisible = true;
7842+
continue;
7843+
}
7844+
block._viewportVisible = !(
7845+
c.x + block.width <= vpLeft ||
7846+
c.x >= vpRight ||
7847+
c.y + block.height <= vpTop ||
7848+
c.y >= vpBottom
7849+
);
7850+
}
7851+
};
7852+
77747853
/***
77757854
* Hides all the blocks.
77767855
*
@@ -7790,6 +7869,8 @@ class Blocks {
77907869
this.showBlocks = () => {
77917870
this.activity.palettes.show();
77927871
this.show();
7872+
// Recompute culling — off-screen blocks stay hidden during playback.
7873+
this._updateViewportCulling();
77937874
this.bringToTop();
77947875
this.activity.refreshCanvas();
77957876
};

0 commit comments

Comments
 (0)