画像下部が波でうねるアニメーションをcanvasで実装する
技術スタック Javascript

画像下部が波でうねるアニメーションをcanvasで実装する

2020.09.01

こんにちはjunです。今日は以下のようなcanvasを用いたアニメーションを作っていきます。 レスポンシブをとりあえず画像の一部をうねらせるだけであれば70行ほどのjsコードですみます。

こちらにて実際に動かしています。

必要な知識

細かい実装の説明にうつる前に今回用いる必要な知識と原理について確認します。以下の知識にある程度知見がある人はすっ飛ばしてください。

  • canvas要素
  • jsでcanvasを読み込む方法
  • jsでcanvasに画像を設定する方法
  • 三角関数(超基礎)

canvasでのアニメーションの原理

実装サイトでも見ていただいと思いますが、きちんとアニメーションをしておりまた、動画を流しているわけではありません。このアニメーションはcanvas要素というものをjsで操作することで実装ができます。

画像を波打たせる方法は普通のimgタグやdivでは難しいです。柔軟に簡単に実装するためにcanvasを用います。

このcanvasでのアニメーションは実は見えないスピードで「画像を消しては、再描画、消して、再描画…」というのを行っています。また描画する画像は下部を透明な波線で消してから描画しています。また波が連なり、流れるように見せるために三角関数を用いて波型に消す箇所を計算しています。

ちょっとわかりにくいにので図にしてみます。

  • canvasでは上図のオレンジ点で示した様な描画地点を座標で指定します。(1)
  • 描画地点は数式を用いて指定できるので、波の部分は三角関数で座標を指定します。(2)
  • そして画像の端にたどり着いたら元の描画いちに戻る様にします。(3、4)
  • 囲まれた部分の色などを指定できるので、透明化を行います。(5)
  • そしてすぐに画像を元に戻して、1〜5を再度行う。

また1〜6を繰り返すたびにsni(θ)のθを増やしていけば毎回異なる波がうねる様に見えます。この様にして波のアニメーションを実装します。

canvasって何?

canvasというのはHTML5で扱われる要素の一つであり、2次元図形・グラフィック・アニメーションをjavaScriptを用いて描画することができます。cssでは解決できない図形やアニメーションを実装することができます。数十年前だとflashが担っていたことをHTMLで行うような感じです。

canvasに画像を表示させる

ではまずはうねらせる画像をcanvas要素に表示させるところまで行います。今回は同階層に

  • index.html
  • app.js
  • sample.jpg

を用意しておきます。sample.jpgは縦横比1:2にトリミングをしておきます。では作っていきましょう。まずは以下のように適当にHTMLを作っておきます。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>waving image</title>
        <style>
            *{
                margin:0;
                padding:0;
            }
            main{
                background:rgb(240, 255, 255);;
            }
        </style>
    </head>
    <body>
        <main>
            <canvas id="canvas"></canvas>
        </main>
        <script src="./app.js"></script>
    </body>
</html>

描画がされるcanvasを用意して、javascriptで拾えるようにidをつけておきましょう。あとは描画を実行するjsファイルをcanvasより後に書いておきます。

javascriptで描画対象のcanvasを設定

app.jsに以下のようにコードを書きます。

function initAnimation(){
    var canvas = document.getElementById('canvas');
    var ctx = canvas.getContext('2d');

    var imagePath = ('./sample.jpg');
    var image = new Image();
    image.src = imagePath;

    canvas.width = Number(window.innerWidth);
    canvas.height = Number(canvas.width/2);
    image.onload = function(){
          ctx.drawImage(image,0,0,image.width,image.height,0,0,canvas.width,canvas.height);
    }
}

この箇所ではHTMLからcanvas要素を指定して、jsを用いてcanvasの操作を行える様にするおまじないです。以降はこのctx(描画コンテキストインスタンス)に様々な指定を行います。

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');

そして

var imagePath = ('./sample.jpg');
var image = new Image();
image.src = imagePath;

canvas.width = Number(window.innerWidth);
canvas.height = Number(canvas.width/2);
image.onload = function(){
        ctx.drawImage(image,0,0,image.width,image.height,0,0,canvas.width,canvas.height);
}

