CircleCI API v2で自由自在に業務ワークフローのタスクを実行する

ごきげんよう、CI日和ですね。技術部の谷脇です。

先日Jenkinsでテストを実行したり、Slack Botからトリガーして実行していた業務ワークフローの必要なタスクをえいやっとCircleCIに持っていったのでその話をします。

長いので要約すると

  • ヘビーにJenkins使ってたのを全部CircleCIに持っていきました。自動テストとSlack Botからトリガーされるジョブです
  • CircleCI 2.1の機能をつかって設定を書きました。いい感じです
  • テスト以外でBotからキックするようなジョブはそのままだと難しいので、プレビューリリースのCircleCI API v2を使いました。必見です

自前運用のJenkinsさんお疲れ様でした

テストを継続的に動かす環境といえばJenkinsです。カヤックでも多くのプロジェクトでテストや、その他様々なタスクを実行する環境として利用されてきました。

ぼくらの甲子園!ポケットもそういった例に漏れず、Jenkinsをヘビーに使っていました。

ただ、自分で運用するJenkinsの面倒を見続けるのも以下のようなデメリットが生じる状況になってきました。

  • Jenkinsおじさん後継者問題
    • JenkinsはJava製、カヤックはPerl/Go/Ruby/C#...
    • 技術スタックが違うのでなにか起こったときに対処がし辛い
    • スレーブを使うなど複雑な設定にするとさらに把握が大変
  • Jenkins自体がセキュリティリスクになる問題
    • サービス側で気をつけていても、認証をしくじったりしてJenkinsに侵入されるケース
    • ソースコードやマスタデータを取られたりするリスク
    • Jenkins自体にも脆弱性が度々見つかっているのでアップデートをこまめに、つまりこれはサービス運用だ
  • フルマネージドサービスは勝手に強くなるが、Jenkinsは勝手に強くならない問題
    • 勝手に機能が増えるみたいなことにはならない。プラグインを入れると強くなる。だがプラグインもまとめて面倒見るのは大変
    • 一方フルマネージドは勝手に機能が増えて強くなる

また、数あるCIサービスの中でもCircleCIを選択しました。すでに他のプロジェクトで導入済みで、そのときに会社単位でPerformance Planも契約済みだったからです。導入するハードルがたまたま一番低かったからとも言えます。他の会社さんではCircleCIが最適だとは限りません。

他の理由としては、フルマネージドのCI環境を提供するサービスの中では、並列でCPU時間を大量に使うような重いテストに比較的最適なサービスかと思います。

というわけで、移行を以下の手順で行いました。

  1. テストの実行をJenkinsからCircleCIへ移植
  2. Jenkinsで従来行っていた業務タスク実行をCircleCIへ移植
  3. ちゃんと全部CircleCIで動いているのを確認してJenkinsを撤収

そんなわけで、現在の構成と、やっているタスクを挙げてみます。

f:id:mackee_w:20191024184015p:plain

図にある通り、Jenkinsが2つあります。1つはテスト用のJenkinsで、EC2スポットインスタンスで強いインスタンスを用いたものです。

もう1つは、プロジェクト内の開発サーバに立てたJenkinsです。主にSlack botをトリガーにしてタスクを実行しています。このJenkinsでは、

  • Googleスプレッドシートからマスタデータの取り込み
  • 開発環境へデプロイ
  • アセットデータの生成

などなど、業務ワークフローに必要なタスクが行われています。CircleCIとJenkinsどちらも管理するのも面倒なので、こういったタスク類もCircleCIにお引越しします。

CircleCIでテストを実行する

テストを実行するのはCircleCIのもともとの用途なので、そこまで変なことはしていません。もともとJenkinsで実行されていたテストは以下の仕様でした。

  • Amazon LinuxベースのDockerコンテナイメージ上で実行
  • テスト対象はPerlアプリケーション。go-proveで並列実行
  • コンテナ内にMySQLを並列数分立ててテストに利用
  • 64vCPUを利用できるインスタンス1台で80並列実行

CircleCIもDockerを利用するほうが便利なのと、もともと並列数もべた書きではなく、コア数に応じてスケールするようになっていたため、変更せずにそのまま利用します。

なお、カヤックはすでに別のプロジェクトで使うために、CircleCIのPerformance Planを使用しています。この移行では8vCPUのxlargeを使用します。

ですが、8vCPUでももともとの64vCPUには満たないので、circleci tests splitを使った並列実行をします。

テストの並列実行 - CircleCI

