Poor performance with SKShapeNode in Sprite Kit
Asked Answered
F

3

13

I'm making a "Achtung die kurve"-clone in Sprite Kit. For the constantly moving lines/players I'm using A CGMutablePathRef along with an SKShapeNode. In the update method I'm doing this

// _lineNode is an instance of SKShapeNode and path is CGMutablePathRef
CGPathAddLineToPoint(path, NULL, _xPos, _yPos);
_lineNode.path = path;

to add to the line. The update method is also updating the _xPos and _yPos constantly to make it grow.

I guess what I'm really asking is is there another, more efficient way of drawing the lines, since the way I'm doing it now drops the frame rate way too much after a while (about 15-20 seconds). At this point the FPS just drops constantly until the game is unplayable. The Time Profiler tells me that this line: _lineNode.path = path is the cause of the FPS drop.

Thanks for any help! It is greatly appreciated.

PS. I'm trying to not use SKShapeNode at all since they seem to not being able to draw the lines too good (Small holes/artifacts in the curves etc.)

Screenshot: Line being constantly drawn drawn

Fibrinolysin answered 3/7, 2014 at 12:4 Comment(5)
1. Do not test performance in the Simulator. It is not the least bit representative when it comes to performance. 2. Don't expect SKShapeNode to be fast, at least not when you have a) many of them or b) change their paths frequently. My understanding is they are mainly for debug drawing and crop nodes, and not supposed to be used as a game's main visual node.Caryl
I see. Well, devices does get the same FPS drops as the emulator. Do you have any suggestions as to what I should use for the lines? Thanks for replying :)Fibrinolysin
Either sprites that stretch from point to point, or cocos2d and custom OpenGL drawing.Caryl
Ok thanks! I may go the cocos2d path instead thenFibrinolysin
Depending on the complexity of your project, Sparrow also has OpenGL drawing I believe, so that is an option as well. It's just not as robust as cocos2D.Auberta
A
23

Unfortunately, SKShapeNode is not that great for what you are trying to do. However, there is a way to optimize this, albeit with some caveats.

First one of the largest problems with the fps is that the draw count gets extremely high because each line segment you add is another draw. If you set showsDrawCount on your SKView instance, you will see what I mean.

In this answer Multiple skshapenode in one draw?, you can get more information about how you can use shouldRasterize property of a SKEffectNode to solve the problem if you are drawing something once. If you don't do this, you will have processor time spent on numerous draws each frame.

So you can see that the draws is the main issue with you not getting the performance you desire. However, you seem to want to be drawing consistently over time, so what I am going to suggest might be a viable solution for you.

The logic of the solution I am suggesting is as such :

1 - Create a SKSpriteNode that we can use as a canvas.

2 - Create one SKShapeNode that will be used to draw ONLY the current line segment.

3 - Make that SKShapeNode a child of the canvas.

4 - Draw a new line segment via SKShapeNode

