Corona/LUA Runtime - Great optimization potential?

Hey guys - many thanks for taking a look at the memory issues of my module. @no2games your amendment works a treat. It wasn’t enough that the timer was referred to by the same name on each recursion so that’s where I went amiss.

@torbenratzlaff - I’ll test run your localised version of spine as soon as i get the chance. Have you seen about getting it accepted into the official runtimes? I would suggest contacting Esoteric directly through their forums. Otherwise as soon as those runtimes are updated, all your ammendments will need to be made again…

Hey guys,

sorry it took me so long to upload to github.

Here’s the link of the fork.

https://github.com/torbenratzlaff/spine-runtimes

Would be great if you contribute your changes/feedback there :slight_smile:

Looks good. I’m on vacation for a couple weeks, but I’ll try to get my wrapper updated for this and add the pre-calculated sin/cos/rad functions as soon as I can. Nice work.

Would be great for the performance to implement the lookup table.

But I wonder, if slow rotation would look a bit strange, because you round the angle to the next integer.

Soo, anything new on this front?

for what it’s worth…, just be sure to benchmark any proposed trig lookup on an actual device, cuz it often doesn’t pay off.  math.cos (et al) are native calls, whereas a table lookup needs:  conversion of the angle to index, a proper modulo handling negative values, and finally the table indexing itself.  and all that interpreted indexing code often works out to be slower than just calling math.cos directly!  so you may lose accuracy AND performance.

I did update Bone.lua to use a single lookup table, but I agree since Corona itself is capped at 30 or 60FPS unless you have performance issues on a device there’s no reason to implement it. And you do see “clicking” into angles with slow rotations of big objects.

Here’s a snippet of the lines I added. It’s pretty easy to comment/uncomment to see it both ways.

I really haven’t had a chance to evaluate your fork of the spine runtimes, because I’m working on a game release right now and we’re trying to lock things down and I don’t want to introduce any new code. I will take a peek the second things slow down.

