プログラミング はじめの一歩 JavaScript + p5.js編
38:番外編 円とサイン

本稿は、毎日新聞の「プログラミング・はじめの一歩」とは関係のないオリジナル記事です。

基本

サインやコサインなど、いわゆる三角関数と聞くと、「もはやチンプンカンプン」と思われる方も多いでしょうが、サインは実は滑らかに変化する細かな数値を繰り返し生み出す装置と考えると、アレルギーも少しは弱まるかもしれません。

円を描く

円は、p5.jsのellipse()関数を使うと描画できますが、ここでやろうとするのは、複数の点を円状に描画することで、全体として円に見せようとする作業です。

円の中心座標を(centerX,centerY)、半径をradiusとすると、その円周上の点(x,y)は次の計算で求めることができます。cos()とsin()はp5.jsの関数で、それぞれ与えられた角度のコサインとサインの値を返します。角度はラジアン単位で指定します。

// (centerX,centerY)を中心とする半径radiusの円の円周上の点(x,y)の座標
const x = centerX + cos(angle) * radius;
const y = centerY + sin(angle) * radius;

円を描画するには、変数angleを0から359まで変化させ、その値をcos()とsin()関数に渡して、返される値で点を描きます。ラジアンは見た目から難しそうで、360度を2Πとして扱いたくない場合には、 angleMode(DEGREES)を設定することで解決できます。

// 変数angleを0から1ずつ、360未満まで大きくする
for (let angle = 0; angle < 360; angle++) {
    // (centerX,centerY)を中心とする半径radiusの円の円周上の点(x,y)の座標
    const x = centerX + cos(angle) * radius;
    const y = centerY + sin(angle) * radius;
    // その(x,y)を点で描画する
    point(x, y);
}

400 x 400 のキャンバスのセンターを中心とする半径100の円は次のコードで描画できます。

// 円の中心座標(x,y)
let centerX, centerY;
// 円の半径
const radius = 100;

function setup() {
    createCanvas(400, 400);
    angleMode(DEGREES); // 角度をラジアンでなく度単位で扱えるようにする
    // 円の中心をキャンバスセンターにする
    centerX = width / 2;
    centerY = height / 2;

    background(220);
    strokeWeight(2); // 線を少し太くする
    drawCircle();
}

function drawCircle() {
    // 変数angleを0から1ずつ、360未満まで大きくする
    for (let angle = 0; angle < 360; angle++) {
        // (centerX,centerY)を中心とする半径radiusの円の円周上の点(x,y)の座標
        const x = centerX + cos(angle) * radius;
        const y = centerY + sin(angle) * radius;
        // その(x,y)を点で描画する
        // https://p5js.org/reference/#/p5/point
        point(x, y);
    }

    // 確認
    // 角度0度のときの(x,y)は、円の中心から右に半径分だけ進んだ位置に当たる
    // => ここかスタート位置
    let x = centerX + cos(0) * radius;
    let y = centerY + sin(0) * radius;
    fill(255, 0, 0);
    ellipse(x, y, 10, 10);

    // 45度のときの(x,y)は、0度のときの(x,y)から時計回りに45度進んだ位置に当たる
    x = centerX + cos(45) * radius;
    y = centerY + sin(45) * radius;
    fill(0, 255, 0);
    ellipse(x, y, 10, 10);

    // 90度のときの(x,y)は、0度のときの(x,y)から時計回りに90度進んだ位置に当たる
    x = centerX + cos(90) * radius;
    y = centerY + sin(90) * radius;
    fill(0, 0, 255);
    ellipse(x, y, 10, 10);

    // -90度のときの(x,y)は、0度のときの(x,y)から反時計回りに90度進んだ位置に当たる
    x = centerX + cos(-90) * radius;
    y = centerY + sin(-90) * radius;
    fill(255, 255, 0);
    ellipse(x, y, 10, 10);
}

drawCircle()関数のforループより下では、確認のため、指定された角度から計算した(x,y)位置を色分けした小さな円で示してします。下図はこれを説明するための図です。

ここで覚えておくと後々役立つのは、角度0度は円の中心から真右に進んだ方向にあるということです。これはx軸の正の方向です。この角度は時計回りに大きくなります。赤い丸のcos(0)とsin(0)の位置からangleに1ずつ足していくと、点と中心とを結ぶ線は時計回りに進み、angleが45になったときには、点は緑の丸の位置に来ます。

