虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

2020 年も終わりなので SVG で花火を打ち上げたい

この記事は、虎の穴ラボ Advent Calendar 2020の22個目の記事です。
昨日は、ReactについてNSSさんが、明日はPony言語についてY.Fさんの記事が投稿されます。ぜひこちらもご覧ください。

qiita.com

みなさんこんにちは。とらラボのおっくんです。

今回は、とある npm パッケージを利用して、SVG で花火を打ち上げたいと思います。

作成されたものを見ていただくのが、話が早いです。 こちらです。

f:id:toranoana-lab:20201215101107g:plain

それでは順を追ってこちらの作成方法について解説します。

はじめに

おさらい -SVG のアニメーション-

SVG でアニメーションを実現するためには、<animate>を使います。 簡単な例を示すと、以下のようになります。

[sample1.svg]

<svg width="300" height="300">
    <!--背景-->
    <rect x="0" y="0" width="300" height="300" fill="#000" />

    <!--動く赤い箱-->
    <rect x="0" y="0" width="10" height="10" fill="#F00">
        <!--x,yの値の変化をanimateで定義-->
        <animate attributeName="x" values="0;290;0" dur="5s" repeatCount="indefinite" />
        <animate attributeName="y" values="0;290;0" dur="5s" repeatCount="indefinite" />
    </rect>
</svg>

こちらを実際に表示させると次のようになります。

5 秒間に、赤い箱の x,y 座標それぞれが 0 -> 290 -> 0 に変化しています。 このことにより、箱は左上と右下を往復するアニメーションになります。

SVG で複雑なアニメーションをする辛さ

先に示したように、SVG 要素のプロパティの変化を<animate>に記述することでアニメーションを実現できますが、複雑なアニメーションを作ろうと考えると辛い点がいくつかあります。

  • 多数の要素を連動させる時、個別にそれぞれの SVG 要素のプロパティの変化を記述しないといけない。 例えば、<rect>を 4 つ動かす場合、最低 4 つの<animate>も個別に与える必要がある。
  • 複雑な動作を文字列でべた書きする必要がある。 直線の往復であれば、開始座標=>終点座標=>開始座標の記述で十分ですが、「徐々に加速」や「どこかを経由してから終点座標に到着する」など複雑な動作をさせる場合、すべての座標を記述する必要がある

何にせよ、少し複雑なことをしようとすると記述量が膨大になってしまいます。

PSVG 導入による解決

前述した辛いポイントを解決できる npm パッケージがあります。 @lingdong/psvgです。

www.npmjs.com

github はこちら

github.com

PSVG は、変数や計算式・制御フローなどのプログラミング機能を埋め込んで記述できる SVG の拡張です。
コンパイルすることでピュアな SVG に変換できます。

詳細は、github の DOC や Examples を見ていただくのが良いのですが、簡単な使用例を示します。

sample2.psvg を以下のように作成します。 (実は拡張子を svg としてもいいのですが、変換前であることを示すには psvg としておいた方がわかりやすいです。)

[sample2.psvg]

<psvg width="300" height="300">

  <!--円運動を行う<animate>の要素二つを返す関数-->
  <def-circular_motion split="4" dur="1" center_point_x="100" center_point_y="100" r="10">
    <var angle="{ 2 * PI / split }" />
    <var points_x="" />
    <var points_y="" />

    <for i="0" true="{ i <= split }" step="1">
      <var tmp_x="{ r * COS(angle * i) + center_point_x }" />
      <var tmp_y="{ r * SIN(angle * i) + center_point_y }" />

      <asgn points_x="{ CAT(points_x, tmp_x, ';') }" />
      <asgn points_y="{ CAT(points_y, tmp_y, ';') }" />
    </for>

    <animate attributeName="cx" values="{points_x}" dur="{dur}s" repeatCount="indefinite" />
    <animate attributeName="cy" values="{points_y}" dur="{dur}s" repeatCount="indefinite" />
  </def-circular_motion>

  <rect x="0" y="0" width="{ WIDTH }" height="{ HEIGHT }" fill="#000" />

  <!--円運動する円の定義-->
  <circle r="10" fill="rgb(255,255,255)">
    <circular_motion split="20" dur="3" center_point_x="{ WIDTH/2 }" center_point_y="{ HEIGHT/2 }" r="100" />
  </circle>