local Bone = {} local lookup = {} for i = 0, 359 do lookup[i] = math.sin( math.pi / 180 \* i ) end function Bone.new (data, skeleton, parent) if not data then error("data cannot be nil", 2) end if not skeleton then error("skeleton cannot be nil", 2) end local self = { data = data, -- continued -- self.worldScaleY = self.scaleY self.worldRotation = self.rotationIK self.worldFlipX = skeletonFlipX ~= self.flipX self.worldFlipY = skeletonFlipY ~= self.flipY end --local radians = math.rad(self.worldRotation) --local cos = math.cos(radians) --local sin = math.sin(radians) local angle = (self.worldRotation - self.worldRotation % 1) % 360 local cos = lookup[(angle+90) % 360] local sin = lookup[angle]

Searched the web for some validation and found a few (not Corona) test that other developers shared.

The conclusion seems to be, that it depends which framework you use and how it handles the math calls.

But the difference is so small in comparison to other optimizations (e.g. localising) that it isn’t worth the effort and downsides to use a lookup table instead of a localized math function.

@no2games

did you upload your spine wrapper to github ? 
i need for a good spine wrapper 

Regards 
Jens

@blomjens1 Not pushed it github yet, but it’s pretty much the code I posted on the first page of this thread…

ok thanks!

The latest spine runtimes can be found on Esoteric Software’s gitHub: https://github.com/EsotericSoftware/spine-runtimes 

Yeah, that’s the version I was referring to.

Spine is entirely global as well as you can see in spine-corona/spine.lua…

Yeah, been experiencing very sluggish behaviour when using more than one skeleton - especially as new animations are being called when onComplete is triggered by a previous animation (or any other event start, onEnd, events). After continually doing this, frame rate drops right down to 2, lua memory is bulked. Upon changing the scene, the frame rate stays low and the lua memory still holds the memory it picked up from the spine animation scene. Haven’t found this on a looping animation, only one that plays a new animation after an event…

Perhaps a table somewhere is just collecting animation data and storing it globally? Any ideas?

Torbenratzlaff - be good to know how you get on. In the meantime, I’ll start bug busting further…

So far I’m done with the following:

  • adding metatables wherever possible and necessary

  • localizing global variables and function

  • localizing variables in time critical processes (e.g. loops, runtime)

  • removing unnecessary code (e.g. mesh transformation)

  • wrinting a wrapper, so skeletonData, spritesheets and others have to be only created once

  • creating a new skeleton from preexisting skeletonData with a single line of code

Now I ask myself, what to do with those changes. Is there a spine “official” in the Corona forums? Or should I make a pull request to the above mentioned GitHub project?

@kilopop

Concerning the memory leak you mentioned. Could you go a little bit more into detail how to reproduce that bug?

I’m interested in this as well. I have a WIP wrapper that lets you load a spine and “tether” it to a corona physics object AND combines all the spine events into one easy event.phase / event.data

 hero.spine = spine.new("spine/hero.json", { x=hero.x, y=hero.y, imageDir = "spine/heroParts", --imageSheet = "spine/hero.png", --imageData = "spine.hero", tetherObject = hero, tetherOffsetY = hero.height/2, mixTime = 0.25, }) hero.spine:play("idle",true)  

I’d be happy to donate the wrapper to the overall project…

BTW, I would do both a pull request and a fork of the spine runtimes…

@torbenratzlaff: Certainly, I’d be interested to see what you’ve come up with. What are the performance improvements like? I would suggest you start a post on the Esoteric Runtime board: http://esotericsoftware.com/forum/viewforum.php?f=7&sid=57e243a5c329cd004672f602078ca116 To ask the the question of the best method to get these users changes out there. In the meantime, I’d be interested in testing your new version to see if it affects the issues I’m experiencing.

To clarify the bottlenecking I’m getting. The library I’ve developed works similar to spineHelper. It uses a metatable to create the spine objects and then animation is added in a separate object oriented function M:animation. There is no problem if animations are called within the onComplete event e,g,

state.onComplete = function (trackIndex, loop) state:addAnimationByName( data ) end

The above would trigger particular animations according to what is set in the data every time the animation triggers an onComplete event. In this case the animations loop forever happily with no problems. 

But if the animation module is called multiple times then the memory mounts up and the framerate decreases. An example setup uses eventListeners to go between a scene page and the onComplete event of the Spine library:
 

-- Scene 1 -- obj[name].spine is a table with a reference to a skeleton.group -- There are 4 animations, antics1 - antics4. Each time animLoop is called, a random one plays function M:animLoop (event) local event = event local name = event.tag local rand = math.random( 1, 4 ) obj[name].spine:animation( {onComplete="animLoop", sequence="antics"..rand}) end Runtime:addEventListener("animLoop", M) -- Spine Library state.onComplete = function (trackIndex, loop) --loopCount if onComplete[trackIndex] ~= nil then Runtime:dispatchEvent( {name=onComplete[trackIndex], tag=name} ) end end

Each time the Spine module is called, it declares local variables with the skeleton, state etc that are created and associated with the object when Spine was being created.

function M:animation (params) local params = params or {} local skeleton = self.skeleton local root = self.root local stateData = self.stateData local state = self.state local name = self.name -- rest of module end

So I kinda wonder if these local variables are mounting up over time?

Any ideas at this stage would be great. I could post the library as it is so far if that helps.

How does your library handle continual animation calls @torbenratzlaff? If you were to use event listeners like this in your onComplete events would you get the same results?

@no2games - It would be great to see your wrapper. In particular how you are handling spine events.

There are a few different Spine libraries out there. It would be great to combine the best of them to create an on-going library. SpineHelper is an adequate start but only handles simple tasks.

@kilopop Mine is not overly complicated… What I tried to do was extend skeleton to contain all the animation, images, etc to keep them in one structure. I also killed all that extra math and locked the animations into 30 or 60 FPS based on what the config.lua is set at… I figure if your animations can’t keep up you have bigger issues.

Finally I wrapped all the state events into one onEvent() but it should probably be renamed to onAnimation()

So I use it like this… Create a display object…

hero = display.newObject() physics.addBody(hero)

Then I create the spine as a metatable (or object) off of that display object

hero.spine = spine.new("spine/hero.json", { x=hero.x, y=hero.y, imageDir = "spine/heroParts", tetherObject = hero, mixTime = 0.25, }) hero.spine:play("idle",true) 

Then I hide the original display object

hero.isVisible = false

I keep my spine runtimes in a package structure, so you’ll need to update the one spine = require()

Anything you can do to speed this up or improve the stability is appreciated. 

-- Project: Spine Wrapper 0.3 -- -- Date: Mar 20, 2015 -- Updated: Apr 14, 2015 local spine = require "com.esoteric.spine-corona.spine" local M = {} M.delta = 1 / display.fps function M.new(spineJson, options) -- defaults options = options or {} local speed = options.speed or 1 local tetherObject = options.tetherObject or nil local offsetX, offsetY = options.tetherOffsetX or 0, options.tetherOffsetY or 0 -- Json loading local jsonData = spine.SkeletonJson.new() jsonData.scale = options.scale or 1 local skeletonData = jsonData:readSkeletonDataFile(spineJson) -- build our skeleton object local skeleton = spine.Skeleton.new(skeletonData) skeleton.delta = M.delta -- function to load from images or texture pack / shoebox if options.imageSheet and options.imageData then skeleton.imageData = require (options.imageData) skeleton.imageSheet = graphics.newImageSheet( options.imageSheet, skeleton.imageData.sheet ) end function skeleton:createImage(attachment) if options.imageDir then return display.newImage(options.imageDir .. "/" .. attachment.name .. ".png") elseif self.imageSheet and self.imageData then local frame = self.imageData:getFrameIndex(attachment.name) return display.newImage(self.imageSheet, frame) end end skeleton.count = 0 -- dump animation data local animations = skeleton.data.animations for i = 1, #animations do print ("Animation found:", animations[i].name) end function skeleton.animationExists(name) for i = 1, #animations do if name == animations[i].name then return true end end return false end -- initial animation/pose setup skeleton:setToSetupPose() skeleton.stateData = spine.AnimationStateData.new(skeletonData) local stateData = skeleton.stateData -- set global mix duration if #animations \> 1 then for i = 1, #animations do for j = 1, #animations do if not (i==j) then stateData:setMix(animations[i].name, animations[j].name, options.mixTime or 0.5) end end end end -- setup animation queue skeleton.state = spine.AnimationState.new(stateData) local state = skeleton.state -- Events: -- let's combine these events in a more corona-like fashion if options.onEvent then local e = {} local eventFunction = options.onEvent state.onStart = function (trackIndex) e.phase = "started" e.trackIndex = trackIndex e.name = state:getCurrent(trackIndex).animation.name if options.default==nil then -- don't fire started with default animation eventFunction(skeleton,e) end end state.onEnd = function (trackIndex) e.phase = "ended" e.trackIndex = trackIndex e.name = state:getCurrent(trackIndex).animation.name eventFunction(skeleton,e) end state.onComplete = function (trackIndex, loopCount) e.phase = "completed" e.trackIndex = trackIndex e.loopCount = loopCount e.name = state:getCurrent(trackIndex).animation.name eventFunction(skeleton,e) end state.onEvent = function (trackIndex, event) e.phase = event.data.name or "unknown" e.trackIndex = trackIndex e.loopCount = loopCount e.data = event.intValue or event.floatValue or event.stringValue or "" e.name = state:getCurrent(trackIndex).animation.name eventFunction(skeleton,e) end end local function offScreen(object) local bounds = object.contentBounds local sox, soy = display.screenOriginX, display.screenOriginY if bounds.xMax \< sox then return true end if bounds.yMax \< soy then return true end if bounds.xMin \> display.actualContentWidth - sox then return true end if bounds.yMin \> display.actualContentHeight - soy then return true end return false end function skeleton.destroy() Runtime:removeEventListener("enterFrame", skeleton.enterFrame) if skeleton.group then display.remove(skeleton.group) skeleton.group = nil end skeleton.imageSheet = nil skeleton = nil end function skeleton.enterFrame() local hasGroup = skeleton and skeleton.group and skeleton.group.numChildren local state = skeleton.state -- if we loose our group then destroy if hasGroup then if tetherObject and tetherObject.x and tetherObject.y then skeleton.group.rotation = tetherObject.rotation skeleton.group.x, skeleton.group.y = tetherObject.x + offsetX, tetherObject.y + offsetY else skeleton:destroy() return false end -- offscreen if offScreen(skeleton.group) then return end -- Update the state with the delta time, apply it, and update the world transforms. state:update(skeleton.delta) state:apply(skeleton) if skeleton then skeleton:updateWorldTransform() end else state:update(M.delta) -- in case we are missing state:apply(skeleton) skeleton:destroy() return false end return true end function skeleton.init() if tetherObject then skeleton.group.rotation = tetherObject.rotation skeleton.group.x, skeleton.group.y = tetherObject.x + offsetX, tetherObject.y + offsetY end -- Update the state with the delta time, apply it, and update the world transforms. state:update(skeleton.delta) state:apply(skeleton) skeleton:updateWorldTransform() return true end -- \*\*\* simple wrapper functions \*\*\* -- what's playing function skeleton:currentAnimation(trackIndex) if not self.state then return end local currentState = self.state:getCurrent(trackIndex or 0) if not currentState then return end return currentState.animation.name end -- play an animation function skeleton:play(name, loop, trackIndex) if self.isPaused then self:resume() end if self.animationExists(name) and self:currentAnimation() ~= name then self.state:setAnimationByName(trackIndex or 0, name or self.data.animations[1].name, loop or false) else --print ("WARNING: Animation not found or already playing:",name) return false end self.isPaused = false end -- add an animation to the queue function skeleton:add(name, loop, delay, trackIndex) if self.animationExists(name) and self:currentAnimation() ~= name then self.state:addAnimationByName(trackIndex or 0, name or self.data.animations[1].name, loop or false, delay) else --print ("WARNING: Animation not found or already playing:",name) end end -- pause the animation function skeleton:pause() if not self.isPaused then --print("pausing") self.isPaused = true Runtime:removeEventListener("enterFrame", self.enterFrame) end return self.isPaused end -- resume the animation function skeleton:resume() if self.isPaused then --print ("resuming") self.isPaused = false timer.performWithDelay(M.delta, function () Runtime:addEventListener("enterFrame", self.enterFrame) end) end return self.isPaused end -- set default placement if skeleton.group then skeleton.group.x = options.x or 0 skeleton.group.y = options.y or 0 if options.anchored then skeleton.group.anchorChildren = true skeleton.group.anchorX,skeleton.group.anchorY = 0.5,0.5 end end -- kick off the first frame to load the parts skeleton.init() -- start paused skeleton.isPaused = true -- unless we choose a default animation if options.default then skeleton:play(options.default, options.loop) end return skeleton end return M