jsのImageオブジェクトを用いてsrcプロパティに映す画像のパスを入れます。canvas.widthcanvas.heightでcanvasの大きさを指定します。画像が縦横1:2なので canvasもその比率に沿う様にしました。

image.onload を用いてsrcで指定した画像の読み込みが終わったら、 drawImage() メソッドを用いてcanvasに画像を描画する様にします。 image.onload を使わないと画像が読み込まれる前に描画しようとするので、映されません。

とりあえずここまでくると、canvas要素しかないHTMLにもかかわらず、以下の様に画像が描画されているはずです。

drawImage()の使い方

drawImage()はcanvasに範囲を指定して画像を描画します。canvas全体に画像を描画するならば必ず、最後の引数まで入力したほうがいいです。(MDNの解説(英語))

void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

それぞれの引数を説明すると

Imageインスタンス。つまり描画する対象の画像

  1. 画像の切り抜き開始地点のX座標
  2. 画像の切り抜き開始地点のY座標
  3. 第2引数で指定したX座標から切り抜くX方向の距離(幅)
  4. 第3引数で指定したY座標から切り抜くY方向の距離(高さ)
  5. canvasに描画する開始地点のX座標
  6. canvasに描画する開始地点のX座標
  7. 第6引数で指定したX座標から描画するX方向の距離(幅)
  8. 第7引数で指定したY座標から描画するY方向の距離(高さ)

という感じです!私の今回の例でいくと

ctx.drawImage(image,0,0,image.width,image.height,0,0,canvas.width,canvas.height);

0,0,image.width,image.height, で画像の(0,0)座標地点から画像の幅と高さ分、画像を切り取るという意味です。つまり画像全体を読み取っているのと同じです。

もし0,0,image.width/2,image.height/2, としたら元画像の1/4だけの部分切り取られた画像が映し出されます。

0,0,canvas.width,canvas.height ここもcanvasの(0,0)座標地点からcanvasの幅と高さ分、画像を貼り付けるという意味です。つまりcanvas全体に画像を貼り付けるのと同じです。

ここは実際にコードを描いて比率をいじって見てください。そうすればここの意味がわかる様になります。

波線に画像を切り抜く

それでは次にこの画像を波線に切り抜きます。切り抜きのイメージとしては上で説明した図の様に、一辺が波線の四角形を描いて、透明に塗り潰します。image.onload以降に以下のコードを加えます。

image.onload = function(){
    initDraw();
}
    
var canvasEndX = canvas.width;
var canvasEndY = canvas.height;
var waveStartPoint = canvasEndY-150;

var amplitude = 30;
var period = 1000;
var degree = 0;

function initDraw(){
    imageSet(image,canvasEndX,canvasEndY);
    waveDrawing(waveStartPoint,canvasEndX,canvasEndY,degree,amplitude,period);
}

function imageSet(imageObj,canvasEndX,canvasEndY){
    var imgWidth = imageObj.width;
    var imgHeight = imageObj.height;
    ctx.drawImage(image,0,0,imgWidth,imgHeight,0,0,canvasEndX,canvasEndY);
}

function waveDrawing(waveStartPoint,canvasEndX,canvasEndY,deg,am,tp){
    var waveStartY = waveStartPoint;
    ctx.globalCompositeOperation = "destination-out";
    ctx.beginPath();
    ctx.moveTo(0, waveStartY);

    for (var x=0; x <= canvasEndX; x+= 1) {
        var y = -am*Math.sin((Math.PI/tp)*(deg+x));
        ctx.lineTo(x, y+waveStartY);
    }

    ctx.lineTo(canvasEndX,canvasEndY);
    ctx.lineTo(0,canvasEndY);
    ctx.closePath();

     ctx.fillStyle = "rgba(255,255,255,1)"; //opacity 1
    ctx.fill();
}

ここでは3つの関数を作成します。

  • initDraw():初期の波線くりぬき画像を描画する。
  • imageSet():画像をキャンバスに描画する。またすでに画像がある場合はそれをクリアする。
  • waveDrawing():画像を波線にくり抜く。

それぞれを解説していきます。

imageSet() で画像を描画

function imageSet(imageObj,canvasEndX,canvasEndY){
     var imgWidth = imageObj.width;
     var imgHeight = imageObj.height;
     ctx.drawImage(image,0,0,imgWidth,imgHeight,0,0,canvasEndX,canvasEndY);
}

