見出し画像

【three.js】3次元グラフィックス:コイル型ばねの作り方

3次元グラフィックスでコイル型のばねを作成するための方法を解説するよ。ばねの形状を決定するために必要な基本的なパラメータは、ばねの長さ(lengthプロパティ)、ばねの半径(radiusプロパティ)、管の半径(tubeプロパティ)と巻き数(windingNumberプロパティ)だよ。ばねの長さとばねの半径は、管断面の中心座標を基準としていることに注意してください。次の図はコイル型ばねの正面図と平面図、断面の拡大図だよ。

3次元グラフィックスはポリゴンで表現する必要があるから表面を分割する頂点を設定する必要があるね。ばね外周の分割数(radialSegmentsプロパティ)と管外周の分割数(tubularSegmentsプロパティ)とするよ。3次元グラフィックスでは頂点を設定したあとに、どの頂点を用いてポリゴンを構成する三角形を用いて表面とするかを指定するインデックスを設定する必要があるね。まずは頂点の計算方法を解説するよ。

頂点の設定方法

上図はばねを横からと上からの見たときの断面図だよ。まず、$${\theta}$$と$${\phi }$$に着目してください。$${\theta }$$は管断面中心から頂点座標に向かう直線とx-y平面となす角、$${\phi }$$はx-y平面上のばねの中心軸から管断面中心座標に向かう直線とx軸とのなす角と定義するね。このように定義すると外周分割番号r、管周分割番号tに対して

$$
\theta = 2\pi \times\frac{t}{N_t}\ ,\ \phi = 2\pi \times\frac{r}{N_r}
$$

という関係式が得られね。ただし、$${ N_t = {\rm tubularSegments}}$$(管分割数)、$${ N_r ={\rm radialSegments}}$$(外周分割数)だよ。この関係式から、外周分割番号r、管周分割番号tの頂点座標のxとy成分を決定することができるね。上図(左)から、頂点座標をx-y平面へ射影した点は、ばね中心軸からの距離

$$
l_{xy} = r_{\rm radius} + r_{\rm tube}\cos\theta
$$

に存在することがわかるね。そのため、上図(右)からもわかるとおり、頂点座標のx, y成分は

$$
x = l_{xy}\cos \phi = (r_{\rm radius} + r_{\rm tube}\cos\theta)\cos \phi
$$

$$
y = l_{xy}\sin \phi = (r_{\rm radius} + r_{\rm tube}\cos\theta)\sin \phi
$$

と得られるよ。次にz成分です。まず各管断面の中心座標のz成分の値について考えるよ。ばねの全長はlengthで与えられているので、一巻きあたりの長さはlength / windingNumberとなるね。一巻きあたり管断面はradialSegments個存在するので、隣り合う管断面のz座標の変化分は

$$
\delta h = {\rm length}/(N_w\times N_r)
$$

であるので、巻番号w番目の外周分割番号r番目の管断面のz座標は

$$
h = \delta h \times( N_r\times w+ r) = \frac{{\rm length}\times( N_r\times w + r) }{N_w\times N_r}
$$

となるね。ただし、$${\rm N_w = windingNumber}$$(巻き数)を表すよ。通常wとrの範囲は0からN_w-1、0からN_r-1で定義されていますが、ばねの最後の断面は巻番号w=N_wのr=0と表すこととします(w=N_w-1, r=N_rと表すことも可能)。この式の結果と上図(左)から、管断面上の頂点座標のz成分は

$$
z = h + r_{\rm tube} \sin\theta - {\rm length} / 2
$$

となるね。ただし、最後の$${- {\rm length} / 2}$$は、ばねオブジェクトの基準点をオブジェクトの中心にもってくるための補正だよ。以上で、巻番号w、外周分割番号r、管周分割番号tに対する頂点座標は

$$
\boldsymbol v=\left(\begin{matrix} x \\ y \\ z\end{matrix} \right) = \left( \begin{matrix}(r_{\rm radius} + r_{\rm tube}\cos\theta)\cos \phi\\ (r_{\rm radius} + r_{\rm tube}\cos\theta)\sin \phi \\ h + r_{\rm tube} \sin\theta - {\rm length} / 2\end{matrix} \right)
$$

と決定することができました。

頂点インデックスの設定方法

