Search code examples
scalajavafxbenchmarkingscalafx

ScalaFX canvas poor performance when drawing image with animation timer


I plan to make a rhythm game by using ScalaFX with canvas, When I try to run the code, I found that it consumes a lot of GPU, and sometimes the frame rate drop at 30 fps, even I only draw one image on the canvas without drawing any animated note, dancer, process gauge, etc.

canvas

performance

Below is my code

import scalafx.animation.AnimationTimer
import scalafx.application.JFXApp
import scalafx.scene.Scene
import scalafx.scene.canvas.{Canvas, GraphicsContext}
import scalafx.scene.image.Image
import scalafx.scene.layout.Pane
import scalafx.scene.paint.Color.Green

object MainApp extends JFXApp{
  var MainScene: Scene = new Scene {
    fill = Green
  }
  var MainStage: JFXApp.PrimaryStage = new JFXApp.PrimaryStage {
    scene = MainScene
    height = 720
    width = 1280
  }

  var gameCanvas:Canvas = new Canvas(){
    layoutY=0
    layoutX=0
    height=720
    width=1280
  }
  var gameImage:Image = new Image("notebar.png")

  var gc:GraphicsContext = gameCanvas.graphicsContext2D
  MainScene.root = new Pane(){
    children=List(gameCanvas)
  }

  var a:Long = 0
  val animateTimer = AnimationTimer(t => {
    val nt:Long = t/1000000
    val frameRate:Long = 1000/ (if((nt-a)==0) 1 else nt-a)

    //check the frame rate 
    println(frameRate)
    a = nt
    gc.clearRect(0,0,1280,720)
    gc.drawImage(gameImage,0,0,951,160)

  })

  animateTimer.start()
}

how can I improve the performance or is there any better ways to do the same thing without using canvas?