テストを実行するタスクを記述していきます。以下に登場するYAMLファイルはリポジトリの.circleci/config.ymlに書いていきます。

設定に使うCircleCIのバージョンは、2.1を使っています。

以下の例は一部を抜粋している形です。完全なYAMLの例が見たい方は、公式ドキュメントの例を参照するか、以下の記事を参考してください。

CircleCI 2.1 の新機能を使って冗長な config.yml をすっきりさせよう!

executor

テストの実行環境を設定します。CircleCI 2.1では実行環境をexecutorsという形で記述し、jobから設定を呼び出せるようになりました。

CircleCIを設定する - executors

references:
  test_reporters: &test_reporters /tmp/test-reports
  working_directory: &working_directory /home/circleci/project

executors:
  test_runner:
    resource_class: xlarge
    docker:
      - image: $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/circleci/project:latest
    environment:
      TEST_REPORTS: *test_reporters
    working_directory: *working_directory

以上の設定では、AWSで使えるDockerのプライベートレジストリである、Amazon ECRからDockerイメージをpullして使うようにしています。

*test_reportersおよび、*working_directoryはCircleCIの機能ではなくYAMLのアンカー/エイリアスの機能で定義しています。YAMLの他の場所からでも、同じ値を呼び出すためにこうしています。

Dockerイメージの置き先

テスト用のコンテナイメージのpullの話題が出たので、イメージの置き場所のついての工夫も述べます。

普段はap-northeast-1、つまり東京リージョンを使っているのですが、CircleCI上のイメージに関してはus-east-1を用いています。

ap-northeast-1に置いていたところ、us-east-1への転送料金がAWS側に発生していたのを抑えたいのでus-east-1に引っ越ししました。また、副次的な効果として、初期化時のイメージのpullも早くなりました。

以上のことから、ジョブは現在AWSのus-east-1で走っているようです。ただ、以下のポストにもあるように、リージョンは予告なく変更される可能性があります。

Where are the build servers located geographically?

commands

CircleCI 2.1ではcommandsという形で、jobで使う処理のまとまりに名前を付けれるようになりました。違うjobで一部だけ同じ処理をするときに使えます。

CircleCIを設定する - commands

commandsでは次の処理を定義しています。

プロジェクトリポジトリをcloneしたものをキャッシュからリストアする

CircleCIのキャッシュからリポジトリをリストアしています。

歴史の長いプロジェクトで、毎回GitHubからcloneを行うには時間を要してしまうためキャッシュに保存しています。キャッシュは過去に保存したもの中にブランチ名やコミットハッシュが同じものがあればそれが、なければキーに前方一致したものが使われます。

restore_repo:
  steps:
    - restore_cache:
        keys:
          - &repo_cache_key repo-v2-{{ .Branch }}-{{ .Revision }}
          - repo-v2-{{ .Branch }}-
          - repo-v2-

テスト対象のチェックアウト

かなりデカイリポジトリを必要な分だけ高速にcloneやcheckoutするために、いろいろごちゃごちゃ書いているので詳細には書きません。具体的には以下のようなことをしています。

  • clone済みでない場合
    • git initしてsparse checkoutの有効化
      • テストには使われなさそうなデカイファイルサイズのアセットが含まれるディレクトリを除外する目的で追加
      • そういったファイルのファイルサイズや命名規則を見るテストも含まれたため、有効活用はできず
    • git remote で originを設定
    • --depth 100でclone
      • デプロイ前にアセットのファイルを生成するスクリプトがある程度過去のcommit hashも参照するため
      • 上のテストを実際にやっているのでコケないためにある程度さかのぼったものも含めてcloneする
  • clone済みの場合
    • --depth 100git fetch
    • checkoutする
  • 最後に社内共通ライブラリをgit submodule update --initでcloneする
    • これのせいでcloneなどにdeployキーが使えないので、もともと使っていたBotアカウントをcloneに使用している
checkout_code:
  steps:
    run: <<省略>>

リポジトリキャッシュの保存

先述した「リポジトリキャッシュのリストア」に使われるキャッシュを保存するものです。ここでも同じエイリアスを使って、定義を共通にしています。

save_repo:
  steps:
    - save_cache:
        key: *repo_cache_key
        paths:
          - *working_directory

依存ライブラリのキャッシュ作成とリストア

CircleCIのキャッシュは本来こちらが想定されているようです。

Perlのプロジェクトなので、cpanfileを読んだ依存ライブラリのインストールと、保存をしています。{{ checksum <<filename>> }}で依存ライブラリの構成が変わったときにキャッシュを再作成するような設定をしています。