ポリゴンを構成する三角形は3つの頂点番号を指定することで定義することができるね。上図はばねを構成する管の拡大図だよ。ポリゴンを構成する三角形は隣接する2つの管周上の4つの頂点座標を用いて指定することができるね。4つの頂点を指定する巻番号、外周分割番号、管周分割番号の組み合わせは、v1 =(w, r, t)、v2 =(w, r, t+1), v3 =(w, r+1, t), v4 =(w, r+1, t+1)だよ。それぞれのパラメータ(w, r, t)には範囲が決められているので、それに合わせる必要があるね。例えば、管外周分割番号tは同一円周上の点を指定するので、t=Ntとt=0は同じ座標点を表わすよ。一方、外周分割番号rは、r=Nrとr=0では真上から見れば同じ座標点を指しているように見えるけれども、一巻き上の座標点となっているためwをw+1とする必要があるね。このインデックスの差し替えを行うために、プログラム上では4つの頂点をv1 =(w1, r1, t1)、v2 =(w1, r1, t2), v3 =(w2, r2, t1), v4 =(w2, r2, t2)と指定しておき、w2, r2, t2を上記の条件に従って変更する必要があるね。

実装結果

レンダリングごとにばねの底面と上面を指定して、その長さに合わせてばねの形状を変化させたときの結果だよ。

Javascript プログラムソース

Javascriptプログラムソースを紹介するよ。わかりやすいように背景や床面などは排除しているよ。もしよければ試してみてください。

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ばねオブジェクト</title>
<style>
*{padding:0px; margin:0px}
div#canvas-frame{
	width:  960px;  /* 横幅 */
	height: 960px; /* 縦幅 */
	overflow:hidden;
}
</style>
<script type="importmap">
	{
		"imports": {
			"three": "https://unpkg.com/three@0.168.0/build/three.module.js",
			"three/addons/": "https://unpkg.com/three@0.168.0/examples/jsm/"
		}
	}
</script>
<script type="module">
import * as THREE from 'three';
import { TrackballControls } from 'three/addons/controls/TrackballControls.js';
////////////////////////////////////////////////////////////////////
// windowイベントの定義
////////////////////////////////////////////////////////////////////
window.addEventListener("load", function () {
	threeStart(); //Three.jsのスタート関数の実行
});
////////////////////////////////////////////////////////////////////
// Three.jsスタート関数の定義
////////////////////////////////////////////////////////////////////
function threeStart() {
	initThree();  //Three.js初期化関数の実行
	initLight();  //光源初期化関数の実行
	initObject(); //オブジェクト初期化関数の実行
	initCamera(); //カメラ初期化関数の実行
	loop();       //無限ループ関数の実行
}
////////////////////////////////////////////////////////////////////
// Three.js初期化関数の定義
////////////////////////////////////////////////////////////////////
//グローバル変数の宣言
let renderer,    //レンダラーオブジェクト
    scene,       //シーンオブジェクト
    canvasFrame; //キャンバスフレームのDOM要素
function initThree() {
	//キャンバスフレームDOM要素の取得
	canvasFrame = document.getElementById('canvas-frame');
	//レンダラーオブジェクトの生成
	renderer = new THREE.WebGLRenderer({ antialias: true });

	if (!renderer) alert('Three.js の初期化に失敗しました');
	//レンダラーのサイズの設定
	renderer.setSize(canvasFrame.clientWidth, canvasFrame.clientHeight);
	//キャンバスフレームDOM要素にcanvas要素を追加
	canvasFrame.appendChild(renderer.domElement);
	//レンダラークリアーカラーの設定
	renderer.setClearColor(0xEEEEEE, 1.0);
	//シーンオブジェクトの生成
	scene = new THREE.Scene();
}
////////////////////////////////////////////////////////////////////
// カメラ初期化関数の定義
////////////////////////////////////////////////////////////////////
//グローバル変数の宣言
let camera;    //カメラオブジェクト
let trackball;
function initCamera() {
	//カメラオブジェクトの生成
	camera = new THREE.PerspectiveCamera(45, canvasFrame.clientWidth / canvasFrame.clientHeight, 1, 10000);
	//カメラの位置の設定
	camera.position.set(0, -45, 10);
	//カメラの上ベクトルの設定
	camera.up.set(0, 0, 1);
	//カメラの中心位置ベクトルの設定
	camera.lookAt(new THREE.Vector3(0,0,10)); //トラックボール利用時は自動的に無効
	
}
////////////////////////////////////////////////////////////////////
// 光源初期化関数の定義
////////////////////////////////////////////////////////////////////
//グローバル変数の宣言
let directionalLight,  //平行光源オブジェクト
    ambientLight;      //環境光オブジェクト
