実際に3Dの表現をしようとすると、ベクトルや行列など準備することがたくさん出てきます。
しかし、ちょっとしたパースペクティブや立体感を感じさせるだけであれば、行列がやってくれる計算のキモの部分だけを抜き出して実装することで、比較的手軽に実装することができます。
まだまだ3Dについての知識が弱いので、知識を拡充する意味でも簡易的な3D表現を実装しつつ解説したいと思います。
ちなみに以前、3Dについてがっつり勉強した時の記事もあるので、よかったら見てみてください。
実際に動くサンプルをjsdo.itにあげています。
別パターンも作ってみました。
アフィン変換
サンプルコードを見ると「affine」という単語が出てきます。
これはアフィン変換と呼ばれるもので、座標を変換するものです。
参考の記事を見てもらうと図解もされているので分かりやすいと思います。
ざっくり言うと、与えられた座標$(x, y, z)$に対して、決まった変換の式(行列)を掛けると、望んだ(※)座標に変換できる、というものです。
※ 望んだ座標は、回転、拡大・縮小、平行移動です。
行列とか変換とかベクトルとか出てきますが、とある$(x, y, z)$に対してこれを掛けたらこうなるよ、というくらいの認識で大丈夫です。
アフィン変換(簡易)サンプルコード
サンプルコードの該当箇所を抜粋すると以下になります。
var affine = {
rotate: {
x: function (rad, position) {
return {
x: position.x,
y: position.y * cos(rad) + position.z * sin(rad),
z: position.y * -sin(rad) + position.z * cos(rad)
};
},
y: function (rad, position) {
return {
x: position.x * cos(rad) + position.z * -sin(rad),
y: position.y,
z: position.x * sin(rad) + position.z * cos(rad)
};
},
z: function (rad, position) {
return {
x: position.x * cos(rad) + position.y * sin(rad),
y: position.x * -sin(rad) + position.y * cos(rad),
z: position.z
};
}
}
};
サンプルコードは回転の変換のみの定義です。
x, y, z
軸それぞれについて個別に計算するようになっています。
よくよく見ると似たような計算をしています。回転軸がどこかによって若干異なるわけですね。
ただ、基本的な形も見えてくると思います。これが回転のアフィン変換(簡易版)です。
ちなみに第一引数は角度(ラジアン)、第二引数は変換したい座標$(x, y, z)$を渡します。
計算結果は回転後の座標の位置になります。
パースペクティブ
さて、今回自分が書きたかったところは実はここですw
ガチで3Dの変換をやろうとすると、行列とベクトルの計算はもちろん、このパースペクティブを導き出すためのめんどくさい計算をする必要が出てきます。
キーワードとしては「同次座標」「視錐台」「射影変換」「プロジェクション行列」などです。
なにをしているかというと「遠いものほど小さくなる」処理です。
・・・なにを当たり前なことを、と思ったと思います(w)が、現実では当たり前に起きるこの現象も、なんとかして計算しなければなりません。
しかし、むずかしい計算をしなくてもどういうことをしたらいいかを把握しておけば簡略化することができます。
以下の図を見てください。
赤ちゃんが道の右端に座っています。
座っている位置は変わっていませんが、遠くになるにつれて小さくなっています。
さて、小さくなっている以外になにか気づくことがありませんか?
そう、 画面の中央 に寄って行っています。
つまり消失点に向かって行っているわけです。
そして3Dの世界のこうした距離はZ
値ですね。
遠いものほど小さい=Z値が大きいほど小さい
と言い変えることができると思います。
さて、大きい値ほど小さくなるにはどうするか。
単純に割ればいいですね。(分母が大きくなればなるほど小さくなる)
それをプログラムで実現することができれば簡易的な3D表現に使えそうです。
そしてそれを実行しているコードが以下になります。
perspective: function (position) {
// フォーカス位置から自身のZ値を引き、フォーカス位置を決める
var fl = (this.focus - this.position.z) || 0.00001,
x = position.x,
y = position.y,
z = position.z;
// 上記で求まった`fl`から対象オブジェクトのZ値を引き、その値で`fl`を割る
// オブジェクトが遠くなればなるほど「0」に近づくことを簡素化している
return {
x: x * (fl / (fl - z)),
y: y * (fl / (fl - z)),
z: z * (fl / (fl - z)),
w: z
};
}
focus
はどの範囲までをカメラに写すか、という意味です。
(プログラムの世界では無限遠のオブジェクトを計算することはできないので、ある一定の範囲外のものは切り捨てます)
撮影可能位置からカメラの位置を引くことで、撮影可能範囲を計算します。
そしてその上で、その値から計算対象のオブジェクトのZ
値を引き、さらにその値で割ることで簡易的なパースペクティブを実現しています。
撮影位置から対象オブジェクトのZ
値を引いているのは、撮影位置からどれくらい離れているか、を計算しています。
仮にfocus
を300
、カメラの位置を0
、対象オブジェクトのZ
値を100とすると、(300 - 0) / ((300 - 0) - 100) = 300 / 200 = 1.5
となります。
つまり、Z
値100
のオブジェクトは1.5
倍になる、ということですね。
ちなみにWebGLやOpenGLの世界はZ
値が マイナスのものほど遠くになる ので、この場合はカメラに近い、ということです。なので1.5
倍なんですね。
逆に-100
として計算すると300 / 400 = 0.75
となり、少し小さくなります。
Z
値を元に、奥行きを反映する
[2014.09.13 修正]
[追記]
すみません、以下の計算部分はCanvas向けの補正の話でした。
3次元でのZ
値はすでに計算されています。
上記の計算でZ
値、つまりどれくらい離れているか、というのを計算しました。
しかし、Canvasに書き出すときはこのままではZ
値は使えません。
上記で計算されたZ値そのままだと数値として大きすぎるので、今回のサンプルでは画面内に収まるように、もう一度w
で割ることで実現しています。(仮に100
とかで割ってもある程度見栄えは同じになります)
実際のコードを抜粋すると以下のようになります。
var m = camera.applyView(this);
// 中略
var d = abs(m.z / m.w);
変数名はいったん無視してくださいw
m
に入るのは計算後の座標です。
d
は、上記で書いたようにz
をw
の値で割ってますね。
算出したd
は、ドットの大きさと透明度に使用しています。
こうすることで、簡易的な3Dの効果が得られる、というわけです。
余談「同次座標」
(同次座標を参考)
あまりしっかりと理解できていない箇所ですが、ざっくりと言えば3次元内の点を無理やり同じ座標(平面)に持ってくる、という感じでしょうか。(なので同じ次元=同次)
スクリーンは2次元なので実際の意味での3次元は表現できません。なので、3次元上の点が2次元(つまり平面)の場合にどこに位置するか、ということを計算する必要があるわけです。
そしてその答えがw
の値なんですね。w
は計算前のz
そのままです。
このw
をどう使うかというと、w
が1
になるようにします。つまりw
で割ります。
すべての座標の点をw
で割って、w
が1
になるように(同次)すれば、3次元の点が2次元のどこにあるか、がわかる、というわけです。
w
で割るとは
w
はz
値そのものでした。
$w / w = 1$は当たり前の話ですね。
これを座標に拡張して
$(x, y, z, w) / w = (x/w, y/w, z/w, w/w) = (x/w, y/w, z/w, 1)$
としたら分かりやすいでしょう。
つまり、座標すべてをw
で割ることで「すべての(x, y, z)座標が$w = 1$のときにどこにあるか」が計算できる、というわけです。
今回のサンプルコード
そんなに長くないのでサンプルコードを載せておきます。
実際の動作はこちらで確認できます。
// forked from edo_m18's "簡易3D表現" http://jsdo.it/edo_m18/fzAi
(function (win, doc, Class) {
'use strict';
/* ---------------------------------
IMPORT
------------------------------------ */
var cos = Math.cos,
sin = Math.sin,
abs = Math.abs,
PI = Math.PI,
sqrt = Math.sqrt,
floor = Math.floor,
pow = Math.pow,
random = Math.random;
/**
* Camera data.
*/
var camera = {
focus: 300,
position: {
x: 0,
y: 0,
z: 0
},
rotate: {
x: 0,
y: 0,
z: 0
},
up: {
x: 0,
y: 1,
z: 0
},
applyView: function (target) {
var ret = target;
ret = this.applyRotate(ret);
ret = this.applyTranslate(ret);
ret = this.perspective(ret);
return ret;
},
applyRotate: function (target) {
var xrad = this.rotate.x * PI / 180,
yrad = this.rotate.y * PI / 180,
zrad = this.rotate.z * PI / 180,
ret = target;
ret = affine.rotate.x(xrad, ret);
ret = affine.rotate.y(yrad, ret);
ret = affine.rotate.z(zrad, ret);
return ret;
},
applyTranslate: function (target) {
var x = this.position.x,
y = this.position.y,
z = this.position.z;
// カメラが動くとオブジェクトは逆に動くためマイナスする
return {
x: target.x - x,
y: target.y - y,
z: target.z - z
};
},
perspective: function (position) {
// フォーカス位置から自身のZ値を引き、フォーカス位置を決める
var fl = (this.focus - this.position.z) || 0.00001,
x = position.x,
y = position.y,
z = position.z;
// 上記で求まった`fl`から対象オブジェクトのZ値を引き、その値で`fl`を割る
// オブジェクトが遠くなればなるほど「0」に近づくことを簡素化している
return {
x: x * (fl / (fl - z)),
y: y * (fl / (fl - z)),
z: z * (fl / (fl - z)),
w: z
};
}
};
/**
* Vertex 3d class.
* @constructor
* @extend Class
* @param {number} x
* @param {number} y
* @param {number} z
*/
var Vertex3d = Class.extend({
init: function (x, y, z) {
this.x = x || 0;
this.y = y || 0;
this.z = z || 0;
},
setAttribute: function (name, val) {
this[name] = val;
},
getAttribute: function (name) {
return this[name];
}
});
/**
* Particle class
* @constructor
* @extend Vertex3d
* @param {number} x x position.
* @param {number} y y position.
* @param {number} z z position.
* @param {Object} opt An option data.
*/
var Particle = Vertex3d.extend({
init: function (x, y, z, opt) {
this._super.apply(this, arguments);
opt || (opt = {});
this.size = opt.size || 5;
this.sp = opt.sp || 5;
this.color = opt.color || 'red';
},
update: function () {
var temp = affine.rotate.y(this.sp / 10 * PI / 180, this);
this.x = temp.x;
this.y = temp.y;
this.z = temp.z;
},
draw: function (ctx, camera) {
var m = camera.applyView(this);
m.r = this.size;
var d = abs(m.z / m.w);
ctx.save();
ctx.beginPath();
ctx.arc(m.x, m.y, m.r * d, 0, PI * 2, false);
ctx.fillStyle = this.color;
ctx.globalAlpha = d;
ctx.fill();
ctx.closePath();
ctx.restore();
}
});
/**
* Affine methods.
*/
var affine = {
rotate: {
x: function (rad, position) {
return {
x: position.x,
y: position.y * cos(rad) + position.z * sin(rad),
z: position.y * -sin(rad) + position.z * cos(rad)
};
},
y: function (rad, position) {
return {
x: position.x * cos(rad) + position.z * -sin(rad),
y: position.y,
z: position.x * sin(rad) + position.z * cos(rad)
};
},
z: function (rad, position) {
return {
x: position.x * cos(rad) + position.y * sin(rad),
y: position.x * -sin(rad) + position.y * cos(rad),
z: position.z
};
}
}
};
var cv = null;
var ctx = null;
var w = 0;
var h = 0;
var point = null;
var particles = [];
var particleNum = 300;
function init() {
cv = doc.getElementById('cv');
ctx = cv.getContext('2d');
w = cv.width = win.innerWidth;
h = cv.height = win.innerHeight;
for (var i = 0; i < particleNum; i++) {
var size = ~~(random() * 10) + 5;
var x = ~~(random() * w) - w / 2;
var y = ~~(random() * h) - h / 2;
var z = ~~(random() * 1000);
var sp = ~~(random() * 2);
var r = ~~(random() * 255);
var g = ~~(random() * 255);
var b = ~~(random() * 255);
var color = 'rgb(' + r + ', ' + g + ', ' + b + ')';
var p = new Particle(x, y, z, {
size: size,
sp: sp,
color: color
});
particles.push(p);
}
setEvents();
loop();
}
function setEvents() {
var isTouch = 'ontouchstart' in window;
var M_DOWN = isTouch ? 'touchstart' : 'mousedown';
var M_MOVE = isTouch ? 'touchmove' : 'mousemove';
var M_UP = isTouch ? 'touchend' : 'mouseup';
var prevX = 0;
var prevY = 0;
var dragging = false;
doc.addEventListener(M_DOWN, function (e) {
dragging = true;
prevX = (isTouch ? e.touches[0].pageX : e.pageX);
prevY = (isTouch ? e.touches[0].pageY : e.pageY);
e.preventDefault();
return false;
});
doc.addEventListener(M_MOVE, function (e) {
if (!dragging) {
return;
}
var x = (isTouch ? e.touches[0].pageX : e.pageX) - prevX;
var y = (isTouch ? e.touches[0].pageY : e.pageY) - prevY;
camera.rotate.x -= x;
camera.rotate.y -= y;
prevX = e.pageX;
prevY = e.pageY;
}, false);
doc.addEventListener(M_UP, function (e) {
dragging = false;
}, false);
}
function drawAxis() {
ctx.save();
ctx.beginPath();
ctx.strokeStyle = '#333';
ctx.moveTo(0, cv.height / 2);
ctx.lineTo(cv.width, cv.height / 2);
ctx.moveTo(cv.width / 2, 0);
ctx.lineTo(cv.width / 2, cv.height);
ctx.stroke();
ctx.closePath();
ctx.restore();
}
function loop() {
//camera.rotate.x += 1;
ctx.save();
ctx.beginPath();
ctx.fillStyle = 'rgba(30, 30, 30, 0.5)';
ctx.fillRect(0, 0, w, h);
//drawAxis();
//Set center view port.
ctx.translate(w / 2, h / 2);
for (var i = 0, l = particles.length; i < l; i++) {
var p = particles[i];
p.update();
p.draw(ctx, camera);
}
ctx.restore();
setTimeout(loop, 16);
}
doc.addEventListener('DOMContentLoaded', init, false);
}(window, window.document, window.Class));