UI 前沿技术

Silverlight 中的利萨茹动画

Charles Petzold

下载代码示例


我们通常会认为软件比硬件更加灵活,功能也更加多样。在很多情况下确实是这样,因为硬件经常囿于一种配置,而软件可以在重新编程后执行完全不同的任务。

然而,某些十分普通的硬件实际上用途却相当多样。我们来看一看常用(现在已不那么常用)的阴极射线管 (CRT)。这种装置将电子束射到玻璃屏幕的内侧。屏幕涂有一层荧光材料,这种材料通过短暂发光对电子产生反应。

在老式的电视机和计算机显示器上,电子枪在一种稳定模式下移动,横跨屏幕反复进行水平扫描,同时以更缓慢的速度从上到下移动。任意时刻电子的强度决定了该点处光点的亮度。彩色显示器中使用单独的电子枪分别产生红、绿和蓝三原色。

电子枪的方向由电磁铁控制,电子枪实际上可以瞄准玻璃屏幕二维平面上的任意位置。这就是示波器中 CRT 的使用方式。最常见的是,电子束以恒定速度在屏幕上水平进行扫描,通常与特定输入波形同步。垂直偏转显示出该点处波形的幅度。示波器中使用的荧光材料具有时间较长的余辉,从而可显示整个波形,相当于将波形“冻结”以便查看。

示波器还具有一个 X-Y 模式,通过这种模式,可由两个独立输入(通常为正弦曲线波形)来控制电子枪的水平和垂直偏转。以两条正弦曲线作为输入,将在任意时间点照亮点 (x, y),其中,x 和 y 由以下参数方程确定:

parametric equations

A 值是幅度,ω 值是频率,而 k 值是相位偏移。

这两个正弦波相互作用得到的图形就是利萨茹曲线,它是以法国数学家朱尔•安托瓦内•利萨茹 (1822 - 1880) 的名字命名的,利萨茹通过将光束在一对与振动的音叉相连的镜子之间弹射,首先观察到这种曲线。

我的网站 (charlespetzold.com/silverlight/LissajousCurves/LissajousCurves.html) 提供了一个可产生利萨茹曲线的 Silverlight 程序,您可以用它进行实验。图 1 是一个典型显示图。

image: The Web Version of the LissajousCurves Program

图 1 Web 版 LissajousCurves 程序

尽管在静态屏幕快照中不太明显,一个绿点在深灰色屏幕上移动,后面留下一条轨迹,4 秒后逐渐淡出。该点的水平位置由一条正弦曲线控制,垂直位置由另一条正弦曲线控制。当两条曲线的频率为简单的整数比时,会产生重复图案。

现在人们普遍认为,Silverlight 程序必须植入 Windows Phone 7 中,才能显露在高性能台式计算机中无法发现的所有性能问题。这个程序肯定也是这样,本文稍后将讨论这些性能问题。图 2 是运行在 Windows Phone 7 仿真器上的程序。

image: The LissajousCurves Program for Windows Phone 7

图 2 用于 Windows Phone 7 的 LissajousCurves 程序

可下载代码包含一个名为 LissajousCurves 的 Visual Studio 解决方案。该 Web 应用程序由项目 LissajousCurves 和 LissajousCurves.Web 组成。Windows Phone 7 应用程序的项目名称为 LissajousCurves.Phone。该解决方案还包含两个库项目:Petzold.Oscilloscope.Silverlight 和 Petzold.Oscilloscope.Phone,不过这两个项目共享所有相同的代码文件。

推还是拉?

除 TextBlock 和 Slider 控件之外,此程序中仅有的其他可视元素是一个从 UserControl 派生的名为 Oscilloscope 的类。名为 SineCurve 的类的两个实例为 Oscilloscope 提供数据。

SineCurve 本身没有可视元素,但我是从 FrameworkElement 派生该类的,因此可以将这两个实例放在可视化树中,对它们定义绑定。实际上,程序中的所有内容都与绑定有关:从 Slider 控件到 SineCurve 元素,从 SineCurve 到 Oscilloscope。Web 版程序的 MainPage.xaml.cs 文件只有默认提供的代码,手机应用程序的等效文件仅实现删除逻辑。

SineCurve 定义两个属性(受依赖关系属性支持),分别名为 Frequency 和 Amplitude。一个 SineCurve 实例提供 Oscilloscope 的水平值,另一个实例提供垂直值。

