UI 最前線
Silverlight でのリサージュ アニメーション
Charles Petzold
私たちは、通常、ソフトウェアをハードウェアより柔軟で汎用性が高いと考えています。ハードウェアは 1 つの構成から変更できませんが、ソフトウェアはまったく異なる処理を実行するようプログラミングできるため、ほとんどの場合はこの考え方に間違いはありません。
しかし、一部のハードウェアはかなり平凡なものでも、実は非常に高い汎用性を備えています。一般的な (現在ではそれほど一般的ではなくなったとも言える) ブラウン管 (CRT) について考えてみましょう。これは、ガラス画面の内側に電子ビームを当てるデバイスです。画面には蛍光体が塗られ、当たった電子に反応して短時間発光します。
昔のテレビやコンピューターのモニターは、電子銃が一定のパターンで移動して、画面を水平に繰り返し走査し、これを上から下へ、水平方向よりもゆっくりと繰り返します。その時点の電子の強さによってその位置にあるドットの明るさが決まります。カラー ディスプレイの場合、それぞれ別の電子銃を使用して赤、緑、青の三原色を作り出します。
電子銃の向きは電磁石で制御し、ガラスの 2 次元平面上の任意の場所に狙いを定めることができます。オシロスコープで CRT を使用しているのはこのためです。ほとんどの場合、オシロスコープのビームは一定の速度で画面上を水平に走査し、通常は特定の入力波形と同期します。垂直偏向は、その時点の波形の振幅を表します。オシロスコープに使用している蛍光体はかなり長く光り続けるため、波形全体が表示されます (実際のところ、人間の目には波形が "止まっている" ように見えます)。
オシロスコープには、X-Y モードという、電子銃の水平偏向と垂直偏向を 2 つの独立した入力 (通常は正弦曲線などの波形) に基づいて制御できるモードもあります。入力が 2 つの正弦曲線の場合、任意の時点で点 (x, y) が発光します。この x と y の値は、次のようなパラメーター方程式から求められます。
A の値は振幅、ω の値は周波数、k の値は位相オフセットです。
これら 2 つの正弦波の相互作用から生まれるパターンが、リサージュ曲線です。これは、この曲線を最初に図形として作成したフランスの数学者、Jules Antoine Lissajous (1822 ~ 1880) にちなんで名付けられました。彼は振動する 2 つの音さに取り付けた 2 枚の鏡の間で光を反射させて曲線を作成しました。
リサージュ曲線を生成する Silverlight プログラムを私の Web サイト (charlespetzold.com/silverlight/LissajousCurves/LissajousCurves.html、英語) で実験できます。図 1 は典型的な表示を示しています。
図 1 Web 版の LissajousCurves プログラム
静止画のスクリーンショットではかなりわかりにくいですが、緑色の点が濃い灰色の画面上を動き、4 秒間でフェードアウトする軌跡が表示されます。この点は、水平位置を 1 つの正弦曲線で、垂直位置をもう 1 つの正弦曲線で制御しています。2 つの周波数が単純な整数比の場合、同じパターンが繰り返し表示されます。
今では広く認知された事実ですが、Silverlight プログラムは、機会があれば Windows Phone 7 に移植して、それまで高性能なデスクトップ コンピューターでは隠れていたパフォーマンスの問題を明らかにする必要があります。このプログラムは確実に Windows Phone 7 に移植できますが、コラムの後半ではこのパフォーマンスに関する問題について説明します。図 2 は、Windows Phone 7 エミュレーターで実行中のプログラムを示しています。
図 2 Windows Phone 7 向けの LissajousCurves プログラム
ダウンロード可能なコードは、LissajousCurves という 1 つの Visual Studio ソリューションで構成しています。Web アプリケーションは、LissajousCurves プロジェクトと LissajousCurves.Web プロジェクトで構成しています。Windows Phone 7 アプリケーションのプロジェクト名は LissajousCurves.Phone です。LissajousCurves ソリューションには Petzold.Oscilloscope.Silverlight と Petzold.Oscilloscope.Phone という 2 つのライブラリ プロジェクトも含めていますが、これらのプロジェクトはまったく同じコード ファイルを共有しています。
プッシュかプルか
TextBlock コントロールと Slider コントロール以外にこのプログラムに含めたビジュアル要素は、UserControl から派生した Oscilloscope というクラスだけです。Oscilloscope クラスにデータを提供するのは、SineCurve というクラスの 2 つのインスタンスです。
SineCurve クラス自体にビジュアル要素はありませんが、今回はこのクラスを FrameworkElement から派生して、2 つのインスタンスをビジュアル ツリーに配置し、バインドを定義しました。実際には、プログラムのすべての要素をバインドで結び付けています (Slider コントロールから SineCurve 要素へのバインド、SineCurve 要素から Oscilloscope 要素へのバインドなど)。Web 版プログラム用の MainPage.xaml.cs ファイルには、既定で作成されるコードだけを含め、携帯電話アプリケーションの対応するファイルにはトゥームストーン ロジックだけを実装しています。
SineCurve クラスは、Frequency と Amplitude の 2 つのプロパティを定義します (これらのプロパティは依存関係プロパティによってサポートされています)。SineCurve クラスの 1 つのインスタンスは Oscilloscope クラスの水平値を提供し、もう 1 つのインスタンスは垂直値を提供します。
SineCurve クラスでは、次のような IProvideAxisValue というインターフェイスも実装します。
public interface IProvideAxisValue {
double GetAxisValue(DateTime dateTime);
}
SineCurve クラスは、2 つのフィールドと 2 つのプロパティを参照するかなり単純なメソッドを使ってこのインターフェイスを実装します。
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 クラスは、XProvider と YProvider という IProvideAxisValue 型の 2 つのプロパティを定義します (これらのプロパティも依存関係プロパティによってサポートされています)。すべての構成要素が動き続けるよう、Oscilloscope クラスでは CompositionTarget.Rendering イベントのハンドラーを実装します。このイベントはビデオ ディスプレイのリフレッシュ レートと同期して発生するため、アニメーションを実行するための便利なツールとして機能します。CompositionTarget.Rendering イベント ハンドラーを呼び出すたびに、Oscilloscope クラスは、XProvider プロパティと YProvider プロパティに設定された 2 つの SineCurve オブジェクトの GetAxisValue メソッドを呼び出します。
つまり、プログラムはプル モデルを実装します。Oscilloscope オブジェクトがデータを必要とするタイミングを決定し、2 つのデータ プロバイダーからデータをプルします (データの表示方法については、この後説明します)。
プログラムに機能を追加し始めるにつれ、このモデルの妥当性を疑い始めました (正弦曲線を表示する追加のコントロールのインスタンスを 2 つ追加したときは特に疑問に思いましたが、これらのインスタンスは役に立たないおまけの機能だったのですぐに削除しました)。2 つのプロバイダーから同じデータをプルするオブジェクトが 3 つあったので、プッシュ モデルの方が適しているのではないかと考えました。
そこでプログラムを再構成して、SineCurve クラスに CompositionTarget.Rendering イベントのハンドラーを実装し、単に X と Y という double 型のプロパティを使用して Oscilloscope コントロールにデータをプッシュしました。
このプッシュ モデルの根本的な問題を予想しておけば良かったのですが、Oscilloscope コントロールでは、X プロパティと Y プロパティの 2 つの異なる変更を受け取るようになったため、滑らかな曲線ではなく連続した階段状のパターンが表示されるようになりました (図 3 参照)。
図 3 大失敗したプッシュ モデルの実験結果
プル モデルに戻ることを決意するのは簡単でした。
WriteableBitmap でレンダリングする
このプログラムを思い付いたときから、WriteableBitmap を使用することこそ、実際のオシロスコープ画面を実装する最適な解決策だという考えにまったく疑問を抱いていませんでした。
WriteableBitmap は、ピクセル単位のアドレス指定をサポートする Silverlight ビットマップです。ビットマップのすべてのピクセルは、32 ビット整数の配列として公開されます。プログラムから、これらのピクセルを任意の方法で取得および設定できます。WriteableBitmap には、FrameworkElement 型のオブジェクトのビジュアル要素をビットマップ上にレンダリングできる Render メソッドがあります。
オシロスコープに単純な静的曲線を表示するだけなら、Polyline か Path を使用して、WriteableBitmap については検討すらしなかったはずです。この曲線の形を変更する必要があったとしても、Polyline か Path の方が適しています。しかし、オシロスコープに表示する曲線は、サイズを大きくし、独特の色にする必要があります。また、曲線を徐々にフェードアウトすることも必要です。後から表示する曲線を以前の曲線より明るくします。1 つの曲線を使用する場合、さまざまな長さで色を変えることになります。このような考え方は、Silverlight ではサポートされません。
WriteableBitmap がなかったら、数百もの Polyline 要素を作成して、すべての要素に異なる色を付けてあちこちに動かし、CompositionTarget.Rendering イベントが発生するたびにレイアウト パスを開始する必要があったでしょう。Silverlight プログラミングに関するあらゆる知識を総動員すると、WriteableBitmap を使用すれば確実にパフォーマンスが向上すると考えられました。
Oscilloscope クラスの初期バージョンでは、CompositionTarget.Rendering イベントを処理するために、2 つの SineCurve プロバイダーから新しい値を取得し、WriteableBitmap のサイズに合わせて値を変更し、前の点から現在の点までの Line オブジェクトを作成していました。この Line オブジェクトを、次のように単純に WriteableBitmap の Render メソッドに渡します。
writeableBitmap.Render(line, null);
Oscilloscope クラスでは、Persistence プロパティを定義しています。このプロパティは、あるピクセルのカラー コンポーネントやアルファ コンポーネントが 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 アプリケーションと携帯電話アプリケーションの両方でプログラムは問題なく実行されるように見えましたが、まだ完成ではないことはわかっていました。オシロスコープ画面には曲線が表示されず、連続した直線の集まりが表示されていました。デジタルでアナログのシミュレーションを行う場合、直線の集まりほどシミュレーションの効果を台無しにするものはありません。
補間
CompositionTarget.Rendering イベント ハンドラーの呼び出しは、ビデオ ディスプレイのリフレッシュと同期しています。Windows Phone 7 のディスプレイを含め、ほとんどのビデオ ディスプレイでは、通常のリフレッシュ レートは毎秒 60 フレーム前後です。つまり、CompositionTarget.Rendering イベント ハンドラーは、約 16 ~ 17 ミリ秒ごとに呼び出されます (実際には、後で説明するように、この数値を実現できるのは状況が最適な場合のみです)。正弦波が毎秒 1 サイクルとゆっくりとした場合でも、幅 480 ピクセルのオシロスコープでは、隣接する 2 つのサンプルのピクセル座標が約 35 ピクセル離れる可能性があります。
Oscilloscope クラスでは、連続する各サンプルの間を曲線で補間する必要があります。しかし、どのような曲線で補間するのでしょう。
最初に選択したのは、正準スプライン (別名、カーディナル スプライン) でした。正準スプラインでは、一連の制御点 (p1、p2、p3、および p4) に対し、"テンション" 係数に基づいた歪曲度で p2 と p3 の間で 3 次補間を行います。これは汎用の解決策です。
正準スプラインは Windows フォームではサポートされますが、Windows Presentation Foundation (WPF) や Silverlight には組み込まれていません。さいわい、2009 年にぴったりなタイトルのブログ エントリ「Canonical Splines in WPF and Silverlight」(WPF と Silverlight の正準スプライン、bit.ly/bDaWgt、英語) で、正準スプラインをレンダリングする WPF と Silverlight のコードを開発していました。
補間を使って Polyline を生成したら、CompositionTarget.Rendering イベント ハンドラーの処理では最後に次のような呼び出しを行います。
writeableBitmap.Render(polyline, null);
正準スプラインは役に立ちましたが、あまり適切ではありませんでした。2 つの正弦曲線の周波数が単純な整数倍のときは、曲線が特定のパターンで安定するはずです。しかし、曲線が安定しなかったことから、補間曲線は実際のサンプル点によって少しずつ異なることがわかりました。
携帯電話では、この問題がもっと深刻になりました。これは主に、携帯電話の低性能のプロセッサではプログラムの要求すべてに対応することができなかったためでした。周波数が高いと携帯電話のリサージュ曲線は滑らかに曲がって見えましたが、ほとんどランダムなパターンで動いているように見えました。
やがて、時間に基づいて補間できることに気が付きました。連続する 2 回の CompositionTarget.Rendering イベント ハンドラーの呼び出し間隔は、約 17 ミリ秒です。このようなミリ秒単位の中間値をすべてループ処理し、2 つの SineCurve プロバイダーで GetAxisValue メソッドを呼び出すと、さらに滑らかなポリラインを作成できました。
この手法はずっとうまく機能しました。
パフォーマンスを向上する
すべての Windows Phone 7 プログラマ向けの必読資料として、「Windows Phone 向けアプリケーションのパフォーマンスに関する考慮事項」(bit.ly/fdvh7Z、英語) があります。携帯電話アプリケーションのパフォーマンス向上に関する多くの便利なヒントに加えて、Visual Studio でプログラムを実行する際に画面の端に表示される数値の意味が説明されています (図 5 参照)。
図 5 Windows Phone 7 のパフォーマンス インジケーター
この数値行は、Application.Current.Host.Settings.EnableFrameRateCounter プロパティを true に設定すると有効になります。標準の App.xaml.cs ファイルでは、プログラムを Visual Studio デバッガーで実行している場合はこのプロパティが true になっています。
最初の 2 つの数値が最も重要です。何も処理していなければ 2 つの数値が "0" になることもありますが、どちらの数値もフレーム レート、つまり 1 秒あたりに表示されるフレーム数を示しています。ほとんどのビデオ ディスプレイが毎秒 60 回の速度でリフレッシュされると述べました。しかし、アプリケーション プログラムでは、新しいフレームの処理にかかる時間が 16 ~ 17 ミリ秒を超えるアニメーションを実行しようとすることもあります。
たとえば、CompositionTarget.Rendering イベント ハンドラーで処理の実行に 50 ミリ秒かかるとしましょう。その場合、プログラムではビデオ ディスプレイを毎秒 20 回のレートで更新することになります。これがプログラムのフレーム レートです。
ただし、毎秒 20 フレームというのは、それほど低いフレーム レートではありません。映画は毎秒 24 フレームで再生され、標準テレビの (インタレースを考慮した) 事実上のフレーム レートは、米国では毎秒 30 フレーム、ヨーロッパでは毎秒 25 フレームです。しかし、フレーム レートが毎秒 15 ~ 10 フレームに低下すると、遅さが目立ち始めます。
Silverlight for Windows Phone では一部のアニメーションをグラフィックス プロセッシング ユニット (GPU) にオフロードできるので、GPU とやり取りするセカンダリ スレッド (構成スレッドまたは GPU スレッドとも呼ばれます) を備えています。画面に表示される最初の数値は、このスレッドに関連付けられるフレーム レートです。2 つ目の数値は UI のフレーム レートで、アプリケーションのプライマリ スレッドを表します。これが、すべての CompositionTarget.Rendering イベント ハンドラーを実行するスレッドです。
LissajousCurves プログラムを手元の携帯電話で実行すると、GPU スレッドと UI スレッドについてそれぞれ 22 と 11 という数値が表示され、正弦曲線の周波数を上げると少し数値が下がりました。このパフォーマンスを向上できるでしょうか。
ここでは、CompositionTarget.Rendering メソッドの次に示す重要なステートメントにかかる時間が気になり始めました。
writeableBitmap.Render(polyline, null);
このステートメントは毎秒 60 回呼び出され、16 ~ 17 本の線分で構成されえるポリラインを作成するはずですが、実際には毎秒約 11 回呼び出され、90 本のポリラインを作成していました。
拙著『Programming Windows Phone 7』(Microsoft Press、2010 年) で、XNA 向けの線のレンダリング ロジックを紹介しました。今回はこのロジックを Oscilloscope クラス向けの Silverlight に応用できました。これで、WriteableBitmap の Render メソッドをまったく呼び出さなくなり、ビットマップ内のピクセルを直接変更してポリラインを描画するようになりました。
残念ながら、どちらのフレーム レートも 0 に落ち込みました。つまり、Silverlight には、この方法よりもずっと速くビットマップ上に直線をレンダリングする方法があることになります (コードがポリラインに対して最適化されていないことにも注意する必要があります)。
この時点で、WriteableBitmap 以外の手法が妥当かどうか考え始めました。WriteableBitmap と Image 要素の代わりに Canvas を使用し、Polyline 要素を作成するたびに、作成した Polyline 要素を Canvas に追加しました。
もちろん、無限に追加することはできません。Canvas に数十万もの子要素を追加することはお勧めできません。しかも、これらの Polyline 子要素をフェードアウトする必要があります。そこで 2 つの手法を試しました。1 つは、各 Polyline に ColorAnimation をアタッチして、色のアルファ チャネルを減らし、アニメーションの完了後に Polyline を Canvas から削除する手法です。もう 1 つはもっと手動の処理が多く、Polyline 子要素を列挙し、色のアルファ チャネルを手動で減らし、アルファ チャネルが 0 になったら子要素を削除する手法です。
これら 4 つの手法は Oscilloscope クラスにまだ存在しており、C# ファイルの先頭にある 4 つの #define ステートメントで有効になります。図 6 に、手法ごとのフレーム レートを示します。
図 6 オシロスコープの 4 つの更新手法によるフレーム レート
構成スレッド | UI スレッド | |
WriteableBitmap (Polyline のレンダリング) | 22 | 11 |
WriteableBitmap (手動の輪郭塗りつぶし) | 0 | 0 |
Canvas と Polyline (アニメーションのフェードアウト) | 20 | 20 |
Canvas と Polyline (手動のフェードアウト) | 31 | 15 |
図 6 を見ると、WriteableBitmap に関する最初の直感が間違っていたことがわかります。今回の場合は、Canvas に一連の Polyline 要素を配置する方が確実に適しています。2 つのフェードアウト手法は興味深い結果を示しています。アニメーションで実行する場合、構成スレッドではフェードアウトが毎秒 20 フレームで行われます。手動で実行する場合、UI スレッドでは毎秒 15 フレームで行われます。しかし、新しい Polyline 要素の追加は必ず UI スレッドで行われ、フェードアウト ロジックを GPU にオフロードする場合はフレーム レートが 20 になります。
結論として、全体的なパフォーマンスは 3 つ目の手法が最も優れています。
さて、今回の教訓は何でしょう。もちろん、最高のパフォーマンスを実現するには実験が必要なことです。さまざまな手法に挑戦し、決して最初の直感を信用しないことです。
Charles Petzold は MSDN Magazine の記事を長期にわたって担当している寄稿編集者です。新しく執筆した『Programming Windows Phone 7』(Microsoft Press、2010 年) は、bit.ly/cpebookpdf (英語) から無料でダウンロードできます。
この記事のレビューに協力してくれた技術スタッフの Jesse Liberty に心より感謝いたします。