restore_cpan_local:
  steps:
    - restore_cache:
        keys:
          - &cpan_local_cache_key v1-project-cpanlocal-{{ checksum "cpanfile" }}
          - v1-project-cpanlocal-
install_cpanm:
  steps:
    - run:
        name: cpanm
        command: |
          export PATH=/home/circleci/bin:/home/circleci/local/perl/bin:$PATH
          cd /home/circleci/project
          carton install

テストの実行

テストを実行するスクリプト自体は、もともとDockerでテストを動かすために使われたシェルスクリプトが存在していたため、それを流用しています。なので、YAML上では以下の記述だけです。

run_test:
  steps:
    - run:
        command: |
          set -xu
          mkdir -p ${TEST_REPORTS}
          sh docker/circleci/app/docker_run.sh

このdocker_run.shには、テスト実行時の前準備などが書かれています。もともとJenkinsで使われていたスクリプトを流用したものです。テストを実行する部分は以下のようになっています。

export WORKER=`echo "$NCPU * 1.25" | bc | cut -d. -f1`
WRAPPER="ssmwrap -paths /dev -- carton exec --"
PLUGIN_OPT="-plugin harriet"
tests=$(circleci tests glob 't/**/*.t' | circleci tests split --split-by=timings)

$WRAPPER go-prove -j $WORKER -exec "$PERL" $PLUGIN_OPT -formatter junit $tests > ${TEST_REPORTS}/junit_01.xml

circleci tests splitで、テストジョブの分割を行っています。--split-by=timingsはテストの実行時間をもとにテストを分割します。

現在はxlargeで10コンテナ並列でテストを実行しています。circleci tests splitを使うと、コンテナごとにそれぞれ違うファイルが分配されて、分担してテストが実行されます。

jobs

以上に述べたexecutorscommandsをまとめてjobsに記述します。

jobs:
  app_test:
    executor:
      name: test_runner
    parallelism: 10
    steps:
      - restore_repo
      - checkout_code
      - restore_cpan_local
      - install_cpanm
      - run_test

これで、だいたいJenkinsのときとテスト実行時間と同じ程度の速度でテストを実行することが出来ました。

ただ気になる点として、全体のジョブの実行時間のうち、テスト実行よりもコンテナイメージのpullとリポジトリのキャッシュからの復旧のほうが時間がかかっています。このあたりをチューニングすればさらに早くなると思われます。

テスト以外を実行する

さて、これでテストの移植はできました。今度はそれ以外のジョブの実行です。

CircleCI 2.1を使っているので、上記のYAMLとは別にwokflowsも記述しています。

workflows:
  version: 2.1
  test:
    jobs:
      - app_test:
          name: "test for app"
          filters:
            branches:
              only:
                - release
                - master
                - /^feature\/.*/
                - /^fix\/.*/

これで、onlyに挙げられているブランチがpushされれば、テストジョブが起動するわけですが、従来の「マスタデータの取り込み」といったタスクはpushとは無関係に起動する必要があります。

Jenkinsの時代はSlack botが発言をトリガーにJenkinsのbuildWithParametersというエンドポイントを叩いていたわけですが、CircleCIでも相当のことが出来ないか検討してみます。

もちろん、CircleCIでもAPIでジョブをトリガーする方法はあります。以下のドキュメントを参照してください。

API を使用したジョブのトリガー

これで万事解決! といきたいところですが注意点があります。上記のドキュメントから引用します。

現在のところ、CircleCI 2.1 と Workflows を使用する場合には、単一のジョブをトリガーすることができません。

思いっきり条件に当てはまっています。APIドキュメントの方には、

Note Triggering a new job with a branch is not currently supported with configurations that specify version: 2.1.

とも書かれています。つまりこのAPIは使用できません。

さて、このドキュメントにはworkflowsを使うケースではこちらも使えるよという記述もあります。

Trigger a new Build by Project (preview)

ですがこのAPIにも難があります。

This endpoint does not yet support the build_parameters options that the job-triggering endpoint supports.

つまり特定のブランチがpushされたときにトリガーされるのと同じ挙動は実現できるものの、特別なジョブを実行したり、ジョブにパラメータをAPIから渡すことが出来ないと書かれています。これも要件に合いません。

詰んだか!? と思いましたが、ここでCircleCI API v2なるものを発見します。

CircleCI-Public/api-preview-docs

