雑食性雑感雑記

知識の整理場。ため込んだ知識をブログ記事として再構築します。

【JavaScript & Canvas】Boids アルゴリズム 習作

「群制御みたいなもの」をなんか作ってみたいなーと調べていたらたどり着いた Boids アルゴリズム。
まずはアルゴリズムを理解するのに、良く書かれているコードの内容を見て、整理しながら書いてみました。

作成物

正方形領域だと移動範囲が狭いので、縦長にしました。

技術要素

Boids アルゴリズム?

詳しい記事はいっぱいあるので、そちらを参照したほうが詳しくなれると思います。( とりあえず作りたかったので、私もそこまで調べてない。。)

ルールとしては、各オブジェクト (= boid) について、
・近くの集団 (群れ) 同士で近づくようにする。
・近すぎる boid 同士は離れるようにする。
・群れの速度はそれぞれ合わせるようにする。

という条件を持たせることで、一定の集団が作られて協調しながら動いている (ように見える) のがこのアルゴリズムですね。

今回のコードは参照記事の github のコードを読んで、自分なりに書いてみたものなので、
関数名はほぼそのまま参照元のコードから持ってきています。

Boids クラス処理 :

////////////////////
//
// Boids
//
class Boids {

    constructor(width, height, count) {

        // 領域サイズ
        this.width = width;
        this.height = height;

        // 個数
        this.count = count;
        this.boids = [];
        for (let i = 0; i < this.count; i++) {
            this.boids.push(new Boid(RAND() * width, RAND() * height, RAND() * 10 - 5, RAND() * 10 - 5));
        }
    }

    update(ctx) {

        for (let idx = 0; idx < this.count; idx++) {

            // 
            // 移動量 (dx, dy) を更新
            //

            // 群れの中心に近づく
            this.#flyTowardsCenter(idx);

            // 周囲と近すぎる場合に離れる
            this.#avoidOthers(idx);

            // 群れと速度を合わせる
            this.#matchVelocity(idx);

            // 速くなり過ぎた場合の速度調整
            this.#limitSpeed(idx);

            // 境界に近づいた場合の調整
            this.#keepWithinBounds(idx);

            //
            // 位置 (x, y) 更新
            //
            const boid = this.boids[idx];
            boid.x += boid.dx;
            boid.y += boid.dy;
            this.boids[idx].update(boid);

            // 描画
            boid.draw(ctx);
        }
    }

    // 群れの中心に近づく
    #flyTowardsCenter(idx) {
        const boid = this.boids[idx];

        // 群れの中心位置を算出
        let center_x = 0; let center_y = 0;
        let neighbors_count = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_VISUAL_RANGE_2) {
                center_x += this.boids[i].x; center_y += this.boids[i].y;
                neighbors_count++;
            }
        }

        // 移動量調整
        if (neighbors_count > 0) {
            center_x /= neighbors_count; center_y /= neighbors_count;

            boid.dx += (center_x - boid.x) * BOID_CENTERING_FACTOR;
            boid.dy += (center_y - boid.y) * BOID_CENTERING_FACTOR;

            // Update
            this.boids[idx].update(boid);
        }
    }

    // 周囲と近すぎる場合に離れる
    #avoidOthers(idx) {
        const boid = this.boids[idx];

        // 近くの Boid の距離を取得
        let move_x = 0; let move_y = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_AVOID_MIN_DISTANCE_2) {
                move_x += boid.x - this.boids[i].x;
                move_y += boid.y - this.boids[i].y;
            }
        }

        // 移動量調整
        boid.dx += move_x * BOID_AVOID_FACTOR;
        boid.dy += move_y * BOID_AVOID_FACTOR;
        this.boids[idx].update(boid);
    }

    // 群れと速度を合わせる
    #matchVelocity(idx) {
        const boid = this.boids[idx];

        // 群れの移動量を取得
        let avg_dx = 0; let avg_dy = 0;
        let neighbors_count = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_VISUAL_RANGE_2) {
                avg_dx += this.boids[i].dx; avg_dy += this.boids[i].dy;
                neighbors_count += 1;
            }
        }

        // 移動量調整
        if (neighbors_count > 0) {
            avg_dx /= neighbors_count; avg_dy /= neighbors_count;
            boid.dx += (avg_dx - boid.dx) * BOID_MATCHING_FACTOR;
            boid.dy += (avg_dy - boid.dy) * BOID_MATCHING_FACTOR;
            this.boids[idx].update(boid);
        }
    }

    // 速くなり過ぎた場合の速度調整
    #limitSpeed(idx) {
        const boid = this.boids[idx];
        const speed2 = boid.dx * boid.dx + boid.dy * boid.dy;
        if (speed2 > BOID_SPEED_LIMIT_2) {
            const speed = Math.sqrt(speed2);
            boid.dx = (boid.dx / speed) * BOID_SPEED_LIMIT;
            boid.dy = (boid.dy / speed) * BOID_SPEED_LIMIT;
            this.boids[idx].update(boid);
        }
    }

    // 境界に近づいた場合の調整
    #keepWithinBounds(idx) {

        // Margin 混みで境界に近づいた時、移動量調整
        const boid = this.boids[idx];
        if (boid.x < MARGIN) {
            boid.dx += BOID_TERN_FACTOR;
        }
        if (boid.x > this.width - MARGIN) {
            boid.dx -= BOID_TERN_FACTOR;
        }
        if (boid.y < MARGIN) {
            boid.dy += BOID_TERN_FACTOR;
        }
        if (boid.y > this.height - MARGIN) {
            boid.dy -= BOID_TERN_FACTOR;
        }
        this.boids[idx].update(boid);
    }
}

Boid の形状

1つ1つはどんな形状が見やすいんだろう…?
ここでは簡単に、進行方向と動いていることが分かればよいので、現在位置 (x, y) から進行方向に引いた線分を軸とした二等辺三角形を引いています。

Boid クラスの描画処理 :

////////////////////
//
// Boid
// Boid 単体の管理クラス
//
class Boid {

    (略)

    // ctx に Boid を描画
    // 位置 (x, y) から進行方向に伸ばした線分を軸とした二等辺三角形
    draw(ctx) {
        // NOTE: 画面座標系では Y 座標が逆

        // 角度
        const angle = Math.atan2(-this.dy, this.dx);
        const angle_p = angle + PI_half;

        // 頂点
        const pos1_x = this.x + BOID_HEIGHT * Math.cos(angle);
        const pos1_y = this.y - BOID_HEIGHT * Math.sin(angle);

        // 底辺両端
        const bottom_half = BOID_BOTTOM / 2.0;
        const pos2_x = this.x + bottom_half * Math.cos(angle_p);
        const pos2_y = this.y - bottom_half * Math.sin(angle_p);
        const pos3_x = this.x - bottom_half * Math.cos(angle_p);
        const pos3_y = this.y + bottom_half * Math.sin(angle_p);

        // 描画
        ctx.fillStyle = BOID_FILL_COLOR;
        ctx.strokeStyle = BOID_STROKE_COLOR;
        ctx.beginPath();
        ctx.moveTo(pos1_x, pos1_y);
        ctx.lineTo(pos2_x, pos2_y);
        ctx.lineTo(pos3_x, pos3_y);
        ctx.lineTo(pos1_x, pos1_y);
        ctx.fill();
        ctx.stroke();
    }
}


その他工夫

Boids アルゴリズムはそれぞれの Boid 間の距離を算出して動きを決めます。
一般的に sqrt 計算は時間がかかるとされているので、通常の L2 距離を求めるとすると、大量の sqrt が使われることになります。

Unity 辺りではよく使われる小技ですが、
比較用の定数等予め2乗しておき、距離算出も

    // 別の boid との2乗距離を算出
    distance2(other) {
        return Math.pow(this.x - other.x, 2) + Math.pow(this.y - other.y, 2);        
    }

と、sqrt しない距離との比較を行うことにより、一部計算をサボっています。
( どれだけ高速化に貢献しているかは測ってないのでわかりません。)

まとめ

Boids アルゴリズムを書き下し、基本形の中身を理解することができました。
既に完成しているものを書き下しただけなので、もう少し工夫を入れて、面白い連携具合になるようにしてみます。

今回のコード全文

以下に貼っておきます。

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 540;
const HEIGHT = 720;

// アイキャッチ画像撮影用大き目
// const WIDTH = 1000;
// const HEIGHT = 1000;