</psvg>

用意ができたら、以下のコマンドで変換します。

$ npx psvg sample2.psvg > sample2.svg

実行すると計算結果が反映された、SVG が sample2.svg に書き出されています。

書き出し結果は以下の通りです。(見易いようにフォーマットを施しています。)

[sample2.svg]

<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300">
    <rect x="0" y="0" width="300" height="300" fill="#000" />
    <circle r="10" fill="rgb(255,255,255)">
        <animate attributeName="cx"
            values="250 ; 245.10565162951536 ; 230.90169943749476 ; 208.77852522924732 ; 180.90169943749476 ; 150 ; 119.09830056250527 ; 91.2214747707527 ; 69.09830056250527 ; 54.89434837048465 ; 50 ; 54.894348370484636 ; 69.09830056250526 ; 91.22147477075268 ; 119.09830056250524 ; 149.99999999999997 ; 180.90169943749473 ; 208.7785252292473 ; 230.90169943749473 ; 245.10565162951536 ; 250 ;"
            dur="3s" repeatCount="indefinite" />
        <animate attributeName="cy"
            values="150 ; 180.90169943749473 ; 208.77852522924732 ; 230.90169943749476 ; 245.10565162951536 ; 250 ; 245.10565162951536 ; 230.90169943749476 ; 208.77852522924732 ; 180.90169943749476 ; 150 ; 119.09830056250527 ; 91.2214747707527 ; 69.09830056250527 ; 54.89434837048465 ; 50 ; 54.894348370484636 ; 69.09830056250524 ; 91.22147477075266 ; 119.09830056250524 ; 149.99999999999997 ;"
            dur="3s" repeatCount="indefinite" />
    </circle>
</svg>

表示すると次のようになります。

円運動のような、直線的な動作ではない多数の記述を要するパラメータをベタ書きすることなく、記述できました。

本題 SVG での花火の打ち上げ方

イメージ固め

記憶の中の打ち上げ花火を思い返しておきましょう。
または、Youtube などで打ち上げ花火大会の動画などを見てイメージを固めましょう。

打ち上げ花火の分解

細かい動作を記述していくに辺り、打ち上げ花火の構成要素を考えてみました。

  • 打ちあがる花火玉
  • 炎色反応による色の付いた火の粉
  • 重力による数多の火の粉の落下

一度にたくさん打ちあがる風景や花火の広がった時の模様を意識する方もいるかと思います。 このあたりの分解は、個人の打ち上げ花火のイメージに左右されると思うので、ぜひご自身の花火を実現してください。

実装

検討した花火の構成要素を元に、fireart.psvgファイルを作成し実装を行います。
実装は以下のとおりです。

[fireart.psvg]