ドキュメントには記載されておらず、まだプレビューリリース段階のAPIです。予告なしで破壊的な変更されることもありうるのですが、ドキュメントを注意深く見ていくと、これは使えそうだとなりました。

POST /project/:project_slug/pipeline

CircleCI API v2では新たにpipeline parametersという概念が追加されています。CircleCI 2.1でも利用できたparametersですが、jobやcommands定義で使えるものの、workflowsでは使えませんでした。

CircleCI API v2では、APIリクエスト時にパラメータを指定できるようになってます。workflowsでは、APIで渡されたパラメータを埋め込むことができるようになりました。これがpipeline parametersです。pipeline parametersの仕様は別のリポジトリで公開されています。

CircleCI-Public/pipeline-preview-docs

pipeline parametersを使った例を挙げます。

version: 2.1

parameters:
  taskname:
    type: string
    default: ""

executors:
  task_runner:
    resource_class: xlarge
    docker:
      - image: << docker_image >>

jobs:
  do_task:
    executor:
      name: task_runner
    parameters:
      taskname:
        type: string
        default: ""
    steps:
      - run:
          command: make << parameters.taskname >>

workflows:
  version: 2.1
  run_task:
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>

CircleCI 2.1のparametersも使っているのでわかりにくいですが、トップレベルのparametersで定義したものと、workflows<< pipeline.parameters.taskname >>と呼び出しているのが、pipeline parametersです。

pipeline parametersを埋め込んで、APIでトリガーさせてみます。

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "taskname": "great_awesome_task"
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

すると<< pipeline.parameters.taskname >>great_awesome_taskに置き換わって実行されます。

ちなみにカヤックでは上記の例のmakeを使っている部分を、Daikuに置き換えて使用しています。リポジトリに置かれたDaikufileにPerlのコードでタスクを増やすと、CircleCIのconfigをいじらずにプロジェクト固有のタスクを実行できます。Rubyのrakeや、Node.jsのnpm runでも同じことが出来るかと思います。

走るworkflowを制御する

上記のYAMLに加えてテストのジョブもworkflowに設定している場合に問題が起こります。pushしてテストが走ると同時に、任意のタスク実行のためにmakeが走ってしまうのです。

業務ワークフローに必要なタスクをbotなどからトリガーする場合、タスク実行に使うブランチを限定する場合は容易に回避できます。workflowの設定に、filtersでブランチを列挙すれば、無駄にタスクが走るのを回避できます。

workflows
  version: 2.1
  test:
    jobs:
      - app_test:
          filters:
            branches:
              only:
                - master
                - develop
                - /^feature\/.*/
                - /^fix\/.*/
                - /^hotfix\/.*/
  run_task:
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>
          filters:
            branches:
              only:
                - /workflow.*/

この例ではworkflowで始まるブランチが指定されたときのみ、ワークフロー用の業務タスクを実行します。

ですが、私のプロジェクトでは特定のブランチの情報で実行してほしいケースがあるため、上記の方法ではなく別の方法で走るworkflowを制御しています。

これもpreviewのドキュメント中に記載がある、Conditional Workflowsです。

Conditional Workflows

要約すると、先述したpipeline parametersでworkflowを実行するかどうかを決定するというものです。実際に使ってみましょう。

parameters:
  taskname:
    type: string
    default: ""
  kick_task:
    type: boolean
    default: false

workflows:
  version: 2.1
  test:
    unless: << pipeline.parameters.kick_task >>
    jobs:
      - app_test:
          filters:
            branches:
              only:
                - master
                - develop
                - /^feature\/.*/
                - /^fix\/.*/
                - /^hotfix\/.*/
  run_task:
    when: << pipeline.parameters.kick_task >>
    jobs:
      - do_task:
          taskname: << pipeline.parameters.taskname >>

kick_taskをtrueにして設定するかどうかで、run_taskが実行されるか、testが実行されるかを区別できます。例えば、

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "kick_task": true,
    "taskname": "great_awesome_task"
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

とすれば、run_taskが起動し、

curl -u ${CIRCLECI_TOKEN}: -X POST --header "Content-Type: application/json" -d '{
  "branch": "special_nice_branch",
  "parameters": {
    "kick_task": false,
  }
}' https://circleci.com/api/v2/project/gh/kayac/something-repository/pipeline

とすれば通常のテストが起動します。

このように、Slack botから業務ワークフローのタスクを起動する際に、pipeline parametersをつけることで、テストのときと処理を分けています。

まとめ

