Update: Apple has resolved many of these bugs as fixed in iOS 8. I'll write a followup when we get further into the betas.
For the past three months I've spent damn near every night and weekend moment building my next iOS game. I now regularly shut down Diesel Cafe. The game is my most ambitious project yet and I'm having a blast making it. As of today it's sixteen thousand lines and growing strong. For the UI I'm using Sprite Kit which has been a real pleasure. But lurking inside it there is one source of pain that keeps recurring.
SKShapeNode
is a subclass of SKNode
that draws a CGPathRef
. It can render Bézier curves, polygons, rings, Louisiana, whatever. You can set the stroke color of the shape, or its fill color, or both. You could probably implement a decent chunk of your game's HUD with it. Bézier curves are a great way to give visual feedback of a user's gesture as in, say, Flight Control. Describing shapes at runtime rather than at design time (as in SKSpriteNode
) unlocks worlds of possibilities.
However, SKShapeNode
is by far the least-well engineered API in Sprite Kit. In fact, I have trouble naming a single lousier API that I've used since I started programming professionally. Say what you will about tenets of SOAP, at least it's an ethos.
I respect that iOS 7 was a rush order. It's unfair to expect that everything will come out perfectly during a platform reinvention. However I maintain that Sprite Kit would have been improved by simply holding SKShapeNode
back until iOS 8. It was not ready to ship. But since people have it, they want to use it. And to those people, BEWARE!
SKShapeNode
, how do I loathe thee? Let me count the ways.
SKShapeNode
…is…widely…known…to…leak…memory.Unfixable memory leaks is already enough reason to avoid using an API. But wait, there's more…
From
SKShapeNode
's documentation, "A line width larger than2.0
may cause rendering artifacts in the final rendered image."It's good that they are up front about this limitation. But that is still pretty weak.
Sometimes
setStrokeColor:[SKColor redColor]
has no visual effect at all. So you have to trick theSKShapeNode
into redrawing itself. Changing itsalpha
is one way to do it:#if BUSTED_SKSHAPENODE_SETSTROKECOLOR CGFloat oldAlpha = shape.alpha; shape.alpha = 0; shape.alpha = oldAlpha; #endif shape.strokeColor = [SKColor redColor];
Note that it is not sufficient to simply say
shape.alpha = shape.alpha
. That does not trigger a display. For whatever reason, the internals demand you actually change the property value.You know, I wouldn't be surpised to learn that internally, Sprite Kit uses a
setNeedsDisplay:
system likeCALayer
. That is an optimization to eliminate useless redraws. If that's the case, then whoever was working onSKShapeNode
apparently forgot to havesetStrokeColor:
invoke thesetNeedsDisplay:
of Sprite Kit.Digging deeper, it seems this problem manifests itself only when the
SKShapeNode
is a descendent ofSKEffectNode
. To see it in action, start a new project using the Sprite Kit template and replace your scene class's implementation with this:-(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { SKEffectNode *container = [SKEffectNode node]; [self addChild:container]; SKShapeNode *shape = [SKShapeNode node]; shape.path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(20, 20, 20, 20) cornerRadius:4].CGPath; shape.position = CGPointMake(CGRectGetMidX(self.frame), CGRectGetMidY(self.frame)); shape.strokeColor = [SKColor redColor]; [container addChild:shape]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ NSLog(@"setting stroke color"); if (0) {// <-- CHANGE ME CGFloat oldAlpha = shape.alpha; shape.alpha = 0; shape.alpha = oldAlpha; } shape.strokeColor = [SKColor greenColor]; }); } returnself; }
For me, the round rect stays red indefinitely. If you change that
if (0)
to a true value, then the alpha change causes the subsequentsetStrokeColor:
to have the intended visible effect.I've reported this to Apple as rdar://16400219.
SKShapeNode
sometimes drops little rendering glitches throughout my scenes.Those red lines are from
SKShapeNode
instances that once rendered red rectangles. Many frames ago. For whatever reasonSKShapeNode
decided to try to resurrect them, but only did half the job.This one is the most baffling and upsetting. It seems that if you have too many
SKShapeNode
instances visible on screen, it completely screws up the scene rendering. The scene shrinks to about 60% of its height for a few moments. In the following screenshots you can see what happens when I tiptoe past the apparentSKShapeNode
limit (thanks to all that detritus from the previous point). The game becomes completely unusable.This problem seems to yet again be the fault of
SKShapeNode
inside of anSKEffectNode
. My guess is thatSKEffectNode
's unique rendering model is triggering this.SKEffectNode
lets you apply Core Image filters (which are akin to Photoshop filters) to some of your nodes. It's amazingly powerful. Seriously next level shit. But to achieve that,SKEffectNode
must render its subtree into a separate buffer to which it can apply its CI filter. This different codepath is probably the cause of all the problems. But ifSKShapeNode
freaks out when it's being rendered into anSKEffectNode
, I seriously question how robust Sprite Kit is. (Incidentally,SKEffectNode
also doesn't respect thezPosition
s of its children, but that's another post altogether. The solution for that one is to interject a plainSKNode
into the node tree. See rdar://16534245)Anyway. I've luckily been able to replicate this crazy rendering glitch with a small amount of code. I've recorded a video showing the bug. As before, replace the Sprite Kit template's scene class's implementation with the following:
-(id)initWithSize:(CGSize)size { if (self = [super initWithSize:size]) { SKEffectNode *container = [SKEffectNode node]; [self addChild:container]; } returnself; } -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { UITouch *touch = [touches anyObject]; SKEffectNode *container = self.children[0]; SKShapeNode *shape = [SKShapeNode node]; shape.path = [UIBezierPath bezierPathWithRoundedRect:CGRectMake(-10, -10, 20, 20) cornerRadius:4].CGPath; shape.position = [touch locationInNode:self]; shape.strokeColor = [SKColor colorWithHue:drand48() saturation:1 brightness:1 alpha:1]; shape.blendMode = SKBlendModeAdd; [container addChild:shape]; }
Tap the screen a few times. All's well.
Tap the screen a few more times…Hey what the hell was that?
What in the world did Apple do to cause this bug? Regardless, I've reported this one as rdar://16400203.
According to other folks,
SKShapeNode
also has terrible performance and is missing key features from itsCAShapeLayer
counterpart.
Because of all these flaws, SKShapeNode
is completely untrustworthy. I now refuse to use SKShapeNode
for any new code I write. I have also been refactoring existing code that uses it to stop using it. Here are some ways I've been able to do that.
Just remove the
SKShapeNode
. For some effects it's not worth all the trouble. You'll soon think of something better to replace it.For borders on opaque nodes, just use a
SKSpriteNode
instantiated with . This gets you a rectangular block of the provided+[SKSpriteNode spriteNodeWithColor:size:]
SKColor
. Beyond just borders, I've converted my HP bars this way too.Switching to a sprite even looks better. And you won't have to fear using a border width of greater than 2.0. Cripes!
Sprite Kit plays well enough with
CALayer
and friends. When you can get away with it, stick aCAShapeLayer
into yourSKView
's layer. I use this in two places in my game: a drawing pad and a procedurally-generated lightning bolt.This works fine if your
CAShapeLayer
is going to be the topmost UI component. However if you need to display Sprite Kit content over the layer, things would get tricky. Maybe you can use twoSKView
instances, sandwiching theCAShapeLayer
. That sounds like an awful lot of work though. Personally, I've chosen my battles carefully; there will be nothing in my game that renders above that drawing pad or lightning bolt.Be aware that using
CALayer
requires jumping through a fewconvertPoint:
hurdles. The coordinate system of Sprite Kit is different from the coordinate system of Core Animation. Natch.Render a
CGPathRef
offscreen using a disconnectedCAShapeLayer
. Then snapshot that into an image. Then create anSKSpriteNode
with that snapshot as a texture. While I haven't personally used this technique, I see no reason it wouldn't work.Now you can add that sprite to your scene, animate it all over town, put it over or under other nodes, etc. You now have an unchanging
SKShapeNode
without all of the insane, unfixable bugs.
You know, maybe that one is worth doing right. The first person to implement the complete SKShapeNode
API using an SKSpriteNode
backed by a CALayer
wins … my undying respect!
Update: Reader Michael Redig pointed me to his SKUtilities
project which implements exactly that: SKUShapeNode
is a subclass of SKSpriteNode
that renders using a CAShapeLayer
. It's currently incomplete but certainly looks to me like a good start.
As far as I'm concerned, this is how SKShapeNode
should be handled in your codebase:
#define SKShapeNode SHAPENODE_IS_BANNED
This results in an error if, in a moment of weakness, you try to use SKShapeNode
:
