WebGLのアルファブレンディングやってみる

前々回はドットで線を描画、前回はドットで矩形の描画とアルファブレンディングをやってみました。

どちらもループ処理で1ドットずつ打っていきました。例えばフルHDだとピクセル数は1920×1080=2073600になりますが、これだけの数を順番に1ピクセルずつ色をつけているのでしょうか?たぶん実際はGPUを使って並列にピクセルに色をつけるのではないかなと思います。今回は、WebGLでシェーダーを使ってGPUでドットを打ちます。

JavaScriptでCPUを使った処理だと下図の左のように1ピクセルずつ順番にループ処理で色付け、WebGLGPUを使った処理だと下図の右のようにvertexシェーダーで緑点を指定して色付けする範囲を指定し、範囲内の青く囲ったピクセルを並列でfragmentシェーダーに従って色付けする、というようなイメージでしょうか。

gyazo.com

わたくし、シェーダーは完全に初心者です。The Book of Shaders が良いとどこかで知って、いつか読んでみようと思っていたので読みながらWebGLで動かしてみました、という日記です。

まずはシェーダーをWebGLで動かす

Hello World!

The Book of Shaders の Hello World! のページを動かす。

完全に初心者だけどAndroidOpenGL ESの入門者向けの本は少し読んだことがある。そこで学んだことを思い出しつつやってみる。

Getting started with WebGL - Web APIs | MDN を見てみると、HTMLにcanvasタグをおいて const gl = canvas.getContext("webgl"); とやれば gl オブジェクトが手に入るみたい。

シェーダーを書くための文法はGLSL。WebGLではHTMLのscriptタグ内にvertexシェーダーとfragmentシェーダー、それぞれのソースコードを記述するっぽい。

The Book of Shaders にはfragmentシェーダーしか記述されてないけど、vertexシェーダーはcanvas全体を使うような矩形にすれば良いでしょう。fragmentシェーダーは写経する。

<canvas style="width:800px;height:400px;"></canvas>

<script id="vertex-shader" type="x-shader/x-vertex">
attribute vec2 pos;
void main() {
  gl_Position = vec4(pos, 0, 1);
}
</script>

<script id="fragment-shader" type="x-shader/x-fragment">
void main() {
    gl_FragColor = vec4(1.0,0.0,1.0,1.0);
}
</script>

前回までのようにループ処理で1ドット打つようなソースコードではない。vertexシェーダーで色をつける場所を指定する、fragmentシェーダーで1ピクセルごとの色付けを指定する、GPUはシェーダーに従ってたくさんのピクセルを並列で同時に色付けする、というようなイメージかな。GPUなので超並列で実行できるし、いくつかの関数はハードウェアを使って高速に実行されるらしい。

WebGLを使うJavaScriptのコードを書く。 initialize() という関数を用意して初期化処理をする、その後 gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); で描画する。

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

function initialize() {
  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  
  // vertexシェーダーのソースコードをHTMLのscriptタグから読み込んでコンパイルする
  const vertexShaderSource = document.querySelector('#vertex-shader').text;
  const vertShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vertShader, vertexShaderSource);
  gl.compileShader(vertShader);
  var isCompiled = gl.getShaderParameter( vertShader, gl.COMPILE_STATUS );
  if ( !isCompiled ) {
    throw new Error( 'Shader compile error: ' + gl.getShaderInfoLog( vertShader ) );
  }
  
  // fragmentシェーダーのソースコードをHTMLのscriptタグから読み込んでコンパイルする
  const fragmentShaderSource = document.querySelector('#fragment-shader').text;
  const fragShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fragShader, fragmentShaderSource);
  gl.compileShader(fragShader);
  var isCompiled = gl.getShaderParameter( fragShader, gl.COMPILE_STATUS );
  if ( !isCompiled ) {
    throw new Error( 'Shader compile error: ' + gl.getShaderInfoLog( fragShader ) );
  }
  
  // リンクして実行できるプログラムにする
  var program = gl.createProgram();
  gl.attachShader(program, vertShader);
  gl.attachShader(program, fragShader);
  gl.linkProgram(program);
  gl.useProgram(program);
    
  // vertexシェーダーの `pos` 変数を使って頂点を4箇所セットする
  const vertex_buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vertex_buffer);
  const position = new Float32Array([
    -1, -1,
    1, -1,
    -1, 1,
    1, 1,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);
  const positionLocation = gl.getAttribLocation(program, 'pos');
  gl.enableVertexAttribArray(positionLocation);
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
}

// 初期化処理を実行する
initialize();

// 4つの頂点を三角形を使って描画する。シェーダーでは点・線・三角形で描画する
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

結果は紫色の矩形が表示されるだけ。fragmentシェーダーで gl_FragColor = vec4(1.0,0.0,1.0,1.0); と記述があって、RGBAで色を指定している。redとblueが1.0になってるので紫色になる。

Uniforms

The Book of Shaders の Uniformsのページを読んでいく。

uniform変数というのがある。CPU側の処理、つまり今回だとJavaScript側からパラメータを、GPU側の処理(つまり、fragmentシェーダー)に渡せる。全てのピクセルに対する処理でこのパラメータを読めるということ。

まず u_time というuniform変数が導入されてる。これは実行を開始してからの経過時間をJavaScript側からfragmentシェーダーへ伝えてるのに使う。経過時間に応じて色を変えるということをする。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_time;

void main() {
  gl_FragColor = vec4(abs(sin(u_time)),0.0,0.0,1.0);
}
</script>

次に、処理開始時間と u_time に値を渡すための値を app というオブジェクトに持たせることにする。

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

const app = {
  startTime: new Date().getTime(),
  uTime: null
};

function initialize() {
  ...
  
  gl.useProgram(program);
    
  // gl.useProgram()以降で可能
  app.uTime = gl.getUniformLocation(program, "u_time");

  ...

描画する処理を rendering() という関数にする。1度描画したらそれで終わりというわけではないので、 requestAnimationFrame(rendering); で繰り返し実行されるようにする。

function rendering() {
  // uniforms変数を更新
  const now = new Date().getTime();
  const currentTime = (now - app.startTime) / 1000;
  gl.uniform1f(app.uTime, currentTime); // gl.uniform1f() という関数で値をセット
  
  // 描画
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(rendering);
}

initialize();
rendering();

結果、こうなる。いいね、ちゃんと色に変化がある!各ピクセルを色付けするfragmentシェーダーは、CPU側のJavaScriptから渡された utime によって色が変わるため。

Image from Gyazo

次は u_resolution というuniform変数を導入する。これは描画領域全体のサイズをJavaScript側で計算してfragmentシェーダーへ伝える。

さらに gl_FragCoord というシェーダーで最初から用意されている変数を使う。ピクセルの座標を表す。これはfragmentシェーダーが実行される毎に異なる値になっていて、varying変数と呼ぶ。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_resolution;
uniform float u_time;

void main() {
  // 経過時間で色を変える
  //gl_FragColor = vec4(abs(sin(u_time)), 0.0, 0.0, 1.0); コメントアウト
  
  // ピクセルの位置で色を変える
  vec2 st = gl_FragCoord.xy/u_resolution;
  gl_FragColor = vec4(st.x,st.y,0.0,1.0);
}
</script>

app に追加。

const app = {
  startTime: new Date().getTime(),
  uTime: null,
  uResolution: null
};

function initialize() {
  ...
  
  gl.useProgram(program);
  
  // uniforms
  app.uTime = gl.getUniformLocation(program, "u_time");
  app.uResolution = gl.getUniformLocation(program, "u_resolution");
  
  ...

canvasのサイズが動的になっても対応できるようにしておく。

function resize() {
  app.width = canvas.width;
  app.height = canvas.height;
  gl.uniform2f(app.uResolution, app.width, app.height);
  gl.viewport(0, 0, app.width, app.height);
}
window.addEventListener('resize', resize);

initialize();
resize();
rendering();

結果、こうなる。良いですな〜。

最後に u_mouse というuniform変数を導入する。これはマウスの位置をJavaScript側からfragmentシェーダーに渡す。マウスの位置で色付けを変える。ここまでとやってることはあまり変わらないので結果だけ。

See the Pen WebGL HelloWorld by kariyayo (@kariyayo) on CodePen.


できた!!

好きな大きさの長方形を描く

ここまでの例はcanvas全体に色付けしていた。canvas内の好きな場所に好きな大きさの長方形を描くにはどうすればいいか?

The Book of Shaders の「 Shapes」で、正規化された座標(canvas全体の座標を0.0 ~ 1.0で表した座標)で、(0.1, 0.1) から (0.9, 0.9) の範囲を白、それ以外を黒にする例がある。これを応用すればできるはず。

(0.1, 0.1) から (0.9, 0.9) の範囲を白にするのは以下のコードでできた。

<script id="fragment-shader" type="x-shader/x-fragment">
uniform float u_resolution;

void main() {
  vec2 st = gl_FragCoord.xy/u_resolution.xy;
  vec3 color = vec3(0.0);

  // x, y 両方とも0.1以上なら白(vec2(1.0))
  vec2 bl = step(vec2(0.1), st);
  // x. y 両方とも0.9以上なら白(vec2(1.0))
  vec2 tr = step(vec2(0.1), 1.0-st);
  
  // 全て条件を満たせば vec3(1.0) つまり白、どれか1つでも満たしていなければ vec3(0.0) つまり黒
  color = vec3(bl.x * bl.y * tr.x * tr.y);

  gl_FragColor = vec4(color,1.0);
}
</script>

これを始点と大きさで長方形を表して、その範囲内であれば色付けするように書き換える。あと好きな色も指定できるようにする。diffは以下。

@@ -5,11 +5,18 @@
   vec2 st = gl_FragCoord.xy/u_resolution.xy;
   vec3 color = vec3(0.0);
 
-  // x, y 両方とも0.1以上なら白(vec2(1.0))
-  vec2 bl = step(vec2(0.1), st);
-  // x. y 両方とも0.9以上なら白(vec2(1.0))
-  vec2 tr = step(vec2(0.1), 1.0-st);
-  
+  // 始点
+  vec2 start = vec2(0.1);
+  // 大きさ(0.1~0.9の範囲なので0.8)
+  vec2 wh = vec2(0.8);
+
+  vec2 end = start + wh;
+
+  // x, y 両方ともstart以上なら白
+  vec2 bl = step(start, st);
+  // x. y 両方ともend以下なら白
+  vec2 tr = step(vec2(1.0-end.x, 1.0-end.y), 1.0-st);
+
   // 全て条件を満たせば vec3(1.0) つまり白、どれか1つでも満たしていなければ vec3(0.0) つまり黒
   color = vec3(bl.x * bl.y * tr.x * tr.y);

ここまでOK。これを関数化する。ついでに白以外の好きな色を指定できるようにしておく。

vec3 rect(vec2 st, vec2 start, vec2 wh, vec3 color) {
  vec2 end = start + wh;
  // x, y 両方ともstart以上なら色付け
  vec2 bottom_left = step(start, st);
  // x. y 両方ともend以下なら色付け
  vec2 top_right = step(vec2(1.0-end.x, 1.0-end.y), 1.0-st);
  // 全て条件を満たせばcolorで色づけ、どれか1つでも満たしていなければ黒
  return color * vec3(bottom_left.x * bottom_left.y * top_right.x * top_right.y);
}

void main() {
  ...
  
  vec2 start = vec2(0.5, 0.0);
  vec2 wh = vec2(0.5, 0.5);
  color = rect(st, start, wh, vec3(1.0, 0.0, 0.0));

ここまでくれば、 rect() を2回利用することで赤い長方形と青い長方形を描くことができる。

  // 赤い長方形
  vec2 start = vec2(0.05, 0.5);
  vec2 wh = vec2(0.2, 0.4);
  vec3 c = rect(st, start, wh, vec3(1.0, 0.0, 0.0));
  if (c != vec3(0.0)) {
    color = c;
  }
  
  // 青い長方形
  start = vec2(0.1, 0.25);
  wh = vec2(0.2, 0.4);
  c = rect(st, start, wh, vec3(0.0, 0.0, 1.0));
  if (c != vec3(0.0)) {
    color = c;
  }

結果はこうなる。

gyazo.com

アルファブレンディング

自分で計算する

長方形が重なった部分の色をアルファブレンディングしたい。前回同様に不透明度を使って計算すればできる。けどWebGLにはブレンディングの機能があってそっちを使うべきな気がする。

一旦ここまでのソースコードを活かすと、アルファブレンディングする関数を用意して色を決める時に呼べば良いだけ。

vec4 alphaBlend(vec4 fg, vec4 bg) {
  // 背景の色成分 * (1.0 - 不透明度) + 前景の色成分 * 不透明度
  vec3 cc = bg.rgb * (vec3(1.0) - vec3(fg.a)) + fg.rgb * vec3(fg.a);
  return vec4(cc, fg.a);
}

...

  // 青い長方形
  start = vec2(0.1, 0.25);
  wh = vec2(0.2, 0.4);
  c = rect(st, start, wh, vec4(0.0, 0.0, 1.0, 0.8));
  if (c != vec4(0.0)) {
    color = alphaBlend(c, color);
  }

結果はこうなる。

See the Pen WebGL Shapes by kariyayo (@kariyayo) on CodePen.


WebGLの機能を使う

これまではvertexシェーダーで範囲を指定して、fragmentシェーダーで各ピクセルの色づけしていた。この「範囲を指定してその範囲内のピクセルの色づけをする」というステップを長方形の数だけJavaScript側から実行する。この別のやり方でアルファブレンディングしてみる。

vertexシェーダーで指定した範囲のピクセルを全て同じ色にすればいいのでfragmentシェーダーはすごいシンプルになる。

uniform vec4 u_color;

void main() {
  gl_FragColor = u_color;
}

JavaScriptrendering からvertexシェーダーの position にデータを流すのと、fragmentシェーダーの u_color にデータを流すのを、赤い長方形、青い長方形と2回実行する。

function rendering() {

  ...
  
  // 赤い長方形
  const redRect = new Float32Array([
    // 左下
    -0.8, 0,
    // 右下
    0, 0,
    // 左上
    -0.8, 1,
    // 右上
    0, 1,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, redRect, gl.STATIC_DRAW);
  gl.uniform4f(app.uColor, 1.0, 0.0, 0.0, 1.0);
  // render
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  
  // 青い長方形
  const blueRect = new Float32Array([
    // 左下
    -0.5, -0.5,
    // 右下
    0.2, -0.5,
    // 左上
    -0.5, 0.4,
    // 右上
    0.2, 0.4,
  ]);
  gl.bufferData(gl.ARRAY_BUFFER, blueRect, gl.STATIC_DRAW);
  gl.uniform4f(app.uColor, 0.0, 0.0, 1.0, 0.8);
  // render
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

  requestAnimationFrame(rendering);
}

そして、WebGLのブレンディング機能を使う。 gl.blendFunc() の第一引数はこれから色付けする色に掛ける係数。不透明度を掛けるので gl.SRC_ALPHA を指定する。第二引数は既についている色(背景色)に掛ける係数で、 1.0-不透明度 を掛けるので gl.ONE_MINUS_SRC_ALPHA を指定する。

function rendering() {
  ...

  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

  ...

結果こうなる。

See the Pen WebGL Shapes with blend functor by kariyayo (@kariyayo) on CodePen.


できた!

おしまい

シェーダー楽しい!!!

ドットを重ねるときの色

前回、「2Dグラフィックスのしくみ」という本を読んでドットで線を描いてみました。

今回は四角形を2つ重ねてみます!という日記です。

結果はこうなります。

See the Pen DotRect by kariyayo (@kariyayo) on CodePen.


前回同様にCanvasにドットを打っていきます。10pixel * 10pixelの大きさを1ドットとします。

<body>
  <canvas id="canvas" width="400" height="400"></canvas>
</body>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");

/**
 * @param {int} x
 * @param {int} y
 * @param {object} color - {alpha: float, red: int, green: int, blue: int}
 */
function setDot(context, x, y, color) {
  context.fillStyle = `rgb(${color.red},${color.green},${color.blue})`;
  context.globalAlpha = color.alpha; // 透明度
  context.fillRect(x*10, y*10, 10, 10); // 10ピクセル*10ピクセルの大きさの四角形をドットとみなす
}

四角形をドットで描く

これは簡単。左上と右下の2点と色を指定して四角形を描く drawRect() 関数を用意する。

左上からスタートして、まずは一番上の行をドットで描く。1行分ドットを打ったら、次の行(yの値を+1)をドットで描く。

function drawRect(context, x1, y1, x2, y2, color) {
  const xStart = Math.min(x1, x2);
  const yStart = Math.min(y1, y2);
  const xEnd = Math.max(x1, x2);
  const yEnd = Math.max(y1, y2);
  for (let y = yStart; y <= yEnd; y++) {
    for (let x = xStart; x <= xEnd; x++) {
      dot(context, x, y, color);
    }
  }
}

// 左上座標(5,5)、右下座標(20,20)の赤い四角形
drawRect(context, 5, 5, 20, 20, {alpha:1, red:255, green:0, blue:0});

gyazo.com

2つ描いてみるとどうなるか?後で描いた青い四角形が手前に表示され、赤い四角形は一部が隠れる。

// 左上座標(5,5)、右下座標(20,20)の赤い四角形
drawRect(context, 5, 5, 20, 20, {alpha:1, red:255, green:0, blue:0});

// 左上座標(10,10)、右下座標(30,30)の青い四角形
drawRect(context, 10, 10, 30, 30, {alpha:1, red:0, green:0, blue:255});

gyazo.com

アルファブレンディング

赤い四角形が隠れていたけれど、重なっている部分を透明度を考慮して色を調整してみる。透明度を考慮して合成する処理をアルファブレンディングと呼ぶ。

これまで color の表現として {alpha:1, red:255, green:0, blue:0} というオブジェクトを使ってきた。このalphaは不透明度で0~1の値をとり、0に近づくほど透明になる。0だと完全に透明。この alpha をちゃんと取り扱っていく。

上に重なる青い四角形の alpha が0.7だとどうなるか?重なる部分は下にある赤い四角形の色と、上に重ねる青い四角形の色を良い感じに混ぜた色にする必要がある。つまり、青い四角形のドットを打つときに下にあるドットの色を取得して、重ねるドットの色を決める必要がある。そのために処理を見直す。

Bufferを導入する

これまでは以下のようにdrawRect()関数からdot()関数を呼び出してCanvasのメソッドを呼んでドットを打っていた。

gyazo.com

dot()関数からCanvasのメソッドを呼ぶのをやめて、一度2次元配列をドットのBuffuerとして利用する。render()関数というのを用意してドットのBufferを元にCanvasのメソッドを呼ぶようにする。こうすることで、drawRect関数でBufferである2次元配列から読み取ることで、青い四角形に隠されてしまう部分のドットの色を読み取ることができる。

gyazo.com

このやり方にするために関数を作成&変更する。 dot() 関数はBufferに書き込むまで、 render() でBufferの内容をcanvasへ描き込む。

// 400 * 400 の2次元配列で表現したドットのBuffer
const dotBuffer = new Array(400).fill(0).map(() => new Array(400));

// Bufferの内容をCanvasに1ドットずつ打つ
function render(context) {
  for (let y = 0; y < dotBuffer.length; y++) {
    const row = dotBuffer[y];
    for (let x = 0; x < row.length; x++) {
      const color = row[x];
      if (color) {
        context.fillStyle = `rgb(${color.red},${color.green},${color.blue})`;
        
        // アルファブレンディング後の色がセットされているので、canvas自体のalphaの考慮は不要
        // context.globalAlpha = color.alpha;

        context.fillRect(x*10, y*10, 10, 10);
      }
    }
  }
}

/**
 * Bufferにcolorを保存するだけ
 *
 * @param {int} x
 * @param {int} y
 * @param {object} color - {alpha: float, red: int, green: int, blue: int}
 */
function dot(x, y, color) {
  dotBuffer[y][x] = color;
}

これで準備OK。

色合成をする

合成後の色は以下の式で求める。不透明度が1に近づくほど前景色に近づいていくことが分かる。

合成後の色 = 背景色 * (1.0 - 不透明度) + 前景色 * 不透明度

合成後の色を求める関数を alphaBlend() として追加、 drawRect() から利用する。

function alphaBlend(fgColor, bgColor) {
  // 背景の色成分 * (1.0 - 不透明度) + 前景の色成分 * 不透明度
  const blend = (fg, bg, alpha) => bg * (1.0 - alpha) + fg * alpha;

  if (!bgColor) {
    bgColor = {alpha:1, red:255, green:255, blue:255};
  }
  const r = blend(fgColor.red, bgColor.red, fgColor.alpha);
  const g = blend(fgColor.green, bgColor.green, fgColor.alpha);
  const b = blend(fgColor.blue, bgColor.blue, fgColor.alpha);
  return {alpha:fgColor.alpha, red:r, green:g, blue:b};
}

function drawRect(x1, y1, x2, y2, color) {
  const xStart = Math.min(x1, x2);
  const yStart = Math.min(y1, y2);
  const xEnd = Math.max(x1, x2);
  const yEnd = Math.max(y1, y2);
  for (let y = yStart; y <= yEnd; y++) {
    for (let x = xStart; x <= xEnd; x++) {
      const bgColor = dotBuffer[y][x]; // ←追加
      const c = alphaBlend(color, bgColor); // ←追加
      dot(x, y, c);
    }
  }
}

青い四角形の不透明度を0.8として実行してみる。赤い折り紙に青いセロファンを重ねたようになるはず。

See the Pen DotRect by kariyayo (@kariyayo) on CodePen.


いい感じ!

おしまい

線と四角形をドットを打って描画してきました。1ドットずつ手続き的に打ってきましたが、GPUを使って並列実行してみたいですね。次回以降でやってみようかなぁ。

ドットで線を描く

「2Dグラフィックスのしくみ」という本が面白かったです。メモリのデータはどのように画面に表示されるのかという話がわかりやすく書いてあります。2次元に並んだピクセル上に直線や円を表示したりベジェ曲線を描いたり、画像を加工したりペイントツールの仕組みの話があったり。

この記事では直線を斜めに描くのと、ベジェ曲線を描くのをJavaScriptで実装してみました、という日記です。

See the Pen Dot Line by kariyayo (@kariyayo) on CodePen.

直線。

See the Pen Dot Bezier Curve by kariyayo (@kariyayo) on CodePen.

曲線。

直線を描く

Canvasにドットを打っていくことにします。

<body>
  <canvas id="canvas" width="400" height="400"></canvas>
</body>
const canvas = document.getElementById("canvas");
const context = canvas.getContext("2d");

まず、ドットを打つ関数を用意する。見やすいように1ドットの大きさを10ピクセル*10ピクセルとする。

/**
 * @param {int} x
 * @param {int} y
 * @param {object} color - {alpha: float, red: int, green: int, blue: int}
 */
function setDot(context, x, y, color) {
  context.fillStyle = `rgb(${color.red},${color.green},${color.blue})`;
  context.globalAlpha = color.alpha; // 透明度
  context.fillRect(x*10, y*10, 10, 10); // 10ピクセル*10ピクセルの大きさの矩形をドットとみなす
}

例として、 (0,0) ~ (4,3) の直線を引くことにする。直線を斜めに描くため2マスに跨ってしまうドットがある。具体的には (0,0) , (1, 3/4), (2, 6/3), (3, 9/4) , (4, 3) の5つのドットを打つことになる。これらのドットのうち、例えば (1, 3/4)(1, 0)(1,1) に跨ってしまう。

こういうドットは四捨五入してどっちかのマスに寄せることにする。

結果はこういう感じ。

https://i.gyazo.com/3310cfe5b0c0632f9106136e0f13177e.png

// (0,0) ~ (4,3) まで線を引く
const x1 = 0;
const y1 = 0;
const x2 = 4;
const y2 = 3;
const deltaY = (y2 - y1) / (x2 - x1); 
let y = y1
for (var x = 0; x < x2+1; x++) {
  const intY = Math.round(y);
  setDot(context, x, intY, {alpha:1.0, red:200, green:200, blue:200});
  y = y + deltaY;
}

しかし、x軸方向に1ずつ増やす処理にしているため (0,0) ~ (3,6) のように縦長だとうまくつながらない。

https://i.gyazo.com/7cd297ccdcc2aa5417630db0c77f6674.png

ちょっと改造するとうまくいく。

https://i.gyazo.com/9106dd02e4144061eccce1f13ab6c373.png

// (0,0) ~ (3,6) まで線を引く
const x1 = 0;
const y1 = 0;
const x2 = 3;
const y2 = 6;
const deltaY = (y2 - y1) / (x2 - x1);
let y = y1;
for (var x = 0; x < x2+1; x++) {
  const intY = Math.round(y);
  setDot(context, x, intY, {alpha:1, red:200, green:200, blue:200});

  // 縦が足りない部分のドット
  const nextY = Math.round(deltaY * (x + 1));
  for (var cy = intY+1; cy < nextY; cy++) {
    setDot(context, x, cy, {alpha:1, red:200, green:200, blue:200})
  }
  y = y + deltaY;
}

(5,5) ~ (40,15) のように、ちょっと長い直線を描くとガタガタになる。

https://i.gyazo.com/776c65e1d679ba541b570536eff884be.png

そこで不透明度を使ってアンチエイリアシングする。

intY を求めるときに四捨五入( Math.round() )ではなく切り捨て( Math.floor() )して、差分 y - intYf として求める。そして、 1 - f を不透明度とする。つまり差分が少ない=ドットが2つのマスに跨っていないほど、不透明度が1.0に近づくようにする。

https://i.gyazo.com/1ed375a4171daa5e4bc6bc776b03f1fe.png

// (5,5) ~ (40,15) まで線を引く
const x1 = 5;
const y1 = 5;
const x2 = 40;
const y2 = 15;
const deltaY = (y2 - y1) / (x2 - x1);
let y = y1;
for (var x = 0; x < x2+1; x++) {
  const intY = Math.floor(y);
  const f = y - intY;
  setDot(context, x, intY, {alpha:1-f, red:200, green:200, blue:200});
  setDot(context, x, intY+1, {alpha:f, red:200, green:200, blue:200})
  y = y + deltaY;
}

なかなか良い!

縦長だとまた繋がらなくなってしまったのでさっきと同じように改造すると良さそう。結果的にこうなった。

See the Pen Dot Line by kariyayo (@kariyayo) on CodePen.

曲線を描く

次は曲線。ベジェ曲線を描く。名前はよく聞くけど実際どうやって計算してるのかさっぱり知らなかった。

例えば、 (5,5) ~ (30, 12) の間に曲線を描きたい。途中に3点加えた計5点を制御点と呼ぶ。制御点とベジェ曲線の定義を使って曲線を描く。

https://i.gyazo.com/342ecfdcf9ac2926fb1eb33c8cdd8fe0.png

const ps = [
  {x: 5, y: 5},
  {x: 8, y: 18},
  {x: 20, y: 20},
  {x: 25, y: 3},
  {x: 30, y: 12},
];

setDot(context, ps[0].x, ps[0].y, {alpha:1.0, red:0, green:0, blue:200});
setDot(context, ps[1].x, ps[1].y, {alpha:1.0, red:200, green:200, blue:200});
setDot(context, ps[2].x, ps[2].y, {alpha:1.0, red:200, green:200, blue:200});
setDot(context, ps[3].x, ps[3].y, {alpha:1.0, red:200, green:200, blue:200});
setDot(context, ps[4].x, ps[4].y, {alpha:1.0, red:200, green:0, blue:0});

本には具体例の計算式が書いてる。一般化した定義はWikipediaを見てみる。ベジェ曲線 - Wikipedia

 \displaystyle
P(t) = \sum_{i=0}^{N-1}B_{i}J_{N-1,i}(t)
 \displaystyle
J_{n,i}(t) = \binom{n}{i}{}t^i (1-t)^{n-i}

x軸を1ずつ増やすたびに t を計算して  P(t) を使って座標を求める。  J_{N-1,i}(t)バーンスタイン基底関数と呼ばれる関数。

これらを実装する。

let combi = null;

/**
 * 組み合わせ(nCr)の計算
 */
function combination(n, r) {
  if (combi == null) {
    const pascalTriangl = (n) => {
      const pt = [];
      for (let i = 0; i < n+1; i++) {
        pt[i] = [];
        pt[i][0] = 1;
        pt[i][i] = 1;
        for (let j = 1; j < i; j++) {
          pt[i][j] = pt[i-1][j-1] + pt[i-1][j];
        }
      }
      return pt;
    }
    combi = pascalTriangl(n);
  }
  return combi[n][r];
}

/**
 * バーンスタイン基底関数
 */
function bernstein(n, i, t) {
  return combination(n, i) * (t**i) * ((1-t)**(n-i));
}

/**
 * P(t)
 */
function bezier(ps, t) {
  const n = ps.length;
  const p = ps.map((p, i) => {
      const b = bernstein(n-1, i, t);
      const px = p.x * b;
      const py = p.y * b;
      return {x: px, y: py};
    }).reduce((acc, v) => {
      return {x: acc.x + v.x, y: acc.y + v.y};
    }, {x: 0, y:0})
  }
  return p;
}

この bezier()ベジェ曲線の定義)を使って曲線を描く。制御点毎に bezier() を使って座標を計算する。

const ps = [
  {x: 5, y: 5},
  {x: 8, y: 18},
  {x: 20, y: 20},
  {x: 25, y: 3},
  {x: 30, y: 12},
];

const start = ps[0];
const last = ps[ps.length-1];

for (var x = start.x; x <= last.x; x++) {
  const t = (x - start.x) / (last.x - start.x);
  let p = bezier(ps, t);
  p = {x: Math.floor(p.x), y: Math.floor(p.y)};
  setDot(context, p.x, p.y, {alpha:1.0, red:200, green:200, blue:200})
}
setDot(context, start.x, start.y, {alpha:1.0, red:0, green:0, blue:200}); // スタート地点は青
setDot(context, last.x, last.y, {alpha:1.0, red:200, green:0, blue:0}); // ゴール地点は赤

結果こんな感じ。線が途切れる。。

https://i.gyazo.com/3974797b2a82c26f1b97e4d921b5bf48.png

今のソースコードだとx軸方向に1ドット t を計算してベジェ曲線の定義で座標を求める。この座標を求める頻度を少し減らすことにする。始点から終点までを短い直線をつなげると考える。例えば、 (5,5) ~ (30,12)区間を5分割して、5つの直線を繋ぎ合わせて曲線を表現する。そうすることで曲線が繋がるように調整できる。

先ほどの直線を描く処理を drawLine() 関数として定義する。

function drawLine(context, x1, y1, x2, y2, color) {
  const deltaY = (y2 - y1) / (x2 - x1);
  let y = y1;
  for (var x = x1; x < x2+1; x++) {
    const intY = Math.floor(y);
    const f = y - intY;
    setDot(context, x, intY, {alpha:1-f, red:color.red, green:color.green, blue:color.blue});
    setDot(context, x, intY+1, {alpha:f, red:color.red, green:color.green, blue:color.blue})

    y = y + deltaY;
  }
}

5分割してループする。ループ毎に分割された直線を描くためスタート地点とゴール地点の2つの座標を求める。 スタート地点用の t1 とゴール地点用の t2 を計算してそれぞれの座標を求め、drawLine() を使って直線を描く。

const ps = [
  {x: 5, y: 5},
  {x: 8, y: 18},
  {x: 20, y: 20},
  {x: 25, y: 3},
  {x: 30, y: 12},
];

const div = 5; //分割数
const deltaT = 1.0 / div;

for (var i = 0; i < div; i++) {
  const t1 = i / div;
  let p1 = bezier(ps, t1);
  p1 = {x: Math.floor(p1.x), y: Math.floor(p1.y)};

  const t2 = t1 + deltaT;
  let p2 = bezier(ps, t2);
  p2 = {x: Math.floor(p2.x), y: Math.floor(p2.y)};

  // 直線を描く
  drawLine(context, p1.x, p1.y, p2.x, p2.y, {alpha:1.0, red:200, green:200, blue:200})
}
setDot(context, ps[0].x, ps[0].y, {alpha:1.0, red:0, green:0, blue:200}); // スタート地点は青
setDot(context, ps[ps.length-1].x, ps[ps.length-1].y, {alpha:1.0, red:200, green:0, blue:0}); // ゴール地点は赤

https://i.gyazo.com/d3ae2d410b9f3cb9706adee183758def.png

結果的にこうなった。曲線をどれくらい細かい直線に分割するか、というのが重要で、曲線の長さによって調整する必要があるらしい。

See the Pen Dot Bezier Curve by kariyayo (@kariyayo) on CodePen.

おしまい

面白い!けど本当に表層触っただけで沼がとても深そうだなと感じました!