function initLight() {
	//平行光源オブジェクトの生成
	directionalLight = new THREE.DirectionalLight(0xffffff, 2.0, 0);
	//平行光源オブジェクトの位置の設定
	directionalLight.position.set(0, -50, 50);
	//平行光源オブジェクトの影の生成元
	directionalLight.castShadow = true;
	//正投影カメラのパラメータ
	directionalLight.shadow.camera.left = -100;    //左
	directionalLight.shadow.camera.right = 100;    //右
	directionalLight.shadow.camera.top = 100;      //上
	directionalLight.shadow.camera.bottom = -100;  //下
	directionalLight.shadow.camera.near = 0;       //手前
	directionalLight.shadow.camera.far = 300;      //奥
	//平行光源オブジェクトのシーンへの追加
	scene.add(directionalLight);

	//環境光オブジェクトの生成
	ambientLight = new THREE.AmbientLight(0x666666);
	//環境光オブジェクトのシーンへの追加
	scene.add(ambientLight);
}
////////////////////////////////////////////////////////////////////
// オブジェクト初期化関数の定義
////////////////////////////////////////////////////////////////////
let spring;
function initObject() {
	//ばねオブジェクトの生成
	spring = new SimpleSpring (5, 0.5, 10, 10, 50, 10, 0x00FFFF)
	//ばねオブジェクトをシーンへ追加
	scene.add( spring.CG );
}
////////////////////////////////////////////////////////////////////
// 無限ループ関数の定義
////////////////////////////////////////////////////////////////////
//グローバル変数の宣言
let step = 0; //ステップ数
function loop() {
	//ステップ数のインクリメント
	step++;
	let lz = 15 + 7 * Math.sin( Math.PI/60 * step );
	let lx = 10 * Math.cos( Math.PI/80 * step );
	let ly = 4 * Math.sin( Math.PI/150 * step );
	let sx = 4 * Math.cos( Math.PI/170 * step );
	let sy = 1 * Math.sin( Math.PI/310 * step );
	spring.setSprintBottomToTop( new THREE.Vector3(sx, sy, 0), new THREE.Vector3(lx, ly, lz) );
	//レンダリング
	renderer.render(scene, camera);
	//「loop()」関数の呼び出し
	requestAnimationFrame(loop);
}
///////////////////////////////////
// コイル型ばねの形状生成クラス
///////////////////////////////////
class SimpleSpring{
	constructor( radius = 10, tube = 2, length = 10, windingNumber = 10, radialSegments = 20, tubularSegments = 10, color = 0xc39143){
		this.radius          = radius;         //ばねの半径
		this.tube            = tube;           //管の半径
		this.length          = length;         //ばねの長さ
		this.windingNumber   = windingNumber;  //ばねの巻き数
		this.radialSegments  = radialSegments; //外周の分割数
		this.tubularSegments = tubularSegments;//管周分割数
		this.color = color;                    //色

		//頂点座標データ
		this.vertices = [];
		this.indices = [];

		this.initIndex();
		this.updateSpringVertix( length );

		//線形状オブジェクトの宣言と生成
		const geometry = new THREE.BufferGeometry();
		geometry.setIndex( this.indices );
		geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( this.vertices, 3 ) );
		geometry.computeVertexNormals();

