From deb3c23bcf63d08a34a9363ba1fdae3d0752da01 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 10 Mar 2026 11:03:59 +0530 Subject: [PATCH] fix(refboard): cap loaded textures at 60 and prioritize by distance When zoomed out, all items fall within the viewport so the culling system tried to load everything at once. Now: - Max 60 textures loaded at any time (sorted by distance to center) - Max 5 new texture loads per tick (prevents spike) - Items over budget or far away are unloaded immediately --- frontend/src/canvas/PixiCanvas.tsx | 60 ++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/frontend/src/canvas/PixiCanvas.tsx b/frontend/src/canvas/PixiCanvas.tsx index 4056ef4..0be6578 100644 --- a/frontend/src/canvas/PixiCanvas.tsx +++ b/frontend/src/canvas/PixiCanvas.tsx @@ -16,7 +16,7 @@ import { } from 'react'; import { Application } from 'pixi.js'; import { Viewport } from 'pixi-viewport'; -import { SceneManager, getItemWorldBounds } from './SceneManager'; +import { SceneManager, getItemWorldBounds, type SceneItem } from './SceneManager'; import { TextureManager } from './TextureManager'; import { ImageSprite } from './sprites/ImageSprite'; import { SpringManager } from './spring'; @@ -167,9 +167,10 @@ const PixiCanvas = forwardRef( // -- Visibility culling ticker (runs every 200ms, not every frame) -- // Images: load textures when near viewport, unload when far away. - // Uses preload margin (1 screen) and larger unload margin (2 screens) - // to avoid thrashing during panning. + // Uses preload margin and hysteresis. Caps concurrent loaded textures + // to prevent GPU OOM when zoomed out (all items visible at once). + const MAX_LOADED_TEXTURES = 60; // GPU budget — only this many images loaded at once let lastCullCheck = 0; app.ticker.add((ticker) => { lastCullCheck += ticker.deltaMS; @@ -177,34 +178,27 @@ const PixiCanvas = forwardRef( lastCullCheck = 0; const bounds = viewport.getVisibleBounds(); - // Preload margin: 1x screen size beyond viewport edges const loadMargin = Math.max(bounds.width, bounds.height); - // Unload margin: 2x screen size — hysteresis prevents thrash const unloadMargin = loadMargin * 2; + const vcx = bounds.x + bounds.width / 2; + const vcy = bounds.y + bounds.height / 2; + + // Collect image items with distance from viewport center + const imageItems: { item: SceneItem; sprite: ImageSprite; dist: number; near: boolean }[] = []; for (const item of scene.getAllItems()) { const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); if (item.type === 'image' && item.displayObject instanceof ImageSprite) { - const nearViewport = + const near = ix + iw > bounds.x - loadMargin && ix < bounds.x + bounds.width + loadMargin && iy + ih > bounds.y - loadMargin && iy < bounds.y + bounds.height + loadMargin; - - if (nearViewport && !item.displayObject.loaded) { - item.displayObject.loadTexture(); - } else if (!nearViewport) { - // Only unload if well outside viewport (hysteresis) - const farFromViewport = - ix + iw < bounds.x - unloadMargin || - ix > bounds.x + bounds.width + unloadMargin || - iy + ih < bounds.y - unloadMargin || - iy > bounds.y + bounds.height + unloadMargin; - if (farFromViewport && item.displayObject.loaded) { - item.displayObject.unloadTexture(); - } - } + const cx = ix + iw / 2; + const cy = iy + ih / 2; + const dist = (cx - vcx) ** 2 + (cy - vcy) ** 2; + imageItems.push({ item, sprite: item.displayObject, dist, near }); } if (item.type === 'video' && 'onVisibilityChange' in item.displayObject) { @@ -216,6 +210,32 @@ const PixiCanvas = forwardRef( (item.displayObject as any).onVisibilityChange(inView); } } + + // Sort by distance — closest to viewport center first + imageItems.sort((a, b) => a.dist - b.dist); + + // Determine which should be loaded: nearest items up to budget + const nearItems = imageItems.filter(i => i.near); + const shouldBeLoaded = new Set( + nearItems.slice(0, MAX_LOADED_TEXTURES).map(i => i.item.id) + ); + + // Load nearest unloaded items (cap at 5 per tick to avoid spike) + let loadsThisTick = 0; + for (const { item, sprite } of imageItems) { + if (shouldBeLoaded.has(item.id) && !sprite.loaded && loadsThisTick < 5) { + sprite.loadTexture(); + loadsThisTick++; + } + } + + // Unload items that are either far away or over budget + for (const { item, sprite, near } of imageItems) { + if (!sprite.loaded) continue; + if (!near || !shouldBeLoaded.has(item.id)) { + sprite.unloadTexture(); + } + } }); // -- Store refs ------------------------------------------------------