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.
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
}
}
}
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 );
}
}