6

I'm attempting to animate the drawing of a stroke on a path using SpriteKit. I have implemented a working solution using SKActions and a separate implementation using CABasicAnimations. The SKAction solution is not very elegant; it creates and strokes a new path on each iteration of an SKAction.repeatAction(action:count:) call with each new path slightly more complete than the previous.

func start() {
    var i:Double = 1.0
    let spinAction:SKAction = SKAction.repeatAction(SKAction.sequence([
        SKAction.runBlock({
            self.drawCirclePercent(i * 0.01)

            if (++i > 100.0) {
                i = 1.0
            }
        }),
        SKAction.waitForDuration(0.01)
    ]), count: 100)
    runAction(spinAction)
}

func drawCirclePercent(percent:Double) {
    UIGraphicsBeginImageContext(self.size)

    let ctx:CGContextRef = UIGraphicsGetCurrentContext()
    CGContextSaveGState(ctx)
    CGContextSetLineWidth(ctx, lineWidth)
    CGContextSetRGBStrokeColor(ctx, 1.0, 1.0, 1.0, 1.0)
    CGContextAddArc(ctx, CGFloat(self.size.width/2.0), CGFloat(self.size.height/2.0), radius/2.0, CGFloat(M_PI * 1.5), CGFloat(M_PI * (1.5 + 2.0 * percent)), 0)
    CGContextStrokePath(ctx)

    let textureImage:UIImage = UIGraphicsGetImageFromCurrentImageContext()
    texture = SKTexture(image: textureImage)

    UIGraphicsEndImageContext()
}

While the above code works, it certainly is not pretty or efficient, and is likely not how SKActions were intended to be used. The CABasicAnimation solution is much more elegant and much more efficient.

let path:CGMutablePathRef = CGPathCreateMutable()
CGPathAddArc(path, nil, 0, 0, 40.0, CGFloat(M_PI_2 * 3.0), CGFloat(M_PI_2 * 7.0), false)

let pathLayer:CAShapeLayer = CAShapeLayer()
pathLayer.frame = CGRectMake(100, 100, 80.0, 80.0)
pathLayer.path = path
pathLayer.strokeColor = SKColor.whiteColor().CGColor
pathLayer.fillColor = nil
pathLayer.lineWidth = 3.0
self.view.layer.addSublayer(pathLayer)

let pathAnimation:CABasicAnimation = CABasicAnimation(keyPath: "strokeEnd")
pathAnimation.duration = 2.0
pathAnimation.fromValue = 0.0
pathAnimation.toValue = 1.0
pathLayer.addAnimation(pathAnimation, forKey: "strokeEndAnimation")

My issue is that I would really prefer to have all the code contained within a subclass of SKSpriteNode (so much so that if the above two solutions are the only options I have I would go with the first). Is there any way in which I can improve my SKAction implementation to closer resemble the CoreAnimation implementation, without the need to include CoreAnimation? Essentially, I'm wondering if SpriteKit has functionality that I'm unaware of which could be used to improve the first implementation.

Shruti Thombre
  • 989
  • 4
  • 11
  • 27
