チュートリアル スケッチを最適化する方法

スケッチを最適化する方法

By Greg Benedis-Grab, Dave Pagurek

プログラミング能力が向上するにつれて、より複雑なp5.jsスケッチを作成するようになります。時にはこれらのスケッチが遅すぎて実行され、期待していた効果が得られないことがあります。これは通常、コンピュータのプロセッサがデフォルトのフレームレート内でプログラムのすべてのステップを実行できないためです。言い換えれば、コンピュータが視覚的に満足のいくスピードで指示を完了するのに苦労しているのです。このガイドでは、以下のステップを含むコード改善のフレームワークを紹介します:

  1. 問題を定義する
  2. コードを理解し整理する
  3. 仮説を立てる
  4. 仮説をテストする
  5. プロジェクトの目標を振り返る
  6. 繰り返す

このフレームワークはすべてのコード改善に役立ち、コンピュータプログラミングの標準的なアプローチです。具体例では、ブログComputing Stories: Scintillating Simulationsからの例を使用して、コードのパフォーマンスに特に焦点を当てて説明します。この記事の最後には、コードのパフォーマンスを向上させるための追加の戦略も提供されます。

前提条件

  • このガイドは、ループ、オブジェクト、配列を使ったプログラミングに慣れていることを前提としています。これらのトピックについて復習するには、p5.jsの入門チュートリアルをチェックしてください。

ステップ1 – 問題を定義する

私が書いていたブログ投稿のために、1,000個の粒子がキャンバス上で跳ね回るシミュレーションを作成したいと思いました。各粒子の動きを決定する正確な物理法則をプログラミングするのに苦労しました。粒子の数を増やしてスケッチを実行すると、粒子の動きがイライラするほど遅くなりました。

問題は、アニメーションの実行が遅すぎることです。視聴者の視覚体験を向上させるために、シミュレーションのスピードを上げたいと思いました。このガイドは、コードをより速く実行したい場合に対処するのに役立つように設計されています。私の例を使用して、関連するステップを説明します。よければ、最終的なブログ投稿を見ることができます:Scintillating Simulations

ステップ2 – コードを理解し整理する

問題を定義した後、一歩下がってプログラムを振り返ってみましょう。

  • 最初のステップとして、コードの各部分が何をしているのかを理解する必要があります。私はこのプロセスを進める中で、しばしばコードを再編成します。このようにコードを改善することをリファクタリングと呼びます。
  • コメントを追加するのも良いアイデアです。コメントは自分の考えを思い出すのに役立ちます。コメントは、小さな説明には短く、大きなアイデアの説明には長くすることができます。
  • このステップで、私はParticleクラスの定義をparticle.jsという別のファイルに移動しました。以下は、私が開発したクラスメソッドの概要です。
class Particle {
  constructor(pos, v, r, h, s) {
    // オブジェクトを作成し初期化するコード。
  }

  collide(p) {
    // ニュートン力学に基づく衝突アルゴリズムを使用して、
    // 別の粒子pとの単一の衝突を処理するコード。
  }

  bounce() {
    // 壁での反射を処理するコード。
  }

  update() {
    // 速度と重力を使用して位置を更新するコード。
  }

  show() {
    // Particleオブジェクトをキャンバス上にレンダリングするコード。
  }
}

発生した具体的な問題を深く理解することが重要です。この場合、アニメーションが遅く実行されているのは、フレームレートが予想よりも低いためだとわかっています。フレームレートとは、アニメーションが1秒間に表示されるフレーム数のことです。各フレームはdraw()関数の1回の反復に相当します。

p5.jsでフレームレートを指定しない場合、デフォルトでは1秒間に60フレームのフレームレートに到達しようとします。これにより、脳が目を通じて情報を処理できる速度に基づいてスムーズなアニメーションが作成されます。1秒間に30フレーム以上の速度で動くものは、連続的に見えます。draw()の実行にかかる時間が増加し、1秒間に希望する回数実行するのに十分な時間がなくなると、フレームレートが低下するのが見えるでしょう。これが、私のアニメーションが望んでいたものと大きく異なって見えた理由です。何が起こっているのかをよりよく理解するために、frameRate()のドキュメントを参照するとよいでしょう。

以下に、フレームレートを表示したアニメーションを示します。表示することで、変更の影響を見やすくなります。

この場合、私はコードのパフォーマンスを向上させようとしています。同じようなプロセスが、コードの機能性の他の側面を改善する場合にも適用されます。