SineCurve 类还实现一个接口,我将它命名为 IProvideAxisValue:

public interface IProvideAxisValue {
  double GetAxisValue(DateTime dateTime);
}

SineCurve 通过一个十分简单的方法实现此接口,该方法引用两个字段以及这两个属性:

public double GetAxisValue(DateTime dateTime) {
  phaseAngle += 2 * Math.PI * this.Frequency * 
    (dateTime - lastDateTime).TotalSeconds;
  phaseAngle %= 2 * Math.PI;
  lastDateTime = dateTime;

  return this.Amplitude * Math.Sin(phaseAngle);
}

Oscilloscope 类定义两个 IProvideAxisValue 类型的属性(也受依赖关系属性支持),分别名为 XProvider 和 YProvider。为了实现移动,Oscilloscope 为 CompositionTarget.Rendering 事件安装一个处理程序。此事件将与视频显示器的刷新率同步触发,可作为动画执行的便利工具。每次调用 CompositionTarget.Rendering 处理程序时,Oscilloscope 都会对设置为自身的 XProvider 和 YProvider 属性的这两个 SineCurve 对象调用 GetAxisValue。

换句话说,该程序实现拉模型。Oscilloscope 对象确定何时需要数据,然后从这两个数据提供程序拉出数据。(我稍后会讨论它如何显示这些数据。)

随着我开始向这个程序添加更多功能(具体而言,是显示正弦曲线的附加控件的两个实例,但最终还是因并无作用反成干扰被我去掉了),我开始怀疑这种模型是否合理。我有三个对象从两个提供程序拉出相同数据,我想可能采用推模型会更好。

我重新组织程序结构,以便 SineCurve 类为 CompositionTarget.Rendering 安装处理程序,并通过现在简单称为 X 和 Y、类型为 double 的属性将数据推到 Oscilloscope 控件。

我可能应该预料到这种特定推模型的基本缺陷:Oscilloscope 现在接收的 X 和 Y 分别都在变化,所构造的不是平滑的曲线,而是一系列梯级,如图 3 所示。

image: The Disastrous Result of a Push-Model Experiment

图 3 推模型实验的混乱结果

显然,很容易做出使用拉模型的决定!

通过 WriteableBitmap 呈现

从构思这个程序时起,我就坚定地认为,使用 WriteableBitmap 是实现实际 Oscilloscope 屏幕的最佳方法。

WriteableBitmap 是一种支持像素寻址的 Silverlight 位图。位图的所有像素公开为 32 位整数数组。程序可以任意获取和设置这些像素。WriteableBitmap 还有一个 Render 方法,通过这种方法,可以将类型为 FrameworkElement 的任何对象的可视元素呈现到位图上。

如果 Oscilloscope 只是需要显示简单的静态曲线,我会使用 Polyline 或 Path,甚至不会考虑使用 WriteableBitmap。即使该曲线需要改变形状,仍然会首选 Polyline 或 Path。但是,由 Oscilloscope 显示的曲线需要增加大小,还需要着色(有点奇怪)。线条需要逐渐淡出:最新显示的线条部分比旧的线条部分更加明亮。如果我使用单条曲线,则它沿线需要各种颜色。这在 Silverlight 中 是不受支持的!

如果不使用 WriteableBitmap,程序就需要创建几百个不同的 Polyline 元素,它们的颜色各不相同,位置各异,从而在每个 CompositionTarget.Rendering 事件之后都会触发布局过程。根据我对 Silverlight 编程的了解,WriteableBitmap 的性能肯定会好得多。

Oscilloscope 类的一个早期版本对 CompositionTarget.Rendering 事件进行处理,方法是从两个 SineCurve 提供程序获取新值,将这些值调整到 WriteableBitmap 的大小,然后构造一个从上一个点到当前点的 Line 对象。只需将该对象传递给 WriteableBitmap 的 Render 方法:

writeableBitmap.Render(line, null);

Oscilloscope 类定义了一个 Persistence 属性,指示任何颜色或像素的 Alpha 分量从 255 减少到 0 的秒数。让这些像素淡出涉及到直接像素寻址。代码如图 4 所示。

图 4 像素值淡出代码

accumulatedDecrease += 256 * 
  (dateTime - lastDateTime).TotalSeconds / Persistence;
