Is it possible to animate a path similar to how StrokeEnd works in SpriteKit ?

I've been googling this for a while now and trying out various things from StackOverflow etc.


I want to draw paths, then animate a line that 'grows' over that path.

I'm coming to the conclusion it is NOT possible to animate a shape or bezierpath in an SKScene.


Using the CABasicAnimation stuff would work, but I can't figure out how to add layers or views...


Using SKActions isn't really what I want to do... i can only scale etc. Not really grow as i need.


It feels like there is a massive gap in what SpriteKit can do here. Has anyone found this? Anyone found a way around it ?


I'm really stuck here.. The whole concept of my game idea relys on this feature and I can't figure out a way to even start !!! 😟


appreciate any ideas or pointers.


thanks

Accepted Reply

Squillop, yes 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. Note that within the shader supplied below "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 you to control the color as you go, allowing the stroke to be a gradient color 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
        }      
    }
}

Replies

Squillop, yes 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. Note that within the shader supplied below "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 you to control the color as you go, allowing the stroke to be a gradient color 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
        }      
    }
}

This is awesome, many thanks!!

How exactly can I set the colour of the animation? I've studied the code and I can see how to set the overall colour in the shader, by manipulating the

gl_FragColor

value.

But I can't see how to change this on the fly from withing GameScene??

i figure something like .setValue on the gl_FragColor attibute? am i getting warm??

again, many thanks !

No problem. Here's an example that colors the stroke with a gradient:


Shader file "gradientStroke.fsh":


void main()
{

    if ( u_path_length == 0.0 ) {
  
        gl_FragColor = vec4( 1.0, 0.0, 1.0, 1.0 ); // error as u_path_length should never be zero, draw magenta
  
    } else if ( v_path_distance / u_path_length <= u_current_percentage ) { // draw color up to the current stroke percentage
        float c = v_path_distance / u_path_length; // c is a small number that becomes 1.0 toward the end of the stroke
        float v = 1.0 - c; // v is 1.0 at the start and approaches 0 toward the end of the stroke.
        // at any point in the stroke, v + c = 1.0

        vec4 s = u_color_start;
        vec4 e = u_color_end;
  
        gl_FragColor = vec4( clamp( s.r*v + e.r*c, 0.0, 1.0 ), // clamp between 0.0-1.0 just in case
                             clamp( s.g*v + e.g*c, 0.0, 1.0 ),
                             clamp( s.b*v + e.b*c, 0.0, 1.0 ),
                             clamp( s.a*v + e.a*c, 0.0, 1.0 ) );
    } else { // draw nothing!
        gl_FragColor = vec4( 0.0, 0.0, 0.0, 0.0 );
    }
}


Scene subclass:


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!

    // define the start and end colors here
    var startColorUniform = SKUniform( name: "u_color_start", vectorFloat4: vector_float4( [1.0, 1.0, 0.0, 1.0] ) )
    var endColorUniform = SKUniform( name: "u_color_end", vectorFloat4: vector_float4( [1.0, 0.0, 0.0, 1.0] ) )

    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 ) {
        // percentage is the only variable uniform since start and end color don't change
        strokeLengthUniform = SKUniform( name: "u_current_percentage", float: 0.0 )
    
        // pass all uniforms to the shader
        let uniforms: [SKUniform] = [strokeLengthUniform, startColorUniform, endColorUniform]
        strokeShader = shaderWithFilename( "gradientStroke", fileExtension: "fsh", uniforms: uniforms )
        strokeLengthFloat = 0.0
        let cameraNode = SKCameraNode()
        self.camera = cameraNode
        let path = CGMutablePath()
        path.addRoundedRect(in: CGRect( x: 0, y: 0, width: 200, height: 150 ), cornerWidth: 35, cornerHeight: 35, transform: CGAffineTransform.identity )
        let strokeWidth = 17.0 * strokeSizeFactor
    
        let shapeNode = SKShapeNode( path: path )
        shapeNode.lineWidth = strokeWidth
        shapeNode.lineCap = .round
        addChild( shapeNode )
        shapeNode.addChild( cameraNode )
        shapeNode.strokeShader = strokeShader
    
        let rect = shapeNode.calculateAccumulatedFrame()
        cameraNode.position = CGPoint( x: rect.size.width/2.0, y: rect.size.height/2.0 )
    
    }

    override func update(_ currentTime: TimeInterval) {
        strokeLengthFloat += 0.01
        if strokeLengthFloat > 1.0 {
            strokeLengthFloat = 0.0
        }
    
    }
}

thanks again Bob. This is good stuff ! I'm going to explore this now in detail.

Hi, sorry to bump this topic but I think I have run into the problem noted in the comment:

// prior to a fix in iOS 10.2, bug #27989113  "SKShader/SKShapeNode: u_path_length is not set unless shouldUseLocalStrokeBuffers() is true"


Things work well on a iOS 10.2 device, but not on a iOS 9.3.5 device. Indeed it seems like the problem is with u_path_length. I am only using a simple circle with the skshapenode - it seems like u_path_length is not correctly calculated by SpriteKit (or some other part) which makes u_path_length be 0 or just not set... it totally breaks iOS 9.3.5 device, screen goes black. I haven't figured out a workaround. Any info?

I am using a simple circle... if I just use lines, it seems to work.

Hey zappy, confirmed I'm seeing that too - the SKView is black using the Metal renderer in combination with SKShapeNode's strokeShader. Adding the PrefersOpenGL = YES (boolean Info.plist key) to request the OpenGL renderer fixes the black view but u_path_length is mistakenly zero in iOS 9 (tested 9.2 and 9.3.5). The extra points workaround did not seem to work (whether line, rect, or arc). I'll post back if I find a way to support iOS 9, otherwise you might need to deploy to iOS 10.

One technique that I've seen used successfully elsewhere is to use dashed lines where the 'draw' segment length is what's animated and covers the entire stroke. The 'don't draw' segment is set to be excessively long (i.e. at least as long as the overall stroke length itself.) Then by animating the length of the 'draw' segment between zero and whatever the stroke length is, it emulates 'drawing' the stroke as if it were a pen. Hope that makes sense.