ステップ3 – 仮説を立てる

コードを見直す時間を過ごした後、問題が衝突検出アルゴリズムにあるのではないかという直感を得ました。衝突を検出するために、すべての粒子のペアを比較して、それらがどれだけ近いかを判断する必要がありました。最初の粒子は他の999個の粒子と比較されます。次に2番目の粒子が他の998個の粒子と比較され、以下同様に続きます。1,000個の粒子がある場合、約1,000 x 1,000 = 1,0002 = 1,000,000回の比較があります。

ループが何回実行されるかを見積もることは、コードの実行にかかる時間を判断する良い方法です。プログラマーは時々、アルゴリズムの複雑さを記述するビッグOの表記法を使用します。私の衝突アルゴリズムの時間複雑度はO(n2)と言えます。なぜなら、必要な比較の数は粒子の数nの2乗だからです。1,000個の場合、約1,0002回の比較があります。以下が、この作業をすべて行うcollisions()関数です:

function collisions() {
  // 各粒子のペアについて、衝突したかどうかを判断します。
  for (let i = 0; i < particles.length - 1; i += 1) {
    for (let j = i + 1; j < particles.length; j += 1) {
      // 以下の式を簡略化するために、比較する粒子を格納するためのローカル変数b1とb2を宣言します。
      let b1 = particles[i];
      let b2 = particles[j];
      
      // 各粒子の現在の位置と速度ベクトルをコピーします。
      let pos1 = b1.pos.copy();
      let pos2 = b2.pos.copy();
      let vel1 = b1.vel.copy();
      let vel2 = b2.vel.copy();
      
      // オイラー法を使用して次の位置を計算します。
      pos1.add(vel1.mult(dt));
      pos2.add(vel2.mult(dt));
      
      // 衝突をチェックします。
      if (pos1.dist(pos2) < b1.r + b2.r) {
        b1.collide(b2);
      }
    }
  }
}

通常、最も多く実行される操作がプログラムを遅くする原因です。また、コードの他の部分も遅延に寄与している可能性があります。1,000個の粒子の位置を更新してレンダリングするのにも時間がかかります。新しい粒子位置を計算する際にp5.Vector.copy()メソッドを使用していることも問題かもしれません。Particle.collide()メソッドにもp5.Vector.copy()の呼び出しがあるかもしれません。オブジェクトのコピーを作成すると、ループが実行されるたびにベクトル用の新しいメモリを割り当てる必要があり、これには時間がかかります。既存のベクトルを修正することで、新しいコピーを作成する代わりに時間を節約できる可能性があります。

コードが一歩一歩何をしているのかをできるだけ理解し、それに対処するためのアイデアを練ることが重要です。コードの一部で何が起こっているのかわからない場合、console.log()print()を使用して変数の値をコンソールに出力するのは素晴らしい戦略です。

ステップ4 – 仮説をテストする

アイデアをテストする最も簡単な方法は、変更を加えて何が起こるかを確認することです。通常、一度に1つの変更を加えて、各変更の間にテストするのが最善です。テストの際は注意深く、慎重に行ってください。このプロセスを利用して、コードをより深く理解しましょう。

コードをテストしている間、「1,000個の粒子が本当に必要なのか?」と考えました。代わりに500個の粒子でスケッチを試してみました。

let numParticles = 500;
// スケッチの残りの部分。

どれほどの違いがあるか見てください!frameRate()関数を使用して、ビジュアライゼーションにフレームレートを含めました。キャンバス上に表示していますが、代替案としてconsole.log(frameRate())を使用してコンソールに出力することもできます。

変更前

変更後

スケッチは粒子が少ないとスムーズに実行されるので、その変更が問題を解決しているようです。おそらく、これが利用可能な最も簡単な解決策でしょう。

同様に、実際の物理法則をシミュレートする必要があるのかどうか疑問に思いました。この場合、それはプロジェクトにとって重要でしたが、ゲームの場合、物理の簡略版でも十分機能し、パフォーマンスを大幅に向上させる可能性があります。

また、元のp5.Vectorオブジェクトを修正し、p5.Vector.copy()の呼び出しを避けることで、メモリをより効果的に使用することもできます。このような変更には注意が必要です。コードの他の場所で使用されているオブジェクトを更新すると、意図しないバグを引き起こす可能性があります。

