[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
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:
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):
Bug 2 - StartDrawing()/StopDrawing() on a sprite leaks one texture per repaint
Compiled runtime (spider_SpriteOutput, the stopDrawingCallback executed by StopDrawing):
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):
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:
(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:
(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:
With this patch applied, memory stays flat in both repro cases above, and the progressive slowdown in my game is gone.
Thanks!
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)}
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
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)
}
"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()
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
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);
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) {}
Thanks!