この関数は単に画像を描画するだけです。引数に画像オブジェクトとキャンバスの幅・高さ情報をとり、先ほども説明したdrawImage()を用いてキャンバスに画像を描画します。

waveDrawing()で波線くりぬきをする

waveDrawing()という関数で波線のくりぬき処理をかいていきます。

function waveDrawing(waveStartPoint,canvasEndX,canvasEndY,deg,am,tp){
      var waveStartY = waveStartPoint;
      ctx.globalCompositeOperation = "destination-out";
      ctx.beginPath();
      ctx.moveTo(0, waveStartY);

      for (var x=0; x <= canvasEndX; x+= 1) {
           var y = -am*Math.sin((Math.PI/tp)*(deg+x));;
           ctx.lineTo(x, y+waveStartY);
      }

      ctx.lineTo(canvasEndX,canvasEndY);
      ctx.lineTo(0,canvasEndY);
      ctx.closePath();

      ctx.fillStyle = "rgba(255,255,255,1)"; //opacity 1
      ctx.fill();
}

各パラメーターは

  • waveStartPoint:波を書き始めるY軸の開始位置
  • canvasEndX:canvasの右端のX座標(最大のcanvasX座標)
  • canvasEndY:canvasの下端のY座標(最大のcanvasXY座標)
  • deg:角度の初期値
  • am:振幅(波の最大の高さを変化させる)
  • tp:周期(1波の幅を変化させる)

最後の3つは高校の三角関数を思い出してください。それほど難しく考えず、amを大きくすれば波が大きくなり、tpの場合は大きいほどなだらかな波になります。

またctxは上部で定義したcanvasインスタンスです。今回はグローバルにしてます。

描画位置を定義し、重ね合わせの設定をする

var waveStartY = waveStartPoint;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.moveTo(0, waveStartY);

画像を波線に透過させる場合にこの設定 ctx.globalCompositeOperation = "destination-out"; が重要です。MDNの解説

このglobalCompositeOperationはcanvasにおける図形どうしが重ね合わさった際にどう描画するのかを定義します。冒頭で出したこの図を見てみてください。

透明のくりぬきは画像の上に、赤線で範囲を指定してその中を透明色に塗りつぶすということをしています。その時に「すでに描画された画像」と「透明色に塗り潰された図形」が重ね合わさっています。

その時にを設定していると、後で描画した図形と重なり合わない部分だけが残る様に描画されます。つまり上図の5の様に一部分だけ透明になります。

くりぬき範囲を指定する

 ctx.beginPath();
 ctx.moveTo(0, waveStartY);
 for (var x=0; x <= canvasEndX; x+= 1) {
     var y = -am*Math.sin((Math.PI/tp)*(deg+x));;
     ctx.lineTo(x, y+waveStartY);
 }

 ctx.lineTo(canvasEndX,canvasEndY);
 ctx.lineTo(0,canvasEndY);
 ctx.closePath();

次にくりぬきの範囲を指定します。上図でいうと1〜4を指します。フォトショップやイラストレーターを使っている人なら「パスで選択範囲を指定」という意味がわかると思います。それをここでjsを用いて行っています。

念のために解説すると、パスというのは図形を構成する点(座標)みたいなものです。そして図形はその点を結ぶことで描画できます。四角形であれば点(頂点)は4つあって、それを一筆書きすると四角ができますよね。その一筆書きの順路と位置をこのコードで定義しています。

  1. ctx.beginPath()でパスの指定を開始します。
  2. ctx.moveTo(X,Y)で指定した座標にパスを移動させます。
  3. ctx.lineTo(nextX,nextY)で次の座標を指定しパスを移動させつつ線をひきます。
  4. ctx.closePath()でパスの指定を終了します。

今回はこのパスの指定を

  1. 画像の左端(X=0)、指定した波の開始地点(waveStartY)より(0, waveStartY)からパスを開始。
  2. 画像の右端(X=canvasEndX)までfor文を用いて、さらに三角関数の式にx座標を入れて、波線を描く様にパスを指定していく。
  3. 画像の右端までついたら、画像の右下端、左下端を通って、開始地点に戻る。
  4. パスを閉じる。

この様にしています。

選択範囲を透明色で塗りつぶす

