Page 1 of 1

[SB 3.20] Sprite memory leaks: FreeSprite() keeps the Pixi BaseTexture alive + StopDrawing() orphans a texture on every

Posted: Fri Jul 17, 2026 11:07 pm
by Fox
Hello,

While hunting a progressive slowdown in my game (input latency growing the longer you play), I traced it down to two memory leaks in the SpiderBasic runtime itself. Verified by reading the compiled JS output of SpiderBasic 3.20 (bundled renderer: pixi.js-legacy 5.3.12).

Environment
- SpiderBasic 3.20 (Windows x86), Web export
- Bundled pixi.js-legacy 5.3.12
- Reproduced on Chrome and Firefox, desktop and mobile

Bug 1 - FreeSprite() never releases the pixel data

Compiled runtime:

Code: Select all

function spider_FreeSprite(a){var b;if(-1==a)spider.sprite.objects.CleanAll();
else if(b=spider.sprite.objects.Get(a))b.texture&&b.texture.destroy(),
spider.sprite.objects.Remove(a)}
texture.destroy() is called without the destroyBase argument. In Pixi 5, this destroys the Texture object only: the BaseTexture (which owns the source canvas AND the uploaded WebGL texture) survives, and it stays referenced by Pixi's global BaseTextureCache. So it is not even garbage-collectable. Every FreeSprite() permanently retains width*height*4 bytes of canvas data (plus GPU memory if the sprite was ever displayed).

Minimal repro (watch memory in the browser task manager):

Code: Select all

InitSprite()
OpenWindow(0, 0, 0, 800, 600, "FreeSprite leak")
OpenWindowedScreen(WindowID(0), 0, 0, 800, 600)

; 200 create/free cycles of a 1024x1024 sprite
; -> ~800 MB of canvases retained forever (200 * 1024*1024*4)
For i = 1 To 200
  CreateSprite(0, 1024, 1024)
  FreeSprite(0)
Next
Bug 2 - StartDrawing()/StopDrawing() on a sprite leaks one texture per repaint

Compiled runtime (spider_SpriteOutput, the stopDrawingCallback executed by StopDrawing):

Code: Select all

stopDrawingCallback:function(){
  PIXI.Texture.removeFromCache(b.texture);
  b.texture=PIXI.Texture.fromCanvas(c);
  spider_sprite_UpdateCollisionMask(b)
}
The old texture is removed from the cache but never destroyed, then a brand-new Texture/BaseTexture is created for the same canvas. If the old texture was already rendered at least once, its GPU-side data is tracked by the renderer and is not released deterministically. Result: any game that repaints a sprite every frame (HUD counters, progress bars, dynamic overlays - a very common SB pattern) accumulates memory continuously. As a side effect you also get the well-known console warning spam:
"BaseTexture added to the cache with an id [...] that already had an entry".

Minimal repro (~1 MB leaked per frame, ~60 MB/s):

Code: Select all

InitSprite()
OpenWindow(0, 0, 0, 800, 600, "StopDrawing leak")
OpenWindowedScreen(WindowID(0), 0, 0, 800, 600)

CreateSprite(0, 512, 512)

Procedure RenderFrame()
  If StartDrawing(SpriteOutput(0))
    Box(0, 0, 512, 512, RGB(Random(255), 100, 100))
    StopDrawing()
  EndIf
  DisplaySprite(0, 144, 44)
  FlipBuffers()
EndProcedure

BindEvent(#PB_Event_RenderFrame, @RenderFrame())
FlipBuffers()
Impact
RAM and VRAM grow for the whole session. Framerate degrades progressively, which shows up as growing input/cursor latency. In my game (background layers regenerated per level + per-frame HUD repaints) this made long sessions unplayable.

Suggested fix

FreeSprite:

Code: Select all

if (b.texture) b.texture.destroy(true);   // also destroys the BaseTexture
(with a guard to keep the current behaviour for textures whose resource is not a canvas, e.g. shared loader textures from LoadSprite, if that matters internally).

stopDrawingCallback:

Code: Select all

var old = b.texture;
b.texture = PIXI.Texture.fromCanvas(c);
if (old && old !== b.texture) old.destroy(true);
spider_sprite_UpdateCollisionMask(b);
(removeFromCache becomes unnecessary: destroy(true) cleans both caches.)

Note for the fix: CreateSprite() on an already-used sprite number frees the old one through spider.sprite.objects.Allocate, whose free callback was captured at load time - so the fix must be applied inside the original functions, not only by reassigning the global.

Workaround for other users (until fixed)

Runtime monkey-patch, injected as inline JS at the very top of the main source (before any CreateSprite). It fixes both paths and reroutes Allocate to the corrected FreeSprite. destroy(true) is only applied when the texture source is a canvas; everything is wrapped in try/catch so a future runtime change falls back to the current behaviour instead of breaking the boot:

Code: Select all

! try {
!   var sbxDestroyTex = function(t){
!     try {
!       var bt = t && t.baseTexture;
!       var src = bt && bt.resource && bt.resource.source;
!       if (src && src.tagName === 'CANVAS') { t.destroy(true); return true; }
!     } catch(e) {}
!     return false;
!   };
!   spider_FreeSprite = function(a){
!     if (a == -1) {
!       var self = spider.sprite.objects;
!       for (var k in self.map) { if (self.map.hasOwnProperty(k)) { spider_FreeSprite(k); } }
!     } else {
!       var b = spider.sprite.objects.Get(a);
!       if (b) {
!         if (b.texture && !sbxDestroyTex(b.texture) && b.texture.destroy) { b.texture.destroy(); }
!         spider.sprite.objects.Remove(a);
!       }
!     }
!   };
!   var sbxAllocOrig = spider.sprite.objects.Allocate;
!   spider.sprite.objects.Allocate = function(a){
!     if (a != -1 && this.map && this.map.hasOwnProperty(a)) { spider_FreeSprite(a); }
!     return sbxAllocOrig.call(this, a);
!   };
!   var sbxOutOrig = spider_SpriteOutput;
!   spider_SpriteOutput = function(a){
!     var r = sbxOutOrig(a);
!     if (r && r.stopDrawingCallback) {
!       var sbxOldTex = r.sprite ? r.sprite.texture : null;
!       var sbxCbOrig = r.stopDrawingCallback;
!       r.stopDrawingCallback = function(){
!         sbxCbOrig();
!         if (sbxOldTex && r.sprite && sbxOldTex !== r.sprite.texture) { sbxDestroyTex(sbxOldTex); }
!       };
!     }
!     return r;
!   };
! } catch(e) {}
With this patch applied, memory stays flat in both repro cases above, and the progressive slowdown in my game is gone.

Thanks!