3次元回転するドーナツをテキストで描画（ライブラリ無し, JavaScript, HTML）

## モチベーション
YouTubeでドーナツを作成している動画を発見し．大学時代に触れた立体の回転計算と合わせて試しに作ってみようと思いました．
回転計算を自力で実装したかったので，Three.jsを含めライブラリは未使用です．


## 実装内容
>innerHTMLの更新頻度や常時実行処理など様々な点でアドバイスいただいたので記事を修正しました，処理が爆速になって嬉しいです．

### 1. HTML, CSS
CSSは描画に使用するテキストの範囲設定と色，描画のための文字サイズ統一のみで，
HTMLもテキスト出力用のdivタグだけ置きました．

```html:htmlとインラインのcss
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Donut</title>
  <style>
	body{
		background-color: black;
		color:white;
	}
	#text {
		/*文字幅統一*/
		font-family: monospace;
	}
  </style>
</head>
<body>
    <div id="text">
	</div>
</body>

<script>
    scriptタグ内の文量が多いので別で記載しています
</script>

</html>
```



### 2. JavaScript
重要な処理は以下のように分類できます．
- ドーナツ表面の座標取得
- 三次元回転（クォータニオン）：donutRotate()
    - 回転軸，量の設定
    - 任意の座標に対する回転行列の定義：qRotate(rotAngle, rotVec, posVec)
- テキストへの描画：createCharByPos()
    - ドーナツを平面に射影
    - ドーナツ表面の各点の描画位置の設定
    - 描画時の文字の設定