Solution

  • There are a few factors that are potentially slowing down the frame rate:

    • You are outputting the frame rate to the console every frame. This is a very slow operation, and also can be expected to slow down the frame rate. (This is probably the biggest performance hit.)
    • You are calculating the frame rate during frame rendering. Philosophically, a good example of Heisenberg's uncertainty principle, since by measuring the frame rate, you're interfering with it and slowing it down... ;-)
    • You are clearing the entire canvas each time you want to redraw your image, rather than just that part of it taken up the image. (This initially proved not to be a huge factor in my version of your code, but when I disabled JavaFX's speed limit—see update below—it turned out to make a big difference.)

    Regarding the frame rate, in the version below, I record the time (in nanoseconds) of the first frame, and count the number of frames drawn. When the application exits, it reports the average frame rate. This is a simpler calculation that doesn't interfere too much with the operations inside the animation handler, and is a good measure of overall performance. (There's going to be considerable variability in the timing of each frame, due to garbage collection, other processes, JIT compilation improvements, etc. We'll try to skip over all of that by looking at the average rate.)

    I also changed the code to clear only the region occupied by the image.

    I've also simplified your code a little, to make it slightly more conventional in its use of ScalaFX (using the stage member of the main class, for instance, as well as making more use of type inference):

    import scalafx.animation.AnimationTimer
    import scalafx.application.JFXApp
    import scalafx.scene.Scene
    import scalafx.scene.canvas.Canvas
    import scalafx.scene.image.Image
    import scalafx.scene.layout.Pane
    import scalafx.scene.paint.Color.Green
    
    object MainApp
    extends JFXApp {
    
      // Nanoseconds per second.
      val NanoPerSec = 1.0e9
    
      // Height & width of canvas. Change in a single place.
      val canvasHeight = 720
      val canvasWidth = 1280
    
      // Fixed canvas size.
      val gameCanvas = new Canvas(canvasWidth, canvasHeight)
    
      // The image.
      val gameImage = new Image("notebar.png")
      val gc = gameCanvas.graphicsContext2D
    
      stage = new JFXApp.PrimaryStage {
        height = canvasHeight
        width = canvasWidth
        scene = new Scene {
          fill = Green
          root = new Pane {
            children=List(gameCanvas)
          }
        }
      }
    
      // Class representing an initial frame time, last frame time and number of frames
      // drawn. The first frame is not counted.
      //
      // (Ideally, this would be declared in its own source file. I'm putting it here for
      // convenience.)
      final case class FrameRate(initTime: Long, lastTime: Long = 0L, frames: Long = 0L) {
        // Convert to time in seconds
        def totalTime: Double = if(frames == 0L) 1.0 else (lastTime - initTime) / NanoPerSec
    
        def mean: Double = frames / totalTime
        def update(time: Long): FrameRate = copy(lastTime = time, frames = frames + 1)
      }
    
      // Current frame rate.
      private var frames: Option[FrameRate] = None
    
      val animateTimer = AnimationTimer {t =>
    
        // Update frame rate.
        frames = Some(frames.fold(FrameRate(t))(_.update(t)))
    
        // Send information to console. Comment out to determine impact on frame rate.
        //println(s"Frame rate: ${frames.fold("Undefined")(_.mean.toString)}")
    
        // Clear region of canvas.
        //
        // First clears entire canvas, second only image. Comment one out.
        //gc.clearRect(0, 0, canvasWidth, canvasHeight)
        gc.clearRect(0, 0, gameImage.width.value, gameImage.height.value)
    
        // Redraw the image. This version doesn't need to know the size of the image.
        gc.drawImage(gameImage, 0, 0)
      }
    
      animateTimer.start()
    
      // When the application terminates, output the mean frame rate.
      override def stopApp(): Unit = {
        println(s"Mean frame rate: ${frames.fold("Undefined")(_.mean.toString)}")
      }
    }
    

    (BTW: avoid use of var statements in Scala whenever possible. Shared mutable state is unavoidable when using JavaFX/ScalaFX, but Propertys provide much better mechanisms for dealing with it. Try to get into the habit of using val element declarations unless they really, really do need to be vars. And if you do need to use vars, they should nearly always be declared private to prevent uncontrolled external access and modification.)

    Benchmarking Java programs is an art form, but clearly, the longer you run each version, the better the average frame rate is going to be. On my machine (with an image of my own) I achieved the following, rather unscientific, results after running the application for 5 minutes:

    • Clearing entire canvas and writing to console: 39.69 fps
    • Clearing entire canvas, no output to console: 59.85 fps
    • Clearing only image, no output to console: 59.86 fps

    Clearing just the image, rather than the whole canvas appears to have little effect, and surprised me a little. However, outputting to the console had a huge effect on the frame rate.

    Aside from using a canvas, another possibility is to simply position an image within a scene group, and then move it around by changing its co-ordinates. The code to do that is below (using properties to indirectly move the image):

    import scalafx.animation.AnimationTimer
    import scalafx.application.JFXApp
    import scalafx.beans.property.DoubleProperty
    import scalafx.scene.{Group, Scene}
    import scalafx.scene.image.ImageView
    import scalafx.scene.layout.Pane
    import scalafx.scene.paint.Color.Green
    import scalafx.scene.shape.Rectangle
    
    object MainApp
    extends JFXApp {
    
      // Height & width of app. Change in a single place.
      val canvasHeight = 720
      val canvasWidth = 1280
    
      // Nanoseconds per second.
      val NanoPerSec = 1.0e9
    
      // Center of the circle about which the image will move.
      val cX = 200.0
      val cY = 200.0
    
      // Radius about which we'll move the image.
      val radius = 100.0
    
      // Properties for positioning the image (might be initial jump).
      val imX = DoubleProperty(cX + radius)
      val imY = DoubleProperty(cY)
    
      // Image view. It's co-ordinates are bound to the above properties. As the properties
      // change, so does the image's position.
      val imageView = new ImageView("notebar.png") {
        x <== imX // Bind to property
        y <== imY // Bind to property
      }
    
      stage = new JFXApp.PrimaryStage {
        height = canvasHeight
        width = canvasWidth
        scene = new Scene {thisScene => // thisScene is a self reference
          fill = Green
          root = new Group {
            children=Seq(
              new Rectangle { // Background
                width <== thisScene.width // Bind to scene/stage width
                height <== thisScene.height // Bind to scene/stage height
                fill = Green
              },
              imageView
            )
          }
        }
      }
    
      // Class representing an initial frame time, last frame time and number of frames
      // drawn. The first frame is not counted.
      //
      // (Ideally, this would be declared in its own source file. I'm putting it here for
      // convenience.)
      final case class FrameRate(initTime: Long, lastTime: Long = 0L, frames: Long = 0L) {
        // Convert to time in seconds
        def totalTime: Double = if(frames == 0L) 1.0 else (lastTime - initTime) / NanoPerSec
    
        def mean: Double = frames / totalTime
        def update(time: Long) = copy(lastTime = time, frames = frames + 1)
      }
    
      // Current frame rate.
      var frames: Option[FrameRate] = None
    
      val animateTimer = AnimationTimer {t =>
    
        // Update frame rate.
        frames = Some(frames.fold(FrameRate(t))(_.update(t)))
    
        // Change the position of the image. We'll make the image move around a circle
        // clockwise, doing 1 revolution every 10 seconds. The center of the circle will be
        // (cX, cY). The angle is therefore the modulus of the time in seconds divided by 10
        // as a proportion of 2 pi radians.
        val angle = (frames.get.totalTime % 10.0) * 2.0 * Math.PI / 10.0
    
        // Update X and Y co-ordinates related to the center and angle.
        imX.value = cX + radius * Math.cos(angle)
        imY.value = cY + radius * Math.sin(angle)
      }
    
      animateTimer.start()
    
      // When the application terminates, output the mean frame rate.
      override def stopApp(): Unit = {
        println(s"Mean frame rate: ${frames.fold("Undefined")(_.mean.toString)}")
      }
    }
    

    This produces a mean frame rate for me, after 5 minutes of running, of 59.86 fps—almost exactly the same as using a canvas.

    In this example, the motion is a little jerky, which could well be caused by garbage collection cycles. Maybe try experimenting with different GC's?

    BTW, I move the image around in this version to force something to happen. If the properties don't change, then I suspected that the image would not be updated that frame. Indeed, if I just set the properties to the same value each time, the frame rate becomes: 62.05 fps.

    Using the canvas means that you have to determine what is drawn, and how to redraw it. But using the JavaFX scene graph (as in the last example) means that JavaFX takes care of figuring out whether the frame even needs to be redrawn. It doesn't make a big difference in this particular case, but it might speed things up if there are few content differences between successive frames. Something to bear in mind.

    Is that fast enough? BTW, there's a lot of overhead in relation to content in this particular example. I wouldn't be at all surprised if adding other elements to the animation had very little impact upon the frame rate. It would probably be best to try it and see. Over to you...

    (For another possibility regarding animation, refer to the ColorfulCircles demo that comes with the ScalaFX sources.)

    UPDATE: I mentioned this in a comment, but it's perhaps worth highlighting in the main answer: JavaFX has a default speed limit of 60 fps, which also impacts the benchmarking above—and which also explains why the CPU and GPU are not better utilized.

    To enable your application to run at the highest possible frame rate (possibly not a great idea if you're looking to maximize battery charge on a laptop, or to improve overall application performance), enable the following property when running your application:

    -Djavafx.animation.fullspeed=true

    Note that this property is undocumented and unsupported, meaning that it may go away in a future version of JavaFX.

    I re-ran the benchmarks with this property set, and observed these results:

    Using a canvas:

    • Clearing entire canvas and writing to console: 64.72 fps
    • Clearing entire canvas, no output to console: 144.74 fps
    • Clearing only image, no output to console: 159.48 fps

    Animating a scene graph:

    • No output to console: 217.68 fps

    These results change my original conclusions significantly:

    • Rendering images—and even animating them—using the scene graph is far more efficient (36% better in terms of frame rate) than the best result obtained when drawing the image on the canvas. This is not unexpected, given that the scene graph is optimized to improve performance.
    • If using the canvas, clearing only the region occupied by the image has a roughly 10% (for this example) better frame rate than clearing the entire canvas.

    Refer to this answer for further details about the javafx.animation.fullspeed property.