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!