>クォータニオンを用いた回転行列の定義は，[矢田部 学さんの”クォータニオン計算便利ノート”](https://www.mesw.co.jp/business/report/pdf/mss_18_07.pdf)を参照しました．非常に分かりやすかったです．

ドーナツ表面の初期座標を取得後，~~setIntervalで一定時間ごとに回転計算を実行しています．~~ コメントでいただいたrequestAnimationFrameを用いた処理に変更しました．
回転計算では，クォータニオンを使用したことで任意ベクトルを軸にした回転が可能になりました．
そのため，今回のような静的なベクトル(1,1,1)ではなく動的なベクトルを軸に設定することで複雑な回転も容易に実装できます．
```javascript:scriptタグの中身
//ドーナツを表示する場所
const textArea = document.getElementById("text");

//ドーナツ表面の座標を配列に格納
donutPos=[];
let tmp = 80;//ドーナツの表面上の点の密度
let donutSize = 2.5;//ドーナツの穴のサイズ
for(let i=0; i<tmp; i++){
	let tmpCount = 1;
	let tmpJ = Math.floor(tmp*Math.abs(donutSize + Math.sin(2*Math.PI * i/tmp)));
	for(let j=0; j<tmpJ; j+=1){
		donutPos.push([
			(donutSize + Math.cos(2*Math.PI * i/tmp)) * Math.cos(2*Math.PI * j/tmpJ)/donutSize,
			Math.sin(2*Math.PI * i/tmp)/donutSize,
			(donutSize + Math.cos(2*Math.PI * i/tmp)) * Math.sin(2*Math.PI * j/tmpJ)/donutSize,
		]);
	}
}

//クオータニオンを用いた回転計算
function qRotate(rotAngle, rotVec, posVec){
	//回転軸，量をクオータニオンに変換
	[q0, q1, q2, q3]=[
		Math.cos(rotAngle/2),
		Math.sin(rotAngle/2) * rotVec[0],
		Math.sin(rotAngle/2) * rotVec[1],
		Math.sin(rotAngle/2) * rotVec[2]
	]
	//回転行列（クオータニオン版）を定義
	let [
		[A11, A12, A13],
		[A21, A22, A23],
		[A31, A32, A33]
	] = [
		[q0*q0 + q1*q1 - q2*q2 - q3*q3, 2*(q1*q2 - q0*q3), 			   2*(q1*q3 + q0*q2)			],
		[2*(q1*q2 + q0*q3), 			q0*q0 - q1*q1 + q2*q2 - q3*q3, 2*(q2*q3 - q0*q1)			],
		[2*(q1*q3 - q0*q2), 			2*(q2*q3 + q0*q1), 			   q0*q0 - q1*q1 - q2*q2 + q3*q3]
	]
	//行列計算で回転後の座標を
	return [
		(A11*posVec[0] + A12*posVec[1] + A13*posVec[2]),
		(A21*posVec[0] + A22*posVec[1] + A23*posVec[2]),
		(A31*posVec[0] + A32*posVec[1] + A33*posVec[2])
	];
}

//テキストで描画
function createCharByPos(){
	textArea.innerHTML =  "";

	//描画範囲
	let charNumForWidth = 100;
	let charNumForHeight = Math.floor(charNumForWidth/2);

	//描画に使用するテキスト範囲を配列に定義
	let posArray = [...Array(charNumForHeight)].map((_) => [...Array(charNumForWidth)].fill(10));
	
	//ドーナツ表面の各点を，描画用のテキスト配列に射影
	donutPos.forEach((pos)=>{
		//テキスト出力時の位置を計算
		pos = pos.map((val)=>{
			return val/Math.sqrt(3);
		})
		let posX = Math.floor(charNumForWidth/2 + pos[0]*charNumForWidth/2);
		let posY = Math.floor(charNumForHeight/2 + pos[1]*charNumForHeight/2);
		//描画用のテキスト配列にz軸方向の距離を格納
		//手前側の描画が優先
		posArray[posY][posX] = posArray[posY][posX] < pos[2] && posArray[posY][posX] || pos[2];

	});

	//描画用のテキスト配列の各要素に文字を設定
	const multipleValue = 0.9;
	let tmpStr = '';
	for(let i=0; i<charNumForHeight; i++){
		for(let j=0; j<charNumForWidth; j++){
			//z軸の距離に応じて文字の種類を変更
			const z = posArray[i][j] / multipleValue;
			//論理演算子は最終的に片方の値を出力する性質を利用
			tmpStr +=
				z < -0.8 && '@' ||
				z < -0.6 && '$' ||
				z < -0.4 && '#' ||
				z < -0.2 && '*' ||
				z <  0.0 && '=' ||
				z <  0.2 && ':' ||
				z <  0.4 && '~' ||
				z <  0.6 && '-' ||
				z <  0.8 && ',' ||
				z <  1.0 && '.' || '&nbsp;';
		}
		tmpStr += "<br>"
	}
	textArea.innerHTML = tmpStr;
}

//ベクトルのノルムを1に変更
function unitVector(vec){
	return vec.map((val)=>{
		return val/Math.sqrt(vec[0] ** 2 + vec[1] ** 2 + vec[2] ** 2);
	});
}

//即時実行かつrequestAnimationFrameでフレーム読み込みごとに再帰実行
(function donutRotate(){
	//回転軸，量を設定
	let rotAngle = Math.PI/500;
	let rotVec = unitVector([
		1,
		1,
		1
	]);
	
	//回転計算
	donutPos = donutPos.map((posVec)=>{
		return qRotate(rotAngle, rotVec, posVec);
	})

	//テキストで描画
	createCharByPos()

	//ブラウザのフレーム更新直後に実行する
	//setIntervalでも良いが，アニメーションを見せたいのであれば
	//画面表示の邪魔になりにくいrequestAnimationFrameが良さそう?
	requestAnimationFrame(donutRotate);
})();
```

## 実装結果
無事動きました，嬉しかったです．
（たまに空いている点はドーナツ表面の点の密度が低いために穴が開いてしまっている感じですね）
![スクリーンショット (759).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3880312/32c4e011-14a7-5b0d-b387-8329ce96a7ef.png)
![スクリーンショット (760).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3880312/bb29663e-3706-9f71-d81e-f53ac3f7bf88.png)
![スクリーンショット (761).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3880312/5961dd4a-17fb-ca0d-88f5-2bedaf6f92e2.png)


## 反省
作成できましたが，いくつか反省点見つかりました
- ドーナツの表面座標取得のコードはもっと綺麗に書けそう
- ドーナツ表面の文字選別にz座標を使用したが，光源との位置関係で文字を変えた方が良かった

## ついでに作ったもの
![スクリーンショット (757).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3880312/c96c3b07-31fd-6c42-e76d-383c4585ce39.png)