他にも、違うブランチ(コミットハッシュ)だが、テスト済みのブランチとコード部分の内容が一緒だからテストをスキップする、などなど様々なことをやっていますが、長くなってきたのでこのへんで。

今後は、柔軟にプロジェクト依存のタスクが書けるのを生かして、本番環境への自動デプロイなどにチャレンジして見ようと思います。

最後にもう一度注意です。CircleCI API v2はまだプレビューリリースの段階で、この記事を書いた時点(2019年10月)ではこの情報で使えていますが、予告なしの破壊的変更が入って、そのままでは使えなくなったりする可能性があります。

なので、CircleCI上でしか実行できない、環境が作れないようなタスクを実行するのに使うのは避けましょう。壊れたときに泣きを見ます。

以上、CircleCIバリバリ使っているよ情報でした。

物理シミュレーションで紙吹雪エフェクトを作ってみた

画面をクリックするとwebGLサンプルに飛びます。動画もあります。

こんにちは。技術部平山です。

最近たまたま紙吹雪っぽいエフェクトを作る機会があったので、これを、 「これまで書いてきた基本的な技法を組み合わせる応用例」という位置付けで紹介しようと思います。

コードはgithubに置いておきました。 また、ちょろっと入れて試したい方のために、パッケージも用意してあります

使い方

パッケージをつっこんで、中にあるConfettiというプレハブをどこかに置けば、何か絵が出ます。 デフォルト設定だと、だいたい100くらい離れた所から見ないとよくわからないので、 カメラからの距離には注意してください。

所詮サンプルなので、それほどのカスタマイズ性はありません。 パラメータの変更で済まない場合は、改造するなり自作するなりしてください。 自作の方がおすすめです。それなりにいろんな知識を試されると思います。

作るために必要な知識

使う知識の分野はだいたい以下の感じです。

  • 3Dベクトル演算
  • 質点物理(高校でやる奴)
  • クォータニオン少し(積分含む)
  • スキニングを用いたインスタンシング的描画

過去の記事で関連するものとしては、

あたりがあります。これらの記事の内容を理解していないといけない、 というわけではありませんが、かなり深くつながったお話にはなります。

一枚の紙切れから

まず、一枚の紙切れの挙動を作る所から始めましょう。 それができてしまえば、あとはたくさん出すだけです。 今回は「紙切れと紙切れはぶつからないし影響し合わない」 としているので、紙切れが増えた時に問題になるのは処理速度だけです。 しかし処理速度についても、今回は「すぐできる範囲」でだけ手を打ち、 手間がかかる所(逆に言えば面白いところ)は、また今度にとっておきます。

質点物理

物理シミュレーションの中で一番簡単なのは、 質点のシミュレーションです。

大きさがないので、回転を考える必要がありません。

大きさがないので、形という概念がそもそも必要ありません。

Unityに入っている剛体(rigidbody)物理 に比べれは、遥かに簡単に処理することができます。 質点でできることに剛体を使うのは無駄ですから、まずは質点でできないかを考えるべきなのです。

運動方程式の実装

さて、質点の物理は簡単に言えば以下のコードで書けます。

accel = force / mass;
velocity += accel * dt;
position += velocity * dt;

これは運動方程式をコードにしたもので、 力を質量で割って加速度を出し(1行目)、加速度を積分して速度を出し(2行目)、 速度を積分して位置を出します(3行目)。

積分にはいくつか手法がありますが、上のコードは最も簡単で使い勝手がいい 半陰的オイラー法(英語)です。 2行目と3行目を逆にすると、基本のオイラー法になりますが、 実用にはなりません。 速度を先に更新して、それをすぐ位置の更新に使う、という半陰的オイラーの方が 圧倒的に使えます。

質量の省略

今回の応用では、質量を1に固定することにました。 力を質量で割る必要がなくなって楽だからです。 また、個別に質量を持つ必要がなくなってデータ量が減ります。 これによって上のコードの1行目が不要になり、いきなり加速度が計算できます(概念的には1で割っていますが)。

velocity += accel * dt;
position += velocity * dt;

かかる力

では紙切れにはどんな力がかかるでしょうか。今回は二つの力をかけています。

  • 空気抵抗
  • 重力

しかし、質点の状態では空気抵抗はかけようがありません。大きさがゼロなので、 空気抵抗はゼロのはずです。が、ウソをついて入れます。 速度に比例した強さでブレーキがかかるとしましょう。

accel = gravity + (velocity * -resistance);