サインカーブ

sin()に渡す値を1ずつ大きくすると、滑らかに変化する細かな数値が得られます。以下ではplotly.jsというJavaScriptライブラリを使って、数値の変化を見ていきます。

HTMLファイルでは、p5.jsとともにplotly.jsを読み込みます。HTML要素には、plotly.jsが使用するdiv要素(“chart”)を作成し、CSSにそれに応じたスタイルを定義します。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>p5.js Examples</title>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.1.9/p5.min.js"></script>
  <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
  <style>
    .graph-area{
      margin: 10px;
      width: 500px;
      height:500px;
      border: 1px solid black;
    }
  </style>
</head>
<body>
  <h3>サインカーブの描画</h3>
  <p>sinValue = sin(angle)</p>
  <div id="chart" class="graph-area"></div>
  <script src="sketch.js"></script>
</body>
</html>

sketch.jsのJavaScriptコードには以下を記述します。

// サインとは何ぞや?
// plotly.jsを使って、サインカーブを描画する

// 画面のマウスダウンで描画をオン/オフするためのブーリアン変数
// 最初はオフなのでfalseにする
let isAction = false;
let angle = 0; // 角度

function setup() {
    noCanvas();
    angleMode(DEGREES); // 分かりやすいように度モードにする
}

function draw() {
    // 画面のマウスプレスで描画開始
    if (isAction) {
        drawSinCurve();
        // 角度を1度ずつ大きくする
        angle++;
    }
}

// plotly.jsでサインカーブを描画する
function drawSinCurve() {
    // angleは1ずつ大きくなるので、それに応じてsinValueも変化する
    // -1 -> 1 の間を滑らかに変化する
    const sinValue = sin(angle);
    // 横軸はフレームカウント数、縦軸はサイン値
    plot(frameCount, sinValue);
}

// 画面のマウスプレスで描画のオン/オフを切り替える
function mousePressed() {
    isAction = !isAction
}

// Plotlyを使ってグラフを描く
// 描くグラフのx値とy値を入れる配列
const xArray = [];
const yArray = [];

function plot(xData, yData) {
    xArray.push(xData);
    yArray.push(yData);
    // plotly.jsの仕様
    const trace = {
        x: xArray,
        y: yArray,
        type: 'scatter'
    };
    const data = [trace];
    const layout = {
        xaxis: {
            // range: [0, 400],
            title: 'frameCount値'
        },
        yaxis: {
            range: [-1, 1],
            title: 'sin値'
        }
    };
    // plotly.jsのメソッドを呼び出して'chart' div要素に描画
    Plotly.newPlot('chart', data, layout, {
        displayModeBar: false
    });
}

下図は実行画面です。

このグラフから分かるように、sin(angle)から生み出される数値は、0から上向きにスタートし、1で下向きに変わって0を通過してさらに小さくなり、-1を最下点として再び上向きに変わります。この滑らかな曲線で表される変化が無限に繰り返されます。

コサインも同様に滑らかに変化する細かな数値を生み出しますが、スタートが0でなく1なので、プログラミングでは0からスタートするサインの方が使い勝手がよく、多くの場面で使用されます。

sin()周りの数値をいじる

sin()関数が返す値の滑らかな変化は波のように見えます。sin()の周りの数値をいじることで、この波に変化を与えることができます。

波の高さ(振幅)は、サイン値に数値をかけることで高くできる

波の高さは振幅と呼ばれます。この高さはサイン値に数値をかけることで、高くできます。前の円の描画で使ったコードで言うと、半径を表すradiusがこれに当たります。

y = centerY + sin(angle) * radius;

波の高さが大きくなるということは、-1から1の間の数値を生み出していたsin(angle)が、たとえばsin(angle) * 5 によって-5から5までの数値を生み出すようになるということです。振幅は英語でamplitudeと言います。下図はsin(angle)に何もかけないサインカーブ(青い線)と、2をかけて振幅を倍にしたサインカーブ(オレンジ色の線)の違いを示しています。

次のコードでは、getSineAmplitude()関数が返す数値を円の直径に利用して円を2つ描画しています。getSineAmplitude()は渡された引数にsin(angle)をかけた値を返します。この値は滑らかに増減を繰り返すので、描かれる円は伸縮することになります。

// 波の高さ(振幅)は、サイン値に数値をかけることで高くできる
const amplitude = 4;