// はみ出したときの許容量
const MARGIN = 100;

// 色設定
const BACKGROUND_STROKE_COLOR = 'rgb(119, 196, 187)'; // 描画外枠色
const BACKGROUND_FILL_COLOR = 'rgb(209, 235, 231)'; // 背景色
const BOID_STROKE_COLOR = 'rgb(196, 120, 130)'; // Boid 外枠色
const BOID_FILL_COLOR = 'rgb(219, 173, 180)'; // Boid 内部色

// Boid 個数
const BOIDS_COUNT = 70;

// Boid 描画サイズ
const BOID_BOTTOM = 20;
const BOID_HEIGHT = 40;

// Boids パラメータ
const BOIDS_VISUAL_RANGE = 75; // 近くにいる Boid を群れとみなす距離
const BOIDS_AVOID_MIN_DISTANCE = 20; // 近すぎる Boid を避ける距離

const BOID_SPEED_LIMIT = 15; // 速度最大値

const BOID_CENTERING_FACTOR = 0.005; // flyTowardsCenter
const BOID_AVOID_FACTOR = 0.05; // avoidOthers
const BOID_MATCHING_FACTOR = 0.05; // matchingVelocity
const BOID_TERN_FACTOR = 1; // keepWithinBounds

// NOTE: 計算速度を鑑みてsqrtは使わず事前に2乗しておく。
const BOIDS_VISUAL_RANGE_2 = Math.pow(BOIDS_VISUAL_RANGE, 2);
const BOIDS_AVOID_MIN_DISTANCE_2 = Math.pow(BOIDS_AVOID_MIN_DISTANCE, 2);
const BOID_SPEED_LIMIT_2 = Math.pow(BOID_SPEED_LIMIT, 2);

// 計算用定数等
const PI = Math.PI;
const PI_half = Math.PI / 2.0;

function RAND() { return Math.random(); }


////////////////////
//
// Boid
// Boid 単体の管理クラス
//
class Boid {

    constructor(x, y, dx, dy) {
        this.x = x; this.y = y;
        this.dx = dx; this.dy = dy;
    }

    // 位置更新
    update(boid) {
        this.x = boid.x; this.y = boid.y;
        this.dx = boid.dx; this.dy = boid.dy;
    }

    // 別の boid との2乗距離を算出
    distance2(other) {
        return Math.pow(this.x - other.x, 2) + Math.pow(this.y - other.y, 2);        
    }

    // ctx に Boid を描画
    // 位置 (x, y) から進行方向に伸ばした線分を軸とした二等辺三角形
    draw(ctx) {
        // NOTE: 画面座標系では Y 座標が逆

        // 角度
        const angle = Math.atan2(-this.dy, this.dx);
        const angle_p = angle + PI_half;

        // 頂点
        const pos1_x = this.x + BOID_HEIGHT * Math.cos(angle);
        const pos1_y = this.y - BOID_HEIGHT * Math.sin(angle);

        // 底辺両端
        const bottom_half = BOID_BOTTOM / 2.0;
        const pos2_x = this.x + bottom_half * Math.cos(angle_p);
        const pos2_y = this.y - bottom_half * Math.sin(angle_p);
        const pos3_x = this.x - bottom_half * Math.cos(angle_p);
        const pos3_y = this.y + bottom_half * Math.sin(angle_p);

        // 描画
        ctx.fillStyle = BOID_FILL_COLOR;
        ctx.strokeStyle = BOID_STROKE_COLOR;
        ctx.beginPath();
        ctx.moveTo(pos1_x, pos1_y);
        ctx.lineTo(pos2_x, pos2_y);
        ctx.lineTo(pos3_x, pos3_y);
        ctx.lineTo(pos1_x, pos1_y);
        ctx.fill();
        ctx.stroke();
    }
}


////////////////////
//
// Boids
//
class Boids {

    constructor(width, height, count) {

        // 領域サイズ
        this.width = width;
        this.height = height;

        // 個数
        this.count = count;
        this.boids = [];
        for (let i = 0; i < this.count; i++) {
            this.boids.push(new Boid(RAND() * width, RAND() * height, RAND() * 10 - 5, RAND() * 10 - 5));
        }
    }

    update(ctx) {

        for (let idx = 0; idx < this.count; idx++) {

            // 
            // 移動量 (dx, dy) を更新
            //

            // 群れの中心に近づく
            this.#flyTowardsCenter(idx);

            // 周囲と近すぎる場合に離れる
            this.#avoidOthers(idx);

            // 群れと速度を合わせる
            this.#matchVelocity(idx);

            // 速くなり過ぎた場合の速度調整
            this.#limitSpeed(idx);

            // 境界に近づいた場合の調整
            this.#keepWithinBounds(idx);

            //
            // 位置 (x, y) 更新
            //
            const boid = this.boids[idx];
            boid.x += boid.dx;
            boid.y += boid.dy;
            this.boids[idx].update(boid);

            // 描画
            boid.draw(ctx);
        }
    }

    // 群れの中心に近づく
    #flyTowardsCenter(idx) {
        const boid = this.boids[idx];

        // 群れの中心位置を算出
        let center_x = 0; let center_y = 0;
        let neighbors_count = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_VISUAL_RANGE_2) {
                center_x += this.boids[i].x; center_y += this.boids[i].y;
                neighbors_count++;
            }
        }

        // 移動量調整
        if (neighbors_count > 0) {
            center_x /= neighbors_count; center_y /= neighbors_count;

            boid.dx += (center_x - boid.x) * BOID_CENTERING_FACTOR;
            boid.dy += (center_y - boid.y) * BOID_CENTERING_FACTOR;

            // Update
            this.boids[idx].update(boid);
        }
    }

    // 周囲と近すぎる場合に離れる
    #avoidOthers(idx) {
        const boid = this.boids[idx];

        // 近くの Boid の距離を取得
        let move_x = 0; let move_y = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_AVOID_MIN_DISTANCE_2) {
                move_x += boid.x - this.boids[i].x;
                move_y += boid.y - this.boids[i].y;
            }
        }

        // 移動量調整
        boid.dx += move_x * BOID_AVOID_FACTOR;
        boid.dy += move_y * BOID_AVOID_FACTOR;
        this.boids[idx].update(boid);
    }

    // 群れと速度を合わせる
    #matchVelocity(idx) {
        const boid = this.boids[idx];

        // 群れの移動量を取得
        let avg_dx = 0; let avg_dy = 0;
        let neighbors_count = 0;
        for (let i = 0; i < this.boids.length; i++) {
            if (i == idx) continue;
            if (boid.distance2(this.boids[i]) < BOIDS_VISUAL_RANGE_2) {
                avg_dx += this.boids[i].dx; avg_dy += this.boids[i].dy;
                neighbors_count += 1;
            }
        }

        // 移動量調整
        if (neighbors_count > 0) {
            avg_dx /= neighbors_count; avg_dy /= neighbors_count;
            boid.dx += (avg_dx - boid.dx) * BOID_MATCHING_FACTOR;
            boid.dy += (avg_dy - boid.dy) * BOID_MATCHING_FACTOR;
            this.boids[idx].update(boid);
        }
    }

    // 速くなり過ぎた場合の速度調整
    #limitSpeed(idx) {
        const boid = this.boids[idx];
        const speed2 = boid.dx * boid.dx + boid.dy * boid.dy;
        if (speed2 > BOID_SPEED_LIMIT_2) {
            const speed = Math.sqrt(speed2);
            boid.dx = (boid.dx / speed) * BOID_SPEED_LIMIT;
            boid.dy = (boid.dy / speed) * BOID_SPEED_LIMIT;
            this.boids[idx].update(boid);
        }
    }

    // 境界に近づいた場合の調整
    #keepWithinBounds(idx) {

        // Margin 混みで境界に近づいた時、移動量調整
        const boid = this.boids[idx];
        if (boid.x < MARGIN) {
            boid.dx += BOID_TERN_FACTOR;
        }
        if (boid.x > this.width - MARGIN) {
            boid.dx -= BOID_TERN_FACTOR;
        }
        if (boid.y < MARGIN) {
            boid.dy += BOID_TERN_FACTOR;
        }
        if (boid.y > this.height - MARGIN) {
            boid.dy -= BOID_TERN_FACTOR;
        }
        this.boids[idx].update(boid);
    }
}


