基於three.js的3D炫酷元素週期表

保持思考發表於2019-03-12
最近在學習three.js在拿example中的專案練手,用了一整天的時間模仿了一個炫酷的元素週期表,在原有的基礎上進行了一些改變。下面我會逐步講解這個專案,算是加深理解,讓大家提提意見。

因為我未搭建個人伺服器。截幾張圖給大家看看效果我做的效果(大部分是和原來的一樣)。可能一部分人已經見過這個經典動畫了。(這裡是原專案地址:threejs.org/examples/cs…基於three.js的3D炫酷元素週期表

基於three.js的3D炫酷元素週期表

基於three.js的3D炫酷元素週期表基於three.js的3D炫酷元素週期表

基於three.js的3D炫酷元素週期表基於three.js的3D炫酷元素週期表

除了優化了原來的HELIX和GRID形式的的排版之外,我用另外一種方式也建立了兩種自定義的排版方式。等會分享給大家。

下面是GitHub倉庫地址,檔案很簡單,就一個HTML檔案。想自己手動實現或者拿去用的可以看一下。喜歡的給顆星星,不勝感激(請忽略程式碼中的註釋哈哈)。

github.com/yjhtry/proj…

下面開始分析這個小專案

技術棧
  1. HTML, CSS3, Javascript
  2. three.js, tween.js
  3. 三角函式
實現原理
  1. 利用three.js提供的CSS3DRenderer渲染器,通過CSS3轉換屬性將分層3D轉換應用於DOM元素。其實就是包裝一下DOM元素,可以像操作three.js中Mesh物件一樣去操作DOM元素。本質上還是利用CSS3的3D動畫屬性。這個專案就是操作轉換後DOM元素的positionrotation的屬性值來建立動畫
  2. 使用輕量級動畫庫tween'補間'控制DOM元素positionrotation屬性值的過渡。
  3. 確定不同排版的每一個DOM元素的positionrotation(部分排版需要確定rotation)的值,並將之儲存在THREE.Object3D的子物件的position屬性中(也可以是一組想象陣列後面我會詳細講解),然後使用‘補間’將DOM元素的positionrotation像其儲存的對應屬性值過渡。
話不多說,直接上程式碼。

HTML結構

<div id="container">
    	<!-- 選中選單結構 start-->
    	<div id="menu">
    		<button id="table">TABLE</button>
    		<button id="sphere">SPHERE</button>
    		<button id="sphere2">SPHERE2</button>
    		<button id="plane">PLANE</button>
    		<button id="helix">HELIX</button>
    		<button id="grid">GRID</button>
    	</div>
    	<!-- end -->
    </div>複製程式碼

HTML部分非常簡單僅僅是一個包含六個控制轉換的按鈕的選擇欄,下面看看他們的樣式

        #menu {
		position: absolute; 
		z-index: 100;
		width: 100%; 
		bottom: 50px; 
		text-align: center; 
		font-size: 32px 
	}

	button {
		border: none;
		background-color: transparent; 
		color: rgba( 127, 255, 255, 0.75 ); 
		padding: 12px 24px; 
		cursor: pointer; 
		outline: 1px solid rgba( 127, 255, 255, 0.75 );
	}

	button:hover { 
		background-color: rgba( 127, 255, 255, 0.5 ) 
	}

	button:active { 
		background-color: rgba( 127, 255, 255, 0.75 ) 
	}複製程式碼

首先將選擇欄絕對定位到視窗底部50px處,這裡注意z-index: 100,將其層級設定為最高可以防止hover,click事件被其它元素攔截。然後清除button預設樣式,並給它增加了:hover和:active偽類,使互動更生動。

效果如下:

基於three.js的3D炫酷元素週期表

然後是118個DOM元素的結構和樣式,因為他們是在JavaScript程式碼中動態建立了,這裡我單獨寫了一個元素的結構。

<div class="element">
    <div class="number">1</div>		
    <div class="symbol">H</div>
    <div class="detail">Hydrogen<br>1.00794</div>
</div>複製程式碼

CSS樣式

    .element {
		width: 120px;
		height: 160px;
		cursor: default;
		text-align: center;
		border: 1px solid rgba( 127, 255, 255, 0.25 );
		box-shadow: 0 0 12px rgba( 0, 255, 255, 0.5 );
	}

	.element:hover{ 
		border: 1px solid rgba( 127, 255, 255, 0.75 ); 
		box-shadow: 0 0 12px rgba( 0, 255, 255, 0.75 ); 
	}

	.element .number {
		position: absolute; 
		top: 20px; 
		right: 20px; 
		font-size: 12px; 
		color: rgba( 127, 255, 255, 0.75 ); 
	}

	.element .symbol {
		position: absolute; 
		top: 40px; 
		left: 0px; 
		right: 0; 
		font-size: 60px; 
		font-weight: bold; 
		color: rgba( 255, 255, 255, 0.75 ); 
		text-shadow: 0 0 10px rgba( 0, 255, 255, 0.95 );
	}

	.element .detail {
		position: absolute; 
		left: 0; 
		right: 0; 
		bottom: 15px; 
		font-size: 12px; 
		color: rgba( 127, 255, 255, 0.75 ); 
	}複製程式碼

注意box-shadowtext-shadow。下面是效果圖

基於three.js的3D炫酷元素週期表

通過box-shadowtext-shadow使DOM元素產生了立體感。

JavaScript部分

首先定義了118個元素的資料儲存結構,這裡使用的是陣列(因外數量較多,我只拿過來前二十五個,github程式碼中有完整資料)

    const table   = [
			"H", "Hydrogen", "1.00794", 1, 1,
			"He", "Helium", "4.002602", 18, 1,
			"Li", "Lithium", "6.941", 1, 2,
			"Be", "Beryllium", "9.012182", 2, 2,
			"B", "Boron", "10.811", 13, 2,
			"C", "Carbon", "12.0107", 14, 2,
			"N", "Nitrogen", "14.0067", 15, 2,
			"O", "Oxygen", "15.9994", 16, 2,
			"F", "Fluorine", "18.9984032", 17, 2,
			"Ne", "Neon", "20.1797", 18, 2,
			"Na", "Sodium", "22.98976...", 1, 3,
			"Mg", "Magnesium", "24.305", 2, 3,
			"Al", "Aluminium", "26.9815386", 13, 3,
			"Si", "Silicon", "28.0855", 14, 3,
			"P", "Phosphorus", "30.973762", 15, 3,
			"S", "Sulfur", "32.065", 16, 3,
			"Cl", "Chlorine", "35.453", 17, 3,
			"Ar", "Argon", "39.948", 18, 3,
			"K", "Potassium", "39.948", 1, 4,
			"Ca", "Calcium", "40.078", 2, 4,
			"Sc", "Scandium", "44.955912", 3, 4,
			"Ti", "Titanium", "47.867", 4, 4,
			"V", "Vanadium", "50.9415", 5, 4,
			"Cr", "Chromium", "51.9961", 6, 4,
			"Mn", "Manganese", "54.938045", 7, 4
            ]複製程式碼

先來分析一下這個資料結構

"H", "Hydrogen", "1.00794", 1, 1,複製程式碼

一共118個元素,每個元素在table陣列定義了五條資料分別是符號(symbol),英文全稱,質量(detail),元素在表格排版中所在的列(column)和行(row)這兩個資料在建立表格盤版的時我會說明使用方法。

		let scene, camera, renderer, controls;
		const objects = [];
		const targets = { 
			grid: [],
			helix: [], 
			table: [], 
			sphere: [] 
		};複製程式碼

這裡定義了一些全域性變數。scene,camera,renderer是three.js的環境物件,相機及渲染器。controls是three.js提供控制庫,用於與使用者互動,很簡單。objects用於儲存118個DOM元素。targets物件包含四個陣列型別的屬性值,用來儲存存有不同排版目標位置的Object3D子物件。

元素的建立以及動畫的控制由init函式執行,下面主要的篇幅用於將它

function init() {

    const felidView   = 40;
    const width       = window.innerWidth;
    const height      = window.innerHeight;
    const aspect      = width / height;
    const nearPlane   = 1;
    const farPlane    = 10000;
    const WebGLoutput = document.getElementById('container');

    scene    = new THREE.Scene();
    camera   = new THREE.PerspectiveCamera( felidView, aspect, nearPlane, farPlane );
    camera.position.z = 3000;
			
    renderer = new THREE.CSS3DRenderer();
    renderer.setSize( width, height );
    renderer.domElement.style.position = 'absolute';
    WebGLoutput.appendChild( renderer.domElement );

 複製程式碼

(可能我的程式碼縮排比較奇怪,我主要是為了趣味性哈哈)這段程式碼建立了three.js的三個基本元件,場景,相機(perspectiveCamera),渲染器。這裡需要注意的是,這裡的far-clipping-plane設定 的值比較大,自己做的話可以設定小一些,降低效能損耗。注意這裡採用的是CSS3D渲染器。

透視相機的視錐圖


基於three.js的3D炫酷元素週期表

平面之間的部分被稱為視錐,簡單點來說就是相機的拍攝區域。圖上的fov(視場)是相機的第一個引數,決定了相機拍攝範圍的大小,類似於人眼的橫向視域(大於180deg了吧)。aspect引數控制相機投影平面的寬高比(一般是canvas的寬高比)這個主要是為了防止圖片變形,因為投影平面上的影象最終會通過canvas顯示。注意使用CSS3D渲染器時,顯示視口是div元素。

let i   = 0;
let len = table.length;

for ( ; i < len; i += 5 ) {

    const element      		  = document.createElement('div');
    element.className 		  = 'element';
    element.style.backgroundColor = `rgba( 0, 127, 127, ${ Math.random() * 0.5 + 0.25 } )`;
			
    const number        = document.createElement('div');
    number.className    = 'number';number.textContent  = i / 5 + 1;
    element.appendChild( number );
			
    const symbol        = document.createElement('div');
    symbol.className    = 'symbol';
    symbol.textContent  = table[ i ];
    element.appendChild( symbol );
				
    const detail 	= document.createElement('div');
    detail.className 	= 'detail';
    detail.innerHTML 	= `${ table[ i + 1 ] }<br/>${ table[ i + 2 ] }`;
    element.appendChild( detail );

    const object 	= new THREE.CSS3DObject( element );
    object.position.x   = Math.random() * 4000 - 2000;
    object.position.y   = Math.random() * 4000 - 2000;
    object.position.z   = Math.random() * 4000 - 2000;

    scene.add( object );
    objects.push( object );

		}複製程式碼

 這段程式碼建立了顯示週期表元素的HTML結構,並將每一個DOM元素使用THREE.CSS3DObject類包裝成3D物件。然後隨機分配物件的位置在( -2000, 2000 )這個區間內。最後把物件新增場景中,並放入objects陣列中儲存,為在後面的動畫做準備。

上面的已經完成了118元素的建立到隨機分配位置顯示的部分。下面開始建立集中排版需要的資料。

table排版

function createTableVertices() {

    let i = 0;

    for ( ; i < len; i += 5 ) {

    const object      = new THREE.Object3D();

    // [ clumn 18 ]
    object.position.x = table[ i  + 3 ] * 140 - 1260;
    object.position.y = -table[ i + 4 ] * 180 + 1000;
    object.position.z = 0;

    targets.table.push( object );

	}
}複製程式碼

這個排版比較簡單,使用table陣列中每個元素的第四個資料(column)和第五個資料(row)直接就可以的到每個元素對應的table排版的位置資訊,然後將它們賦值給對應的object.position屬性中儲存(這個不一定非要這樣,只要是THREE.Vector3型別的資料就可以)。最後將物件儲存到對應的陣列中,以便在動畫中使用。

shpere排版

const objLength = objects.length;

function createSphereVertices() {

	let i = 0;
	const vector  = new THREE.Vector3();

	for ( ; i < objLength; ++i ) {

	    let phi   = Math.acos( -1 + ( 2  * i ) / objLength );
	    let theta = Math.sqrt( objLength * Math.PI ) * phi;
	    const object      = new THREE.Object3D();

	    object.position.x =  800 * Math.cos( theta ) * Math.sin( phi );
	    object.position.y =  800 * Math.sin( theta ) * Math.sin( phi );
	    object.position.z = -800 * Math.cos( phi );

	    // rotation object 
					
	    vector.copy( object.position ).multiplyScalar( 2 );
	    object.lookAt( vector );
	    targets.sphere.push( object );
	}

}複製程式碼

說實話這段程式碼理解的不是很到位總感覺原作者的演算法複雜化了,程式碼貼出來請大佬分析一下。後面我自己用別的方法實現了一種‘圓’不是很好看,但是很好理解。我先說一下vector這個變數的作用,它用來作為'目標位置',使用object.lookAt( vector )這個方法讓這個位置的物件看向vector這一點所在的方向,在three.js的內部會將object旋轉以‘看向vector’。將得到旋轉的值並儲存在object物件的rotation屬性中,在動畫中將元素物件的rotation屬性過渡為對應的值,使其旋轉。

 helix排版

function createHelixVertices() {

        let i = 0;
	const vector = new THREE.Vector3();

	for ( ; i < objLength; ++i ) {

	    let phi = i * 0.213 + Math.PI;

	    const object = new THREE.Object3D();

	    object.position.x = 800  * Math.sin( phi );
	    object.position.y = -( i * 8 ) + 450;
	    object.position.z = 800  * Math.cos( phi + Math.PI );

	    object.scale.set( 1.1, 1.1, 1.1 );

	    vector.x = object.position.x * 2;
	    vector.y = object.position.y;
	    vector.z = object.position.z * 2;

	    object.lookAt( vector );
	    targets.helix.push( object );

	}

}複製程式碼

這個排版很好理解,首先看一下Y軸採取的是在Y方向上逐個下降的演算法。如果X,Z軸不做處理那就是延Y軸的排成一排。然後我講一下這個0.213是怎麼取的

基於three.js的3D炫酷元素週期表

基於three.js的3D炫酷元素週期表

因為總共118個元素,如果想讓這些元素排列成圓的用上圖的的兩種函式就可以,我使用的是正弦函式,有圖可以看出使118個元素排成四個圓只需要給每一個元素一個對應的角度,再通過Math.sin( angle )或Math.cos( angle )計算後,得到四組週期性的值,元素就會呈圓形排列。通過計算公式4 * Math.PI * 2 / 118得出0.213,這樣每一個元素在週期表中的位置(這裡是從0開始。)乘以0.213,得到與其對應的角度。使用這個角度通過正玄餘玄函式得到在圓中的位置。

grid排版

function createGridVertices() {

	let i = 0;

	for ( ; i < objLength; ++i ) {

	    const object      = new THREE.Object3D();

	    object.position.x =  360  * ( i   % 5) - 800;
	    object.position.y = -360  * ( ( i /  5 >> 0 ) % 5 ) + 700;
	    object.position.z = -700  * ( i   / 25 >> 0 );

	    targets.grid.push( object );

	}
}複製程式碼

網格佈局使用的主要是分組的思想,這是個5 * 5的網格。在X軸上的佈局採用求餘可以使元素分為五列,在Y軸上先除以5然後取整(這裡我喜歡使用>>位操作符,和Math.floor一個效果)。這樣做是為元素分行,然後求餘分列。當一個平面內5 * 5排滿後,在Z軸上判斷元素屬於哪一面。

上面四種佈局是原來的經典佈局,原作者使用的是將每個元素將要過低的位置儲存起來。還有兩種佈局是我通過這種思想延伸的,比較偷懶,也很簡單。先看一下是如何使用tween動畫庫來完成元素位置的過渡。

const gridBtn    = document.getElementById('grid');
const tableBtn   = document.getElementById('table');
const helixBtn   = document.getElementById('helix');
const sphereBtn  = document.getElementById('sphere');

gridBtn.addEventListener(    'click', function() { transform( targets.grid,   2000 )},   false );
tableBtn.addEventListener(   'click', function() { transform( targets.table,  2000 ) },  false );
helixBtn.addEventListener(   'click', function() { transform( targets.helix,  2000 ) },  false );
sphereBtn.addEventListener(  'click', function() { transform( targets.sphere, 2000 ) },  false );複製程式碼

function transform( targets, duration ) {

        TWEEN.removeAll();

	for ( let i = 0; i < objLength; ++i ) {

	let object = objects[ i ];
	let target = targets[ i ];

	new TWEEN.Tween( object.position )
	    .to( { x: target.position.x, y: target.position.y, z: target.position.z },
                                                Math.random() * duration + duration )
	    .easing( TWEEN.Easing.Exponential.InOut )
	    .start();


	    new TWEEN.Tween( object.rotation )
	    .to( { x: target.rotation.x, y: target.rotation.y, z: target.rotation.z },
                                                Math.random() * duration + duration )
	    .easing( TWEEN.Easing.Exponential.InOut )
	    .start();

	}

	// 這個補間用來在位置與旋轉補間同步執行,通過onUpdate在每次更新資料後渲染scene和camera
	new TWEEN.Tween( {} )
	    .to( {}, duration * 2 )
	    .onUpdate( render )
	    .start();

}複製程式碼

從事件繫結的回撥可以看出,觸發不同的排版時,我們傳入對應的資料。然後將資料取出通過tween.js過渡這些資料產生動畫。這裡有tween.js使用的詳細介紹github.com/tweenjs/twe…

迴圈之外的的這個‘補間’是用來在動畫過渡期間執行渲染頁面函式的。如下

function render() {

        renderer.render( scene, camera );

}複製程式碼

onWindowResize函式用於縮放頁面時更新相機引數,場景大小以及重新渲染畫面

animation通過requestAnimationFrame這個動畫神器重新整理‘所有補間資料’,更新trackball控制器

function onWindowResize() {

	camera.aspect = window.innerWidth / window.innerHeight
	camera.updateProjectionMatrix();

	renderer.setSize( window.innerWidth, window.innerHeight );
	render();

}
		
function animation() {

        TWEEN.update();
        controls.update();
	requestAnimationFrame( animation );	
}複製程式碼

最後說一下我擴充的兩種‘投機取巧的排版’

const sphere2Btn = document.getElementById('sphere2');
sphere2Btn.addEventListener( 'click', function() { transformSphere2( 2000 ) },  false );

function transformSphere2(duration) {

        TWEEN.removeAll();

	const sphereGeom = new THREE.SphereGeometry( 800, 12, 11 );
	const vertices = sphereGeom.vertices;
	const vector = new THREE.Vector3();

	for ( let i = 0; i < objLength; ++i ) {

		const target = new THREE.Object3D();

		target.position.copy(vertices[i]);
		vector.copy( target.position ).multiplyScalar( 2 );
		target.lookAt( vector );

		let object = objects[ i ];

		new TWEEN.Tween( object.position )
			.to( vertices[i],
			Math.random() * duration + duration )
			.easing( TWEEN.Easing.Exponential.InOut )
			.start();

		new TWEEN.Tween( object.rotation )
			.to( { x: target.rotation.x, y: target.rotation.y, z: target.rotation.z }, Math.random() * duration + duration )
			.easing( TWEEN.Easing.Exponential.InOut )
			.start();

	}

		new TWEEN.Tween( this )
			.to( {}, duration * 2 )
			.onUpdate( render )
			.start();

}複製程式碼

整個動畫的原理: 為每個元素建立一個目標位置,這些位置組合產生的排版就是元素最終的排版,通過‘補間’過渡位置的轉換。所以我直接使用three.js內建的幾何體,使用它的vertices屬性中的位置作為目標位置(有一點限制,vertices中頂點(位置)的數目最好接近118)。這樣通過內建的幾何體我們可以不進行數學計算,直接建立一些有意思的排版。

寫到這裡講的也差不多了,我是一個剛入門前端的菜鳥,歡迎大家的指點和批評!喜歡的同學可以給個贊哦!


相關文章