こうですね。重力は定数で良いでしょう(地球からの距離が大きく変われば変化しますが、紙吹雪ですからね)。 例えば(0f, -9.81f, 0f)みたいな感じのベクトルになります。 今回は好きな方向に落とせるように、Unityの設定を取らずに、 自分で与えられるようにしています。Z方向に落ちたってかまいません。

空気抵抗は、速度ベクトルvelocityに、抵抗の強さresistanceを掛けて力にします。 この時にマイナスをつけるのは、「速度の逆向き」の力が発生するからです。

拡張の選択肢二つ

さて、とりあえず単純な質点物理でできることはここまでなんですが、 はっきり言ってこの段階では紙切れには見えません。 一切回転せずに、放物線を描いで落ちるだけです。 これに何を足したら、紙切れっぽくなるの?ということを考える必要があります。

ここで望む動きを得るために行う拡張には、選択肢が二つあります。

  • 剛体物理に切り換える
  • 質点物理のままどうにかする

剛体物理は回転を扱えますから、剛体物理にすれば100%解決します。 問題は、理屈が面倒くさくて計算も重いことです。 UnityのRigidbodyを使えば実装そのものはしないで済みますが、 最終的に千枚とか1万枚とか出したいと考えると、 ridigbodyコンポーネントをつけたgameObjectをその数置くのは ちょっと辛いでしょう。

そこで今回はもう一つの道を行きます。 質点物理のままでどうにかしましょう。 質点物理を拡張する場合の常道は、「質点を増やすこと」です。 2個あれば結んで線が表現でき、3個あれば三角形が表現できます。 多数をつなげば立体だって表現できます。

まずは一番簡単な2個で行きましょう。

二個の質点

とりあえず、位置と速度を2個用意して、バラバラに積分します。

accel0 = gravity + (velocity0 * -resistance);
accel1 = gravity + (velocity1  * -resistance);
velocity0 += accel0 * dt;
velocity1 += accel1 * dt;
position0 += velocity0 * dt;
position1 += velocity1 * dt;

これけだけでは、単にバラバラに点があるだけです。 望む結果を得るには二つのことをやる必要があります。

  • 2つの点の間の関係を保つ
  • 2つの点でかかる力が変わるようにする。

2点が何の関係もなくバラバラに動いたら大変ですよね。 1毎の紙切れの中の2点ですから、何らかの関係が必要です。

そして、2点にかかる力、ひいては加速度が違ってこないと、両方が同じ動きをするだけなので 2点にした意味がありません。片方は早く下に落ちるが、片方はなかなか落ちない、 みたいなことが起こるように細工する必要があります。

拘束関係

まずは関係を作ります。

2点は一つの紙切れの中のとある代表点です。 例えば左端と右端、みたいな感じでしょう。 今紙切れが変形しないのであれば「2つの質点の距離は不変」です。 これを強制するために、位置は2つ持たず1個だけにし、 点0から点1へ向かうベクトルと、点の距離をフィールドに持っておきます。 ベクトルは前方向を表すということでforward、距離は紙切れの長さということでlengthとしておきましょう。 すると、更新処理はこうなります。

// 二点を中心位置から生成
var position0 = position - (forward * (length * 0.5f));
var position1 = position + (forward * (length * 0.5f));
// 積分
accel0 = gravity + (velocity0 * -resistance);
accel1 = gravity + (velocity1  * -resistance);
velocity0 += accel0 * dt;
velocity1 += accel1 * dt;
position0 += velocity0 * dt;
position1 += velocity1 * dt;
// 二点の中心を位置として保存
position = (position0 + position1) * 0.5f;
// forwardは2点の差で更新
forward = (position1 - position0).normalized;

フィールドはpositionだけになったので、毎フレームpositionとforward、lengthから 2点を生成します。position0とposition1はローカル変数なのでvarです。

f:id:hirasho0:20191009110233p:plain

そして、積分が終わった後で2点の平均をpositionに保存し、 今の位置からforwardを再計算します。

速度がウソついてるのを直す(Verlet(ベレ)法)

よく見てみると、現状速度がウソをついています。 バラバラに積分していれば問題ないのですが、2点の距離を保つ計算をするために 無理矢理位置を動かしてしまったので、速度が合いません。

例えば、x=0から速度5で1秒進めばx=5に着きますが、 ここで無理矢理xを4にされてしまったらどうでしょう。 速度には5と書いてありますが、実際には4しか進んでいないのです。

この状態は不安なので(放っておいてもいいのかもしれず確証はない)、解消しておきましょう。 簡単な方法は、積分をVerlet(ベレ)法 に変えることです。

