JavaScript
canvas

【Javascript】三角形のグラデーションを描画する

概要

この記事では、Javascriptを用いた、三角形のグラデーションを描画するコードとその解説をしています。内容を大雑把にいうと、三角形の面積の計算とImageDataを使えば出力できます。描画結果は以下のようになります。

gradation.png

Triangle(三角形)クラス

まずは、三角形をクラス化します。このクラスでは、以下の処理を行えるようにします。

  1. 頂点を保持する
  2. 面積を計算する
  3. 点P(x, y)の内外判定

コード

Triangle.js
class Triangle {
    constructor() {
        this._path = [];
    }

    addPath(x, y) {
        this._path.push({x: x, y: y});
        return this;// メソッドチェーン
    }

    getPath(index) {
        return this._path[index % this.size];
    }

    movePath(index, x, y) {
        let point = this._path[index % this.size];
        point = {x: x, y: y};
        return this;// メソッドチェーン
    }

    get size() {
        return this._path.length;
    }

    get area() {
        let sum = 0;

        if(this.size < 3) {
            return 0;
        }
        for (let i=0; i<this.size; i++) {
            sum += this.getPath(i).x*this.getPath(i+1).y-this.getPath(i+1).x*this.getPath(i).y;
        }
        return Math.abs(sum/2);
    }

    isInside(x, y){
        let sum = 0;
        for (let i=0; i<this.size; i++)
            sum += this._getAreaFromPointAndIndexs(x, y, i);
        return this.area === Math.abs(sum);
    }

    getAreasFromPoint(x, y) {
        let areas = [];
        for (let i=0; i<this.size; i++)
            areas[i] = this._getAreaFromPointAndIndexs(x, y, i);
        return areas;
    }

    _getAreaFromPointAndIndexs(x, y, index1){
        let index2 = index1 + 1;
        let s = new Triangle();
        s.addPath(x, y);
        s.addPath(this.getPath(index1).x, this.getPath(index1).y);
        s.addPath(this.getPath(index2).x, this.getPath(index2).y);
        return s.area;
    }
}

解説

メソッドチェーン

addPath, movePath関数では、Triangle クラス自身を戻り値としています。この方法をメソッドチェーンと呼びます。これ使うと、コードの重複を減らすことができます。

メソッドチェーンを使わない場合

let triangle = new TriangleCanvas('triangle');

triangle.addPath(0, 0);
triangle.addPath(width, height/2);
triangle.addPath(0, height);

メソッドチェーンを使う場合

let triangle = new TriangleCanvas('triangle');

triangle.addPath(0, 0)
        .addPath(width, height/2)
        .addPath(0, height);

三角形の面積

三角形の面積は、ベクトルの外積を用いて導出することができます。
例として以下の △ABC を考えてみましょう。

座標 カラーコード
A (0, 0) #E60012
B (0, 256) 黄色 #FFFB00
C (256, 128) #009944

triangle0.png

辺ABの外積は以下の計算式で導出できます。

x_a y_b - x_b y_a

これを、A~Cの各点で計算し、和を導出します。

x_a y_b - x_b y_a + x_b y_c - x_c y_b + x_c y_a - x_c y_a

この値の半分が△ABC の面積です。ただし、この値は負の数になる場合があるので、絶対値をつけて計算します。

S = \frac{1}{2} |x_a y_b - x_b y_a + x_b y_c - x_c y_b + x_c y_a - x_c y_a
|

三角形の内外判定

面積が導出できれば、内外判定は簡単に行えます。例えば、以下のような点P(白枠の点)を考えてみましょう。

triangle.png

この点と各頂点を結んだ三角形は、全部で3つ(△ABP, △BCP, △CAP)あります。

triangle2.png

もしも、三角形の内部に点Pがあれば、△ABCの面積と3つの三角形の面積の和は等しくなります。一方、外側にあるならば、△ABCの面積よりも3つの三角形の面積の和が大きくなるため一致しません。

triangle3.png

三角形のグラデーション

引き続き、以下の三角形を例に説明します。

座標 カラーコード R G B
A (0, 0) #E60012 230 0 18
B (0, 256) 黄色 #FFFB00 255 251 0
C (256, 128) #009944 0 153 68

コード

以前作ったImageDataを簡単に扱うクラスを用いると、描画部分は以下のようなコードになります。

// 色: RGB
let r = [230, 255, 0];
let g = [0, 251, 153];
let b = [18, 0, 68];

// 三角形の座標
let triangle = new TriangleCanvas('triangle');
triangle.addPath(0, 0)
        .addPath(256, 128)
        .addPath(0, 256);