優れた戦略は、変更を加えてframeRate()をベンチマーキングツールとして使用し、パフォーマンスがどのように向上したかを確認することです。フレームレートが低下した場合、問題があることがわかります。これにより、新しいバグを誤って導入していないことも確認できます。

テストがより洗練されてくると、定量的に何が起こっているかを評価する他の方法が必要になるかもしれません。Chromeでは、開発者ツールを開いて「FPSメーターを表示」オプションをオンにすると、FPS(1秒あたりのフレーム数、frameRate()が測定する単位)のグラフを取得できます。

関数やサブルーチンの実行にかかる時間を確認したい場合もあるでしょう。これを行う簡単な方法は手動プロファイリングと呼ばれます。millis()関数を使用して、コードブロックの前後の時間をチェックできます。例えば:

let start = millis(); // コードブロックの前の時間を保存します。
// 時間を測定したい処理を行います
random(0, 100);

// コードブロックの後の時間を保存します
let end = millis();
let elapsed = end - start;
console.log(`This took: ${elapsed} ms.`) // 差分、つまり経過時間を出力します。

プロファイリングしようとしているコードを複数回実行し、平均を計算するのが最善です。

注意

console.log()print()はコードを遅くするので、プロジェクトの最終版からは必ず削除してください!

これらのステップに自信がついたら、自動プロファイリングを試してみるとよいでしょう。Chromeの開発者ツールにはパフォーマンス評価機能があり、コードのパフォーマンスのスナップショットを自動的に取得できます。

Chrome DevToolsを開き、「Performance」タブに移動します。円形のボタンをクリックするかショートカット(Ctrl+E/Cmd+E)を使用して記録を開始し、ウェブページ上でユーザーのアクションをシミュレートするインタラクションを行います。データを数秒間収集した後、プロファイラーの停止ボタンをクリックします。

記録前のChrome開発者ツールのプロファイラーのスクリーンショット。「新しい記録を開始するには記録ボタンをクリックするかCmd+Eを押してください。ページの読み込みを記録するには再読み込みボタンをクリックするかCmd+Shift+Eを押してください。記録後、ドラッグして概要の関心領域を選択してください。その後、マウスホイールまたはWASDキーでタイムラインをズームおよびパンできます。」と表示されています。

記録前のChrome開発者ツールのプロファイラーのスクリーンショット。「新しい記録を開始するには記録ボタンをクリックするかCmd+Eを押してください。ページの読み込みを記録するには再読み込みボタンをクリックするかCmd+Shift+Eを押してください。記録後、ドラッグして概要の関心領域を選択してください。その後、マウスホイールまたはWASDキーでタイムラインをズームおよびパンできます。」と表示されています。

私の場合、記録をクリックした後、アニメーションを実行させただけです。プロファイラーは、CPUとメモリの使用状況を含む詳細なイベントのタイムラインをキャプチャします。これらの使用状況の数値は、アルゴリズムが遅く実行されている理由についてより深い洞察を与えてくれます。フレームチャートを使用してパフォーマンスを表示できます。これは呼び出しスタックを視覚的に表現し、実行時間に大きく寄与している関数を強調表示します。Bottom-Upタブをクリックして、最も時間がかかっている関数を特定しました。

データを記録した後のChromeプロファイラーのBottom-Upタブのスクリーンショット。テーブル形式で表示されており、Self Time、Total Time、Activityの列が見えます。Self Timeの降順でソートされています。トップ項目はcopyメソッドで、時間の48.3%を占めています。

データを記録した後のChromeプロファイラーのBottom-Upタブのスクリーンショット。テーブル形式で表示されており、Self Time、Total Time、Activityの列が見えます。Self Timeの降順でソートされています。トップ項目はcopyメソッドで、時間の48.3%を占めています。

collisions()関数について正しかったようです。この関数が93.4%の時間を占めています。その大部分は、そのループ内でp5.Vectorとその.copy()メソッドを使用することに費やされているようです。もちろん、このツールが役立つためには、関数に適切な名前が付けられている必要があります。完全なドキュメントについては、パフォーマンス機能リファレンスページを参照してください。

利用可能な異なる選択肢について十分な知識を得たら、コードをどのように改善するかを決定する時です。

ステップ5 – 目標を振り返り、決定する

コードの最適化は、トレードオフを伴う継続的なプロセスです。何を達成しようとしているのかを慎重に考え、コードの開発は創造的なプロセスであることを忘れないでください。スケッチのコピーを作成して異なるアイデアを試し、どの解決策が最もニーズに合っているかを確認することができます。私のプロジェクトの場合、最良の解決策はシミュレーション内の粒子の数を減らすことかもしれません。