////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // Boids
        this.boids = new Boids(this.width, this.height, BOIDS_COUNT);
    }

    // 更新処理
    update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        //////
        /// buff に描画
        ///

        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.fillStyle = BACKGROUND_FILL_COLOR;
        this.context_buff.strokeStyle = BACKGROUND_STROKE_COLOR;
        this.context_buff.fillRect(0, 0, this.width, this.height);
        this.context_buff.strokeRect(0, 0, this.width, this.height);
       
        // Boids 更新
        this.boids.update(this.context_buff);

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(WIDTH, HEIGHT, BASE_ID);
    canvas.update(0);
}

</script>

アイキャッチ用

【JavaScript & Canvas】等速度運動のボールを弾ませてみる

先日にボールを壁に反射させるのを作った。
コレ、ボールが反射するときに弾ませてみたら面白いかなーと思い、弾ませる方法を考えてみた。

作成物

内容は前と一緒。Canvas エリアにマウスカーソルを持っていくと、mousemove イベントによりボールが生成されます。
ボールは壁に反射する際、ちょっと弾んでから反射します。

技術要素

Canvas の楕円形

楕円は――と思ったら、ellipse() で描けるんですね。便利。
短辺と長辺を設定すれば描けるとのことでちょっと実験。

短辺 = 20、長辺 = 40 とした楕円と、
両辺 20 で楕円を描いたもの、同じく両辺 40 で楕円を描いたものを並べてみました。配置を見ると、細かい調整無く使えそう。

context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.ellipse(200, 200, 30, 60, 0, 0, 2 * Math.PI, false);
context.fill();
context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.arc(200, 400, 30, 0, 2 * Math.PI, false);
context.fill();
context.beginPath();
context.fillStyle = 'rgb(213, 83, 182)';
context.arc(400, 200, 60, 0, 2 * Math.PI, false);
context.fill();

弾ませる

弾ませるための定義。以下のように決めました。

『反射しなければいけない』内部の閾値および、『まだ進めるけどだんだん形が歪む』外側の閾値を定め、
・壁との距離が内側閾値以下 → 反射。
・壁との距離が外側閾値以下 → そのまま進むが、ボールを歪ませる (楕円形にする)。

あとは割合を計算するだけ。

実装においての問題とか

現在の実装では、射出方向によって、閾値を大きく超えてしまうことがあります。
初速の設定でマージンを大きくとるとか、反射したときに描画に矛盾が出ない位置まで移動するとかあるのでしょうが、今回はとりあえずこのままで。。

この「閾値大きく超える」問題により、ボールがベタっと反射するイメージ以上につぶれてしまうことになります。
せめてこの問題だけ軽減したく、
今回は X 方向、Y 方向の外側閾値に対して、45°位置を 1:1 として、マージンを付けることにしました。
例えば、20°方向に射出する場合、X方向の方が強くぶつかるので、X方向の弾みを閾値により少なくする形式です。

これを踏まえて、Point クラスは以下のようになっています。

class Point {

    constructor(x, y, ts) {

        // 初期位置およびタイムスタンプ
        this.x = x
        this.y = y
        this.ts = ts

        // 有効フラグ
        // 無効になったら上位モジュールより削除
        this.enabled = true;

        // 表示色
        this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')'

        // 大きさ
        const frac = Math.random();
        // this.size = frac * 20 + 10; // 10 ~ 30

        // 外側サイズ ... 壁に弾んだときに歪む
        this.outer_size = frac * 20 + 10; // 10 ~ 30
        this.outer_size_x = this.outer_size;
        this.outer_size_y = this.outer_size;

        // 内側サイズ ... 壁の反射
        this.inner_size = this.outer_size * 3.0 / 4.0; // 外側サイズの 3/4

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.3 + 0.3; // 0.3 ~ 0.6

        // 投射方向
        this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi

        // 速度によって貫通してしまうので、縦・横でそれぞれ弾む許容量を設定
        const outer_inner_diff = Math.abs(this.outer_size - this.inner_size);
        this.outer_size_base_x = this.inner_size + ((Math.cos(this.direction) + 1) / 2.0) * outer_inner_diff;
        this.outer_size_base_y = this.inner_size + ((Math.sin(this.direction) + 1) / 2.0) * outer_inner_diff;

        // 反射
        this.ref_count = 0;
        this.ref_x = 1;
        this.ref_y = 1;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.ts;

        // 速度
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction);

        // 位置を算出
        const dx = this.vx * ts_diff;
        const dy = this.vy * ts_diff;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x += this.ref_x * dx
        this.y += - this.ref_y * dy;

        // 現在 timestamp 更新
        this.ts = ts;

        // 中心から壁までの距離を算出
        const wall_dist_x = Math.min(Math.max(0, this.x), Math.abs(WIDTH - this.x));
        const wall_dist_y = Math.min(Math.max(0, this.y), Math.abs(HEIGHT - this.y));
        const wall_dist = Math.min(wall_dist_x, wall_dist_y);

        // 中心から壁までの距離が内側サイズより小さかったら反射
        if (wall_dist_x < this.inner_size) {this.ref_x *= -1; this.ref_count++;}
        if (wall_dist_y < this.inner_size) {this.ref_y *= -1; this.ref_count++;}

        // 外側サイズより小さかったら、その分だけ歪ませる
        // 短い方を採用
        if (wall_dist < this.outer_size) {
            if (wall_dist_x < wall_dist_y) {
                const frac = wall_dist_x / this.outer_size_base_x;
                this.outer_size_x = wall_dist_x;
                this.outer_size_y = this.outer_size / frac;            
            }
            else {
                const frac = wall_dist_y / this.outer_size_base_y;
                this.outer_size_x = this.outer_size / frac;
                this.outer_size_y = wall_dist_y;
            }
        }
        else {
            this.outer_size_x = this.outer_size;
            this.outer_size_y = this.outer_size;
        }

        // 一定数跳ね返ったら無効化
        if (this.ref_count >= 50) this.enabled = false;
    }    
}

コード全文

以下に貼っておきます。

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 640;
const HEIGHT = 480;

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
class Point {

    constructor(x, y, ts) {

        // 初期位置およびタイムスタンプ
        this.x = x
        this.y = y
        this.ts = ts

        // 有効フラグ
        // 無効になったら上位モジュールより削除
        this.enabled = true;

        // 表示色
        this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')'

        // 大きさ
        const frac = Math.random();
        // this.size = frac * 20 + 10; // 10 ~ 30

        // 外側サイズ ... 壁に弾んだときに歪む
        this.outer_size = frac * 20 + 10; // 10 ~ 30
        this.outer_size_x = this.outer_size;
        this.outer_size_y = this.outer_size;

        // 内側サイズ ... 壁の反射
        this.inner_size = this.outer_size * 3.0 / 4.0; // 外側サイズの 3/4

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.3 + 0.3; // 0.3 ~ 0.6

        // 投射方向
        this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi

        // 速度によって貫通してしまうので、縦・横でそれぞれ弾む許容量を設定
        const outer_inner_diff = Math.abs(this.outer_size - this.inner_size);
        this.outer_size_base_x = this.inner_size + ((Math.cos(this.direction) + 1) / 2.0) * outer_inner_diff;
        this.outer_size_base_y = this.inner_size + ((Math.sin(this.direction) + 1) / 2.0) * outer_inner_diff;

        // 反射
        this.ref_count = 0;
        this.ref_x = 1;
        this.ref_y = 1;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.ts;

        // 速度
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction);

        // 位置を算出
        const dx = this.vx * ts_diff;
        const dy = this.vy * ts_diff;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x += this.ref_x * dx
        this.y += - this.ref_y * dy;

        // 現在 timestamp 更新
        this.ts = ts;

        // 中心から壁までの距離を算出
        const wall_dist_x = Math.min(Math.max(0, this.x), Math.abs(WIDTH - this.x));
        const wall_dist_y = Math.min(Math.max(0, this.y), Math.abs(HEIGHT - this.y));
        const wall_dist = Math.min(wall_dist_x, wall_dist_y);

        // 中心から壁までの距離が内側サイズより小さかったら反射
        if (wall_dist_x < this.inner_size) {this.ref_x *= -1; this.ref_count++;}
        if (wall_dist_y < this.inner_size) {this.ref_y *= -1; this.ref_count++;}