// 画面のマウスダウンで描画をオン/オフするためのブーリアン変数
// 最初はオフなのでfalseにする
let isAction = false;
let angle = 0; // 角度

let sinValue1 = 0,
    sinValue2 = 0;

function setup() {
    createCanvas(400, 400);
    angleMode(DEGREES); // 分かりやすいように度モードにする
    noStroke();
}

function draw() {
    background(220);
    // 異なるサイズで伸縮する2つの円を描画する
    scaleCirecleAndDrawSinCurve();
    // 画面のマウスプレスで描画開始
    if (isAction) {
        // 角度を1度ずつ大きくする
        angle++;
    }
}

// サイン値に、渡された振幅値を掛けて返す
function getSineAmplitude(amplitude) {
    // 波の高さ(振幅)は、サイン値に数値をかけることで高くできる
    return sin(angle) * amplitude;
}

// 2つの円を描画する
function scaleCirecleAndDrawSinCurve() {
    // 左の青い円 => 振幅値が1なので、円は大きくならない
    sinValue1 = getSineAmplitude(1);
    fill(91, 151, 199);
    // sinValue1*50はマイナス値になるが、p5.jsが絶対値に直す
    ellipse(100, 200, sinValue1 * 50);

    // 右のオレンジの円 => 振幅値が大きいので、円は大きくなる
    sinValue2 = getSineAmplitude(2);
    fill(255, 131, 12);
    // sinValue2*50はマイナス値になるが、p5.jsが絶対値に直す
    ellipse(250, 200, sinValue2 * 50);
}

// 画面のマウスプレスで描画のオン/オフを切り替える
function mousePressed() {
    isAction = !isAction
}

下は実行画面です。画面のクリックで2つの円が伸縮を開始します。再びクリックすると止まります。2つの円は同じタイミングで伸縮を繰り返しますが、左の円にはgetSineAmplitude(1)が、右の円にはgetSineAmplitude(2)が返す値を使っているので、右の円の方が大きくなります。

サイン値に数値を足すと、曲線全体を上に上げることができる

サイン値に数値を足すと、曲線全体を上方に移すことができます。円の描画で使ったコードで言うと、円の中心座標を表すcenterYがこれに当たります。sin(angle)に(またはsin(angle)にradiusをかけた数値)にcenterYを足すのですから、y値がその分大きくなるのは当然です。

y = centerY + sin(angle) * radius;

下図はサイン値を、sin(angle) * 2 + 0(青い線) とsin(angle) * 2 + 2(オレンジ色の線)で描いた場合の違いを示しています。オレンジ色の線は、sin(angle) * 2 に足した2だけ上方に移動しているのが分かります。

次のコードでは、この sin(angle) * 2 + 2 を使って、矩形の塗りのアルファ値を変化させています。

// サイン値に数値を足すと、曲線全体を上に上げることができる
const amplitude = 2;
// 画面のマウスダウンで描画をオン/オフするためのブーリアン変数
// 最初はオフなのでfalseにする
let isAction = false;
let angle = 0; // 角度
let sinValue2 = 0;

function setup() {
    createCanvas(800, 200);
    angleMode(DEGREES); // 分かりやすいように度モードにする
    noStroke();
}

function draw() {
    // 画面のマウスプレスで描画開始
    if (isAction) {
        // 2つサインカーブと1つの矩形を描画する
        drawSinCurveAndAlphaRect();
        // 角度を1度ずつ大きくする
        angle++;
        if (angle > 720) {
            isAction = false;
        }
    }
}

// サイン値に数値を足すと、曲線全体を上に上げることができる
function getSinePlus(num) {
    return sin(angle) * amplitude + num;
}

// 1つの矩形を描画する
function drawSinCurveAndAlphaRect() {
    // sin(angle) * amplitude + numの曲線(2を足す)
    sinValue2 = getSinePlus(2);
    // このsinValue2はグラフからも分かるように0と4の間で変化する
    // この数値をmap()関数を使って、255と4の間の数値にマッピングする
    // たとえば、0は255に、4は0に変換される。
    // これを塗りのアルファ値に適用する
    const alpha = map(sinValue2, 0, 4, 255, 0);
    // alphaは浮動小数点数なので整数に変換する
    fill(0, int(alpha));
    rect(angle, 10, 1, 200);
}

// 画面のマウスプレスで描画のオン/オフを切り替える
function mousePressed() {
    isAction = !isAction
}

