3
votes

I'm currently working on a SpriteKit project and need to create a comet with a fading tail that animates across the screen. I am having serious issues with SpriteKit in this regards.

Attempt 1. It:

  1. Draws a CGPath and creates an SKShapeNode from the path
  2. Creates a square SKShapeNode with gradient
  3. Creates an SKCropNode and assigns its maskNode as line, and adds square as a child
  4. Animates the square across the screen, while being clipped by the line/SKCropNode

    func makeCometInPosition(from: CGPoint, to: CGPoint, color: UIColor, timeInterval: NSTimeInterval) {
            ... (...s are (definitely) irrelevant lines of code)
            let path = CGPathCreateMutable()
            ...
            let line = SKShapeNode(path:path)
            line.lineWidth = 1.0
            line.glowWidth = 1.0
    
            var squareFrame = line.frame
            ...
            let square = SKShapeNode(rect: squareFrame)
            //Custom SKTexture Extension. I've tried adding a normal image and the leak happens either way. The extension is not the problem
            square.fillTexture = SKTexture(color1: UIColor.clearColor(), color2: color, from: from, to: to, frame: line.frame)
    
            square.fillColor = color
            square.strokeColor = UIColor.clearColor()
            square.zPosition = 1.0
    
            let maskNode = SKCropNode()
            maskNode.zPosition = 1.0
            maskNode.maskNode = line
            maskNode.addChild(square)
    
            //self is an SKScene, background is an SKSpriteNode
            self.background?.addChild(maskNode)
    
            let lineSequence = SKAction.sequence([SKAction.waitForDuration(timeInterval), SKAction.removeFromParent()])
    
            let squareSequence = SKAction.sequence([SKAction.waitForDuration(1), SKAction.moveBy(CoreGraphics.CGVectorMake(deltaX * 2, deltaY * 2), duration: timeInterval), SKAction.removeFromParent()])
    
            square.runAction(SKAction.repeatActionForever(squareSequence))
            maskNode.runAction(lineSequence)
            line.runAction(lineSequence)
        }
    

    This works, as shown below. enter image description here

The problem is that after 20-40 other nodes come on the screen, weird things happen. Some of the nodes on the screen disappear, some stay. Also, the fps and node count (toggled in the SKView and never changed)

self.showsFPS = true
self.showsNodeCount = true

disappear from the screen. This makes me assume it's a bug with SpriteKit. SKShapeNode has been known to cause issues.

Attempt 2. I tried changing square from an SKShapeNode to an SKSpriteNode (Adding and removing lines related to the two as necessary)

let tex = SKTexture(color1: UIColor.clearColor(), color2: color, from:    from, to: to, frame: line.frame)
let square = SKSpriteNode(texture: tex)

the rest of the code is basically identical. This produces a similar effect with no bugs performance/memory wise. However, something odd happens with SKCropNode and it looks like this enter image description here

It has no antialiasing, and the line is thicker. I have tried changing anti-aliasing, glow width, and line width. There is a minimum width that can not change for some reason, and setting the glow width larger does this enter image description here . According to other stackoverflow questions maskNodes are either 1 or 0 in alpha. This is confusing since the SKShapeNode can have different line/glow widths.

Attempt 3. After some research, I discovered I might be able to use the clipping effect and preserve line width/glow using an SKEffectNode instead of SKCropNode.

    //Not the exact code to what I tried, but very similar
    let maskNode = SKEffectNode()
    maskNode.filter = customLinearImageFilter
    maskNode.addChild(line)

This produced the (literally) exact same effect as attempt 1. It created the same lines and animation, but the same bugs with other nodes/fps/nodeCount occured. So it seems to be a bug with SKEffectNode, and not SKShapeNode.

I do not know how to bypass the bugs with attempt 1/3 or 2.

Does anybody know if there is something I am doing wrong, if there is a bypass around this, or a different solution altogether for my problem?

Edit: I considered emitters, but there could potentially be hundreds of comets/other nodes coming in within a few seconds and didn't think they would be feasible performance-wise. I have not used SpriteKit before this project so correct me if I am wrong.

1
I would use emitters to achieve that effectA Tyshka
Don't think they would work performance wise (See edit at bottom of post)Hayden Holligan
I agree if there are a lot of comets that emitters would not be a good choice.A Tyshka