        // 外側サイズより小さかったら、その分だけ歪ませる
        // 短い方を採用
        if (wall_dist < this.outer_size) {
            if (wall_dist_x < wall_dist_y) {
                const frac = wall_dist_x / this.outer_size_base_x;
                this.outer_size_x = wall_dist_x;
                this.outer_size_y = this.outer_size / frac;            
            }
            else {
                const frac = wall_dist_y / this.outer_size_base_y;
                this.outer_size_x = this.outer_size / frac;
                this.outer_size_y = wall_dist_y;
            }
        }
        else {
            this.outer_size_x = this.outer_size;
            this.outer_size_y = this.outer_size;
        }

        // 一定数跳ね返ったら無効化
        if (this.ref_count >= 50) this.enabled = false;
    }    
}

////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;
        
        // 情報表示用
        this.text = document.createElement("div");

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
            document.body.appendChild(this.text);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
            base.appendChild(this.text);
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // イベント追加
        // this.canvas.addEventListener('click', this.onclick.bind(this), false);

        // 「マウス上にあれば常に」版
        this.canvas.addEventListener('mousemove', this.onclick.bind(this), false);

        // アニメーション用カウント
        this.count = 0;

        // クリック位置
        this.click_x = null;
        this.click_y = null;

        // 点群
        this.points = []
    }

    // クリック時イベント
    onclick(event)
    {
        const rect = event.target.getBoundingClientRect();
        this.click_x = event.clientX - rect.left;
        this.click_y = event.clientY - rect.top;
    }

    // 更新処理
    update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        // クリックされていたら点を追加
        if (this.click_x != null && this.click_y != null) {
            this.points.push(new Point(this.click_x, this.click_y, timestamp))
            this.click_x = null;
            this.click_y = null;
        }

        //////
        /// buff に描画
        ///
        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.strokeRect(0, 0, this.width, this.height);
       
        // animation
        this.points.forEach(element => {
            element.update(timestamp);
            this.context_buff.beginPath();
            this.context_buff.ellipse(element.x, element.y, element.outer_size_x, element.outer_size_y, 0, 2 * Math.PI, false);
            this.context_buff.fillStyle = element.color;
            this.context_buff.fill();
        });

        // 範囲外に出たものを除外
        this.points = this.points.filter((value, index, array) => value.enabled);

        // 現在のボール数
        this.text.innerHTML = "Ball count : " + this.points.length;

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(WIDTH, HEIGHT, BASE_ID);
    canvas.update(0);
}

</script>


アイキャッチ用

【JavaScript & Canvas】一定間隔で更新する描画を作ってみる。(ライフゲーム)

前回まではベストエフォートで常に更新処理終わったら描画の更新をかける方針でした。
そうではなく「一定のタイミングで更新」かける方式の描画についても試してみます。

ライフゲームはよく作っていて分かりやすいのでこれで。

作成物




ライフゲームについてはたくさん解説記事あると思うので割愛。
色が付いているのは、3つのレイヤのライフゲームが同時に動いていて、その重なりによって色を変えているからです。
レイヤ同士が相互作用起こすルールは入ってないので、通常のライフゲーム以上の複雑な動きはしませんが、それなりに色が変わって面白いかも?

技術要素

処理待ち

ベースとなる部分は同じにして、ここに「設定処理時間よりも速く処理が終わったら待つ」処理を入れます。
元のコードは

class CanvasOp {
    ( 中略 )

    update(timestamp) {
        ( 中略。更新処理。)

        // 次フレームへ
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}

let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

という感じでした。

処理待ちに関しては、Promise と setTimeout を組み合わせると実現できるそうなのでこれを採用。
また、時間計測も必要で、これは performance が最近の Javascript で使えるようなのでこちらを使います。
await キーワードが入るとその関数には async を付ける必要があるとのこと。
( C# みたいに await - async 地獄になるかと思ったけど、そういうわけでは無いらしい?あまり調べてない。)

最終形は次の通り。

class CanvasOp {
    ( 中略 )

    async update(timestamp) {

        // 計測開始
        const start = performance.now();

        ( 更新処理 )

        // 計測終了
        const end = performance.now();

        // 1フレーム分処理が設定時間を割っていたら、それだけ待つ
        const elapsed = end - start;
        if (elapsed < WAITING_MS) {
            const sleep = ms => new Promise(res => setTimeout(res, ms));
            await sleep(WAITING_MS - elapsed);
        }

        // 次フレームへ
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}

let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

ライフゲーム

ライフゲームの実装は
・Field ... 線やセルの描画を管理
・LifeGameCells ... 盤面のセルの動きの管理。隣接数の計算とか次フレームの生死の決定とか。
・Game ... 全体の管理。
という3つのクラスで動かしています。

1つ分を LifeGameCells に閉じ込めることによって、3つのレイヤを別々に動かせることができるようになりました。

class Game {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面
        // 3 Layer 用意し、有効状態によって色を切り替え
        this.cells01 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells02 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells03 = new LifeGameCells(this.h_cell_count, this.h_cell_count);

        // 配色
        this.colors = Array.from(CELL_COLORS);

        // 初期化
        this.init();
    }

    // 描画対象の座標 & 色を返す
    getPoints() {
        let points = [];
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let color_idx = 0;
                if (this.cells01.get(x, y) == ALIVE) color_idx += 1;
                if (this.cells02.get(x, y) == ALIVE) color_idx += 2;
                if (this.cells03.get(x, y) == ALIVE) color_idx += 4;

                if (color_idx == 0) continue;
                points.push({x: x, y: y, color: this.colors[color_idx]});                
            }
        }
        return points;
    }

    // 初期化
    init() {
        // ランダムで生死状態を設定
        this.cells01.random();
        this.cells02.random();
        this.cells03.random();
    }

    // 更新
    update() {
        // 現在の結果の隣接数を更新
        this.cells01.update();
        this.cells02.update();
        this.cells03.update();
    }
}

( コード全文は記事の一番下においたので、興味ある方はそちらを。)

レイヤ同士の相互干渉とか書けると面白そうですね。。どういう動きするとよいのだろうか……?

その他

セルのサイズ、線の太さ、セルの個数で全体サイズが決まっています。
ここでは、はてなブログ表示できるサイズになっていますが、高速処理可能ならもっと大きくしても面白いかも。

// セル1辺サイズ (pixel)
const CELL_SIZE = 6;

// 区切り線の太さ (pixel)
const LINE_WIDTH = 1;

// 横方向および縦方向のセル個数
const H_CELL_COUNT = 80;
const V_CELL_COUNT = 80;

色についてはグラデーション配色を出してくれるサイトより。

// 色設定
// 3レイヤの色を設定して、有効状態に応じて切り替える。
// 配色は https://fffuel.co/hhhue/ より
let CELL_COLORS = [];
CELL_COLORS.push(''); // 0番目は空白
CELL_COLORS.push('rgb(236,91,19)');
CELL_COLORS.push('rgb(255,140,69)');
CELL_COLORS.push('rgb(255,227,209)');
CELL_COLORS.push('rgb(19,236,200)');
CELL_COLORS.push('rgb(108,255,251)');
CELL_COLORS.push('rgb(209,255,253)');
CELL_COLORS.push('rgb(0,161,207)');

あと、待ち時間も設定値です。

// 処理待ちを行う時間 (ミリ秒)
// 更新処理がこの値より短かった場合は、その分だけ描画更新を待つ。
const WAITING_MS = 100;

コード全体

<div id="canvas_base"></div>
<div>
    <input type="button" value="Reset" onclick="resetButtonClick()" />
</div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// セル1辺サイズ (pixel)
const CELL_SIZE = 6;

// 区切り線の太さ (pixel)
const LINE_WIDTH = 1;

// 横方向および縦方向のセル個数
const H_CELL_COUNT = 80;
const V_CELL_COUNT = 80;

// 処理待ちを行う時間 (ミリ秒)
// 更新処理がこの値より短かった場合は、その分だけ描画更新を待つ。
const WAITING_MS = 100;

// 描画エリアサイズ ... セル個数から計算
const WIDTH = H_CELL_COUNT * (CELL_SIZE + LINE_WIDTH) + LINE_WIDTH;
const HEIGHT = V_CELL_COUNT * (CELL_SIZE + LINE_WIDTH) + LINE_WIDTH;

// 状態
const DEAD = 0;
const ALIVE = 1;