画面をクリックすると、黒い矩形が横に伸びていき、塗りのアルファ値(透明度)が変化します。sin(angle) * 2 + 2 の数値が大きいほど透明度が増すので、縦の光源が2つ並んだような効果が生まれます。

sin()関数に渡す引数に手を加えることで、サインカーブをずらすことができる

sin()関数にはここまで角度を表すangleを渡してきました。sin()関数に渡す引数に手を加える、とはここでは、angleに度数の数値を足すことを意味しています( sin(angle + num))。このnumに10や20を代入してsin()関数に渡すと、angle + num によってsin()関数が受け取る値が変化し、サインカーブが横にずれるのです。このずれのことを位相と言います。

下図はsin(angle + 0)(青い線)とsin(angle + 45)(オレンジ色の線)、sin(angle + 90)(緑の線)で描いたサインカーブの違いを示しています。実は緑の線のsin(angle + 90)はコサインカーブと同じです。

サインカーブのずれとは、具体的にはタイミングのずれを意味します。上のグラフの横軸はframeCount値ですが、これは時間の経過と見なせます。たとえば200辺りのとき、まず緑色の線が最大値になり、その後少し遅れてオレンジ色の線が最大値になり、少し遅れて青色の線が最大値になります。

次のコードでは、このずれを一連の魚の骨のイメージのy位置に適用しています。

魚の骨は、頭と尾、あばら骨(縦の骨)は画像のイメージで、背骨(横の骨)は太い線で表しています。下図はその説明図で、イメージの中心を赤丸で示しています。この中心のy値にずらしたサインカーブの値を与えると、イメージは少しずつずれながら上下に動いて見えます。またその中心の(x,y)を太い線で描くと、骨同士を連結する、上下に動く背骨に見えます。

以下はそのコードです。

let isAction = false;
let angle = 0; // 角度
const amplitude = 20; // 振幅は共通

let offsetY = 130; // y位置(=円の中心のy座標)
const speed = 5; // 角速度
const baseX = 100; // 円のx位置に基本にする値

// 魚の骨のイメージ
let head, tail;
let bone1, bone2, bone3, bone4, bone5, bone6;
// スライダ 骨のイメージのy位置を変更できる
let slider;

function preload() {
    head = loadImage('images/head.png');
    bone1 = loadImage('images/bone1.png');
    bone2 = loadImage('images/bone2.png');
    bone3 = loadImage('images/bone3.png');
    bone4 = loadImage('images/bone4.png');
    bone5 = loadImage('images/bone5.png');
    bone6 = loadImage('images/bone6.png');
    tail = loadImage('images/tail.png');
}

function setup() {
    //const canvas = createCanvas(400, 200);
    createCanvas(400, 300);
    //canvas.parent('sketch-holder')
    angleMode(DEGREES); // 分かりやすいように度モードにする
    imageMode(CENTER);
    // 魚の背骨は太い線で代用
    strokeWeight(13);
    // スライダ
    // createSlider(min, max, [value], [step])
    slider = createSlider(0, 300, offsetY, 1);
    slider.position(10, 310);
    slider.style('width', '300px');
    // つまみのドラッグで、魚の骨のイメージのy位置を変える
    slider.input(() => {
        offsetY = slider.value();
    });
}

function draw() {
    background(220);
    // 魚の骨のイメージと背骨の線に使用する一連のサイン値
    const y1 = getSinePlus(0); // 通常のサイン値sin(angle)
    const y2 = getSinePlus(12); // 12度足す sin(angle + 12)
    const y3 = getSinePlus(22); // 22度足す sin(angle + 22)
    const y4 = getSinePlus(45);
    const y5 = getSinePlus(57);
    const y6 = getSinePlus(69);
    const y7 = getSinePlus(80);
    const y8 = getSinePlus(90);

    // 魚の背骨を太い線で描画
    // 一連のy1 -> y8 を使っているので、線も上下する
    line(baseX, y1, baseX * 1.3, y2);
    line(baseX * 1.3, y2, baseX * 1.5, y3);
    line(baseX * 1.5, y3, baseX * 1.8, y4);
    line(baseX * 1.8, y4, baseX * 2.0, y5);
    line(baseX * 2.0, y5, baseX * 2.3, y6);
    line(baseX * 2.3, y6, baseX * 2.55, y7);
    line(baseX * 2.55, y7, baseX * 3, y8);
    // 魚の骨の各イメージを描画
    image(tail, baseX, y1);
    image(bone1, baseX * 1.3, y2);
    image(bone2, baseX * 1.5, y3);
    image(bone3, baseX * 1.8, y4);
    image(bone4, baseX * 2.0, y5);
    image(bone5, baseX * 2.3, y6);
    image(bone6, baseX * 2.55, y7);
    image(head, baseX * 3, y8);

    // 画面のマウスプレスで描画開始
    if (isAction) {
        angle += speed;
    }
}