		//材質オブジェクトの宣言と生成
		let material = new THREE.MeshPhongMaterial({ color: color, specular: 0xffffff, shininess: 250 })
		//線オブジェクトの生成
		this.CG = new THREE.Mesh(geometry, material);

	}
	updateSpringVertix( ) {
		let setFlag = (this.vertices.length == 0)? true : false;
		let Nw = this.windingNumber; //巻き数
		let Nr = this.radialSegments; //外周分割数
		let Nt = this.tubularSegments;  //管周分割数
		//管断面作成当たりの高さの増分
		let deltaH = this.length / Nw / Nr;
		let n = 0;
		//ばねオブジェクトを構成する頂点座標の取得
		for(let w = 0; w <= Nw; w++){ //巻き番号
			let _Nr = ( w == Nw )? 1 : Nr;
			for(let r = 0; r < _Nr; r++){ //外周の分割番号
				let phi = 2.0 * Math.PI * r/Nr; //外周の分割番号

				//管断面の中心座標のz成分
				let h = deltaH * ( Nr * w + r);
				for(let t = 0; t < Nt; t++){ //管の分割
					let theta = 2.0 * Math.PI * t / Nt; //管の分割角
					let x = ( this.radius + this.tube * Math.cos(theta) ) * Math.cos(phi);
					let y = ( this.radius + this.tube * Math.cos(theta) ) * Math.sin(phi);
					let z =	this.tube * Math.sin(theta) + h - this.length / 2;
					if( setFlag ){
						this.vertices.push( x, y, z );
					} else {
						this.CG.geometry.attributes.position.array[ 3 * n + 0 ] = x;
						this.CG.geometry.attributes.position.array[ 3 * n + 1 ] = y;
						this.CG.geometry.attributes.position.array[ 3 * n + 2 ] = z;
						n++;						
					}
				}
			}
		}
		//最初の管断面の中心座標
		if( setFlag ){
			this.vertices.push( this.radius, 0,  - this.length / 2 );
		} else {
			this.CG.geometry.attributes.position.array[ 3 * n + 0 ] = this.radius;
			this.CG.geometry.attributes.position.array[ 3 * n + 1 ] = 0;
			this.CG.geometry.attributes.position.array[ 3 * n + 2 ] = -this.length / 2;
			n++;						
		}
		//最後の管断面の中心座標
		if( setFlag ){
			this.vertices.push( this.radius, 0, this.length / 2 );
		} else {
			this.CG.geometry.attributes.position.array[ 3 * n + 0 ] = this.radius;
			this.CG.geometry.attributes.position.array[ 3 * n + 1 ] = 0;
			this.CG.geometry.attributes.position.array[ 3 * n + 2 ] = this.length / 2;
			n++;						
		}
	}
	initIndex(){
		let Nw = this.windingNumber; //巻き数
		let Nr = this.radialSegments; //外周分割数
		let Nt = this.tubularSegments;  //管周分割数
		//ばねオブジェクトを構成する面指定配列の設定
		for(let w = 0; w < Nw; w++){ //巻き番号
			for(let r = 0; r < Nr; r++){ //外周分割数
				//巻き番号の指定
				let w1 = w;
				let w2 = ( r !== Nr -1 )? w : w + 1;
				//外周分割番号の指定
				let r1 = r;
				let r2 = ( r !== Nr -1 )? r + 1 : 0;
				for( let t = 0; t < Nt; t++ ){  //管分割数
					//管分割番号
					let t1 = t;
					let t2 = ( t !== Nt -1 )? t + 1 : 0;
					//平面を構成する4点の頂点番号の算出
					let v1 = (Nr * Nt) * w1 + Nt * r1 + t1;
					let v2 = (Nr * Nt) * w1 + Nt * r1 + t2;
					let v3 = (Nr * Nt) * w2 + Nt * r2 + t1;
					let v4 = (Nr * Nt) * w2 + Nt * r2 + t2;
					//頂点番号v1,v3,v4を面として指定
					this.indices.push ( v1, v3, v4 );
					//頂点番号v4,v2,v1を面として指定
					this.indices.push ( v4, v2, v1 );
				}
			}
		}

		///////////////////////////////////////////////////////////////////////
		//最初の管断面の面を指定
		let w = 0;
		let r = 0;
		for( let t = 0; t < Nt ; t++ ) { //管分割数
			//管分割番号
			let t1 = t;
			let t2 = ( t !== Nt -1 )? t + 1 : 0;
			//管断面の中心座標とその他の2点の頂点番号
			let v1 = (Nr * Nt) * Nw + Nt;
			let v2 = (Nr * Nt) * w + Nt * r + t1;
			let v3 = (Nr * Nt) * w + Nt * r + t2;
			this.indices.push ( v1, v2, v3 );
		}
		//最後の管断面の面を指定
		w = Nw;
		r = 0;
		for( let t = 0; t < Nt ; t++ ) { //管分割数
			//管分割番号
			let t1 = t;
			let t2 = ( t !== Nt -1 )? t + 1 : 0;
			//管断面の中心座標とその他の2点の頂点番号
			let v1 =  Nw * Nr * Nt + Nt + 1;
			let v2 = (Nr * Nt) * w + Nt * r + t1;
			let v3 = (Nr * Nt) * w + Nt * r + t2;
			this.indices.push ( v1, v3, v2 );
		}
	}
	setSprintBottomToTop( bottom, top ){

		//矢印の起点ベクトル
		let L = new THREE.Vector3( ).subVectors( top, bottom );
		this.length = L.length();
		this.updateSpringVertix();
		this.CG.geometry.attributes.position.needsUpdate = true;
		this.CG.geometry.computeVertexNormals();

		//中心座標ベクトル
		let R = new THREE.Vector3( ).addVectors( top, bottom ).multiplyScalar( 1/2 );
		//3次元オブジェクトの基準位置を考慮して配置
		this.CG.position.copy( R );

		//基準となるベクトル
		let V1 = new THREE.Vector3(0, 0, 1);
		//規格化した位置ベクトル
		let V2 = L.clone().normalize();
		//回転軸ベクトル
		let V3 = new THREE.Vector3().crossVectors( V1, V2);
		//回転角のcos値
		let cosTheta = V1.x * V2.x + V1.y * V2.y + V1.z * V2.z;
		//回転ありの場合
		if( Math.abs(V3.length()) > 0.001 ){
			//回転角
			let theta = Math.acos( cosTheta );
			//回転に対応するクオータニオンの生成
			let q = new THREE.Quaternion().setFromAxisAngle(
				V3.normalize(), theta
			)
			//姿勢クオータニオンに設定
			this.CG.quaternion.copy( q );
		}
	}
}
</script>
</head>
<body>
	<div id="canvas-frame"></div><!-- canvas要素を配置するdiv要素 -->
</body>
</html>

この記事が気に入ったらサポートをしてみませんか?