// 色設定
// 3レイヤの色を設定して、有効状態に応じて切り替える。
// 配色は https://fffuel.co/hhhue/ より
let CELL_COLORS = [];
CELL_COLORS.push(''); // 0番目は空白
CELL_COLORS.push('rgb(236,91,19)');
CELL_COLORS.push('rgb(255,140,69)');
CELL_COLORS.push('rgb(255,227,209)');
CELL_COLORS.push('rgb(19,236,200)');
CELL_COLORS.push('rgb(108,255,251)');
CELL_COLORS.push('rgb(209,255,253)');
CELL_COLORS.push('rgb(0,161,207)');

////////////////////
//
// 描画フィールド操作クラス
// 格子盤に円形を配置
//
class Field {

    constructor(context, width, height, cell_size, line_width, h_cell_count, v_cell_count) {
        // Canvas 操作用 Context
        this.context = context

        // Canvas サイズ
        this.width = width;
        this.height = height;

        // 要素サイズ
        this.cell_size = cell_size;

        // 境界線幅
        this.line_width = line_width;

        // 水平方向要素数
        this.h_cell_count = h_cell_count;

        // 垂直方向要素数
        this.v_cell_count = v_cell_count;

        // 計算用
        this.pos_offset = this.line_width + this.cell_size / 2.0; // 要素円形描画の開始位置
        this.pos_step = this.line_width + this.cell_size; // 格子や要素描画の移動幅
        this.arc_radius = this.cell_size / 2.0;

        // 格子色
        this.lattice_color = 'rgb(211, 211, 211)';
    }

    // Image data
    getImageData() {
        return this.context.getImageData(0, 0, this.width, this.height);
    }

    // 表示更新
    update(points) {

        // Clear
        this.context.clearRect(0, 0, this.width, this.height);

        // 格子描画
        this.drawLattice();

        // 点描画
        this.drawPoints(points);
    }


    // 格子描画
    drawLattice() {

        this.context.beginPath();
        this.context.strokeStyle = this.lattice_color;
        // 縦
        for (let x = 0; x <= this.h_cell_count; x++) {
            const pos = x * this.pos_step;
            this.context.moveTo(pos, 0);
            this.context.lineTo(pos, this.height);
        }

        // 横
        for (let y = 0; y <= this.v_cell_count; y++) {
            const pos = y * this.pos_step;
            this.context.moveTo(0, pos);
            this.context.lineTo(this.width, pos);
        }

        this.context.stroke();
    }

    // 点の描画
    drawPoints(points) {

        points.forEach(elem => {
            this.context.beginPath();
            this.context.fillStyle = elem.color;
            const pos_x = this.pos_offset + this.pos_step * elem.x;
            const pos_y = this.pos_offset + this.pos_step * elem.y;
            this.context.arc(pos_x, pos_y, this.arc_radius, 0, 2 * Math.PI, false);
            this.context.fill();
        });
    }
}


////////////////////
// 
// Life game セル操作
//
class LifeGameCells {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面の状態保持
        this.cells = new Array(this.h_cell_count * this.v_cell_count);       // 現フレーム
        this.next_cells = new Array(this.h_cell_count * this.v_cell_count);  // 次フレーム

        // 周辺の生存個数カウント
        this.neighborhood = new Array(this.h_cell_count * this.v_cell_count);

        // 初期化
        this.init();
    }

    // 現状態を 0 = DEAD で初期化
    init() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.set(x, y, DEAD);
            }
        }
    }

    // 次状態を 0 = DEAD で初期化
    initNext() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.setNext(x, y, DEAD);
            }
        }
    }

    // ランダムで DEAD or ALIVE 設定
    random() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.set(x, y, Math.floor(Math.random() * 2)); // 0 or 1
            }
        }
    }

    // 次フレーム情報更新
    update() {
        // 次フレーム状態更新
        this.initNext();

        // 周辺情報更新
        this.updateNeighborhood();

        // 情報更新
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                // DEAD 状態時
                if (this.get(x, y) == DEAD) {
                    if (this.getNeighborhood(x, y) == 3) {
                        this.setNext(x, y, ALIVE);
                    }
                }
                // ALIVE 状態時
                else {
                    const n = this.getNeighborhood(x, y);
                    if (n == 2 || n == 3) {
                        this.setNext(x, y, ALIVE);
                    }
                }
            }
        }

        // 次フレーム → 現フレーム
        this.cells = Array.from(this.next_cells);
    }

    // 自身の周辺の個数をカウントする。
    updateNeighborhood() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let nx2, ny2;
                let count = 0;
                for (let ny = y - 1; ny <= y + 1; ny++) {
                    for (let nx = x - 1; nx <= x + 1; nx++) {
                        if (nx == x && ny == y) continue;
                        nx2 = nx; ny2 = ny;

                        // Loop
                        if (ny2 < 0) ny2 += this.v_cell_count;
                        ny2 %= this.v_cell_count;
                        if (nx2 < 0) nx2 += this.h_cell_count;
                        nx2 %= this.h_cell_count;

                        if (this.get(nx2, ny2) == ALIVE) count++;
                    }
                }
                this.setNeighborhood(x, y, count);
            }
        }
    }

    // (x, y) の状態を取得
    get(x, y) {
        return this.cells[x + y * this.h_cell_count];
    }

    // (x, y) の状態を設定
    set(x, y, value) {
        this.cells[x + y * this.h_cell_count] = value;
    }

    // (x, y) の次フレーム状態を取得
    getNext(x, y) {
        return this.next_cells[x + y * this.h_cell_count];
    }

    // (x, y) の次フレーム状態を設定
    setNext(x, y, value) {
        this.next_cells[x + y * this.h_cell_count] = value;
    }    

    // (x, y) の周辺カウント数を取得
    getNeighborhood(x, y) {
        return this.neighborhood[x + y * this.h_cell_count] 
    }

    // (x, y) の周辺カウント数を設定
    setNeighborhood(x, y, value) {
        this.neighborhood[x + y * this.h_cell_count] = value;
    }
}


////////////////////
//
// Life game 処理
//
class Game {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面
        // 3 Layer 用意し、有効状態によって色を切り替え
        this.cells01 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells02 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells03 = new LifeGameCells(this.h_cell_count, this.h_cell_count);

        // 配色
        this.colors = Array.from(CELL_COLORS);

        // 初期化
        this.init();
    }

    // 描画対象の座標 & 色を返す
    getPoints() {
        let points = [];
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let color_idx = 0;
                if (this.cells01.get(x, y) == ALIVE) color_idx += 1;
                if (this.cells02.get(x, y) == ALIVE) color_idx += 2;
                if (this.cells03.get(x, y) == ALIVE) color_idx += 4;

                if (color_idx == 0) continue;
                points.push({x: x, y: y, color: this.colors[color_idx]});                
            }
        }
        return points;
    }

    // 初期化
    init() {
        // ランダムで生死状態を設定
        this.cells01.random();
        this.cells02.random();
        this.cells03.random();
    }

    // 更新
    update() {
        // 現在の結果の隣接数を更新
        this.cells01.update();
        this.cells02.update();
        this.cells03.update();
    }
}


////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, cell_size, line_width, h_cell_count, v_cell_count, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;
        
        // 情報表示用
        this.text = document.createElement("div");

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
            document.body.appendChild(this.text);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
            base.appendChild(this.text);
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // Life game 制御
        this.game = new Game(h_cell_count, v_cell_count);
        this.game.init();

        // 描画用フィールド
        this.field = new Field(this.context_buff, width, height, cell_size, line_width, h_cell_count, v_cell_count);

        // リセット処理用フラグ
        this.reset_flag = false;
    }

    // リセット
    reset() {
        this.reset_flag = true;
    }

    // 更新処理
    async update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        // 処理開始
        const start = performance.now();

        //////
        /// buff に描画
        ///
        if (this.reset_flag) {
            // リセット
            this.game.init();
            this.reset_flag = false;
        }
        else {
            // Life game 更新
            this.game.update();
        }
        // 表示更新
        this.field.update(this.game.getPoints());

        //////
        /// Canvas に転送
        ///
        this.context.putImageData(this.field.getImageData(), 0, 0);

        // 処理終了
        const end = performance.now();

        // 1フレーム分処理が設定時間を割っていたらそれだけ待つ
        const elapsed = end - start;
        if (elapsed < WAITING_MS) {
            const sleep = ms => new Promise(res => setTimeout(res, ms));
            await sleep(WAITING_MS - elapsed);
        }

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

function resetButtonClick()
{
    canvas.reset();
}

</script>


アイキャッチ用

【JavaScript & Canvas】 投射と跳ね返り