簡単に言えば、速度を覚えておくのをやめます。 その代わり、前の位置を追加で覚えておくようにします。積分は、

var newPosition = position + (position - prevPosition) + (accel * (dt * dt * 0.5f));
prevPosition = position;
position = newPosition;

となります。(position - prevPosition)は、1フレームに動いた量なので、 今までの(velocity * dt)にだいたい相当します。

こうすると、速度を覚えておかないので、位置を無理矢理いじってもウソの状態になりません。 Verlet法の利点は他にも安定性や精度などあるのですが、 Verlet法の利点を最大に活かすためには「フレームとフレームの間の時間が一定」「速度が力に影響しない」 という条件が必要で、今はどちらも満たされません。 フレームの間の時間が変わる場合でも精度を上げたい場合には、 A Simple Time-Corrected Verlet Integration Method に良い解説があります(やったことないですけどね!)。

空気抵抗を工夫する

さて、次は2点にかかる力が変わるようにしましょう。 2点で位置がずれれば、それによって回転運動が発生します。

今、紙きれは曲がっているものとします。 そして、空気抵抗は紙切れの法線方向にかかるものとします。

f:id:hirasho0:20191009110230p:plain

2点で法線が異なれば、かかる空気抵抗も違ってくるはずです。

f:id:hirasho0:20191009110224p:plain

速度ベクトルをそれぞれの法線に射影したものがそれぞれかかる力となり、 図ではそれがF0とF1です。さっきVerletにしてしまったので速度ベクトルはありませんが、 近似しちゃいましょう。

velocity0 = (position0 - prevPositon0) / dt;

で良しとします。dtは毎回変わるので、今回のdtで割っちゃうのはウソなんですが、 気にないことにしました。安定して動いてればそんなに変わりませんからね。

あとは法線がnだと仮定すると、

var dot = Vector3.Dot(n, -velocity);
var accel = n * (dot * resistance);

と加速度が計算できます。内積が負、つまり射影した時に 法線と逆を向く場合もそのままでかまいません。 これは裏から空気がぶつかった、ということで概念的には 法線をひっくり返してから内積を計算することになるわけですが、 結果は同じになります(先にnを反転させればdotも反転し、掛ければ同じになる)。

法線を計算する、ためにクォータニオン

あとは法線です。とはいえ、今の状態だと、点が2個の1次元世界なので、 「上ってどっち?」という状態です。そこで、「この紙切れにとっての上」を 定義してやる必要があります。 そのための道具がクォータニオンです。

クォータニオンである変数rotationが姿勢を持っているとすれば、 先程用意したforward、つまり前方向ベクトルは、

var forward = rotation * new Vector3(0f, 0f, 1f);

で出てきます。もうforwardを持つ必要はありませんね。 上方向、右方向はそれぞれ、

var up = rotation * new Vector3(0f, 1f, 0f);
var right = rotation * new Vector3(1f, 0f, 0f);

です。紙がXZ平面で、法線がy軸であるとすれば、法線はこのupです。 これを2点で別々に修正して、紙が曲がっていることを表現します。 例えば、点0の法線はforwardの0.1倍を足し、 点1の法線はfowardの0.1倍を引く、というようにすれば、法線が2点で違ったものになります。