上記の方法で選択範囲を指定すれば、あとは塗りつぶすだけです。

ctx.fillStyle = "rgba(255,255,255,1)"; //opacity 1
ctx.fill();

fillStyleで塗り潰しの色を設定できます。ここは実際、globalCompositeOperation = "destination-out";を設定していれば何色でも大丈夫です。ですが念のため透明色を設定。

そしてfill()で指定したスタイル、パスの範囲で塗り潰しを行います。globalCompositeOperation = "destination-out";が設定されているので塗り潰された部分と画像の重なり合う部分以外が残り、重なり部分は透明になります。

ここまで来れば以下の様に波線にくり抜かれた画像が得られます。

ループでアニメーションを実装

定義したimageSet()waveDrawing()を用いて以下のループを設定します。

function loop(){
      setInterval(function(){
      imageSet(image,canvasEndX,canvasEndY);
      waveDrawing(waveStartPoint,canvasEndX,canvasEndY,degree,amplitude,period);
      degree += 12; //12はなんとなく
    },30)
}

ここで一番大切なのはdeggre +=4の様にwaveDrawing()で用いる初期角度を足していくことです。こうすることで波がウネウネします。degreeの加算が多いほど波が早くなります。50以上にすると荒波になります笑

そしてこのloop()の関数をimage.onloadで呼び出して発火させます。

image.onload = function(){
    initDraw();
    loop();
}

コード全体

上記をまとめたコードがこちらです。

window.onload = init();
function init(){
    initAnimation();

    function initAnimation(){
        var canvas = document.getElementById('canvas');
        var ctx = canvas.getContext('2d');

        var imagePath = ('./sample.jpg');
        var image = new Image();
        image.src = imagePath;

        //set canvas width and height
        canvas.width = Number(window.innerWidth);
        canvas.height = Number(canvas.width/2);
        image.onload = function(){
                initDraw();
                loop();
            }
            
        var canvasEndX = canvas.width;
        var canvasEndY = canvas.height;
        var waveStartPoint = canvasEndY-150;

        var amplitude = 30;
        var period = 600;
        var degree = 0;

        function initDraw(){
            imageSet(image,canvasEndX,canvasEndY);
            waveDrawing(waveStartPoint,canvasEndX,canvasEndY,degree,amplitude,period);
        }

        function loop(){
            setInterval(function(){
                imageSet(image,canvasEndX,canvasEndY);
                waveDrawing(waveStartPoint,canvasEndX,canvasEndY,degree,amplitude,period);
                degree += 12;
            },30)
        }

        function imageSet(imageObj,canvasEndX,canvasEndY){
            var imgWidth = imageObj.width;
            var imgHeight = imageObj.height;

            ctx.globalCompositeOperation = "destination-over";
            ctx.drawImage(image,0,0,imgWidth,imgHeight,0,0,canvasEndX,canvasEndY);
        }

        function waveDrawing(waveStartPoint,canvasEndX,canvasEndY,deg,am,tp){
            var waveStartY = waveStartPoint;
            ctx.globalCompositeOperation = "destination-out";
            ctx.beginPath();
            ctx.moveTo(0, waveStartY);

            for (var x=0; x <= canvasEndX; x+= 1) {
                var y = -am*Math.sin((Math.PI/tp)*(deg+x));
                ctx.lineTo(x, y+waveStartY);
            }

            ctx.lineTo(canvasEndX,canvasEndY);
            ctx.lineTo(0,canvasEndY);
            ctx.closePath();

            ctx.fillStyle = "rgba(255,255,255,1)"; //opacity 1
            ctx.fill();
        }

    }
}

波の様子を変えたい場合

このコードの場合は

var amplitude = 30;
var period = 600;

function loop(){
      ...
      degree += 12;
      },30)
}

amplitude(振幅)で波の最大の高さ、period(周期)で1波の周期、degree +=で波の速さを変化させることができます。他にもwaveDrawing()で定義した三角関数の式を変えることで単純なサイン波でなく、複雑な波を描画できます。

最後に

以上がcanvasを用いて画像をウネウネさせる方法です。canvasではこの様に複雑なアニメーションを用いた描画が可能です。jsで記述するのでFlashのActionScriptとかやっていた人は馴染みがあるかもしれません。

Copyright © 2021 jun. All rights reserved.