前回 : 等速度運動
等速度運動やったなら、斜方投射もやっておこうと物理の計算を復習。

作成物

マウスオーバーで円 (= ボール) を作り出すのは前回と同じ。
右方向を0°として、0° ~ 180°の方向にランダムなボールを生成して投射します。
X方向の範囲外部分はループ。
Y方向下側が地面になっていて、衝突するとバウンドします。

技術要素

投射

物理の知識に詳しい記事はいっぱいあるので省略。
コンストラクタで初速を設定し、それを X 方向・Y方向に分割して速度を算出し、更に位置も算出します。

class Point {

    constructor(x, y, ts) {

        // 初期位置および初期タイムスタンプ
        this.first_x = x;
        this.first_y = y;
        this.first_ts = ts;

        // 初速
        const frac = Math.random();
        this.v0 = (1 - frac) * 0.3 + 0.7; // 0.3 ~ 1.0

        // 投射方向
        this.direction = Math.random() * Math.PI; // 0 ~ 180°

        // 加速度
        // NOTE: 重力 = 9.8 を使うと速すぎるので適当な小さい値を設定。
        this.a = 0.001;

        (後略)
    }

    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.first_ts;
        const ts_diff2 = ts_diff * ts_diff; // 2乗

        // 速度
        // NOTE: x は等速度、y は等加速度で落下。
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction) - this.a * ts_diff;

        // 位置を算出
        const dx = this.v0 * Math.cos(this.direction) * ts_diff;
        const dy = this.v0 * Math.sin(this.direction) * ts_diff - 0.5 * this.a * ts_diff2;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x = this.first_x + dx;
        this.y = this.first_y - dy; 

        ( 後略 )
    }
}

ループ & 反射

X方向はループ処理にしました。
範囲を超えたら、その分だけ加算 or 減算します。

        // x方向 ... 壁にぶつかったらループ
        while (this.x < 0) this.x += WIDTH;
        while (this.x > WIDTH) this.x -= WIDTH;

Y方向は反射。
衝突時の反射角度、衝突時速度を求め、衝突した位置を初期位置にして描画することで実現しています。
衝突時速度に反発係数を乗算して、徐々に速度が 0 になるようになっています。

        // y方向 ... 反射角度および衝突時距離を算出し、衝突位置より描画を再開
        if (this.y > HEIGHT) {

            // 衝突時の速度から反射角度を算出
            this.direction = Math.abs(Math.PI / 2.0 - Math.atan(this.vx, this.vy));

            // 衝突時速度
            this.v0 = Math.sqrt(this.vx * this.vx + this.vy * this.vy) * REFLECTION; // 反発係数

            // 激突位置から再開
            this.first_ts = ts
            this.first_y = HEIGHT;
            this.first_x = this.x;

            // 速度が小さくなったら無効化
            if (this.v0 < REFLECTION_VMIN) this.enabled = false;
        }

Canvas の操作とかは前回と一緒なので省略。

コード全体

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 500;
const HEIGHT = 500;

// 反発係数
const REFLECTION = 0.8;

// 反射時に速度が一定以下になったら除外
const REFLECTION_VMIN = 0.2;

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
class Point {

    constructor(x, y, ts) {

        // 初期位置および初期タイムスタンプ
        this.first_x = x;
        this.first_y = y;
        this.first_ts = ts;

        // 有効フラグ
        // 無効になったら上位モジュールより削除
        this.enabled = true;

        // 表示色
        this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')'

        // 大きさ
        const frac = Math.random();
        this.size = frac * 20 + 10; // 10 ~ 30

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.3 + 0.7; // 0.3 ~ 1.0

        // 投射方向
        this.direction = Math.random() * Math.PI; // 0 ~ 180°

        // 加速度
        // NOTE: 重力 = 9.8 を使うと速すぎるので適当な小さい値を設定。
        this.a = 0.001;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.first_ts;
        const ts_diff2 = ts_diff * ts_diff; // 2乗

        // 速度
        // NOTE: x は等速度、y は等加速度で落下。
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction) - this.a * ts_diff;

        // 位置を算出
        const dx = this.v0 * Math.cos(this.direction) * ts_diff;
        const dy = this.v0 * Math.sin(this.direction) * ts_diff - 0.5 * this.a * ts_diff2;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x = this.first_x + dx;
        this.y = this.first_y - dy; 

        // x方向 ... 壁にぶつかったらループ
        while (this.x < 0) this.x += WIDTH;
        while (this.x > WIDTH) this.x -= WIDTH;

        // y方向 ... 反射角度および衝突時距離を算出し、衝突位置より描画を再開
        if (this.y > HEIGHT) {

            // 衝突時の速度から反射角度を算出
            this.direction = Math.abs(Math.PI / 2.0 - Math.atan(this.vx, this.vy));

            // 衝突時速度
            this.v0 = Math.sqrt(this.vx * this.vx + this.vy * this.vy) * REFLECTION; // 反発係数

            // 激突位置から再開
            this.first_ts = ts
            this.first_y = HEIGHT;
            this.first_x = this.x;

            // 速度が小さくなったら無効化
            if (this.v0 < REFLECTION_VMIN) this.enabled = false;
        }
    }    
}


////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;
        
        // 情報表示用
        this.text = document.createElement("div");

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
            document.body.appendChild(this.text);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
            base.appendChild(this.text);
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // イベント追加
        // this.canvas.addEventListener('click', this.onclick.bind(this), false);

        // 「マウス上にあれば常に」版
        this.canvas.addEventListener('mousemove', this.onclick.bind(this), false);

        // アニメーション用カウント
        this.count = 0;

        // クリック位置
        this.click_x = null;
        this.click_y = null;

        // 点群
        this.points = []
    }

    // クリック時イベント
    onclick(event)
    {
        const rect = event.target.getBoundingClientRect();
        this.click_x = event.clientX - rect.left;
        this.click_y = event.clientY - rect.top;
    }

    // 更新処理
    update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        // クリックされていたら点を追加
        if (this.click_x != null && this.click_y != null) {
            this.points.push(new Point(this.click_x, this.click_y, timestamp))
            this.click_x = null;
            this.click_y = null;
        }

        //////
        /// buff に描画
        ///
        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.strokeRect(0, 0, this.width, this.height);
       
        // animation
        this.points.forEach(element => {
            element.update(timestamp);
            this.context_buff.beginPath();
            this.context_buff.arc(element.x, element.y, element.size, 0, 2 * Math.PI, false);
            this.context_buff.fillStyle = element.color;
            this.context_buff.fill();
        });

        // 範囲外に出たものを除外
        this.points = this.points.filter((value, index, array) => value.enabled);

        // 現在のボール数
        this.text.innerHTML = "Ball count : " + this.points.length;

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(WIDTH, HEIGHT, BASE_ID);
    canvas.update(0);
}

</script>

アイキャッチ用

前回と一緒っぽいけど……。

【JavaScript & Canvas】 等速度運動と反射

前回に下地となるクラスを作ったので、アニメーションが活かせる簡単な処理を作ってみます。

『ボールを投げて壁に反射して――』が分かり易そうなのでコレを作ってみよう。

作成物


Canvas エリアにマウスカーソルを持っていくと、mousemove イベントにより球体が生成されます。
球体は何回か壁に反射して消滅します。
マウス動かして大量生成すると、カラフルな感じになります。

技術要素

描画している点を管理する「Point」クラス

Canvas の処理で描画位置等管理するのは大変なので、描画位置やスタイルを管理するための Point クラスを作ります。
コンストラクタで初期位置およびタイムスタンプを入力。カラーや大きさはランダムで設定しておきます。
更新 (update) 時に前回タイムスタンプとの差分を取って、その分だけ等速度に移動させます。
この辺は高校生の物理辺りですかね。詳しいブログいっぱいあるだろうから省略。

反射については、1 or -1 が入る変数 ref_x および ref_y を用意して、壁にぶつかるごとに符号を入れ替えています。
……が、速度によっては通り過ぎてしまい、抜け出せないまま反射最大数を超えることになります。。

反射最大数を超えると有効化フラグが false となります。無効となった point は上位モジュールにより削除します。

Point クラス :

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
class Point {