int decrease = (int)accumulatedDecrease;

// If integral decrease, sweep through the pixels
if (decrease > 0) {
  accumulatedDecrease -= decrease;

  for (int index = 0; index < 
    writeableBitmap.Pixels.Length; index++) {

    int pixel = writeableBitmap.Pixels[index];

    if (pixel != 0) {
      int a = pixel >> 24 & 0xFF;
      int r = pixel >> 16 & 0xFF;
      int g = pixel >> 8 & 0xFF;
      int b = pixel & 0xFF;

      a = Math.Max(0, a - decrease);
      r = Math.Max(0, r - decrease);
      g = Math.Max(0, g - decrease);
      b = Math.Max(0, b - decrease);

      writeableBitmap.Pixels[index] = a << 24 | r << 16 | g << 8 | b;
    }
  }
}

在程序开发中的这个位置,我采取了一些必要步骤,以便该程序也能在手机上正常运行。 在 Web 和手机上,程序似乎运行得都很好,但我知道,问题并没有完全解决。 我没有在 Oscilloscope 屏幕上看到曲线:我看到的是一组连接起来的直线。 这样一组直线,瞬间就破坏了数字仿真模拟的效果!

插值

CompositionTarget.Rendering 处理程序的调用是与视频显示器刷新同步进行的。 对于大多数视频显示器(包括 Windows Phone 7 的显示器),刷新率通常为每秒 60 帧。 换句话说,大约每隔 16 或 17 毫秒就调用一次 CompositionTarget.Rendering 事件处理程序。 (实际上,您可以看到,这只是最好的情况。)即使正弦波的频率小到仅每秒一个周期,对于 480 个像素宽的示波器来说,两个相邻样点的像素坐标也可能相距约 35 个像素。

Oscilloscope 需要在一条曲线的连续样点之间插值。 但这是何种曲线?

我的第一个选择是规范样条(也称为基数样条)。 对于由控制点 p1、p2、p3 和 p4 组成的序列,规范样条可基于一个“张力”因子以某种弯曲度在 p2 和 p3 之间进行三次差值。 这是一种通用解决方案。

规范样条在 Windows 窗体中受支持,但从没有引入 Windows Presentation Foundation (WPF) 或 Silverlight 中。 幸运的是,我有一些 WPF 和 Silverlight 规范样条代码,这些代码是我为 2009 年的一篇博客文章开发的,这篇文章的标题正是“WPF 和 Silverlight 中的规范样条”(bit.ly/bDaWgt)。

通过插值生成 Polyline 之后,CompositionTarget.Rendering 以如下所示的调用结束处理:

writeableBitmap.Render(polyline, null);

规范样条起作用,但不太正确。当两条正弦曲线的频率是简单的整数倍时,该曲线应稳定为一个固定模式。但这种情况并未发生,我意识到,经过插值的曲线会因实际采样点而略有不同。

这种问题在手机上会更严重,主要是因为手机处理器比较难于满足我施加给它的所有要求。在较高频率下,手机上的利萨茹曲线看上去是光滑弯曲的,但似乎是以近乎随机的形式在移动!

我慢慢地意识到,我可以基于时间进行差值。对 CompositionTarget.Rendering 事件处理程序的两次连续调用间隔大约 17 毫秒。我只需遍历所有这些中间毫秒值,在两个 SineCurve 提供程序中调用 GetAxisValue 方法,就可以构造更光滑的折线。

这种方法的效果好得多。

提高性能

bit.ly/fdvh7Z 的文档页“Windows Phone 应用程序的性能注意事项”提供了适用于所有 Windows Phone 7 编程人员的重要信息。除了关于提高手机应用程序性能的众多有用提示之外,它还介绍了在 Visual Studio 中运行程序时显示在屏幕侧边的数字的含义,如图 5 所示。

image: Performance Indicators in Windows Phone 7

图 5 Windows Phone 7 中的性能指示器

通过将 Application.Current.Host.Settings.EnableFrameRateCounter 属性设置为 true,可以启用这行数字,如果程序在 Visual Studio 调试器中运行,则由标准 App.xaml.cs 文件进行这一设置。

