3Dグラフィックの基礎

#3Dグラフィックの基礎を JavaScript で実践

波紋のシミュレーションをワイヤフレームで表示します。
視点変換、透視変換の基礎が学べます。
![hamon.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/178810/9bc52178-b799-06e9-bcfd-d3e46761013d.png)

```
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>3D</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script>

var SCREEN_X = 1280;
var SCREEN_Y = 720;

var CX = SCREEN_X/2;	// 中央の位置を記録
var CY = SCREEN_Y/2;	// 中央の位置を記録
var L = 0.55;	// スクリーンと目までの距離[m]
var STOR = 1280/0.34;	// スクリーンとリアルの比率（34cmで1280px）

var POINT_X_NUM = 64;
var POINT_Y_NUM = 64;

var eye = {};
eye.p = {x: 5, y: 10, z: -18};	// 目の位置
eye.v = {x: -eye.p.x, y: -eye.p.y, z: -eye.p.z};	// 視線方向
eye.l = {x: -1, y: -1, z: 0};	// 光の方向

var canvas;
var ctx;
var p;	// 点の配列(x, y, z, vx, vy, vz, sx, sy)
var poly;	// ポリゴンの配列
var timer;	// インターバルのハンドル
var hamon;

$(document).ready(function(){
	canvas = $('#canvas')[0];
	canvas.width = SCREEN_X;
	canvas.height = SCREEN_Y;
	ctx = canvas.getContext('2d');
	
	p = [];
	poly = [];
	
	for(var i = 0; i < POINT_Y_NUM; i++){
		for(var j = 0; j < POINT_X_NUM; j++){
			var bp = i*POINT_Y_NUM + j;
			p[bp] = {
				x: j*10/POINT_X_NUM - 5,
				y: 0.0,
				z: 5 - i*10/POINT_Y_NUM,
				vx: 0.0,
				vy: 0.0,
				vz: 0.0,
				sx: 0,
				sy: 0
			};
		}
	}
	
	var n = 0;
	for(var i = 0; i < POINT_Y_NUM - 1; i++){
		for(var j = 0; j < POINT_X_NUM - 1; j++){
			var bp = i*POINT_Y_NUM + j;
			poly[n] = [bp, bp + POINT_Y_NUM + 1, bp + 1];
			n++;
			poly[n] = [bp, bp + POINT_Y_NUM    , bp + POINT_Y_NUM + 1];
			n++;
		}
	}
	
	
	var NUM = 16;
	hamon = [];
	for(var i = 0; i < NUM; i++){
		hamon[i] = {
			x: 5*Math.cos(2*Math.PI/NUM*i),
			z: 5*Math.sin(2*Math.PI/NUM*i),
			r: 0.05,
			i: 0
		};
	}
	
	calcViewG();
	var t = 0;
	timer = setInterval(function(){
		t += 0.1;
		if(t >= 2*Math.PI){
			t -= 2*Math.PI;
		}
		
		for(var i = 0; i < POINT_Y_NUM; i++){
			for(var j = 0; j < POINT_X_NUM; j++){
				bp = i*POINT_Y_NUM + j;
				p[bp].y = 0;
				for(var k in hamon){
					var h = hamon[k];
					var r = Math.sqrt((h.x - p[bp].x)*(h.x - p[bp].x) + (h.z - p[bp].z)*(h.z - p[bp].z));
					p[bp].y += h.r*Math.sin(t - 5*r + h.i);
				}
			}
		}
		
		reDraw();
	}, 1);
});




function reDraw(){
	// 視点変換
	var g = eye.g;
	for(var i in p){
		p[i].vx = p[i].x*g[0][0] + p[i].y*g[0][1] + p[i].z*g[0][2] + g[0][3];
		p[i].vy = p[i].x*g[1][0] + p[i].y*g[1][1] + p[i].z*g[1][2] + g[1][3];
		p[i].vz = p[i].x*g[2][0] + p[i].y*g[2][1] + p[i].z*g[2][2] + g[2][3];
	}
	
	// 透視変換
	for(var i in p){
		p[i].sx = parseInt(CX + STOR*L*p[i].vx/p[i].vz);
		p[i].sy = parseInt(CY - STOR*L*p[i].vy/p[i].vz);
	}
	
	// キャンバス初期化
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	
	// 描画
	ctx.clearRect(0, 0, canvas.width, canvas.height);
	ctx.strokeStyle = "black";
	ctx.lineWidth = 1;
	
	// 横線
	for(var i = 0; i < POINT_Y_NUM; i++){
		for(var j = 0; j < POINT_X_NUM; j++){
			bp = i*POINT_Y_NUM + j;
			if(j == 0){
				ctx.beginPath();
				ctx.moveTo(p[bp].sx, p[bp].sy);
			}else{
				ctx.lineTo(p[bp].sx, p[bp].sy);
			}
		}
		ctx.stroke();
	}
	
	// 縦線
	for(var j = 0; j < POINT_X_NUM; j++){
		for(var i = 0; i < POINT_Y_NUM; i++){
			bp = i*POINT_Y_NUM + j;
			if(i == 0){
				ctx.beginPath();
				ctx.moveTo(p[bp].sx, p[bp].sy);
			}else{
				ctx.lineTo(p[bp].sx, p[bp].sy);
			}
		}
		ctx.stroke();
	}
}


// 視線左回転
function turnLeft(){
	var cosT = Math.cos(-0.1);
	var sinT = Math.sin(-0.1);
	var x = eye.p.x;
	var y = eye.p.z;
	eye.p.x = x*cosT - y*sinT;
	eye.p.z = y*cosT + x*sinT;
	eye.v.x = -eye.p.x;
	eye.v.z = -eye.p.z;
	calcViewG();
}

// 視線右回転
function turnRight(){
	var cosT = Math.cos(0.1);
	var sinT = Math.sin(0.1);
	var x = eye.p.x;
	var y = eye.p.z;
	eye.p.x = x*cosT - y*sinT;
	eye.p.z = y*cosT + x*sinT;
	eye.v.x = -eye.p.x;
	eye.v.z = -eye.p.z;
	calcViewG();
}



/**
 * 行列計算
 */
function gyoretsu(a, b){
	var r = [];
	
	for(var y = 0; y < 4; y++){
		r[y] = [];
		for(var x = 0; x < 4; x++){
			r[y][x] = a[0][x]*b[y][0]
					+ a[1][x]*b[y][1]
					+ a[2][x]*b[y][2]
					+ a[3][x]*b[y][3];
		}
	}
	
	return r;
}


/**
 * 視点行列計算
 */
function calcViewG(){
	// 光源ベクトルを単位ベクトルに
	var r = Math.sqrt(eye.l.x*eye.l.x + eye.l.y*eye.l.y + eye.l.z*eye.l.z);
	eye.l.x = eye.l.x/r;
	eye.l.y = eye.l.y/r;
	eye.l.z = eye.l.z/r;
	
	// 平行移動
	var g = [
		[1, 0, 0, -eye.p.x],
		[0, 1, 0, -eye.p.y],
		[0, 0, 1, -eye.p.z],
		[0, 0, 0,        1]
	];
	
	// y軸回転で視線をz軸方向に
	r = Math.sqrt(eye.v.x*eye.v.x + eye.v.z*eye.v.z);
	var cosT = eye.v.z/r;
	var sinT = eye.v.x/r;
	g = gyoretsu(g, [
		[cosT, 0, -sinT, 0],
		[   0, 1,     0, 0],
		[sinT, 0,  cosT, 0],
		[   0, 0,     0, 1]
	]);
	
	// x軸回転で視線をz軸と平行に
	var ez = r;
	r = Math.sqrt(ez*ez + eye.v.y*eye.v.y);
	cosT = ez/r;
	sinT = -eye.v.y/r;
	eye.g = gyoretsu(g, [
		[   1,     0,    0, 0],
		[   0,  cosT, sinT, 0],
		[   0, -sinT, cosT, 0],
		[   0,     0,    0, 1]
	]);
}


</script>
</head>
<body>
<div style="width: 1280px; margin: auto">
<h1 style="float:left">3D</h1>
<button id="left" style="float:left" onclick="turnLeft();">←</button>
<button id="right" style="float:left" onclick="turnRight();">→</button><br />
<button id="stop" style="float:left" onclick="clearInterval(timer);">停止</button>
<div style="clear;both"></div>

<canvas id="canvas" style="width:100%; height: 720px;border:solid 1px black"></canvas>
</div>
</body>
</html>
```