Search code examples
sprite-kit

Animating dashes sequentially over a spline path in SpriteKit: How can I fix the gaps that appear on the straight sections?


I've created a SpriteNode subclass which is instantiated in the scene as a spline, and then I call the function .animateDottedPath().
I expect the dashes to animate over 5 seconds along the original path.

The animation almost works, however in the screenshot, there are these gaps missing when I animate using the copy of the original path.

I've looked at the debug output, and the path components returned make me suspicious. My guess is that something with the path copied using fromSplinePath.copy(dashingWithPhase: 100.0, lengths: [10]) doesn't translate well when I call self.dottedPathCopy.addPath(self.dottedPathComponents[self.dashIndex]) inside the _animatePath action.

Apologies for messy code, no need to suggest rewrites, but I would greatly appreciate any answer to offer some insight as to why there are these large gaps in the mutable path.

enter image description here

import SpriteKit

/// First, initialize a spline with node = SKShapeNode(splinePoints: &points, count: points.count)
// then create a dashed path for animation with DashedSplinePath(fromSplinePath: node.path!)
// the animation uses dashed paths from a copy of the original spline and adds them sequentially
class DashedSplinePath: SKShapeNode {
    var dottedPathCopy: CGMutablePath!
    var dottedPathComponents: [CGPath] = []
    
    var drawDuration: TimeInterval = 5
    var dashIndex = 0
    
    private var _animatePath: SKAction {
        return SKAction.customAction(withDuration: drawDuration, actionBlock: { (node, timeEl) in
            
            let currentPathComponentIndex = Int( Float(self.dottedPathComponents.count) * Float(timeEl / self.drawDuration) - 1 )
            if(self.dashIndex < currentPathComponentIndex) {
                
                self.dottedPathCopy.addPath(self.dottedPathComponents[self.dashIndex])
                print(self.dottedPathComponents[self.dashIndex])
                self.path = self.dottedPathCopy
                self.dashIndex += 1
                print(self.dashIndex)
            }
        })
    }
    
    func animateDottedPath() {
        self.dashIndex = 0
        self.dottedPathComponents = self.path!.componentsSeparated()
        
        self.dottedPathCopy = dottedPathComponents.first!.mutableCopy()
        self.alpha = 1.0
        self.zPosition = 0.0
        
        self.run(_animatePath)
    }
    
    
    init(fromSplinePath: CGPath) {
        super.init()
        self.path = fromSplinePath.copy(dashingWithPhase: 100.0, lengths: [10])
        
        self.zPosition = -1
        self.glowWidth = 0
        self.strokeColor = NSColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0)
        self.lineWidth = 10
        self.alpha = 1.0
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Here's an existing stack overflow article I used to understand how the dashed/dotted path works from the original spline: Drawing dashed line in Sprite Kit using SKShapeNode


Solution

  • you can achieve this effect using a shader. in fact SKShapeNode path lengths are easily animatable due to the v_path_distance and u_path_length shader inputs documented here.

    enter image description here

    class GameScene: SKScene {
        var dottedLine:SKShapeNode?
        
        override func didMove(to view: SKView) {
            
            //a SKShapeNode containing a bezier path
            let startPoint = CGPoint(x: -200, y: 0)
            let control = CGPoint(x: 0, y: 300)
            let endPoint = CGPoint(x: 200, y: 150)
            
    //multiplatform beziers ftw
    #if os(macOS)
            let bezierPath = NSBezierPath()
            bezierPath.move(to: startPoint)
            bezierPath.curve(to: endPoint, controlPoint: control)
    #elseif os(iOS)
            let bezierPath = UIBezierPath()
            bezierPath.move(to: startPoint)
            bezierPath.addQuadCurve(to: endPoint, controlPoint: control)
    #endif
            
            let dottedPath = bezierPath.cgPath.copy(dashingWithPhase: 1, lengths: [10])
            dottedLine = SKShapeNode(path: dottedPath)
            dottedLine?.lineWidth = 5
            dottedLine?.strokeColor = .white
            self.addChild(dottedLine ?? SKNode())
    
            //shader code
            let shader_lerp_path_distance:SKShader = SKShader(source: """
    //v_path_distance and u_path_length defined at
    //https://developer.apple.com/documentation/spritekit/creating-a-custom-fragment-shader
    void main(){
        //draw based on an animated value (u_lerp) in range 0-1
        if (v_path_distance < (u_path_length * u_lerp)) {
            gl_FragColor = texture2D(u_texture, v_tex_coord); //sample texture and draw fragment 
    
        } else {
            gl_FragColor = 0; //else don't draw
        }
    }
    """)
            
            //set up shader uniform
            let u_lerp = SKUniform(name: "u_lerp", float:0)
            shader_lerp_path_distance.uniforms = [ u_lerp ]
            dottedLine?.strokeShader = shader_lerp_path_distance
            
            //animate a value from 0-1 and update the shader uniform
            let DURATION = 3.0
            func lerp(a:CGFloat, b:CGFloat, fraction:CGFloat) -> CGFloat {
                return (b-a) * fraction + a
            }
            let animation = SKAction.customAction(withDuration: DURATION) { (node : SKNode!, elapsedTime : CGFloat) -> Void in
                let fraction = CGFloat(elapsedTime / CGFloat(DURATION))
                let i = lerp(a:0, b:1, fraction:fraction)
                u_lerp.floatValue = Float(i)
            }
            dottedLine?.run(animation)
        }
    }