前两个数字最为重要:有时,如果没有执行任何操作,则这两个数字显示为 0,但它们都可用于显示帧速率,即它们可显示每秒的帧数。刚才提到过,大多数视频显示器都以每秒 60 次的速率进行刷新。但是,应用程序可能会尝试执行动画,其中的每个新帧都需要 16 或 17 毫秒以上的处理时间。

例如,假设一个 CompositionTarget.Rendering 处理程序需要 50 毫秒执行当前操作。在这种情况下,程序将以每秒 20 次的速率更新视频显示。这就是程序的帧速率。

现在,每秒 20 帧并不是太差的帧速率。请注意,电影的播放速率是每秒 24 帧,在美国,标准电视的有效帧速率(考虑隔行扫描)是每秒 30 帧,在欧洲是每秒 25 帧。但是,一旦帧速率降到 15 或 10,影响就明显了。

Silverlight for Windows Phone 能够将某些动画卸载到图形处理单元 (GPU),这样,它就有一个辅助线程(有时称为复合线程或 GPU 线程)与 GPU 交互。第一个数字是与该线程关联的帧速率。第二个数字是 UI 帧速率,是指应用程序的主线程。所有 CompositionTarget.Rendering 处理程序都在主线程中运行。

在我的手机上运行 LissajousCurves 程序,我看到数字 22 和 11,它们分别是 GPU 和 UI 线程的数据,如果我增加正弦曲线的频率,它们就会略微下降。我能够做得更好吗?

我开始想知道我的 CompositionTarget.Rendering 方法中的这一重要语句需要多少时间:

writeableBitmap.Render(polyline, null);

对于 16 或 17 段的折线,每秒会调用 60 次该语句,但实际上,对于 90 段的折线,差不多每秒调用 11 次该语句。

在《Programming Windows Phone 7》(Microsoft Press,2010)中,我为 XNA 编写了一些线条呈现逻辑,我可以针对 Silverlight 在此 Oscilloscope 类对该逻辑进行调整。 那时,我根本不会调用 WriteableBitmap 的 Render 方法,而是直接改变位图中的像素来绘制折线。

遗憾的是,两个帧速率都急速降低到 0! 这使我想到,Silverlight 知道如何在位图上呈现线条,速度比我快得多。 (我还注意到,我的代码并没有针对折线得到优化。)

这时,我想知道是否应该使用 WriteableBitmap 以外的方法。 我用 Canvas 代替了 WriteableBitmap 和 Image 元素,构建每个 Polyline 时,我只是添加该 Canvas。

当然,您不能毫无限制这样做。 您不会希望 Canvas 拥有成千上万个子项。 此外,这些 Polyline 子项需要淡出。 我尝试过两种方法:第一种方法是将 ColorAnimation 连接到每个 Polyline 以降低颜色的 Alpha 通道,然后在动画完成时从 Canvas 中移除 Polyline。 第二种方法手动成分更多,它枚举 Polyline 子项,手动降低颜色的 Alpha 通道,然后在 Alpha 下降到 0 时移除子项。

这四种方法仍存在于 Oscilloscope 类中,在 C# 文件顶部使用四个 #define 语句可以启用这些方法。 图 6 显示了每种方法的帧速率。

图 6 四种 Oscilloscope 更新方法的帧速率

  复合线程 UI 线程
WriteableBitmap(Polyline 呈现) 22 11
WriteableBitmap(手动轮廓填充) 0 0
Canvas(Polyline,动画淡出) 20 20
Canvas(Polyline,手动淡出) 31 15

根据图 6,我对 WriteableBitmap 的最初直觉是错误的。 在这种情况下,将一组 Polyline 元素置于画布中真的会更好。 两种淡出方法很令人感兴趣:在由动画执行时,复合线程以每秒 20 帧的速率执行淡出。 手动执行时,UI 线程以每秒 15 帧的速率执行淡出。 但是,添加新的 Polyline 元素总是在 UI 线程中发生,当淡出逻辑卸载到 GPU 时,帧速率为 20。

总之,第三种方法的整体性能是最好的。

我们今天学到了什么? 显然,要达到最佳性能,有必要进行实验。 尝试不同的方法,不要相信自己的最初直觉。

Charles Petzold  MSDN 杂志 *的长期特约编辑。*他的新书《Programming Windows Phone 7》(Microsoft Press,2010)可从 bit.ly/cpebookpdf 免费下载。

衷心感谢以下技术专家对本文的审阅:Jesse Liberty