let data = new SimpleImageData(this._id);
while(data.hasNext) {
    if(triangle.isInside(data.x, data.y)) {
        const areas = triangle.getAreasFromPoint(data.x, data.y);
        data.r = (r[0]*areas[1] + r[1]*areas[2] + r[2]*areas[0]) / triangle.area;
        data.g = (g[0]*areas[1] + g[1]*areas[2] + g[2]*areas[0]) / triangle.area;
        data.b = (b[0]*areas[1] + b[1]*areas[2] + b[2]*areas[0]) / triangle.area;
        data.a = 255;
    }
}
data.load();

描画結果
giza.png

解説

このコードのポイントはこの部分です。

    if(triangle.isInside(data.x, data.y)) {
        const areas = triangle.getAreasFromPoint(data.x, data.y);
        data.r = (r[0]*areas[1] + r[1]*areas[2] + r[2]*areas[0]) / triangle.area;
        data.g = (g[0]*areas[1] + g[1]*areas[2] + g[2]*areas[0]) / triangle.area;
        data.b = (b[0]*areas[1] + b[1]*areas[2] + b[2]*areas[0]) / triangle.area;
        data.a = 255;
    }

△ABCの頂点の色と面積の配列が一つずつずれて計算されています。その理由は、計算する面積比と頂点の対応が、該当する頂点を除いた二点と点Pから構成される三角形の面積が色の比率を示しているからです。つまり、点A(赤: #E60012)の色の割合は、△BCP(赤い三角形)の面積から求まるということです。
triangle2.png


加えて、このコードには一つ欠点があります。それは、三角形の端がカクカクギザギザしていることです。これは「ジャギ」というもので、描画された線にアンチエイリアスされていないことが原因です。アンチエイリアスとは、線と線の境界をいい感じにぼかすことです。

TriangleCanvas クラス

今までのことを踏まえて、Triangle クラスを拡張したTriangleCanvas クラスを作ります。このクラスは、主に色の保持と描画を担当します。

コード

TriangleCanvas.js
class TriangleCanvas extends Triangle{
    constructor(canvas) {
        super();
        this._id = canvas;
        this.canvas = document.getElementById(canvas).getContext('2d');
        this._colors = [];
    }
    // @Override
    addPath(x, y, color = {r: 0, g: 0, b: 0}) {
        super.addPath(x, y);
        this._colors.push(color);
        return this;// メソッドチェーン
    }

    moveColor(index, color) {
        this._getColor(index) = color;
        return this;// メソッドチェーン
    }

    _getColor(index) {
        return this._colors[index % this.size];
    }

    r(index) {
        return Math.round(this._getColor(index).r);
    }

    g(index) {
        return Math.round(this._getColor(index).g);
    }

    b(index) {
        return Math.round(this._getColor(index).b);
    }

    draw() {
        this.canvas.beginPath();
        for(var i=0;i<this.size;i++) {
            if(i == 0) {
                this.canvas.moveTo(this.getPath(i).x, this.getPath(i).y);
            } else {
                this.canvas.lineTo(this.getPath(i).x, this.getPath(i).y);
            }
        }
        this.canvas.closePath();
    }

    fill() {
        this.draw();
        this.canvas.fill();
    }

    gradient() {
        this.fill();
        let data = new SimpleImageData(this._id);
        while(data.hasNext) {
            if(data.a > 0) { //this.isInside(data.x, data.y) だとアンチエリアス分、黒縁が残る
                const areas = this.getAreasFromPoint(data.x, data.y);
                data.r = (this.r(0)*areas[1] + this.r(1)*areas[2] + this.r(2)*areas[0]) / this.area;
                data.g = (this.g(0)*areas[1] + this.g(1)*areas[2] + this.g(2)*areas[0]) / this.area;
                data.b = (this.b(0)*areas[1] + this.b(1)*areas[2] + this.b(2)*areas[0]) / this.area;
            }
        }
        data.load();
    }
}
onload = () => {
    const canvas = document.getElementById("canvas");
    const width = canvas.width;
    const height = canvas.height;
    ctx.clearRect(0, 0, width, height);

    let triangle = new TriangleCanvas("canvas");
    triangle.addPath(0, 0, {r: 230, g: 0, b: 18})
            .addPath(width, height/2, {r: 0, g: 153, b: 68})
            .addPath(0, height, {r: 255, g: 251, b: 0});

    triangle.gradient();
};

解説

先ほど述べた三角形の端がギザギザすることは、予め、Canvasの機能を使って滑らかな線の三角形を描画しておくことで解決しました。コード中のコメントにも記載がありますが、条件式がisInside関数から変更されています。もしもisInside関数を使うとこのようにfill関数で描画した三角形の端が残ってしまします。

test.png