私たちが見てきたような物理シミュレーションを研究している科学者やゲーム開発者は、キャンバスをより小さな領域に分割し、同じ領域内の粒子間の衝突のみを検出するという代替戦略を開発しました。これは賢明な解決策ですが、私のコードの大幅なリファクタリングが必要になり、将来のチュートリアルの主題になるかもしれません。ゲームにおける「空間分割」についてオンラインで詳細情報を探してみてください。

プロジェクトの目標に最も適した決定を下し、そのプロセスでコーディングについてより多くを学ぶ機会を活用してください。

ステップ6 – 繰り返す

プログラミングは反復的なプロセスです。これらのステップを繰り返し行って、スケッチを開発し拡張し続けてください。ブログ投稿に取り組んでいた時、うまく機能するアニメーションを作成するまで、これらのステップを何度も繰り返しました。繰り返すことで、一度に1つの問題に焦点を当て、スケッチを段階的に改善することができました。例えば、アニメーションがスムーズに、適切な速度で実行されるように粒子の数を減らしました。

追加のパフォーマンス改善戦略

スケッチのパフォーマンスを向上させる方法は多数あります。以下は、さまざまなシナリオで試すことができる役立つヒントとテクニックです。

時間のかかるプロセスを前もって処理する

可能であれば、画像処理などの時間のかかるプロセスを前もって処理し、draw()内で毎フレーム実行するのではなく、一度だけ実行するのが最善です。draw()をできるだけ高速にするために、setup()中にできるだけ多くの処理を行ってください。preload()関数を追加して、他の関数が開始する前にファイルがプロジェクトに読み込まれるようにしてください。

フレンドリーエラーシステム(FES)を無効にする

p5.min.jsではなく非圧縮のp5.jsファイルを使用する場合、関数に予期しない引数が渡されたなどの問題に関する警告を提供するフレンドリーエラーシステム(FES)があります。FESはコードを大幅に遅くする可能性があり、場合によっては最大約10倍も遅くなることがあります!スケッチの先頭に1行のコードを追加することでFESを無効にすることができます:

p5.disableFriendlyErrors = true; // FESを無効にします

ネイティブJavaScriptを使用する

p5.jsメソッドの代わりにネイティブJavaScriptメソッドを使用することで、コードを高速化できます。例えば、p5.jsライブラリmin(5, 4, 3)の代わりに、Mathオブジェクトを使用してMath.min(5, 4, 3)を呼び出すことができます。

以下は、Chromeで各メソッドを10,000,000回実行した例です:

メソッド

時間 (ms)

ランダム

p5.js random()

283.88

ネイティブ Math.random()

190.01

最小値

p5.js min()

781.41

ネイティブ Math.min()

538.15

画像処理

画像内のピクセルをループ処理する際、単に画像のサイズを縮小するか、サンプリングすることで簡単にパフォーマンスを向上させることができます。1,000 x 1,000の画像で作業している場合、1,000,000ピクセルを反復処理しています。その画像を半分の500 x 500(250,000ピクセル)に縮小すると、以前の反復の1/4だけで済むようになります。

画像のリサイズとサンプリングについては、いくつかのオプションがあります:

  • PhotoshopやGIMPなどのアプリを使用して、インポート前に画像をリサイズします。これにより、リサイズアルゴリズムを制御し、画像をシャープにするフィルターを適用できるため、おそらく最高品質の画像が得られます。
  • p5.Imageresize()メソッドを使用して画像をリサイズします。ここでは、リサイズの処理方法についてほとんどブラウザに依存しています。詳細については、MDNの画像レンダリングに関する情報をチェックしてください。
  • 元の画像から2番目(または3番目、4番目など)のピクセルだけを描画することで画像をサンプリングします。非常にシンプルで効果的ですが、多くのピクセルをスキップすると、画像の細かい詳細が失われる可能性があります。
  • 実際には、これらはほぼ同じパフォーマンスを持っているようです。画像を事前にリサイズすることで、最終的な視覚効果を最も制御できます。リサイズできない場合は、スケッチ内でのサンプリングやリサイズが役立つかもしれません。
  • 最後に注意!画像を大幅にリサイズする場合や、重要な細かい詳細がある画像の場合、サンプリングやリサイズは非常に悪い結果をもたらす可能性があります。

次のステップ

参考文献