    constructor(x, y, ts) {

        // 初期位置およびタイムスタンプ
        this.x = x
        this.y = y
        this.ts = ts

        // 有効フラグ
        // 無効になったら上位モジュールより削除
        this.enabled = true;

        // 表示色
        this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')'

        // 大きさ
        const frac = Math.random();
        this.size = frac * 20 + 10; // 10 ~ 30

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.7 + 0.5; // 0.5 ~ 1.2

        // 投射方向
        this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi

        // 反射
        this.ref_count = 0;
        this.ref_x = 1;
        this.ref_y = 1;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.ts;

        // 速度
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction);

        // 位置を算出
        const dx = this.vx * ts_diff;
        const dy = this.vy * ts_diff;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x += this.ref_x * dx
        this.y += - this.ref_y * dy;

        // 現在 timestamp 更新
        this.ts = ts;

        // 反射
        if (this.x < 0 || this.x > WIDTH) {this.ref_x *= -1; this.ref_count++;}
        if (this.y < 0 || this.y > HEIGHT) {this.ref_y *= -1; this.ref_count++;}

        // 一定数跳ね返ったら無効化
        if (this.ref_count >= 50) this.enabled = false;
    }    
}


描画更新処理

前回、下地クラスを作った時の更新処理で用いた window.requestAnimationFrame() で呼び出される update ですが、第一引数にタイムスタンプが入ってきます。
よって、このタイムスタンプの差分を使うことで、差分だけ移動させることが可能です。

まず、point の生成は、マウスオーバーのイベント発生時にその座標を保持しておき、

// (始めはマウスクリックイベントのつもりだったので名前が onclick。。)
onclick(event)
{
    const rect = event.target.getBoundingClientRect();
    this.click_x = event.clientX - rect.left;
    this.click_y = event.clientY - rect.top;
}

update 処理内で、位置が設定されていたら点を生成します。

// ( update 内 )
if (this.click_x != null && this.click_y != null) {
    this.points.push(new Point(this.click_x, this.click_y, timestamp))
    this.click_x = null;
    this.click_y = null;
}

JavaScript は forEach 使えるの便利。これを使って描画。

this.points.forEach(element => {
    element.update(timestamp);
    this.context_buff.beginPath();
    this.context_buff.arc(element.x, element.y, element.size, 0, 2 * Math.PI, false);
    this.context_buff.fillStyle = element.color;
    this.context_buff.fill();
});

Point クラスで、「一定数反射したら enabled = false にする」というのを入れているので、
条件を満たした point を filter 使って削除します。

// 範囲外に出たものを除外
this.points = this.points.filter((value, index, array) => value.enabled);

コード全文

貼っておきます。

コード全文 :

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 500;
const HEIGHT = 500;

////////////////////
//
// 描画する点
// 物理法則に従って落下させる
// 
class Point {

    constructor(x, y, ts) {

        // 初期位置およびタイムスタンプ
        this.x = x
        this.y = y
        this.ts = ts

        // 有効フラグ
        // 無効になったら上位モジュールより削除
        this.enabled = true;

        // 表示色
        this.color = 'rgb(' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ',' + Math.floor(Math.random() * 256) + ')'

        // 大きさ
        const frac = Math.random();
        this.size = frac * 20 + 10; // 10 ~ 30

        // 初速
        // 大きさに反比例
        this.v0 = (1 - frac) * 0.7 + 0.5; // 0.5 ~ 1.2

        // 投射方向
        this.direction = Math.random() * Math.PI * 2; // 0 ~ 2 * Pi

        // 反射
        this.ref_count = 0;
        this.ref_x = 1;
        this.ref_y = 1;
    }

    // 現在位置を取得
    update(ts) {
        // 初期時刻からの差分
        const ts_diff = ts - this.ts;

        // 速度
        this.vx = this.v0 * Math.cos(this.direction);
        this.vy = this.v0 * Math.sin(this.direction);

        // 位置を算出
        const dx = this.vx * ts_diff;
        const dy = this.vy * ts_diff;

        // 初期位置に加算
        // NOTE: canvas 座標系は y 下方向が正方向
        this.x += this.ref_x * dx
        this.y += - this.ref_y * dy;

        // 現在 timestamp 更新
        this.ts = ts;

        // 反射
        if (this.x < 0 || this.x > WIDTH) {this.ref_x *= -1; this.ref_count++;}
        if (this.y < 0 || this.y > HEIGHT) {this.ref_y *= -1; this.ref_count++;}

        // 一定数跳ね返ったら無効化
        if (this.ref_count >= 50) this.enabled = false;
    }    
}


////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;
        
        // 情報表示用
        this.text = document.createElement("div");

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
            document.body.appendChild(this.text);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
            base.appendChild(this.text);
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // イベント追加
        // this.canvas.addEventListener('click', this.onclick.bind(this), false);

        // 「マウス上にあれば常に」版
        this.canvas.addEventListener('mousemove', this.onclick.bind(this), false);

        // アニメーション用カウント
        this.count = 0;

        // クリック位置
        this.click_x = null;
        this.click_y = null;

        // 点群
        this.points = []
    }

    // クリック時イベント
    onclick(event)
    {
        const rect = event.target.getBoundingClientRect();
        this.click_x = event.clientX - rect.left;
        this.click_y = event.clientY - rect.top;
    }

    // 更新処理
    update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        // クリックされていたら点を追加
        if (this.click_x != null && this.click_y != null) {
            this.points.push(new Point(this.click_x, this.click_y, timestamp))
            this.click_x = null;
            this.click_y = null;
        }

        //////
        /// buff に描画
        ///
        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.strokeRect(0, 0, this.width, this.height);
       
        // animation
        this.points.forEach(element => {
            element.update(timestamp);
            this.context_buff.beginPath();
            this.context_buff.arc(element.x, element.y, element.size, 0, 2 * Math.PI, false);
            this.context_buff.fillStyle = element.color;
            this.context_buff.fill();
        });

        // 範囲外に出たものを除外
        this.points = this.points.filter((value, index, array) => value.enabled);

        // 現在のボール数
        this.text.innerHTML = "Ball count : " + this.points.length;

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(WIDTH, HEIGHT, BASE_ID);
    canvas.update(0);
}

</script>

アイキャッチ用

【JavaScript & Canvas】 アレコレ作るための下地クラスを整備

前回、JavaScript の復習と仕事の復習かねて Canvas やってたら楽しかったので、もう少しアレコレ作ってみる。

――その前に、アニメーションとかやろうとするとコードがごちゃごちゃになるのは目に見えているので、
その辺を整理する。

作成物

今回は特に工夫は無く、
アニメーションで■が左上から右下に移動するだけです。

技術要素

HTML タグ作成する必要は無い。

document.addElement() があるので、canvas 要素を作って body に組み込んでしまえばよいのでした。

また、いくつか調べて分かった知識が追加されています。
・最近の (ES6) クラスの定義方法。
・「hidden された canvas をもう一つ用意し、ダブルバッファリングすると速い。」
等々。

/////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(base_id, width, height) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");
        this.canvas_buff.hidden = true;

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // アニメーション用カウント
        this.count = 0;
    }

    ( 以下略 )
}

更新処理

更新処理、いくつか方法があるらしいですが window.requestAnimationFrame() を採用。
何がベストなのか、詳しくは調べてないけど使えればよいか。

コンストラクタで書いた通り、
バッファに書き込んで、それを表示用に持ってくる形で描画。

class CanvasOp {

    ( 前略 )

    // 更新処理
    update(ts)
    {
        // 空なら停止
        if (this.context == null) {
            return;
        }

        //////
        /// buff に描画
        ///
        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.strokeRect(0, 0, this.width, this.height);

        // animathon
        this.count += 1;
        this.context_buff.beginPath();
        this.context_buff.fillRect(this.count - 5, this.count - 5, 10, 10);
        this.context_buff.stroke();
        if (this.count > 500) this.count = 0;

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((ts) => this.update(ts));
    };
}


メイン

HTML としては、Canvas を紐づける div だけ必要。

<div id="canvas_base"></div>

<script>
()
</script>


後は実行だけ。load 終わったら実行。
canvas.update() が実行されると、あとは続けて無限に実行。

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 500;
const HEIGHT = 500;

////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(BASE_ID, WIDTH, HEIGHT);
    canvas.update(0);
}


とりあえず下地ができたので、色々作ってみようと思います。

コード全文

<div id="canvas_base"></div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// 描画エリアサイズ
const WIDTH = 500;
const HEIGHT = 500;

