March 22, 2014

SKShapeNode, you are dead to me

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.

  1. SKShapeNodeiswidelyknowntoleakmemory.

    Unfixable memory leaks is already enough reason to avoid using an API. But wait, there's more…

  2. From SKShapeNode's documentation, "A line width larger than 2.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.

  3. Sometimes setStrokeColor:[SKColor redColor] has no visual effect at all. So you have to trick the SKShapeNode into redrawing itself. Changing its alpha 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 like CALayer. That is an optimization to eliminate useless redraws. If that's the case, then whoever was working on SKShapeNode apparently forgot to have setStrokeColor: invoke the setNeedsDisplay: of Sprite Kit.


    Digging deeper, it seems this problem manifests itself only when the SKShapeNode is a descendent of SKEffectNode. 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];
            });
        }
        return self;
    }

    For me, the round rect stays red indefinitely. If you change that if (0) to a true value, then the alpha change causes the subsequent setStrokeColor: to have the intended visible effect.

    I've reported this to Apple as rdar://16400219.

  4. 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 reason SKShapeNode decided to try to resurrect them, but only did half the job.

  5. 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 apparent SKShapeNode 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 an SKEffectNode. My guess is that SKEffectNode'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 if SKShapeNode freaks out when it's being rendered into an SKEffectNode, I seriously question how robust Sprite Kit is. (Incidentally, SKEffectNode also doesn't respect the zPositions of its children, but that's another post altogether. The solution for that one is to interject a plain SKNode 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];
        }
        return self;
    }
    
    -(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.

  6. According to other folks, SKShapeNode also has terrible performance and is missing key features from its CAShapeLayer 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.

  1. Just remove the SKShapeNode. For some effects it's not worth all the trouble. You'll soon think of something better to replace it.

  2. For borders on opaque nodes, just use a SKSpriteNode instantiated with +[SKSpriteNode spriteNodeWithColor:size:]. This gets you a rectangular block of the provided 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!

  3. Sprite Kit plays well enough with CALayer and friends. When you can get away with it, stick a CAShapeLayer into your SKView'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 two SKView instances, sandwiching the CAShapeLayer. 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 few convertPoint: hurdles. The coordinate system of Sprite Kit is different from the coordinate system of Core Animation. Natch.

  4. Render a CGPathRef offscreen using a disconnected CAShapeLayer. Then snapshot that into an image. Then create an SKSpriteNode 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: