具有 DisplayLink 异常行为的核心图形
Core Graphics with DisplayLink Unexpected Behavior
我正在尝试学习 Core Graphics,但无法理解我编写的代码的行为,该代码使用子类化的 UIView 和 draw(_ rect:)
函数的重写。
我写了一个基本的弹跳球演示。任意数量的随机球都是以随机位置和速度创建的。然后它们在屏幕上弹跳。
我的问题是球看起来移动的方式出乎意料并且有很多闪烁。下面是循环遍历所有球的顺序:
- 检查碰撞。
- 如果与墙发生碰撞,速度乘以-1。
- 按球速增加球位置。
我目前没有清除上下文,所以我希望现有的球保持原样。相反,它们似乎随着移动的球平稳地滑动。
我想了解为什么会这样。
这是当前代码如何以 4 fps 运行的图像,以便您可以看到形状是如何绘制和来回移动的:
这是我的代码:
class ViewController: UIViewController {
let myView = MyView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
myView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(myView)
NSLayoutConstraint.activate([
myView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
myView.widthAnchor.constraint(equalTo: view.widthAnchor),
myView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
createDisplayLink(fps: 60)
}
func createDisplayLink(fps: Int) {
let displaylink = CADisplayLink(target: self,
selector: #selector(step))
displaylink.preferredFramesPerSecond = fps
displaylink.add(to: .current,
forMode: RunLoop.Mode.default)
}
@objc func step(displaylink: CADisplayLink) {
myView.setNeedsDisplay()
}
}
class MyView: UIView {
let numBalls = 5
var balls = [Ball]()
override init(frame:CGRect) {
super.init(frame:frame)
for _ in 0..<numBalls {
balls.append(
Ball(
ballPosition: Vec2(x: CGFloat.random(in: 0...UIScreen.main.bounds.width), y: CGFloat.random(in: 0...UIScreen.main.bounds.height)),
ballSpeed: Vec2(x: CGFloat.random(in: 0.5...2), y: CGFloat.random(in: 0.5...2))))
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for i in 0..<numBalls {
if balls[i].ballPosition.x > self.bounds.width - balls[i].ballSize || balls[i].ballPosition.x < 0 {
balls[i].ballSpeed.x *= -1
}
balls[i].ballPosition.x += balls[i].ballSpeed.x
if balls[i].ballPosition.y > self.bounds.height - balls[i].ballSize || balls[i].ballPosition.y < 0 {
balls[i].ballSpeed.y *= -1
}
balls[i].ballPosition.y += balls[i].ballSpeed.y
}
for i in 0..<numBalls {
context.setFillColor(UIColor.red.cgColor)
context.setStrokeColor(UIColor.green.cgColor)
context.setLineWidth(0)
let rectangle = CGRect(x: balls[i].ballPosition.x, y: balls[i].ballPosition.y, width: balls[i].ballSize, height: balls[i].ballSize)
context.addEllipse(in: rectangle)
context.drawPath(using: .fillStroke)
}
}
}
这里有很多误区,我会尽量一一解答:
CADisplayLink 不保证它会每 1/60 秒调用一次您的步骤方法。 属性 被称为 preferred 每秒帧数是有原因的。这只是向系统提示您想要什么。它可能不会经常打电话给你,无论如何都会有一些错误。
要手动执行您自己的动画,您需要查看给定帧实际附加的时间,并使用它来确定事物的位置。 CADisplayLink 包含一个 timestamp
让您知道。你不能只增加速度。你需要用速度乘以实际时间来确定距离。
“我目前没有清除上下文,所以我希望现有的球保持原样。”每次调用 draw(rect:)
时,您都会收到一个新的上下文。您有责任为当前帧绘制所有内容。帧之间没有持久性。 (Core Animation 通常通过高效地将 CALayer 组合在一起来提供这些功能;但是您选择使用 Core Graphics,并且每次都需要在那里绘制所有内容。我们通常不会这样使用 Core Graphics。)
myView.setNeedsDisplay()
并不意味着“立即绘制此帧”。意思是“下次你要画的时候,这个视图需要重画”。根据 CADisplayLink 触发的确切时间,您可能会丢帧,也可能不会。使用 Core Graphics,您需要在调用 setNeedsDisplay()
之前更新所有圆圈的位置。然后 draw(rect:)
应该只是绘制它们,而不是计算它们是什么。 (虽然 CADisplayLink 实际上是为与 CALayers 一起工作而设计的,而 NSView 绘图并不是为经常更新而设计的,因此要保持流畅可能仍然有点棘手。)
创建这个系统的更正常的方法是为每个球生成一个 CAShapeLayer 并将它们放置在 NSView 的图层上。然后在 CADisplayLink 回调中,您将根据下一帧的时间戳调整它们的位置。或者,您可以设置一个重复的 NSTimer 或 DispatchTimerSource(而不是 CADisplayLink),其速度远低于屏幕刷新速度(例如 1/20 秒),并在该回调中移动图层位置。这会很好而且简单,并且避免 CADisplayLink 的复杂性(它更强大,但希望您使用时间戳并考虑其他软实时问题)。
我正在尝试学习 Core Graphics,但无法理解我编写的代码的行为,该代码使用子类化的 UIView 和 draw(_ rect:)
函数的重写。
我写了一个基本的弹跳球演示。任意数量的随机球都是以随机位置和速度创建的。然后它们在屏幕上弹跳。
我的问题是球看起来移动的方式出乎意料并且有很多闪烁。下面是循环遍历所有球的顺序:
- 检查碰撞。
- 如果与墙发生碰撞,速度乘以-1。
- 按球速增加球位置。
我目前没有清除上下文,所以我希望现有的球保持原样。相反,它们似乎随着移动的球平稳地滑动。 我想了解为什么会这样。
这是当前代码如何以 4 fps 运行的图像,以便您可以看到形状是如何绘制和来回移动的:
这是我的代码:
class ViewController: UIViewController {
let myView = MyView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBlue
myView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(myView)
NSLayoutConstraint.activate([
myView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
myView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
myView.widthAnchor.constraint(equalTo: view.widthAnchor),
myView.heightAnchor.constraint(equalTo: view.heightAnchor)
])
createDisplayLink(fps: 60)
}
func createDisplayLink(fps: Int) {
let displaylink = CADisplayLink(target: self,
selector: #selector(step))
displaylink.preferredFramesPerSecond = fps
displaylink.add(to: .current,
forMode: RunLoop.Mode.default)
}
@objc func step(displaylink: CADisplayLink) {
myView.setNeedsDisplay()
}
}
class MyView: UIView {
let numBalls = 5
var balls = [Ball]()
override init(frame:CGRect) {
super.init(frame:frame)
for _ in 0..<numBalls {
balls.append(
Ball(
ballPosition: Vec2(x: CGFloat.random(in: 0...UIScreen.main.bounds.width), y: CGFloat.random(in: 0...UIScreen.main.bounds.height)),
ballSpeed: Vec2(x: CGFloat.random(in: 0.5...2), y: CGFloat.random(in: 0.5...2))))
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext() else { return }
for i in 0..<numBalls {
if balls[i].ballPosition.x > self.bounds.width - balls[i].ballSize || balls[i].ballPosition.x < 0 {
balls[i].ballSpeed.x *= -1
}
balls[i].ballPosition.x += balls[i].ballSpeed.x
if balls[i].ballPosition.y > self.bounds.height - balls[i].ballSize || balls[i].ballPosition.y < 0 {
balls[i].ballSpeed.y *= -1
}
balls[i].ballPosition.y += balls[i].ballSpeed.y
}
for i in 0..<numBalls {
context.setFillColor(UIColor.red.cgColor)
context.setStrokeColor(UIColor.green.cgColor)
context.setLineWidth(0)
let rectangle = CGRect(x: balls[i].ballPosition.x, y: balls[i].ballPosition.y, width: balls[i].ballSize, height: balls[i].ballSize)
context.addEllipse(in: rectangle)
context.drawPath(using: .fillStroke)
}
}
}
这里有很多误区,我会尽量一一解答:
CADisplayLink 不保证它会每 1/60 秒调用一次您的步骤方法。 属性 被称为 preferred 每秒帧数是有原因的。这只是向系统提示您想要什么。它可能不会经常打电话给你,无论如何都会有一些错误。
要手动执行您自己的动画,您需要查看给定帧实际附加的时间,并使用它来确定事物的位置。 CADisplayLink 包含一个
timestamp
让您知道。你不能只增加速度。你需要用速度乘以实际时间来确定距离。“我目前没有清除上下文,所以我希望现有的球保持原样。”每次调用
draw(rect:)
时,您都会收到一个新的上下文。您有责任为当前帧绘制所有内容。帧之间没有持久性。 (Core Animation 通常通过高效地将 CALayer 组合在一起来提供这些功能;但是您选择使用 Core Graphics,并且每次都需要在那里绘制所有内容。我们通常不会这样使用 Core Graphics。)myView.setNeedsDisplay()
并不意味着“立即绘制此帧”。意思是“下次你要画的时候,这个视图需要重画”。根据 CADisplayLink 触发的确切时间,您可能会丢帧,也可能不会。使用 Core Graphics,您需要在调用setNeedsDisplay()
之前更新所有圆圈的位置。然后draw(rect:)
应该只是绘制它们,而不是计算它们是什么。 (虽然 CADisplayLink 实际上是为与 CALayers 一起工作而设计的,而 NSView 绘图并不是为经常更新而设计的,因此要保持流畅可能仍然有点棘手。)
创建这个系统的更正常的方法是为每个球生成一个 CAShapeLayer 并将它们放置在 NSView 的图层上。然后在 CADisplayLink 回调中,您将根据下一帧的时间戳调整它们的位置。或者,您可以设置一个重复的 NSTimer 或 DispatchTimerSource(而不是 CADisplayLink),其速度远低于屏幕刷新速度(例如 1/20 秒),并在该回调中移动图层位置。这会很好而且简单,并且避免 CADisplayLink 的复杂性(它更强大,但希望您使用时间戳并考虑其他软实时问题)。