/////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(base_id, width, height) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");
        // this.canvas = document.getElementById("canvas");
        // this.canvas_buff = document.getElementById("canvas_buff");
        this.canvas_buff.hidden = true;

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // アニメーション用カウント
        this.count = 0;
    }

    // 更新処理
    update(ts)
    {
        // 空なら停止
        if (this.context == null) {
            return;
        }

        //////
        /// buff に描画
        ///
        // Clear
        this.context_buff.clearRect(0, 0, this.width, this.height);

        // 外枠
        this.context_buff.strokeRect(0, 0, this.width, this.height);

        // animation
        this.count += 1;
        this.context_buff.beginPath();
        this.context_buff.fillRect(this.count - 5, this.count - 5, 10, 10);
        this.context_buff.stroke();
        if (this.count > 500) this.count = 0;

        //////
        /// Canvas に転送
        ///
        const data = this.context_buff.getImageData(0, 0, this.width, this.height);
        this.context.putImageData(data, 0, 0);

        // Update
        window.requestAnimationFrame((ts) => this.update(ts));
    };
}


////////////////////
//
// onload
//
window.onload = function()
{
    let canvas = new CanvasOp(BASE_ID, WIDTH, HEIGHT);
    canvas.update(0);
}

</script>

JavaScriptの復習ついでに垂線を求めて描画してみる

( 個人技術ブログネタが無かったからすっかり空いてしまった…… )

最近は画像処理アレコレしているが、その中で座標の点を操作する処理をよく作ってる。
内容としては高校までの数学レベルなんだけど、具体的にコードに落としながらだとイージーミスしたり、業務レベルで使うこと考えてきれいに書いたりと、結構奥深い。

ここ最近作った中で、比較的他でも使いまわせそうなものについて、JavaScript として書き直してみる。
( 仕事では Python だった。久々に JavaScript の勉強もかねて。)

作成物


Canvas not supported.

今回の作成物。
480 × 480 サイズの canvas の中でマウスクリックに反応し、■の点を打ちます。
・2点目を打点 ⇒ 2点を結ぶ直線を引く。
・3点目以上 ⇒ 前に書いた直線と垂直に交わる直線を引く。
動作を行います。

面白い絵柄になることを期待したけど、
よく考えたら初めに描画した直線の角度に依存して、あとは垂線しか引けないので、格子柄にしかならないのだった……

要素

線分を画面端まで伸ばす

傾き (= 変化量) と切片は中学校数学で出るやつですね。

// 傾き & 切片
slope = (y2 - y1) / (x2 - x1);
intercept = y1 - slope * x1;

例外処理 (傾き取れない場合) を考える必要あり。

// 傾きが無い or ゼロの場合
if (x2 - x1 == 0) { return {x1: x1, y1: 0, x2: x2, y2: HEIGHT}; }
if (y2 - y1 == 0) { return {x1: 0, y1: y1, x2: WIDTH, y2: y2}; }

後は画面端を考えれば OK。
左端の場合、x = 0 の場合を考えて、そのときの y が画面の上下端に行った場合を調整すればよい。

// 画面左端
left_x = 0; 
left_y = intercept;
if (left_y < 0) {
    // 画面端 (下)
    left_x = -intercept / slope;
    left_y = 0;
}
else if (left_y > HEIGHT) {
    // 画面端 (上)
    left_x = (HEIGHT - intercept) / slope;
    left_y = HEIGHT;
}

右端の場合も同じ。

コード :

////////////////////
//
// 2つの点で設定された線分を画面端まで伸ばす
//
function getExtendLine(x1, y1, x2, y2) 
{
    // 傾きが無い or ゼロの場合
    if (x2 - x1 == 0) { return {x1: x1, y1: 0, x2: x2, y2: HEIGHT}; }
    if (y2 - y1 == 0) { return {x1: 0, y1: y1, x2: WIDTH, y2: y2}; }

    // 計算用に座標上下反転
    y1 = HEIGHT - y1;
    y2 = HEIGHT - y2;

    // 傾き & 切片
    slope = (y2 - y1) / (x2 - x1);
    intercept = y1 - slope * x1;

    // 画面左端
    left_x = 0; 
    left_y = intercept;
    if (left_y < 0) {
        // 画面端 (下)
        left_x = -intercept / slope;
        left_y = 0;
    }
    else if (left_y > HEIGHT) {
        // 画面端 (上)
        left_x = (HEIGHT - intercept) / slope;
        left_y = HEIGHT;
    }
    
    // 画面右端
    right_x = WIDTH;
    right_y = slope * WIDTH + intercept;
    if (right_y < 0) {
        // 画面端 (下)
        right_x = -intercept / slope;
        right_y = 0;
    } else if (right_y > HEIGHT) {
        // 画面端 (上)
        right_x = (HEIGHT - intercept) / slope;
        right_y = HEIGHT;
    }

    // 座標戻す
    left_y = HEIGHT - left_y;
    right_y = HEIGHT - right_y;

    return {x1: left_x, y1: left_y, x2: right_x, y2: right_y};
}

垂線の座標を求める

垂線はベクトルを使うのが簡単なのですね。内積とか久しぶり。
触ると思い出しますが、やらないと忘れたまま。

長さ1の単位ベクトルを求め、内積を取ってそれだけ進めば OK。
詳しい解説はいっぱいそういう記事あるので丸投げ。。

コード :

////////////////////
//
// 線分に対して垂線の足を延ばす
//
function getPerpendicular(line, point) 
{
    x1 = line.x1; y1 = line.y1;
    x2 = line.x2; y2 = line.y2;
    px = point.x; py = point.y;

    // 線分Wの単位ベクトルUを求める
    W = {x: x2 - x1, y: y2 - y1};
    dist_w = Math.sqrt(Math.pow(W.x, 2) + Math.pow(W.y, 2));
    U = {x: W.x / dist_w, y: W.y / dist_w};

    // A ... point から線分の片方の端までのベクトル
    A = {x: px - x1, y: py - y1};

    // t ... A と U との内積
    t = A.x * U.x + A.y * U.y;

    // 線分の片方の端から t 倍だけ進んだ先の点が求める座標
    H = {x: x1 + t * U.x, y: y1 + t * U.y};

    return H;
}

まとめ

後は、クリック時処理として「2点以上打たれていたら」「1点以上打たれていたら」の処理を入れるだけ。

////////////////////
//
// onload
//
window.onload = function()
{
    const canvas = document.getElementById("canvas");
    if (canvas.getContext) {
        context = canvas.getContext("2d");

        // 幅・高さを取得
        WIDTH = canvas.width;
        HEIGHT = canvas.height;

        // 外側領域を結ぶ
        context.strokeRect(0, 0, canvas.width, canvas.height);
    }

    // Click event
    function onClick(e) {
        context.beginPath();

        // クリック点取得して描画
        const rect = e.target.getBoundingClientRect();
        x = e.clientX - rect.left;
        y = e.clientY - rect.top;
        context.fillRect(x - 5, y - 5, 10, 10);
        const point = {x: x, y: y};

        // 線の色
        context.strokeStyle = getColor();

        // 既に2点以上打たれていたら、前2点を用いて垂線を引く
        if (points.length >= 2) {

            // 前2点の直線
            const pre1 = points[points.length - 1];
            const pre2 = points[points.length - 2];
            const ext_line = getExtendLine(pre1.x, pre1.y, pre2.x, pre2.y);

            // 垂線を引き、point 追加
            const p_point = getPerpendicular(ext_line, point);
            points.push(p_point);

            // 垂線を端まで伸ばして線を引く
            const ext_p_line = getExtendLine(x, y, p_point.x, p_point.y);
            context.moveTo(ext_p_line.x1, ext_p_line.y1);
            context.lineTo(ext_p_line.x2, ext_p_line.y2);
        }

        // 1点だけ打たれていたら、直線を引く
        else if (points.length >= 1) {
            const pre = points[points.length - 1];

            // 直線を引く
            const ext_line = getExtendLine(x, y, pre.x, pre.y);
            context.moveTo(ext_line.x1, ext_line.y1);
            context.lineTo(ext_line.x2, ext_line.y2);
        }
        context.stroke();

        // Point 追加
        points.push(point)
    }
    canvas.addEventListener('click', onClick, false);
}


久しぶりに JavaScript 触りましたが手軽で面白いですね。
var ばかりで生きてきたので let とか const とかに慣れない。。復習かねてもう少し色々書きたい。

参考にしたもの

  • 配色

developer.mozilla.org

  • Canvas イベント操作

qiita.com

アイキャッチ画像