ScalaFX canvas 使用动画计时器绘制图像时性能不佳

ScalaFX canvas poor performance when drawing image with animation timer

我打算使用 ScalaFX 和 canvas 制作节奏游戏, 当我尝试 运行 代码时,我发现它消耗了大量的 GPU,有时帧速率下降到 30 fps,即使我只在 canvas 上绘制一张图像而没有绘制任何动画笔记, dancer, process gauge等

下面是我的代码

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()
}

我怎样才能提高性能,或者有没有更好的方法在不使用 canvas 的情况下做同样的事情?

有几个因素可能会降低帧速率:

  • 您每帧都将帧速率输出到控制台。这是一个非常慢的操作,也可能会降低帧速率。 (这可能是最大的性能损失。)
  • 您正在计算帧渲染期间的帧率。从哲学上讲,这是 海森堡不确定性原理 的一个很好的例子,因为通过测量帧速率,您会干扰它并减慢它...;-)
  • 每次要重绘图像时,您都会清除整个 canvas,而不是只清除占据图像的那一部分。 (这最初证明在我的代码版本中并不是一个重要因素,但是当我禁用 JavaFXspeed limit——请参阅下面的更新——结果有很大的不同。)

关于帧率,在下面的版本中,我记录了第一帧的时间(以纳秒为单位),并统计绘制的帧数。当应用程序退出时,它会报告平均帧速率。这是一个更简单的计算,不会过多干扰动画处理程序内部的操作,并且是衡量整体性能的一个很好的指标。 (由于垃圾收集、其他进程、JIT 编译改进等原因,每个帧的时间会有相当大的变化。我们将通过查看平均速率来尝试跳过所有这些。)

我还更改了代码以仅清除图像占用的区域。

我还稍微简化了您的代码,使其在 ScalaFX 的使用中更加传统(使用 stage 主 class,例如,以及更多地使用类型推断):

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)}")
  }
}

(顺便说一句:尽可能避免在 Scala 中使用 var 语句。使用 共享可变状态 是不可避免的 JavaFX/ScalaFX,但是Propertys提供了更好的处理机制。试着养成习惯使用 val 元素声明,除非它们真的非常需要 vars。如果你确实需要使用 vars,它们应该几乎总是被声明为 private以防止不受控制的外部访问和修改。)

基准测试 Java 程序是一种艺术形式,但很明显,运行 每个版本的时间越长,平均帧速率就越好是。在我的机器上(使用我自己的图像)在 运行 运行应用程序 5 分钟后,我获得了以下相当不科学的结果:

  • 清除整个 canvas 并写入控制台:39.69 fps
  • 正在清除整个 canvas,控制台无输出:59.85 fps
  • 仅清除图像,没有输出到控制台:59.86 fps

只清除图像,而不是整个canvas似乎效果不大,让我有点吃惊。但是,输出到控制台对帧率有很大影响。

除了使用 canvas 之外,另一种可能性是简单地将图像定位在场景组中,然后通过更改其坐标来移动它。执行此操作的代码如下(使用属性间接移动图像):

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)}")
  }
}

在 运行ning 5 分钟后,这为我产生了 59.86 fps 的平均帧速率——几乎与使用 canvas.

完全相同

在这个例子中,运动有点不稳定,这很可能是由垃圾收集 周期引起的。也许尝试使用不同的 GC

顺便说一句,我在这个版本中四处移动图像以强制发生某些事情。如果属性没有改变,那么我怀疑图像不会在那一帧更新。事实上,如果我每次都将属性设置为相同的值,则帧速率变为:62.05 fps。

使用canvas意味着你必须确定绘制的是什么,以及如何重绘它。但是使用 JavaFX 场景图(如上例所示)意味着 JavaFX 负责计算是否需要重新绘制框架。在这种特殊情况下,它并没有太大的区别,但如果连续帧之间的内容差异很小,它可能会加快速度。要记住的事情。

够快吗?顺便说一句,在这个特定示例中,有很多与内容相关的开销。如果向动画中添加其他元素对帧速率的影响很小,我一点也不会感到惊讶。最好尝试一下看看。交给你...

(有关动画的另一种可能性,请参阅 ColorfulCirclesScalaFX 源一起提供的演示。)

更新:我在评论中提到了这一点,但也许值得在主要答案中强调:JavaFX默认 速度限制 为 60 fps,这也会影响上述基准测试——这也解释了为什么 CPU 和 GPU 没有得到更好的利用。

要使您的应用程序以尽可能高的帧速率 运行(如果您希望最大限度地提高笔记本电脑的电池电量或提高应用程序的整体性能,这可能不是一个好主意),请启用在 运行 申请您的申请时关注 属性:

-Djavafx.animation.fullspeed=true

请注意,此 属性 未记录且不受支持,这意味着它可能会在 JavaFX.[=23= 的未来版本中消失]

我用这个 属性 集重新运行 基准测试,并观察到这些结果:

使用 canvas:

  • 正在清除整个 canvas 并写入控制台:64.72 fps
  • 正在清除整个 canvas,控制台无输出:144.74 fps
  • 仅清除图像,没有输出到控制台:159.48 fps

动画场景图:

  • 控制台无输出:217.68 fps

这些结果显着改变了我原来的结论:

  • 使用场景图渲染图像——甚至动画它们——比在 canvas 上绘制图像时获得的最佳结果更有效(帧速率高 36%)。这并不意外,因为场景图经过优化以提高性能。
  • 如果使用 canvas,仅清除图像占用的区域比清除整个 canvas.
  • 的帧速率大约高 10%(对于此示例)

有关 javafx.animation.fullspeed 属性 的更多详细信息,请参阅 this answer