<psvg width="600" height="1000">
    <var g="9.8" />
    <var animation_end_time="20" />

    <!--打ち上げた花火玉の座標点と大きさの変化を記述したanimateを返す関数-->
    <def-shot start_time="0" end_time="10" base_point_x="0" start_point_y="0" end_point_y="100" r="1">
        <var tmp_pos_x="{ base_point_x }" />
        <var points_x="{ CAT(base_point_x, ';') }" />

        <var tmp_pos_y="{ start_point_y }" />
        <var points_y="{ CAT(start_point_y, ';') }" />
        <var sp="{ -(end_point_y - start_point_y) / (end_time - start_time)}" />

        <var tmp_r="{ r }" />
        <var arr_r="{ CAT( r, ';') }" />
        <var diff_r="{ r / (end_time - start_time)}" />

        <for i="0" true="{ i <= end_time }" step="1">
            <if true="{ i > start_time}">
              <asgn tmp_pos_y="{ tmp_pos_y - sp }" />
              <asgn tmp_r="{ tmp_r - diff_r }" />
            </if>
            <asgn points_x="{ CAT(points_x, (tmp_pos_x + (RANDOM()-0.5) * tmp_r*10), ';') }" />
            <asgn points_y="{ CAT(points_y, tmp_pos_y, ';') }" />
            <asgn arr_r="{ CAT(arr_r, tmp_r, ';') }" />
        </for>
        <for i="{end_time}" true="{ i <= animation_end_time }" step="1">
            <asgn points_x="{ CAT(points_x, (WIDTH + 100), ';') }" />
            <asgn points_y="{ CAT(points_y, (HEIGHT + 100), ';') }" />
            <asgn arr_r="{ CAT(arr_r, tmp_r, ';') }" />
        </for>
        <animate attributeName="cx" values="{points_x}" dur="{animation_end_time}s" repeatCount="indefinite" />
        <animate attributeName="cy" values="{points_y}" dur="{animation_end_time}s" repeatCount="indefinite" />
        <animate attributeName="r" values="{arr_r}" dur="{animation_end_time}s" repeatCount="indefinite" />
    </def-shot>

    <!--花火の火の粉一つ一つのを表す多数のcircleを返す関数-->
    <def-fireart start_time="0" end_time="10" point_x="0" point_y="0" split="100" r="1">
      <var angle="{ 2 * PI / split }"/>

      <for i="0" true="{ i < split }" step="1">
        <var acc="{ 120 * RANDOM() }" />
        <var tmp_pos_x="{ point_x }" />
        <var points_x="{ CAT(point_x, ';') }" />

        <var tmp_pos_y="{ point_y }" />
        <var points_y="{ CAT(point_y, ';') }" />

        <var tmp_r="{ 0 }" />
        <var arr_r="{ CAT( 0, ';') }" />
        <var end_r="{ start_time / 2 + (end_time - start_time / 2 ) * RANDOM() / 2}" />

        <var acc_x="{ acc * COS(angle * i) }"/>
        <var acc_y="{ acc * SIN(angle * i) }"/>
        <var sp_x="0"/>
        <var sp_y="0"/>


        <for i="0" true="{ i < animation_end_time }" step="1">
          <if>
            <cond true="{ i < start_time }">
              <asgn points_x="{ CAT(points_x,  point_x, ';') }" />
              <asgn points_y="{ CAT(points_y, point_y, ';') }" />
              <asgn arr_r="{ CAT(arr_r, 0, ';') }" />
            </cond>
            <cond true="{ start_time == i }">
              <asgn sp_x="{ acc_x }" />
              <asgn sp_y="{ acc_y }" />

              <asgn tmp_pos_x="{ tmp_pos_x + sp_x }" />
              <asgn tmp_pos_y="{ tmp_pos_y + sp_y }" />
              <asgn tmp_r="{ RANDOM() * 3 }" />

              <asgn points_x="{ CAT(points_x, tmp_pos_x, ';') }" />
              <asgn points_y="{ CAT(points_y, tmp_pos_y, ';') }" />
              <asgn arr_r="{ CAT(arr_r, tmp_r, ';') }" />
            </cond>
            <cond true="{ start_time < i && i <= end_time}">
              <asgn sp_y="{ sp_y + g * 10 }" />

              <asgn tmp_pos_x="{ tmp_pos_x + sp_x }" />
              <asgn tmp_pos_y="{ tmp_pos_y + sp_y }" />
              <asgn tmp_r="{ RANDOM() * 2 }" />
              <if true="{end_r < i}">
                <asgn tmp_r="0"/>
              </if>

              <asgn points_x="{ CAT(points_x, tmp_pos_x, ';') }" />
              <asgn points_y="{ CAT(points_y, tmp_pos_y, ';') }" />
              <asgn arr_r="{ CAT(arr_r, tmp_r, ';') }" />
            </cond>
            <cond true="{ end_time < animation_end_time }">
              <asgn points_x="{ CAT(points_x,  point_x, ';') }" />
              <asgn points_y="{ CAT(points_y, point_y, ';') }" />
              <asgn arr_r="{ CAT(arr_r, 0, ';') }" />
            </cond>
          </if>

        </for>

        <!--放射円状に色を変えるため、加速を表すaccを色情報の計算で使用する-->
        <circle fill="rgb(255,255,255)" fill="rgb({ acc + 120 }, { RANDOM() * 50 + 100 }, 0)">
          <animate attributeName="cx" values="{points_x}" dur="{animation_end_time}s" repeatCount="indefinite" />
          <animate attributeName="cy" values="{points_y}" dur="{animation_end_time}s" repeatCount="indefinite" />
          <animate attributeName="r"  values="{arr_r}" dur="{animation_end_time}s" repeatCount="indefinite" />
        </circle>
      </for >
    </def-fireart>

    <rect x="0" y="0" width="{ WIDTH }" height="{ HEIGHT }" fill="#000" />

    <!--1発目-->
    <circle cx="{ WIDTH/2 }" cy="0" fill="rgb(255,255,255)">
        <shot start_time="0" end_time="4" base_point_x="{ WIDTH/2 }" start_point_y="1010" end_point_y="300" r="10" />
    </circle>
    <fireart start_time="5" end_time="13" point_x="{ WIDTH/2 }" point_y="200" split="1000" r="5" />

    <!--2発目-->
    <circle cx="{ WIDTH/2 }" cy="0" fill="rgb(255,255,255)">
        <shot start_time="3" end_time="7" base_point_x="{ WIDTH/3 }" start_point_y="1010" end_point_y="400" r="10" />
    </circle>
    <fireart start_time="8" end_time="18" point_x="{ WIDTH/3 }" point_y="300" split="1000" r="5" />

    <!--3発目-->
    <circle cx="{ WIDTH/2 }" cy="0" fill="rgb(255,255,255)">
        <shot start_time="5" end_time="10" base_point_x="{ WIDTH/3*2 }" start_point_y="1010" end_point_y="450" r="10" />
    </circle>
    <fireart start_time="10" end_time="20" point_x="{ WIDTH/3*2 }" point_y="350" split="1000" r="5" />

