user10309700
user10309700

Reputation:

Swift, SpriteKit: Low FPS with a huge for-loop in update method

Is it normal to have very low FPS (~7fps to ~10fps) with Sprite Kit using the code below?

Use case:

I'm drawing just lines from bottom to top (1024 * 64 lines). I have some delta value that determines the positions of a single line for every frame. These lines represent my CGPath, which is assigned to the SKShapeNode every frame. Nothing else. I'm wondering about the performance of SpriteKit (or maybe of Swift).

Do you have any suggestions to improve the performance?

Screen:

screenshot

Code:

import UIKit
import SpriteKit

class SKViewController: UIViewController {
    
    @IBOutlet weak var skView: SKView!
    
    var scene: SKScene!
    var lines: SKShapeNode!
    
    let N: Int = 1024 * 64
    var delta: Int = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        scene = SKScene(size: skView.bounds.size)
        scene.delegate = self
        
        skView.showsFPS = true
        skView.showsDrawCount = true
        skView.presentScene(scene)
        
        lines = SKShapeNode()
        lines.lineWidth = 1
        lines.strokeColor = .white
        
        scene.addChild(lines)
    }
}

extension SKViewController: SKSceneDelegate {
    func update(_ currentTime: TimeInterval, for scene: SKScene) {
        let w: CGFloat = scene.size.width
        let offset: CGFloat = w / CGFloat(N)
        
        let path = UIBezierPath()
        
        for i in 0 ..< N { // N -> 1024 * 64 -> 65536
            let x1: CGFloat = CGFloat(i) * offset
            let x2: CGFloat = x1
            let y1: CGFloat = 0
            let y2: CGFloat = CGFloat(delta)

            path.move(to: CGPoint(x: x1, y: y1))
            path.addLine(to: CGPoint(x: x2, y: y2))
        }
        
        lines.path = path.cgPath
        
        // Updating delta to simulate the changes
        //
        if delta > 100 {
            delta = 0
        }
        delta += 1
    }
}

Thanks and Best regards, Aiba ^_^

Upvotes: 2

Views: 634

Answers (2)

pua666
pua666

Reputation: 336

Check the number of subdivisions

No iOS device has a screen width over 3000 pixels or 1500 points (retina screens have logical points and physical pixels where a point is equivalent to 2 or 3 pixels depending on the scale factor; iOS works with points, but you have to also remember pixels), and the ones that even come close are those with the biggest screens (iPad Pro 12.9 and iPhone Pro Max) in landscape mode.

A typical device in portrait orientation will be less than 500 points and 1500 pixels wide.

You are dividing this width into 65536 parts, and will end up with pixel (not even point) coordinates like 0.00, 0.05, 0.10, 0.15, ..., 0.85, which will actually refer to the same pixel twenty times (my result, rounded up, in an iPhone simulator).

Your code draws twenty to sixty lines in the exact same physical position, on top of each other! Why do that? If you set N to w and use 1.0 for offset, you'll have the same visible result at 60 FPS.

Reconsider the approach

The implementation will still have some drawbacks, though, even if you greatly reduce the amount of work to be done per frame. It's not recommended to advance animation frames in update(_:) since you get no guarantees on the FPS, and you usually want your animation to follow a set schedule, i.e. complete in 1 second rather than 60 frames. (Should the FPS drop to, say, 10, a 60-frame animation would complete in 6 seconds, whereas a 1-second animation would still finish in 1 second, but at a lower frame rate, i.e. skipping frames.)

Visibly, what your animation does is draw a rectangle on the screen whose width fills the screen, and whose height increases from 0 to 100 points. I'd say, a more "standard" way of achieving this would be something like this:

let sprite = SKSpriteNode(color: .white, size: CGSize(width: scene.size.width, height: 100.0))
sprite.yScale = 0.0
scene.addChild(sprite)
sprite.run(SKAction.repeatForever(SKAction.sequence([
    SKAction.scaleY(to: 1.0, duration: 2),
    SKAction.scaleY(to: 0.0, duration: 0.0)
])))

Note that I used SKSpriteNode because SKShapeNode is said to suffer from bugs and performance issues, although people have reported some improvements in the past few years.

But if you do insist on redrawing the entire texture of your sprite every frame due to some specific need, that may indeed be something for custom shaders… But those require learning a whole new approach, not to mention a new programming language.

Your shader would be executed on the GPU for each pixel. I repeat: the code would be executed for each single pixel – a radical departure from the world of SpriteKit.

The shader would access a bunch of values to work with, such a normalized set of coordinates (between (0.0,0.0) and (1.0,1.0) in a variable called v_tex_coord) and a system time (seconds elapsed since the shader has been running) in u_time, and it would need to determine what color value the pixel in question would need to be – and set it by storing the value in the variable gl_FragColor.

It could be something like this:

void main() {
       
// default to a black color, or a three-dimensional vector v3(0.0, 0.0, 0.0):
    vec3 color = vec3(0.0); 

// take the fraction part of the time in seconds;
// this value will go from 0.0 to 0.9999… every second, then drop back to 0.0.
// use this to determine the relative height of the area we want to paint white:
    float height = fract(u_time); 

// check if the current pixel is below the height of the white area:
    if (v_tex_coord.y < height) { 

// if so, set it to white (a three-dimensional vector v3(1.0, 1.0, 1.0)):
        color = vec3(1.0); 
    }
 
    gl_FragColor = vec4(color,1.0); // the fourth dimension is the alpha
}

Put this in a file called shader.fsh, create a full-screen sprite mySprite, and assign the shader to it:

mySprite.shader = SKShader.init(fileNamed: "shader.fsh")

Once you display the sprite, its shader will take care of all of the rendering. Note, however, that your sprite will lose some SpriteKit functionalities as a result.

Upvotes: 1

0-1
0-1

Reputation: 773

CPU

65536 is a rather large number. Telling the CPU to comprehend this many loops will always result in slowness. For example, even if I make a test Command Line project that only measures the time it takes to run an empty loop:

while true {
    let date = Date().timeIntervalSince1970


    for _ in 1...65536 {}

    let date2 = Date().timeIntervalSince1970
    print(1 / (date2 - date))
}

It will result in ~17 fps. I haven't even applied the CGPath, and it's already appreciably slow.


Dispatch Queue

If you want to keep your game at 60fps, but your rendering of specifically your CGPath may be still slow, you can use a DispatchQueue.

var rendering: Bool = false // remember to make this one an instance value

while true {
    let date = Date().timeIntervalSince1970

    if !rendering {
        rendering = true
        let foo = DispatchQueue(label: "Run The Loop")
        foo.async {
            for _ in 1...65536 {}
            let date2 = Date().timeIntervalSince1970
            print("Render", 1 / (date2 - date))
        }
        rendering = false
    }
}

This retains a natural 60fps experience, and you can update other objects, however, the rendering of your SKShapeNode object is still quite slow.


GPU

If you'd like to speed up the rendering, I would recommend looking into running it on the GPU instead of the CPU. The GPU (Graphics Processing Unit) is much better fitted for this, and can handle huge loops without disturbing gameplay. This may require you to program it as an SKShader, in which there are tutorials for.

Upvotes: 2

Related Questions