// sin()関数に渡す引数に手を加えることで、サインカーブをずらすことができる
function getSinePlus(num) {
    return offsetY + sin(angle + num) * amplitude;
}

// 画面のマウスプレスで描画のオン/オフを切り替える
function mousePressed() {
    isAction = true;
}

画面のクリックで魚の骨が動きだします。下のスライダで魚全体のy位置を変えることができます。

angle変数に足す変数speedの値を変えると、サインカーブの1周期の長さを変えることができる

ここまでdraw()関数内で、angleに足してきたspeedの値を変えると、変化の速さを変えることができます。

angle += speed;

下図の左はspeedが1のときのサインカーブで、右はspeedが10のときのサインカーブです。同じ400ほどの時間内で、右のカーブでは山が10個できていることが分かります。

speedの変化は、下図のようにとらえることもできます。下図は小さな円で大きな円を描いたもので、左はspeedが1のときの、右は10のときの結果です。小さな円はangleが0の位置からスタートし、時計回りに進みます。speedが1のときは小さな円の移動量が小さいので小さな円が多く描かれます。これに対しspeedが10のときは移動量が大きいので速く1周し、結果小さな円の描かれる数は少なくなります。

次のコードでは、マウスカーソルがキャンバスセンターに近いとハートマークの鼓動が速まり、遠いと遅くなります。

let bgImage; // 背景にする中年男女のイメージ
let cx, cy; // キャンバスセンター

let angle = 0; // 角度
let speed = 1; // スピード。今回はこれを変化させる

function preload() {
    bgImage = loadImage('images/bg.png');
}

function setup() {
    createCanvas(400, 400);
    angleMode(DEGREES);
    noStroke();
    // ハートマークの色
    fill(234, 86, 112);
    cx = width / 2;
    cy = height / 2;
}

function draw() {
    background(220);
    // 背景イメージを描画
    image(bgImage, 0, 0);
    // キャンバスセンターとマウスカーソルまでの距離
    let distance = dist(cx, cy, mouseX, mouseY);
    // 100より大きい場合(=>カーソルが遠くにある)
    if (distance > 100) {
        // speedは小さい => 伸縮するスピードは遅い
        speed = 1;
        // そうでない場合(=> カーソルが近くにある)
    }
    else {
        // speedは大きい 伸縮するスピードは速い
        speed = 5;
    }
    // ハートマークを伸縮させる
    beat();
    // カーソルとキャンバスセンターとの距離によって変化するspeedをangleに足す
    angle += speed;
}

// ハートマークを伸縮させる
function beat() {
    const h = abs(sin(angle)) + 1;
    heart(cx, cy, h * 50);
}

// ハートマークを作成する関数
// https://editor.p5js.org/Mithru/sketches/Hk1N1mMQg
function heart(x, y, size) {
    beginShape();
    vertex(x, y);
    bezierVertex(x - size / 2, y - size / 2, x - size, y + size / 3, x, y + size);
    bezierVertex(x + size, y + size / 3, x + size / 2, y - size / 2, x, y);
    endShape(CLOSE);
}

マウスカーソルをハートマークに近づけると、ドキドキのリズムが速まります。

“プログラミング はじめの一歩 JavaScript + p5.js編
38:番外編 円とサイン” への2件の返信

  1. 以下のイメージフォルダー内のpngファイルはどこにありますか?
    当方初心者のため自分では作成できず困っています。
    ご指導、よろしくお願いします。
      head = loadImage(‘images/head.png’);
    bone1 = loadImage(‘images/bone1.png’);
    bone2 = loadImage(‘images/bone2.png’);
    ・・・・・
     ・・・・・
    (JavaScript + p5.js編 38:番外編 円とサイン)

  2. 魚の頭や骨の画像は自分で用意するよりしかたありません。
    このサンプルの停止画面をスクリーンショットで撮影し、背景のグレーを消し去る方法もあります。また、そのように見える画像(つたなくてもOK)を作ることもできます。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA