Javascriptで非インタラクティブなアニメーションを作る

冬っぽい雪が降るだけのアニメーションをjavascriptで作った。

デモ

ずっと動いたままになるので、バッテリー残量注意。

Fuyu yuki

ソースもgithubに: ikapper/anim-fuyu-yuki

使ったもの

canvasを直に操作するのは辛いので、fabricを使用した。公式サイトはこちら: Fabric.js Javascript Canvas Library

リソースはwebpackでバンドルしている。

していること

開いたときに表示されているものが全てだが、一応書いておく。

  • fabricstatic canvasを生成
  • 生成したcanvasに背景色となる色を設定
  • 家を2つ生成
  • 雪を生成
  • 雪を下へアニメーション

fabric.StaticCanvasの生成

fabric.StaticCanvasの生成時はaddメソッドでレンダリングが行われないようにしておく:

fabcanvas = new fabric.StaticCanvas('canvas', {
    renderOnAddRemove: false
});

レンダリングしたいときは、fabcanvas.renderAll()を実行する。 レンダリングを行うタイミングは、初期化後と雪を下方向に動かした後の2つ。後者は何度もコールする。

また、背景色も設定する。fabcanvas.setBackgroundColor('#e2e2e2');とするだけ。

家の生成

家は自作したsvgイメージを読み込むことにした。

import HouseWinter from "./assets/house_winter.svg";

// fabricのStaticCanvasのインスタンス。初期化時に代入される。
let fabcanvas;

const roofColors = ['#ffe1e1', '#ff9595', '#ffca95', '#ca95ff'];
function init_house(scaling) {
    // 家を表現
    const canvasH = fabcanvas.getHeight();
    const canvasW = fabcanvas.getWidth();
    fabric.loadSVGFromURL(HouseWinter, (oImg) => {
        const house = new fabric.Group(oImg);
        house.set({ scaleX: scaling, scaleY: scaling });
        house.set({
            top: canvasH - house.height * scaling,
            left: (canvasW - house.width * scaling) * Math.random()
        });
        house.item(3).set({ 'stroke': roofColors[Math.floor(roofColors.length * Math.random())] });
        fabcanvas.insertAt(house, 0);
    });
}

上方から順に説明していく。

屋根色をランダムにするために、何色か用意してリストで保持している。

引数のscalingは拡大縮小の倍率で、生成するsvgの大きさで調整が必要。今回は0.50.3で生成している。

関数の実行時には、最初にcanvasの幅と高さを取得している。定数で持っていてもいいのだが、ウィンドウのリサイズにも対応するようにしているため、各関数で取得するようにしている。家のレンダリングは最初の1回だけなのであまり関係はない。

fabric.loadSVGFromURL()は画像パスとコールバックを指定してsvgをロードできる関数。oImgは(確か)fabric.Imageのインスタンスのリストだったので、fabric.Groupで1つのhouseにまとめている。

そして、houseの大きさをセットして、canvas上の位置を決めている。

house.item(3)は屋根部分で、かつstrokeで着色しているので、strokeを上書きしている。

fabric.StaticCanvas.add()はレイヤ的に考えると最上位に追加されるようだ。アニメーションされる雪が奥側に行ってしまうと少し不自然になるので、奥側にhouseを配置するためにfabric.StaticCanvas.insertAt()を使って挿入位置を指定している。実際を考えたら奥に行く雪もあるが考慮してない。

スケール倍率を指定してこの関数を呼び出せば、canvasに家を描画できる。重なったときの違和感はご愛嬌。

雪の生成

雪は円で表現している。大きさの異なる円をcanvas上に散らせておけばそれっぽく見えるのは不思議な感じもする。

const snows = [];
const SNOW_COUNT_MAX = 150;  // 粒
const snowColors = ['#f8f8ff', '#f9fafb', '#fffaff', '#fbfbf9', '#fafafa'];
function init_yuki() {
    const canvasW = fabcanvas.getWidth();
    const canvasH = fabcanvas.getHeight();
    for (let i = 0; i < SNOW_COUNT_MAX; i++) {
        const snow = new fabric.Circle({
            radius: 10 * Math.random() + 3,
            fill: snowColors[Math.floor(snowColors.length * Math.random())],
            left: canvasW * Math.random(),
            top: canvasH * Math.random(),
        });
        snow.velY = 15 * Math.random() + 10;
        fabcanvas.add(snow);
        snows.push(snow);
    }
}

関数外から。

snowsは、アニメーション用に関数外で保持する。fabcanvasから取得もできるが、家との区別が面倒なのでこの形にした。

次に雪の最大個数をSNOW_COUNT_MAXで定義しておく。数を大きくしすぎるとかくつくことがある。

また家の屋根と同じく、雪の色も5通り用意しておく。

関数の外側はこれで終わり、今度は中身に移る。

家の方で述べたとおり、まず最初にcanvasの幅・高さを取得する。

次にループで雪となる円を生成していく。

fabric.Circlenewすればいい。半径・塗りつぶし色・位置の順で指定している。topleftcanvasの左上からの位置。

その後、アニメーションで使うvelYを設定する。下方向へ動く速度になる。単位はpixel/sec。範囲は[10,25)とした。

最後に、fabcanvassnowsに追加する。

アニメーション

雪を下方向へ動かすアニメーションを行いたい。

fabric.util.requestAnimFrame()を利用する。window.requestAnimationFrame()の改良版らしい。 使い方は同じ。

let timestamp = 0;
const reqAnimFrame = () => {
    fabric.util.requestAnimFrame((time) => {
        const timedelta = time - timestamp;  // ms
        timestamp = time;

        const canvasH = fabcanvas.getHeight();
        const canvasW = fabcanvas.getWidth();
        for (let snow of snows) {
            let newy = snow.get('top') + snow.velY * timedelta / 1000;
            if (newy > canvasH + 20) {
                newy = -20;
                snow.set('left', canvasW * Math.random());
            }
            snow.set('top', newy);
        }
        fabcanvas.renderAll();
        reqAnimFrame();
    }, fabcanvas);
};
reqAnimFrame();

正しい使い方をしている自信があまりない。とりあえず普通に動いているのでいいだろう。

前回実行時の時間を保持するために外側にtimestampを持つ。単位はミリ秒。

最近無名関数でも自身を呼び出して再帰できることを知ったが、ここではそれは行ってないので、関数を定義してその名前で再帰している。

ページのロード完了時からの経過時間を返してくれていると思うので、timestampで前回実行時間を保持し、渡されてきたtimeとの差を比較し、経過時間timedeltaを知る。そのあとにtimestampを更新する。(改善点有り、後述)

またこれまで通りcanvasの幅・高さを取得。

ループでsnowsの中身のfabric.Circleインスタンス達の位置を更新する。

速度velYに基づいて次の高さを決める。その高さがcanvasの高さを超えたら、今度は天辺から描画し直す。雪の大きさの分だけ上にはみ出させて違和感を軽減している。 また天辺からの時は、横方向の座標を再度ランダムに決定する。

全ての雪(円)について更新が終わったらcanvasに再描画を促す。 そして最後に再帰。

再帰するまでの処理で時間がかかりすぎると動作が重くなってしまう。

改善点

とりあえず思いつくのは2点ある。修正するかもしれない改善点。

動作が重くなりがち

もう少し動作を軽くしたい。たったこれだけなのに割と重く感じる。 原因はやはり雪となる円のせいだろう。円を1つずつ描くのはやはり効率が悪い気がする。

雪のグループの画を何枚か用意して重ねれば簡単に増やせるとは思うが、ランダム性は薄くなってしまう気がする。そもそもそんな簡単に軽くなるかも分からないが…

アニメーションの起点とブラウザの仕様

requestAnimFrame()timestampの初期値はnullにしておくべきだった。そうしないと、最初のreqAnimFrame()timedeltaの値が正確で無くなってしまうためだ。

さらに、ブラウザの省エネ設計により、他タブを開くなどでcanvasが隠れてしまうと、requestAnimationFrame()の更新頻度がかなり下がり、結果timedeltaが大きくなり、雪が画面外の下方へ行ったと判定されて、全て上方へ行ってしまうという問題もある。 これは、timedeltaが大きすぎたら適当な値(1000/60など)に調整することでいい感じにごまかすことはできると思う。

もしくはsetTimeout,setIntervalなどを使う方法もある。あれらはおそらく他タブを開いても実行される?と思う(未確認)。

おわり

もう少し色々配置したいがsvgを作るのが大変だ。もっと作るのに慣れるべきだ。見た目も改善できるし。

もっと眺めてて面白いものを作りたいものだ。

以上です。


Javascript本の広告

Amazon.co.jp: JavaScript逆引きレシピ 第2版 eBook : 山田 祥寛: Kindleストア
Amazon.co.jp: JavaScript逆引きレシピ 第2版 eBook : 山田 祥寛: Kindleストア
タイトルとURLをコピーしました