</psvg>

こちらを、変換し出力した SVG が冒頭でも紹介したこちらです。

f:id:toranoana-lab:20201215101107g:plain

降り注ぐ火の粉の雰囲気はよく表現できたかなと思っています。

実は、はてなブログで投稿・公開するにあたり、文字数制限に引っかかってしまったので、上記の画像は撮影・変換したgifになっています。 これだけの数の火の粉を表示パラメーターの変更をした結果として出力されたSVGファイルの容量が5.6MBになってしまいました。 「軽量できれいなアニメーションを作れる」という利点を完全に逸脱してしまったというのが今回のオチになります。
「動きを間引く」、「座標計算結果はすべて整数にして出力にする」などの対策で、容量自体の削減は見込めると考えています。

今回は、PSVG を利用した SVG アニメーションの実装について紹介しました。 ぜひ皆さんの憧憬のある花火を、SVG の夜空に打ち上げてみてほしいと思います。

(今年はイベントらしいイベントもなく寂しかったので、 SVG で花火を打ち上げたおっくんでした。)

P.S

虎の穴ラボではいくつかのオンラインイベントを企画しております。是非ご参加ください!!

TORA LAB Management & Leader Meetup

12/23(金) 19:30からとらのあなが運営している「とらのあな通販」と「Fantia」の開発の魅力を発表し、参加いただいた方の気になる点やご質問に答えるイベントとなっています。 yumenosora.connpass.com

その他採用情報

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。 カジュアル面談では虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今期何見ました?」といったオタクトークから業務の話まで何でもお応えします。 カジュアル面談や採用情報はこちらをご確認ください。 yumenosora.co.jp