5 - Use the SKView method `textureFromNode to save what has currently been drawn on the canvas.

6 - set the texture of the canvas to that texture.

Loop back to #4 and make a new path for your SKShapeNode for the next line segment.

Repeat as needed.

The result should be that your draw count will never be higher than 2 draws, which would solve the problem of a high draw count.

Basically, you are preserving what has previously been drawn in a texture, therefore only ever needing one SKShapeNode draw for the latest line segment and one draw for the SKTexture.

Again, I have not tried this process yet, and if there is any lag it would be in that textureFromNode call each frame. If anything would be your bottleneck, that would be it!

I might try this theory out some time today, as I need textureFromNode for another problem I am trying to solve, and so I'll definitely find out how fast/slow that method is! haha

UPDATE

This is not complete code, but is the important parts to achieve the desired drawing performance (60fps) :

The basic node elements are :

container -> SKNode that contains all elements that need to be cached

canvas -> SKSpriteNode that will display the cached version of drawn segments

pool of segments -> used to draw segments initially, and get reused as needed

First create a pool of SKShapeNodes :

pool = [[NSMutableArray alloc]init];

//populate the SKShapeNode pool
// the amount of segments in pool, dictates how many segments
// will be drawn before caching occurs.
for (int index = 0; index < 5; index++)
{
    SKShapeNode *segment = [[SKShapeNode alloc]init];
    segment.strokeColor = [SKColor whiteColor];
    segment.glowWidth = 1;
    [pool addObject:segment];
}

Next create method for getting a SKShapeNode from pool :

-(SKShapeNode *)getShapeNode
{
    if (pool.count == 0)
    {
        // if pool is empty, 
        // cache the current segment draws and return segments to pool
        [self cacheSegments];
    }

    SKShapeNode *segment = pool[0];
    [pool removeObjectAtIndex:0];

    return segment;
}

Next create a method for getting a segment from pool and drawing the line :

-(void)drawSegmentFromPoint:(CGPoint)fromPoint toPoint:(CGPoint)toPoint
{
    SKShapeNode *curSegment = [self getShapeNode];
    CGMutablePathRef path = CGPathCreateMutable();
    curSegment.lineWidth = 3;
    curSegment.strokeColor = [SKColor whiteColor];
    curSegment.glowWidth = 1;
    curSegment.name = @"segment";

    CGPathMoveToPoint(path, NULL, fromPoint.x, fromPoint.y);
    CGPathAddLineToPoint(path, NULL, toPoint.x, toPoint.y);
    curSegment.path = path;
    lastPoint = toPoint;
    [canvas addChild:curSegment];
}

Next is a method for creating a texture and returning existing segments to the pool :

-(void)cacheSegments
{
    SKTexture *cacheTexture =[ self.view textureFromNode:container];
    canvas.texture = cacheTexture;
    [canvas setSize:CGSizeMake(canvas.texture.size.width, canvas.texture.size.height)];
    canvas.anchorPoint = CGPointMake(0, 0);
    [canvas enumerateChildNodesWithName:@"segment" usingBlock:^(SKNode *node, BOOL *stop)
     {
         [node removeFromParent];
         [pool addObject:node];
     }];

}

Lastly the touch handlers :

-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    [self cacheSegments];
    for (UITouch *touch in touches)
    {
        CGPoint location = [touch locationInNode:self];
        lastPoint = location;
        [self drawSegmentFromPoint:lastPoint toPoint:location];
    }
}
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
    for (UITouch *touch in touches)
    {
        CGPoint location = [touch locationInNode:self];
        [self drawSegmentFromPoint:lastPoint toPoint:location];
    }
}

As I said, this is not all inclusive code, I assume you understand enough about the concept that you can implement into your application. These are just examples of my barebones implementation.

Auberta answered 3/7, 2014 at 15:22 Comment(12)
Thanks for the (very detailed) suggestion! I may try this, but was hoping to use something other than SKShapeNode because of the ugly "holes" in the curves it makes :)Fibrinolysin
Ok, I tried this method out and it's definitely better framerate overall, especially when you are done drawing. However, while you are drawing the textureFromNode call is indeed a bottleneck as suspected. So maybe creating a threshold by which x amount of segments get drawn before caching the image would improve that lag.Auberta
I might try adding code to update that every time the segments reach say 5 segments it caches via textureFromNode and kills existing segments. There might be a balance there. But in general, SpriteKit lacks a OpenGL solution for shape drawing, which sucks :/Auberta
Ok, great news, this is very viable now with me setting the amount of segments used before caching. 60fps now! However, your line quality is not solved by this, haha. Will update answer as this is very viable for most situations. I also pooled the SKShapeNodes to avoid the ugly memory leak from SKShapeNodes.Auberta
Answer is now updated with some example code for the pieces involved. I get 60fps with a pool of 5 segments, you could however increase/decrease that amount as desired.Auberta
Thanks for your time! I accepted your answer since you found a way of doing it with SKShapeNode and 60 FPS. Good job! :) I'm still leaning towards Cocos2d though, just because it seems I will get more freedom with it. Also I'll probably learn a bit more about the technical aspects of game development by using it.Fibrinolysin
Yep, cocos2d is currently the better choice if you need drawing performance and capabilities. However, I did check out this achtung game, and it would be easily doable in SpriteKit using this method. Very fun game.Auberta
@Auberta Hi! I'm begginer in spritekit and trying to adapt your code, but I have problem with container. We have line " SKTexture *cacheTexture =[ self.view textureFromNode:container];" But you haven't used container at all, so there isn't any texture?Tessy
@Auberta Also why we need that pool? Why I can't draw on canvas, before next drawing get texture form it, remove all elements, but texture and repeat that?Tessy
One reason is/was the memory leak in SKShapeNode. The other is that SKShapeNode performance is horrible when you have a large number of segments.Auberta
In regards to your other question, last part of my post says "As I said, this is not all inclusive code, I assume you understand enough about the concept that you can implement into your application."Auberta
@Auberta nice answer, it help me to discover many related things around SKShapeNode I had not paid much attention, thank you.Spile
R
2

To fix the "holes" in the curves just set the lineCap to a non-zero value:

curSegment.lineCap = 1;
Reify answered 7/12, 2015 at 4:48 Comment(0)
S
2

I've try to translate to Swift 2.2 the good answer proposed by prototypical:

A custom node:

class AOShapeNode: SKNode {
    var currentScene: SKScene!
    var canvas = SKShapeNode()
    var segment = SKShapeNode()
    var pool:[SKShapeNode]!
    var poolSize: Int = 50 // big number to improve performance
    var segmentLineWidth: CGFloat = 3

    init(currentScene scene:SKScene,nodeSize size: CGSize) {

        super.init()
        print("---");
        print("∙ \(self.dynamicType)")
        print("---")
        self.userInteractionEnabled = true
        self.currentScene = scene
        self.addChild(canvas)
        pool = [SKShapeNode]()
        for _ in 0..<poolSize
        {
            let segment = SKShapeNode()
            segment.strokeColor = UIColor.blackColor()
            segment.glowWidth = 1
            segment.lineCap = CGLineCap(rawValue: 1)!
            pool.append(segment)
        }
    }

    func getShapeNode() -> SKShapeNode {
        if(pool.count == 0)
        {
            self.cacheSegments()
        }
        let segment = pool.first
        pool.removeFirst()
        return segment!
    }

    func drawSegmentFromPoint(fromPoint:CGPoint, toPoint:CGPoint)->CGPoint {
        let curSegment = self.getShapeNode()
        let path = CGPathCreateMutable()
        curSegment.lineWidth = segmentLineWidth
        curSegment.strokeColor = SKColor.blackColor()
        curSegment.glowWidth = 1
        curSegment.lineCap = CGLineCap(rawValue: 1)!
        curSegment.name = "segment"
        CGPathMoveToPoint(path, nil, fromPoint.x, fromPoint.y)
        CGPathAddLineToPoint(path, nil, toPoint.x, toPoint.y)
        curSegment.path = path
        canvas.addChild(curSegment)
        return toPoint
    }

    func cacheSegments() {
        if let cacheTexture = self.currentScene.view?.textureFromNode(self) {
            let resizeAction = SKAction.setTexture(cacheTexture, resize: true)
            canvas.runAction(resizeAction)
        }
        canvas.enumerateChildNodesWithName("segment", usingBlock: {
            (node: SKNode!, stop: UnsafeMutablePointer <ObjCBool>) -> Void in
            self.pool.append(node as! SKShapeNode)
            node.removeFromParent()
        })

    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

}

The GameScene :

class GameScene: SKScene {
    var line : AOShapeNode!
    var lastPoint :CGPoint = CGPointZero

    override func didMoveToView(view: SKView) {
        /* Setup your scene here */
        line = AOShapeNode.init(currentScene: self, nodeSize: self.size)
        self.addChild(line)
    }

    override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) {
       /* Called when a touch begins */
        line.cacheSegments()

        for touch in touches {
            let location = touch.locationInNode(self)
            lastPoint = location
            lastPoint = line.drawSegmentFromPoint(lastPoint, toPoint: location)
        }
    }

    override func touchesMoved(touches: Set<UITouch>, withEvent event: UIEvent?) {
        /* Called when a touch moved */
        for touch in touches {
            let location = touch.locationInNode(self)
            lastPoint = line.drawSegmentFromPoint(lastPoint, toPoint: location)
        }
    }
}

This above it's all the code but if you want to try it, this is the link to the github repo.


Reading some articles around the possibilities to improve better SKShapeNode alternatives or strong re-factoring, I've found this project called SKUShapeNode, actually incorporated in SKUtilities 2 project, an idea to make a subclass of SKSpriteNode that renders using a CAShapeLayer (some documentation), there are some bugs and there is always to convertPoint from UIKit CALayer to actual Sprite Kit using node.

Spile answered 24/6, 2016 at 16:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.