1 Answers

4
votes

This looks like a problem for a custom shader attached to the comet path. If you are not familiar with OpenGL Shading Language (GLSL) in SpriteKit it lets you jump right into the GPU fragment shader specifically to control the drawing behavior of the nodes it is attached to via SKShader.

Conveniently the SKShapeNode has a strokeShader property for hooking up an SKShader to draw the path. When connected to this property the shader gets passed the length of the path and the point on the path currently being drawn in addition to the color value at that point.*

controlFadePath.fsh

void main() {

  //uniforms and varyings
  vec4  inColor = v_color_mix;
  float length = u_path_length;
  float distance = v_path_distance;
  float start = u_start;
  float end = u_end;

  float mult;

  mult = smoothstep(end,start,distance/length);
  if(distance/length > start) {discard;}

  gl_FragColor = vec4(inColor.r, inColor.g, inColor.b, inColor.a) * mult;
}

To control the fade along the path pass a start and end point into the custom shader using two SKUniform objects named u_start and u_end These get added to the custom shader during initialization of a custom SKShapeNode class CometPathShape and animated via a custom Action.

class CometPathShape:SKShapeNode

class CometPathShape:SKShapeNode {

  //custom shader for fading
  let pathShader:SKShader
  let fadeStartU = SKUniform(name: "u_start",float:0.0)
  let fadeEndU = SKUniform(name: "u_end",float: 0.0)
  let fadeAction:SKAction

  override init() {
    pathShader = SKShader(fileNamed: "controlFadePath.fsh")

    let fadeDuration:NSTimeInterval = 1.52
    fadeAction = SKAction.customActionWithDuration(fadeDuration, actionBlock:
      { (node:SKNode, time:CGFloat)->Void in
            let D = CGFloat(fadeDuration)
            let t = time/D
            var Ps:CGFloat = 0.0
            var Pe:CGFloat = 0.0

            Ps = 0.25 + (t*1.55)
            Pe = (t*1.5)-0.25

            let comet:CometPathShape = node as! CometPathShape
            comet.fadeRange(Ps,to: Pe) })

    super.init()

    path = makeComet...(...)   //custom method that creates path for comet shape 

    strokeShader = pathShader
    pathShader.addUniform(fadeStartU)
    pathShader.addUniform(fadeEndU)
    hidden = true
    //set up for path shape, eg. strokeColor, strokeWidth...
    ...
  }

  func fadeRange(from:CGFloat, to:CGFloat) {
    fadeStartU.floatValue = Float(from)
    fadeEndU.floatValue = Float(to)     
  }

  func launch() {
    hidden = false
    runAction(fadeAction, completion: { ()->Void in self.hidden = true;})
  }

...

The SKScene initializes the CometPathShape objects, caches and adds them to the scene. During update: the scene simply calls .launch() on the chosen CometPathShapes.

class GameScene:SKScene

...
  override func didMoveToView(view: SKView) {
    /* Setup your scene here */
    self.name = "theScene"
    ...

    //create a big bunch of paths with custom shaders
    print("making cache of path shape nodes")
    for i in 0...shapeCount {
      let shape = CometPathShape()
      let ext = String(i)
      shape.name = "comet_".stringByAppendingString(ext)
      comets.append(shape)
      shape.position.y = CGFloat(i * 3)
      print(shape.name)
      self.addChild(shape)
    }

  override func update(currentTime: CFTimeInterval) {
    //pull from cache and launch comets, skip busy ones
    for _ in 1...launchCount {
        let shape = self.comets[Int(arc4random_uniform(UInt32(shapeCount)))]
        if shape.hasActions() { continue }
        shape.launch()
    }
}

This cuts the number of SKNodes per comet from 3 to 1 simplifying your code and the runtime environment and it opens the door for much more complex effects via the shader. The only drawback I can see is having to learn some GLSL.**

*not always correctly in the device simulator. Simulator not passing distance and length values to custom shader.

**that and some idiosyncrasies in CGPath glsl behavior. Path construction is affecting the way the fade performs. Looks like v_path_distance is not blending smoothly across curve segments. Still, with care constructing the curve this should work.