Scott Mielcarski
  • 760
  • 6
  • 17
  • I'll look into this further when not posting from my phone, but here's a hint to get started with: if you create a custom [`SKShader`](https://developer.apple.com/library/prerelease/ios/documentation/SpriteKit/Reference/SKShader_Ref/index.html) for drawing a shape node, one of the variables you can access in your shader GLSL code is arc length along the path. – rickster Aug 09 '14 at 06:11
  • @rickster Ah, interesting. I'm not very familiar with GLSL, but I will do some reading. – Scott Mielcarski Aug 09 '14 at 06:44
  • Did anyone ever end up writing the custom SKShader for drawing an arc? – Ben Morrow Sep 16 '16 at 01:37
  • @BenMorrow I dug into it, but ended up using the method posted in my original question. I think the SKShader option would be far superior though. Sorry I can't be of more help. – Scott Mielcarski Sep 16 '16 at 03:40
  • @ScottMielcarski Thanks, I'll use something similar – Ben Morrow Sep 16 '16 at 19:45
  • @BenMorrow No problem, if you come up with a better solution post it as an answer. I'm sure other people have experienced similar problems. – Scott Mielcarski Sep 17 '16 at 17:57

2 Answers2

9

you can animate SKShapeNode's path by supplying a custom strokeShader that outputs based on a few of SKShader's properties, v_path_distance and u_path_length. This hinted at by rickster above, full code to do so follows. Within the shader, u_current_percentage is added by us and refers to the current point within the path we want stroked up to. By that, the scene determines the pace of the animated stroking. Also note that strokeShader being a fragment shader, outputs an RGB at every step, it allows color control along the path, making a gradient color possible for example.

The shader is added as a file to the Xcode project "animateStroke.fsh":

    void main()  
{  
    if ( u_path_length == 0.0 ) {  
        gl_FragColor = vec4( 0.0, 0.0, 1.0, 1.0 );   
    } else if ( v_path_distance / u_path_length <= u_current_percentage ) {  
        gl_FragColor = vec4( 1.0, 0.0, 0.0, 1.0 );   
    } else {  
        gl_FragColor = vec4( 0.0, 0.0, 0.0, 0.0 );   
    }  
}  

And the sample SKScene subclass using it:

    import SpriteKit  
    import GameplayKit  
    func shaderWithFilename( _ filename: String?, fileExtension: String?, uniforms: [SKUniform] ) -> SKShader {  
        let path = Bundle.main.path( forResource: filename, ofType: fileExtension )  
        let source = try! NSString( contentsOfFile: path!, encoding: String.Encoding.utf8.rawValue )  
        let shader = SKShader( source: source as String, uniforms: uniforms )  
        return shader  
}  
class GameScene: SKScene {  
    let strokeSizeFactor = CGFloat( 2.0 )  
    var strokeShader: SKShader!  
    var strokeLengthUniform: SKUniform!  
    var _strokeLengthFloat: Float = 0.0  
    var strokeLengthKey: String!  
    var strokeLengthFloat: Float {  
        get {  
            return _strokeLengthFloat  
        }  
        set( newStrokeLengthFloat ) {  
            _strokeLengthFloat = newStrokeLengthFloat  
            strokeLengthUniform.floatValue = newStrokeLengthFloat  
        }  
    }  

    override func didMove(to view: SKView) {  
        strokeLengthKey = "u_current_percentage"  
        strokeLengthUniform = SKUniform( name: strokeLengthKey, float: 0.0 )  
        let uniforms: [SKUniform] = [strokeLengthUniform]  
        strokeShader = shaderWithFilename( "animateStroke", fileExtension: "fsh", uniforms: uniforms )  
        strokeLengthFloat = 0.0  
        let cameraNode = SKCameraNode()  
        self.camera = cameraNode  
        let strokeHeight = CGFloat( 200 ) * strokeSizeFactor  
        let path1 = CGMutablePath()  
        path1.move( to: CGPoint.zero )  
        path1.addLine( to: CGPoint( x: 0, y: strokeHeight ) )  

        // prior to a fix in iOS 10.2, bug #27989113  "SKShader/SKShapeNode: u_path_length is not set unless shouldUseLocalStrokeBuffers() is true"  
        // add a few points to work around this bug in iOS 10-10.1 ->  
        // for i in 0...15 {  
        //    path1.addLine( to: CGPoint( x: 0, y: strokeHeight + CGFloat( 0.001 ) * CGFloat( i ) ) )  
        // }  

        path1.closeSubpath()  
        let strokeWidth = 17.0 * strokeSizeFactor  
        let path2 = CGMutablePath()  
        path2.move( to: CGPoint.zero )  
        path2.addLine( to: CGPoint( x: 0, y: strokeHeight ) )  
        path2.closeSubpath()  
        let backgroundShapeNode = SKShapeNode( path: path2 )  
        backgroundShapeNode.lineWidth = strokeWidth  
        backgroundShapeNode.zPosition = 5.0  
        backgroundShapeNode.lineCap = .round  
        backgroundShapeNode.strokeColor = SKColor.darkGray  
        addChild( backgroundShapeNode )  

        let shapeNode = SKShapeNode( path: path1 )  
        shapeNode.lineWidth = strokeWidth  
        shapeNode.lineCap = .round  
        backgroundShapeNode.addChild( shapeNode )  
        shapeNode.addChild( cameraNode )  
        shapeNode.strokeShader = strokeShader  
        backgroundShapeNode.calculateAccumulatedFrame()  

        cameraNode.position = CGPoint( x: backgroundShapeNode.frame.size.width/2.0, y: backgroundShapeNode.frame.size.height/2.0 )              
    }  

    override func update(_ currentTime: TimeInterval) {        
        // the increment chosen determines how fast the path is stroked. Note this maps to "u_current_percentage" within animateStroke.fsh  
        strokeLengthFloat += 0.01  
        if strokeLengthFloat > 1.0 {  
            strokeLengthFloat = 0.0  
        }        
    }  
}  
Bobjt
  • 4,040
  • 1
  • 29
  • 29
  • Great stuff. Do you have any insight on how to make color shift "across" the line itself. For now I'd like to have the alpha of the outskirts of the line fade out from 1.0 to 0.0. I.e. this is not about v_path_distance, but should refer to some shader variable (if any such exists, I'm not sure) "horizontally" across the line. A SKShapeNode with the default shader does this with the glowWidth property. I'm aiming to do something similar. Eventually it would be nice to similarly have gradients across the line too. – Jonny Jan 20 '17 at 03:42
  • 1
    I think I found out how to make use of the glowWidth values. I added my updated shader in another answer. – Jonny Jan 20 '17 at 03:57
  • Concerning the comment about bug 27989113: It seems like this does not work with iOS 9.3.5 (or any iOS 9 maybe, and maybe before iOS 10.2, I don't have such devices to test atm). I assume there is a problem with u_path_length... For me I am using a circle (`[path1 addArcWithCenter:CGPointZero radius:RADIUS startAngle:M_PI_2 * 5 endAngle:M_PI_2 * 1 clockwise:NO];`) and this messes up the whole spritekit view - it goes all black. I've yet to figure out how to fix this. – Jonny Jan 23 '17 at 03:10
  • Can you explain the shader a little bit? You can't set glowWidth or color with this. – twodayslate Mar 03 '17 at 18:09
  • Great solution! Any idea how to animate the custom strokeShader using SKAction? – Bogy Nov 16 '17 at 09:07
  • Yes, in fact you can use SKAction.customAction passing in a block that SpriteKit will call-back over the specified duration. SK passes `elapsedTime` into the block, so you can use that to drive your progress instead of the update callback, as in the answer above. – Bobjt Nov 16 '17 at 21:40
  • It is possible to move a SKSpriteNode over that path. Actually I tried something like , let moveObject = SKAction.move(to: movePoint, duration: 2.0) player.run(SKAction.sequence([ moveObject ])) But the object reaches its position earlier than the target line. Any help will be appreciated. – Madhavan Nov 20 '18 at 13:46
  • I have implemented this way with help of your code.. but exact output nt coming.. https://stackoverflow.com/questions/53405562/drawing-line-using-vertex-shader-fragment-shader-in-spritkit-in-swift . can you check it here .. – PRADIP KUMAR Nov 21 '18 at 06:08
4

Based on Bobjt's answer, my shader which makes use of the glowWidth of SKShapeNode... So when I use this I can use shapeNode.glowWidth = 5.0; etc!

void main()
{
    vec4 def = SKDefaultShading();

    if ( u_path_length == 0.0 ) {
        gl_FragColor = vec4( 0xff / 255.0f, 0 / 255.0f, 0 / 255.0f, 1.0f );
    } else if ( v_path_distance / u_path_length <= u_current_percentage ) {
        gl_FragColor = vec4(
                            //0x1e / 255.0f, 0xb8 / 255.0f, 0xca / 255.0f, <-- original boring color :-P
                            0x1e / 255.0f * def.z, 0xb8 / 255.0f * def.z, 0xca / 255.0f * def.z, def.z
                            );
    } else {
        gl_FragColor = vec4( 0.0f, 0.0f, 0.0f, 0.0f );
    }
}
Community
  • 1
  • 1
Jonny
  • 15,955
  • 18
  • 111
  • 232