var up = rotation * new Vector3(0f, 1f, 0f);
var n0 = (up + (forward * 0.1f).normalized;
var n1 = (up - (forward * 0.1f).normalized;

2点それぞれの法線が生成できました。この0.1fは調整パラメータで、 normalBendRatioとして用意してあります。大きいほど曲がりが大きい、 ということですね。

姿勢更新

最後に、姿勢クォータニオンを更新します。 それには、forwardが計算前と後でどう変わったかを見て、 そこから回転クォータニオンを生成し、これを今の姿勢に掛け算して回します。

f:id:hirasho0:20191009110235p:plain

var newForward = (p1 - p0).normalized;
// forward -> newForwardに向くような、回転軸右ベクタの回転を作用させる
var dq = Quaternion.FromToRotation(forward, newForward);
rotation = dq * orientation; // dqは時間的に後の回転だから、ベクタから遠い方、つまり前から乗算
rotation.Normalize();

今回転は1軸でしか起こらない(左右軸でしか回らない)ので、 forwardの変化具合を見れば十分です。

これどういうふうに動くの?

だいたいこんな動きをします。

f:id:hirasho0:20191009110227p:plain

紙切れの法線が垂直(地面に置いてある時のような感じ)に近いほど、 空気抵抗が大きくなるので減速します。 紙切れが立った状態になると、空気抵抗が小さいので重力でどんどん加速します。

しかし、2点で法線が違うため、縦に立った状態であっても、 2点でかかる力が異なり、落ちる速度に差が出ます。その結果紙が回転し、 立った状態からだんだん寝た状態になってくるわけです。

結果、ヒラヒラした感じで落ちてくることになります。 重力と空気抵抗のかねあい次第では、一時的に上に舞い上がったりもするので、 結構面白いです。

風について

今回の実装では風の強さも指定できます。

風の強さだけ空気抵抗の計算時に速度を補正し、 「空気に対しての相対速度」で空気抵抗を計算しています。 紙切れごとに風を少しづつ変えると、 もっとランダム感が出ていいかもしれませんが、 それをやりたければ、質量を導入してバラつかせた方が 安いかもしれません。

描画について

描画は、「紙切れごとにMeshRendererがついたGameObjectを用意する」 というようなことをやるとさすがに重いので、 今回最適化をしてしまいました。

といっても簡単で、以前紹介した「スキニングを使ったインスタンシングもどき」)を使うだけです。 SkinnedInstancingRenderer なるクラスをその記事の時に作ったので、これを使います。

そうすれば、それぞれの紙切れにGameObjectやTransformを持たせる必要がありません。 位置とQuaternionはありますから、 ここから行列を作って、Mesh.bindposes に毎フレーム詰めればそれで描画できるわけです。

行列を作るコードはこんな感じです。

public void GetTransform(
    ref Matrix4x4 matrix,
    float ZSize, // 紙の長さ
    float XSize) // 紙の幅
{
    matrix.SetTRS(position, orientation, new Vector3(XSize, 1f, ZSize));
}

Matrix4x4.SetTRS に位置、姿勢クォータニオン、スケールを与えればいいだけで簡単です。

色について

今回は色が7種類ありますが、それぞれ別のマテリアルになると速度が 遅くなってしまいます。そこで、テクスチャのアトラス化を行いました。

8x1の小さなテクスチャに8個の色を入れておいて、 それぞれの紙切れごとに違うUVを指定することで、色を換えています。

f:id:hirasho0:20191009110241p:plain

アトラス、という感じではないですが、これも立派なアトラスですよね。

例えば青であれば、Uに(3/8)+(0.5/8)=0.4375を入れ、Vはなんでもいいので0とします。 (0.5/8)は画素一個の幅の半分で、これを足すことで画素の真ん中の座標を得られます。

おわりに

どうでしょう。 ベクトル、クォータニオン、運動方程式、といった道具が使えると、 やれることが広がるので、個人的にはおすすめです。 その上でGPUの機能をうまく利用して最適化を行うと、 結構派手なことができるようになります。

もちろんUnity時代ですから、数学や最適化などの地味なことはUnityに任せて、 自分は面白いことに集中する、というのも良いでしょう。 「こんなもんShurikenで作れよ」って話です。 ただ、自分でも多少やっておくことで、「こういう知識がある奴は、この手のものが作れる」 あるいは「この手のものを作りたいと言っているのに、こういう知識がない奴は地雷」 というような判断ができるようになります。全く役に立たないということはない、と私は信じています。

なお、今回の「質点2つでヒラヒラさせる手法」を考えたのは、もう17年も前のことです。 当時参加していた製品に羽を舞い散らす演出があったのですが、 動きに物理感がなくて当時の私はあまり気に入らなかったので、 勝手に変えてしまったのでした。 新卒で会社に入って半年も経っていない頃だったせいか、 今から考えても意味のわからない情熱を持っていた気がします。 そのステージの空気の薄さや重力によってパラメータを変える、という無駄なこだわりがありました。

さて、今回扱わなかった要素に「物理計算の高速化」があります。 紙切れの挙動はそれなりに複雑で、ベクトルの正規化や、クォータニオンとベクトルの積のような重い処理が 結構な回数入っていますから、結構重いのです。 私のPC(MacBook Pro 2018)だと、フレームあたり5000枚くらいが限度です。 最近使った製品では、スマホの性能を鑑みて500枚以内に抑えました。

でも、もっと速くして、もっとたくさん出したいですよね?

最近Unity関係で流行りのDOTS(Data Oriented Technology stack) で提唱されているような手法を使えば、CPUの余っているコアを使ってもっと高速に計算できるはずです。 実はまだやっていないので、どれくらい速くできるか私も楽しみでおります。